Compare commits
13 Commits
dependabot
...
plugin-sys
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fe0a48015 | ||
|
|
131cee951f | ||
|
|
1db4e66099 | ||
|
|
d9f51d047d | ||
|
|
dc5631f12b | ||
|
|
3f9d5bdc3d | ||
|
|
64e18014c3 | ||
|
|
e9226713e8 | ||
|
|
6a5e644798 | ||
|
|
0b0be7c1b6 | ||
|
|
484346ffcc | ||
|
|
5b3acbfcb5 | ||
|
|
2288dd90d6 |
2
.github/workflows/README.md
vendored
2
.github/workflows/README.md
vendored
@@ -23,7 +23,7 @@ amd when it finds a new controller version, the workflow performs the following
|
||||
- Updates the controller API package version in `go.mod`.
|
||||
- Patches the controller CRDs version in the `manifests/crds` overlay.
|
||||
- Patches the controller Deployment version in `manifests/bases` overlay.
|
||||
- Opens a Pull Request against the `main` branch.
|
||||
- Opens a Pull Request against the checked out branch.
|
||||
- Triggers the e2e test suite to run for the opened PR.
|
||||
|
||||
|
||||
|
||||
2
.github/workflows/conformance.yaml
vendored
2
.github/workflows/conformance.yaml
vendored
@@ -3,7 +3,7 @@ name: conformance
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ 'main', 'update-components', 'release/**', 'conform*' ]
|
||||
branches: [ 'main', 'update-components-**', 'release/**', 'conform*' ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
2
.github/workflows/update.yaml
vendored
2
.github/workflows/update.yaml
vendored
@@ -106,7 +106,7 @@ jobs:
|
||||
committer: GitHub <noreply@github.com>
|
||||
author: fluxcdbot <fluxcdbot@users.noreply.github.com>
|
||||
signoff: true
|
||||
branch: update-components
|
||||
branch: update-components-${{ github.ref_name }}
|
||||
title: Update toolkit components
|
||||
body: |
|
||||
${{ steps.update.outputs.pr_body }}
|
||||
|
||||
@@ -186,6 +186,8 @@ func main() {
|
||||
// logger, we configure it's logger to do nothing.
|
||||
ctrllog.SetLogger(logr.New(ctrllog.NullLogSink{}))
|
||||
|
||||
registerPlugins()
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
|
||||
if err, ok := err.(*RequestError); ok {
|
||||
|
||||
340
cmd/flux/plugin.go
Normal file
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)
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,14 @@ func reconciliationHandled(kubeClient client.Client, namespacedName types.Namesp
|
||||
return false, err
|
||||
}
|
||||
|
||||
return result.Status == kstatus.CurrentStatus, nil
|
||||
switch result.Status {
|
||||
case kstatus.CurrentStatus:
|
||||
return true, nil
|
||||
case kstatus.InProgressStatus:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("%s", result.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,17 @@ func (resume resumeCommand) run(cmd *cobra.Command, args []string) error {
|
||||
|
||||
resume.printMessage(reconcileResps)
|
||||
|
||||
// Return an error if any reconciliation failed
|
||||
var failedCount int
|
||||
for _, r := range reconcileResps {
|
||||
if r.resumable != nil && r.err != nil {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
if failedCount > 0 {
|
||||
return fmt.Errorf("reconciliation failed for %d %s(s)", failedCount, resume.kind)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
18
go.mod
18
go.mod
@@ -8,15 +8,16 @@ replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1
|
||||
require (
|
||||
github.com/Masterminds/semver/v3 v3.4.0
|
||||
github.com/ProtonMail/go-crypto v1.3.0
|
||||
github.com/briandowns/spinner v1.23.2
|
||||
github.com/cyphar/filepath-securejoin v0.6.1
|
||||
github.com/distribution/distribution/v3 v3.0.0
|
||||
github.com/fluxcd/cli-utils v0.37.2-flux.1
|
||||
github.com/fluxcd/go-git-providers v0.26.0
|
||||
github.com/fluxcd/helm-controller/api v1.5.1
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.0
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.0
|
||||
github.com/fluxcd/kustomize-controller/api v1.8.1
|
||||
github.com/fluxcd/notification-controller/api v1.8.1
|
||||
github.com/fluxcd/helm-controller/api v1.5.3
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.1
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.1
|
||||
github.com/fluxcd/kustomize-controller/api v1.8.2
|
||||
github.com/fluxcd/notification-controller/api v1.8.2
|
||||
github.com/fluxcd/pkg/apis/event v0.25.0
|
||||
github.com/fluxcd/pkg/apis/meta v1.26.0
|
||||
github.com/fluxcd/pkg/auth v0.40.0
|
||||
@@ -31,8 +32,8 @@ require (
|
||||
github.com/fluxcd/pkg/ssh v0.24.0
|
||||
github.com/fluxcd/pkg/tar v0.17.0
|
||||
github.com/fluxcd/pkg/version v0.14.0
|
||||
github.com/fluxcd/source-controller/api v1.8.0
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.1.0
|
||||
github.com/fluxcd/source-controller/api v1.8.1
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.1.1
|
||||
github.com/go-git/go-git/v5 v5.16.5
|
||||
github.com/go-logr/logr v1.4.3
|
||||
github.com/gonvenience/bunt v1.4.2
|
||||
@@ -40,6 +41,7 @@ require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/go-containerregistry v0.20.7
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||
github.com/homeport/dyff v1.10.2
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/manifoldco/promptui v0.9.0
|
||||
@@ -49,7 +51,6 @@ require (
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/theckman/yacspin v0.13.12
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/text v0.34.0
|
||||
@@ -162,7 +163,6 @@ require (
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
|
||||
|
||||
32
go.sum
32
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/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
|
||||
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
|
||||
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
|
||||
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
|
||||
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
|
||||
@@ -174,16 +176,16 @@ github.com/fluxcd/gitkit v0.6.0 h1:iNg5LTx6ePo+Pl0ZwqHTAkhbUHxGVSY3YCxCdw7VIFg=
|
||||
github.com/fluxcd/gitkit v0.6.0/go.mod h1:svOHuKi0fO9HoawdK4HfHAJJseZDHHjk7I3ihnCIqNo=
|
||||
github.com/fluxcd/go-git-providers v0.26.0 h1:0DUsXc1nS9Fe4n8tXSEUCGemWzHShd66gmotayDPekw=
|
||||
github.com/fluxcd/go-git-providers v0.26.0/go.mod h1:VJDKUOhZwNAIqDF5iPtIpTr/annsDbKMkPpWiDMBdpo=
|
||||
github.com/fluxcd/helm-controller/api v1.5.1 h1:yraWl0ImzO4yIy/N5d9i54N+OZxKuFZqjed8wrIjy8U=
|
||||
github.com/fluxcd/helm-controller/api v1.5.1/go.mod h1:Yr0y7GKizbvQQGK5wBX6sGCZrTY86AN9n1PNEsji2XE=
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.0 h1:CLPNHQskX0falo4s1suG1ztUe9IGaY9q5ntcz5Fxt9A=
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.0/go.mod h1:dIpTDlWgUfjvdGZCNfe8Ht9sCiHwRbJDoIbwfLQ56wc=
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.0 h1:7TtE9DrCnlH1Wn3R3UKXJHNhX/FgS0ejdjFKHzl+XHs=
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.0/go.mod h1:hLGsqTv3RydJXaApmN+ZtIOHNxlUdmuOJl323x6dsPE=
|
||||
github.com/fluxcd/kustomize-controller/api v1.8.1 h1:Pe5+sV1i1EwfK5TA4ogYX6YJ6ADJaETmG58WYieRkG4=
|
||||
github.com/fluxcd/kustomize-controller/api v1.8.1/go.mod h1:+ZJB/dIGbnSzZ5J/kiJ8n0USmLNAjfeZU6Xfra0oMZA=
|
||||
github.com/fluxcd/notification-controller/api v1.8.1 h1:tBg5QrXsVAdMEsV/oq3gqApdRDwcO9gyc6plDf/3QGI=
|
||||
github.com/fluxcd/notification-controller/api v1.8.1/go.mod h1:tGlTJS+hSLbgQm1L78hl6N3iWbTerifh1V5Qm8we4Zo=
|
||||
github.com/fluxcd/helm-controller/api v1.5.3 h1:ruLzuyTHjjE9A5B/U+Id2q7yHXXqSFTswdZ14xCS5So=
|
||||
github.com/fluxcd/helm-controller/api v1.5.3/go.mod h1:lTgeUmtVYExMKp7mRDncsr4JwHTz3LFtLjRJZeR98lI=
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.1 h1:uiu7kjdVoW8/461HOemX6I7RcPornEzQliWgTg6LnWI=
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.1/go.mod h1:lkD/drkD6Wc+2SDjVj5KqfozEucTLFexWgby/5ft660=
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.1 h1:4Bj1abzVnjj8+b/293kNeFMRJc+y2wO8Z12ReZ/gA0w=
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.1/go.mod h1:j4JSIocL42HQ77Veg1t60sApOy+lng8/cbXHXGSnfi0=
|
||||
github.com/fluxcd/kustomize-controller/api v1.8.2 h1:LcFUjJccwNrhCo7pQBBneLAlHfZZcb58bWB2LnyFwag=
|
||||
github.com/fluxcd/kustomize-controller/api v1.8.2/go.mod h1:c/mUPIffDDLg1EicXCJtX4N/rc+z5Zh0e/CXjhd7Dyc=
|
||||
github.com/fluxcd/notification-controller/api v1.8.2 h1:TDrXohUC5Gh3BF+v2ux9/zEG1Ax8u49WDW+3Y6GiIEc=
|
||||
github.com/fluxcd/notification-controller/api v1.8.2/go.mod h1:ozgJGQPy0dG5eOsLZlwAr6n0q/y6+TWd1fGOtavlXJA=
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0 h1:wBpgsKT+jcyZEcM//OmZr9RiF8klL3ebrDp2u2ThsnA=
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0/go.mod h1:TttNS+gocsGLwnvmgVi3/Yscwqrjc17+vhgYfqkfrV4=
|
||||
github.com/fluxcd/pkg/apis/event v0.25.0 h1:zdwytvDhG+fk+Ywl5DOtv7TklkrVgM21WHm1f+YhleE=
|
||||
@@ -220,10 +222,10 @@ github.com/fluxcd/pkg/tar v0.17.0 h1:uNxbFXy8ly8C7fJ8D7w3rjTNJFrb4Hp1aY/30XkfvxY
|
||||
github.com/fluxcd/pkg/tar v0.17.0/go.mod h1:b1xyIRYDD0ket4SV5u0UXYv+ZdN/O/HmIO5jZQdHQls=
|
||||
github.com/fluxcd/pkg/version v0.14.0 h1:T3llSc8sUnsuFrW5ng2ePSfXwGXUKv0YG9QXf0ErhWw=
|
||||
github.com/fluxcd/pkg/version v0.14.0/go.mod h1:YHdg/78kzf+kCqS+SqSOiUxum5AjxlixiqwpX6AUZB8=
|
||||
github.com/fluxcd/source-controller/api v1.8.0 h1:ndrYmcv6ZMcdQHFSUkOrFVDO7h16SfDBSw/DOqf/LPo=
|
||||
github.com/fluxcd/source-controller/api v1.8.0/go.mod h1:1O7+sMbqc1+3tPvjmtgFz+bASTl794Y9SxpebHDDSGA=
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.1.0 h1:pXKC3VNacjGT6hDyBqP/2kaNlrzNANUn7si5BuW40QE=
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.1.0/go.mod h1:s5ahWDfD0KmpFAbQf3DHCLnWMRkfqt3l5VoCk08LFts=
|
||||
github.com/fluxcd/source-controller/api v1.8.1 h1:49HiJF5mNEdZTwueQMRahTVts35B+xhN5CsuOAL9gQ0=
|
||||
github.com/fluxcd/source-controller/api v1.8.1/go.mod h1:HgZ6NSH1cyOE2jRoNwln1xEwr9ETvrLeiy1o4O04vQM=
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.1.1 h1:1LfT50ty+78MKKbschAZl28QbVqIyjaNq17KmW5wPJI=
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.1.1/go.mod h1:6M1BzBGQRoIuSenSQlfJHwMVVobFPiNPxXqfN0IILc4=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
@@ -535,8 +537,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U=
|
||||
github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8=
|
||||
github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4=
|
||||
github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg=
|
||||
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
|
||||
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||
github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo=
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/theckman/yacspin"
|
||||
"github.com/briandowns/spinner"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -81,7 +81,7 @@ type Builder struct {
|
||||
action kustomize.Action
|
||||
kustomization *kustomizev1.Kustomization
|
||||
timeout time.Duration
|
||||
spinner *yacspin.Spinner
|
||||
spinner *spinner.Spinner
|
||||
dryRun bool
|
||||
strictSubst bool
|
||||
recursive bool
|
||||
@@ -111,22 +111,9 @@ func WithTimeout(timeout time.Duration) BuilderOptionFunc {
|
||||
|
||||
func WithProgressBar() BuilderOptionFunc {
|
||||
return func(b *Builder) error {
|
||||
// Add a spinner
|
||||
cfg := yacspin.Config{
|
||||
Frequency: 100 * time.Millisecond,
|
||||
CharSet: yacspin.CharSets[59],
|
||||
Suffix: "Kustomization diffing...",
|
||||
SuffixAutoColon: true,
|
||||
Message: spinnerDryRunMessage,
|
||||
StopCharacter: "✓",
|
||||
StopColors: []string{"fgGreen"},
|
||||
}
|
||||
spinner, err := yacspin.New(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create spinner: %w", err)
|
||||
}
|
||||
b.spinner = spinner
|
||||
|
||||
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
|
||||
s.Suffix = " Kustomization diffing... " + spinnerDryRunMessage
|
||||
b.spinner = s
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -215,7 +202,7 @@ func withClientConfigFrom(in *Builder) BuilderOptionFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// withClientConfigFrom copies spinner field
|
||||
// withSpinnerFrom copies the spinner field from another Builder.
|
||||
func withSpinnerFrom(in *Builder) BuilderOptionFunc {
|
||||
return func(b *Builder) error {
|
||||
b.spinner = in.spinner
|
||||
@@ -746,12 +733,7 @@ func (b *Builder) StartSpinner() error {
|
||||
if b.spinner == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := b.spinner.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start spinner: %w", err)
|
||||
}
|
||||
|
||||
b.spinner.Start()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -759,14 +741,6 @@ func (b *Builder) StopSpinner() error {
|
||||
if b.spinner == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
status := b.spinner.Status()
|
||||
if status == yacspin.SpinnerRunning || status == yacspin.SpinnerPaused {
|
||||
err := b.spinner.Stop()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop spinner: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
b.spinner.Stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -173,14 +173,14 @@ func (b *Builder) diff() (string, bool, error) {
|
||||
|
||||
// finished with Kustomization diff
|
||||
if b.spinner != nil {
|
||||
b.spinner.Message(spinnerDryRunMessage)
|
||||
b.spinner.Suffix = " " + spinnerDryRunMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if b.spinner != nil {
|
||||
b.spinner.Message("processing inventory")
|
||||
b.spinner.Suffix = " processing inventory"
|
||||
}
|
||||
|
||||
if b.kustomization.Spec.Prune && len(diffErrs) == 0 {
|
||||
@@ -204,7 +204,7 @@ func (b *Builder) diff() (string, bool, error) {
|
||||
|
||||
func (b *Builder) kustomizationDiff(kustomization *kustomizev1.Kustomization) (string, bool, error) {
|
||||
if b.spinner != nil {
|
||||
b.spinner.Message(fmt.Sprintf("%s in %s", spinnerDryRunMessage, kustomization.Name))
|
||||
b.spinner.Suffix = " " + fmt.Sprintf("%s in %s", spinnerDryRunMessage, kustomization.Name)
|
||||
}
|
||||
|
||||
sourceRef := kustomization.Spec.SourceRef.DeepCopy()
|
||||
|
||||
213
internal/plugin/catalog.go
Normal file
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)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.1/helm-controller.crds.yaml
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.1/helm-controller.deployment.yaml
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.3/helm-controller.crds.yaml
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.3/helm-controller.deployment.yaml
|
||||
- account.yaml
|
||||
transformers:
|
||||
- labels.yaml
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- https://github.com/fluxcd/image-automation-controller/releases/download/v1.1.0/image-automation-controller.crds.yaml
|
||||
- https://github.com/fluxcd/image-automation-controller/releases/download/v1.1.0/image-automation-controller.deployment.yaml
|
||||
- https://github.com/fluxcd/image-automation-controller/releases/download/v1.1.1/image-automation-controller.crds.yaml
|
||||
- https://github.com/fluxcd/image-automation-controller/releases/download/v1.1.1/image-automation-controller.deployment.yaml
|
||||
- account.yaml
|
||||
transformers:
|
||||
- labels.yaml
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- https://github.com/fluxcd/image-reflector-controller/releases/download/v1.1.0/image-reflector-controller.crds.yaml
|
||||
- https://github.com/fluxcd/image-reflector-controller/releases/download/v1.1.0/image-reflector-controller.deployment.yaml
|
||||
- https://github.com/fluxcd/image-reflector-controller/releases/download/v1.1.1/image-reflector-controller.crds.yaml
|
||||
- https://github.com/fluxcd/image-reflector-controller/releases/download/v1.1.1/image-reflector-controller.deployment.yaml
|
||||
- account.yaml
|
||||
transformers:
|
||||
- labels.yaml
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- https://github.com/fluxcd/kustomize-controller/releases/download/v1.8.1/kustomize-controller.crds.yaml
|
||||
- https://github.com/fluxcd/kustomize-controller/releases/download/v1.8.1/kustomize-controller.deployment.yaml
|
||||
- https://github.com/fluxcd/kustomize-controller/releases/download/v1.8.2/kustomize-controller.crds.yaml
|
||||
- https://github.com/fluxcd/kustomize-controller/releases/download/v1.8.2/kustomize-controller.deployment.yaml
|
||||
- account.yaml
|
||||
transformers:
|
||||
- labels.yaml
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- https://github.com/fluxcd/notification-controller/releases/download/v1.8.1/notification-controller.crds.yaml
|
||||
- https://github.com/fluxcd/notification-controller/releases/download/v1.8.1/notification-controller.deployment.yaml
|
||||
- https://github.com/fluxcd/notification-controller/releases/download/v1.8.2/notification-controller.crds.yaml
|
||||
- https://github.com/fluxcd/notification-controller/releases/download/v1.8.2/notification-controller.deployment.yaml
|
||||
- account.yaml
|
||||
transformers:
|
||||
- labels.yaml
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- https://github.com/fluxcd/source-controller/releases/download/v1.8.0/source-controller.crds.yaml
|
||||
- https://github.com/fluxcd/source-controller/releases/download/v1.8.0/source-controller.deployment.yaml
|
||||
- https://github.com/fluxcd/source-controller/releases/download/v1.8.1/source-controller.crds.yaml
|
||||
- https://github.com/fluxcd/source-controller/releases/download/v1.8.1/source-controller.deployment.yaml
|
||||
- account.yaml
|
||||
transformers:
|
||||
- labels.yaml
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- https://github.com/fluxcd/source-watcher/releases/download/v2.1.0/source-watcher.crds.yaml
|
||||
- https://github.com/fluxcd/source-watcher/releases/download/v2.1.0/source-watcher.deployment.yaml
|
||||
- https://github.com/fluxcd/source-watcher/releases/download/v2.1.1/source-watcher.crds.yaml
|
||||
- https://github.com/fluxcd/source-watcher/releases/download/v2.1.1/source-watcher.deployment.yaml
|
||||
- account.yaml
|
||||
transformers:
|
||||
- labels.yaml
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- https://github.com/fluxcd/source-controller/releases/download/v1.8.0/source-controller.crds.yaml
|
||||
- https://github.com/fluxcd/kustomize-controller/releases/download/v1.8.1/kustomize-controller.crds.yaml
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.1/helm-controller.crds.yaml
|
||||
- https://github.com/fluxcd/notification-controller/releases/download/v1.8.1/notification-controller.crds.yaml
|
||||
- https://github.com/fluxcd/image-reflector-controller/releases/download/v1.1.0/image-reflector-controller.crds.yaml
|
||||
- https://github.com/fluxcd/image-automation-controller/releases/download/v1.1.0/image-automation-controller.crds.yaml
|
||||
- https://github.com/fluxcd/source-watcher/releases/download/v2.1.0/source-watcher.crds.yaml
|
||||
- https://github.com/fluxcd/source-controller/releases/download/v1.8.1/source-controller.crds.yaml
|
||||
- https://github.com/fluxcd/kustomize-controller/releases/download/v1.8.2/kustomize-controller.crds.yaml
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.3/helm-controller.crds.yaml
|
||||
- https://github.com/fluxcd/notification-controller/releases/download/v1.8.2/notification-controller.crds.yaml
|
||||
- https://github.com/fluxcd/image-reflector-controller/releases/download/v1.1.1/image-reflector-controller.crds.yaml
|
||||
- https://github.com/fluxcd/image-automation-controller/releases/download/v1.1.1/image-automation-controller.crds.yaml
|
||||
- https://github.com/fluxcd/source-watcher/releases/download/v2.1.1/source-watcher.crds.yaml
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
# RFC-0010 Multi-Tenant Workload Identity
|
||||
|
||||
**Status:** implementable
|
||||
|
||||
<!--
|
||||
Status represents the current state of the RFC.
|
||||
Must be one of `provisional`, `implementable`, `implemented`, `deferred`, `rejected`, `withdrawn`, or `replaced`.
|
||||
-->
|
||||
**Status:** implemented
|
||||
|
||||
**Creation date:** 2025-02-22
|
||||
|
||||
**Last update:** 2025-04-29
|
||||
**Last update:** 2026-03-13
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -1420,10 +1415,11 @@ options to call `gcp.NewTokenSource()` and feed this token source to the
|
||||
`HelmRepository` and `HelmChart`, as well as for SOPS decryption
|
||||
in the `Kustomization` API and Azure Event Hubs in the
|
||||
`Provider` API.
|
||||
|
||||
<!--
|
||||
Major milestones in the lifecycle of the RFC such as:
|
||||
- The first Flux release where an initial version of the RFC was available.
|
||||
- The version of Flux where the RFC graduated to general availability.
|
||||
- The version of Flux where the RFC was retired or superseded.
|
||||
-->
|
||||
* In Flux 2.7 object-level workload identity was introduced for all
|
||||
the remaining APIs that support cloud providers, i.e. `Bucket`,
|
||||
`GitRepository` and `ImageUpdateAutomation`, and also all the
|
||||
remaining types for the `Provider` API, i.e. `azuredevops` and
|
||||
`googlepubsub`. In addition, support for controller and
|
||||
object-level workload identity was introduced for the
|
||||
`Kustomization` and `HelmRelease` APIs for remote cluster
|
||||
access.
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
# RFC-0011: OpenTelemetry Tracing
|
||||
|
||||
**Status:** provisional
|
||||
|
||||
<!--
|
||||
Status represents the current state of the RFC.
|
||||
Must be one of `provisional`, `implementable`, `implemented`, `deferred`, `rejected`, `withdrawn`, or `replaced`.
|
||||
-->
|
||||
**Status:** implemented
|
||||
|
||||
**Creation date:** 2025-04-24
|
||||
|
||||
**Last update:** 2025-08-13
|
||||
**Last update:** 2026-03-13
|
||||
|
||||
## Summary
|
||||
The aim is to be able to collect traces via OpenTelemetry (OTel) across all Flux related objects, such as HelmReleases, Kustomizations and among others. These may be sent towards a tracing provider where may be potentially stored and visualized. Flux does not have any responsibility on storing and visualizing those, it keeps being completely stateless. Thereby, being seamless for the user, the implementation is going to be part of the already existing `Alert` API Type. Therefore, `EventSources` is going to discriminate the events belonging to the specific sources, which are going to be looked up to and send them out towards the `Provider` set. In this way, it could facilitate the observability and monitoring of Flux related objects.
|
||||
@@ -210,9 +205,4 @@ This design ensures trace continuity even in challenging distributed environment
|
||||
|
||||
## Implementation History
|
||||
|
||||
<!--
|
||||
Major milestones in the lifecycle of the RFC such as:
|
||||
- The first Flux release where an initial version of the RFC was available.
|
||||
- The version of Flux where the RFC graduated to general availability.
|
||||
- The version of Flux where the RFC was retired or superseded.
|
||||
-->
|
||||
* RFC implemented and generally available in Flux [v2.7.0](https://github.com/fluxcd/flux2/releases/tag/v2.7.0)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# RFC-0012 External Artifact
|
||||
|
||||
**Status:** provisional
|
||||
**Status:** implemented
|
||||
|
||||
**Creation date:** 2025-04-08
|
||||
|
||||
**Last update:** 2025-09-03
|
||||
**Last update:** 2026-03-13
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -319,9 +319,4 @@ control the adoption of the `ExternalArtifact` feature in their clusters.
|
||||
|
||||
## Implementation History
|
||||
|
||||
<!--
|
||||
Major milestones in the lifecycle of the RFC such as:
|
||||
- The first Flux release where an initial version of the RFC was available.
|
||||
- The version of Flux where the RFC graduated to general availability.
|
||||
- The version of Flux where the RFC was retired or superseded.
|
||||
-->
|
||||
* RFC implemented and generally available in Flux [v2.7.0](https://github.com/fluxcd/flux2/releases/tag/v2.7.0)
|
||||
|
||||
@@ -58,7 +58,7 @@ require (
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/devigned/tab v0.1.1 // indirect
|
||||
github.com/docker/cli v29.2.0+incompatible // indirect
|
||||
github.com/docker/cli v29.0.3+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
|
||||
@@ -95,8 +95,8 @@ github.com/devigned/tab v0.1.1 h1:3mD6Kb1mUOYeLpJvTVSDwSg5ZsfSxfvxGRTxRsJsITA=
|
||||
github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
|
||||
github.com/dimchansky/utfbom v1.1.0 h1:FcM3g+nofKgUteL8dm/UpdRXNC9KmADgTpLKsu0TRo4=
|
||||
github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
|
||||
github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
|
||||
github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli v29.0.3+incompatible h1:8J+PZIcF2xLd6h5sHPsp5pvvJA+Sr2wGQxHkRl53a1E=
|
||||
github.com/docker/cli v29.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
|
||||
|
||||
Reference in New Issue
Block a user