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/main_test.go b/cmd/flux/main_test.go index 78159920..eeb3de80 100644 --- a/cmd/flux/main_test.go +++ b/cmd/flux/main_test.go @@ -374,6 +374,12 @@ func executeCommand(cmd string) (string, error) { // in subsequent executions which causes tests to fail that rely on the value // of "Changed". resumeCmd.PersistentFlags().Lookup("wait").Changed = false + // Reset the help flag value and Changed state so that a prior + // "--help" invocation does not leak into subsequent test runs. + if hf := rootCmd.Flags().Lookup("help"); hf != nil { + hf.Value.Set("false") + hf.Changed = false + } }() args, err := shellwords.Parse(cmd) if err != nil { diff --git a/cmd/flux/plugin.go b/cmd/flux/plugin.go new file mode 100644 index 00000000..efeb3df3 --- /dev/null +++ b/cmd/flux/plugin.go @@ -0,0 +1,112 @@ +/* +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" + "strings" + "time" + + "github.com/briandowns/spinner" + "github.com/spf13/cobra" + + "github.com/fluxcd/flux2/v2/internal/plugin" +) + +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 + }, +} + +func init() { + 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) + } +} + +// 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_install.go b/cmd/flux/plugin_install.go new file mode 100644 index 00000000..71b9b643 --- /dev/null +++ b/cmd/flux/plugin_install.go @@ -0,0 +1,80 @@ +/* +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" + + "github.com/spf13/cobra" + + "github.com/fluxcd/flux2/v2/internal/plugin" +) + +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, +} + +func init() { + pluginCmd.AddCommand(pluginInstallCmd) +} + +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 +} diff --git a/cmd/flux/plugin_list.go b/cmd/flux/plugin_list.go new file mode 100644 index 00000000..cdc58ddc --- /dev/null +++ b/cmd/flux/plugin_list.go @@ -0,0 +1,57 @@ +/* +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 ( + "github.com/spf13/cobra" + + "github.com/fluxcd/flux2/v2/internal/plugin" + "github.com/fluxcd/flux2/v2/pkg/printers" +) + +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, +} + +func init() { + pluginCmd.AddCommand(pluginListCmd) +} + +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) +} diff --git a/cmd/flux/plugin_search.go b/cmd/flux/plugin_search.go new file mode 100644 index 00000000..0c9add19 --- /dev/null +++ b/cmd/flux/plugin_search.go @@ -0,0 +1,81 @@ +/* +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 ( + "strings" + + "github.com/spf13/cobra" + + "github.com/fluxcd/flux2/v2/internal/plugin" + "github.com/fluxcd/flux2/v2/pkg/printers" +) + +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(pluginSearchCmd) +} + +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) +} diff --git a/cmd/flux/plugin_test.go b/cmd/flux/plugin_test.go new file mode 100644 index 00000000..90f1b482 --- /dev/null +++ b/cmd/flux/plugin_test.go @@ -0,0 +1,264 @@ +/* +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 + } + } + }() + + 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) + } +} diff --git a/cmd/flux/plugin_uninstall.go b/cmd/flux/plugin_uninstall.go new file mode 100644 index 00000000..844f6d5a --- /dev/null +++ b/cmd/flux/plugin_uninstall.go @@ -0,0 +1,48 @@ +/* +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 ( + "github.com/spf13/cobra" + + "github.com/fluxcd/flux2/v2/internal/plugin" +) + +var pluginUninstallCmd = &cobra.Command{ + Use: "uninstall ", + Aliases: []string{"delete"}, + 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, +} + +func init() { + pluginCmd.AddCommand(pluginUninstallCmd) +} + +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 +} diff --git a/cmd/flux/plugin_update.go b/cmd/flux/plugin_update.go new file mode 100644 index 00000000..db3a5e0e --- /dev/null +++ b/cmd/flux/plugin_update.go @@ -0,0 +1,102 @@ +/* +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" + + "github.com/spf13/cobra" + + "github.com/fluxcd/flux2/v2/internal/plugin" +) + +var pluginUpdateCmd = &cobra.Command{ + Use: "update [name]", + Aliases: []string{"upgrade"}, + 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, +} + +func init() { + pluginCmd.AddCommand(pluginUpdateCmd) +} + +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 +} diff --git a/go.mod b/go.mod index 5563d076..bdc6f6b3 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1 require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/ProtonMail/go-crypto v1.3.0 + github.com/briandowns/spinner v1.23.2 github.com/cyphar/filepath-securejoin v0.6.1 github.com/distribution/distribution/v3 v3.1.0 github.com/fluxcd/cli-utils v0.37.2-flux.1 @@ -40,6 +41,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.20.7 github.com/hashicorp/go-cleanhttp v0.5.2 + github.com/hashicorp/go-retryablehttp v0.7.8 github.com/homeport/dyff v1.10.2 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/manifoldco/promptui v0.9.0 @@ -49,7 +51,6 @@ require ( github.com/onsi/gomega v1.39.1 github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 github.com/spf13/cobra v1.10.2 - github.com/theckman/yacspin v0.13.12 golang.org/x/crypto v0.50.0 golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 @@ -162,7 +163,6 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect diff --git a/go.sum b/go.sum index 843d2366..30e6b07e 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= +github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/bshuster-repo/logrus-logstash-hook v1.1.0 h1:o2FzZifLg+z/DN1OFmzTWzZZx/roaqt8IPZCIVco8r4= github.com/bshuster-repo/logrus-logstash-hook v1.1.0/go.mod h1:Q2aXOe7rNuPgbBtPCOzYyWDvKX7+FpxE5sRdvcPoui0= github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= @@ -538,8 +540,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U= github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8= -github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= -github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo= diff --git a/internal/build/build.go b/internal/build/build.go index 7010c3b6..17cfb65e 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -30,7 +30,7 @@ import ( "sync" "time" - "github.com/theckman/yacspin" + "github.com/briandowns/spinner" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -141,7 +141,7 @@ type Builder struct { action kustomize.Action kustomization *kustomizev1.Kustomization timeout time.Duration - spinner *yacspin.Spinner + spinner *spinner.Spinner dryRun bool strictSubst bool recursive bool @@ -173,22 +173,9 @@ func WithTimeout(timeout time.Duration) BuilderOptionFunc { func WithProgressBar() BuilderOptionFunc { return func(b *Builder) error { - // Add a spinner - cfg := yacspin.Config{ - Frequency: 100 * time.Millisecond, - CharSet: yacspin.CharSets[59], - Suffix: "Kustomization diffing...", - SuffixAutoColon: true, - Message: spinnerDryRunMessage, - StopCharacter: "✓", - StopColors: []string{"fgGreen"}, - } - spinner, err := yacspin.New(cfg) - if err != nil { - return fmt.Errorf("failed to create spinner: %w", err) - } - b.spinner = spinner - + s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + s.Suffix = " Kustomization diffing... " + spinnerDryRunMessage + b.spinner = s return nil } } @@ -296,7 +283,7 @@ func withClientConfigFrom(in *Builder) BuilderOptionFunc { } } -// withClientConfigFrom copies spinner field +// withSpinnerFrom copies the spinner field from another Builder. func withSpinnerFrom(in *Builder) BuilderOptionFunc { return func(b *Builder) error { b.spinner = in.spinner @@ -838,12 +825,7 @@ func (b *Builder) StartSpinner() error { if b.spinner == nil { return nil } - - err := b.spinner.Start() - if err != nil { - return fmt.Errorf("failed to start spinner: %w", err) - } - + b.spinner.Start() return nil } @@ -851,14 +833,6 @@ func (b *Builder) StopSpinner() error { if b.spinner == nil { return nil } - - status := b.spinner.Status() - if status == yacspin.SpinnerRunning || status == yacspin.SpinnerPaused { - err := b.spinner.Stop() - if err != nil { - return fmt.Errorf("failed to stop spinner: %w", err) - } - } - + b.spinner.Stop() return nil } diff --git a/internal/build/diff.go b/internal/build/diff.go index 8884e57f..72063d9f 100644 --- a/internal/build/diff.go +++ b/internal/build/diff.go @@ -173,14 +173,14 @@ func (b *Builder) diff() (string, bool, error) { // finished with Kustomization diff if b.spinner != nil { - b.spinner.Message(spinnerDryRunMessage) + b.spinner.Suffix = " " + spinnerDryRunMessage } } } } if b.spinner != nil { - b.spinner.Message("processing inventory") + b.spinner.Suffix = " processing inventory" } if b.kustomization.Spec.Prune && len(diffErrs) == 0 { @@ -204,7 +204,7 @@ func (b *Builder) diff() (string, bool, error) { func (b *Builder) kustomizationDiff(kustomization *kustomizev1.Kustomization) (string, bool, error) { if b.spinner != nil { - b.spinner.Message(fmt.Sprintf("%s in %s", spinnerDryRunMessage, kustomization.Name)) + b.spinner.Suffix = " " + fmt.Sprintf("%s in %s", spinnerDryRunMessage, kustomization.Name) } sourceRef := kustomization.Spec.SourceRef.DeepCopy() diff --git a/internal/plugin/catalog.go b/internal/plugin/catalog.go new file mode 100644 index 00000000..019bfada --- /dev/null +++ b/internal/plugin/catalog.go @@ -0,0 +1,167 @@ +/* +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 plugin + +import ( + "fmt" + "io" + "net/http" + "time" + + "github.com/hashicorp/go-retryablehttp" + "sigs.k8s.io/yaml" + + plugintypes "github.com/fluxcd/flux2/v2/pkg/plugin" +) + +const ( + // defaultCatalogBase points at the latest GitHub release of fluxcd/plugins. + defaultCatalogBase = "https://github.com/fluxcd/plugins/releases/latest/download/" + envCatalogBase = "FLUXCD_PLUGIN_CATALOG" +) + +// CatalogClient fetches plugin manifests and catalogs from a remote URL. +type CatalogClient struct { + // BaseURL is the catalog base URL for fetching manifests. + BaseURL string + + // HTTPClient is the HTTP client used for catalog requests. + HTTPClient *http.Client + + // GetEnv returns the value of an environment variable. + GetEnv func(key string) string +} + +// NewCatalogClient returns a CatalogClient with production defaults. +func NewCatalogClient() *CatalogClient { + return &CatalogClient{ + BaseURL: defaultCatalogBase, + HTTPClient: newHTTPClient(30 * time.Second), + GetEnv: func(key string) string { return "" }, + } +} + +// baseURL returns the effective catalog base URL. +func (c *CatalogClient) baseURL() string { + if env := c.GetEnv(envCatalogBase); env != "" { + return env + } + return c.BaseURL +} + +// FetchManifest fetches a single plugin manifest from the catalog. +func (c *CatalogClient) FetchManifest(name string) (*plugintypes.Manifest, error) { + url := c.baseURL() + name + ".yaml" + body, err := c.fetch(url) + if err != nil { + return nil, fmt.Errorf("plugin %q not found in catalog", name) + } + + var manifest plugintypes.Manifest + if err := yaml.Unmarshal(body, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse plugin manifest for %q: %w", name, err) + } + + if manifest.APIVersion != plugintypes.APIVersion { + return nil, fmt.Errorf("plugin %q has unsupported apiVersion %q (expected %q)", name, manifest.APIVersion, plugintypes.APIVersion) + } + if manifest.Kind != plugintypes.PluginKind { + return nil, fmt.Errorf("plugin %q has unexpected kind %q (expected %q)", name, manifest.Kind, plugintypes.PluginKind) + } + + return &manifest, nil +} + +// FetchCatalog fetches the generated catalog.yaml. +func (c *CatalogClient) FetchCatalog() (*plugintypes.Catalog, error) { + url := c.baseURL() + "catalog.yaml" + body, err := c.fetch(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch plugin catalog: %w", err) + } + + var catalog plugintypes.Catalog + if err := yaml.Unmarshal(body, &catalog); err != nil { + return nil, fmt.Errorf("failed to parse plugin catalog: %w", err) + } + + if catalog.APIVersion != plugintypes.APIVersion { + return nil, fmt.Errorf("plugin catalog has unsupported apiVersion %q (expected %q)", catalog.APIVersion, plugintypes.APIVersion) + } + if catalog.Kind != plugintypes.CatalogKind { + return nil, fmt.Errorf("plugin catalog has unexpected kind %q (expected %q)", catalog.Kind, plugintypes.CatalogKind) + } + + return &catalog, nil +} + +const maxResponseBytes = 10 << 20 // 10 MiB + +func (c *CatalogClient) fetch(url string) ([]byte, error) { + resp, err := c.HTTPClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + + return io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) +} + +// newHTTPClient returns a retrying HTTP client with the given timeout. +func newHTTPClient(timeout time.Duration) *http.Client { + rc := retryablehttp.NewClient() + rc.RetryMax = 3 + rc.Logger = nil + c := rc.StandardClient() + c.Timeout = timeout + return c +} + +// ResolveVersion finds the requested version in the manifest. +// If version is empty, returns the first (latest) version. +func ResolveVersion(manifest *plugintypes.Manifest, version string) (*plugintypes.Version, error) { + if len(manifest.Versions) == 0 { + return nil, fmt.Errorf("plugin %q has no versions", manifest.Name) + } + + if version == "" { + return &manifest.Versions[0], nil + } + + for i := range manifest.Versions { + if manifest.Versions[i].Version == version { + return &manifest.Versions[i], nil + } + } + + return nil, fmt.Errorf("version %q not found for plugin %q", version, manifest.Name) +} + +// ResolvePlatform finds the platform entry matching the given OS and arch. +func ResolvePlatform(pv *plugintypes.Version, goos, goarch string) (*plugintypes.Platform, error) { + for i := range pv.Platforms { + if pv.Platforms[i].OS == goos && pv.Platforms[i].Arch == goarch { + return &pv.Platforms[i], nil + } + } + + return nil, fmt.Errorf("no binary for %s/%s", goos, goarch) +} diff --git a/internal/plugin/catalog_test.go b/internal/plugin/catalog_test.go new file mode 100644 index 00000000..e6134fc6 --- /dev/null +++ b/internal/plugin/catalog_test.go @@ -0,0 +1,241 @@ +/* +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 plugin + +import ( + "net/http" + "net/http/httptest" + "testing" + + plugintypes "github.com/fluxcd/flux2/v2/pkg/plugin" +) + +func TestFetchManifest(t *testing.T) { + manifest := ` +apiVersion: cli.fluxcd.io/v1beta1 +kind: Plugin +name: operator +description: Flux Operator CLI +bin: flux-operator +versions: + - version: 0.45.0 + platforms: + - os: linux + arch: amd64 + url: https://example.com/flux-operator_0.45.0_linux_amd64.tar.gz + checksum: sha256:abc123 +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/operator.yaml" { + w.Write([]byte(manifest)) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client := &CatalogClient{ + BaseURL: server.URL + "/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { return "" }, + } + + m, err := client.FetchManifest("operator") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.Name != "operator" { + t.Errorf("expected name 'operator', got %q", m.Name) + } + if m.Bin != "flux-operator" { + t.Errorf("expected bin 'flux-operator', got %q", m.Bin) + } + if len(m.Versions) != 1 { + t.Fatalf("expected 1 version, got %d", len(m.Versions)) + } + if m.Versions[0].Version != "0.45.0" { + t.Errorf("expected version '0.45.0', got %q", m.Versions[0].Version) + } +} + +func TestFetchManifestNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + client := &CatalogClient{ + BaseURL: server.URL + "/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { return "" }, + } + + _, err := client.FetchManifest("nonexistent") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestFetchCatalog(t *testing.T) { + catalog := ` +apiVersion: cli.fluxcd.io/v1beta1 +kind: PluginCatalog +plugins: + - name: operator + description: Flux Operator CLI + homepage: https://fluxoperator.dev/ + source: https://github.com/controlplaneio-fluxcd/flux-operator + license: AGPL-3.0 + - name: schema + description: CRD schemas + homepage: https://example.com/ + source: https://github.com/example/flux-schema + license: Apache-2.0 +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/catalog.yaml" { + w.Write([]byte(catalog)) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client := &CatalogClient{ + BaseURL: server.URL + "/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { return "" }, + } + + c, err := client.FetchCatalog() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(c.Plugins) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(c.Plugins)) + } + if c.Plugins[0].Name != "operator" { + t.Errorf("expected name 'operator', got %q", c.Plugins[0].Name) + } + if c.Plugins[1].Name != "schema" { + t.Errorf("expected name 'schema', got %q", c.Plugins[1].Name) + } +} + +func TestCatalogEnvOverride(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/custom/catalog.yaml" { + w.Write([]byte(`apiVersion: cli.fluxcd.io/v1beta1 +kind: PluginCatalog +plugins: [] +`)) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client := &CatalogClient{ + BaseURL: "https://should-not-be-used/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { + if key == envCatalogBase { + return server.URL + "/custom/" + } + return "" + }, + } + + c, err := client.FetchCatalog() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(c.Plugins) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(c.Plugins)) + } +} + +func TestResolveVersion(t *testing.T) { + manifest := &plugintypes.Manifest{ + Name: "operator", + Versions: []plugintypes.Version{ + {Version: "0.45.0"}, + {Version: "0.44.0"}, + }, + } + + t.Run("latest", func(t *testing.T) { + v, err := ResolveVersion(manifest, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.Version != "0.45.0" { + t.Errorf("expected '0.45.0', got %q", v.Version) + } + }) + + t.Run("specific", func(t *testing.T) { + v, err := ResolveVersion(manifest, "0.44.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.Version != "0.44.0" { + t.Errorf("expected '0.44.0', got %q", v.Version) + } + }) + + t.Run("not found", func(t *testing.T) { + _, err := ResolveVersion(manifest, "0.99.0") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("no versions", func(t *testing.T) { + _, err := ResolveVersion(&plugintypes.Manifest{Name: "empty"}, "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestResolvePlatform(t *testing.T) { + pv := &plugintypes.Version{ + Version: "0.45.0", + Platforms: []plugintypes.Platform{ + {OS: "darwin", Arch: "arm64", URL: "https://example.com/darwin_arm64.tar.gz"}, + {OS: "linux", Arch: "amd64", URL: "https://example.com/linux_amd64.tar.gz"}, + }, + } + + t.Run("found", func(t *testing.T) { + p, err := ResolvePlatform(pv, "darwin", "arm64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.OS != "darwin" || p.Arch != "arm64" { + t.Errorf("unexpected platform: %s/%s", p.OS, p.Arch) + } + }) + + t.Run("not found", func(t *testing.T) { + _, err := ResolvePlatform(pv, "windows", "amd64") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/internal/plugin/completion.go b/internal/plugin/completion.go new file mode 100644 index 00000000..73e31d84 --- /dev/null +++ b/internal/plugin/completion.go @@ -0,0 +1,75 @@ +/* +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 plugin + +import ( + "os/exec" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +// commandFunc is an alias to allow DI in tests. +var commandFunc = exec.Command + +// CompleteFunc returns a ValidArgsFunction that delegates completion +// to the plugin binary via Cobra's __complete protocol. +func CompleteFunc(pluginPath string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + completeArgs := append([]string{"__complete"}, args...) + completeArgs = append(completeArgs, toComplete) + + out, err := commandFunc(pluginPath, completeArgs...).Output() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return parseCompletionOutput(string(out)) + } +} + +// parseCompletionOutput parses Cobra's __complete output format. +// Each line is a completion, last line is :. +func parseCompletionOutput(out string) ([]string, cobra.ShellCompDirective) { + out = strings.TrimRight(out, "\n") + if out == "" { + return nil, cobra.ShellCompDirectiveError + } + lines := strings.Split(out, "\n") + + // Last line is the directive in format ":N" + lastLine := lines[len(lines)-1] + completions := lines[:len(lines)-1] + + directive := cobra.ShellCompDirectiveDefault + if strings.HasPrefix(lastLine, ":") { + if val, err := strconv.Atoi(lastLine[1:]); err == nil { + directive = cobra.ShellCompDirective(val) + } + } + + var results []string + for _, c := range completions { + if c == "" { + continue + } + results = append(results, c) + } + + return results, directive +} diff --git a/internal/plugin/completion_test.go b/internal/plugin/completion_test.go new file mode 100644 index 00000000..fb96dd56 --- /dev/null +++ b/internal/plugin/completion_test.go @@ -0,0 +1,80 @@ +/* +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 plugin + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestParseCompletionOutput(t *testing.T) { + tests := []struct { + name string + input string + expectedCompletions []string + expectedDirective cobra.ShellCompDirective + }{ + { + name: "standard output", + input: "instance\nrset\nrsip\nall\n:4\n", + expectedCompletions: []string{"instance", "rset", "rsip", "all"}, + expectedDirective: cobra.ShellCompDirective(4), + }, + { + name: "default directive", + input: "foo\nbar\n:0\n", + expectedCompletions: []string{"foo", "bar"}, + expectedDirective: cobra.ShellCompDirectiveDefault, + }, + { + name: "with descriptions", + input: "get\tGet resources\nbuild\tBuild resources\n:4\n", + expectedCompletions: []string{"get\tGet resources", "build\tBuild resources"}, + expectedDirective: cobra.ShellCompDirective(4), + }, + { + name: "empty completions", + input: ":4\n", + expectedCompletions: nil, + expectedDirective: cobra.ShellCompDirective(4), + }, + { + name: "empty input", + input: "", + expectedCompletions: nil, + expectedDirective: cobra.ShellCompDirectiveError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + completions, directive := parseCompletionOutput(tt.input) + if directive != tt.expectedDirective { + t.Errorf("directive: got %d, want %d", directive, tt.expectedDirective) + } + if len(completions) != len(tt.expectedCompletions) { + t.Fatalf("completions count: got %d, want %d", len(completions), len(tt.expectedCompletions)) + } + for i, c := range completions { + if c != tt.expectedCompletions[i] { + t.Errorf("completion[%d]: got %q, want %q", i, c, tt.expectedCompletions[i]) + } + } + }) + } +} diff --git a/internal/plugin/discovery.go b/internal/plugin/discovery.go new file mode 100644 index 00000000..a8ab37a3 --- /dev/null +++ b/internal/plugin/discovery.go @@ -0,0 +1,205 @@ +/* +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 plugin + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +const ( + pluginPrefix = "flux-" + defaultDirName = "plugins" + defaultBaseDir = ".fluxcd" + envPluginDir = "FLUXCD_PLUGINS" +) + +// reservedNames are command names that cannot be used as plugin names. +var reservedNames = map[string]bool{ + "plugin": true, + "help": true, +} + +// Plugin represents a discovered plugin binary. +type Plugin struct { + // Name is the plugin name, e.g. "operator" (derived from "flux-operator"). + Name string + + // Path is the absolute path to the plugin binary. + Path string +} + +// Handler discovers and executes plugins. Uses dependency injection +// for testability. +type Handler struct { + // ReadDir lists directory entries. + ReadDir func(name string) ([]os.DirEntry, error) + + // Stat returns file info, following symlinks. + Stat func(name string) (os.FileInfo, error) + + // GetEnv returns the value of an environment variable. + GetEnv func(key string) string + + // HomeDir returns the current user's home directory. + HomeDir func() (string, error) +} + +// NewHandler returns a Handler with production defaults. +func NewHandler() *Handler { + return &Handler{ + ReadDir: os.ReadDir, + Stat: os.Stat, + GetEnv: os.Getenv, + HomeDir: os.UserHomeDir, + } +} + +// Discover scans the plugin directory for executables matching flux-*. +// It skips builtins, reserved names, directories, non-executable files, +// and broken symlinks. +func (h *Handler) Discover(builtinNames []string) []Plugin { + dir := h.PluginDir() + if dir == "" { + return nil + } + + entries, err := h.ReadDir(dir) + if err != nil { + return nil + } + + builtins := make(map[string]bool, len(builtinNames)) + for _, name := range builtinNames { + builtins[name] = true + } + + var plugins []Plugin + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, pluginPrefix) { + continue + } + if entry.IsDir() { + continue + } + + pluginName := pluginNameFromBinary(name) + if pluginName == "" { + continue + } + if reservedNames[pluginName] || builtins[pluginName] { + continue + } + + fullPath := filepath.Join(dir, name) + + // Use Stat to follow symlinks and check the target. + info, err := h.Stat(fullPath) + if err != nil { + // Broken symlink, permission denied, etc. + continue + } + if !info.Mode().IsRegular() { + continue + } + if !isExecutable(info) { + continue + } + + plugins = append(plugins, Plugin{ + Name: pluginName, + Path: fullPath, + }) + } + + return plugins +} + +// PluginDir returns the plugin directory path. If FLUXCD_PLUGINS is set, +// returns that path. Otherwise returns ~/.fluxcd/plugins/. +// Does not create the directory — callers that write (install, update) +// should call EnsurePluginDir first. +func (h *Handler) PluginDir() string { + if dir := h.GetEnv(envPluginDir); dir != "" { + return dir + } + + home, err := h.HomeDir() + if err != nil { + return "" + } + + return filepath.Join(home, defaultBaseDir, defaultDirName) +} + +// EnsurePluginDir creates the plugin directory if it doesn't exist +// and returns the path. Best-effort — ignores mkdir errors for +// read-only filesystems. User-managed directories (via $FLUXCD_PLUGINS) +// are not auto-created. +func (h *Handler) EnsurePluginDir() string { + if envDir := h.GetEnv(envPluginDir); envDir != "" { + return envDir + } + + home, err := h.HomeDir() + if err != nil { + return "" + } + + dir := filepath.Join(home, defaultBaseDir, defaultDirName) + _ = os.MkdirAll(dir, 0o755) + return dir +} + +// pluginNameFromBinary extracts the plugin name from a binary filename. +// "flux-operator" → "operator", "flux-my-tool" → "my-tool". +// Returns empty string for invalid names. +func pluginNameFromBinary(filename string) string { + if !strings.HasPrefix(filename, pluginPrefix) { + return "" + } + + name := strings.TrimPrefix(filename, pluginPrefix) + + // On Windows, strip known extensions. + if runtime.GOOS == "windows" { + for _, ext := range []string{".exe", ".cmd", ".bat"} { + if strings.HasSuffix(strings.ToLower(name), ext) { + name = name[:len(name)-len(ext)] + break + } + } + } + + if name == "" { + return "" + } + + return name +} + +// isExecutable checks if a file has the executable bit set. +// On Windows, this always returns true (executability is determined by extension). +func isExecutable(info os.FileInfo) bool { + if runtime.GOOS == "windows" { + return true + } + return info.Mode().Perm()&0o111 != 0 +} diff --git a/internal/plugin/discovery_test.go b/internal/plugin/discovery_test.go new file mode 100644 index 00000000..83021c08 --- /dev/null +++ b/internal/plugin/discovery_test.go @@ -0,0 +1,302 @@ +/* +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 plugin + +import ( + "fmt" + "io/fs" + "os" + "testing" + "time" +) + +// mockDirEntry implements os.DirEntry for testing. +type mockDirEntry struct { + name string + isDir bool + mode fs.FileMode +} + +func (m *mockDirEntry) Name() string { return m.name } +func (m *mockDirEntry) IsDir() bool { return m.isDir } +func (m *mockDirEntry) Type() fs.FileMode { return m.mode } +func (m *mockDirEntry) Info() (fs.FileInfo, error) { return nil, nil } + +// mockFileInfo implements os.FileInfo for testing. +type mockFileInfo struct { + name string + mode fs.FileMode + isDir bool + regular bool +} + +func (m *mockFileInfo) Name() string { return m.name } +func (m *mockFileInfo) Size() int64 { return 0 } +func (m *mockFileInfo) Mode() fs.FileMode { return m.mode } +func (m *mockFileInfo) ModTime() time.Time { return time.Time{} } +func (m *mockFileInfo) IsDir() bool { return m.isDir } +func (m *mockFileInfo) Sys() any { return nil } + +func newTestHandler(entries []os.DirEntry, statResults map[string]*mockFileInfo, envVars map[string]string) *Handler { + return &Handler{ + ReadDir: func(name string) ([]os.DirEntry, error) { + if entries == nil { + return nil, fmt.Errorf("directory not found") + } + return entries, nil + }, + Stat: func(name string) (os.FileInfo, error) { + if info, ok := statResults[name]; ok { + return info, nil + } + return nil, fmt.Errorf("file not found: %s", name) + }, + GetEnv: func(key string) string { + return envVars[key] + }, + HomeDir: func() (string, error) { + return "/home/testuser", nil + }, + } +} + +func TestDiscover(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-operator", mode: 0}, + &mockDirEntry{name: "flux-local", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-operator": {name: "flux-operator", mode: 0o755}, + "/test/plugins/flux-local": {name: "flux-local", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(plugins)) + } + if plugins[0].Name != "operator" { + t.Errorf("expected name 'operator', got %q", plugins[0].Name) + } + if plugins[1].Name != "local" { + t.Errorf("expected name 'local', got %q", plugins[1].Name) + } +} + +func TestDiscoverSkipsBuiltins(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-version", mode: 0}, + &mockDirEntry{name: "flux-get", mode: 0}, + &mockDirEntry{name: "flux-operator", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-version": {name: "flux-version", mode: 0o755}, + "/test/plugins/flux-get": {name: "flux-get", mode: 0o755}, + "/test/plugins/flux-operator": {name: "flux-operator", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover([]string{"version", "get"}) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Name != "operator" { + t.Errorf("expected name 'operator', got %q", plugins[0].Name) + } +} + +func TestDiscoverSkipsReserved(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-plugin", mode: 0}, + &mockDirEntry{name: "flux-help", mode: 0}, + &mockDirEntry{name: "flux-operator", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-plugin": {name: "flux-plugin", mode: 0o755}, + "/test/plugins/flux-help": {name: "flux-help", mode: 0o755}, + "/test/plugins/flux-operator": {name: "flux-operator", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Name != "operator" { + t.Errorf("expected name 'operator', got %q", plugins[0].Name) + } +} + +func TestDiscoverSkipsNonExecutable(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-noperm", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-noperm": {name: "flux-noperm", mode: 0o644}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestDiscoverSkipsDirectories(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-somedir", isDir: true, mode: fs.ModeDir}, + } + stats := map[string]*mockFileInfo{} + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestDiscoverFollowsSymlinks(t *testing.T) { + entries := []os.DirEntry{ + // Symlink entry — Type() returns symlink, but Stat resolves to regular executable. + &mockDirEntry{name: "flux-linked", mode: fs.ModeSymlink}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-linked": {name: "flux-linked", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Name != "linked" { + t.Errorf("expected name 'linked', got %q", plugins[0].Name) + } +} + +func TestDiscoverDirNotExist(t *testing.T) { + h := newTestHandler(nil, nil, map[string]string{envPluginDir: "/nonexistent"}) + + plugins := h.Discover(nil) + if len(plugins) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestDiscoverCustomDir(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-custom", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/custom/path/flux-custom": {name: "flux-custom", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/custom/path"}) + + plugins := h.Discover(nil) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Path != "/custom/path/flux-custom" { + t.Errorf("expected path '/custom/path/flux-custom', got %q", plugins[0].Path) + } +} + +func TestDiscoverSkipsNonFluxPrefix(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "kubectl-foo", mode: 0}, + &mockDirEntry{name: "random-binary", mode: 0}, + &mockDirEntry{name: "flux-operator", mode: 0}, + } + stats := map[string]*mockFileInfo{ + "/test/plugins/flux-operator": {name: "flux-operator", mode: 0o755}, + } + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } +} + +func TestDiscoverBrokenSymlink(t *testing.T) { + entries := []os.DirEntry{ + &mockDirEntry{name: "flux-broken", mode: fs.ModeSymlink}, + } + // No stat entry for flux-broken — simulates a broken symlink. + stats := map[string]*mockFileInfo{} + h := newTestHandler(entries, stats, map[string]string{envPluginDir: "/test/plugins"}) + + plugins := h.Discover(nil) + if len(plugins) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestPluginNameFromBinary(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"flux-operator", "operator"}, + {"flux-my-tool", "my-tool"}, + {"flux-", ""}, + {"notflux-thing", ""}, + {"flux-a", "a"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := pluginNameFromBinary(tt.input) + if got != tt.expected { + t.Errorf("pluginNameFromBinary(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestPluginDir(t *testing.T) { + t.Run("uses env var", func(t *testing.T) { + h := &Handler{ + GetEnv: func(key string) string { + if key == envPluginDir { + return "/custom/plugins" + } + return "" + }, + HomeDir: func() (string, error) { + return "/home/user", nil + }, + } + dir := h.PluginDir() + if dir != "/custom/plugins" { + t.Errorf("expected '/custom/plugins', got %q", dir) + } + }) + + t.Run("uses default", func(t *testing.T) { + h := &Handler{ + GetEnv: func(key string) string { return "" }, + HomeDir: func() (string, error) { + return "/home/user", nil + }, + } + dir := h.PluginDir() + if dir != "/home/user/.fluxcd/plugins" { + t.Errorf("expected '/home/user/.fluxcd/plugins', got %q", dir) + } + }) +} diff --git a/internal/plugin/exec_unix.go b/internal/plugin/exec_unix.go new file mode 100644 index 00000000..10b09572 --- /dev/null +++ b/internal/plugin/exec_unix.go @@ -0,0 +1,30 @@ +//go:build !windows + +/* +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 plugin + +import ( + "os" + "syscall" +) + +// Exec replaces the current process with the plugin binary. +// This is what kubectl does — no signal forwarding or exit code propagation needed. +func Exec(path string, args []string) error { + return syscall.Exec(path, append([]string{path}, args...), os.Environ()) +} diff --git a/internal/plugin/exec_windows.go b/internal/plugin/exec_windows.go new file mode 100644 index 00000000..51e16186 --- /dev/null +++ b/internal/plugin/exec_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +/* +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 plugin + +import ( + "os" + "os/exec" +) + +// Exec runs the plugin as a child process with full I/O passthrough. +// Matches kubectl's Windows fallback pattern. +func Exec(path string, args []string) error { + cmd := exec.Command(path, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + err := cmd.Run() + if err == nil { + os.Exit(0) + } + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return err +} diff --git a/internal/plugin/install.go b/internal/plugin/install.go new file mode 100644 index 00000000..7b2129e6 --- /dev/null +++ b/internal/plugin/install.go @@ -0,0 +1,366 @@ +/* +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 plugin + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "crypto/sha256" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "sigs.k8s.io/yaml" + + plugintypes "github.com/fluxcd/flux2/v2/pkg/plugin" +) + +// Installer handles downloading, verifying, and installing plugins. +type Installer struct { + // HTTPClient is the HTTP client used for downloading plugin archives. + HTTPClient *http.Client +} + +// NewInstaller returns an Installer with production defaults. +func NewInstaller() *Installer { + return &Installer{ + HTTPClient: newHTTPClient(5 * time.Minute), + } +} + +// Install downloads, verifies, extracts, and installs a plugin binary +// to the given plugin directory. +func (inst *Installer) Install(pluginDir string, manifest *plugintypes.Manifest, pv *plugintypes.Version, plat *plugintypes.Platform) error { + tmpFile, err := os.CreateTemp("", "flux-plugin-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + resp, err := inst.HTTPClient.Get(plat.URL) + if err != nil { + return fmt.Errorf("failed to download plugin: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download plugin: HTTP %d", resp.StatusCode) + } + + hasher := sha256.New() + writer := io.MultiWriter(tmpFile, hasher) + if _, err := io.Copy(writer, resp.Body); err != nil { + return fmt.Errorf("failed to download plugin: %w", err) + } + tmpFile.Close() + + actualChecksum := fmt.Sprintf("sha256:%x", hasher.Sum(nil)) + if actualChecksum != plat.Checksum { + return fmt.Errorf("checksum verification failed (expected: %s, got: %s)", plat.Checksum, actualChecksum) + } + + // manifest.Bin is the single source of truth for the installed binary + // name (e.g. "flux-validate"). On Windows we always append ".exe". + // For archives it's also the entry name we look up; for raw binaries + // it's the rename target regardless of the URL's filename. + binName := manifest.Bin + if runtime.GOOS == "windows" { + binName += ".exe" + } + destPath := filepath.Join(pluginDir, binName) + + // extractTarget is the path to match inside the archive. When the + // platform specifies an extractPath, use it verbatim (it may be a + // nested path like "bin/flux-operator"). Otherwise fall back to + // binName which matches by basename. + extractTarget := binName + if plat.ExtractPath != "" { + extractTarget = plat.ExtractPath + } + + format, err := detectArchiveFormat(tmpFile.Name(), plat.URL) + if err != nil { + return fmt.Errorf("failed to detect plugin format: %w", err) + } + + switch format { + case formatZip: + err = extractFromZip(tmpFile.Name(), extractTarget, destPath) + case formatTarGz: + err = extractFromTarGz(tmpFile.Name(), extractTarget, destPath) + case formatTar: + err = extractFromTar(tmpFile.Name(), extractTarget, destPath) + case formatBinary: + err = copyPluginBinary(tmpFile.Name(), destPath) + default: + return fmt.Errorf("unexpected plugin format: %v", format) + } + if err != nil { + return err + } + + receipt := plugintypes.Receipt{ + Name: manifest.Name, + Version: pv.Version, + InstalledAt: time.Now().UTC().Format(time.RFC3339), + Platform: *plat, + } + return writeReceipt(pluginDir, manifest.Name, &receipt) +} + +// Uninstall removes a plugin binary (or symlink) and its receipt from the +// plugin directory. Returns an error if the plugin is not installed. +func Uninstall(pluginDir, name string) error { + binName := pluginPrefix + name + if runtime.GOOS == "windows" { + binName += ".exe" + } + + binPath := filepath.Join(pluginDir, binName) + + // Use Lstat so we detect symlinks without following them. + if _, err := os.Lstat(binPath); os.IsNotExist(err) { + return fmt.Errorf("plugin %q is not installed", name) + } + + if err := os.Remove(binPath); err != nil { + return fmt.Errorf("failed to remove plugin binary: %w", err) + } + + // Receipt is optional (manually installed plugins don't have one). + if err := os.Remove(receiptPath(pluginDir, name)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove plugin receipt: %w", err) + } + + return nil +} + +// ReadReceipt reads the install receipt for a plugin. +// Returns nil if no receipt exists. +func ReadReceipt(pluginDir, name string) *plugintypes.Receipt { + data, err := os.ReadFile(receiptPath(pluginDir, name)) + if err != nil { + return nil + } + + var receipt plugintypes.Receipt + if err := yaml.Unmarshal(data, &receipt); err != nil { + return nil + } + + return &receipt +} + +func receiptPath(pluginDir, name string) string { + return filepath.Join(pluginDir, pluginPrefix+name+".yaml") +} + +func writeReceipt(pluginDir, name string, receipt *plugintypes.Receipt) error { + data, err := yaml.Marshal(receipt) + if err != nil { + return fmt.Errorf("failed to marshal receipt: %w", err) + } + + return os.WriteFile(receiptPath(pluginDir, name), data, 0o644) +} + +// archiveFormat is the detected format of a downloaded plugin artifact. +type archiveFormat int + +const ( + formatBinary archiveFormat = iota + formatZip + formatTarGz + formatTar +) + +// detectArchiveFormat determines the artifact format by first checking the URL +// extension, then falling back to magic-byte inspection. Returns formatBinary +// if neither indicates a known archive, in which case the downloaded file is +// installed as-is. +func detectArchiveFormat(path, url string) (archiveFormat, error) { + switch lower := strings.ToLower(url); { + case strings.HasSuffix(lower, ".zip"): + return formatZip, nil + case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"): + return formatTarGz, nil + case strings.HasSuffix(lower, ".tar"): + return formatTar, nil + } + + f, err := os.Open(path) + if err != nil { + return formatBinary, err + } + defer f.Close() + + // Read enough bytes to cover the tar "ustar" magic at offset 257. + var hdr [262]byte + n, err := io.ReadFull(f, hdr[:]) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return formatBinary, err + } + + // ZIP: PK\x03\x04 (file), PK\x05\x06 (empty), PK\x07\x08 (spanned). + if n >= 4 && hdr[0] == 'P' && hdr[1] == 'K' && + (hdr[2] == 0x03 || hdr[2] == 0x05 || hdr[2] == 0x07) { + return formatZip, nil + } + // gzip: \x1f\x8b + if n >= 2 && hdr[0] == 0x1f && hdr[1] == 0x8b { + return formatTarGz, nil + } + // tar: "ustar" at offset 257 + if n >= 262 && string(hdr[257:262]) == "ustar" { + return formatTar, nil + } + + return formatBinary, nil +} + +// extractFromTarGz extracts a named file from a tar.gz archive +// and streams it directly to destPath. +func extractFromTarGz(archivePath, targetName, destPath string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("failed to read gzip: %w", err) + } + defer gr.Close() + + return extractTarStream(gr, targetName, destPath) +} + +// extractFromTar extracts a named file from an uncompressed tar archive +// and streams it directly to destPath. +func extractFromTar(archivePath, targetName, destPath string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + + return extractTarStream(f, targetName, destPath) +} + +// matchArchiveEntry reports whether the archive entry name matches the +// target. If target contains a path separator it is matched as an exact +// path; otherwise only the base name of the entry is compared. +func matchArchiveEntry(entryName, target string) bool { + if strings.Contains(target, "/") { + return entryName == target + } + return filepath.Base(entryName) == target +} + +// extractTarStream walks a tar stream and streams the first matching +// regular file to destPath. Non-regular entries (symlinks, devices, +// directories) and entries with unsafe paths are skipped. +func extractTarStream(r io.Reader, targetName, destPath string) error { + tr := tar.NewReader(r) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar: %w", err) + } + + if !filepath.IsLocal(header.Name) { + continue + } + if !header.FileInfo().Mode().IsRegular() { + continue + } + if matchArchiveEntry(header.Name, targetName) { + return writeStreamToFile(tr, destPath) + } + } + + return fmt.Errorf("binary %q not found in archive", targetName) +} + +// copyPluginBinary copies a raw downloaded binary to destPath with 0755 mode. +// Used when the downloaded artifact is not an archive. +func copyPluginBinary(srcPath, destPath string) error { + src, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("failed to open downloaded binary: %w", err) + } + defer src.Close() + + return writeStreamToFile(src, destPath) +} + +// extractFromZip extracts a named file from a zip archive and streams it +// directly to destPath. Non-regular entries (symlinks, devices, directories) +// and entries with unsafe paths are skipped. +func extractFromZip(archivePath, targetName, destPath string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return fmt.Errorf("failed to open zip: %w", err) + } + defer r.Close() + + for _, f := range r.File { + if !filepath.IsLocal(f.Name) { + continue + } + if !f.FileInfo().Mode().IsRegular() { + continue + } + if matchArchiveEntry(f.Name, targetName) { + rc, err := f.Open() + if err != nil { + return fmt.Errorf("failed to open %q in zip: %w", targetName, err) + } + err = writeStreamToFile(rc, destPath) + rc.Close() + return err + } + } + + return fmt.Errorf("binary %q not found in archive", targetName) +} + +func writeStreamToFile(r io.Reader, destPath string) error { + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return fmt.Errorf("failed to create %s: %w", destPath, err) + } + + if _, err := io.Copy(out, r); err != nil { + if closeErr := out.Close(); closeErr != nil { + return fmt.Errorf("failed to write plugin binary: %w (also failed to close file: %v)", err, closeErr) + } + return fmt.Errorf("failed to write plugin binary: %w", err) + } + return out.Close() +} diff --git a/internal/plugin/install_test.go b/internal/plugin/install_test.go new file mode 100644 index 00000000..dc2dd587 --- /dev/null +++ b/internal/plugin/install_test.go @@ -0,0 +1,815 @@ +/* +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 plugin + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "fmt" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + plugintypes "github.com/fluxcd/flux2/v2/pkg/plugin" +) + +// createTestTarGz creates a tar.gz archive containing a single file. +func createTestTarGz(name string, content []byte) ([]byte, error) { + var buf bytes.Buffer + + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + hdr := &tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + return nil, err + } + if _, err := tw.Write(content); err != nil { + return nil, err + } + + tw.Close() + gw.Close() + return buf.Bytes(), nil +} + +// createTestTar creates an uncompressed tar archive containing a single file. +func createTestTar(name string, content []byte) ([]byte, error) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + hdr := &tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + return nil, err + } + if _, err := tw.Write(content); err != nil { + return nil, err + } + + tw.Close() + return buf.Bytes(), nil +} + +// tarEntry describes a single entry for createTestTarGzMulti. +type tarEntry struct { + header tar.Header + content []byte +} + +// createTestTarGzMulti creates a tar.gz archive with arbitrary entries. +// Used to test rejection of unsafe or non-regular entries. +func createTestTarGzMulti(entries []tarEntry) ([]byte, error) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for _, e := range entries { + hdr := e.header + hdr.Size = int64(len(e.content)) + if err := tw.WriteHeader(&hdr); err != nil { + return nil, err + } + if len(e.content) > 0 { + if _, err := tw.Write(e.content); err != nil { + return nil, err + } + } + } + + tw.Close() + gw.Close() + return buf.Bytes(), nil +} + +// zipEntry describes a single entry for createTestZip. +type zipEntry struct { + name string + mode fs.FileMode + content []byte +} + +// createTestZip creates a zip archive with arbitrary entries. Entries may +// carry Unix mode bits (e.g. os.ModeSymlink) to exercise non-regular files. +func createTestZip(entries []zipEntry) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + for _, e := range entries { + hdr := &zip.FileHeader{ + Name: e.name, + Method: zip.Deflate, + } + mode := e.mode + if mode == 0 { + mode = 0o755 + } + hdr.SetMode(mode) + + w, err := zw.CreateHeader(hdr) + if err != nil { + return nil, err + } + if _, err := w.Write(e.content); err != nil { + return nil, err + } + } + + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func TestInstall(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho hello") + archive, err := createTestTarGz("flux-operator", binaryContent) + if err != nil { + t.Fatalf("failed to create test archive: %v", err) + } + + checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(archive)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(archive) + })) + defer server.Close() + + pluginDir := t.TempDir() + + manifest := &plugintypes.Manifest{ + Name: "operator", + Bin: "flux-operator", + } + pv := &plugintypes.Version{Version: "0.45.0"} + plat := &plugintypes.Platform{ + OS: "linux", + Arch: "amd64", + URL: server.URL + "/flux-operator_0.45.0_linux_amd64.tar.gz", + Checksum: checksum, + } + + installer := &Installer{HTTPClient: server.Client()} + if err := installer.Install(pluginDir, manifest, pv, plat); err != nil { + t.Fatalf("install failed: %v", err) + } + + // Verify binary was written. + binPath := filepath.Join(pluginDir, "flux-operator") + data, err := os.ReadFile(binPath) + if err != nil { + t.Fatalf("binary not found: %v", err) + } + if string(data) != string(binaryContent) { + t.Errorf("binary content mismatch") + } + + // Verify receipt was written. + receipt := ReadReceipt(pluginDir, "operator") + if receipt == nil { + t.Fatal("receipt not found") + } + if receipt.Version != "0.45.0" { + t.Errorf("expected version '0.45.0', got %q", receipt.Version) + } + if receipt.Name != "operator" { + t.Errorf("expected name 'operator', got %q", receipt.Name) + } +} + +func TestInstallChecksumMismatch(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho hello") + archive, err := createTestTarGz("flux-operator", binaryContent) + if err != nil { + t.Fatalf("failed to create test archive: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(archive) + })) + defer server.Close() + + pluginDir := t.TempDir() + + manifest := &plugintypes.Manifest{Name: "operator", Bin: "flux-operator"} + pv := &plugintypes.Version{Version: "0.45.0"} + plat := &plugintypes.Platform{ + OS: "linux", + Arch: "amd64", + URL: server.URL + "/archive.tar.gz", + Checksum: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + } + + installer := &Installer{HTTPClient: server.Client()} + err = installer.Install(pluginDir, manifest, pv, plat) + if err == nil { + t.Fatal("expected checksum error, got nil") + } + if !bytes.Contains([]byte(err.Error()), []byte("checksum verification failed")) { + t.Errorf("expected checksum error, got: %v", err) + } +} + +func TestInstallBinaryNotInArchive(t *testing.T) { + // Archive contains "wrong-name" instead of "flux-operator". + archive, err := createTestTarGz("wrong-name", []byte("content")) + if err != nil { + t.Fatalf("failed to create test archive: %v", err) + } + + checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(archive)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(archive) + })) + defer server.Close() + + pluginDir := t.TempDir() + + manifest := &plugintypes.Manifest{Name: "operator", Bin: "flux-operator"} + pv := &plugintypes.Version{Version: "0.45.0"} + plat := &plugintypes.Platform{ + OS: "linux", + Arch: "amd64", + URL: server.URL + "/archive.tar.gz", + Checksum: checksum, + } + + installer := &Installer{HTTPClient: server.Client()} + err = installer.Install(pluginDir, manifest, pv, plat) + if err == nil { + t.Fatal("expected error for missing binary, got nil") + } + if !bytes.Contains([]byte(err.Error()), []byte("not found in archive")) { + t.Errorf("expected 'not found in archive' error, got: %v", err) + } +} + +func TestUninstall(t *testing.T) { + pluginDir := t.TempDir() + + // Create fake binary and receipt. + binPath := filepath.Join(pluginDir, "flux-testplugin") + os.WriteFile(binPath, []byte("binary"), 0o755) + receiptPath := filepath.Join(pluginDir, "flux-testplugin.yaml") + os.WriteFile(receiptPath, []byte("name: testplugin"), 0o644) + + if err := Uninstall(pluginDir, "testplugin"); err != nil { + t.Fatalf("uninstall failed: %v", err) + } + + if _, err := os.Stat(binPath); !os.IsNotExist(err) { + t.Error("binary was not removed") + } + if _, err := os.Stat(receiptPath); !os.IsNotExist(err) { + t.Error("receipt was not removed") + } +} + +func TestUninstallNonExistent(t *testing.T) { + pluginDir := t.TempDir() + + err := Uninstall(pluginDir, "nonexistent") + if err == nil { + t.Fatal("expected error for non-existent plugin, got nil") + } + if !strings.Contains(err.Error(), "is not installed") { + t.Errorf("expected 'is not installed' error, got: %v", err) + } +} + +func TestUninstallSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require elevated privileges on Windows") + } + + pluginDir := t.TempDir() + + // Create a real binary and symlink it into the plugin dir. + realBin := filepath.Join(t.TempDir(), "flux-operator") + os.WriteFile(realBin, []byte("real binary"), 0o755) + + linkPath := filepath.Join(pluginDir, "flux-linked") + os.Symlink(realBin, linkPath) + + if err := Uninstall(pluginDir, "linked"); err != nil { + t.Fatalf("uninstall symlink failed: %v", err) + } + + // Symlink should be removed. + if _, err := os.Lstat(linkPath); !os.IsNotExist(err) { + t.Error("symlink was not removed") + } + // Original binary should still exist. + if _, err := os.Stat(realBin); err != nil { + t.Error("original binary was removed — symlink removal should not affect target") + } +} + +func TestUninstallManualBinary(t *testing.T) { + pluginDir := t.TempDir() + + // Manually copied binary with no receipt. + binPath := filepath.Join(pluginDir, "flux-manual") + os.WriteFile(binPath, []byte("binary"), 0o755) + + if err := Uninstall(pluginDir, "manual"); err != nil { + t.Fatalf("uninstall manual binary failed: %v", err) + } + + if _, err := os.Stat(binPath); !os.IsNotExist(err) { + t.Error("binary was not removed") + } +} + +func TestReadReceipt(t *testing.T) { + pluginDir := t.TempDir() + + t.Run("exists", func(t *testing.T) { + receiptData := `name: operator +version: "0.45.0" +installedAt: "2026-03-28T20:05:00Z" +platform: + os: darwin + arch: arm64 + url: https://example.com/archive.tar.gz + checksum: sha256:abc123 +` + os.WriteFile(filepath.Join(pluginDir, "flux-operator.yaml"), []byte(receiptData), 0o644) + + receipt := ReadReceipt(pluginDir, "operator") + if receipt == nil { + t.Fatal("expected receipt, got nil") + } + if receipt.Version != "0.45.0" { + t.Errorf("expected version '0.45.0', got %q", receipt.Version) + } + if receipt.Platform.OS != "darwin" { + t.Errorf("expected OS 'darwin', got %q", receipt.Platform.OS) + } + }) + + t.Run("not exists", func(t *testing.T) { + receipt := ReadReceipt(pluginDir, "nonexistent") + if receipt != nil { + t.Error("expected nil receipt") + } + }) +} + +func TestInstallRawBinary(t *testing.T) { + // Bytes that don't match zip/gzip/tar magic — treated as a raw binary. + binaryContent := []byte("#!/bin/sh\necho raw plugin") + checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(binaryContent)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(binaryContent) + })) + defer server.Close() + + pluginDir := t.TempDir() + + manifest := &plugintypes.Manifest{ + Name: "validate", + Bin: "flux-validate", + } + pv := &plugintypes.Version{Version: "1.2.3"} + plat := &plugintypes.Platform{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + // URL filename deliberately differs from manifest.Bin — mimics a + // typical GitHub release asset that includes platform/version in + // the name. The installer must rename to manifest.Bin. + URL: server.URL + "/download/flux-validate-" + runtime.GOARCH + "-v1.2.3", + Checksum: checksum, + } + + installer := &Installer{HTTPClient: server.Client()} + if err := installer.Install(pluginDir, manifest, pv, plat); err != nil { + t.Fatalf("install failed: %v", err) + } + + // The installed file must be named exactly manifest.Bin (+ .exe on Windows), + // regardless of what the URL path looked like. + wantName := "flux-validate" + if runtime.GOOS == "windows" { + wantName += ".exe" + } + binPath := filepath.Join(pluginDir, wantName) + data, err := os.ReadFile(binPath) + if err != nil { + t.Fatalf("binary not found at %s: %v", binPath, err) + } + if !bytes.Equal(data, binaryContent) { + t.Errorf("binary content mismatch: got %q, want %q", data, binaryContent) + } + + // Nothing should have been written under the URL-derived name. + urlDerived := filepath.Join(pluginDir, "flux-validate-"+runtime.GOARCH+"-v1.2.3") + if _, err := os.Stat(urlDerived); !os.IsNotExist(err) { + t.Errorf("unexpected file at URL-derived path %s", urlDerived) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(binPath) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Mode()&0o111 == 0 { + t.Errorf("binary is not executable: mode %v", info.Mode()) + } + } + + // Raw binary install must still produce a receipt. + if receipt := ReadReceipt(pluginDir, "validate"); receipt == nil { + t.Fatal("receipt not found") + } +} + +func TestDetectArchiveFormat(t *testing.T) { + tarGz, err := createTestTarGz("bin", []byte("content")) + if err != nil { + t.Fatalf("createTestTarGz: %v", err) + } + plainTar, err := createTestTar("bin", []byte("content")) + if err != nil { + t.Fatalf("createTestTar: %v", err) + } + + tests := []struct { + name string + url string + content []byte + want archiveFormat + }{ + // Extension-based detection takes precedence over content. + {"zip extension", "https://example.com/plugin.zip", []byte("ignored"), formatZip}, + {"tar.gz extension", "https://example.com/plugin.tar.gz", []byte("ignored"), formatTarGz}, + {"tgz extension", "https://example.com/plugin.tgz", []byte("ignored"), formatTarGz}, + {"tar extension", "https://example.com/plugin.tar", []byte("ignored"), formatTar}, + {"uppercase extension", "https://example.com/PLUGIN.ZIP", []byte("ignored"), formatZip}, + + // Magic-byte detection when extension is absent or unrecognized. + {"zip magic no extension", "https://example.com/download", []byte{'P', 'K', 0x03, 0x04, 0, 0, 0, 0}, formatZip}, + {"gzip magic no extension", "https://example.com/download", tarGz, formatTarGz}, + {"tar magic no extension", "https://example.com/download", plainTar, formatTar}, + + // Fallback to raw binary. + {"unknown content", "https://example.com/download", []byte("#!/bin/sh\necho hi"), formatBinary}, + {"short file", "https://example.com/download", []byte("ab"), formatBinary}, + {"empty file", "https://example.com/download", []byte{}, formatBinary}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "artifact") + if err := os.WriteFile(tmp, tc.content, 0o644); err != nil { + t.Fatal(err) + } + got, err := detectArchiveFormat(tmp, tc.url) + if err != nil { + t.Fatalf("detectArchiveFormat: %v", err) + } + if got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestExtractFromTarGz(t *testing.T) { + content := []byte("test binary content") + archive, err := createTestTarGz("flux-operator", content) + if err != nil { + t.Fatalf("failed to create archive: %v", err) + } + + tmpFile := filepath.Join(t.TempDir(), "test.tar.gz") + os.WriteFile(tmpFile, archive, 0o644) + + destPath := filepath.Join(t.TempDir(), "flux-operator") + if err := extractFromTarGz(tmpFile, "flux-operator", destPath); err != nil { + t.Fatalf("extract failed: %v", err) + } + data, err := os.ReadFile(destPath) + if err != nil { + t.Fatalf("failed to read extracted file: %v", err) + } + if string(data) != string(content) { + t.Errorf("content mismatch: got %q, want %q", string(data), string(content)) + } +} + +func TestExtractFromTarGzRejectsUnsafeEntries(t *testing.T) { + content := []byte("legit content") + + // Archive contains, in order: + // 1. A symlink whose basename matches the target (must be skipped). + // 2. A regular entry with ".." in the path (must be skipped). + // 3. An absolute-path entry (must be skipped). + // 4. A legitimate regular file that must be extracted. + entries := []tarEntry{ + { + header: tar.Header{ + Name: "flux-operator", + Typeflag: tar.TypeSymlink, + Linkname: "/etc/passwd", + Mode: 0o777, + }, + }, + { + header: tar.Header{ + Name: "../flux-operator", + Typeflag: tar.TypeReg, + Mode: 0o755, + }, + content: []byte("malicious"), + }, + { + header: tar.Header{ + Name: "/absolute/flux-operator", + Typeflag: tar.TypeReg, + Mode: 0o755, + }, + content: []byte("malicious"), + }, + { + header: tar.Header{ + Name: "bin/flux-operator", + Typeflag: tar.TypeReg, + Mode: 0o755, + }, + content: content, + }, + } + + archive, err := createTestTarGzMulti(entries) + if err != nil { + t.Fatalf("createTestTarGzMulti: %v", err) + } + + tmpFile := filepath.Join(t.TempDir(), "test.tar.gz") + os.WriteFile(tmpFile, archive, 0o644) + + destPath := filepath.Join(t.TempDir(), "flux-operator") + if err := extractFromTarGz(tmpFile, "flux-operator", destPath); err != nil { + t.Fatalf("extract failed: %v", err) + } + + data, err := os.ReadFile(destPath) + if err != nil { + t.Fatalf("failed to read extracted file: %v", err) + } + if !bytes.Equal(data, content) { + t.Errorf("extracted content mismatch: got %q, want %q", data, content) + } +} + +func TestExtractFromZip(t *testing.T) { + content := []byte("test binary content") + archive, err := createTestZip([]zipEntry{ + {name: "flux-operator", content: content}, + }) + if err != nil { + t.Fatalf("createTestZip: %v", err) + } + + tmpFile := filepath.Join(t.TempDir(), "test.zip") + os.WriteFile(tmpFile, archive, 0o644) + + destPath := filepath.Join(t.TempDir(), "flux-operator") + if err := extractFromZip(tmpFile, "flux-operator", destPath); err != nil { + t.Fatalf("extract failed: %v", err) + } + + data, err := os.ReadFile(destPath) + if err != nil { + t.Fatalf("failed to read extracted file: %v", err) + } + if !bytes.Equal(data, content) { + t.Errorf("content mismatch: got %q, want %q", data, content) + } +} + +func TestExtractFromZipRejectsUnsafeEntries(t *testing.T) { + content := []byte("legit content") + + // Archive contains, in order: + // 1. A symlink whose basename matches the target (must be skipped). + // 2. An entry with ".." in the path (must be skipped). + // 3. An absolute-path entry (must be skipped). + // 4. A directory entry whose basename matches (must be skipped). + // 5. A legitimate regular file that must be extracted. + entries := []zipEntry{ + { + name: "flux-operator", + mode: fs.ModeSymlink | 0o777, + content: []byte("/etc/passwd"), + }, + { + name: "../flux-operator", + content: []byte("malicious"), + }, + { + name: "/absolute/flux-operator", + content: []byte("malicious"), + }, + { + name: "flux-operator/", + mode: fs.ModeDir | 0o755, + }, + { + name: "bin/flux-operator", + content: content, + }, + } + + archive, err := createTestZip(entries) + if err != nil { + t.Fatalf("createTestZip: %v", err) + } + + tmpFile := filepath.Join(t.TempDir(), "test.zip") + os.WriteFile(tmpFile, archive, 0o644) + + destPath := filepath.Join(t.TempDir(), "flux-operator") + if err := extractFromZip(tmpFile, "flux-operator", destPath); err != nil { + t.Fatalf("extract failed: %v", err) + } + + data, err := os.ReadFile(destPath) + if err != nil { + t.Fatalf("failed to read extracted file: %v", err) + } + if !bytes.Equal(data, content) { + t.Errorf("extracted content mismatch: got %q, want %q", data, content) + } +} + +func TestInstallExtractPath(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho nested") + + // Binary is nested at "subdir/flux-operator" inside the archive. + entries := []tarEntry{ + { + header: tar.Header{ + Name: "subdir/flux-operator", + Typeflag: tar.TypeReg, + Mode: 0o755, + }, + content: binaryContent, + }, + } + archive, err := createTestTarGzMulti(entries) + if err != nil { + t.Fatalf("failed to create archive: %v", err) + } + + checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(archive)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(archive) + })) + defer server.Close() + + pluginDir := t.TempDir() + + manifest := &plugintypes.Manifest{Name: "operator", Bin: "flux-operator"} + pv := &plugintypes.Version{Version: "0.45.0"} + plat := &plugintypes.Platform{ + OS: "linux", + Arch: "amd64", + URL: server.URL + "/archive.tar.gz", + Checksum: checksum, + ExtractPath: "subdir/flux-operator", + } + + installer := &Installer{HTTPClient: server.Client()} + if err := installer.Install(pluginDir, manifest, pv, plat); err != nil { + t.Fatalf("install failed: %v", err) + } + + // Binary must be installed under manifest.Bin, not the extractPath. + binPath := filepath.Join(pluginDir, "flux-operator") + data, err := os.ReadFile(binPath) + if err != nil { + t.Fatalf("binary not found: %v", err) + } + if !bytes.Equal(data, binaryContent) { + t.Errorf("binary content mismatch") + } +} + +func TestInstallExtractPathZip(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho nested zip") + + archive, err := createTestZip([]zipEntry{ + {name: "pkg/bin/flux-operator", content: binaryContent}, + }) + if err != nil { + t.Fatalf("createTestZip: %v", err) + } + + checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(archive)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(archive) + })) + defer server.Close() + + pluginDir := t.TempDir() + + manifest := &plugintypes.Manifest{Name: "operator", Bin: "flux-operator"} + pv := &plugintypes.Version{Version: "0.45.0"} + plat := &plugintypes.Platform{ + OS: "linux", + Arch: "amd64", + URL: server.URL + "/archive.zip", + Checksum: checksum, + ExtractPath: "pkg/bin/flux-operator", + } + + installer := &Installer{HTTPClient: server.Client()} + if err := installer.Install(pluginDir, manifest, pv, plat); err != nil { + t.Fatalf("install failed: %v", err) + } + + binPath := filepath.Join(pluginDir, "flux-operator") + data, err := os.ReadFile(binPath) + if err != nil { + t.Fatalf("binary not found: %v", err) + } + if !bytes.Equal(data, binaryContent) { + t.Errorf("binary content mismatch") + } +} + +func TestMatchArchiveEntry(t *testing.T) { + tests := []struct { + entry, target string + want bool + }{ + // Basename matching (no slash in target). + {"flux-operator", "flux-operator", true}, + {"bin/flux-operator", "flux-operator", true}, + {"deep/nested/flux-operator", "flux-operator", true}, + {"other-binary", "flux-operator", false}, + + // Exact path matching (slash in target). + {"bin/flux-operator", "bin/flux-operator", true}, + {"flux-operator", "bin/flux-operator", false}, + {"other/flux-operator", "bin/flux-operator", false}, + } + for _, tc := range tests { + t.Run(tc.entry+"_"+tc.target, func(t *testing.T) { + if got := matchArchiveEntry(tc.entry, tc.target); got != tc.want { + t.Errorf("matchArchiveEntry(%q, %q) = %v, want %v", tc.entry, tc.target, got, tc.want) + } + }) + } +} + +func TestExtractFromTarGzNotFound(t *testing.T) { + archive, err := createTestTarGz("other-binary", []byte("content")) + if err != nil { + t.Fatalf("failed to create archive: %v", err) + } + + tmpFile := filepath.Join(t.TempDir(), "test.tar.gz") + os.WriteFile(tmpFile, archive, 0o644) + + destPath := filepath.Join(t.TempDir(), "flux-operator") + err = extractFromTarGz(tmpFile, "flux-operator", destPath) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/plugin/update.go b/internal/plugin/update.go new file mode 100644 index 00000000..6ce87e79 --- /dev/null +++ b/internal/plugin/update.go @@ -0,0 +1,106 @@ +/* +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 plugin + +import ( + plugintypes "github.com/fluxcd/flux2/v2/pkg/plugin" +) + +const ( + SkipReasonManual = "manually installed" + SkipReasonUpToDate = "already up to date" +) + +// UpdateResult represents the outcome of updating a single plugin. +// When an update is available, Manifest, Version and Platform are +// populated so the caller can install without re-fetching or re-resolving. +type UpdateResult struct { + // Name is the plugin name. + Name string + + // FromVersion is the currently installed version. + FromVersion string + + // ToVersion is the latest available version. + ToVersion string + + // Skipped is true when the update was not performed. + Skipped bool + + // SkipReason explains why the update was skipped. + SkipReason string + + // Manifest is the resolved plugin manifest for the update. + Manifest *plugintypes.Manifest + + // Version is the resolved target version for the update. + Version *plugintypes.Version + + // Platform is the resolved platform entry for the update. + Platform *plugintypes.Platform + + // Err is set when the update check itself failed. + Err error +} + +// CheckUpdate compares the installed version against the latest in the catalog. +// Returns an UpdateResult describing what should happen. When an update is +// available, Manifest is populated so the caller can install without re-fetching. +func CheckUpdate(pluginDir string, name string, catalog *CatalogClient, goos, goarch string) UpdateResult { + receipt := ReadReceipt(pluginDir, name) + if receipt == nil { + return UpdateResult{ + Name: name, + Skipped: true, + SkipReason: SkipReasonManual, + } + } + + manifest, err := catalog.FetchManifest(name) + if err != nil { + return UpdateResult{Name: name, Err: err} + } + + latest, err := ResolveVersion(manifest, "") + if err != nil { + return UpdateResult{Name: name, Err: err} + } + + if receipt.Version == latest.Version { + return UpdateResult{ + Name: name, + FromVersion: receipt.Version, + ToVersion: latest.Version, + Skipped: true, + SkipReason: SkipReasonUpToDate, + } + } + + plat, err := ResolvePlatform(latest, goos, goarch) + if err != nil { + return UpdateResult{Name: name, Err: err} + } + + return UpdateResult{ + Name: name, + FromVersion: receipt.Version, + ToVersion: latest.Version, + Manifest: manifest, + Version: latest, + Platform: plat, + } +} diff --git a/internal/plugin/update_test.go b/internal/plugin/update_test.go new file mode 100644 index 00000000..53c1fc53 --- /dev/null +++ b/internal/plugin/update_test.go @@ -0,0 +1,153 @@ +/* +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 plugin + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestCheckUpdateUpToDate(t *testing.T) { + manifest := ` +apiVersion: cli.fluxcd.io/v1beta1 +kind: Plugin +name: operator +bin: flux-operator +versions: + - version: 0.45.0 + platforms: + - os: linux + arch: amd64 + url: https://example.com/archive.tar.gz + checksum: sha256:abc123 +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(manifest)) + })) + defer server.Close() + + pluginDir := t.TempDir() + + // Write receipt with same version. + receiptData := `name: operator +version: "0.45.0" +installedAt: "2026-03-28T20:05:00Z" +platform: + os: linux + arch: amd64 +` + os.WriteFile(filepath.Join(pluginDir, "flux-operator.yaml"), []byte(receiptData), 0o644) + + catalog := &CatalogClient{ + BaseURL: server.URL + "/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { return "" }, + } + + result := CheckUpdate(pluginDir, "operator", catalog, "linux", "amd64") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !result.Skipped { + t.Error("expected skipped=true") + } + if result.SkipReason != SkipReasonUpToDate { + t.Errorf("expected %q, got %q", SkipReasonUpToDate, result.SkipReason) + } +} + +func TestCheckUpdateAvailable(t *testing.T) { + manifest := ` +apiVersion: cli.fluxcd.io/v1beta1 +kind: Plugin +name: operator +bin: flux-operator +versions: + - version: 0.46.0 + platforms: + - os: linux + arch: amd64 + url: https://example.com/archive.tar.gz + checksum: sha256:abc123 + - version: 0.45.0 + platforms: + - os: linux + arch: amd64 + url: https://example.com/archive.tar.gz + checksum: sha256:def456 +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(manifest)) + })) + defer server.Close() + + pluginDir := t.TempDir() + + receiptData := `name: operator +version: "0.45.0" +installedAt: "2026-03-28T20:05:00Z" +platform: + os: linux + arch: amd64 +` + os.WriteFile(filepath.Join(pluginDir, "flux-operator.yaml"), []byte(receiptData), 0o644) + + catalog := &CatalogClient{ + BaseURL: server.URL + "/", + HTTPClient: server.Client(), + GetEnv: func(key string) string { return "" }, + } + + result := CheckUpdate(pluginDir, "operator", catalog, "linux", "amd64") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if result.Skipped { + t.Error("expected skipped=false") + } + if result.FromVersion != "0.45.0" { + t.Errorf("expected from '0.45.0', got %q", result.FromVersion) + } + if result.ToVersion != "0.46.0" { + t.Errorf("expected to '0.46.0', got %q", result.ToVersion) + } +} + +func TestCheckUpdateManualInstall(t *testing.T) { + pluginDir := t.TempDir() + + // No receipt — manually installed. + catalog := &CatalogClient{ + BaseURL: "https://example.com/", + HTTPClient: http.DefaultClient, + GetEnv: func(key string) string { return "" }, + } + + result := CheckUpdate(pluginDir, "operator", catalog, "linux", "amd64") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !result.Skipped { + t.Error("expected skipped=true") + } + if result.SkipReason != SkipReasonManual { + t.Errorf("expected 'manually installed', got %q", result.SkipReason) + } +} diff --git a/pkg/plugin/types.go b/pkg/plugin/types.go new file mode 100644 index 00000000..e09d4f30 --- /dev/null +++ b/pkg/plugin/types.go @@ -0,0 +1,135 @@ +/* +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 plugin defines the public types for the Flux CLI plugin system. +// These types represent the plugin catalog schema (cli.fluxcd.io/v1beta1) +// and are safe for use by external consumers. +package plugin + +const ( + // APIVersion is the plugin manifest schema version. + APIVersion = "cli.fluxcd.io/v1beta1" + + // PluginKind is the kind for plugin manifests. + PluginKind = "Plugin" + + // CatalogKind is the kind for the plugin catalog. + CatalogKind = "PluginCatalog" +) + +// Manifest represents a single plugin's manifest from the catalog. +type Manifest struct { + // APIVersion is the manifest schema version (e.g. "cli.fluxcd.io/v1beta1"). + APIVersion string `json:"apiVersion"` + + // Kind is the manifest type, must be "Plugin". + Kind string `json:"kind"` + + // Name is the plugin name used in "flux " invocations. + Name string `json:"name"` + + // Description is a short human-readable summary of the plugin. + Description string `json:"description"` + + // Homepage is the URL to the plugin's documentation site. + Homepage string `json:"homepage,omitempty"` + + // Source is the URL to the plugin's source repository. + Source string `json:"source,omitempty"` + + // Bin is the binary name inside archives and the installed filename + // (e.g. "flux-operator"). On Windows ".exe" is appended automatically. + Bin string `json:"bin"` + + // Versions lists available versions, newest first. + Versions []Version `json:"versions"` +} + +// Version represents a version entry in a plugin manifest. +type Version struct { + // Version is the semantic version string (e.g. "0.45.0"). + Version string `json:"version"` + + // Platforms lists the platform-specific binaries for this version. + Platforms []Platform `json:"platforms"` +} + +// Platform represents a platform-specific binary entry. +type Platform struct { + // OS is the target operating system (e.g. "darwin", "linux", "windows"). + OS string `json:"os"` + + // Arch is the target architecture (e.g. "amd64", "arm64"). + Arch string `json:"arch"` + + // URL is the download URL for the archive or binary. + URL string `json:"url"` + + // Checksum is the expected digest in ":" format + // (e.g. "sha256:cd85d5d84d264..."). + Checksum string `json:"checksum"` + + // ExtractPath overrides the default binary lookup name inside archives. + // When set, it is matched as an exact path within the archive (e.g. + // "bin/flux-operator"). When empty, the archive is searched by the + // base name derived from the manifest's Bin field. + ExtractPath string `json:"extractPath,omitempty"` +} + +// Catalog represents the generated catalog.yaml file. +type Catalog struct { + // APIVersion is the catalog schema version (e.g. "cli.fluxcd.io/v1beta1"). + APIVersion string `json:"apiVersion"` + + // Kind is the catalog type, must be "PluginCatalog". + Kind string `json:"kind"` + + // Plugins lists all available plugins in the catalog. + Plugins []CatalogEntry `json:"plugins"` +} + +// CatalogEntry is a single entry in the plugin catalog. +type CatalogEntry struct { + // Name is the plugin name. + Name string `json:"name"` + + // Description is a short human-readable summary of the plugin. + Description string `json:"description"` + + // Homepage is the URL to the plugin's documentation site. + Homepage string `json:"homepage"` + + // Source is the URL to the plugin's source repository. + Source string `json:"source"` + + // License is the SPDX license identifier (e.g. "Apache-2.0"). + License string `json:"license"` +} + +// Receipt records what was installed for a plugin. +type Receipt struct { + // Name is the plugin name (e.g. "operator"). + Name string `json:"name"` + + // Version is the installed semantic version. + Version string `json:"version"` + + // InstalledAt is the RFC 3339 timestamp of the installation. + InstalledAt string `json:"installedAt"` + + // Platform records the platform-specific details used for installation. + Platform Platform `json:"platform"` +}