Compare commits
3 Commits
rfc-cli-pl
...
plugin-sys
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fe0a48015 | ||
|
|
131cee951f | ||
|
|
1db4e66099 |
@@ -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 {
|
||||||
|
|||||||
340
cmd/flux/plugin.go
Normal file
340
cmd/flux/plugin.go
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/briandowns/spinner"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/fluxcd/flux2/v2/internal/plugin"
|
||||||
|
"github.com/fluxcd/flux2/v2/pkg/printers"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pluginHandler = plugin.NewHandler()
|
||||||
|
|
||||||
|
var pluginCmd = &cobra.Command{
|
||||||
|
Use: "plugin",
|
||||||
|
Short: "Manage Flux CLI plugins",
|
||||||
|
Long: `The plugin sub-commands manage Flux CLI plugins.`,
|
||||||
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// No-op: skip root's namespace DNS validation for plugin commands.
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var pluginListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
|
Short: "List installed plugins",
|
||||||
|
Long: `The plugin list command shows all installed plugins with their versions and paths.`,
|
||||||
|
RunE: pluginListCmdRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
var pluginInstallCmd = &cobra.Command{
|
||||||
|
Use: "install <name>[@<version>]",
|
||||||
|
Short: "Install a plugin from the catalog",
|
||||||
|
Long: `The plugin install command downloads and installs a plugin from the Flux plugin catalog.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Install the latest version
|
||||||
|
flux plugin install operator
|
||||||
|
|
||||||
|
# Install a specific version
|
||||||
|
flux plugin install operator@0.45.0`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: pluginInstallCmdRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
var pluginUninstallCmd = &cobra.Command{
|
||||||
|
Use: "uninstall <name>",
|
||||||
|
Short: "Uninstall a plugin",
|
||||||
|
Long: `The plugin uninstall command removes a plugin binary and its receipt from the plugin directory.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: pluginUninstallCmdRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
var pluginUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update [name]",
|
||||||
|
Short: "Update installed plugins",
|
||||||
|
Long: `The plugin update command updates installed plugins to their latest versions.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Update a single plugin
|
||||||
|
flux plugin update operator
|
||||||
|
|
||||||
|
# Update all installed plugins
|
||||||
|
flux plugin update`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: pluginUpdateCmdRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
var pluginSearchCmd = &cobra.Command{
|
||||||
|
Use: "search [query]",
|
||||||
|
Short: "Search the plugin catalog",
|
||||||
|
Long: `The plugin search command lists available plugins from the Flux plugin catalog.`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: pluginSearchCmdRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
pluginCmd.AddCommand(pluginListCmd)
|
||||||
|
pluginCmd.AddCommand(pluginInstallCmd)
|
||||||
|
pluginCmd.AddCommand(pluginUninstallCmd)
|
||||||
|
pluginCmd.AddCommand(pluginUpdateCmd)
|
||||||
|
pluginCmd.AddCommand(pluginSearchCmd)
|
||||||
|
rootCmd.AddCommand(pluginCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtinCommandNames returns the names of all non-plugin commands on rootCmd.
|
||||||
|
func builtinCommandNames() []string {
|
||||||
|
var names []string
|
||||||
|
for _, c := range rootCmd.Commands() {
|
||||||
|
if c.GroupID != "plugin" {
|
||||||
|
names = append(names, c.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerPlugins scans the plugin directory and registers discovered
|
||||||
|
// plugins as Cobra subcommands on rootCmd.
|
||||||
|
func registerPlugins() {
|
||||||
|
plugins := pluginHandler.Discover(builtinCommandNames())
|
||||||
|
if len(plugins) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rootCmd.ContainsGroup("plugin") {
|
||||||
|
rootCmd.AddGroup(&cobra.Group{
|
||||||
|
ID: "plugin",
|
||||||
|
Title: "Plugin Commands:",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range plugins {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: p.Name,
|
||||||
|
Short: fmt.Sprintf("Runs the %s plugin", p.Name),
|
||||||
|
Long: fmt.Sprintf("This command runs the %s plugin.\nUse 'flux %s --help' for full plugin help.", p.Name, p.Name),
|
||||||
|
DisableFlagParsing: true,
|
||||||
|
GroupID: "plugin",
|
||||||
|
ValidArgsFunction: plugin.CompleteFunc(p.Path),
|
||||||
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return plugin.Exec(p.Path, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rootCmd.AddCommand(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginListCmdRun(cmd *cobra.Command, args []string) error {
|
||||||
|
pluginDir := pluginHandler.PluginDir()
|
||||||
|
plugins := pluginHandler.Discover(builtinCommandNames())
|
||||||
|
if len(plugins) == 0 {
|
||||||
|
cmd.Println("No plugins found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
header := []string{"NAME", "VERSION", "PATH"}
|
||||||
|
var rows [][]string
|
||||||
|
for _, p := range plugins {
|
||||||
|
version := "manual"
|
||||||
|
if receipt := plugin.ReadReceipt(pluginDir, p.Name); receipt != nil {
|
||||||
|
version = receipt.Version
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{p.Name, version, p.Path})
|
||||||
|
}
|
||||||
|
|
||||||
|
return printers.TablePrinter(header).Print(cmd.OutOrStdout(), rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginInstallCmdRun(cmd *cobra.Command, args []string) error {
|
||||||
|
nameVersion := args[0]
|
||||||
|
name, version := parseNameVersion(nameVersion)
|
||||||
|
|
||||||
|
catalogClient := newCatalogClient()
|
||||||
|
manifest, err := catalogClient.FetchManifest(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pv, err := plugin.ResolveVersion(manifest, version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
plat, err := plugin.ResolvePlatform(pv, runtime.GOOS, runtime.GOARCH)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plugin %q v%s has no binary for %s/%s", name, pv.Version, runtime.GOOS, runtime.GOARCH)
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginDir := pluginHandler.EnsurePluginDir()
|
||||||
|
|
||||||
|
installer := plugin.NewInstaller()
|
||||||
|
sp := newPluginSpinner(fmt.Sprintf("installing %s v%s", name, pv.Version))
|
||||||
|
sp.Start()
|
||||||
|
if err := installer.Install(pluginDir, manifest, pv, plat); err != nil {
|
||||||
|
sp.Stop()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sp.Stop()
|
||||||
|
|
||||||
|
logger.Successf("installed %s v%s", name, pv.Version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginUninstallCmdRun(cmd *cobra.Command, args []string) error {
|
||||||
|
name := args[0]
|
||||||
|
pluginDir := pluginHandler.PluginDir()
|
||||||
|
|
||||||
|
if err := plugin.Uninstall(pluginDir, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Successf("uninstalled %s", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginUpdateCmdRun(cmd *cobra.Command, args []string) error {
|
||||||
|
catalogClient := newCatalogClient()
|
||||||
|
|
||||||
|
plugins := pluginHandler.Discover(builtinCommandNames())
|
||||||
|
if len(plugins) == 0 {
|
||||||
|
cmd.Println("No plugins found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a specific plugin is requested, filter to just that one.
|
||||||
|
if len(args) == 1 {
|
||||||
|
name := args[0]
|
||||||
|
var found bool
|
||||||
|
for _, p := range plugins {
|
||||||
|
if p.Name == name {
|
||||||
|
plugins = []plugin.Plugin{p}
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("plugin %q is not installed", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginDir := pluginHandler.EnsurePluginDir()
|
||||||
|
installer := plugin.NewInstaller()
|
||||||
|
for _, p := range plugins {
|
||||||
|
result := plugin.CheckUpdate(pluginDir, p.Name, catalogClient, runtime.GOOS, runtime.GOARCH)
|
||||||
|
if result.Err != nil {
|
||||||
|
logger.Failuref("error checking %s: %v", p.Name, result.Err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Skipped {
|
||||||
|
if result.SkipReason == plugin.SkipReasonManual {
|
||||||
|
logger.Warningf("skipping %s (%s)", p.Name, result.SkipReason)
|
||||||
|
} else {
|
||||||
|
logger.Successf("%s already up to date (v%s)", p.Name, result.FromVersion)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := newPluginSpinner(fmt.Sprintf("updating %s v%s → v%s", p.Name, result.FromVersion, result.ToVersion))
|
||||||
|
sp.Start()
|
||||||
|
if err := installer.Install(pluginDir, result.Manifest, result.Version, result.Platform); err != nil {
|
||||||
|
sp.Stop()
|
||||||
|
logger.Failuref("error updating %s: %v", p.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sp.Stop()
|
||||||
|
logger.Successf("updated %s v%s → v%s", p.Name, result.FromVersion, result.ToVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginSearchCmdRun(cmd *cobra.Command, args []string) error {
|
||||||
|
catalogClient := newCatalogClient()
|
||||||
|
catalog, err := catalogClient.FetchCatalog()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
if len(args) == 1 {
|
||||||
|
query = strings.ToLower(args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginDir := pluginHandler.PluginDir()
|
||||||
|
header := []string{"NAME", "DESCRIPTION", "INSTALLED"}
|
||||||
|
var rows [][]string
|
||||||
|
for _, entry := range catalog.Plugins {
|
||||||
|
if query != "" {
|
||||||
|
if !strings.Contains(strings.ToLower(entry.Name), query) &&
|
||||||
|
!strings.Contains(strings.ToLower(entry.Description), query) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
installed := ""
|
||||||
|
if receipt := plugin.ReadReceipt(pluginDir, entry.Name); receipt != nil {
|
||||||
|
installed = receipt.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, []string{entry.Name, entry.Description, installed})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
if query != "" {
|
||||||
|
cmd.Printf("No plugins matching %q found in catalog\n", query)
|
||||||
|
} else {
|
||||||
|
cmd.Println("No plugins found in catalog")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return printers.TablePrinter(header).Print(cmd.OutOrStdout(), rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNameVersion splits "operator@0.45.0" into ("operator", "0.45.0").
|
||||||
|
// If no @ is present, version is empty (latest).
|
||||||
|
func parseNameVersion(s string) (string, string) {
|
||||||
|
name, version, found := strings.Cut(s, "@")
|
||||||
|
if found {
|
||||||
|
return name, version
|
||||||
|
}
|
||||||
|
return s, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCatalogClient creates a CatalogClient that respects FLUXCD_PLUGIN_CATALOG.
|
||||||
|
func newCatalogClient() *plugin.CatalogClient {
|
||||||
|
client := plugin.NewCatalogClient()
|
||||||
|
client.GetEnv = pluginHandler.GetEnv
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPluginSpinner(message string) *spinner.Spinner {
|
||||||
|
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
|
||||||
|
s.Suffix = " " + message
|
||||||
|
return s
|
||||||
|
}
|
||||||
265
cmd/flux/plugin_test.go
Normal file
265
cmd/flux/plugin_test.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/fluxcd/flux2/v2/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPluginAppearsInHelp(t *testing.T) {
|
||||||
|
origHandler := pluginHandler
|
||||||
|
defer func() { pluginHandler = origHandler }()
|
||||||
|
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
fakeBin := pluginDir + "/flux-testplugin"
|
||||||
|
os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755)
|
||||||
|
|
||||||
|
pluginHandler = &plugin.Handler{
|
||||||
|
ReadDir: os.ReadDir,
|
||||||
|
Stat: os.Stat,
|
||||||
|
GetEnv: func(key string) string {
|
||||||
|
if key == "FLUXCD_PLUGINS" {
|
||||||
|
return pluginDir
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPlugins()
|
||||||
|
defer func() {
|
||||||
|
cmds := rootCmd.Commands()
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
if cmd.Name() == "testplugin" {
|
||||||
|
rootCmd.RemoveCommand(cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootCmd.SetHelpTemplate("")
|
||||||
|
}()
|
||||||
|
|
||||||
|
output, err := executeCommand("--help")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(output, "Plugin Commands:") {
|
||||||
|
t.Error("expected 'Plugin Commands:' in help output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "testplugin") {
|
||||||
|
t.Error("expected 'testplugin' in help output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginListOutput(t *testing.T) {
|
||||||
|
origHandler := pluginHandler
|
||||||
|
defer func() { pluginHandler = origHandler }()
|
||||||
|
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
fakeBin := pluginDir + "/flux-myplugin"
|
||||||
|
os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755)
|
||||||
|
|
||||||
|
pluginHandler = &plugin.Handler{
|
||||||
|
ReadDir: os.ReadDir,
|
||||||
|
Stat: os.Stat,
|
||||||
|
GetEnv: func(key string) string {
|
||||||
|
if key == "FLUXCD_PLUGINS" {
|
||||||
|
return pluginDir
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := executeCommand("plugin list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(output, "myplugin") {
|
||||||
|
t.Errorf("expected 'myplugin' in output, got: %s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "manual") {
|
||||||
|
t.Errorf("expected 'manual' in output (no receipt), got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginListWithReceipt(t *testing.T) {
|
||||||
|
origHandler := pluginHandler
|
||||||
|
defer func() { pluginHandler = origHandler }()
|
||||||
|
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
fakeBin := pluginDir + "/flux-myplugin"
|
||||||
|
os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755)
|
||||||
|
receipt := pluginDir + "/flux-myplugin.yaml"
|
||||||
|
os.WriteFile(receipt, []byte("name: myplugin\nversion: \"1.2.3\"\n"), 0o644)
|
||||||
|
|
||||||
|
pluginHandler = &plugin.Handler{
|
||||||
|
ReadDir: os.ReadDir,
|
||||||
|
Stat: os.Stat,
|
||||||
|
GetEnv: func(key string) string {
|
||||||
|
if key == "FLUXCD_PLUGINS" {
|
||||||
|
return pluginDir
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := executeCommand("plugin list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(output, "1.2.3") {
|
||||||
|
t.Errorf("expected version '1.2.3' in output, got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginListEmpty(t *testing.T) {
|
||||||
|
origHandler := pluginHandler
|
||||||
|
defer func() { pluginHandler = origHandler }()
|
||||||
|
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
pluginHandler = &plugin.Handler{
|
||||||
|
ReadDir: os.ReadDir,
|
||||||
|
Stat: os.Stat,
|
||||||
|
GetEnv: func(key string) string {
|
||||||
|
if key == "FLUXCD_PLUGINS" {
|
||||||
|
return pluginDir
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := executeCommand("plugin list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(output, "No plugins found") {
|
||||||
|
t.Errorf("expected 'No plugins found', got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoPluginsNoRegistration(t *testing.T) {
|
||||||
|
origHandler := pluginHandler
|
||||||
|
defer func() { pluginHandler = origHandler }()
|
||||||
|
|
||||||
|
pluginHandler = &plugin.Handler{
|
||||||
|
ReadDir: func(name string) ([]os.DirEntry, error) {
|
||||||
|
return nil, fmt.Errorf("no dir")
|
||||||
|
},
|
||||||
|
Stat: os.Stat,
|
||||||
|
GetEnv: func(key string) string {
|
||||||
|
if key == "FLUXCD_PLUGINS" {
|
||||||
|
return "/nonexistent"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that registerPlugins with no plugins doesn't add any commands.
|
||||||
|
before := len(rootCmd.Commands())
|
||||||
|
registerPlugins()
|
||||||
|
after := len(rootCmd.Commands())
|
||||||
|
if after != before {
|
||||||
|
t.Errorf("expected no new commands, got %d new", after-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginSkipsPersistentPreRun(t *testing.T) {
|
||||||
|
// Plugin commands override root's PersistentPreRunE with a no-op,
|
||||||
|
// so an invalid namespace should not trigger a validation error.
|
||||||
|
_, err := executeCommand("plugin list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("plugin list should not trigger root's namespace validation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNameVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
wantName string
|
||||||
|
wantVersion string
|
||||||
|
}{
|
||||||
|
{"operator", "operator", ""},
|
||||||
|
{"operator@0.45.0", "operator", "0.45.0"},
|
||||||
|
{"my-tool@1.0.0", "my-tool", "1.0.0"},
|
||||||
|
{"plugin@", "plugin", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
name, version := parseNameVersion(tt.input)
|
||||||
|
if name != tt.wantName {
|
||||||
|
t.Errorf("name: got %q, want %q", name, tt.wantName)
|
||||||
|
}
|
||||||
|
if version != tt.wantVersion {
|
||||||
|
t.Errorf("version: got %q, want %q", version, tt.wantVersion)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginDiscoverSkipsBuiltins(t *testing.T) {
|
||||||
|
origHandler := pluginHandler
|
||||||
|
defer func() { pluginHandler = origHandler }()
|
||||||
|
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
for _, name := range []string{"flux-get", "flux-create", "flux-version"} {
|
||||||
|
os.WriteFile(pluginDir+"/"+name, []byte("#!/bin/sh"), 0o755)
|
||||||
|
}
|
||||||
|
os.WriteFile(pluginDir+"/flux-myplugin", []byte("#!/bin/sh"), 0o755)
|
||||||
|
|
||||||
|
pluginHandler = &plugin.Handler{
|
||||||
|
ReadDir: os.ReadDir,
|
||||||
|
Stat: os.Stat,
|
||||||
|
GetEnv: func(key string) string {
|
||||||
|
if key == "FLUXCD_PLUGINS" {
|
||||||
|
return pluginDir
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins := pluginHandler.Discover(builtinCommandNames())
|
||||||
|
|
||||||
|
if len(plugins) != 1 {
|
||||||
|
names := make([]string, len(plugins))
|
||||||
|
for i, p := range plugins {
|
||||||
|
names[i] = p.Name
|
||||||
|
}
|
||||||
|
t.Fatalf("expected 1 plugin, got %d: %v", len(plugins), names)
|
||||||
|
}
|
||||||
|
if plugins[0].Name != "myplugin" {
|
||||||
|
t.Errorf("expected 'myplugin', got %q", plugins[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
go.mod
4
go.mod
@@ -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.0.0
|
github.com/distribution/distribution/v3 v3.0.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.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/term v0.40.0
|
golang.org/x/term v0.40.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.34.0
|
||||||
@@ -162,7 +163,6 @@ require (
|
|||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // 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
4
go.sum
@@ -91,6 +91,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
|||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/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.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
|
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
|
||||||
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
|
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
|
||||||
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
|
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
|
||||||
@@ -535,8 +537,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/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=
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -81,7 +81,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
|
||||||
@@ -111,22 +111,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +202,7 @@ func withClientConfigFrom(in *Builder) BuilderOptionFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// withClientConfigFrom copies spinner field
|
// withSpinnerFrom copies the spinner field from another Builder.
|
||||||
func withSpinnerFrom(in *Builder) BuilderOptionFunc {
|
func withSpinnerFrom(in *Builder) BuilderOptionFunc {
|
||||||
return func(b *Builder) error {
|
return func(b *Builder) error {
|
||||||
b.spinner = in.spinner
|
b.spinner = in.spinner
|
||||||
@@ -746,12 +733,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,14 +741,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
213
internal/plugin/catalog.go
Normal file
213
internal/plugin/catalog.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/go-retryablehttp"
|
||||||
|
"sigs.k8s.io/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultCatalogBase = "https://raw.githubusercontent.com/fluxcd/plugins/main/"
|
||||||
|
envCatalogBase = "FLUXCD_PLUGIN_CATALOG"
|
||||||
|
|
||||||
|
pluginAPIVersion = "cli.fluxcd.io/v1beta1"
|
||||||
|
pluginKind = "Plugin"
|
||||||
|
catalogKind = "PluginCatalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginManifest represents a single plugin's manifest from the catalog.
|
||||||
|
type PluginManifest struct {
|
||||||
|
APIVersion string `json:"apiVersion"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Bin string `json:"bin"`
|
||||||
|
Versions []PluginVersion `json:"versions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginVersion represents a version entry in a plugin manifest.
|
||||||
|
type PluginVersion struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Platforms []PluginPlatform `json:"platforms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginPlatform represents a platform-specific binary entry.
|
||||||
|
type PluginPlatform struct {
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginCatalog represents the generated catalog.yaml file.
|
||||||
|
type PluginCatalog struct {
|
||||||
|
APIVersion string `json:"apiVersion"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Plugins []CatalogEntry `json:"plugins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogEntry is a single entry in the plugin catalog.
|
||||||
|
type CatalogEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
License string `json:"license,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receipt records what was installed for a plugin.
|
||||||
|
type Receipt struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
InstalledAt string `json:"installedAt"`
|
||||||
|
Platform PluginPlatform `json:"platform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogClient fetches plugin manifests and catalogs from a remote URL.
|
||||||
|
type CatalogClient struct {
|
||||||
|
BaseURL string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
GetEnv func(key string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCatalogClient returns a CatalogClient with production defaults.
|
||||||
|
func NewCatalogClient() *CatalogClient {
|
||||||
|
return &CatalogClient{
|
||||||
|
BaseURL: defaultCatalogBase,
|
||||||
|
HTTPClient: newHTTPClient(30 * time.Second),
|
||||||
|
GetEnv: func(key string) string { return "" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseURL returns the effective catalog base URL.
|
||||||
|
func (c *CatalogClient) baseURL() string {
|
||||||
|
if env := c.GetEnv(envCatalogBase); env != "" {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
return c.BaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchManifest fetches a single plugin manifest from the catalog.
|
||||||
|
func (c *CatalogClient) FetchManifest(name string) (*PluginManifest, error) {
|
||||||
|
url := c.baseURL() + "plugins/" + name + ".yaml"
|
||||||
|
body, err := c.fetch(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("plugin %q not found in catalog", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest PluginManifest
|
||||||
|
if err := yaml.Unmarshal(body, &manifest); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse plugin manifest for %q: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest.APIVersion != pluginAPIVersion {
|
||||||
|
return nil, fmt.Errorf("plugin %q has unsupported apiVersion %q (expected %q)", name, manifest.APIVersion, pluginAPIVersion)
|
||||||
|
}
|
||||||
|
if manifest.Kind != pluginKind {
|
||||||
|
return nil, fmt.Errorf("plugin %q has unexpected kind %q (expected %q)", name, manifest.Kind, pluginKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCatalog fetches the generated catalog.yaml.
|
||||||
|
func (c *CatalogClient) FetchCatalog() (*PluginCatalog, error) {
|
||||||
|
url := c.baseURL() + "catalog.yaml"
|
||||||
|
body, err := c.fetch(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch plugin catalog: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog PluginCatalog
|
||||||
|
if err := yaml.Unmarshal(body, &catalog); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse plugin catalog: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if catalog.APIVersion != pluginAPIVersion {
|
||||||
|
return nil, fmt.Errorf("plugin catalog has unsupported apiVersion %q (expected %q)", catalog.APIVersion, pluginAPIVersion)
|
||||||
|
}
|
||||||
|
if catalog.Kind != catalogKind {
|
||||||
|
return nil, fmt.Errorf("plugin catalog has unexpected kind %q (expected %q)", catalog.Kind, catalogKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &catalog, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxResponseBytes = 10 << 20 // 10 MiB
|
||||||
|
|
||||||
|
func (c *CatalogClient) fetch(url string) ([]byte, error) {
|
||||||
|
resp, err := c.HTTPClient.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHTTPClient returns a retrying HTTP client with the given timeout.
|
||||||
|
func newHTTPClient(timeout time.Duration) *http.Client {
|
||||||
|
rc := retryablehttp.NewClient()
|
||||||
|
rc.RetryMax = 3
|
||||||
|
rc.Logger = nil
|
||||||
|
c := rc.StandardClient()
|
||||||
|
c.Timeout = timeout
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveVersion finds the requested version in the manifest.
|
||||||
|
// If version is empty, returns the first (latest) version.
|
||||||
|
func ResolveVersion(manifest *PluginManifest, version string) (*PluginVersion, error) {
|
||||||
|
if len(manifest.Versions) == 0 {
|
||||||
|
return nil, fmt.Errorf("plugin %q has no versions", manifest.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if version == "" {
|
||||||
|
return &manifest.Versions[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range manifest.Versions {
|
||||||
|
if manifest.Versions[i].Version == version {
|
||||||
|
return &manifest.Versions[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("version %q not found for plugin %q", version, manifest.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePlatform finds the platform entry matching the given OS and arch.
|
||||||
|
func ResolvePlatform(pv *PluginVersion, goos, goarch string) (*PluginPlatform, error) {
|
||||||
|
for i := range pv.Platforms {
|
||||||
|
if pv.Platforms[i].OS == goos && pv.Platforms[i].Arch == goarch {
|
||||||
|
return &pv.Platforms[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no binary for %s/%s", goos, goarch)
|
||||||
|
}
|
||||||
239
internal/plugin/catalog_test.go
Normal file
239
internal/plugin/catalog_test.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchManifest(t *testing.T) {
|
||||||
|
manifest := `
|
||||||
|
apiVersion: cli.fluxcd.io/v1beta1
|
||||||
|
kind: Plugin
|
||||||
|
name: operator
|
||||||
|
description: Flux Operator CLI
|
||||||
|
bin: flux-operator
|
||||||
|
versions:
|
||||||
|
- version: 0.45.0
|
||||||
|
platforms:
|
||||||
|
- os: linux
|
||||||
|
arch: amd64
|
||||||
|
url: https://example.com/flux-operator_0.45.0_linux_amd64.tar.gz
|
||||||
|
checksum: sha256:abc123
|
||||||
|
`
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/plugins/operator.yaml" {
|
||||||
|
w.Write([]byte(manifest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := &CatalogClient{
|
||||||
|
BaseURL: server.URL + "/",
|
||||||
|
HTTPClient: server.Client(),
|
||||||
|
GetEnv: func(key string) string { return "" },
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := client.FetchManifest("operator")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if m.Name != "operator" {
|
||||||
|
t.Errorf("expected name 'operator', got %q", m.Name)
|
||||||
|
}
|
||||||
|
if m.Bin != "flux-operator" {
|
||||||
|
t.Errorf("expected bin 'flux-operator', got %q", m.Bin)
|
||||||
|
}
|
||||||
|
if len(m.Versions) != 1 {
|
||||||
|
t.Fatalf("expected 1 version, got %d", len(m.Versions))
|
||||||
|
}
|
||||||
|
if m.Versions[0].Version != "0.45.0" {
|
||||||
|
t.Errorf("expected version '0.45.0', got %q", m.Versions[0].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchManifestNotFound(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := &CatalogClient{
|
||||||
|
BaseURL: server.URL + "/",
|
||||||
|
HTTPClient: server.Client(),
|
||||||
|
GetEnv: func(key string) string { return "" },
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := client.FetchManifest("nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchCatalog(t *testing.T) {
|
||||||
|
catalog := `
|
||||||
|
apiVersion: cli.fluxcd.io/v1beta1
|
||||||
|
kind: PluginCatalog
|
||||||
|
plugins:
|
||||||
|
- name: operator
|
||||||
|
description: Flux Operator CLI
|
||||||
|
homepage: https://fluxoperator.dev/
|
||||||
|
source: https://github.com/controlplaneio-fluxcd/flux-operator
|
||||||
|
license: AGPL-3.0
|
||||||
|
- name: schema
|
||||||
|
description: CRD schemas
|
||||||
|
homepage: https://example.com/
|
||||||
|
source: https://github.com/example/flux-schema
|
||||||
|
license: Apache-2.0
|
||||||
|
`
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/catalog.yaml" {
|
||||||
|
w.Write([]byte(catalog))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := &CatalogClient{
|
||||||
|
BaseURL: server.URL + "/",
|
||||||
|
HTTPClient: server.Client(),
|
||||||
|
GetEnv: func(key string) string { return "" },
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := client.FetchCatalog()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(c.Plugins) != 2 {
|
||||||
|
t.Fatalf("expected 2 plugins, got %d", len(c.Plugins))
|
||||||
|
}
|
||||||
|
if c.Plugins[0].Name != "operator" {
|
||||||
|
t.Errorf("expected name 'operator', got %q", c.Plugins[0].Name)
|
||||||
|
}
|
||||||
|
if c.Plugins[1].Name != "schema" {
|
||||||
|
t.Errorf("expected name 'schema', got %q", c.Plugins[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogEnvOverride(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/custom/catalog.yaml" {
|
||||||
|
w.Write([]byte(`apiVersion: cli.fluxcd.io/v1beta1
|
||||||
|
kind: PluginCatalog
|
||||||
|
plugins: []
|
||||||
|
`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := &CatalogClient{
|
||||||
|
BaseURL: "https://should-not-be-used/",
|
||||||
|
HTTPClient: server.Client(),
|
||||||
|
GetEnv: func(key string) string {
|
||||||
|
if key == envCatalogBase {
|
||||||
|
return server.URL + "/custom/"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := client.FetchCatalog()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(c.Plugins) != 0 {
|
||||||
|
t.Fatalf("expected 0 plugins, got %d", len(c.Plugins))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveVersion(t *testing.T) {
|
||||||
|
manifest := &PluginManifest{
|
||||||
|
Name: "operator",
|
||||||
|
Versions: []PluginVersion{
|
||||||
|
{Version: "0.45.0"},
|
||||||
|
{Version: "0.44.0"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("latest", func(t *testing.T) {
|
||||||
|
v, err := ResolveVersion(manifest, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if v.Version != "0.45.0" {
|
||||||
|
t.Errorf("expected '0.45.0', got %q", v.Version)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("specific", func(t *testing.T) {
|
||||||
|
v, err := ResolveVersion(manifest, "0.44.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if v.Version != "0.44.0" {
|
||||||
|
t.Errorf("expected '0.44.0', got %q", v.Version)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not found", func(t *testing.T) {
|
||||||
|
_, err := ResolveVersion(manifest, "0.99.0")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no versions", func(t *testing.T) {
|
||||||
|
_, err := ResolveVersion(&PluginManifest{Name: "empty"}, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePlatform(t *testing.T) {
|
||||||
|
pv := &PluginVersion{
|
||||||
|
Version: "0.45.0",
|
||||||
|
Platforms: []PluginPlatform{
|
||||||
|
{OS: "darwin", Arch: "arm64", URL: "https://example.com/darwin_arm64.tar.gz"},
|
||||||
|
{OS: "linux", Arch: "amd64", URL: "https://example.com/linux_amd64.tar.gz"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("found", func(t *testing.T) {
|
||||||
|
p, err := ResolvePlatform(pv, "darwin", "arm64")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if p.OS != "darwin" || p.Arch != "arm64" {
|
||||||
|
t.Errorf("unexpected platform: %s/%s", p.OS, p.Arch)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not found", func(t *testing.T) {
|
||||||
|
_, err := ResolvePlatform(pv, "windows", "amd64")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
75
internal/plugin/completion.go
Normal file
75
internal/plugin/completion.go
Normal 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
|
||||||
|
}
|
||||||
80
internal/plugin/completion_test.go
Normal file
80
internal/plugin/completion_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
195
internal/plugin/discovery.go
Normal file
195
internal/plugin/discovery.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pluginPrefix = "flux-"
|
||||||
|
defaultDirName = "plugins"
|
||||||
|
defaultBaseDir = ".fluxcd"
|
||||||
|
envPluginDir = "FLUXCD_PLUGINS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reservedNames are command names that cannot be used as plugin names.
|
||||||
|
var reservedNames = map[string]bool{
|
||||||
|
"plugin": true,
|
||||||
|
"help": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin represents a discovered plugin binary.
|
||||||
|
type Plugin struct {
|
||||||
|
Name string // e.g., "operator" (derived from "flux-operator")
|
||||||
|
Path string // absolute path to binary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler discovers and executes plugins. Uses dependency injection
|
||||||
|
// for testability.
|
||||||
|
type Handler struct {
|
||||||
|
ReadDir func(name string) ([]os.DirEntry, error)
|
||||||
|
Stat func(name string) (os.FileInfo, error)
|
||||||
|
GetEnv func(key string) string
|
||||||
|
HomeDir func() (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler returns a Handler with production defaults.
|
||||||
|
func NewHandler() *Handler {
|
||||||
|
return &Handler{
|
||||||
|
ReadDir: os.ReadDir,
|
||||||
|
Stat: os.Stat,
|
||||||
|
GetEnv: os.Getenv,
|
||||||
|
HomeDir: os.UserHomeDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover scans the plugin directory for executables matching flux-*.
|
||||||
|
// It skips builtins, reserved names, directories, non-executable files,
|
||||||
|
// and broken symlinks.
|
||||||
|
func (h *Handler) Discover(builtinNames []string) []Plugin {
|
||||||
|
dir := h.PluginDir()
|
||||||
|
if dir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := h.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
builtins := make(map[string]bool, len(builtinNames))
|
||||||
|
for _, name := range builtinNames {
|
||||||
|
builtins[name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var plugins []Plugin
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasPrefix(name, pluginPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginName := pluginNameFromBinary(name)
|
||||||
|
if pluginName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if reservedNames[pluginName] || builtins[pluginName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
// Use Stat to follow symlinks and check the target.
|
||||||
|
info, err := h.Stat(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
// Broken symlink, permission denied, etc.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isExecutable(info) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins = append(plugins, Plugin{
|
||||||
|
Name: pluginName,
|
||||||
|
Path: fullPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginDir returns the plugin directory path. If FLUXCD_PLUGINS is set,
|
||||||
|
// returns that path. Otherwise returns ~/.fluxcd/plugins/.
|
||||||
|
// Does not create the directory — callers that write (install, update)
|
||||||
|
// should call EnsurePluginDir first.
|
||||||
|
func (h *Handler) PluginDir() string {
|
||||||
|
if dir := h.GetEnv(envPluginDir); dir != "" {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
home, err := h.HomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(home, defaultBaseDir, defaultDirName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsurePluginDir creates the plugin directory if it doesn't exist
|
||||||
|
// and returns the path. Best-effort — ignores mkdir errors for
|
||||||
|
// read-only filesystems. User-managed directories (via $FLUXCD_PLUGINS)
|
||||||
|
// are not auto-created.
|
||||||
|
func (h *Handler) EnsurePluginDir() string {
|
||||||
|
if envDir := h.GetEnv(envPluginDir); envDir != "" {
|
||||||
|
return envDir
|
||||||
|
}
|
||||||
|
|
||||||
|
home, err := h.HomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(home, defaultBaseDir, defaultDirName)
|
||||||
|
_ = os.MkdirAll(dir, 0o755)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluginNameFromBinary extracts the plugin name from a binary filename.
|
||||||
|
// "flux-operator" → "operator", "flux-my-tool" → "my-tool".
|
||||||
|
// Returns empty string for invalid names.
|
||||||
|
func pluginNameFromBinary(filename string) string {
|
||||||
|
if !strings.HasPrefix(filename, pluginPrefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(filename, pluginPrefix)
|
||||||
|
|
||||||
|
// On Windows, strip known extensions.
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
for _, ext := range []string{".exe", ".cmd", ".bat"} {
|
||||||
|
if strings.HasSuffix(strings.ToLower(name), ext) {
|
||||||
|
name = name[:len(name)-len(ext)]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// isExecutable checks if a file has the executable bit set.
|
||||||
|
// On Windows, this always returns true (executability is determined by extension).
|
||||||
|
func isExecutable(info os.FileInfo) bool {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return info.Mode().Perm()&0o111 != 0
|
||||||
|
}
|
||||||
302
internal/plugin/discovery_test.go
Normal file
302
internal/plugin/discovery_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
30
internal/plugin/exec_unix.go
Normal file
30
internal/plugin/exec_unix.go
Normal 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())
|
||||||
|
}
|
||||||
42
internal/plugin/exec_windows.go
Normal file
42
internal/plugin/exec_windows.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
/*
|
||||||
|
Copyright 2026 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exec runs the plugin as a child process with full I/O passthrough.
|
||||||
|
// Matches kubectl's Windows fallback pattern.
|
||||||
|
func Exec(path string, args []string) error {
|
||||||
|
cmd := exec.Command(path, args...)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
os.Exit(exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
235
internal/plugin/install.go
Normal file
235
internal/plugin/install.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sigs.k8s.io/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Installer handles downloading, verifying, and installing plugins.
|
||||||
|
type Installer struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInstaller returns an Installer with production defaults.
|
||||||
|
func NewInstaller() *Installer {
|
||||||
|
return &Installer{
|
||||||
|
HTTPClient: newHTTPClient(5 * time.Minute),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install downloads, verifies, extracts, and installs a plugin binary
|
||||||
|
// to the given plugin directory.
|
||||||
|
func (inst *Installer) Install(pluginDir string, manifest *PluginManifest, pv *PluginVersion, plat *PluginPlatform) error {
|
||||||
|
tmpFile, err := os.CreateTemp("", "flux-plugin-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
resp, err := inst.HTTPClient.Get(plat.URL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download plugin: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("failed to download plugin: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
writer := io.MultiWriter(tmpFile, hasher)
|
||||||
|
if _, err := io.Copy(writer, resp.Body); err != nil {
|
||||||
|
return fmt.Errorf("failed to download plugin: %w", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
actualChecksum := fmt.Sprintf("sha256:%x", hasher.Sum(nil))
|
||||||
|
if actualChecksum != plat.Checksum {
|
||||||
|
return fmt.Errorf("checksum verification failed (expected: %s, got: %s)", plat.Checksum, actualChecksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
binName := manifest.Bin
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
binName += ".exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
destName := pluginPrefix + manifest.Name
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
destName += ".exe"
|
||||||
|
}
|
||||||
|
destPath := filepath.Join(pluginDir, destName)
|
||||||
|
|
||||||
|
if strings.HasSuffix(plat.URL, ".zip") {
|
||||||
|
err = extractFromZip(tmpFile.Name(), binName, destPath)
|
||||||
|
} else {
|
||||||
|
err = extractFromTarGz(tmpFile.Name(), binName, destPath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt := Receipt{
|
||||||
|
Name: manifest.Name,
|
||||||
|
Version: pv.Version,
|
||||||
|
InstalledAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Platform: *plat,
|
||||||
|
}
|
||||||
|
return writeReceipt(pluginDir, manifest.Name, &receipt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall removes a plugin binary (or symlink) and its receipt from the
|
||||||
|
// plugin directory. Returns an error if the plugin is not installed.
|
||||||
|
func Uninstall(pluginDir, name string) error {
|
||||||
|
binName := pluginPrefix + name
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
binName += ".exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
binPath := filepath.Join(pluginDir, binName)
|
||||||
|
|
||||||
|
// Use Lstat so we detect symlinks without following them.
|
||||||
|
if _, err := os.Lstat(binPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("plugin %q is not installed", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(binPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove plugin binary: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receipt is optional (manually installed plugins don't have one).
|
||||||
|
if err := os.Remove(receiptPath(pluginDir, name)); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove plugin receipt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadReceipt reads the install receipt for a plugin.
|
||||||
|
// Returns nil if no receipt exists.
|
||||||
|
func ReadReceipt(pluginDir, name string) *Receipt {
|
||||||
|
data, err := os.ReadFile(receiptPath(pluginDir, name))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var receipt Receipt
|
||||||
|
if err := yaml.Unmarshal(data, &receipt); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &receipt
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiptPath(pluginDir, name string) string {
|
||||||
|
return filepath.Join(pluginDir, pluginPrefix+name+".yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeReceipt(pluginDir, name string, receipt *Receipt) error {
|
||||||
|
data, err := yaml.Marshal(receipt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal receipt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(receiptPath(pluginDir, name), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFromTarGz extracts a named file from a tar.gz archive
|
||||||
|
// and streams it directly to destPath.
|
||||||
|
func extractFromTarGz(archivePath, targetName, destPath string) error {
|
||||||
|
f, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
gr, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read gzip: %w", err)
|
||||||
|
}
|
||||||
|
defer gr.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gr)
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read tar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filepath.IsAbs(header.Name) || strings.Contains(header.Name, "..") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filepath.Base(header.Name) == targetName && header.Typeflag == tar.TypeReg {
|
||||||
|
return writeStreamToFile(tr, destPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("binary %q not found in archive", targetName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFromZip extracts a named file from a zip archive
|
||||||
|
// and streams it directly to destPath.
|
||||||
|
func extractFromZip(archivePath, targetName, destPath string) error {
|
||||||
|
r, err := zip.OpenReader(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open zip: %w", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
for _, f := range r.File {
|
||||||
|
if filepath.Base(f.Name) == targetName && !f.FileInfo().IsDir() {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open %q in zip: %w", targetName, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
return writeStreamToFile(rc, destPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("binary %q not found in archive", targetName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeStreamToFile(r io.Reader, destPath string) error {
|
||||||
|
out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create %s: %w", destPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, r); err != nil {
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to write plugin binary: %w", err)
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
331
internal/plugin/install_test.go
Normal file
331
internal/plugin/install_test.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestTarGz creates a tar.gz archive containing a single file.
|
||||||
|
func createTestTarGz(name string, content []byte) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
gw := gzip.NewWriter(&buf)
|
||||||
|
tw := tar.NewWriter(gw)
|
||||||
|
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Name: name,
|
||||||
|
Mode: 0o755,
|
||||||
|
Size: int64(len(content)),
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(content); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tw.Close()
|
||||||
|
gw.Close()
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstall(t *testing.T) {
|
||||||
|
binaryContent := []byte("#!/bin/sh\necho hello")
|
||||||
|
archive, err := createTestTarGz("flux-operator", binaryContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test archive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(archive))
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write(archive)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
manifest := &PluginManifest{
|
||||||
|
Name: "operator",
|
||||||
|
Bin: "flux-operator",
|
||||||
|
}
|
||||||
|
pv := &PluginVersion{Version: "0.45.0"}
|
||||||
|
plat := &PluginPlatform{
|
||||||
|
OS: "linux",
|
||||||
|
Arch: "amd64",
|
||||||
|
URL: server.URL + "/flux-operator_0.45.0_linux_amd64.tar.gz",
|
||||||
|
Checksum: checksum,
|
||||||
|
}
|
||||||
|
|
||||||
|
installer := &Installer{HTTPClient: server.Client()}
|
||||||
|
if err := installer.Install(pluginDir, manifest, pv, plat); err != nil {
|
||||||
|
t.Fatalf("install failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify binary was written.
|
||||||
|
binPath := filepath.Join(pluginDir, "flux-operator")
|
||||||
|
data, err := os.ReadFile(binPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("binary not found: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != string(binaryContent) {
|
||||||
|
t.Errorf("binary content mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify receipt was written.
|
||||||
|
receipt := ReadReceipt(pluginDir, "operator")
|
||||||
|
if receipt == nil {
|
||||||
|
t.Fatal("receipt not found")
|
||||||
|
}
|
||||||
|
if receipt.Version != "0.45.0" {
|
||||||
|
t.Errorf("expected version '0.45.0', got %q", receipt.Version)
|
||||||
|
}
|
||||||
|
if receipt.Name != "operator" {
|
||||||
|
t.Errorf("expected name 'operator', got %q", receipt.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallChecksumMismatch(t *testing.T) {
|
||||||
|
binaryContent := []byte("#!/bin/sh\necho hello")
|
||||||
|
archive, err := createTestTarGz("flux-operator", binaryContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test archive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write(archive)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
manifest := &PluginManifest{Name: "operator", Bin: "flux-operator"}
|
||||||
|
pv := &PluginVersion{Version: "0.45.0"}
|
||||||
|
plat := &PluginPlatform{
|
||||||
|
OS: "linux",
|
||||||
|
Arch: "amd64",
|
||||||
|
URL: server.URL + "/archive.tar.gz",
|
||||||
|
Checksum: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
}
|
||||||
|
|
||||||
|
installer := &Installer{HTTPClient: server.Client()}
|
||||||
|
err = installer.Install(pluginDir, manifest, pv, plat)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected checksum error, got nil")
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(err.Error()), []byte("checksum verification failed")) {
|
||||||
|
t.Errorf("expected checksum error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallBinaryNotInArchive(t *testing.T) {
|
||||||
|
// Archive contains "wrong-name" instead of "flux-operator".
|
||||||
|
archive, err := createTestTarGz("wrong-name", []byte("content"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test archive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(archive))
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write(archive)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
manifest := &PluginManifest{Name: "operator", Bin: "flux-operator"}
|
||||||
|
pv := &PluginVersion{Version: "0.45.0"}
|
||||||
|
plat := &PluginPlatform{
|
||||||
|
OS: "linux",
|
||||||
|
Arch: "amd64",
|
||||||
|
URL: server.URL + "/archive.tar.gz",
|
||||||
|
Checksum: checksum,
|
||||||
|
}
|
||||||
|
|
||||||
|
installer := &Installer{HTTPClient: server.Client()}
|
||||||
|
err = installer.Install(pluginDir, manifest, pv, plat)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing binary, got nil")
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(err.Error()), []byte("not found in archive")) {
|
||||||
|
t.Errorf("expected 'not found in archive' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUninstall(t *testing.T) {
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create fake binary and receipt.
|
||||||
|
binPath := filepath.Join(pluginDir, "flux-testplugin")
|
||||||
|
os.WriteFile(binPath, []byte("binary"), 0o755)
|
||||||
|
receiptPath := filepath.Join(pluginDir, "flux-testplugin.yaml")
|
||||||
|
os.WriteFile(receiptPath, []byte("name: testplugin"), 0o644)
|
||||||
|
|
||||||
|
if err := Uninstall(pluginDir, "testplugin"); err != nil {
|
||||||
|
t.Fatalf("uninstall failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(binPath); !os.IsNotExist(err) {
|
||||||
|
t.Error("binary was not removed")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(receiptPath); !os.IsNotExist(err) {
|
||||||
|
t.Error("receipt was not removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUninstallNonExistent(t *testing.T) {
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
err := Uninstall(pluginDir, "nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-existent plugin, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "is not installed") {
|
||||||
|
t.Errorf("expected 'is not installed' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUninstallSymlink(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("symlinks require elevated privileges on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a real binary and symlink it into the plugin dir.
|
||||||
|
realBin := filepath.Join(t.TempDir(), "flux-operator")
|
||||||
|
os.WriteFile(realBin, []byte("real binary"), 0o755)
|
||||||
|
|
||||||
|
linkPath := filepath.Join(pluginDir, "flux-linked")
|
||||||
|
os.Symlink(realBin, linkPath)
|
||||||
|
|
||||||
|
if err := Uninstall(pluginDir, "linked"); err != nil {
|
||||||
|
t.Fatalf("uninstall symlink failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symlink should be removed.
|
||||||
|
if _, err := os.Lstat(linkPath); !os.IsNotExist(err) {
|
||||||
|
t.Error("symlink was not removed")
|
||||||
|
}
|
||||||
|
// Original binary should still exist.
|
||||||
|
if _, err := os.Stat(realBin); err != nil {
|
||||||
|
t.Error("original binary was removed — symlink removal should not affect target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUninstallManualBinary(t *testing.T) {
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
// Manually copied binary with no receipt.
|
||||||
|
binPath := filepath.Join(pluginDir, "flux-manual")
|
||||||
|
os.WriteFile(binPath, []byte("binary"), 0o755)
|
||||||
|
|
||||||
|
if err := Uninstall(pluginDir, "manual"); err != nil {
|
||||||
|
t.Fatalf("uninstall manual binary failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(binPath); !os.IsNotExist(err) {
|
||||||
|
t.Error("binary was not removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadReceipt(t *testing.T) {
|
||||||
|
pluginDir := t.TempDir()
|
||||||
|
|
||||||
|
t.Run("exists", func(t *testing.T) {
|
||||||
|
receiptData := `name: operator
|
||||||
|
version: "0.45.0"
|
||||||
|
installedAt: "2026-03-28T20:05:00Z"
|
||||||
|
platform:
|
||||||
|
os: darwin
|
||||||
|
arch: arm64
|
||||||
|
url: https://example.com/archive.tar.gz
|
||||||
|
checksum: sha256:abc123
|
||||||
|
`
|
||||||
|
os.WriteFile(filepath.Join(pluginDir, "flux-operator.yaml"), []byte(receiptData), 0o644)
|
||||||
|
|
||||||
|
receipt := ReadReceipt(pluginDir, "operator")
|
||||||
|
if receipt == nil {
|
||||||
|
t.Fatal("expected receipt, got nil")
|
||||||
|
}
|
||||||
|
if receipt.Version != "0.45.0" {
|
||||||
|
t.Errorf("expected version '0.45.0', got %q", receipt.Version)
|
||||||
|
}
|
||||||
|
if receipt.Platform.OS != "darwin" {
|
||||||
|
t.Errorf("expected OS 'darwin', got %q", receipt.Platform.OS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not exists", func(t *testing.T) {
|
||||||
|
receipt := ReadReceipt(pluginDir, "nonexistent")
|
||||||
|
if receipt != nil {
|
||||||
|
t.Error("expected nil receipt")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractFromTarGz(t *testing.T) {
|
||||||
|
content := []byte("test binary content")
|
||||||
|
archive, err := createTestTarGz("flux-operator", content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create archive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile := filepath.Join(t.TempDir(), "test.tar.gz")
|
||||||
|
os.WriteFile(tmpFile, archive, 0o644)
|
||||||
|
|
||||||
|
destPath := filepath.Join(t.TempDir(), "flux-operator")
|
||||||
|
if err := extractFromTarGz(tmpFile, "flux-operator", destPath); err != nil {
|
||||||
|
t.Fatalf("extract failed: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(destPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read extracted file: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != string(content) {
|
||||||
|
t.Errorf("content mismatch: got %q, want %q", string(data), string(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractFromTarGzNotFound(t *testing.T) {
|
||||||
|
archive, err := createTestTarGz("other-binary", []byte("content"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create archive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile := filepath.Join(t.TempDir(), "test.tar.gz")
|
||||||
|
os.WriteFile(tmpFile, archive, 0o644)
|
||||||
|
|
||||||
|
destPath := filepath.Join(t.TempDir(), "flux-operator")
|
||||||
|
err = extractFromTarGz(tmpFile, "flux-operator", destPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
85
internal/plugin/update.go
Normal file
85
internal/plugin/update.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
const (
|
||||||
|
SkipReasonManual = "manually installed"
|
||||||
|
SkipReasonUpToDate = "already up to date"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateResult represents the outcome of updating a single plugin.
|
||||||
|
// When an update is available, Manifest, Version and Platform are
|
||||||
|
// populated so the caller can install without re-fetching or re-resolving.
|
||||||
|
type UpdateResult struct {
|
||||||
|
Name string
|
||||||
|
FromVersion string
|
||||||
|
ToVersion string
|
||||||
|
Skipped bool
|
||||||
|
SkipReason string
|
||||||
|
Manifest *PluginManifest
|
||||||
|
Version *PluginVersion
|
||||||
|
Platform *PluginPlatform
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckUpdate compares the installed version against the latest in the catalog.
|
||||||
|
// Returns an UpdateResult describing what should happen. When an update is
|
||||||
|
// available, Manifest is populated so the caller can install without re-fetching.
|
||||||
|
func CheckUpdate(pluginDir string, name string, catalog *CatalogClient, goos, goarch string) UpdateResult {
|
||||||
|
receipt := ReadReceipt(pluginDir, name)
|
||||||
|
if receipt == nil {
|
||||||
|
return UpdateResult{
|
||||||
|
Name: name,
|
||||||
|
Skipped: true,
|
||||||
|
SkipReason: SkipReasonManual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := catalog.FetchManifest(name)
|
||||||
|
if err != nil {
|
||||||
|
return UpdateResult{Name: name, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
latest, err := ResolveVersion(manifest, "")
|
||||||
|
if err != nil {
|
||||||
|
return UpdateResult{Name: name, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if receipt.Version == latest.Version {
|
||||||
|
return UpdateResult{
|
||||||
|
Name: name,
|
||||||
|
FromVersion: receipt.Version,
|
||||||
|
ToVersion: latest.Version,
|
||||||
|
Skipped: true,
|
||||||
|
SkipReason: SkipReasonUpToDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plat, err := ResolvePlatform(latest, goos, goarch)
|
||||||
|
if err != nil {
|
||||||
|
return UpdateResult{Name: name, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UpdateResult{
|
||||||
|
Name: name,
|
||||||
|
FromVersion: receipt.Version,
|
||||||
|
ToVersion: latest.Version,
|
||||||
|
Manifest: manifest,
|
||||||
|
Version: latest,
|
||||||
|
Platform: plat,
|
||||||
|
}
|
||||||
|
}
|
||||||
153
internal/plugin/update_test.go
Normal file
153
internal/plugin/update_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user