pulumi/pkg/codegen/convert/base_plugin_mapper.go

310 lines
11 KiB
Go

// 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"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/blang/semver"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
)
// basePluginMapper is a Mapper implementation that uses a list of installed plugins to source mappings.
type basePluginMapper struct {
lock sync.Mutex
// The key to use when querying provider plugins for mappings, to identify the type of the source provider.
// "terraform" is an example of a conversion key which identifies mapping requests where the results are expected to
// map Terraform resources to Pulumi resources, for instance.
conversionKey string
// A factory function that the mapper can use to instantiate provider plugins.
providerFactory ProviderFactory
// A function that the mapper can use to install plugins when it fails to locate them.
installPlugin func(pluginName string) *semver.Version
// A list of plugins that the mapper has enumerated as being available to serve mapping requests.
pluginSpecs []basePluginMapperSpec
// A list of hardcoded mappings read from files supplied to the mapper at construction time that will take priority
// over any mappings returned by plugins.
entries map[string][]byte
}
type basePluginMapperSpec struct {
name string
version semver.Version
}
// Workspace encapsulates an environment containing an enumerable set of plugins.
type Workspace interface {
// GetPlugins returns the list of plugins installed in the workspace.
GetPlugins() ([]workspace.PluginInfo, error)
}
type defaultWorkspace struct{}
func (defaultWorkspace) GetPlugins() ([]workspace.PluginInfo, error) {
return workspace.GetPlugins()
}
// DefaultWorkspace returns a default workspace implementation that uses the workspace module directly to get plugin
// info.
func DefaultWorkspace() Workspace {
return defaultWorkspace{}
}
// NewBasePluginMapper creates a new plugin mapper backed by the supplied workspace.
func NewBasePluginMapper(
ws Workspace,
conversionKey string,
providerFactory ProviderFactory,
installPlugin func(pluginName string) *semver.Version,
mappings []string,
) (Mapper, error) {
contract.Requiref(ws != nil, "ws", "must not be nil")
contract.Requiref(providerFactory != nil, "providerFactory", "must not be nil")
// Enumerate _all_ our installed plugins to ask for any mappings they provide. This allows users to convert aws
// terraform code for example by just having 'pulumi-aws' plugin locally, without needing to specify it anywhere on
// the command line, and without tf2pulumi needing to know about every possible plugin.
allPlugins, err := ws.GetPlugins()
if err != nil {
return nil, fmt.Errorf("could not get plugins: %w", err)
}
// First assumption we only care about the latest version of each plugin. If we add support to get a mapping for
// plugin version 1, it seems unlikely that we would remove support for that mapping in v2, so the latest version
// should in most cases be fine. If a user case comes up where this is not fine we can provide the manual workaround
// that this is based on what is locally installed, not what is published and so the user can just delete the higher
// version plugins from their cache.
latestVersions := make(map[string]semver.Version)
for _, plugin := range allPlugins {
if plugin.Kind != apitype.ResourcePlugin {
continue
}
if cur, has := latestVersions[plugin.Name]; has {
if plugin.Version.GT(cur) {
latestVersions[plugin.Name] = *plugin.Version
}
} else {
latestVersions[plugin.Name] = *plugin.Version
}
}
// We now have a list of plugin specs (i.e. a name and version). Save that list because we don't want to iterate all
// the plugins now because the convert might not even ask for any mappings.
plugins := []basePluginMapperSpec{}
for _, plugin := range allPlugins {
if plugin.Kind != apitype.ResourcePlugin {
continue
}
version, has := latestVersions[plugin.Name]
contract.Assertf(has, "latest version should be in map")
plugins = append(plugins, basePluginMapperSpec{
name: plugin.Name,
version: version,
})
}
// Explicitly supplied mappings take precedence over any plugin returned mappings, but we want to error early if we
// can't read any of these.
entries := map[string][]byte{}
for _, path := range mappings {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("could not read mapping file '%s': %w", path, err)
}
// Mapping file names are assumed to be the provider key.
provider := filepath.Base(path)
// Strip the extension.
dotIndex := strings.LastIndex(provider, ".")
if dotIndex != -1 {
provider = provider[0:dotIndex]
}
entries[provider] = data
}
return &basePluginMapper{
conversionKey: conversionKey,
providerFactory: providerFactory,
installPlugin: installPlugin,
pluginSpecs: plugins,
entries: entries,
}, nil
}
// Implements Mapper.GetMapping. A plugin mapper will try to resolve mappings by first building a list of candidate
// plugins as follows:
//
// - If a hint is provided, the mapper will search for a plugin whose name matches that in the hint. If none is
// supplied, the source provider name will be used as the plugin name to search for.
// - The mapper will search its list of enumerated plugins for the name it has chosen. If it does not find a matching
// plugin, it will attempt to install it using the callback supplied to it at construction time.
// - If the mapper finds a matching plugin, either by enumeration or by installation, the matching plugin will be
// moved to the front of the list of plugins to search for mappings, so that it takes priority.
//
// With a list of plugins constructed, the mapper will then query each in turn:
//
// - If the plugin's name matches that in the hint, the mapper will pass parameterization information to the plugin as
// part of its instantiation.
// - With a plugin loaded, GetMappings (note the "s") will be called to see if the plugin reports the set of providers
// for which it has mappings (e.g. an AWS provider plugin might report `["aws"]`).
// - If GetMappings returns a non-empty result, the mapper will then call GetMapping (singular) if any of the keys
// reported matches the source provider name.
// - If GetMappings returns an empty result, or none of its reported keys match, GetMapping will be called with the
// fallback behaviour of passing an empty provider name ("") to the plugin.
//
// If at any point a mapping is returned whose enclosed provider name matches that being searched for, it is returned.
// If no matches are encountered, an empty byte array result is returned.
func (m *basePluginMapper) GetMapping(
ctx context.Context,
provider string,
hint *MapperPackageHint,
) ([]byte, error) {
// See https://github.com/pulumi/pulumi/issues/14718 for why we need this lock. It may be possible to be
// smarter about this and only lock when mutating, or at least splitting to a read/write lock, but this is
// a quick fix to unblock providers. If you do attempt this then write tests to ensure this doesn't
// regress #14718.
m.lock.Lock()
defer m.lock.Unlock()
// If we have a perfect match in our hardcoded mappings, return that.
if entry, has := m.entries[provider]; has {
return entry, nil
}
// If a hint is provided, we will search for a plugin whose name matches that in the hint. If none is supplied, the
// source provider name will be used as the plugin name to search for.
pluginName := provider
if hint != nil {
pluginName = hint.PluginName
}
// Is the plugin we're looking for already in the list of plugins?
foundPlugin := false
for i := 0; i < len(m.pluginSpecs); i++ {
pluginSpec := m.pluginSpecs[i]
if pluginSpec.name == pluginName {
// Yes; move it to the head of the list so that we try it first.
m.pluginSpecs[0], m.pluginSpecs[i] = m.pluginSpecs[i], m.pluginSpecs[0]
foundPlugin = true
break
}
}
if !foundPlugin {
// No; attempt to install it. If we succeed in installing it, we'll put the newly installed plugin at the head of
// the list so that we try it first in the following loop.
version := m.installPlugin(pluginName)
if version != nil {
i := len(m.pluginSpecs)
m.pluginSpecs = append(m.pluginSpecs, basePluginMapperSpec{
name: pluginName,
version: *version,
})
m.pluginSpecs[0], m.pluginSpecs[i] = m.pluginSpecs[i], m.pluginSpecs[0]
}
}
// Try the list of plugins we have and see if any of them produce a mapping we can return.
for _, pluginSpec := range m.pluginSpecs {
descriptor := workspace.PackageDescriptor{
PluginSpec: workspace.PluginSpec{
Name: pluginSpec.name,
Version: &pluginSpec.version,
},
}
// If the current plugin's name matches that which we are looking for, and we have a hint that includes
// parameterization information, we will pass that to the plugin as part of its instantiation.
if pluginSpec.name == pluginName && hint != nil && hint.Parameterization != nil {
descriptor.Parameterization = hint.Parameterization
}
providerPlugin, err := m.providerFactory(descriptor)
if err != nil {
return nil, fmt.Errorf("could not create provider for package %s: %w", descriptor.PackageName(), err)
}
defer contract.IgnoreClose(providerPlugin)
mappings, err := providerPlugin.GetMappings(ctx, plugin.GetMappingsRequest{
Key: m.conversionKey,
})
if err != nil {
return nil, fmt.Errorf(
"could not get %s mappings for package %s: %w",
m.conversionKey, descriptor.PackageName(), err,
)
}
for _, mappingKey := range mappings.Keys {
if mappingKey != provider {
continue
}
mapping, err := providerPlugin.GetMapping(ctx, plugin.GetMappingRequest{
Key: m.conversionKey,
Provider: provider,
})
if err != nil {
return nil, fmt.Errorf("could not get advertized %s mapping for provider %s: %w", m.conversionKey, provider, err)
}
if mapping.Provider != provider {
return nil, fmt.Errorf(
"unexpected provider in %s mapping response for provider %s: %s",
m.conversionKey, provider, mapping.Provider,
)
}
return mapping.Data, nil
}
// If we get here, it means that either the plugin reported no mappings back from GetMappings, or that it did but
// none of them matched. We'll try a blind GetMapping call with an empty provider name to see if the plugin has
// a mapping that matches that way.
mapping, err := providerPlugin.GetMapping(ctx, plugin.GetMappingRequest{
Key: m.conversionKey,
Provider: "",
})
if err != nil {
return nil, fmt.Errorf("could not get %s mapping for provider %s: %w", m.conversionKey, provider, err)
}
if mapping.Provider == provider {
return mapping.Data, nil
}
}
return []byte{}, nil
}