1
0
mirror of synced 2026-05-03 10:03:32 +00:00

Merge pull request #5849 from fluxcd/plugin-system

[RFC-0013] Implement plugin system
This commit is contained in:
Stefan Prodan
2026-04-21 10:24:55 +03:00
committed by GitHub
26 changed files with 3484 additions and 41 deletions

View File

@@ -186,6 +186,8 @@ func main() {
// logger, we configure it's logger to do nothing. // logger, we configure it's logger to do nothing.
ctrllog.SetLogger(logr.New(ctrllog.NullLogSink{})) ctrllog.SetLogger(logr.New(ctrllog.NullLogSink{}))
registerPlugins()
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
if err, ok := err.(*RequestError); ok { if err, ok := err.(*RequestError); ok {

View File

@@ -374,6 +374,12 @@ func executeCommand(cmd string) (string, error) {
// in subsequent executions which causes tests to fail that rely on the value // in subsequent executions which causes tests to fail that rely on the value
// of "Changed". // of "Changed".
resumeCmd.PersistentFlags().Lookup("wait").Changed = false 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) args, err := shellwords.Parse(cmd)
if err != nil { if err != nil {

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

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

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 main
import (
"fmt"
"runtime"
"github.com/spf13/cobra"
"github.com/fluxcd/flux2/v2/internal/plugin"
)
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,
}
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
}

57
cmd/flux/plugin_list.go Normal file
View File

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

81
cmd/flux/plugin_search.go Normal file
View File

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

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

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

View File

@@ -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 <name>",
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
}

102
cmd/flux/plugin_update.go Normal file
View File

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

4
go.mod
View File

@@ -8,6 +8,7 @@ replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1
require ( require (
github.com/Masterminds/semver/v3 v3.4.0 github.com/Masterminds/semver/v3 v3.4.0
github.com/ProtonMail/go-crypto v1.3.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/cyphar/filepath-securejoin v0.6.1
github.com/distribution/distribution/v3 v3.1.0 github.com/distribution/distribution/v3 v3.1.0
github.com/fluxcd/cli-utils v0.37.2-flux.1 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-cmp v0.7.0
github.com/google/go-containerregistry v0.20.7 github.com/google/go-containerregistry v0.20.7
github.com/hashicorp/go-cleanhttp v0.5.2 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/homeport/dyff v1.10.2
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/manifoldco/promptui v0.9.0 github.com/manifoldco/promptui v0.9.0
@@ -49,7 +51,6 @@ require (
github.com/onsi/gomega v1.39.1 github.com/onsi/gomega v1.39.1
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/theckman/yacspin v0.13.12
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.50.0
golang.org/x/term v0.42.0 golang.org/x/term v0.42.0
golang.org/x/text v0.36.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/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // 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/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect

4
go.sum
View File

@@ -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/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 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 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 h1:o2FzZifLg+z/DN1OFmzTWzZZx/roaqt8IPZCIVco8r4=
github.com/bshuster-repo/logrus-logstash-hook v1.1.0/go.mod h1:Q2aXOe7rNuPgbBtPCOzYyWDvKX7+FpxE5sRdvcPoui0= 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= 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/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 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U=
github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8= 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 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= 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= github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo=

View File

@@ -30,7 +30,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/theckman/yacspin" "github.com/briandowns/spinner"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -141,7 +141,7 @@ type Builder struct {
action kustomize.Action action kustomize.Action
kustomization *kustomizev1.Kustomization kustomization *kustomizev1.Kustomization
timeout time.Duration timeout time.Duration
spinner *yacspin.Spinner spinner *spinner.Spinner
dryRun bool dryRun bool
strictSubst bool strictSubst bool
recursive bool recursive bool
@@ -173,22 +173,9 @@ func WithTimeout(timeout time.Duration) BuilderOptionFunc {
func WithProgressBar() BuilderOptionFunc { func WithProgressBar() BuilderOptionFunc {
return func(b *Builder) error { return func(b *Builder) error {
// Add a spinner s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
cfg := yacspin.Config{ s.Suffix = " Kustomization diffing... " + spinnerDryRunMessage
Frequency: 100 * time.Millisecond, b.spinner = s
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
return nil 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 { func withSpinnerFrom(in *Builder) BuilderOptionFunc {
return func(b *Builder) error { return func(b *Builder) error {
b.spinner = in.spinner b.spinner = in.spinner
@@ -838,12 +825,7 @@ func (b *Builder) StartSpinner() error {
if b.spinner == nil { if b.spinner == nil {
return nil return nil
} }
b.spinner.Start()
err := b.spinner.Start()
if err != nil {
return fmt.Errorf("failed to start spinner: %w", err)
}
return nil return nil
} }
@@ -851,14 +833,6 @@ func (b *Builder) StopSpinner() error {
if b.spinner == nil { if b.spinner == nil {
return nil return nil
} }
b.spinner.Stop()
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)
}
}
return nil return nil
} }

View File

@@ -173,14 +173,14 @@ func (b *Builder) diff() (string, bool, error) {
// finished with Kustomization diff // finished with Kustomization diff
if b.spinner != nil { if b.spinner != nil {
b.spinner.Message(spinnerDryRunMessage) b.spinner.Suffix = " " + spinnerDryRunMessage
} }
} }
} }
} }
if b.spinner != nil { if b.spinner != nil {
b.spinner.Message("processing inventory") b.spinner.Suffix = " processing inventory"
} }
if b.kustomization.Spec.Prune && len(diffErrs) == 0 { 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) { func (b *Builder) kustomizationDiff(kustomization *kustomizev1.Kustomization) (string, bool, error) {
if b.spinner != nil { 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() sourceRef := kustomization.Spec.SourceRef.DeepCopy()

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

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

View File

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

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

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
}

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

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

View File

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

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

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

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

135
pkg/plugin/types.go Normal file
View File

@@ -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 <name>" 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 "<algorithm>:<hex>" 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"`
}