diff --git a/CHANGELOG.md b/CHANGELOG.md index ffb0896f2..9a77630ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## 0.16.18 (Unreleased) +- Fix an issue where the Pulumi CLI would load the newest plugin for a resource provider instead of the version that was + requested, which could result in the Pulumi CLI loading a resource provider plugin that is incompatible with the + program. This has the potential to disrupt users that previously had working configurations; if you are experiencing + problems after upgrading to 0.16.17, you can opt-in to the legacy plugin load behavior by setting the environnment + variable `PULUMI_ENABLE_LEGACY_PLUGIN_SEARCH=1`. You can also install plugins that are missing with the command + `pulumi plugin install resource --exact`. + ### Improvements - Attempting to convert an [Output] to a string or to JSON will now result in a warning diff --git a/pkg/workspace/plugins.go b/pkg/workspace/plugins.go index f702c7cd1..380710cc8 100644 --- a/pkg/workspace/plugins.go +++ b/pkg/workspace/plugins.go @@ -26,6 +26,7 @@ import ( "path/filepath" "regexp" "runtime" + "sort" "time" "github.com/blang/semver" @@ -40,6 +41,10 @@ const ( windowsGOOS = "windows" ) +var ( + enableLegacyPluginBehavior = os.Getenv("PULUMI_ENABLE_LEGACY_PLUGIN_SEARCH") != "" +) + // PluginInfo provides basic information about a plugin. Each plugin gets installed into a system-wide // location, by default `~/.pulumi/plugins/--/`. A plugin may contain multiple files, // however the primary loadable executable must be named `pulumi--`. @@ -270,6 +275,16 @@ func HasPluginGTE(plug PluginInfo) (bool, error) { if err != nil { return false, err } + + // If we're not doing the legacy plugin behavior and we've been asked for a specific version, do the same plugin + // search that we'd do at runtime. This ensures that `pulumi plugin install` works the same way that the runtime + // loader does, to minimize confusion when a user has to install new plugins. + if !enableLegacyPluginBehavior && plug.Version != nil { + requestedVersion := semver.MustParseRange(plug.Version.String()) + _, err := SelectCompatiblePlugin(plugs, plug.Kind, plug.Name, requestedVersion) + return err == nil, err + } + for _, p := range plugs { if p.Name == plug.Name && p.Kind == plug.Kind && @@ -366,28 +381,38 @@ func GetPluginPath(kind PluginKind, name string, version *semver.Version) (strin if err != nil { return "", "", errors.Wrapf(err, "loading plugin list") } - var match *PluginInfo - for _, cur := range plugins { - // Since the value of cur changes as we iterate, we can't save a pointer to it. So let's have a local that - // we can take a pointer to if this plugin is the best match yet. - plugin := cur - if plugin.Kind == kind && plugin.Name == name { - // Always pick the most recent version of the plugin available. Even if this is an exact match, we - // keep on searching just in case there's a newer version available. - var m *PluginInfo - if match == nil && version == nil { - m = &plugin // no existing match, no version spec, take it. - } else if match != nil && - (match.Version == nil || (plugin.Version != nil && plugin.Version.GT(*match.Version))) { - m = &plugin // existing match, but this plugin is newer, prefer it. - } else if version != nil && plugin.Version != nil && plugin.Version.GTE(*version) { - m = &plugin // this plugin is >= the version being requested, use it. - } - if m != nil { - match = m - logging.V(6).Infof("GetPluginPath(%s, %s, %s): found candidate (#%s)", - kind, name, version, match.Version) + var match *PluginInfo + if !enableLegacyPluginBehavior && version != nil { + logging.V(6).Infof("GetPluginPath(%s, %s, %s): enabling new plugin behavior", kind, name, version) + candidate, err := SelectCompatiblePlugin(plugins, kind, name, semver.MustParseRange(version.String())) + if err != nil { + return "", "", err + } + match = &candidate + } else { + for _, cur := range plugins { + // Since the value of cur changes as we iterate, we can't save a pointer to it. So let's have a local that + // we can take a pointer to if this plugin is the best match yet. + plugin := cur + if plugin.Kind == kind && plugin.Name == name { + // Always pick the most recent version of the plugin available. Even if this is an exact match, we + // keep on searching just in case there's a newer version available. + var m *PluginInfo + if match == nil && version == nil { + m = &plugin // no existing match, no version spec, take it. + } else if match != nil && + (match.Version == nil || (plugin.Version != nil && plugin.Version.GT(*match.Version))) { + m = &plugin // existing match, but this plugin is newer, prefer it. + } else if version != nil && plugin.Version != nil && plugin.Version.GTE(*version) { + m = &plugin // this plugin is >= the version being requested, use it. + } + + if m != nil { + match = m + logging.V(6).Infof("GetPluginPath(%s, %s, %s): found candidate (#%s)", + kind, name, version, match.Version) + } } } } @@ -409,6 +434,79 @@ func GetPluginPath(kind PluginKind, name string, version *semver.Version) (strin return "", "", nil } +type sortedPluginInfo []PluginInfo + +func (sp sortedPluginInfo) Len() int { return len(sp) } +func (sp sortedPluginInfo) Less(i, j int) bool { + iVersion := sp[i].Version + jVersion := sp[j].Version + switch { + case iVersion == nil && jVersion == nil: + return false + case iVersion == nil: + return true + case jVersion == nil: + return false + default: + return iVersion.LT(*jVersion) + } +} +func (sp sortedPluginInfo) Swap(i, j int) { sp[i], sp[j] = sp[j], sp[i] } + +// SelectCompatiblePlugin selects a plugin from the list of plugins with the given kind and name that sastisfies the +// requested semver range. It returns the highest version plugin that satisfies the requested constraints, or an error +// if no such plugin could be found. +// +// If there exist plugins in the plugin list that don't have a version, SelectCompatiblePlugin will select them if there +// are no other compatible plugins available. +func SelectCompatiblePlugin( + plugins []PluginInfo, kind PluginKind, name string, requested semver.Range) (PluginInfo, error) { + logging.V(7).Infof("SelectCompatiblePlugin(..., %s): beginning", name) + var bestMatch PluginInfo + var hasMatch bool + + // Before iterating over the list of plugins, sort the list of plugins by version in ascending order. This ensures + // that we can do a single pass over the plugin list, from lowest version to greatest version, and be confident that + // the best match that we find at the end is the greatest possible compatible version for the requested plugin. + // + // Plugins without versions are treated as having the lowest version. Ties between plugins without versions are + // resolved arbitrarily. + sort.Sort(sortedPluginInfo(plugins)) + for _, plugin := range plugins { + switch { + case plugin.Kind != kind || plugin.Name != name: + // Not the plugin we're looking for. + case !hasMatch && plugin.Version == nil: + // This is the plugin we're looking for, but it doesn't have a version. We haven't seen anything better yet, + // so take it. + logging.V(7).Infof( + "SelectCompatiblePlugin(..., %s): best plugin %s: no version and no other candidates", + name, plugin.String()) + hasMatch = true + bestMatch = plugin + case plugin.Version == nil: + // This is a rare case - we've already seen a version-less plugin and we're seeing another here. Ignore this + // one and defer to the one we previously selected. + logging.V(7).Infof("SelectCompatiblePlugin(..., %s): skipping plugin %s: no version", name, plugin.String()) + case requested(*plugin.Version): + // This plugin is compatible with the requested semver range. Save it as the best match and continue. + logging.V(7).Infof("SelectCompatiblePlugin(..., %s): best plugin %s: semver match", name, plugin.String()) + hasMatch = true + bestMatch = plugin + default: + logging.V(7).Infof( + "SelectCompatiblePlugin(..., %s): skipping plugin %s: semver mismatch", name, plugin.String()) + } + } + + if !hasMatch { + logging.V(7).Infof("SelectCompatiblePlugin(..., %s): failed to find match", name) + return PluginInfo{}, errors.New("failed to locate compatible plugin") + } + logging.V(7).Infof("SelectCompatiblePlugin(..., %s): selecting plugin '%s': best match ", name, bestMatch.String()) + return bestMatch, nil +} + // getCandidateExtensions returns a set of file extensions (including the dot seprator) which should be used when // probing for an executable file. func getCandidateExtensions() []string { diff --git a/pkg/workspace/plugins_test.go b/pkg/workspace/plugins_test.go new file mode 100644 index 000000000..ead0ad1e5 --- /dev/null +++ b/pkg/workspace/plugins_test.go @@ -0,0 +1,236 @@ +// Copyright 2016-2019, 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 workspace + +import ( + "testing" + + "github.com/blang/semver" + "github.com/stretchr/testify/assert" +) + +func TestPluginSelection_ExactMatch(t *testing.T) { + v1 := semver.MustParse("0.1.0") + v2 := semver.MustParse("0.2.0") + v3 := semver.MustParse("0.3.0") + candidatePlugins := []PluginInfo{ + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v1, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v2, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v3, + }, + { + Name: "notmyplugin", + Kind: ResourcePlugin, + Version: &v3, + }, + { + Name: "myplugin", + Kind: AnalyzerPlugin, + Version: &v3, + }, + } + + requested := semver.MustParseRange("0.2.0") + result, err := SelectCompatiblePlugin(candidatePlugins, ResourcePlugin, "myplugin", requested) + assert.NoError(t, err) + assert.Equal(t, "myplugin", result.Name) + assert.Equal(t, "0.2.0", result.Version.String()) +} + +func TestPluginSelection_ExactMatchNotFound(t *testing.T) { + v1 := semver.MustParse("0.1.0") + v2 := semver.MustParse("0.2.1") + v3 := semver.MustParse("0.3.0") + candidatePlugins := []PluginInfo{ + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v1, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v2, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v3, + }, + { + Name: "notmyplugin", + Kind: ResourcePlugin, + Version: &v3, + }, + { + Name: "myplugin", + Kind: AnalyzerPlugin, + Version: &v3, + }, + } + + requested := semver.MustParseRange("0.2.0") + _, err := SelectCompatiblePlugin(candidatePlugins, ResourcePlugin, "myplugin", requested) + assert.Error(t, err) +} + +func TestPluginSelection_PatchVersionSlide(t *testing.T) { + v1 := semver.MustParse("0.1.0") + v2 := semver.MustParse("0.2.0") + v21 := semver.MustParse("0.2.1") + v3 := semver.MustParse("0.3.0") + candidatePlugins := []PluginInfo{ + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v1, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v2, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v21, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v3, + }, + { + Name: "notmyplugin", + Kind: ResourcePlugin, + Version: &v3, + }, + { + Name: "myplugin", + Kind: AnalyzerPlugin, + Version: &v3, + }, + } + + requested := semver.MustParseRange(">=0.2.0 <0.3.0") + result, err := SelectCompatiblePlugin(candidatePlugins, ResourcePlugin, "myplugin", requested) + assert.NoError(t, err) + assert.Equal(t, "myplugin", result.Name) + assert.Equal(t, "0.2.1", result.Version.String()) +} + +func TestPluginSelection_EmptyVersionNoAlternatives(t *testing.T) { + v1 := semver.MustParse("0.1.0") + v2 := semver.MustParse("0.2.1") + v3 := semver.MustParse("0.3.0") + candidatePlugins := []PluginInfo{ + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v1, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v2, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: nil, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v3, + }, + { + Name: "notmyplugin", + Kind: ResourcePlugin, + Version: &v3, + }, + { + Name: "myplugin", + Kind: AnalyzerPlugin, + Version: &v3, + }, + } + + requested := semver.MustParseRange("0.2.0") + result, err := SelectCompatiblePlugin(candidatePlugins, ResourcePlugin, "myplugin", requested) + assert.NoError(t, err) + assert.Equal(t, "myplugin", result.Name) + assert.Nil(t, result.Version) +} + +func TestPluginSelection_EmptyVersionWithAlternatives(t *testing.T) { + v1 := semver.MustParse("0.1.0") + v2 := semver.MustParse("0.2.0") + v3 := semver.MustParse("0.3.0") + candidatePlugins := []PluginInfo{ + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v1, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v2, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: nil, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: nil, + }, + { + Name: "myplugin", + Kind: ResourcePlugin, + Version: &v3, + }, + { + Name: "notmyplugin", + Kind: ResourcePlugin, + Version: &v3, + }, + { + Name: "myplugin", + Kind: AnalyzerPlugin, + Version: &v3, + }, + } + + requested := semver.MustParseRange("0.2.0") + result, err := SelectCompatiblePlugin(candidatePlugins, ResourcePlugin, "myplugin", requested) + assert.NoError(t, err) + assert.Equal(t, "myplugin", result.Name) + assert.Equal(t, "0.2.0", result.Version.String()) +}