diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index f73197629..44edd8136 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -155,8 +155,8 @@ func NewForSource(source, version string) (installer Installer, err error) { } // FindSource determines the correct Installer for the given source. -func FindSource(location string) (Installer, error) { - installer, err := existingVCSRepo(location) +func FindSource(location, version string) (Installer, error) { + installer, err := existingVCSRepo(location, version) if err != nil && err.Error() == "Cannot detect VCS" { slog.Warn( "cannot get information about plugin source", diff --git a/internal/plugin/installer/vcs_installer.go b/internal/plugin/installer/vcs_installer.go index 3601ec7a8..1e858c9ce 100644 --- a/internal/plugin/installer/vcs_installer.go +++ b/internal/plugin/installer/vcs_installer.go @@ -38,14 +38,15 @@ type VCSInstaller struct { base } -func existingVCSRepo(location string) (Installer, error) { +func existingVCSRepo(location string, version string) (Installer, error) { repo, err := vcs.NewRepo("", location) if err != nil { return nil, err } i := &VCSInstaller{ - Repo: repo, - base: newBase(repo.Remote()), + Repo: repo, + Version: version, + base: newBase(repo.Remote()), } return i, nil } @@ -104,6 +105,17 @@ func (i *VCSInstaller) Update() error { if err := i.Repo.Update(); err != nil { 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()) { return ErrMissingMetadata } diff --git a/internal/plugin/installer/vcs_installer_test.go b/internal/plugin/installer/vcs_installer_test.go index 54f94c724..8846452d6 100644 --- a/internal/plugin/installer/vcs_installer_test.go +++ b/internal/plugin/installer/vcs_installer_test.go @@ -48,6 +48,7 @@ func (r *testRepo) UpdateVersion(version string) error { r.current = version return r.err } +func (r *testRepo) IsDirty() bool { return false } func TestVCSInstaller(t *testing.T) { ensure.HelmHome(t) @@ -96,7 +97,7 @@ func TestVCSInstaller(t *testing.T) { } // Testing FindSource method, expect error because plugin code is not a cloned repository - if _, err := FindSource(i.Path()); err == nil { + if _, err := FindSource(i.Path(), ""); err == nil { t.Fatal("expected error for inability to find plugin source, got none") } else if err.Error() != "cannot get information about plugin source" { t.Fatalf("expected error for inability to find plugin source, got (%v)", err) @@ -158,7 +159,7 @@ func TestVCSInstallerUpdate(t *testing.T) { } // Test FindSource method for positive result - pluginInfo, err := FindSource(i.Path()) + pluginInfo, err := FindSource(i.Path(), "") if err != nil { t.Fatal(err) } @@ -186,3 +187,61 @@ func TestVCSInstallerUpdate(t *testing.T) { t.Fatalf("expected error for plugin modified, got (%v)", err) } } + +func TestVCSInstallerUpdateWithVersion(t *testing.T) { + ensure.HelmHome(t) + + if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); 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) + } +} diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 83ef35107..d63d63213 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -21,7 +21,9 @@ import ( "io" "log/slog" "path/filepath" + "strings" + "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" "helm.sh/helm/v4/internal/plugin" @@ -29,18 +31,39 @@ import ( ) 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 { o := &pluginUpdateOptions{} cmd := &cobra.Command{ - Use: "update ...", + Use: "update ...", Aliases: []string{"up"}, Short: "update one or more Helm plugins", + Long: pluginUpdateDesc, 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 { return o.complete(args) @@ -56,21 +79,43 @@ func (o *pluginUpdateOptions) complete(args []string) error { if len(args) == 0 { return errors.New("please provide plugin name to update") } - o.names = args + + o.plugins = make(map[string]string, len(args)) + + for _, arg := range args { + 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 } func (o *pluginUpdateOptions) run(out io.Writer) error { 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 { return err } var errorPlugins []error - for _, name := range o.names { - if found := findPlugin(plugins, name); found != nil { - if err := updatePlugin(found); err != nil { + for name, version := range o.plugins { + if found := findPlugin(installed, name); found != nil { + if err := updatePlugin(found, version); err != nil { errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%v)", name, err)) } else { fmt.Fprintf(out, "Updated plugin: %s\n", name) @@ -85,7 +130,14 @@ func (o *pluginUpdateOptions) run(out io.Writer) error { 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()) if err != nil { return err @@ -95,7 +147,7 @@ func updatePlugin(p plugin.Plugin) error { return err } - i, err := installer.FindSource(absExactLocation) + i, err := installer.FindSource(absExactLocation, version) if err != nil { return err } diff --git a/pkg/cmd/plugin_update_test.go b/pkg/cmd/plugin_update_test.go new file mode 100644 index 000000000..4458457d2 --- /dev/null +++ b/pkg/cmd/plugin_update_test.go @@ -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) + }) + } +}