Compare commits
3 Commits
plugin-sys
...
rfc-cli-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac91c49e4 | ||
|
|
5fc8afcaaf | ||
|
|
7bf0bda689 |
@@ -22,6 +22,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -48,9 +49,10 @@ from the given directory or a single manifest file.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
type buildArtifactFlags struct {
|
type buildArtifactFlags struct {
|
||||||
output string
|
output string
|
||||||
path string
|
path string
|
||||||
ignorePaths []string
|
ignorePaths []string
|
||||||
|
resolveSymlinks bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var excludeOCI = append(strings.Split(sourceignore.ExcludeVCS, ","), strings.Split(sourceignore.ExcludeExt, ",")...)
|
var excludeOCI = append(strings.Split(sourceignore.ExcludeVCS, ","), strings.Split(sourceignore.ExcludeExt, ",")...)
|
||||||
@@ -61,6 +63,7 @@ func init() {
|
|||||||
buildArtifactCmd.Flags().StringVarP(&buildArtifactArgs.path, "path", "p", "", "Path to the directory where the Kubernetes manifests are located.")
|
buildArtifactCmd.Flags().StringVarP(&buildArtifactArgs.path, "path", "p", "", "Path to the directory where the Kubernetes manifests are located.")
|
||||||
buildArtifactCmd.Flags().StringVarP(&buildArtifactArgs.output, "output", "o", "artifact.tgz", "Path to where the artifact tgz file should be written.")
|
buildArtifactCmd.Flags().StringVarP(&buildArtifactArgs.output, "output", "o", "artifact.tgz", "Path to where the artifact tgz file should be written.")
|
||||||
buildArtifactCmd.Flags().StringSliceVar(&buildArtifactArgs.ignorePaths, "ignore-paths", excludeOCI, "set paths to ignore in .gitignore format")
|
buildArtifactCmd.Flags().StringSliceVar(&buildArtifactArgs.ignorePaths, "ignore-paths", excludeOCI, "set paths to ignore in .gitignore format")
|
||||||
|
buildArtifactCmd.Flags().BoolVar(&buildArtifactArgs.resolveSymlinks, "resolve-symlinks", false, "resolve symlinks by copying their targets into the artifact")
|
||||||
|
|
||||||
buildCmd.AddCommand(buildArtifactCmd)
|
buildCmd.AddCommand(buildArtifactCmd)
|
||||||
}
|
}
|
||||||
@@ -85,6 +88,15 @@ func buildArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("invalid path '%s', must point to an existing directory or file", path)
|
return fmt.Errorf("invalid path '%s', must point to an existing directory or file", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if buildArtifactArgs.resolveSymlinks {
|
||||||
|
resolved, cleanupDir, err := resolveSymlinks(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving symlinks failed: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(cleanupDir)
|
||||||
|
path = resolved
|
||||||
|
}
|
||||||
|
|
||||||
logger.Actionf("building artifact from %s", path)
|
logger.Actionf("building artifact from %s", path)
|
||||||
|
|
||||||
ociClient := oci.NewClient(oci.DefaultOptions())
|
ociClient := oci.NewClient(oci.DefaultOptions())
|
||||||
@@ -96,6 +108,141 @@ func buildArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveSymlinks creates a temporary directory with symlinks resolved to their
|
||||||
|
// real file contents. This allows building artifacts from symlink trees (e.g.,
|
||||||
|
// those created by Nix) where the actual files live outside the source directory.
|
||||||
|
// It returns the resolved path and the temporary directory path for cleanup.
|
||||||
|
func resolveSymlinks(srcPath string) (string, string, error) {
|
||||||
|
absPath, err := filepath.Abs(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a single file, resolve the symlink and return the path to the
|
||||||
|
// copied file within the temp dir, preserving file semantics for callers.
|
||||||
|
if !info.IsDir() {
|
||||||
|
resolved, err := filepath.EvalSymlinks(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("resolving symlink for %s: %w", absPath, err)
|
||||||
|
}
|
||||||
|
tmpDir, err := os.MkdirTemp("", "flux-artifact-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
dst := filepath.Join(tmpDir, filepath.Base(absPath))
|
||||||
|
if err := copyFile(resolved, dst); err != nil {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return dst, tmpDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "flux-artifact-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
visited := make(map[string]bool)
|
||||||
|
if err := copyDir(absPath, tmpDir, visited); err != nil {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpDir, tmpDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyDir recursively copies the contents of srcDir to dstDir, resolving any
|
||||||
|
// symlinks encountered along the way. The visited map tracks resolved real
|
||||||
|
// directory paths to detect and break symlink cycles.
|
||||||
|
func copyDir(srcDir, dstDir string, visited map[string]bool) error {
|
||||||
|
real, err := filepath.EvalSymlinks(srcDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving symlink %s: %w", srcDir, err)
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(real)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting absolute path for %s: %w", real, err)
|
||||||
|
}
|
||||||
|
if visited[abs] {
|
||||||
|
return nil // break the cycle
|
||||||
|
}
|
||||||
|
visited[abs] = true
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(srcDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
srcPath := filepath.Join(srcDir, entry.Name())
|
||||||
|
dstPath := filepath.Join(dstDir, entry.Name())
|
||||||
|
|
||||||
|
// Resolve symlinks to get the real path and info.
|
||||||
|
realPath, err := filepath.EvalSymlinks(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving symlink %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
realInfo, err := os.Stat(realPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat resolved path %s: %w", realPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if realInfo.IsDir() {
|
||||||
|
if err := os.MkdirAll(dstPath, realInfo.Mode()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Recursively copy the resolved directory contents.
|
||||||
|
if err := copyDir(realPath, dstPath, visited); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !realInfo.Mode().IsRegular() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyFile(realPath, dstPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
srcInfo, err := os.Stat(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func saveReaderToFile(reader io.Reader) (string, error) {
|
func saveReaderToFile(reader io.Reader) (string, error) {
|
||||||
b, err := io.ReadAll(bufio.NewReader(reader))
|
b, err := io.ReadAll(bufio.NewReader(reader))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -68,3 +69,113 @@ data:
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_resolveSymlinks(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
// Create source directory with a real file
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
realFile := filepath.Join(srcDir, "real.yaml")
|
||||||
|
g.Expect(os.WriteFile(realFile, []byte("apiVersion: v1\nkind: Namespace\nmetadata:\n name: test\n"), 0o644)).To(Succeed())
|
||||||
|
|
||||||
|
// Create a directory with symlinks pointing to files outside it
|
||||||
|
symlinkDir := t.TempDir()
|
||||||
|
symlinkFile := filepath.Join(symlinkDir, "linked.yaml")
|
||||||
|
g.Expect(os.Symlink(realFile, symlinkFile)).To(Succeed())
|
||||||
|
|
||||||
|
// Also add a regular file in the symlink dir
|
||||||
|
regularFile := filepath.Join(symlinkDir, "regular.yaml")
|
||||||
|
g.Expect(os.WriteFile(regularFile, []byte("apiVersion: v1\nkind: ConfigMap\n"), 0o644)).To(Succeed())
|
||||||
|
|
||||||
|
// Create a symlinked subdirectory
|
||||||
|
subDir := filepath.Join(srcDir, "subdir")
|
||||||
|
g.Expect(os.MkdirAll(subDir, 0o755)).To(Succeed())
|
||||||
|
g.Expect(os.WriteFile(filepath.Join(subDir, "nested.yaml"), []byte("nested"), 0o644)).To(Succeed())
|
||||||
|
g.Expect(os.Symlink(subDir, filepath.Join(symlinkDir, "linkeddir"))).To(Succeed())
|
||||||
|
|
||||||
|
// Resolve symlinks
|
||||||
|
resolved, cleanupDir, err := resolveSymlinks(symlinkDir)
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
t.Cleanup(func() { os.RemoveAll(cleanupDir) })
|
||||||
|
|
||||||
|
// Verify the regular file was copied
|
||||||
|
content, err := os.ReadFile(filepath.Join(resolved, "regular.yaml"))
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
g.Expect(string(content)).To(Equal("apiVersion: v1\nkind: ConfigMap\n"))
|
||||||
|
|
||||||
|
// Verify the symlinked file was resolved and copied
|
||||||
|
content, err = os.ReadFile(filepath.Join(resolved, "linked.yaml"))
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
g.Expect(string(content)).To(ContainSubstring("kind: Namespace"))
|
||||||
|
|
||||||
|
// Verify that the resolved file is a regular file, not a symlink
|
||||||
|
info, err := os.Lstat(filepath.Join(resolved, "linked.yaml"))
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
g.Expect(info.Mode().IsRegular()).To(BeTrue())
|
||||||
|
|
||||||
|
// Verify that the symlinked directory was resolved and its contents were copied
|
||||||
|
content, err = os.ReadFile(filepath.Join(resolved, "linkeddir", "nested.yaml"))
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
g.Expect(string(content)).To(Equal("nested"))
|
||||||
|
|
||||||
|
// Verify that the file inside the symlinked directory is a regular file
|
||||||
|
info, err = os.Lstat(filepath.Join(resolved, "linkeddir", "nested.yaml"))
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
g.Expect(info.Mode().IsRegular()).To(BeTrue())
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_resolveSymlinks_singleFile(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
// Create a real file
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
realFile := filepath.Join(srcDir, "manifest.yaml")
|
||||||
|
g.Expect(os.WriteFile(realFile, []byte("kind: ConfigMap"), 0o644)).To(Succeed())
|
||||||
|
|
||||||
|
// Create a symlink to the real file
|
||||||
|
linkDir := t.TempDir()
|
||||||
|
linkFile := filepath.Join(linkDir, "link.yaml")
|
||||||
|
g.Expect(os.Symlink(realFile, linkFile)).To(Succeed())
|
||||||
|
|
||||||
|
// Resolve the single symlinked file
|
||||||
|
resolved, cleanupDir, err := resolveSymlinks(linkFile)
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
t.Cleanup(func() { os.RemoveAll(cleanupDir) })
|
||||||
|
|
||||||
|
// The returned path should be a file, not a directory
|
||||||
|
info, err := os.Stat(resolved)
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
g.Expect(info.IsDir()).To(BeFalse())
|
||||||
|
|
||||||
|
// Verify contents
|
||||||
|
content, err := os.ReadFile(resolved)
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
g.Expect(string(content)).To(Equal("kind: ConfigMap"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_resolveSymlinks_cycle(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
// Create a directory with a symlink cycle: dir/link -> dir
|
||||||
|
dir := t.TempDir()
|
||||||
|
g.Expect(os.WriteFile(filepath.Join(dir, "file.yaml"), []byte("data"), 0o644)).To(Succeed())
|
||||||
|
g.Expect(os.Symlink(dir, filepath.Join(dir, "cycle"))).To(Succeed())
|
||||||
|
|
||||||
|
// resolveSymlinks should not infinite-loop
|
||||||
|
resolved, cleanupDir, err := resolveSymlinks(dir)
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
t.Cleanup(func() { os.RemoveAll(cleanupDir) })
|
||||||
|
|
||||||
|
// The file should be copied
|
||||||
|
content, err := os.ReadFile(filepath.Join(resolved, "file.yaml"))
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
g.Expect(string(content)).To(Equal("data"))
|
||||||
|
|
||||||
|
// The cycle directory should exist but not cause infinite nesting
|
||||||
|
_, err = os.Stat(filepath.Join(resolved, "cycle"))
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
|
||||||
|
// There should NOT be deeply nested cycle/cycle/cycle/... paths
|
||||||
|
_, err = os.Stat(filepath.Join(resolved, "cycle", "cycle", "cycle"))
|
||||||
|
g.Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
}
|
||||||
|
|||||||
@@ -186,8 +186,6 @@ func main() {
|
|||||||
// logger, we configure it's logger to do nothing.
|
// logger, we configure it's logger to do nothing.
|
||||||
ctrllog.SetLogger(logr.New(ctrllog.NullLogSink{}))
|
ctrllog.SetLogger(logr.New(ctrllog.NullLogSink{}))
|
||||||
|
|
||||||
registerPlugins()
|
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
|
||||||
if err, ok := err.(*RequestError); ok {
|
if err, ok := err.(*RequestError); ok {
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
/*
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
/*
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -103,17 +103,18 @@ The command can read the credentials from '~/.docker/config.json' but they can a
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pushArtifactFlags struct {
|
type pushArtifactFlags struct {
|
||||||
path string
|
path string
|
||||||
source string
|
source string
|
||||||
revision string
|
revision string
|
||||||
creds string
|
creds string
|
||||||
provider flags.SourceOCIProvider
|
provider flags.SourceOCIProvider
|
||||||
ignorePaths []string
|
ignorePaths []string
|
||||||
annotations []string
|
annotations []string
|
||||||
output string
|
output string
|
||||||
debug bool
|
debug bool
|
||||||
reproducible bool
|
reproducible bool
|
||||||
insecure bool
|
insecure bool
|
||||||
|
resolveSymlinks bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var pushArtifactArgs = newPushArtifactFlags()
|
var pushArtifactArgs = newPushArtifactFlags()
|
||||||
@@ -137,6 +138,7 @@ func init() {
|
|||||||
pushArtifactCmd.Flags().BoolVarP(&pushArtifactArgs.debug, "debug", "", false, "display logs from underlying library")
|
pushArtifactCmd.Flags().BoolVarP(&pushArtifactArgs.debug, "debug", "", false, "display logs from underlying library")
|
||||||
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.reproducible, "reproducible", false, "ensure reproducible image digests by setting the created timestamp to '1970-01-01T00:00:00Z'")
|
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.reproducible, "reproducible", false, "ensure reproducible image digests by setting the created timestamp to '1970-01-01T00:00:00Z'")
|
||||||
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.insecure, "insecure-registry", false, "allows artifacts to be pushed without TLS")
|
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.insecure, "insecure-registry", false, "allows artifacts to be pushed without TLS")
|
||||||
|
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.resolveSymlinks, "resolve-symlinks", false, "resolve symlinks by copying their targets into the artifact")
|
||||||
|
|
||||||
pushCmd.AddCommand(pushArtifactCmd)
|
pushCmd.AddCommand(pushArtifactCmd)
|
||||||
}
|
}
|
||||||
@@ -183,6 +185,15 @@ func pushArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("invalid path '%s', must point to an existing directory or file: %w", path, err)
|
return fmt.Errorf("invalid path '%s', must point to an existing directory or file: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pushArtifactArgs.resolveSymlinks {
|
||||||
|
resolved, cleanupDir, err := resolveSymlinks(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving symlinks failed: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(cleanupDir)
|
||||||
|
path = resolved
|
||||||
|
}
|
||||||
|
|
||||||
annotations := map[string]string{}
|
annotations := map[string]string{}
|
||||||
for _, annotation := range pushArtifactArgs.annotations {
|
for _, annotation := range pushArtifactArgs.annotations {
|
||||||
kv := strings.Split(annotation, "=")
|
kv := strings.Split(annotation, "=")
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -8,7 +8,6 @@ replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1
|
|||||||
require (
|
require (
|
||||||
github.com/Masterminds/semver/v3 v3.4.0
|
github.com/Masterminds/semver/v3 v3.4.0
|
||||||
github.com/ProtonMail/go-crypto v1.3.0
|
github.com/ProtonMail/go-crypto v1.3.0
|
||||||
github.com/briandowns/spinner v1.23.2
|
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1
|
github.com/cyphar/filepath-securejoin v0.6.1
|
||||||
github.com/distribution/distribution/v3 v3.0.0
|
github.com/distribution/distribution/v3 v3.0.0
|
||||||
github.com/fluxcd/cli-utils v0.37.2-flux.1
|
github.com/fluxcd/cli-utils v0.37.2-flux.1
|
||||||
@@ -41,7 +40,6 @@ require (
|
|||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/google/go-containerregistry v0.20.7
|
github.com/google/go-containerregistry v0.20.7
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2
|
github.com/hashicorp/go-cleanhttp v0.5.2
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
|
||||||
github.com/homeport/dyff v1.10.2
|
github.com/homeport/dyff v1.10.2
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||||
github.com/manifoldco/promptui v0.9.0
|
github.com/manifoldco/promptui v0.9.0
|
||||||
@@ -51,6 +49,7 @@ require (
|
|||||||
github.com/onsi/gomega v1.39.1
|
github.com/onsi/gomega v1.39.1
|
||||||
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
|
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/theckman/yacspin v0.13.12
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/term v0.40.0
|
golang.org/x/term v0.40.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.34.0
|
||||||
@@ -163,6 +162,7 @@ require (
|
|||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
|
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -91,8 +91,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
|||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||||
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||||
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
|
|
||||||
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
|
|
||||||
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
|
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
|
||||||
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
|
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
|
||||||
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
|
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
|
||||||
@@ -537,6 +535,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U=
|
github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U=
|
||||||
github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8=
|
github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8=
|
||||||
|
github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4=
|
||||||
|
github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg=
|
||||||
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
|
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
|
||||||
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||||
github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo=
|
github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo=
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/briandowns/spinner"
|
"github.com/theckman/yacspin"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
"k8s.io/apimachinery/pkg/api/meta"
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
@@ -81,7 +81,7 @@ type Builder struct {
|
|||||||
action kustomize.Action
|
action kustomize.Action
|
||||||
kustomization *kustomizev1.Kustomization
|
kustomization *kustomizev1.Kustomization
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
spinner *spinner.Spinner
|
spinner *yacspin.Spinner
|
||||||
dryRun bool
|
dryRun bool
|
||||||
strictSubst bool
|
strictSubst bool
|
||||||
recursive bool
|
recursive bool
|
||||||
@@ -111,9 +111,22 @@ func WithTimeout(timeout time.Duration) BuilderOptionFunc {
|
|||||||
|
|
||||||
func WithProgressBar() BuilderOptionFunc {
|
func WithProgressBar() BuilderOptionFunc {
|
||||||
return func(b *Builder) error {
|
return func(b *Builder) error {
|
||||||
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
|
// Add a spinner
|
||||||
s.Suffix = " Kustomization diffing... " + spinnerDryRunMessage
|
cfg := yacspin.Config{
|
||||||
b.spinner = s
|
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
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +215,7 @@ func withClientConfigFrom(in *Builder) BuilderOptionFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// withSpinnerFrom copies the spinner field from another Builder.
|
// withClientConfigFrom copies spinner field
|
||||||
func withSpinnerFrom(in *Builder) BuilderOptionFunc {
|
func withSpinnerFrom(in *Builder) BuilderOptionFunc {
|
||||||
return func(b *Builder) error {
|
return func(b *Builder) error {
|
||||||
b.spinner = in.spinner
|
b.spinner = in.spinner
|
||||||
@@ -733,7 +746,12 @@ func (b *Builder) StartSpinner() error {
|
|||||||
if b.spinner == nil {
|
if b.spinner == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
b.spinner.Start()
|
|
||||||
|
err := b.spinner.Start()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start spinner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,6 +759,14 @@ func (b *Builder) StopSpinner() error {
|
|||||||
if b.spinner == nil {
|
if b.spinner == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
b.spinner.Stop()
|
|
||||||
|
status := b.spinner.Status()
|
||||||
|
if status == yacspin.SpinnerRunning || status == yacspin.SpinnerPaused {
|
||||||
|
err := b.spinner.Stop()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stop spinner: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,14 +173,14 @@ func (b *Builder) diff() (string, bool, error) {
|
|||||||
|
|
||||||
// finished with Kustomization diff
|
// finished with Kustomization diff
|
||||||
if b.spinner != nil {
|
if b.spinner != nil {
|
||||||
b.spinner.Suffix = " " + spinnerDryRunMessage
|
b.spinner.Message(spinnerDryRunMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if b.spinner != nil {
|
if b.spinner != nil {
|
||||||
b.spinner.Suffix = " processing inventory"
|
b.spinner.Message("processing inventory")
|
||||||
}
|
}
|
||||||
|
|
||||||
if b.kustomization.Spec.Prune && len(diffErrs) == 0 {
|
if b.kustomization.Spec.Prune && len(diffErrs) == 0 {
|
||||||
@@ -204,7 +204,7 @@ func (b *Builder) diff() (string, bool, error) {
|
|||||||
|
|
||||||
func (b *Builder) kustomizationDiff(kustomization *kustomizev1.Kustomization) (string, bool, error) {
|
func (b *Builder) kustomizationDiff(kustomization *kustomizev1.Kustomization) (string, bool, error) {
|
||||||
if b.spinner != nil {
|
if b.spinner != nil {
|
||||||
b.spinner.Suffix = " " + fmt.Sprintf("%s in %s", spinnerDryRunMessage, kustomization.Name)
|
b.spinner.Message(fmt.Sprintf("%s in %s", spinnerDryRunMessage, kustomization.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceRef := kustomization.Spec.SourceRef.DeepCopy()
|
sourceRef := kustomization.Spec.SourceRef.DeepCopy()
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
/*
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
/*
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/*
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/*
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
/*
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
/*
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
//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())
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
/*
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
/*
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/*
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
/*
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
312
rfcs/xxxx-cli-plugin-system/README.md
Normal file
312
rfcs/xxxx-cli-plugin-system/README.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# RFC-XXXX Flux CLI Plugin System
|
||||||
|
|
||||||
|
**Status:** provisional
|
||||||
|
|
||||||
|
**Creation date:** 2026-03-30
|
||||||
|
|
||||||
|
**Last update:** 2026-03-30
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This RFC proposes a plugin system for the Flux CLI that allows external CLI tools to be
|
||||||
|
discoverable and invocable as `flux <name>` subcommands. Plugins are installed from a
|
||||||
|
centralized catalog hosted on GitHub, with SHA-256 checksum verification and automatic
|
||||||
|
version updates. The design follows the established kubectl plugin pattern used across
|
||||||
|
the Kubernetes ecosystem.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The Flux CLI currently has no mechanism for extending its functionality with external tools.
|
||||||
|
Projects like [flux-operator](https://github.com/controlplaneio-fluxcd/flux-operator) and
|
||||||
|
[flux-local](https://github.com/allenporter/flux-local) provide complementary CLI tools
|
||||||
|
that users install and invoke separately. This creates a fragmented user experience where
|
||||||
|
Flux-related workflows require switching between multiple binaries with different flag
|
||||||
|
conventions and discovery mechanisms.
|
||||||
|
|
||||||
|
The Kubernetes ecosystem has a proven model for CLI extensibility: kubectl plugins are
|
||||||
|
executables prefixed with `kubectl-` that can be discovered, installed via
|
||||||
|
[krew](https://krew.sigs.k8s.io/), and invoked as `kubectl <name>`. This model has
|
||||||
|
been widely adopted and is well understood by Kubernetes users.
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
- Allow external CLI tools to be invoked as `flux <name>` subcommands without modifying
|
||||||
|
the external binary.
|
||||||
|
- Provide a `flux plugin install` command to download plugins from a centralized catalog
|
||||||
|
with checksum verification.
|
||||||
|
- Support shell completion for plugin subcommands by delegating to the plugin's own
|
||||||
|
Cobra `__complete` command.
|
||||||
|
- Support plugins written as scripts (Python, Bash, etc.) via symlinks into the
|
||||||
|
plugin directory.
|
||||||
|
- Ensure built-in commands always take priority over plugins.
|
||||||
|
- Keep the plugin system lightweight with zero impact on non-plugin Flux commands.
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
- Plugin dependency management (plugins are standalone binaries).
|
||||||
|
- Cosign/SLSA signature verification (SHA-256 only in v1beta1; signatures can be added later).
|
||||||
|
- Automatic update checks on startup (users run `flux plugin update` explicitly).
|
||||||
|
- Private catalog authentication (users can use `$FLUXCD_PLUGIN_CATALOG` with TLS).
|
||||||
|
- Flag sharing between Flux and plugins (`--namespace`, `--context`, etc. are not
|
||||||
|
forwarded; plugins manage their own flags).
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
### Plugin Discovery
|
||||||
|
|
||||||
|
Plugins are executables prefixed with `flux-` placed in a single plugin directory.
|
||||||
|
The `flux-<name>` binary maps to the `flux <name>` command. For example,
|
||||||
|
`flux-operator` becomes `flux operator`.
|
||||||
|
|
||||||
|
The default plugin directory is `~/.fluxcd/plugins/`. Users can override it with the
|
||||||
|
`$FLUXCD_PLUGINS` environment variable. Only this single directory is scanned.
|
||||||
|
|
||||||
|
When a plugin is discovered, it appears under a "Plugin Commands:" group in `flux --help`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Plugin Commands:
|
||||||
|
operator Runs the operator plugin
|
||||||
|
|
||||||
|
Additional Commands:
|
||||||
|
bootstrap Deploy Flux on a cluster the GitOps way.
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin Execution
|
||||||
|
|
||||||
|
On macOS and Linux, `flux operator export report` replaces the current process with
|
||||||
|
`flux-operator export report` via `syscall.Exec`, matching kubectl's behavior.
|
||||||
|
On Windows, the plugin runs as a child process with full I/O passthrough.
|
||||||
|
All arguments after the plugin name are passed through verbatim with
|
||||||
|
`DisableFlagParsing: true`.
|
||||||
|
|
||||||
|
### Shell Completion
|
||||||
|
|
||||||
|
Shell completion is delegated to the plugin binary via Cobra's `__complete` protocol.
|
||||||
|
When the user types `flux operator get <TAB>`, Flux runs
|
||||||
|
`flux-operator __complete get ""` and returns the results. This works automatically
|
||||||
|
for all Cobra-based plugins (like flux-operator). Non-Cobra plugins gracefully degrade
|
||||||
|
to no completions.
|
||||||
|
|
||||||
|
### Plugin Catalog
|
||||||
|
|
||||||
|
A dedicated GitHub repository ([fluxcd/plugins](https://github.com/fluxcd/plugins))
|
||||||
|
serves as the plugin catalog. Each plugin has a YAML manifest:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: cli.fluxcd.io/v1beta1
|
||||||
|
kind: Plugin
|
||||||
|
name: operator
|
||||||
|
description: Flux Operator CLI
|
||||||
|
homepage: https://fluxoperator.dev/
|
||||||
|
source: https://github.com/controlplaneio-fluxcd/flux-operator
|
||||||
|
bin: flux-operator
|
||||||
|
versions:
|
||||||
|
- version: 0.45.0
|
||||||
|
platforms:
|
||||||
|
- os: darwin
|
||||||
|
arch: arm64
|
||||||
|
url: https://github.com/.../flux-operator_0.45.0_darwin_arm64.tar.gz
|
||||||
|
checksum: sha256:cd85d5d84d264...
|
||||||
|
- os: linux
|
||||||
|
arch: amd64
|
||||||
|
url: https://github.com/.../flux-operator_0.45.0_linux_amd64.tar.gz
|
||||||
|
checksum: sha256:96198da969096...
|
||||||
|
```
|
||||||
|
|
||||||
|
A generated `catalog.yaml` (`PluginCatalog` kind) contains static metadata for all
|
||||||
|
plugins, enabling `flux plugin search` with a single HTTP fetch.
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `flux plugin list` (alias: `ls`) | List installed plugins with versions and paths |
|
||||||
|
| `flux plugin install <name>[@<version>]` | Install a plugin from the catalog |
|
||||||
|
| `flux plugin uninstall <name>` | Remove a plugin binary and receipt |
|
||||||
|
| `flux plugin update [name]` | Update one or all installed plugins |
|
||||||
|
| `flux plugin search [query]` | Search the plugin catalog |
|
||||||
|
|
||||||
|
### Install Flow
|
||||||
|
|
||||||
|
1. Fetch `plugins/<name>.yaml` from the catalog URL
|
||||||
|
2. Validate `apiVersion: cli.fluxcd.io/v1beta1` and `kind: Plugin`
|
||||||
|
3. Resolve version (latest if unspecified, or match `@version`)
|
||||||
|
4. Find platform entry matching `runtime.GOOS` / `runtime.GOARCH`
|
||||||
|
5. Download archive to temp file with SHA-256 checksum verification
|
||||||
|
6. Extract only the declared binary from the archive (tar.gz or zip), streaming
|
||||||
|
directly to disk without buffering in memory
|
||||||
|
7. Write binary to plugin directory as `flux-<name>` (mode `0755`)
|
||||||
|
8. Write install receipt (`flux-<name>.yaml`) recording version, platform, download URL, checksum and timestamp
|
||||||
|
|
||||||
|
Install is idempotent -- reinstalling overwrites the binary and receipt.
|
||||||
|
|
||||||
|
### Install Receipts
|
||||||
|
|
||||||
|
When a plugin is installed via `flux plugin install`, a receipt file is written
|
||||||
|
next to the binary:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: operator
|
||||||
|
version: "0.45.0"
|
||||||
|
installedAt: "2026-03-30T10:00:00Z"
|
||||||
|
platform:
|
||||||
|
os: darwin
|
||||||
|
arch: arm64
|
||||||
|
url: https://github.com/.../flux-operator_0.45.0_darwin_arm64.tar.gz
|
||||||
|
checksum: sha256:cd85d5d84d264...
|
||||||
|
```
|
||||||
|
|
||||||
|
Receipts enable `flux plugin list` to show versions, `flux plugin update` to compare
|
||||||
|
installed vs. latest, and provenance tracking. Manually installed plugins (no receipt)
|
||||||
|
show `manual` in listings and are skipped by `flux plugin update`.
|
||||||
|
|
||||||
|
### User Stories
|
||||||
|
|
||||||
|
#### Flux User Installs a Plugin
|
||||||
|
|
||||||
|
As a Flux user, I want to install the Flux Operator CLI as a plugin so that I can
|
||||||
|
manage Flux instances using `flux operator` instead of a separate `flux-operator` binary.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flux plugin install operator
|
||||||
|
flux operator get instance -n flux-system
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Flux User Updates Plugins
|
||||||
|
|
||||||
|
As a Flux user, I want to update all my installed plugins to the latest versions
|
||||||
|
with a single command.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flux plugin update
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Flux User Symlinks a Python Plugin
|
||||||
|
|
||||||
|
As a Flux user, I want to use [flux-local](https://github.com/allenporter/flux-local)
|
||||||
|
(a Python tool) as a Flux CLI plugin by symlinking it into the plugin directory.
|
||||||
|
Since flux-local is not a Go binary distributed via the catalog, I install it with
|
||||||
|
pip and register it manually.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
uv pip install flux-local
|
||||||
|
ln -s "$(pwd)/.venv/bin/flux-local" ~/.fluxcd/plugins/flux-local
|
||||||
|
flux local test
|
||||||
|
```
|
||||||
|
|
||||||
|
Manually symlinked plugins show `manual` in `flux plugin list` and are skipped by
|
||||||
|
`flux plugin update`.
|
||||||
|
|
||||||
|
#### Flux User Discovers Available Plugins
|
||||||
|
|
||||||
|
As a Flux user, I want to search for available plugins so that I can extend my
|
||||||
|
Flux CLI with community tools.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flux plugin search
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Plugin Author Publishes a Plugin
|
||||||
|
|
||||||
|
As a plugin author, I want to submit my tool to the Flux plugin catalog so that
|
||||||
|
Flux users can install it with `flux plugin install <name>`.
|
||||||
|
|
||||||
|
1. Release binary with GoReleaser (produces tarballs/zips + checksums)
|
||||||
|
2. Submit a PR to `fluxcd/plugins` with `plugins/<name>.yaml`
|
||||||
|
3. Subsequent releases are picked up by automated polling workflows
|
||||||
|
|
||||||
|
### Alternatives
|
||||||
|
|
||||||
|
#### PATH-based Discovery (kubectl model)
|
||||||
|
|
||||||
|
kubectl discovers plugins by scanning `$PATH` for `kubectl-*` executables. This is
|
||||||
|
simple but has drawbacks:
|
||||||
|
|
||||||
|
- Scanning the entire PATH is slow on some systems
|
||||||
|
- No control over what's discoverable (any `flux-*` binary on PATH becomes a plugin)
|
||||||
|
- No install/update mechanism built in (requires a separate tool like krew)
|
||||||
|
|
||||||
|
The single-directory approach is faster, more predictable, and integrates install/update
|
||||||
|
directly into the CLI.
|
||||||
|
|
||||||
|
## Design Details
|
||||||
|
|
||||||
|
### Package Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/plugin/
|
||||||
|
discovery.go # Plugin dir scanning, DI-based Handler
|
||||||
|
completion.go # Shell completion via Cobra __complete protocol
|
||||||
|
exec_unix.go # syscall.Exec (//go:build !windows)
|
||||||
|
exec_windows.go # os/exec fallback (//go:build windows)
|
||||||
|
catalog.go # Catalog fetching, manifest parsing, version/platform resolution
|
||||||
|
install.go # Download, verify, extract, receipts
|
||||||
|
update.go # Compare receipts vs catalog, update check
|
||||||
|
|
||||||
|
cmd/flux/
|
||||||
|
plugin.go # Cobra command registration, all plugin subcommands
|
||||||
|
```
|
||||||
|
|
||||||
|
The `internal/plugin` package uses dependency injection (injectable `ReadDir`, `Stat`,
|
||||||
|
`GetEnv`, `HomeDir` on a `Handler` struct) for testability. Tests mock these functions
|
||||||
|
directly without filesystem fixtures.
|
||||||
|
|
||||||
|
### Plugin Directory
|
||||||
|
|
||||||
|
- **Default**: `~/.fluxcd/plugins/` -- auto-created by install/update commands
|
||||||
|
(best-effort, no error if filesystem is read-only).
|
||||||
|
- **Override**: `$FLUXCD_PLUGINS` env var replaces the default directory path.
|
||||||
|
When set, the CLI does not auto-create the directory.
|
||||||
|
|
||||||
|
### Startup Behavior
|
||||||
|
|
||||||
|
`registerPlugins()` is called in `main()` before `rootCmd.Execute()`. It scans the
|
||||||
|
plugin directory and registers discovered plugins as Cobra subcommands. The scan is
|
||||||
|
lightweight (a single `ReadDir` call) and only occurs if the plugin directory exists.
|
||||||
|
Built-in commands always take priority.
|
||||||
|
|
||||||
|
### Manifest Validation
|
||||||
|
|
||||||
|
Both plugin manifests and the catalog are validated after fetching:
|
||||||
|
|
||||||
|
- `apiVersion` must be `cli.fluxcd.io/v1beta1`
|
||||||
|
- `kind` must be `Plugin` or `PluginCatalog` respectively
|
||||||
|
- Checksum format is `<algorithm>:<hex>` (currently `sha256:...`), allowing future
|
||||||
|
algorithm migration without schema changes
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
- **Checksum verification**: All downloaded archives are verified against SHA-256
|
||||||
|
checksums declared in the catalog manifest before extraction.
|
||||||
|
- **Path traversal protection**: Archive extraction guards against tar traversal.
|
||||||
|
- **Response size limits**: HTTP responses from the catalog are capped at 10 MiB to
|
||||||
|
prevent unbounded memory allocation from malicious servers.
|
||||||
|
- **No code execution during discovery**: Plugin directory scanning only reads directory
|
||||||
|
entries and file metadata. No plugin binary is executed during startup.
|
||||||
|
- **Retryable fetching**: All HTTP/S operations use automatic retries for transient network failures.
|
||||||
|
|
||||||
|
### Catalog Repository CI
|
||||||
|
|
||||||
|
The `fluxcd/plugins` repository includes CI workflows that:
|
||||||
|
|
||||||
|
1. Validate plugin manifests on every PR (schema, name consistency, URL reachability,
|
||||||
|
checksum verification, binary presence in archives, no builtin collisions)
|
||||||
|
2. Regenerate `catalog.yaml` when plugins are added or removed
|
||||||
|
3. Automatically poll upstream repositories for new releases and create update PRs
|
||||||
|
|
||||||
|
### Known Limitations (v1beta1)
|
||||||
|
|
||||||
|
1. **No cosign/SLSA verification** -- SHA-256 only. Signature verification can be added later.
|
||||||
|
2. **No plugin dependencies** -- plugins are standalone binaries.
|
||||||
|
3. **No automatic update checks** -- users run `flux plugin update` explicitly.
|
||||||
|
4. **No private catalog auth** -- `$FLUXCD_PLUGIN_CATALOG` works for private URLs but no token injection.
|
||||||
|
5. **No version constraints** -- no `>=0.44.0` ranges. Exact version or latest only.
|
||||||
|
6. **Flag names differ between Flux and plugins** -- e.g., `--context` (flux) vs
|
||||||
|
`--kube-context` (flux-operator). This is a plugin concern, not a system concern.
|
||||||
|
|
||||||
|
## Implementation History
|
||||||
|
|
||||||
|
- **2026-03-30** PoC plugin catalog repository with example manifests and CI validation workflows available at [fluxcd/plugins](https://github.com/fluxcd/plugins).
|
||||||
Reference in New Issue
Block a user