pull/31615/merge
MrJack 16 hours ago committed by GitHub
commit 95c3173367
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -157,8 +157,8 @@ func NewForSource(source, version string) (installer Installer, err error) {
} }
// FindSource determines the correct Installer for the given source. // FindSource determines the correct Installer for the given source.
func FindSource(location string) (Installer, error) { func FindSource(location, version string) (Installer, error) {
installer, err := existingVCSRepo(location) installer, err := existingVCSRepo(location, version)
if err != nil && err.Error() == "Cannot detect VCS" { if err != nil && err.Error() == "Cannot detect VCS" {
slog.Warn( slog.Warn(
"cannot get information about plugin source", "cannot get information about plugin source",

@ -38,13 +38,14 @@ type VCSInstaller struct {
base base
} }
func existingVCSRepo(location string) (Installer, error) { func existingVCSRepo(location, version string) (Installer, error) {
repo, err := vcs.NewRepo("", location) repo, err := vcs.NewRepo("", location)
if err != nil { if err != nil {
return nil, err return nil, err
} }
i := &VCSInstaller{ i := &VCSInstaller{
Repo: repo, Repo: repo,
Version: version,
base: newBase(repo.Remote()), base: newBase(repo.Remote()),
} }
return i, nil return i, nil
@ -104,6 +105,17 @@ func (i *VCSInstaller) Update() error {
if err := i.Repo.Update(); err != nil { if err := i.Repo.Update(); err != nil {
return err return err
} }
ref, err := i.solveVersion(i.Repo)
if err != nil {
return err
}
if ref != "" {
if err := i.setVersion(i.Repo, ref); err != nil {
return err
}
}
if !isPlugin(i.Repo.LocalPath()) { if !isPlugin(i.Repo.LocalPath()) {
return ErrMissingMetadata return ErrMissingMetadata
} }

@ -49,6 +49,7 @@ func (r *testRepo) UpdateVersion(version string) error {
r.current = version r.current = version
return r.err return r.err
} }
func (r *testRepo) IsDirty() bool { return false }
func TestVCSInstaller(t *testing.T) { func TestVCSInstaller(t *testing.T) {
ensure.HelmHome(t) ensure.HelmHome(t)
@ -81,7 +82,7 @@ func TestVCSInstaller(t *testing.T) {
require.EqualErrorf(t, Install(i), "plugin already exists", "expected error for plugin exists") require.EqualErrorf(t, Install(i), "plugin already exists", "expected error for plugin exists")
// Testing FindSource method, expect error because plugin code is not a cloned repository // Testing FindSource method, expect error because plugin code is not a cloned repository
_, err = FindSource(i.Path()) _, err = FindSource(i.Path(), "")
require.Error(t, err, "expected error for inability to find plugin source, got none") require.Error(t, err, "expected error for inability to find plugin source, got none")
require.EqualErrorf(t, err, "cannot get information about plugin source", "expected error for inability to find plugin source") require.EqualErrorf(t, err, "cannot get information about plugin source", "expected error for inability to find plugin source")
} }
@ -127,7 +128,7 @@ func TestVCSInstallerUpdate(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Test FindSource method for positive result // Test FindSource method for positive result
pluginInfo, err := FindSource(i.Path()) pluginInfo, err := FindSource(i.Path(), "")
require.NoError(t, err) require.NoError(t, err)
vcsInstaller := pluginInfo.(*VCSInstaller) vcsInstaller := pluginInfo.(*VCSInstaller)
@ -143,3 +144,61 @@ func TestVCSInstallerUpdate(t *testing.T) {
// Testing update for error // Testing update for error
require.EqualErrorf(t, Update(vcsInstaller), "plugin repo was modified", "expected error for plugin modified") require.EqualErrorf(t, Update(vcsInstaller), "plugin repo was modified", "expected error for plugin modified")
} }
func TestVCSInstallerUpdateWithVersion(t *testing.T) {
ensure.HelmHome(t)
if err := os.MkdirAll(helmpath.DataPath("plugins"), 0o755); err != nil {
t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err)
}
source := "https://github.com/adamreese/helm-env"
testRepoPath, _ := filepath.Abs("../testdata/plugdir/good/echo-v1")
repo := &testRepo{
local: testRepoPath,
remote: source,
tags: []string{"0.1.0", "0.1.1", "0.2.0"},
}
// First install without version
i, err := NewForSource(source, "")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
vcsInstaller, ok := i.(*VCSInstaller)
if !ok {
t.Fatal("expected a VCSInstaller")
}
vcsInstaller.Repo = repo
if err := Install(i); err != nil {
t.Fatal(err)
}
// Now test update with specific version
vcsInstaller.Version = "0.1.1"
if err := Update(vcsInstaller); err != nil {
t.Fatal(err)
}
if repo.current != "0.1.1" {
t.Fatalf("expected version '0.1.1', got %q", repo.current)
}
// Test update with different version
vcsInstaller.Version = "0.2.0"
if err := Update(vcsInstaller); err != nil {
t.Fatal(err)
}
if repo.current != "0.2.0" {
t.Fatalf("expected version '0.2.0', got %q", repo.current)
}
// Test update with non-existent version
vcsInstaller.Version = "0.3.0"
if err := Update(vcsInstaller); err == nil {
t.Fatal("expected error for version does not exist, got none")
} else if err.Error() != fmt.Sprintf("requested version %q does not exist for plugin %q", "0.3.0", source) {
t.Fatalf("expected error for version does not exist, got (%v)", err)
}
}

@ -21,7 +21,9 @@ import (
"io" "io"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"helm.sh/helm/v4/internal/plugin" "helm.sh/helm/v4/internal/plugin"
@ -29,18 +31,39 @@ import (
) )
type pluginUpdateOptions struct { type pluginUpdateOptions struct {
names []string plugins map[string]string
} }
const pluginUpdateDesc = `Update one or more Helm plugins.
An exact semver version can be pinned per-plugin using the @version syntax.
Only exact versions (e.g. 1.2.3) are accepted; the "v" prefix and semver range
constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported for updates.
This ensures a deterministic, reproducible update to a known version:
helm plugin update myplugin@1.2.3 otherplugin@2.0.0
helm plugin update myplugin@1.0.0
If no version is given for a plugin it is updated to the latest version:
helm plugin update myplugin otherplugin
`
func newPluginUpdateCmd(out io.Writer) *cobra.Command { func newPluginUpdateCmd(out io.Writer) *cobra.Command {
o := &pluginUpdateOptions{} o := &pluginUpdateOptions{}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "update <plugin>...", Use: "update <plugin[@version]>...",
Aliases: []string{"up"}, Aliases: []string{"up"},
Short: "update one or more Helm plugins", Short: "update one or more Helm plugins",
Long: pluginUpdateDesc,
ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return compListPlugins(toComplete, args), cobra.ShellCompDirectiveNoFileComp ignoredNames := make([]string, len(args))
for i, arg := range args {
name, _ := parsePluginVersion(arg)
ignoredNames[i] = name
}
return compListPlugins(toComplete, ignoredNames), cobra.ShellCompDirectiveNoFileComp
}, },
PreRunE: func(_ *cobra.Command, args []string) error { PreRunE: func(_ *cobra.Command, args []string) error {
return o.complete(args) return o.complete(args)
@ -56,21 +79,46 @@ func (o *pluginUpdateOptions) complete(args []string) error {
if len(args) == 0 { if len(args) == 0 {
return errors.New("please provide plugin name to update") return errors.New("please provide plugin name to update")
} }
o.names = args
o.plugins = make(map[string]string, len(args))
for _, arg := range args {
if strings.HasSuffix(arg, "@") {
return fmt.Errorf("invalid plugin reference %q: missing version after @", arg)
}
name, version := parsePluginVersion(arg)
if name == "" {
return fmt.Errorf("invalid plugin reference %q: plugin name must not be empty", arg)
}
if _, exists := o.plugins[name]; exists {
return fmt.Errorf("plugin %q specified more than once", name)
}
if version != "" {
if _, err := semver.StrictNewVersion(version); err != nil {
errMsg := fmt.Sprintf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3)", version, name)
if strings.HasPrefix(version, "v") {
errMsg += `; the "v" prefix is not allowed`
}
return fmt.Errorf("%s", errMsg)
}
}
o.plugins[name] = version
}
return nil return nil
} }
func (o *pluginUpdateOptions) run(out io.Writer) error { func (o *pluginUpdateOptions) run(out io.Writer) error {
slog.Debug("loading installed plugins", "path", settings.PluginsDirectory) slog.Debug("loading installed plugins", "path", settings.PluginsDirectory)
plugins, err := plugin.LoadAllDir(settings.PluginsDirectory, plugin.LogIgnorePluginLoadErrorFilterFunc) installed, err := plugin.LoadAllDir(settings.PluginsDirectory, plugin.LogIgnorePluginLoadErrorFilterFunc)
if err != nil { if err != nil {
return err return err
} }
var errorPlugins []error var errorPlugins []error
for _, name := range o.names { for name, version := range o.plugins {
if found := findPlugin(plugins, name); found != nil { if found := findPlugin(installed, name); found != nil {
if err := updatePlugin(found); err != nil { if err := updatePlugin(found, version); err != nil {
errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%w)", name, err)) errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%w)", name, err))
} else { } else {
fmt.Fprintf(out, "Updated plugin: %s\n", name) fmt.Fprintf(out, "Updated plugin: %s\n", name)
@ -85,7 +133,14 @@ func (o *pluginUpdateOptions) run(out io.Writer) error {
return nil return nil
} }
func updatePlugin(p plugin.Plugin) error { func parsePluginVersion(arg string) (name, version string) {
if i := strings.LastIndex(arg, "@"); i >= 0 {
return arg[:i], arg[i+1:]
}
return arg, ""
}
func updatePlugin(p plugin.Plugin, version string) error {
exactLocation, err := filepath.EvalSymlinks(p.Dir()) exactLocation, err := filepath.EvalSymlinks(p.Dir())
if err != nil { if err != nil {
return err return err
@ -95,7 +150,7 @@ func updatePlugin(p plugin.Plugin) error {
return err return err
} }
i, err := installer.FindSource(absExactLocation) i, err := installer.FindSource(absExactLocation, version)
if err != nil { if err != nil {
return err return err
} }

@ -0,0 +1,159 @@
/*
Copyright The Helm Authors.
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 cmd
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParsePluginVersion(t *testing.T) {
tests := []struct {
arg string
wantName string
wantVersion string
}{
{"myplugin", "myplugin", ""},
{"myplugin@1.2.3", "myplugin", "1.2.3"},
{"myplugin@v1.2.3", "myplugin", "v1.2.3"},
{"myplugin@", "myplugin", ""},
{"@version", "", "version"},
{"", "", ""},
// LastIndex ensures the last @ is used as delimiter
{"weird@name@1.0", "weird@name", "1.0"},
}
for _, tt := range tests {
t.Run(tt.arg, func(t *testing.T) {
name, version := parsePluginVersion(tt.arg)
assert.Equal(t, tt.wantName, name)
assert.Equal(t, tt.wantVersion, version)
})
}
}
func TestPluginUpdateComplete(t *testing.T) {
tests := []struct {
name string
args []string
wantPlugins map[string]string
wantErr string
}{
{
name: "no args",
args: []string{},
wantErr: "please provide plugin name to update",
},
{
name: "single plugin no version",
args: []string{"myplugin"},
wantPlugins: map[string]string{"myplugin": ""},
},
{
name: "single plugin with inline version",
args: []string{"myplugin@1.2.3"},
wantPlugins: map[string]string{"myplugin": "1.2.3"},
},
{
name: "multiple plugins no versions",
args: []string{"plugin-a", "plugin-b"},
wantPlugins: map[string]string{"plugin-a": "", "plugin-b": ""},
},
{
name: "multiple plugins with inline versions",
args: []string{"plugin-a@1.0.0", "plugin-b@2.0.0"},
wantPlugins: map[string]string{"plugin-a": "1.0.0", "plugin-b": "2.0.0"},
},
{
name: "multiple plugins each with different exact versions",
args: []string{"plugin-a@1.2.3", "plugin-b@2.0.0", "plugin-c@3.0.0"},
wantPlugins: map[string]string{"plugin-a": "1.2.3", "plugin-b": "2.0.0", "plugin-c": "3.0.0"},
},
{
name: "multiple plugins mixed versions",
args: []string{"plugin-a@1.0.0", "plugin-b"},
wantPlugins: map[string]string{"plugin-a": "1.0.0", "plugin-b": ""},
},
{
name: "multiple plugins mixed with latest in the middle",
args: []string{"plugin-a@1.0.0", "plugin-b", "plugin-c@3.0.0"},
wantPlugins: map[string]string{"plugin-a": "1.0.0", "plugin-b": "", "plugin-c": "3.0.0"},
},
{
name: "duplicate plugin name errors",
args: []string{"myplugin@1.0.0", "myplugin@2.0.0"},
wantErr: `plugin "myplugin" specified more than once`,
},
{
name: "empty plugin name errors",
args: []string{"@1.0.0"},
wantErr: `invalid plugin reference "@1.0.0": plugin name must not be empty`,
},
{
name: "v-prefixed version rejected",
args: []string{"myplugin@v1.2.3"},
wantErr: `invalid version "v1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`,
},
{
name: "tilde range version rejected",
args: []string{"myplugin@~1.2"},
wantErr: `invalid version "~1.2" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`,
},
{
name: "caret range version rejected",
args: []string{"myplugin@^1.2.3"},
wantErr: `invalid version "^1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`,
},
{
name: "gte constraint rejected",
args: []string{"myplugin@>=1.0.0"},
wantErr: `invalid version ">=1.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`,
},
{
name: "wildcard version rejected",
args: []string{"myplugin@1.x"},
wantErr: `invalid version "1.x" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`,
},
{
name: "range constraint rejected",
args: []string{"myplugin@>=1.0.0, <2.0.0"},
wantErr: `invalid version ">=1.0.0, <2.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`,
},
{
name: "garbage version rejected",
args: []string{"myplugin@notaversion"},
wantErr: `invalid version "notaversion" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`,
},
{
name: "range rejected among multiple plugins",
args: []string{"plugin-a@1.0.0", "plugin-b@~2.0"},
wantErr: `invalid version "~2.0" for plugin "plugin-b": must be an exact semver version (e.g. 1.2.3)`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := &pluginUpdateOptions{}
err := o.complete(tt.args)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantPlugins, o.plugins)
})
}
}
Loading…
Cancel
Save