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:
Sean Gillespie 2019-03-01 15:42:38 -08:00 committed by GitHub
parent 4cb6475dc4
commit 1b6fe6271f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 362 additions and 21 deletions

View file

@ -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

View file

@ -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 {

View 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())
}