diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 8c038b8a9..8f1fd30d0 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,17 +31,29 @@ import ( ) type pluginUpdateOptions struct { - names []string - version string + plugins map[string]string } +const pluginUpdateDesc = `Update one or more Helm plugins. + +An exact version can be supplied per-plugin using the @version syntax: + + helm plugin update myplugin@1.2.3 otherplugin@2.0.0 + helm plugin update myplugin@v1.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 }, @@ -50,7 +64,6 @@ func newPluginUpdateCmd(out io.Writer) *cobra.Command { return o.run(out) }, } - cmd.Flags().StringVar(&o.version, "version", "", "specify a version constraint. If this is not specified, the plugin is updated to the latest version") return cmd } @@ -58,21 +71,39 @@ 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.NewVersion(version); err != nil { + return fmt.Errorf("invalid version %q for plugin %q: must be an exact version (e.g. 1.2.3 or v1.2.3)", version, name) + } + } + 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, o.version); 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) @@ -87,6 +118,13 @@ func (o *pluginUpdateOptions) run(out io.Writer) error { return nil } +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 { diff --git a/pkg/cmd/plugin_update_test.go b/pkg/cmd/plugin_update_test.go new file mode 100644 index 000000000..5c72074ab --- /dev/null +++ b/pkg/cmd/plugin_update_test.go @@ -0,0 +1,154 @@ +/* +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@v1.2.3", "plugin-b@2.0.0", "plugin-c@3.0.0"}, + wantPlugins: map[string]string{"plugin-a": "v1.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: "tilde range version rejected", + args: []string{"myplugin@~1.2"}, + wantErr: `invalid version "~1.2" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.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 version (e.g. 1.2.3 or v1.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 version (e.g. 1.2.3 or v1.2.3)`, + }, + { + name: "wildcard version rejected", + args: []string{"myplugin@1.x"}, + wantErr: `invalid version "1.x" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.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 version (e.g. 1.2.3 or v1.2.3)`, + }, + { + name: "garbage version rejected", + args: []string{"myplugin@notaversion"}, + wantErr: `invalid version "notaversion" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.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 version (e.g. 1.2.3 or v1.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) + }) + } +}