1
0
mirror of synced 2026-03-31 14:04:19 +00:00

Compare commits

...

3 Commits

Author SHA1 Message Date
Stefan Prodan
0fe0a48015 Add plugin management commands
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2026-03-30 11:52:24 +03:00
Stefan Prodan
131cee951f Replace yacspin with briandowns/spinner for progress indication
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2026-03-30 11:52:01 +03:00
Stefan Prodan
1db4e66099 Implement plugin catalog and discovery system
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2026-03-30 11:51:21 +03:00
19 changed files with 2602 additions and 41 deletions

View File

@@ -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 {

340
cmd/flux/plugin.go Normal file
View File

@@ -0,0 +1,340 @@
/*
Copyright 2026 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"runtime"
"strings"
"time"
"github.com/briandowns/spinner"
"github.com/spf13/cobra"
"github.com/fluxcd/flux2/v2/internal/plugin"
"github.com/fluxcd/flux2/v2/pkg/printers"
)
var pluginHandler = plugin.NewHandler()
var pluginCmd = &cobra.Command{
Use: "plugin",
Short: "Manage Flux CLI plugins",
Long: `The plugin sub-commands manage Flux CLI plugins.`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// No-op: skip root's namespace DNS validation for plugin commands.
return nil
},
}
var pluginListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List installed plugins",
Long: `The plugin list command shows all installed plugins with their versions and paths.`,
RunE: pluginListCmdRun,
}
var pluginInstallCmd = &cobra.Command{
Use: "install <name>[@<version>]",
Short: "Install a plugin from the catalog",
Long: `The plugin install command downloads and installs a plugin from the Flux plugin catalog.
Examples:
# Install the latest version
flux plugin install operator
# Install a specific version
flux plugin install operator@0.45.0`,
Args: cobra.ExactArgs(1),
RunE: pluginInstallCmdRun,
}
var pluginUninstallCmd = &cobra.Command{
Use: "uninstall <name>",
Short: "Uninstall a plugin",
Long: `The plugin uninstall command removes a plugin binary and its receipt from the plugin directory.`,
Args: cobra.ExactArgs(1),
RunE: pluginUninstallCmdRun,
}
var pluginUpdateCmd = &cobra.Command{
Use: "update [name]",
Short: "Update installed plugins",
Long: `The plugin update command updates installed plugins to their latest versions.
Examples:
# Update a single plugin
flux plugin update operator
# Update all installed plugins
flux plugin update`,
Args: cobra.MaximumNArgs(1),
RunE: pluginUpdateCmdRun,
}
var pluginSearchCmd = &cobra.Command{
Use: "search [query]",
Short: "Search the plugin catalog",
Long: `The plugin search command lists available plugins from the Flux plugin catalog.`,
Args: cobra.MaximumNArgs(1),
RunE: pluginSearchCmdRun,
}
func init() {
pluginCmd.AddCommand(pluginListCmd)
pluginCmd.AddCommand(pluginInstallCmd)
pluginCmd.AddCommand(pluginUninstallCmd)
pluginCmd.AddCommand(pluginUpdateCmd)
pluginCmd.AddCommand(pluginSearchCmd)
rootCmd.AddCommand(pluginCmd)
}
// builtinCommandNames returns the names of all non-plugin commands on rootCmd.
func builtinCommandNames() []string {
var names []string
for _, c := range rootCmd.Commands() {
if c.GroupID != "plugin" {
names = append(names, c.Name())
}
}
return names
}
// registerPlugins scans the plugin directory and registers discovered
// plugins as Cobra subcommands on rootCmd.
func registerPlugins() {
plugins := pluginHandler.Discover(builtinCommandNames())
if len(plugins) == 0 {
return
}
if !rootCmd.ContainsGroup("plugin") {
rootCmd.AddGroup(&cobra.Group{
ID: "plugin",
Title: "Plugin Commands:",
})
}
for _, p := range plugins {
cmd := &cobra.Command{
Use: p.Name,
Short: fmt.Sprintf("Runs the %s plugin", p.Name),
Long: fmt.Sprintf("This command runs the %s plugin.\nUse 'flux %s --help' for full plugin help.", p.Name, p.Name),
DisableFlagParsing: true,
GroupID: "plugin",
ValidArgsFunction: plugin.CompleteFunc(p.Path),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return plugin.Exec(p.Path, args)
},
}
rootCmd.AddCommand(cmd)
}
}
func pluginListCmdRun(cmd *cobra.Command, args []string) error {
pluginDir := pluginHandler.PluginDir()
plugins := pluginHandler.Discover(builtinCommandNames())
if len(plugins) == 0 {
cmd.Println("No plugins found")
return nil
}
header := []string{"NAME", "VERSION", "PATH"}
var rows [][]string
for _, p := range plugins {
version := "manual"
if receipt := plugin.ReadReceipt(pluginDir, p.Name); receipt != nil {
version = receipt.Version
}
rows = append(rows, []string{p.Name, version, p.Path})
}
return printers.TablePrinter(header).Print(cmd.OutOrStdout(), rows)
}
func pluginInstallCmdRun(cmd *cobra.Command, args []string) error {
nameVersion := args[0]
name, version := parseNameVersion(nameVersion)
catalogClient := newCatalogClient()
manifest, err := catalogClient.FetchManifest(name)
if err != nil {
return err
}
pv, err := plugin.ResolveVersion(manifest, version)
if err != nil {
return err
}
plat, err := plugin.ResolvePlatform(pv, runtime.GOOS, runtime.GOARCH)
if err != nil {
return fmt.Errorf("plugin %q v%s has no binary for %s/%s", name, pv.Version, runtime.GOOS, runtime.GOARCH)
}
pluginDir := pluginHandler.EnsurePluginDir()
installer := plugin.NewInstaller()
sp := newPluginSpinner(fmt.Sprintf("installing %s v%s", name, pv.Version))
sp.Start()
if err := installer.Install(pluginDir, manifest, pv, plat); err != nil {
sp.Stop()
return err
}
sp.Stop()
logger.Successf("installed %s v%s", name, pv.Version)
return nil
}
func pluginUninstallCmdRun(cmd *cobra.Command, args []string) error {
name := args[0]
pluginDir := pluginHandler.PluginDir()
if err := plugin.Uninstall(pluginDir, name); err != nil {
return err
}
logger.Successf("uninstalled %s", name)
return nil
}
func pluginUpdateCmdRun(cmd *cobra.Command, args []string) error {
catalogClient := newCatalogClient()
plugins := pluginHandler.Discover(builtinCommandNames())
if len(plugins) == 0 {
cmd.Println("No plugins found")
return nil
}
// If a specific plugin is requested, filter to just that one.
if len(args) == 1 {
name := args[0]
var found bool
for _, p := range plugins {
if p.Name == name {
plugins = []plugin.Plugin{p}
found = true
break
}
}
if !found {
return fmt.Errorf("plugin %q is not installed", name)
}
}
pluginDir := pluginHandler.EnsurePluginDir()
installer := plugin.NewInstaller()
for _, p := range plugins {
result := plugin.CheckUpdate(pluginDir, p.Name, catalogClient, runtime.GOOS, runtime.GOARCH)
if result.Err != nil {
logger.Failuref("error checking %s: %v", p.Name, result.Err)
continue
}
if result.Skipped {
if result.SkipReason == plugin.SkipReasonManual {
logger.Warningf("skipping %s (%s)", p.Name, result.SkipReason)
} else {
logger.Successf("%s already up to date (v%s)", p.Name, result.FromVersion)
}
continue
}
sp := newPluginSpinner(fmt.Sprintf("updating %s v%s → v%s", p.Name, result.FromVersion, result.ToVersion))
sp.Start()
if err := installer.Install(pluginDir, result.Manifest, result.Version, result.Platform); err != nil {
sp.Stop()
logger.Failuref("error updating %s: %v", p.Name, err)
continue
}
sp.Stop()
logger.Successf("updated %s v%s → v%s", p.Name, result.FromVersion, result.ToVersion)
}
return nil
}
func pluginSearchCmdRun(cmd *cobra.Command, args []string) error {
catalogClient := newCatalogClient()
catalog, err := catalogClient.FetchCatalog()
if err != nil {
return err
}
var query string
if len(args) == 1 {
query = strings.ToLower(args[0])
}
pluginDir := pluginHandler.PluginDir()
header := []string{"NAME", "DESCRIPTION", "INSTALLED"}
var rows [][]string
for _, entry := range catalog.Plugins {
if query != "" {
if !strings.Contains(strings.ToLower(entry.Name), query) &&
!strings.Contains(strings.ToLower(entry.Description), query) {
continue
}
}
installed := ""
if receipt := plugin.ReadReceipt(pluginDir, entry.Name); receipt != nil {
installed = receipt.Version
}
rows = append(rows, []string{entry.Name, entry.Description, installed})
}
if len(rows) == 0 {
if query != "" {
cmd.Printf("No plugins matching %q found in catalog\n", query)
} else {
cmd.Println("No plugins found in catalog")
}
return nil
}
return printers.TablePrinter(header).Print(cmd.OutOrStdout(), rows)
}
// parseNameVersion splits "operator@0.45.0" into ("operator", "0.45.0").
// If no @ is present, version is empty (latest).
func parseNameVersion(s string) (string, string) {
name, version, found := strings.Cut(s, "@")
if found {
return name, version
}
return s, ""
}
// newCatalogClient creates a CatalogClient that respects FLUXCD_PLUGIN_CATALOG.
func newCatalogClient() *plugin.CatalogClient {
client := plugin.NewCatalogClient()
client.GetEnv = pluginHandler.GetEnv
return client
}
func newPluginSpinner(message string) *spinner.Spinner {
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
s.Suffix = " " + message
return s
}

265
cmd/flux/plugin_test.go Normal file
View File

@@ -0,0 +1,265 @@
/*
Copyright 2026 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"os"
"strings"
"testing"
"github.com/fluxcd/flux2/v2/internal/plugin"
)
func TestPluginAppearsInHelp(t *testing.T) {
origHandler := pluginHandler
defer func() { pluginHandler = origHandler }()
pluginDir := t.TempDir()
fakeBin := pluginDir + "/flux-testplugin"
os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755)
pluginHandler = &plugin.Handler{
ReadDir: os.ReadDir,
Stat: os.Stat,
GetEnv: func(key string) string {
if key == "FLUXCD_PLUGINS" {
return pluginDir
}
return ""
},
HomeDir: func() (string, error) { return t.TempDir(), nil },
}
registerPlugins()
defer func() {
cmds := rootCmd.Commands()
for _, cmd := range cmds {
if cmd.Name() == "testplugin" {
rootCmd.RemoveCommand(cmd)
break
}
}
rootCmd.SetHelpTemplate("")
}()
output, err := executeCommand("--help")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(output, "Plugin Commands:") {
t.Error("expected 'Plugin Commands:' in help output")
}
if !strings.Contains(output, "testplugin") {
t.Error("expected 'testplugin' in help output")
}
}
func TestPluginListOutput(t *testing.T) {
origHandler := pluginHandler
defer func() { pluginHandler = origHandler }()
pluginDir := t.TempDir()
fakeBin := pluginDir + "/flux-myplugin"
os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755)
pluginHandler = &plugin.Handler{
ReadDir: os.ReadDir,
Stat: os.Stat,
GetEnv: func(key string) string {
if key == "FLUXCD_PLUGINS" {
return pluginDir
}
return ""
},
HomeDir: func() (string, error) { return t.TempDir(), nil },
}
output, err := executeCommand("plugin list")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(output, "myplugin") {
t.Errorf("expected 'myplugin' in output, got: %s", output)
}
if !strings.Contains(output, "manual") {
t.Errorf("expected 'manual' in output (no receipt), got: %s", output)
}
}
func TestPluginListWithReceipt(t *testing.T) {
origHandler := pluginHandler
defer func() { pluginHandler = origHandler }()
pluginDir := t.TempDir()
fakeBin := pluginDir + "/flux-myplugin"
os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755)
receipt := pluginDir + "/flux-myplugin.yaml"
os.WriteFile(receipt, []byte("name: myplugin\nversion: \"1.2.3\"\n"), 0o644)
pluginHandler = &plugin.Handler{
ReadDir: os.ReadDir,
Stat: os.Stat,
GetEnv: func(key string) string {
if key == "FLUXCD_PLUGINS" {
return pluginDir
}
return ""
},
HomeDir: func() (string, error) { return t.TempDir(), nil },
}
output, err := executeCommand("plugin list")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(output, "1.2.3") {
t.Errorf("expected version '1.2.3' in output, got: %s", output)
}
}
func TestPluginListEmpty(t *testing.T) {
origHandler := pluginHandler
defer func() { pluginHandler = origHandler }()
pluginDir := t.TempDir()
pluginHandler = &plugin.Handler{
ReadDir: os.ReadDir,
Stat: os.Stat,
GetEnv: func(key string) string {
if key == "FLUXCD_PLUGINS" {
return pluginDir
}
return ""
},
HomeDir: func() (string, error) { return t.TempDir(), nil },
}
output, err := executeCommand("plugin list")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(output, "No plugins found") {
t.Errorf("expected 'No plugins found', got: %s", output)
}
}
func TestNoPluginsNoRegistration(t *testing.T) {
origHandler := pluginHandler
defer func() { pluginHandler = origHandler }()
pluginHandler = &plugin.Handler{
ReadDir: func(name string) ([]os.DirEntry, error) {
return nil, fmt.Errorf("no dir")
},
Stat: os.Stat,
GetEnv: func(key string) string {
if key == "FLUXCD_PLUGINS" {
return "/nonexistent"
}
return ""
},
HomeDir: func() (string, error) { return t.TempDir(), nil },
}
// Verify that registerPlugins with no plugins doesn't add any commands.
before := len(rootCmd.Commands())
registerPlugins()
after := len(rootCmd.Commands())
if after != before {
t.Errorf("expected no new commands, got %d new", after-before)
}
}
func TestPluginSkipsPersistentPreRun(t *testing.T) {
// Plugin commands override root's PersistentPreRunE with a no-op,
// so an invalid namespace should not trigger a validation error.
_, err := executeCommand("plugin list")
if err != nil {
t.Fatalf("plugin list should not trigger root's namespace validation: %v", err)
}
}
func TestParseNameVersion(t *testing.T) {
tests := []struct {
input string
wantName string
wantVersion string
}{
{"operator", "operator", ""},
{"operator@0.45.0", "operator", "0.45.0"},
{"my-tool@1.0.0", "my-tool", "1.0.0"},
{"plugin@", "plugin", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
name, version := parseNameVersion(tt.input)
if name != tt.wantName {
t.Errorf("name: got %q, want %q", name, tt.wantName)
}
if version != tt.wantVersion {
t.Errorf("version: got %q, want %q", version, tt.wantVersion)
}
})
}
}
func TestPluginDiscoverSkipsBuiltins(t *testing.T) {
origHandler := pluginHandler
defer func() { pluginHandler = origHandler }()
pluginDir := t.TempDir()
for _, name := range []string{"flux-get", "flux-create", "flux-version"} {
os.WriteFile(pluginDir+"/"+name, []byte("#!/bin/sh"), 0o755)
}
os.WriteFile(pluginDir+"/flux-myplugin", []byte("#!/bin/sh"), 0o755)
pluginHandler = &plugin.Handler{
ReadDir: os.ReadDir,
Stat: os.Stat,
GetEnv: func(key string) string {
if key == "FLUXCD_PLUGINS" {
return pluginDir
}
return ""
},
HomeDir: func() (string, error) { return t.TempDir(), nil },
}
plugins := pluginHandler.Discover(builtinCommandNames())
if len(plugins) != 1 {
names := make([]string, len(plugins))
for i, p := range plugins {
names[i] = p.Name
}
t.Fatalf("expected 1 plugin, got %d: %v", len(plugins), names)
}
if plugins[0].Name != "myplugin" {
t.Errorf("expected 'myplugin', got %q", plugins[0].Name)
}
}

4
go.mod
View File

@@ -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.0.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.48.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
@@ -162,7 +163,6 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // 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

4
go.sum
View File

@@ -91,6 +91,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.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
@@ -535,8 +537,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=

View File

@@ -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"
@@ -81,7 +81,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
@@ -111,22 +111,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
}
}
@@ -215,7 +202,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
@@ -746,12 +733,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
}
@@ -759,14 +741,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
}

View File

@@ -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()

213
internal/plugin/catalog.go Normal file
View File

@@ -0,0 +1,213 @@
/*
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"
)
const (
defaultCatalogBase = "https://raw.githubusercontent.com/fluxcd/plugins/main/"
envCatalogBase = "FLUXCD_PLUGIN_CATALOG"
pluginAPIVersion = "cli.fluxcd.io/v1beta1"
pluginKind = "Plugin"
catalogKind = "PluginCatalog"
)
// PluginManifest represents a single plugin's manifest from the catalog.
type PluginManifest struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Name string `json:"name"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Source string `json:"source,omitempty"`
Bin string `json:"bin"`
Versions []PluginVersion `json:"versions"`
}
// PluginVersion represents a version entry in a plugin manifest.
type PluginVersion struct {
Version string `json:"version"`
Platforms []PluginPlatform `json:"platforms"`
}
// PluginPlatform represents a platform-specific binary entry.
type PluginPlatform struct {
OS string `json:"os"`
Arch string `json:"arch"`
URL string `json:"url"`
Checksum string `json:"checksum"`
}
// PluginCatalog represents the generated catalog.yaml file.
type PluginCatalog struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Plugins []CatalogEntry `json:"plugins"`
}
// CatalogEntry is a single entry in the plugin catalog.
type CatalogEntry struct {
Name string `json:"name"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Source string `json:"source,omitempty"`
License string `json:"license,omitempty"`
}
// Receipt records what was installed for a plugin.
type Receipt struct {
Name string `json:"name"`
Version string `json:"version"`
InstalledAt string `json:"installedAt"`
Platform PluginPlatform `json:"platform"`
}
// CatalogClient fetches plugin manifests and catalogs from a remote URL.
type CatalogClient struct {
BaseURL string
HTTPClient *http.Client
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) (*PluginManifest, error) {
url := c.baseURL() + "plugins/" + name + ".yaml"
body, err := c.fetch(url)
if err != nil {
return nil, fmt.Errorf("plugin %q not found in catalog", name)
}
var manifest PluginManifest
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 != pluginAPIVersion {
return nil, fmt.Errorf("plugin %q has unsupported apiVersion %q (expected %q)", name, manifest.APIVersion, pluginAPIVersion)
}
if manifest.Kind != pluginKind {
return nil, fmt.Errorf("plugin %q has unexpected kind %q (expected %q)", name, manifest.Kind, pluginKind)
}
return &manifest, nil
}
// FetchCatalog fetches the generated catalog.yaml.
func (c *CatalogClient) FetchCatalog() (*PluginCatalog, 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 PluginCatalog
if err := yaml.Unmarshal(body, &catalog); err != nil {
return nil, fmt.Errorf("failed to parse plugin catalog: %w", err)
}
if catalog.APIVersion != pluginAPIVersion {
return nil, fmt.Errorf("plugin catalog has unsupported apiVersion %q (expected %q)", catalog.APIVersion, pluginAPIVersion)
}
if catalog.Kind != catalogKind {
return nil, fmt.Errorf("plugin catalog has unexpected kind %q (expected %q)", catalog.Kind, 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 *PluginManifest, version string) (*PluginVersion, 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 *PluginVersion, goos, goarch string) (*PluginPlatform, 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)
}

View File

@@ -0,0 +1,239 @@
/*
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"
)
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 == "/plugins/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 := &PluginManifest{
Name: "operator",
Versions: []PluginVersion{
{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(&PluginManifest{Name: "empty"}, "")
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestResolvePlatform(t *testing.T) {
pv := &PluginVersion{
Version: "0.45.0",
Platforms: []PluginPlatform{
{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")
}
})
}

View File

@@ -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 :<directive_int>.
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
}

View File

@@ -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])
}
}
})
}
}

View File

@@ -0,0 +1,195 @@
/*
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 string // e.g., "operator" (derived from "flux-operator")
Path string // absolute path to binary
}
// Handler discovers and executes plugins. Uses dependency injection
// for testability.
type Handler struct {
ReadDir func(name string) ([]os.DirEntry, error)
Stat func(name string) (os.FileInfo, error)
GetEnv func(key string) string
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
}

View File

@@ -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)
}
})
}

View File

@@ -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())
}

View File

@@ -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
}

235
internal/plugin/install.go Normal file
View File

@@ -0,0 +1,235 @@
/*
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"
)
// Installer handles downloading, verifying, and installing plugins.
type Installer struct {
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 *PluginManifest, pv *PluginVersion, plat *PluginPlatform) 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)
}
binName := manifest.Bin
if runtime.GOOS == "windows" {
binName += ".exe"
}
destName := pluginPrefix + manifest.Name
if runtime.GOOS == "windows" {
destName += ".exe"
}
destPath := filepath.Join(pluginDir, destName)
if strings.HasSuffix(plat.URL, ".zip") {
err = extractFromZip(tmpFile.Name(), binName, destPath)
} else {
err = extractFromTarGz(tmpFile.Name(), binName, destPath)
}
if err != nil {
return err
}
receipt := 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) *Receipt {
data, err := os.ReadFile(receiptPath(pluginDir, name))
if err != nil {
return nil
}
var receipt 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 *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)
}
// 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()
tr := tar.NewReader(gr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar: %w", err)
}
if filepath.IsAbs(header.Name) || strings.Contains(header.Name, "..") {
continue
}
if filepath.Base(header.Name) == targetName && header.Typeflag == tar.TypeReg {
return writeStreamToFile(tr, destPath)
}
}
return fmt.Errorf("binary %q not found in archive", targetName)
}
// extractFromZip extracts a named file from a zip archive
// and streams it directly to destPath.
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.Base(f.Name) == targetName && !f.FileInfo().IsDir() {
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open %q in zip: %w", targetName, err)
}
defer rc.Close()
return writeStreamToFile(rc, destPath)
}
}
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 {
out.Close()
return fmt.Errorf("failed to write plugin binary: %w", err)
}
return out.Close()
}

View File

@@ -0,0 +1,331 @@
/*
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"
"bytes"
"compress/gzip"
"crypto/sha256"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// 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
}
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 := &PluginManifest{
Name: "operator",
Bin: "flux-operator",
}
pv := &PluginVersion{Version: "0.45.0"}
plat := &PluginPlatform{
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 := &PluginManifest{Name: "operator", Bin: "flux-operator"}
pv := &PluginVersion{Version: "0.45.0"}
plat := &PluginPlatform{
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 := &PluginManifest{Name: "operator", Bin: "flux-operator"}
pv := &PluginVersion{Version: "0.45.0"}
plat := &PluginPlatform{
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 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 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")
}
}

85
internal/plugin/update.go Normal file
View File

@@ -0,0 +1,85 @@
/*
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
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 string
FromVersion string
ToVersion string
Skipped bool
SkipReason string
Manifest *PluginManifest
Version *PluginVersion
Platform *PluginPlatform
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,
}
}

View File

@@ -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)
}
}