From 0fe0a48015751766b52fe5b57999aefd36b3b128 Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Mon, 30 Mar 2026 11:52:24 +0300 Subject: [PATCH] Add plugin management commands Signed-off-by: Stefan Prodan --- cmd/flux/main.go | 2 + cmd/flux/plugin.go | 340 ++++++++++++++++++++++++++++++++++++++++ cmd/flux/plugin_test.go | 265 +++++++++++++++++++++++++++++++ 3 files changed, 607 insertions(+) create mode 100644 cmd/flux/plugin.go create mode 100644 cmd/flux/plugin_test.go diff --git a/cmd/flux/main.go b/cmd/flux/main.go index 815edb17..0e96614d 100644 --- a/cmd/flux/main.go +++ b/cmd/flux/main.go @@ -186,6 +186,8 @@ func main() { // logger, we configure it's logger to do nothing. ctrllog.SetLogger(logr.New(ctrllog.NullLogSink{})) + registerPlugins() + if err := rootCmd.Execute(); err != nil { if err, ok := err.(*RequestError); ok { diff --git a/cmd/flux/plugin.go b/cmd/flux/plugin.go new file mode 100644 index 00000000..44df6385 --- /dev/null +++ b/cmd/flux/plugin.go @@ -0,0 +1,340 @@ +/* +Copyright 2026 The Flux 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 main + +import ( + "fmt" + "runtime" + "strings" + "time" + + "github.com/briandowns/spinner" + "github.com/spf13/cobra" + + "github.com/fluxcd/flux2/v2/internal/plugin" + "github.com/fluxcd/flux2/v2/pkg/printers" +) + +var pluginHandler = plugin.NewHandler() + +var pluginCmd = &cobra.Command{ + Use: "plugin", + Short: "Manage Flux CLI plugins", + Long: `The plugin sub-commands manage Flux CLI plugins.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // No-op: skip root's namespace DNS validation for plugin commands. + return nil + }, +} + +var pluginListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List installed plugins", + Long: `The plugin list command shows all installed plugins with their versions and paths.`, + RunE: pluginListCmdRun, +} + +var pluginInstallCmd = &cobra.Command{ + Use: "install [@]", + Short: "Install a plugin from the catalog", + Long: `The plugin install command downloads and installs a plugin from the Flux plugin catalog. + +Examples: + # Install the latest version + flux plugin install operator + + # Install a specific version + flux plugin install operator@0.45.0`, + Args: cobra.ExactArgs(1), + RunE: pluginInstallCmdRun, +} + +var pluginUninstallCmd = &cobra.Command{ + Use: "uninstall ", + Short: "Uninstall a plugin", + Long: `The plugin uninstall command removes a plugin binary and its receipt from the plugin directory.`, + Args: cobra.ExactArgs(1), + RunE: pluginUninstallCmdRun, +} + +var pluginUpdateCmd = &cobra.Command{ + Use: "update [name]", + Short: "Update installed plugins", + Long: `The plugin update command updates installed plugins to their latest versions. + +Examples: + # Update a single plugin + flux plugin update operator + + # Update all installed plugins + flux plugin update`, + Args: cobra.MaximumNArgs(1), + RunE: pluginUpdateCmdRun, +} + +var pluginSearchCmd = &cobra.Command{ + Use: "search [query]", + Short: "Search the plugin catalog", + Long: `The plugin search command lists available plugins from the Flux plugin catalog.`, + Args: cobra.MaximumNArgs(1), + RunE: pluginSearchCmdRun, +} + +func init() { + pluginCmd.AddCommand(pluginListCmd) + pluginCmd.AddCommand(pluginInstallCmd) + pluginCmd.AddCommand(pluginUninstallCmd) + pluginCmd.AddCommand(pluginUpdateCmd) + pluginCmd.AddCommand(pluginSearchCmd) + rootCmd.AddCommand(pluginCmd) +} + +// builtinCommandNames returns the names of all non-plugin commands on rootCmd. +func builtinCommandNames() []string { + var names []string + for _, c := range rootCmd.Commands() { + if c.GroupID != "plugin" { + names = append(names, c.Name()) + } + } + return names +} + +// registerPlugins scans the plugin directory and registers discovered +// plugins as Cobra subcommands on rootCmd. +func registerPlugins() { + plugins := pluginHandler.Discover(builtinCommandNames()) + if len(plugins) == 0 { + return + } + + if !rootCmd.ContainsGroup("plugin") { + rootCmd.AddGroup(&cobra.Group{ + ID: "plugin", + Title: "Plugin Commands:", + }) + } + + for _, p := range plugins { + cmd := &cobra.Command{ + Use: p.Name, + Short: fmt.Sprintf("Runs the %s plugin", p.Name), + Long: fmt.Sprintf("This command runs the %s plugin.\nUse 'flux %s --help' for full plugin help.", p.Name, p.Name), + DisableFlagParsing: true, + GroupID: "plugin", + ValidArgsFunction: plugin.CompleteFunc(p.Path), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return plugin.Exec(p.Path, args) + }, + } + rootCmd.AddCommand(cmd) + } +} + +func pluginListCmdRun(cmd *cobra.Command, args []string) error { + pluginDir := pluginHandler.PluginDir() + plugins := pluginHandler.Discover(builtinCommandNames()) + if len(plugins) == 0 { + cmd.Println("No plugins found") + return nil + } + + header := []string{"NAME", "VERSION", "PATH"} + var rows [][]string + for _, p := range plugins { + version := "manual" + if receipt := plugin.ReadReceipt(pluginDir, p.Name); receipt != nil { + version = receipt.Version + } + rows = append(rows, []string{p.Name, version, p.Path}) + } + + return printers.TablePrinter(header).Print(cmd.OutOrStdout(), rows) +} + +func pluginInstallCmdRun(cmd *cobra.Command, args []string) error { + nameVersion := args[0] + name, version := parseNameVersion(nameVersion) + + catalogClient := newCatalogClient() + manifest, err := catalogClient.FetchManifest(name) + if err != nil { + return err + } + + pv, err := plugin.ResolveVersion(manifest, version) + if err != nil { + return err + } + + plat, err := plugin.ResolvePlatform(pv, runtime.GOOS, runtime.GOARCH) + if err != nil { + return fmt.Errorf("plugin %q v%s has no binary for %s/%s", name, pv.Version, runtime.GOOS, runtime.GOARCH) + } + + pluginDir := pluginHandler.EnsurePluginDir() + + installer := plugin.NewInstaller() + sp := newPluginSpinner(fmt.Sprintf("installing %s v%s", name, pv.Version)) + sp.Start() + if err := installer.Install(pluginDir, manifest, pv, plat); err != nil { + sp.Stop() + return err + } + sp.Stop() + + logger.Successf("installed %s v%s", name, pv.Version) + return nil +} + +func pluginUninstallCmdRun(cmd *cobra.Command, args []string) error { + name := args[0] + pluginDir := pluginHandler.PluginDir() + + if err := plugin.Uninstall(pluginDir, name); err != nil { + return err + } + + logger.Successf("uninstalled %s", name) + return nil +} + +func pluginUpdateCmdRun(cmd *cobra.Command, args []string) error { + catalogClient := newCatalogClient() + + plugins := pluginHandler.Discover(builtinCommandNames()) + if len(plugins) == 0 { + cmd.Println("No plugins found") + return nil + } + + // If a specific plugin is requested, filter to just that one. + if len(args) == 1 { + name := args[0] + var found bool + for _, p := range plugins { + if p.Name == name { + plugins = []plugin.Plugin{p} + found = true + break + } + } + if !found { + return fmt.Errorf("plugin %q is not installed", name) + } + } + + pluginDir := pluginHandler.EnsurePluginDir() + installer := plugin.NewInstaller() + for _, p := range plugins { + result := plugin.CheckUpdate(pluginDir, p.Name, catalogClient, runtime.GOOS, runtime.GOARCH) + if result.Err != nil { + logger.Failuref("error checking %s: %v", p.Name, result.Err) + continue + } + if result.Skipped { + if result.SkipReason == plugin.SkipReasonManual { + logger.Warningf("skipping %s (%s)", p.Name, result.SkipReason) + } else { + logger.Successf("%s already up to date (v%s)", p.Name, result.FromVersion) + } + continue + } + + sp := newPluginSpinner(fmt.Sprintf("updating %s v%s → v%s", p.Name, result.FromVersion, result.ToVersion)) + sp.Start() + if err := installer.Install(pluginDir, result.Manifest, result.Version, result.Platform); err != nil { + sp.Stop() + logger.Failuref("error updating %s: %v", p.Name, err) + continue + } + sp.Stop() + logger.Successf("updated %s v%s → v%s", p.Name, result.FromVersion, result.ToVersion) + } + + return nil +} + +func pluginSearchCmdRun(cmd *cobra.Command, args []string) error { + catalogClient := newCatalogClient() + catalog, err := catalogClient.FetchCatalog() + if err != nil { + return err + } + + var query string + if len(args) == 1 { + query = strings.ToLower(args[0]) + } + + pluginDir := pluginHandler.PluginDir() + header := []string{"NAME", "DESCRIPTION", "INSTALLED"} + var rows [][]string + for _, entry := range catalog.Plugins { + if query != "" { + if !strings.Contains(strings.ToLower(entry.Name), query) && + !strings.Contains(strings.ToLower(entry.Description), query) { + continue + } + } + + installed := "" + if receipt := plugin.ReadReceipt(pluginDir, entry.Name); receipt != nil { + installed = receipt.Version + } + + rows = append(rows, []string{entry.Name, entry.Description, installed}) + } + + if len(rows) == 0 { + if query != "" { + cmd.Printf("No plugins matching %q found in catalog\n", query) + } else { + cmd.Println("No plugins found in catalog") + } + return nil + } + + return printers.TablePrinter(header).Print(cmd.OutOrStdout(), rows) +} + +// parseNameVersion splits "operator@0.45.0" into ("operator", "0.45.0"). +// If no @ is present, version is empty (latest). +func parseNameVersion(s string) (string, string) { + name, version, found := strings.Cut(s, "@") + if found { + return name, version + } + return s, "" +} + +// newCatalogClient creates a CatalogClient that respects FLUXCD_PLUGIN_CATALOG. +func newCatalogClient() *plugin.CatalogClient { + client := plugin.NewCatalogClient() + client.GetEnv = pluginHandler.GetEnv + return client +} + +func newPluginSpinner(message string) *spinner.Spinner { + s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + s.Suffix = " " + message + return s +} diff --git a/cmd/flux/plugin_test.go b/cmd/flux/plugin_test.go new file mode 100644 index 00000000..479e1826 --- /dev/null +++ b/cmd/flux/plugin_test.go @@ -0,0 +1,265 @@ +/* +Copyright 2026 The Flux 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 main + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/fluxcd/flux2/v2/internal/plugin" +) + +func TestPluginAppearsInHelp(t *testing.T) { + origHandler := pluginHandler + defer func() { pluginHandler = origHandler }() + + pluginDir := t.TempDir() + + fakeBin := pluginDir + "/flux-testplugin" + os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755) + + pluginHandler = &plugin.Handler{ + ReadDir: os.ReadDir, + Stat: os.Stat, + GetEnv: func(key string) string { + if key == "FLUXCD_PLUGINS" { + return pluginDir + } + return "" + }, + HomeDir: func() (string, error) { return t.TempDir(), nil }, + } + + registerPlugins() + defer func() { + cmds := rootCmd.Commands() + for _, cmd := range cmds { + if cmd.Name() == "testplugin" { + rootCmd.RemoveCommand(cmd) + break + } + } + rootCmd.SetHelpTemplate("") + }() + + output, err := executeCommand("--help") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(output, "Plugin Commands:") { + t.Error("expected 'Plugin Commands:' in help output") + } + if !strings.Contains(output, "testplugin") { + t.Error("expected 'testplugin' in help output") + } +} + +func TestPluginListOutput(t *testing.T) { + origHandler := pluginHandler + defer func() { pluginHandler = origHandler }() + + pluginDir := t.TempDir() + + fakeBin := pluginDir + "/flux-myplugin" + os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755) + + pluginHandler = &plugin.Handler{ + ReadDir: os.ReadDir, + Stat: os.Stat, + GetEnv: func(key string) string { + if key == "FLUXCD_PLUGINS" { + return pluginDir + } + return "" + }, + HomeDir: func() (string, error) { return t.TempDir(), nil }, + } + + output, err := executeCommand("plugin list") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(output, "myplugin") { + t.Errorf("expected 'myplugin' in output, got: %s", output) + } + if !strings.Contains(output, "manual") { + t.Errorf("expected 'manual' in output (no receipt), got: %s", output) + } +} + +func TestPluginListWithReceipt(t *testing.T) { + origHandler := pluginHandler + defer func() { pluginHandler = origHandler }() + + pluginDir := t.TempDir() + + fakeBin := pluginDir + "/flux-myplugin" + os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755) + receipt := pluginDir + "/flux-myplugin.yaml" + os.WriteFile(receipt, []byte("name: myplugin\nversion: \"1.2.3\"\n"), 0o644) + + pluginHandler = &plugin.Handler{ + ReadDir: os.ReadDir, + Stat: os.Stat, + GetEnv: func(key string) string { + if key == "FLUXCD_PLUGINS" { + return pluginDir + } + return "" + }, + HomeDir: func() (string, error) { return t.TempDir(), nil }, + } + + output, err := executeCommand("plugin list") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(output, "1.2.3") { + t.Errorf("expected version '1.2.3' in output, got: %s", output) + } +} + +func TestPluginListEmpty(t *testing.T) { + origHandler := pluginHandler + defer func() { pluginHandler = origHandler }() + + pluginDir := t.TempDir() + + pluginHandler = &plugin.Handler{ + ReadDir: os.ReadDir, + Stat: os.Stat, + GetEnv: func(key string) string { + if key == "FLUXCD_PLUGINS" { + return pluginDir + } + return "" + }, + HomeDir: func() (string, error) { return t.TempDir(), nil }, + } + + output, err := executeCommand("plugin list") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(output, "No plugins found") { + t.Errorf("expected 'No plugins found', got: %s", output) + } +} + +func TestNoPluginsNoRegistration(t *testing.T) { + origHandler := pluginHandler + defer func() { pluginHandler = origHandler }() + + pluginHandler = &plugin.Handler{ + ReadDir: func(name string) ([]os.DirEntry, error) { + return nil, fmt.Errorf("no dir") + }, + Stat: os.Stat, + GetEnv: func(key string) string { + if key == "FLUXCD_PLUGINS" { + return "/nonexistent" + } + return "" + }, + HomeDir: func() (string, error) { return t.TempDir(), nil }, + } + + // Verify that registerPlugins with no plugins doesn't add any commands. + before := len(rootCmd.Commands()) + registerPlugins() + after := len(rootCmd.Commands()) + if after != before { + t.Errorf("expected no new commands, got %d new", after-before) + } +} + +func TestPluginSkipsPersistentPreRun(t *testing.T) { + // Plugin commands override root's PersistentPreRunE with a no-op, + // so an invalid namespace should not trigger a validation error. + _, err := executeCommand("plugin list") + if err != nil { + t.Fatalf("plugin list should not trigger root's namespace validation: %v", err) + } +} + +func TestParseNameVersion(t *testing.T) { + tests := []struct { + input string + wantName string + wantVersion string + }{ + {"operator", "operator", ""}, + {"operator@0.45.0", "operator", "0.45.0"}, + {"my-tool@1.0.0", "my-tool", "1.0.0"}, + {"plugin@", "plugin", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + name, version := parseNameVersion(tt.input) + if name != tt.wantName { + t.Errorf("name: got %q, want %q", name, tt.wantName) + } + if version != tt.wantVersion { + t.Errorf("version: got %q, want %q", version, tt.wantVersion) + } + }) + } +} + +func TestPluginDiscoverSkipsBuiltins(t *testing.T) { + origHandler := pluginHandler + defer func() { pluginHandler = origHandler }() + + pluginDir := t.TempDir() + + for _, name := range []string{"flux-get", "flux-create", "flux-version"} { + os.WriteFile(pluginDir+"/"+name, []byte("#!/bin/sh"), 0o755) + } + os.WriteFile(pluginDir+"/flux-myplugin", []byte("#!/bin/sh"), 0o755) + + pluginHandler = &plugin.Handler{ + ReadDir: os.ReadDir, + Stat: os.Stat, + GetEnv: func(key string) string { + if key == "FLUXCD_PLUGINS" { + return pluginDir + } + return "" + }, + HomeDir: func() (string, error) { return t.TempDir(), nil }, + } + + plugins := pluginHandler.Discover(builtinCommandNames()) + + if len(plugins) != 1 { + names := make([]string, len(plugins)) + for i, p := range plugins { + names[i] = p.Name + } + t.Fatalf("expected 1 plugin, got %d: %v", len(plugins), names) + } + if plugins[0].Name != "myplugin" { + t.Errorf("expected 'myplugin', got %q", plugins[0].Name) + } +}