mirror of https://github.com/pulumi/pulumi.git
467 lines
17 KiB
Go
467 lines
17 KiB
Go
// 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 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/tokens"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
|
|
)
|
|
|
|
// Workspace is the current workspace.
|
|
// This is used to get the list of plugins installed in the workspace.
|
|
// It's analogous to the workspace package, but scoped down to just the parts we need.
|
|
//
|
|
// This should probably be used to replace a load of our currently hardcoded for real world (i.e actual file
|
|
// system, actual http calls) plugin workspace code, but for now we're keeping it scoped just to help out with
|
|
// testing the mapper code.
|
|
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{}
|
|
}
|
|
|
|
// ProviderFactory creates a provider for a given package and version.
|
|
type ProviderFactory func(tokens.Package, *semver.Version) (plugin.Provider, error)
|
|
|
|
// hostManagedProvider is Provider built from a plugin.Host.
|
|
type hostManagedProvider struct {
|
|
plugin.Provider
|
|
|
|
host plugin.Host
|
|
}
|
|
|
|
var _ plugin.Provider = (*hostManagedProvider)(nil)
|
|
|
|
func (pc *hostManagedProvider) Close() error {
|
|
return pc.host.CloseProvider(pc.Provider)
|
|
}
|
|
|
|
// ProviderFactoryFromHost builds a ProviderFactory
|
|
// that uses the given plugin host to create providers.
|
|
func ProviderFactoryFromHost(host plugin.Host) ProviderFactory {
|
|
return func(pkg tokens.Package, version *semver.Version) (plugin.Provider, error) {
|
|
descriptor := workspace.PackageDescriptor{
|
|
PluginSpec: workspace.PluginSpec{
|
|
Name: string(pkg),
|
|
Version: version,
|
|
},
|
|
}
|
|
|
|
provider, err := host.Provider(descriptor)
|
|
if err != nil {
|
|
desc := pkg.String()
|
|
if version != nil {
|
|
desc += "@" + version.String()
|
|
}
|
|
return nil, fmt.Errorf("load plugin %v: %w", desc, err)
|
|
}
|
|
|
|
return &hostManagedProvider{
|
|
Provider: provider,
|
|
host: host,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
type mapperPluginSpec struct {
|
|
name tokens.Package
|
|
version semver.Version
|
|
// An optional list of providers this plugin can map to, only filled if GetMappings is implemented.
|
|
mappings []string
|
|
// Set to true once we've called GetMappings, mappings may still be nil after this if GetMappings wasn't
|
|
// implemented.
|
|
calledGetMappings bool
|
|
}
|
|
|
|
type pluginMapper struct {
|
|
providerFactory ProviderFactory
|
|
conversionKey string
|
|
plugins []mapperPluginSpec
|
|
entries map[string][]byte
|
|
installProvider func(tokens.Package) *semver.Version
|
|
lock sync.Mutex
|
|
}
|
|
|
|
func NewPluginMapper(ws Workspace,
|
|
providerFactory ProviderFactory,
|
|
key string, mappings []string,
|
|
installProvider func(tokens.Package) *semver.Version,
|
|
) (Mapper, error) {
|
|
contract.Requiref(providerFactory != nil, "providerFactory", "must not be nil")
|
|
contract.Requiref(ws != nil, "ws", "must not be nil")
|
|
|
|
entries := map[string][]byte{}
|
|
|
|
// 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 := make([]mapperPluginSpec, 0)
|
|
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, mapperPluginSpec{
|
|
name: tokens.Package(plugin.Name),
|
|
version: version,
|
|
})
|
|
}
|
|
|
|
// These take precedence over any plugin returned mappings, but we want to error early if we can't read
|
|
// any of these.
|
|
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 &pluginMapper{
|
|
providerFactory: providerFactory,
|
|
conversionKey: key,
|
|
plugins: plugins,
|
|
entries: entries,
|
|
installProvider: installProvider,
|
|
}, nil
|
|
}
|
|
|
|
// getMappingForPlugin calls GetMapping on the given plugin and returns it's result. Currently if looking up
|
|
// the "terraform" mapping and getting an empty result this will fallback to also asking for the "tf" mapping.
|
|
// This is because tfbridge providers originally only replied to "tf", while new ones reply (with the same
|
|
// answer) to both "tf" and "terraform".
|
|
func (l *pluginMapper) getMappingForPlugin(pluginSpec mapperPluginSpec, provider string) ([]byte, string, error) {
|
|
providerPlugin, err := l.providerFactory(pluginSpec.name, &pluginSpec.version)
|
|
if err != nil {
|
|
// We should maybe be lenient here and ignore errors but for now assume it's better to fail out on
|
|
// things like providers failing to start.
|
|
return nil, "", fmt.Errorf("could not create provider '%s': %w", pluginSpec.name, err)
|
|
}
|
|
defer contract.IgnoreClose(providerPlugin)
|
|
|
|
conversionKeys := []string{l.conversionKey}
|
|
if l.conversionKey == "terraform" {
|
|
// TODO: Temporary hack to work around the fact that most of the plugins return a mapping for "tf" but
|
|
// not "terraform" but they're the same thing.
|
|
conversionKeys = append(conversionKeys, "tf")
|
|
}
|
|
|
|
// We'll delete this for loop once the plugins have had a chance to update.
|
|
for _, conversionKey := range conversionKeys {
|
|
mapping, err := providerPlugin.GetMapping(context.TODO(), plugin.GetMappingRequest{
|
|
Key: conversionKey,
|
|
Provider: provider,
|
|
})
|
|
if err != nil {
|
|
// This was an error calling GetMapping, not just that GetMapping returned a nil result. It's fine for
|
|
// GetMapping to return (nil, "", nil) as that simply indicates that the plugin doesn't have a mapping
|
|
// for the requested key.
|
|
return nil, "", fmt.Errorf("could not get mapping for provider '%s': %w", pluginSpec.name, err)
|
|
}
|
|
// A provider should return non-empty results if it has a mapping.
|
|
if mapping.Provider != "" && len(mapping.Data) != 0 {
|
|
return mapping.Data, mapping.Provider, nil
|
|
}
|
|
// If a provider returns (empty, "provider") we also treat that as no mapping, because only the slice part
|
|
// gets returned to the converter plugin and it needs to assume that empty means no mapping, but we warn
|
|
// that this is unexpected.
|
|
if mapping.Provider != "" && len(mapping.Data) == 0 {
|
|
logging.Warningf(
|
|
"provider '%s' returned empty data but a filled provider name '%s' for '%s', "+
|
|
"this is unexpected behaviour assuming no mapping", pluginSpec.name, mapping.Provider, conversionKey)
|
|
}
|
|
}
|
|
|
|
return nil, "", err
|
|
}
|
|
|
|
func (l *pluginMapper) getMappingsForPlugin(pluginSpec *mapperPluginSpec, provider string) ([]byte, bool, error) {
|
|
var providerPlugin plugin.Provider
|
|
if !pluginSpec.calledGetMappings {
|
|
var err error
|
|
providerPlugin, err = l.providerFactory(pluginSpec.name, &pluginSpec.version)
|
|
if err != nil {
|
|
// We should maybe be lenient here and ignore errors but for now assume it's better to fail out on
|
|
// things like providers failing to start.
|
|
return nil, false, fmt.Errorf("could not create provider '%s': %w", pluginSpec.name, err)
|
|
}
|
|
defer contract.IgnoreClose(providerPlugin)
|
|
|
|
mappings, err := providerPlugin.GetMappings(context.TODO(), plugin.GetMappingsRequest{
|
|
Key: l.conversionKey,
|
|
})
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("could not get mappings for provider '%s': %w", pluginSpec.name, err)
|
|
}
|
|
|
|
pluginSpec.calledGetMappings = true
|
|
pluginSpec.mappings = mappings.Keys
|
|
}
|
|
|
|
var hasMapping bool
|
|
for _, mapping := range pluginSpec.mappings {
|
|
if mapping == provider {
|
|
hasMapping = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if hasMapping {
|
|
// This reports it has the mapping so just return that
|
|
if providerPlugin == nil {
|
|
var err error
|
|
providerPlugin, err = l.providerFactory(pluginSpec.name, &pluginSpec.version)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("could not create provider '%s': %w", pluginSpec.name, err)
|
|
}
|
|
defer contract.IgnoreClose(providerPlugin)
|
|
}
|
|
|
|
mapping, err := providerPlugin.GetMapping(context.TODO(), plugin.GetMappingRequest{
|
|
Key: l.conversionKey,
|
|
Provider: provider,
|
|
})
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("could not get mapping for provider '%s': %w", pluginSpec.name, err)
|
|
}
|
|
if mapping.Provider != provider {
|
|
return nil, false, fmt.Errorf(
|
|
"mapping call returned unexpected provider, expected '%s', got '%s'",
|
|
provider, mapping.Provider)
|
|
}
|
|
|
|
return mapping.Data, true, nil
|
|
}
|
|
|
|
return nil, false, nil
|
|
}
|
|
|
|
func (l *pluginMapper) GetMapping(ctx context.Context, provider string, pulumiProvider string) ([]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.
|
|
l.lock.Lock()
|
|
defer l.lock.Unlock()
|
|
|
|
// If we already have an entry for this provider, use it
|
|
if entry, has := l.entries[provider]; has {
|
|
return entry, nil
|
|
}
|
|
|
|
// Converters might not set pulumiProvider so default it to the same name as the foreign provider.
|
|
if pulumiProvider == "" {
|
|
pulumiProvider = provider
|
|
}
|
|
pulumiProviderPkg := tokens.Package(pulumiProvider)
|
|
|
|
// Optimization:
|
|
// If there's a plugin with a name that matches the expected pulumi provider name, move it to the front of
|
|
// the list.
|
|
// This is a common case, so we can avoid an expensive linear search through the rest of the plugins.
|
|
foundPulumiProvider := false
|
|
for i := 0; i < len(l.plugins); i++ {
|
|
pluginSpec := l.plugins[i]
|
|
if pluginSpec.name == pulumiProviderPkg {
|
|
l.plugins[0], l.plugins[i] = l.plugins[i], l.plugins[0]
|
|
foundPulumiProvider = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// If we didn't find the pulumi provider in the list of plugins, then we want to try to install it. Note
|
|
// that we don't want to hard fail here because it might turn out the provider hint was bad.
|
|
if !foundPulumiProvider {
|
|
version := l.installProvider(pulumiProviderPkg)
|
|
if version != nil {
|
|
// Insert at the front of the plugins list. Easiest way to do this is just append then swap.
|
|
i := len(l.plugins)
|
|
l.plugins = append(l.plugins, mapperPluginSpec{
|
|
name: pulumiProviderPkg,
|
|
version: *version,
|
|
})
|
|
l.plugins[0], l.plugins[i] = l.plugins[i], l.plugins[0]
|
|
}
|
|
}
|
|
|
|
// Before we begin the GetMappings loop below iff we've got a plugin at the head of the list which is the exact name
|
|
// match we'll try that plugin first (GetMappings, and then GetMapping) as it will normally be right.
|
|
if len(l.plugins) > 0 && l.plugins[0].name == pulumiProviderPkg {
|
|
data, found, err := l.getMappingsForPlugin(&l.plugins[0], provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Found it via GetMappings lookup just return it
|
|
if found {
|
|
// Don't overwrite entries, the first wins
|
|
if _, has := l.entries[provider]; !has {
|
|
l.entries[provider] = data
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// Once we call GetMappping("") we'll not use this plugin again so pop it from the list.
|
|
pluginSpec := l.plugins[0]
|
|
l.plugins = l.plugins[1:]
|
|
data, mappedProvider, err := l.getMappingForPlugin(pluginSpec, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mappedProvider != "" {
|
|
// Don't overwrite entries, the first wins
|
|
if _, has := l.entries[mappedProvider]; !has {
|
|
l.entries[mappedProvider] = data
|
|
}
|
|
// If this was the provider we we're looking for we can now return it
|
|
if mappedProvider == provider {
|
|
return data, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// The first plugin didn't match by name (or did but didn't have the mapping we wanted) so scan is to see if we can
|
|
// find a plugin thats reports this conversion via GetMappings. If one does then ask it for the mapping and return
|
|
// that. Else cache which mappings are reported in case we need one of those later.
|
|
for idx := range l.plugins {
|
|
data, found, err := l.getMappingsForPlugin(&l.plugins[idx], provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if found {
|
|
// Don't overwrite entries, the first wins
|
|
if _, has := l.entries[provider]; !has {
|
|
l.entries[provider] = data
|
|
}
|
|
return data, nil
|
|
}
|
|
}
|
|
|
|
// No entry yet, start popping providers off the plugin list and return the first one that returns
|
|
// conversion data for this provider for the given key we're looking for. Second assumption is that only
|
|
// one pulumi provider will provide a mapping for each source mapping. This _might_ change in the future
|
|
// if we for example add support to convert terraform to azure/aws-native, or multiple community members
|
|
// bridge the same terraform provider. But as above the decisions here are based on what's locally
|
|
// installed so the user can manually edit their plugin cache to be the set of plugins they want to use.
|
|
for {
|
|
// If we're in this loop we're looking for the mapping via legacy GetMapping("") calls. We shouldn't make these
|
|
// calls against providers who have told us the set they map against.
|
|
|
|
// Find a plugin that doesn't have any mapping information, we'll call GetMapping("") on it.
|
|
var pluginSpec *mapperPluginSpec
|
|
for idx, spec := range l.plugins {
|
|
spec := spec
|
|
contract.Assertf(spec.calledGetMappings, "GetMappings should have been called")
|
|
// If this plugin has mapping information then don't call GetMapping("") on it, if it had the right mapping it
|
|
// would have been picked up in the loop above.
|
|
if spec.mappings == nil {
|
|
pluginSpec = &spec
|
|
|
|
// We're going to call GetMapping("") on this plugin, it will never be needed again so remove it from
|
|
// the list by overwriting it with the plugin from the end and then shrinking the slice.
|
|
last := len(l.plugins) - 1
|
|
l.plugins[idx] = l.plugins[last]
|
|
l.plugins = l.plugins[0:last]
|
|
break
|
|
}
|
|
}
|
|
|
|
if pluginSpec == nil {
|
|
// No plugins left to look in, return that we don't have a mapping but first save that we'll never
|
|
// find a mapping for this provider key.
|
|
l.entries[provider] = []byte{}
|
|
return []byte{}, nil
|
|
}
|
|
|
|
data, mappedProvider, err := l.getMappingForPlugin(*pluginSpec, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mappedProvider != "" {
|
|
// Don't overwrite entries, the first wins
|
|
if _, has := l.entries[mappedProvider]; !has {
|
|
l.entries[mappedProvider] = data
|
|
}
|
|
// If this was the provider we we're looking for we can now return it
|
|
if mappedProvider == provider {
|
|
return data, nil
|
|
}
|
|
}
|
|
}
|
|
}
|