Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better convert dynamically bridged providers #308

Merged
merged 1 commit into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG_PENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@
### Bug Fixes

- Fix the order of arguments to `substr`
- Fix conversion in the presence of dynamically bridged Terraform providers
5 changes: 2 additions & 3 deletions cmd/pulumi-converter-terraform/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (

"github.com/hashicorp/hcl/v2"
tfconvert "github.com/pulumi/pulumi-converter-terraform/pkg/convert"
"github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tf2pulumi/il"
"github.com/pulumi/pulumi/pkg/v3/codegen/convert"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/rpcutil"
Expand All @@ -48,7 +47,7 @@ func (*tfConverter) ConvertState(_ context.Context,
if err != nil {
return nil, fmt.Errorf("create mapper: %w", err)
}
providerInfoSource := il.NewMapperProviderInfoSource(mapper)
providerInfoSource := tfconvert.NewMapperProviderInfoSource(mapper)

if len(req.Args) != 1 {
return nil, errors.New("expected exactly one argument")
Expand Down Expand Up @@ -97,7 +96,7 @@ func (*tfConverter) ConvertProgram(_ context.Context,
if err != nil {
return nil, fmt.Errorf("create mapper: %w", err)
}
providerInfoSource := il.NewMapperProviderInfoSource(mapper)
providerInfoSource := tfconvert.NewMapperProviderInfoSource(mapper)

if *convertExamples != "" {
examplesBytes, err := os.ReadFile(filepath.Join(req.SourceDirectory, *convertExamples))
Expand Down
172 changes: 172 additions & 0 deletions pkg/convert/info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright 2025, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package convert

import (
"context"
"encoding/json"
"fmt"
"sync"

"github.com/blang/semver"
"github.com/opentofu/opentofu/shim"
"github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge"
"github.com/pulumi/pulumi/pkg/v3/codegen/convert"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
"github.com/pulumi/terraform/pkg/configs"
)

// ProviderInfoSource is an interface for retrieving information about a bridged Terraform provider.
type ProviderInfoSource interface {
// GetProviderInfo returns bridged provider information for the given Terraform provider.
GetProviderInfo(
tfProvider string,
requiredProvider *configs.RequiredProvider,
) (*tfbridge.ProviderInfo, error)
}

// mapperProviderInfoSource wraps a convert.Mapper to return tfbridge.ProviderInfo.
type mapperProviderInfoSource struct {
mapper convert.Mapper
}

// NewMapperProviderInfoSource creates a new ProviderInfoSource that uses the provided mapper to build provider
// information.
func NewMapperProviderInfoSource(mapper convert.Mapper) ProviderInfoSource {
return &mapperProviderInfoSource{mapper: mapper}
}

// Implements ProviderInfoSource.GetProviderInfo by working out whether the requested provider is a Pulumi-managed
// provider or a Terraform provider that needs to be dynamically bridged, and then retrieving the relevant information
// from the mapper.
func (s *mapperProviderInfoSource) GetProviderInfo(
tfProvider string,
requiredProvider *configs.RequiredProvider,
) (*tfbridge.ProviderInfo, error) {
// First up, we need to work out whether the Terraform provider name is the one we should look for in the Pulumi
// universe. For most providers, it is, but for some (e.g. "google") we need to rename it (e.g. to "gcp", which is the
// Pulumi provider name for GCP). Generally, we'll use the Terraform name for errors, since that's the one the user
// will have written in the program being converted.
var pulumiProvider string
if renamed, ok := pulumiRenamedProviderNames[tfProvider]; ok {
pulumiProvider = renamed
} else {
pulumiProvider = tfProvider
}

var hint *convert.MapperPackageHint

// If the Pulumi provider name is one that we manage ourselves, we'll use that provider to retrieve information about
// mappings from Terraform to Pulumi. If not, then we'll assume that we are going to dynamically bridge a Terraform
// provider, and thus provide a hint that asks the mapper to boot up the terraform-provider plugin and parameterize it
// with the relevant Terraform provider details before retrieving a mapping.
if isTerraformProvider(pulumiProvider) && requiredProvider != nil {
tfVersion, diags := shim.FindTfPackageVersion(requiredProvider)
if diags.HasErrors() {
return nil, fmt.Errorf("could not find version for terraform provider %s: %v", tfProvider, diags)
}

version := semver.MustParse(tfVersion.String())

hint = &convert.MapperPackageHint{
PluginName: "terraform-provider",
Parameterization: &workspace.Parameterization{
Name: tfProvider,
Version: version,
Value: []byte(fmt.Sprintf(
`{"remote":{"url":"%s","version":"%s"}}`,
requiredProvider.Source,
tfVersion.String(),
)),
},
}
} else {
// Again, for non-bridged providers, the plugin name we want to find is the *Pulumi universe name* (e.g. "gcp", not
// "google" for GCP). That said, when we finally call GetMapping, we are always passing the Terraform provider name,
// since that's the thing we want mappings for.
hint = &convert.MapperPackageHint{
PluginName: pulumiProvider,
}
}

mapping, err := s.mapper.GetMapping(context.TODO(), tfProvider, hint)
if err != nil {
return nil, err
}

// Might be nil or []
if len(mapping) == 0 {
return nil, fmt.Errorf(
"could not find mapping information for provider %s; "+
"try installing a pulumi plugin that supports this terraform provider",
tfProvider,
)
}

var info *tfbridge.MarshallableProviderInfo
err = json.Unmarshal(mapping, &info)
if err != nil {
return nil, fmt.Errorf("could not decode mapping information for provider %s: %s", tfProvider, mapping)
}

return info.Unmarshal(), nil
}

// CachingProviderInfoSource wraps a ProviderInfoSource in a cache for faster access.
type CachingProviderInfoSource struct {
lock sync.RWMutex

source ProviderInfoSource
entries map[string]*tfbridge.ProviderInfo
}

// NewCachingProviderInfoSource creates a new CachingProviderInfoSource that wraps the given ProviderInfoSource.
func NewCachingProviderInfoSource(source ProviderInfoSource) *CachingProviderInfoSource {
return &CachingProviderInfoSource{
source: source,
entries: map[string]*tfbridge.ProviderInfo{},
}
}

// GetProviderInfo returns the tfbridge information for the indicated Terraform provider as well as the name of the
// corresponding Pulumi resource provider.
func (s *CachingProviderInfoSource) GetProviderInfo(
provider string,
requiredProvider *configs.RequiredProvider,
) (*tfbridge.ProviderInfo, error) {
if info, ok := s.getFromCache(provider); ok {
return info, nil
}

s.lock.Lock()
defer s.lock.Unlock()

info, err := s.source.GetProviderInfo(provider, requiredProvider)
if err != nil {
return nil, err
}

s.entries[provider] = info
return info, nil
}

// getFromCache retrieves the provider information from the cache, taking a read lock to do so.
func (s *CachingProviderInfoSource) getFromCache(provider string) (*tfbridge.ProviderInfo, bool) {
s.lock.RLock()
defer s.lock.RUnlock()

info, ok := s.entries[provider]
return info, ok
}
18 changes: 18 additions & 0 deletions pkg/convert/pulumiverse.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@

package convert

import "slices"

// isTerraformProvider returns true if and only if the given provider name is *not* one in the "Pulumi universe". This
// means that this function should return true for any provider that must be dynamically bridged. Note that the given
// provider name must be a *Pulumi package name*, not (for instance) a Terraform provider name.
func isTerraformProvider(name string) bool {
return !slices.Contains(pulumiSupportedProviders, name)
}

// pulumiRenamedProviderNames is a map whose keys are Terraform provider names and whose values are the corresponding
// (managed) Pulumi provider names, in the cases where they differ.
var pulumiRenamedProviderNames = map[string]string{
"azurerm": "azure",
"bigip": "f5bigip",
"google": "gcp",
"template": "terraform-template",
}

var pulumiSupportedProviders = []string{
"acme",
"aiven",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[
"warning:non_supported_lifecycle_hooks_emit_warnigs/main.tf:1,1-40:non_supported_lifecycle_hooks_emit_warnigs/main.tf:1,1-40:converting create_before_destroy lifecycle hook is not supported:in Pulumi, resources are always created before destroy unless the resource is created with the \nresource option deleteBeforeReplace. If this behavior is desired, it must be set. \nSee https://www.pulumi.com/docs/iac/concepts/options/deletebeforereplace/ for details",
"warning:non_supported_lifecycle_hooks_emit_warnigs/main.tf:1,1-40:non_supported_lifecycle_hooks_emit_warnigs/main.tf:1,1-40:converting create_before_destroy lifecycle hook is not supported:in Pulumi, resources are always created before destroy unless the resource is created with the\nresource option deleteBeforeReplace. If this behavior is desired, it must be set.\nSee https://www.pulumi.com/docs/iac/concepts/options/deletebeforereplace/ for details",
"warning:non_supported_lifecycle_hooks_emit_warnigs/main.tf:9,1-40:non_supported_lifecycle_hooks_emit_warnigs/main.tf:9,1-40:converting replace_triggered_by lifecycle hook is not supported:"
]
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package "boundary" {
baseProviderName = "terraform-provider"
baseProviderVersion = "0.6.0"
baseProviderVersion = "0.8.1"
parameterization {
version = "1.1.9"
name = "boundary"
Expand Down
29 changes: 29 additions & 0 deletions pkg/convert/testdata/terraform-provider/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
terraform {
required_version = ">= 1.1.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
planetscale = {
source = "planetscale/planetscale"
version = "~> 0.1.0"
}
}
}

provider "google" {
project = var.gcp_project
region = var.gcp_region
zone = var.gcp_zone
}

provider "planetscale" {
service_token = var.planetscale_service_token
service_token_name = "planetscaletoken"
}

resource "planetscale_database" "db" {
name = "pulumi-convert-db"
organization = var.planetscale_org
}
Loading
Loading