From 7c5db2672b290f8f0d792f28cd75c85cdab66bfd Mon Sep 17 00:00:00 2001 From: Will Jones Date: Fri, 21 Feb 2025 17:01:16 +0000 Subject: [PATCH] Better convert dynamically bridged providers When converting Terraform code to PCL, a big part of the work is mapping Terraform resources and their providers to the equivalent Pulumi bits and pieces. There are a few parts to this process: 1. Given a Terraform provider P, what is the equivalent Pulumi provider? 2. When a Pulumi provider has been determined, what are the mappings we should use to turn the various resources, data sources, etc. under that provider into their Pulumi equivalents? For part 1, we mostly use a hard-coded list -- the set of managed, first-class Pulumi providers is finite, and anything not in that list can be dynamically bridged. Armed with this, part 2 is reduced to the problem of asking the engine (which is calling us as the converter) for the right set of mappings. Until now this part has been broken in the presence of providers which we need to dynamically bridge: while we can generate the correct PCL, with appropriate `package` blocks that describe the plugins and parameterization required, we have not been able to pass the same information (e.g. parameterization) to the mapper. With pulumi/pulumi#18671, we now can! This change thus does that while tidying up a few other things around dynamically bridged/parameterized providers, and gets conversion in the presence of these providers a bit further along. > [!NOTE] > Things are likely still not 100% perfect, since we use > `required_providers` blocks to work out the versions we'll pass when > parameterizing. In the case of converting state, we don't have these, > and it's not a given that we'll always have them when converting > programs either, but we can iterate from this base and hopefully close > these gaps quickly. Fixes pulumi/pulumi#18187 Co-authored-by: Brandon Pollack --- CHANGELOG_PENDING.md | 1 + cmd/pulumi-converter-terraform/main.go | 5 +- pkg/convert/info.go | 172 ++++++++++++++++++ pkg/convert/pulumiverse.go | 18 ++ .../pcl/diagnostics.json | 2 +- .../required_providers_tf/pcl/main.pp | 2 +- .../testdata/terraform-provider/main.tf | 29 +++ pkg/convert/tf.go | 47 +++-- pkg/convert/tf_scopes.go | 6 +- pkg/convert/tf_state.go | 5 +- pkg/convert/tf_state_test.go | 3 +- pkg/convert/translate_test.go | 71 +++++++- pkg/il/mapper.go | 61 ------- pkg/testing/mappings.go | 22 +++ 14 files changed, 343 insertions(+), 101 deletions(-) create mode 100644 pkg/convert/info.go create mode 100644 pkg/convert/testdata/terraform-provider/main.tf delete mode 100644 pkg/il/mapper.go diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index db96ef9..34f88ea 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -24,3 +24,4 @@ ### Bug Fixes - Fix the order of arguments to `substr` +- Fix conversion in the presence of dynamically bridged Terraform providers diff --git a/cmd/pulumi-converter-terraform/main.go b/cmd/pulumi-converter-terraform/main.go index 7194738..311a06c 100644 --- a/cmd/pulumi-converter-terraform/main.go +++ b/cmd/pulumi-converter-terraform/main.go @@ -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" @@ -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") @@ -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)) diff --git a/pkg/convert/info.go b/pkg/convert/info.go new file mode 100644 index 0000000..50477a1 --- /dev/null +++ b/pkg/convert/info.go @@ -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 +} diff --git a/pkg/convert/pulumiverse.go b/pkg/convert/pulumiverse.go index 626767b..cfdcc5e 100644 --- a/pkg/convert/pulumiverse.go +++ b/pkg/convert/pulumiverse.go @@ -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", diff --git a/pkg/convert/testdata/programs/non_supported_lifecycle_hooks_emit_warnigs/pcl/diagnostics.json b/pkg/convert/testdata/programs/non_supported_lifecycle_hooks_emit_warnigs/pcl/diagnostics.json index 7183580..f02ea7e 100644 --- a/pkg/convert/testdata/programs/non_supported_lifecycle_hooks_emit_warnigs/pcl/diagnostics.json +++ b/pkg/convert/testdata/programs/non_supported_lifecycle_hooks_emit_warnigs/pcl/diagnostics.json @@ -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:" ] diff --git a/pkg/convert/testdata/programs/required_providers_tf/pcl/main.pp b/pkg/convert/testdata/programs/required_providers_tf/pcl/main.pp index 72de53f..8f8a8e4 100644 --- a/pkg/convert/testdata/programs/required_providers_tf/pcl/main.pp +++ b/pkg/convert/testdata/programs/required_providers_tf/pcl/main.pp @@ -1,6 +1,6 @@ package "boundary" { baseProviderName = "terraform-provider" - baseProviderVersion = "0.6.0" + baseProviderVersion = "0.8.1" parameterization { version = "1.1.9" name = "boundary" diff --git a/pkg/convert/testdata/terraform-provider/main.tf b/pkg/convert/testdata/terraform-provider/main.tf new file mode 100644 index 0000000..00e3e8f --- /dev/null +++ b/pkg/convert/testdata/terraform-provider/main.tf @@ -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 +} diff --git a/pkg/convert/tf.go b/pkg/convert/tf.go index f71c562..01a60c5 100644 --- a/pkg/convert/tf.go +++ b/pkg/convert/tf.go @@ -35,7 +35,6 @@ import ( "github.com/hashicorp/hcl/v2/hclwrite" "github.com/hashicorp/terraform-svchost/disco" "github.com/opentofu/opentofu/shim" - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tf2pulumi/il" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge" "github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/syntax" "github.com/pulumi/pulumi/pkg/v3/codegen/pcl" @@ -2356,7 +2355,7 @@ func convertLocal(state *convertState, scopes *scopes, } func convertDataResource(state *convertState, - info il.ProviderInfoSource, scopes *scopes, + info ProviderInfoSource, scopes *scopes, dataResource *configs.Resource, ) (hclwrite.Tokens, string, hclwrite.Tokens, hclwrite.Tokens) { // We translate dataResources into invokes @@ -2440,7 +2439,7 @@ func convertDataResource(state *convertState, func convertProvisioner( state *convertState, - info il.ProviderInfoSource, scopes *scopes, + info ProviderInfoSource, scopes *scopes, provisioner *configs.Provisioner, resourceName string, provisionerIndex int, forEach hcl.Expression, @@ -2530,7 +2529,7 @@ func convertProvisioner( } func convertManagedResources(state *convertState, - info il.ProviderInfoSource, scopes *scopes, + info ProviderInfoSource, scopes *scopes, managedResource *configs.Resource, target *hclwrite.Body, ) { @@ -2576,8 +2575,8 @@ func convertManagedResources(state *convertState, state.appendDiagnostic(&hcl.Diagnostic{ Severity: hcl.DiagWarning, Summary: "converting create_before_destroy lifecycle hook is not supported", - Detail: `in Pulumi, resources are always created before destroy unless the resource is created with the -resource option deleteBeforeReplace. If this behavior is desired, it must be set. + Detail: `in Pulumi, resources are always created before destroy unless the resource is created with the +resource option deleteBeforeReplace. If this behavior is desired, it must be set. See https://www.pulumi.com/docs/iac/concepts/options/deletebeforereplace/ for details`, Subject: managedResource.DeclRange.Ptr(), Context: managedResource.DeclRange.Ptr(), @@ -2804,7 +2803,7 @@ func translateRemoteModule( packageSubdir string, destinationRoot afero.Fs, // The root of the destination filesystem to write PCL to. destinationDirectory string, // A path in destination to write the translated code to. - info il.ProviderInfoSource, + info ProviderInfoSource, requiredProviders map[string]*configs.RequiredProvider, ) hcl.Diagnostics { fetcher := getmodules.NewPackageFetcher() @@ -2860,7 +2859,7 @@ func translateModuleSourceCode( sourceDirectory string, // The path in sourceRoot to the source terraform module. destinationRoot afero.Fs, // The root of the destination filesystem to write PCL to. destinationDirectory string, // A path in destination to write the translated code to. - info il.ProviderInfoSource, + info ProviderInfoSource, requiredProviders map[string]*configs.RequiredProvider, topLevelModule bool, ) hcl.Diagnostics { @@ -2875,7 +2874,7 @@ func translateModuleSourceCode( requiredProviders[name] = provider } - scopes := newScopes(info) + scopes := newScopes() state := &convertState{ sources: sources, @@ -2960,7 +2959,7 @@ func translateModuleSourceCode( // We rewrite uses of template because it's really common but the provider for it is // deprecated. As such we don't want to try and do a mapping lookup for it. - providerInfo, err := info.GetProviderInfo("", "", provider, "") + providerInfo, err := info.GetProviderInfo(provider, requiredProviders[provider]) if err != nil { state.appendDiagnostic(&hcl.Diagnostic{ Subject: &dataResource.DeclRange, @@ -2993,7 +2992,7 @@ func translateModuleSourceCode( key := managedResource.Type + "." + managedResource.Name // Try to grab the info for this resource type provider := impliedProvider(managedResource.Type) - providerInfo, err := info.GetProviderInfo("", "", provider, "") + providerInfo, err := info.GetProviderInfo(provider, requiredProviders[provider]) if err != nil { state.appendDiagnostic(&hcl.Diagnostic{ Subject: &managedResource.DeclRange, @@ -3255,6 +3254,10 @@ func translateModuleSourceCode( for _, item := range items { if item.provider != nil { provider := item.provider + cfgName := provider.Name + if rename, ok := pulumiRenamedProviderNames[provider.Name]; ok { + cfgName = rename + } // If an alias is set just warn and ignore this, we can't support this yet if provider.Alias != "" { @@ -3279,7 +3282,7 @@ func translateModuleSourceCode( } // Try to grab the info for this provider config - providerInfo, err := info.GetProviderInfo("", "", provider.Name, "") + providerInfo, err := info.GetProviderInfo(provider.Name, requiredProviders[provider.Name]) if err != nil { state.appendDiagnostic(&hcl.Diagnostic{ Subject: &provider.DeclRange, @@ -3328,7 +3331,7 @@ func translateModuleSourceCode( Subject: &provider.DeclRange, Severity: hcl.DiagWarning, Summary: "Failed to evaluate provider config", - Detail: fmt.Sprintf("Could not evaluate expression for %s:%s", provider.Name, attrKey), + Detail: fmt.Sprintf("Could not evaluate expression for %s:%s", cfgName, attrKey), }) // If we couldn't eval the config we'll emit an obvious TODO to the config for it val = cty.StringVal("TODO: " + state.sourceCode(value.Expr.Range())) @@ -3365,7 +3368,8 @@ func translateModuleSourceCode( } } - cfg[provider.Name+":"+name] = workspace.ProjectConfigType{ + // When there is a renamed provider be sure to update it's name in the Pulumi.yaml config. + cfg[cfgName+":"+name] = workspace.ProjectConfigType{ Value: yamlValue, } } @@ -3553,6 +3557,13 @@ func getPackageBlock(name string, prov *configs.RequiredProvider) (*hclwrite.Blo packageNameParts := strings.Split(prov.Source, "/") packageName := packageNameParts[len(packageNameParts)-1] + // Some Terraform providers correspond to Pulumi packages which have different names (e.g. "google" is actually backed + // by the Pulumi "gcp" package). Perform that renaming now before generating definitions/looking up whether we'll + // bridge or not. + if renamed, ok := pulumiRenamedProviderNames[packageName]; ok { + packageName = renamed + } + // TODO(pulumi/pulumi#17933) For now we just the package name portion of the source (also known as the "type" in tf // parlance). This may lead to name overlap, but as of now this is how our system works. If we need to fix name // overlap, this is the place to start. @@ -3564,7 +3575,7 @@ func getPackageBlock(name string, prov *configs.RequiredProvider) (*hclwrite.Blo if isTerraformProvider(packageName) { body.SetAttributeValue("baseProviderName", cty.StringVal("terraform-provider")) - body.SetAttributeValue("baseProviderVersion", cty.StringVal("0.6.0")) + body.SetAttributeValue("baseProviderVersion", cty.StringVal("0.8.1")) // Right now we use the shim of the opentofu implementation of getting the // TF Package version to access an internal API. @@ -3595,13 +3606,9 @@ func getPackageBlock(name string, prov *configs.RequiredProvider) (*hclwrite.Blo return block, diags } -func isTerraformProvider(name string) bool { - return !slices.Contains(pulumiSupportedProviders, name) -} - func TranslateModule( source afero.Fs, sourceDirectory string, - destination afero.Fs, info il.ProviderInfoSource, + destination afero.Fs, info ProviderInfoSource, ) hcl.Diagnostics { modules := make(map[moduleKey]string) return translateModuleSourceCode(modules, diff --git a/pkg/convert/tf_scopes.go b/pkg/convert/tf_scopes.go index 26af2e7..7f77e93 100644 --- a/pkg/convert/tf_scopes.go +++ b/pkg/convert/tf_scopes.go @@ -19,7 +19,6 @@ import ( "strings" "github.com/hashicorp/hcl/v2" - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tf2pulumi/il" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim/schema" @@ -53,8 +52,6 @@ type PathInfo struct { } type scopes struct { - info il.ProviderInfoSource - // All known roots, keyed by fully qualified path e.g. data.some_data_source roots map[string]PathInfo @@ -69,9 +66,8 @@ type scopes struct { scope *lang.Scope } -func newScopes(info il.ProviderInfoSource) *scopes { +func newScopes() *scopes { s := &scopes{ - info: info, roots: make(map[string]PathInfo), locals: make([]map[string]string, 0), } diff --git a/pkg/convert/tf_state.go b/pkg/convert/tf_state.go index 74de976..8317f9f 100644 --- a/pkg/convert/tf_state.go +++ b/pkg/convert/tf_state.go @@ -22,7 +22,6 @@ import ( "strings" "github.com/hashicorp/hcl/v2" - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tf2pulumi/il" "github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" "github.com/pulumi/terraform/pkg/addrs" @@ -43,7 +42,7 @@ func getString(addr addrs.Resource, obj map[string]interface{}, key string) (str return str, nil } -func TranslateState(info il.ProviderInfoSource, path string) (*plugin.ConvertStateResponse, error) { +func TranslateState(info ProviderInfoSource, path string) (*plugin.ConvertStateResponse, error) { stateFile, err := os.Open(path) if err != nil { return nil, err @@ -185,7 +184,7 @@ func TranslateState(info il.ProviderInfoSource, path string) (*plugin.ConvertSta // Try to grab the info for this resource type tfType := resource.Addr.Resource.Type provider := impliedProvider(tfType) - providerInfo, err := info.GetProviderInfo("", "", provider, "") + providerInfo, err := info.GetProviderInfo(provider, nil /*requiredProvider*/) if err != nil { // Don't fail the import, just warn diagnostics = append(diagnostics, &hcl.Diagnostic{ diff --git a/pkg/convert/tf_state_test.go b/pkg/convert/tf_state_test.go index ba30fff..d0f2221 100644 --- a/pkg/convert/tf_state_test.go +++ b/pkg/convert/tf_state_test.go @@ -23,7 +23,6 @@ import ( "github.com/hashicorp/hcl/v2" bridgetesting "github.com/pulumi/pulumi-converter-terraform/pkg/testing" - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tf2pulumi/il" "github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin" "github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil" "github.com/stretchr/testify/assert" @@ -59,7 +58,7 @@ func TestTranslateState(t *testing.T) { } mapper := &bridgetesting.TestFileMapper{Path: filepath.Join(testDir, "mappings")} - info := il.NewCachingProviderInfoSource(il.NewMapperProviderInfoSource(mapper)) + info := NewCachingProviderInfoSource(NewMapperProviderInfoSource(mapper)) for _, tt := range tests { tt := tt // avoid capturing loop variable in the closure diff --git a/pkg/convert/translate_test.go b/pkg/convert/translate_test.go index 82dc4c0..7e0943c 100644 --- a/pkg/convert/translate_test.go +++ b/pkg/convert/translate_test.go @@ -30,14 +30,15 @@ import ( "github.com/spf13/afero" "github.com/blang/semver" - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tf2pulumi/il" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfgen" + "github.com/pulumi/pulumi/pkg/v3/codegen/convert" "github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/syntax" "github.com/pulumi/pulumi/pkg/v3/codegen/pcl" "github.com/pulumi/pulumi/pkg/v3/codegen/schema" "github.com/pulumi/pulumi/sdk/v3/go/common/diag" "github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors" "github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil" + "github.com/pulumi/pulumi/sdk/v3/go/common/workspace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -99,7 +100,7 @@ func TestTranslate(t *testing.T) { // Test framework for eject // Each folder in testdata has a pcl folder, we check that if we convert the hcl we get the expected pcl - // You can regenerate the test data by running "PULUMI_ACCEPT=1 go test" in this folder (pkg/tf2pulumi/convert). + // You can regenerate the test data by running "PULUMI_ACCEPT=1 go test" in this folder (pkg/convert). testDir, err := filepath.Abs(filepath.Join("testdata")) require.NoError(t, err) infos, err := os.ReadDir(filepath.Join(testDir, "programs")) @@ -192,7 +193,7 @@ func TestTranslate(t *testing.T) { osFs := afero.NewOsFs() pclFs := afero.NewBasePathFs(osFs, pclPath) - providerInfoSource := il.NewMapperProviderInfoSource(mapper) + providerInfoSource := NewMapperProviderInfoSource(mapper) diagnostics := TranslateModule(osFs, hclPath, pclFs, providerInfoSource) // If PULUMI_ACCEPT is set then clear the PCL folder and copy the generated files out. Note we @@ -371,7 +372,7 @@ func Test_GenerateTestDataSchemas(t *testing.T) { mappingsPath := filepath.Join(testDir, "mappings") schemasPath := filepath.Join(testDir, "schemas") mapper := &bridgetesting.TestFileMapper{Path: mappingsPath} - providerInfoSource := il.NewMapperProviderInfoSource(mapper) + providerInfoSource := NewMapperProviderInfoSource(mapper) nilSink := diag.DefaultSink(io.Discard, io.Discard, diag.FormatOptions{ Color: colors.Never, @@ -388,7 +389,7 @@ func Test_GenerateTestDataSchemas(t *testing.T) { // Strip off the .json part to make the package name pkg := strings.Replace(info.Name(), filepath.Ext(info.Name()), "", -1) - provInfo, err := providerInfoSource.GetProviderInfo("", "", pkg, "") + provInfo, err := providerInfoSource.GetProviderInfo(pkg, nil /*requiredProvider*/) require.NoError(t, err) schema, err := tfgen.GenerateSchema(*provInfo, nilSink) @@ -399,3 +400,63 @@ func Test_GenerateTestDataSchemas(t *testing.T) { }) } } + +// Tests that the converter correctly loads mappings for providers that are not part of the Pulumiverse, by requesting +// mapping for an appropriately parameterized instance of the Terraform provider plugin. +func TestTranslateParameterized(t *testing.T) { + t.Parallel() + + // Arrange. + testDir, err := filepath.Abs(filepath.Join("testdata")) + require.NoError(t, err) + + testPath := filepath.Join(testDir, "terraform-provider") + + seen := map[string]*convert.MapperPackageHint{} + + mapper := &bridgetesting.MockMapper{ + GetMappingF: func( + _ context.Context, + provider string, + hint *convert.MapperPackageHint, + ) ([]byte, error) { + seen[provider] = hint + return []byte{}, nil + }, + } + + osFs := afero.NewOsFs() + + tempDir := t.TempDir() + pclPath := filepath.Join(tempDir, "pcl") + pclFs := afero.NewBasePathFs(osFs, pclPath) + + providerInfoSource := NewMapperProviderInfoSource(mapper) + + expectedToSee := map[string]*convert.MapperPackageHint{ + // "google" has a Pulumiverse provider ("gcp"), so we should expect the converter to request the Pulumi plugin with + // that name, with no parameterization. + "google": { + PluginName: "gcp", + }, + + // "planetscale" is not a Pulumiverse provider, so we should expect the converter to request for it to be + // dynamically bridged, by providing a mapping hint that mentions the terraform-provider plugin with an appropriate + // parameterization. + "planetscale": { + PluginName: "terraform-provider", + Parameterization: &workspace.Parameterization{ + Name: "planetscale", + Version: semver.MustParse("0.1.0"), + Value: []byte(`{"remote":{"url":"planetscale/planetscale","version":"0.1.0"}}`), + }, + }, + } + + // Act. + diagnostics := TranslateModule(osFs, testPath, pclFs, providerInfoSource) + + // Assert. + require.False(t, diagnostics.HasErrors(), "translate diagnostics should not have errors: %v", diagnostics) + require.Equal(t, expectedToSee, seen, "expected to see an appropriate set of provider hints") +} diff --git a/pkg/il/mapper.go b/pkg/il/mapper.go deleted file mode 100644 index 4746521..0000000 --- a/pkg/il/mapper.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2016-2023, 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 il - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tf2pulumi/il" - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge" - "github.com/pulumi/pulumi/pkg/v3/codegen/convert" -) - -// mapperProviderInfoSource wraps a convert.Mapper to return tfbridge.ProviderInfo -type mapperProviderInfoSource struct { - mapper convert.Mapper -} - -func NewMapperProviderInfoSource(mapper convert.Mapper) il.ProviderInfoSource { - return &mapperProviderInfoSource{mapper: mapper} -} - -func (mapper *mapperProviderInfoSource) GetProviderInfo( - registryName, namespace, name, version string, -) (*tfbridge.ProviderInfo, error) { - // TODO: Mapper has been made context aware, but ProviderInfoSource isn't. - data, err := mapper.mapper.GetMapping(context.TODO(), name, &convert.MapperPackageHint{ - PluginName: il.GetPulumiProviderName(name), - }) - if err != nil { - return nil, err - } - // Might be nil or [] - if len(data) == 0 { - return nil, fmt.Errorf( - "could not find mapping information for provider %s; "+ - "try installing a pulumi plugin that supports this terraform provider", - name, - ) - } - - var info *tfbridge.MarshallableProviderInfo - err = json.Unmarshal(data, &info) - if err != nil { - return nil, fmt.Errorf("could not decode schema information for provider %s: %w", name, err) - } - return info.Unmarshal(), nil -} diff --git a/pkg/testing/mappings.go b/pkg/testing/mappings.go index 3dc08e8..54f179a 100644 --- a/pkg/testing/mappings.go +++ b/pkg/testing/mappings.go @@ -63,3 +63,25 @@ func (l *TestFileMapper) GetMapping( return mappingBytes, nil } + +// MockMapper provides a way to mock the Mapper interface for testing purposes. +type MockMapper struct { + // GetMappingF is a function that will be called when Mapper.GetMapping is invoked. + GetMappingF func( + context.Context, + string, + *convert.MapperPackageHint, + ) ([]byte, error) +} + +func (m *MockMapper) GetMapping( + ctx context.Context, + provider string, + hint *convert.MapperPackageHint, +) ([]byte, error) { + if m.GetMappingF == nil { + panic("GetMappingF is not implemented") + } + + return m.GetMappingF(ctx, provider, hint) +}