Look for exact match when loading plugins (#2483)
* Look for exact match when loading plugins Pulumi's current behavior when loading plugins is surprising in that it will attempt to load the "latest" provider binary instead of exactly the version that was requested. Since provider binaries and provider packages are tied together and versioned together, this is going to be problematic if a provider makes a breaking change. Although there are other issues in this area, this commit fixes the arguably bug-like behavior of loading the latest plugin and instead opts to load the plugin that exactly the requested semver range. Today, the engine will never ask for anything other than an exact version match. Since this is a breaking change, this commit also includes an environment variable that allows users to return back to the "old" plugin loading behavior if they are broken. The intention is that this escape hatch can be removed in a future release once we are confident that this change does not break people. * CR feedback * Use SelectCompatiblePlugin for HasPluginGTE check
This commit is contained in:
parent
4cb6475dc4
commit
1b6fe6271f
|
@ -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 <name> <version> --exact`.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Attempting to convert an [Output<T>] to a string or to JSON will now result in a warning
|
||||
|
|
|
@ -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/<kind>-<name>-<version>/`. A plugin may contain multiple files,
|
||||
// however the primary loadable executable must be named `pulumi-<kind>-<name>`.
|
||||
|
@ -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 {
|
||||
|
|
236
pkg/workspace/plugins_test.go
Normal file
236
pkg/workspace/plugins_test.go
Normal file
|
@ -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())
|
||||
}
|
Loading…
Reference in a new issue