Compare commits
14 Commits
plugin-sys
...
v2.8.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa461ed21 | ||
|
|
f11a921e0c | ||
|
|
b248efab1d | ||
|
|
4d5e044eb9 | ||
|
|
3c8917ca28 | ||
|
|
c1f11bcf3d | ||
|
|
bc6aa4754c | ||
|
|
0e7194ce77 | ||
|
|
97222a775a | ||
|
|
959025e649 | ||
|
|
75f5e33fd1 | ||
|
|
79cae5d033 | ||
|
|
41505fd5f3 | ||
|
|
2042ddf849 |
6
.github/labels.yaml
vendored
6
.github/labels.yaml
vendored
@@ -44,12 +44,12 @@
|
||||
description: Feature request proposals in the RFC format
|
||||
color: '#D621C3'
|
||||
aliases: ['area/RFC']
|
||||
- name: backport:release/v2.5.x
|
||||
description: To be backported to release/v2.5.x
|
||||
color: '#ffd700'
|
||||
- name: backport:release/v2.6.x
|
||||
description: To be backported to release/v2.6.x
|
||||
color: '#ffd700'
|
||||
- name: backport:release/v2.7.x
|
||||
description: To be backported to release/v2.7.x
|
||||
color: '#ffd700'
|
||||
- name: backport:release/v2.8.x
|
||||
description: To be backported to release/v2.8.x
|
||||
color: '#ffd700'
|
||||
|
||||
2
.github/workflows/README.md
vendored
2
.github/workflows/README.md
vendored
@@ -23,7 +23,7 @@ amd when it finds a new controller version, the workflow performs the following
|
||||
- Updates the controller API package version in `go.mod`.
|
||||
- Patches the controller CRDs version in the `manifests/crds` overlay.
|
||||
- Patches the controller Deployment version in `manifests/bases` overlay.
|
||||
- Opens a Pull Request against the checked out branch.
|
||||
- Opens a Pull Request against the `main` branch.
|
||||
- Triggers the e2e test suite to run for the opened PR.
|
||||
|
||||
|
||||
|
||||
2
.github/workflows/conformance.yaml
vendored
2
.github/workflows/conformance.yaml
vendored
@@ -3,7 +3,7 @@ name: conformance
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ 'main', 'update-components-**', 'release/**', 'conform*' ]
|
||||
branches: [ 'main', 'update-components', 'release/**', 'conform*' ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
2
.github/workflows/update.yaml
vendored
2
.github/workflows/update.yaml
vendored
@@ -106,7 +106,7 @@ jobs:
|
||||
committer: GitHub <noreply@github.com>
|
||||
author: fluxcdbot <fluxcdbot@users.noreply.github.com>
|
||||
signoff: true
|
||||
branch: update-components-${{ github.ref_name }}
|
||||
branch: update-components
|
||||
title: Update toolkit components
|
||||
body: |
|
||||
${{ steps.update.outputs.pr_body }}
|
||||
|
||||
3
.github/workflows/upgrade-fluxcd-pkg.yaml
vendored
3
.github/workflows/upgrade-fluxcd-pkg.yaml
vendored
@@ -3,9 +3,6 @@ name: upgrade-fluxcd-pkg
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upgrade-fluxcd-pkg:
|
||||
uses: fluxcd/gha-workflows/.github/workflows/upgrade-fluxcd-pkg.yaml@v0.9.0
|
||||
|
||||
@@ -186,8 +186,6 @@ func main() {
|
||||
// logger, we configure it's logger to do nothing.
|
||||
ctrllog.SetLogger(logr.New(ctrllog.NullLogSink{}))
|
||||
|
||||
registerPlugins()
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
|
||||
if err, ok := err.(*RequestError); ok {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -152,14 +152,7 @@ func reconciliationHandled(kubeClient client.Client, namespacedName types.Namesp
|
||||
return false, err
|
||||
}
|
||||
|
||||
switch result.Status {
|
||||
case kstatus.CurrentStatus:
|
||||
return true, nil
|
||||
case kstatus.InProgressStatus:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("%s", result.Message)
|
||||
}
|
||||
return result.Status == kstatus.CurrentStatus, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,17 +126,6 @@ func (resume resumeCommand) run(cmd *cobra.Command, args []string) error {
|
||||
|
||||
resume.printMessage(reconcileResps)
|
||||
|
||||
// Return an error if any reconciliation failed
|
||||
var failedCount int
|
||||
for _, r := range reconcileResps {
|
||||
if r.resumable != nil && r.err != nil {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
if failedCount > 0 {
|
||||
return fmt.Errorf("reconciliation failed for %d %s(s)", failedCount, resume.kind)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ The following template can be used for the GitHub release page:
|
||||
|
||||
<!-- Text describing the most important changes in this release -->
|
||||
|
||||
ℹ️ Please follow the [Upgrade Procedure for Flux v2.7+](https://github.com/fluxcd/flux2/discussions/5572) for a smooth upgrade from Flux v2.6 to the latest version.
|
||||
|
||||
### Fixes and improvements
|
||||
|
||||
<!-- List of fixes and improvements to the controllers and CLI -->
|
||||
@@ -38,7 +36,7 @@ The following template can be used for the GitHub release page:
|
||||
|
||||
## Components changelog
|
||||
|
||||
- <name>-controller [v<version>](https://github.com/fluxcd/<name>-controller/blob/<version>/CHANGELOG.md)
|
||||
- <name>-controller [v<version>](https://github.com/fluxcd/<name>-controller/blob/<version>/CHANGELOG.md
|
||||
|
||||
## CLI changelog
|
||||
|
||||
|
||||
28
go.mod
28
go.mod
@@ -8,30 +8,29 @@ replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1
|
||||
require (
|
||||
github.com/Masterminds/semver/v3 v3.4.0
|
||||
github.com/ProtonMail/go-crypto v1.3.0
|
||||
github.com/briandowns/spinner v1.23.2
|
||||
github.com/cyphar/filepath-securejoin v0.6.1
|
||||
github.com/distribution/distribution/v3 v3.0.0
|
||||
github.com/fluxcd/cli-utils v0.37.2-flux.1
|
||||
github.com/fluxcd/go-git-providers v0.26.0
|
||||
github.com/fluxcd/helm-controller/api v1.5.3
|
||||
github.com/fluxcd/helm-controller/api v1.5.2
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.1
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.1
|
||||
github.com/fluxcd/kustomize-controller/api v1.8.2
|
||||
github.com/fluxcd/notification-controller/api v1.8.2
|
||||
github.com/fluxcd/pkg/apis/event v0.25.0
|
||||
github.com/fluxcd/pkg/apis/meta v1.26.0
|
||||
github.com/fluxcd/pkg/auth v0.40.0
|
||||
github.com/fluxcd/pkg/chartutil v1.23.0
|
||||
github.com/fluxcd/pkg/apis/event v0.24.1
|
||||
github.com/fluxcd/pkg/apis/meta v1.25.1
|
||||
github.com/fluxcd/pkg/auth v0.38.4
|
||||
github.com/fluxcd/pkg/chartutil v1.22.1
|
||||
github.com/fluxcd/pkg/envsubst v1.5.0
|
||||
github.com/fluxcd/pkg/git v0.46.0
|
||||
github.com/fluxcd/pkg/kustomize v1.28.0
|
||||
github.com/fluxcd/pkg/oci v0.63.0
|
||||
github.com/fluxcd/pkg/runtime v0.103.0
|
||||
github.com/fluxcd/pkg/git v0.43.1
|
||||
github.com/fluxcd/pkg/kustomize v1.27.1
|
||||
github.com/fluxcd/pkg/oci v0.60.1
|
||||
github.com/fluxcd/pkg/runtime v0.100.4
|
||||
github.com/fluxcd/pkg/sourceignore v0.17.0
|
||||
github.com/fluxcd/pkg/ssa v0.70.0
|
||||
github.com/fluxcd/pkg/ssa v0.67.3
|
||||
github.com/fluxcd/pkg/ssh v0.24.0
|
||||
github.com/fluxcd/pkg/tar v0.17.0
|
||||
github.com/fluxcd/pkg/version v0.14.0
|
||||
github.com/fluxcd/pkg/version v0.12.0
|
||||
github.com/fluxcd/source-controller/api v1.8.1
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.1.1
|
||||
github.com/go-git/go-git/v5 v5.16.5
|
||||
@@ -41,7 +40,6 @@ require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/go-containerregistry v0.20.7
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||
github.com/homeport/dyff v1.10.2
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/manifoldco/promptui v0.9.0
|
||||
@@ -51,6 +49,7 @@ require (
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/theckman/yacspin v0.13.12
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/text v0.34.0
|
||||
@@ -129,7 +128,7 @@ require (
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0 // indirect
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.16.0 // indirect
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.15.1 // indirect
|
||||
github.com/fluxcd/pkg/cache v0.13.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
@@ -163,6 +162,7 @@ require (
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
|
||||
|
||||
56
go.sum
56
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/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
|
||||
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
|
||||
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
|
||||
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
|
||||
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
|
||||
@@ -176,8 +174,8 @@ github.com/fluxcd/gitkit v0.6.0 h1:iNg5LTx6ePo+Pl0ZwqHTAkhbUHxGVSY3YCxCdw7VIFg=
|
||||
github.com/fluxcd/gitkit v0.6.0/go.mod h1:svOHuKi0fO9HoawdK4HfHAJJseZDHHjk7I3ihnCIqNo=
|
||||
github.com/fluxcd/go-git-providers v0.26.0 h1:0DUsXc1nS9Fe4n8tXSEUCGemWzHShd66gmotayDPekw=
|
||||
github.com/fluxcd/go-git-providers v0.26.0/go.mod h1:VJDKUOhZwNAIqDF5iPtIpTr/annsDbKMkPpWiDMBdpo=
|
||||
github.com/fluxcd/helm-controller/api v1.5.3 h1:ruLzuyTHjjE9A5B/U+Id2q7yHXXqSFTswdZ14xCS5So=
|
||||
github.com/fluxcd/helm-controller/api v1.5.3/go.mod h1:lTgeUmtVYExMKp7mRDncsr4JwHTz3LFtLjRJZeR98lI=
|
||||
github.com/fluxcd/helm-controller/api v1.5.2 h1:3xeFPcsvibingV32y8MRj040ydZIPOMCTToMXOkhtUo=
|
||||
github.com/fluxcd/helm-controller/api v1.5.2/go.mod h1:lTgeUmtVYExMKp7mRDncsr4JwHTz3LFtLjRJZeR98lI=
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.1 h1:uiu7kjdVoW8/461HOemX6I7RcPornEzQliWgTg6LnWI=
|
||||
github.com/fluxcd/image-automation-controller/api v1.1.1/go.mod h1:lkD/drkD6Wc+2SDjVj5KqfozEucTLFexWgby/5ft660=
|
||||
github.com/fluxcd/image-reflector-controller/api v1.1.1 h1:4Bj1abzVnjj8+b/293kNeFMRJc+y2wO8Z12ReZ/gA0w=
|
||||
@@ -188,40 +186,40 @@ github.com/fluxcd/notification-controller/api v1.8.2 h1:TDrXohUC5Gh3BF+v2ux9/zEG
|
||||
github.com/fluxcd/notification-controller/api v1.8.2/go.mod h1:ozgJGQPy0dG5eOsLZlwAr6n0q/y6+TWd1fGOtavlXJA=
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0 h1:wBpgsKT+jcyZEcM//OmZr9RiF8klL3ebrDp2u2ThsnA=
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0/go.mod h1:TttNS+gocsGLwnvmgVi3/Yscwqrjc17+vhgYfqkfrV4=
|
||||
github.com/fluxcd/pkg/apis/event v0.25.0 h1:zdwytvDhG+fk+Ywl5DOtv7TklkrVgM21WHm1f+YhleE=
|
||||
github.com/fluxcd/pkg/apis/event v0.25.0/go.mod h1:TlK8HWYrTwl0raqBRC+ROoNpYW5fdVnwcwOBOx5Kzw8=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.16.0 h1:PhWXEhqQqsisIpwp1/wHvTvo+MO+GGzsBPoN0ZnRE3Y=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.16.0/go.mod h1:IZOy4CCtR/hxMGb7erK1RfbGnczVv4/dRBoVD37AywI=
|
||||
github.com/fluxcd/pkg/apis/meta v1.26.0 h1:dxP1FfBpTCYso6odzRcltVnnRuBb2VyhhgV0VX9YbUE=
|
||||
github.com/fluxcd/pkg/apis/meta v1.26.0/go.mod h1:c7o6mJGLCMvNrfdinGZehkrdZuFT9vZdZNrn66DtVD0=
|
||||
github.com/fluxcd/pkg/auth v0.40.0 h1:p6Kw6KH+z8oRqngKhmTt8ILKD/rC+8tP87a//kLZhi8=
|
||||
github.com/fluxcd/pkg/auth v0.40.0/go.mod h1:Oq/hIEKUMTbL2bv5blf+EhC/jXXJLsOjIMtJj/AtG3Y=
|
||||
github.com/fluxcd/pkg/apis/event v0.24.1 h1:TClVdn02aiq3sAl9BuzLjjTIxm3JJ83fJ9nchtBa4qg=
|
||||
github.com/fluxcd/pkg/apis/event v0.24.1/go.mod h1:TlK8HWYrTwl0raqBRC+ROoNpYW5fdVnwcwOBOx5Kzw8=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.15.1 h1:t9QZh+3ZS8EKmlxrnnbcKZcGTrg8FDvMF1T8BHMCuqI=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.15.1/go.mod h1:IZOy4CCtR/hxMGb7erK1RfbGnczVv4/dRBoVD37AywI=
|
||||
github.com/fluxcd/pkg/apis/meta v1.25.1 h1:WG1GIC/SOz0GjxT0uVuO6AMicQ3yFsk6bDozCnq+fto=
|
||||
github.com/fluxcd/pkg/apis/meta v1.25.1/go.mod h1:c7o6mJGLCMvNrfdinGZehkrdZuFT9vZdZNrn66DtVD0=
|
||||
github.com/fluxcd/pkg/auth v0.38.4 h1:xVsJ1rakUm5zS2tOKguZOQc5g6wLgCNxW2a9exidd4M=
|
||||
github.com/fluxcd/pkg/auth v0.38.4/go.mod h1:KTXOh770ukcyQfC8NavEFzm110ORSQRan0v/kjzgFXs=
|
||||
github.com/fluxcd/pkg/cache v0.13.0 h1:MqtlgOwIVcGKKgV422e39O+KFSVMWuExKeRaMDBjJlk=
|
||||
github.com/fluxcd/pkg/cache v0.13.0/go.mod h1:0xRZ1hitrIFQ6pl68ke2wZLbIqA2VLzY78HpDo9DVxs=
|
||||
github.com/fluxcd/pkg/chartutil v1.23.0 h1:ohstQEVnrBIbN85FGu83hnmAohLl0PdOoPlsM6+cjyI=
|
||||
github.com/fluxcd/pkg/chartutil v1.23.0/go.mod h1:kFhmD6DwBgRsvC1ilINsomargMi2WbqvSndWQLikkLc=
|
||||
github.com/fluxcd/pkg/chartutil v1.22.1 h1:ufI9LJ4d5T79h9ruBQRoRcSmuI/KkcwEqWdxu/9Xub8=
|
||||
github.com/fluxcd/pkg/chartutil v1.22.1/go.mod h1:4/2mpNLyfox3uey++hG21AePPsMWekdhSWAtSdDiubQ=
|
||||
github.com/fluxcd/pkg/envsubst v1.5.0 h1:S07mo+MkGhptdHA4pRze5HPKlc8tHxKswNdcMZi1WDY=
|
||||
github.com/fluxcd/pkg/envsubst v1.5.0/go.mod h1:c3a8DYI855sZUubHFYQbjfjop6Wu4/zg1cLyf7SnCes=
|
||||
github.com/fluxcd/pkg/git v0.46.0 h1:QMh0+ZzQ2jO6rIGj4ffR5trZ8g/cxvt8cVajReJ8Iyw=
|
||||
github.com/fluxcd/pkg/git v0.46.0/go.mod h1:iHcIjx9c8zye3PQiajTJYxgOMRiy7WCs+hfLKDswpfI=
|
||||
github.com/fluxcd/pkg/gittestserver v0.26.0 h1:+RZrCzFRsE+d5WaqAoqaPCEgcgv/jZp6+f7DS0+Ynb8=
|
||||
github.com/fluxcd/pkg/gittestserver v0.26.0/go.mod h1:7fybYb0yej1fFNiF1ohs0Jr0XzyaZQ/cRh3AFEoCtuc=
|
||||
github.com/fluxcd/pkg/kustomize v1.28.0 h1:0RuFVczJRabbt8frHZ/ql8aqte6BOOKk274O09l6/hE=
|
||||
github.com/fluxcd/pkg/kustomize v1.28.0/go.mod h1:cW08mnngSP8MJYb6mDmMvxH8YjNATdiML0udb37dk+M=
|
||||
github.com/fluxcd/pkg/oci v0.63.0 h1:ZPKTT2C+gWYjhP63xC76iTPdYE9w3ABcsDq77uhAgwo=
|
||||
github.com/fluxcd/pkg/oci v0.63.0/go.mod h1:qMPz4njvm6hJzdyGSb8ydSqrapXxTQwJonxHIsdeXSQ=
|
||||
github.com/fluxcd/pkg/runtime v0.103.0 h1:J5y5GPhWdkyqIUBlaI1FP2N02TtZmsjbWhhZubuTSFk=
|
||||
github.com/fluxcd/pkg/runtime v0.103.0/go.mod h1:mbo2f3azo3yVQgm7XZGxQB6/2zvzQ5Wgtd8TjRRwwAw=
|
||||
github.com/fluxcd/pkg/git v0.43.1 h1:lw29P44wueKzQk79KnYyvisfw//cxg0S4cDeTYx+Slo=
|
||||
github.com/fluxcd/pkg/git v0.43.1/go.mod h1:3R/AjCe7ee7FqWcAG+2IiuJPOCxrGHF4SCGkuvKS6OQ=
|
||||
github.com/fluxcd/pkg/gittestserver v0.25.1 h1:40Ridmy1xKxBM9ItDn012R4VKmaoDqzvGaC5g7xv+mw=
|
||||
github.com/fluxcd/pkg/gittestserver v0.25.1/go.mod h1:7fybYb0yej1fFNiF1ohs0Jr0XzyaZQ/cRh3AFEoCtuc=
|
||||
github.com/fluxcd/pkg/kustomize v1.27.1 h1:BLOBNLb2N5ObttZA8XJhZ2NqNY1ZjBqQtTpNlIx8/L4=
|
||||
github.com/fluxcd/pkg/kustomize v1.27.1/go.mod h1:A2RQTe9woDPiwJDWFlkoP4oF9eX9DeXr89FEkKnSObk=
|
||||
github.com/fluxcd/pkg/oci v0.60.1 h1:mT6WBX+MBIcczzEnw/W4cfXyt5JSRNhRoB/UnJ72K6M=
|
||||
github.com/fluxcd/pkg/oci v0.60.1/go.mod h1:w2FGseUl3WGjwRMH/3h6MTI4gKahcBQtnGbn/TQVA34=
|
||||
github.com/fluxcd/pkg/runtime v0.100.4 h1:rwvbeoeWN0BTJORJBISJJEkWn6DVfmWwynFl2GseWns=
|
||||
github.com/fluxcd/pkg/runtime v0.100.4/go.mod h1:M6LjRJ1hIe2s6E2ykFfae1Xy/rLvOFQf2QquMKmN350=
|
||||
github.com/fluxcd/pkg/sourceignore v0.17.0 h1:Z72nruRMhC15zIEpWoDrAcJcJ1El6QDnP/aRDfE4WOA=
|
||||
github.com/fluxcd/pkg/sourceignore v0.17.0/go.mod h1:3e/VmYLId0pI/H5sK7W9Ibif+j0Ahns9RxNjDMtTTfY=
|
||||
github.com/fluxcd/pkg/ssa v0.70.0 h1:IBylYPiTK1IEdCC2DvjKXIhwQcbd5VufXA9WS3zO+tE=
|
||||
github.com/fluxcd/pkg/ssa v0.70.0/go.mod h1:6igtlt7/zF+nNFQpa5ZAkkvtpL6o36NRU39/PqqC+Bg=
|
||||
github.com/fluxcd/pkg/ssa v0.67.3 h1:mjuhH5fNOYstkF6jB7EeaWmfnt5T272Cup8ZD9O8YBQ=
|
||||
github.com/fluxcd/pkg/ssa v0.67.3/go.mod h1:6igtlt7/zF+nNFQpa5ZAkkvtpL6o36NRU39/PqqC+Bg=
|
||||
github.com/fluxcd/pkg/ssh v0.24.0 h1:hrPlxs0hhXf32DRqs68VbsXs0XfQMphyRVIk0rYYJa4=
|
||||
github.com/fluxcd/pkg/ssh v0.24.0/go.mod h1:xWammEqalrpurpcMiixJRXtynRQtBEoqheyU5F/vWrg=
|
||||
github.com/fluxcd/pkg/tar v0.17.0 h1:uNxbFXy8ly8C7fJ8D7w3rjTNJFrb4Hp1aY/30XkfvxY=
|
||||
github.com/fluxcd/pkg/tar v0.17.0/go.mod h1:b1xyIRYDD0ket4SV5u0UXYv+ZdN/O/HmIO5jZQdHQls=
|
||||
github.com/fluxcd/pkg/version v0.14.0 h1:T3llSc8sUnsuFrW5ng2ePSfXwGXUKv0YG9QXf0ErhWw=
|
||||
github.com/fluxcd/pkg/version v0.14.0/go.mod h1:YHdg/78kzf+kCqS+SqSOiUxum5AjxlixiqwpX6AUZB8=
|
||||
github.com/fluxcd/pkg/version v0.12.0 h1:MGbdbNf2D5wazMqAkNPn+Lh5j+oY0gxQJFTGyet5Hfc=
|
||||
github.com/fluxcd/pkg/version v0.12.0/go.mod h1:YHdg/78kzf+kCqS+SqSOiUxum5AjxlixiqwpX6AUZB8=
|
||||
github.com/fluxcd/source-controller/api v1.8.1 h1:49HiJF5mNEdZTwueQMRahTVts35B+xhN5CsuOAL9gQ0=
|
||||
github.com/fluxcd/source-controller/api v1.8.1/go.mod h1:HgZ6NSH1cyOE2jRoNwln1xEwr9ETvrLeiy1o4O04vQM=
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.1.1 h1:1LfT50ty+78MKKbschAZl28QbVqIyjaNq17KmW5wPJI=
|
||||
@@ -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/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U=
|
||||
github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8=
|
||||
github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4=
|
||||
github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg=
|
||||
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
|
||||
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||
github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo=
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/theckman/yacspin"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -81,7 +81,7 @@ type Builder struct {
|
||||
action kustomize.Action
|
||||
kustomization *kustomizev1.Kustomization
|
||||
timeout time.Duration
|
||||
spinner *spinner.Spinner
|
||||
spinner *yacspin.Spinner
|
||||
dryRun bool
|
||||
strictSubst bool
|
||||
recursive bool
|
||||
@@ -111,9 +111,22 @@ func WithTimeout(timeout time.Duration) BuilderOptionFunc {
|
||||
|
||||
func WithProgressBar() BuilderOptionFunc {
|
||||
return func(b *Builder) error {
|
||||
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
|
||||
s.Suffix = " Kustomization diffing... " + spinnerDryRunMessage
|
||||
b.spinner = s
|
||||
// Add a spinner
|
||||
cfg := yacspin.Config{
|
||||
Frequency: 100 * time.Millisecond,
|
||||
CharSet: yacspin.CharSets[59],
|
||||
Suffix: "Kustomization diffing...",
|
||||
SuffixAutoColon: true,
|
||||
Message: spinnerDryRunMessage,
|
||||
StopCharacter: "✓",
|
||||
StopColors: []string{"fgGreen"},
|
||||
}
|
||||
spinner, err := yacspin.New(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create spinner: %w", err)
|
||||
}
|
||||
b.spinner = spinner
|
||||
|
||||
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 {
|
||||
return func(b *Builder) error {
|
||||
b.spinner = in.spinner
|
||||
@@ -733,7 +746,12 @@ func (b *Builder) StartSpinner() error {
|
||||
if b.spinner == nil {
|
||||
return nil
|
||||
}
|
||||
b.spinner.Start()
|
||||
|
||||
err := b.spinner.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start spinner: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -741,6 +759,14 @@ func (b *Builder) StopSpinner() error {
|
||||
if b.spinner == 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
|
||||
}
|
||||
|
||||
@@ -173,14 +173,14 @@ func (b *Builder) diff() (string, bool, error) {
|
||||
|
||||
// finished with Kustomization diff
|
||||
if b.spinner != nil {
|
||||
b.spinner.Suffix = " " + spinnerDryRunMessage
|
||||
b.spinner.Message(spinnerDryRunMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if b.spinner != nil {
|
||||
b.spinner.Suffix = " processing inventory"
|
||||
b.spinner.Message("processing inventory")
|
||||
}
|
||||
|
||||
if b.kustomization.Spec.Prune && len(diffErrs) == 0 {
|
||||
@@ -204,7 +204,7 @@ func (b *Builder) diff() (string, bool, error) {
|
||||
|
||||
func (b *Builder) kustomizationDiff(kustomization *kustomizev1.Kustomization) (string, bool, error) {
|
||||
if b.spinner != nil {
|
||||
b.spinner.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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.3/helm-controller.crds.yaml
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.3/helm-controller.deployment.yaml
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.2/helm-controller.crds.yaml
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.2/helm-controller.deployment.yaml
|
||||
- account.yaml
|
||||
transformers:
|
||||
- labels.yaml
|
||||
|
||||
@@ -3,7 +3,7 @@ kind: Kustomization
|
||||
resources:
|
||||
- https://github.com/fluxcd/source-controller/releases/download/v1.8.1/source-controller.crds.yaml
|
||||
- https://github.com/fluxcd/kustomize-controller/releases/download/v1.8.2/kustomize-controller.crds.yaml
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.3/helm-controller.crds.yaml
|
||||
- https://github.com/fluxcd/helm-controller/releases/download/v1.5.2/helm-controller.crds.yaml
|
||||
- https://github.com/fluxcd/notification-controller/releases/download/v1.8.2/notification-controller.crds.yaml
|
||||
- https://github.com/fluxcd/image-reflector-controller/releases/download/v1.1.1/image-reflector-controller.crds.yaml
|
||||
- https://github.com/fluxcd/image-automation-controller/releases/download/v1.1.1/image-automation-controller.crds.yaml
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# RFC-0010 Multi-Tenant Workload Identity
|
||||
|
||||
**Status:** implemented
|
||||
**Status:** implementable
|
||||
|
||||
<!--
|
||||
Status represents the current state of the RFC.
|
||||
Must be one of `provisional`, `implementable`, `implemented`, `deferred`, `rejected`, `withdrawn`, or `replaced`.
|
||||
-->
|
||||
|
||||
**Creation date:** 2025-02-22
|
||||
|
||||
**Last update:** 2026-03-13
|
||||
**Last update:** 2025-04-29
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -1415,11 +1420,10 @@ options to call `gcp.NewTokenSource()` and feed this token source to the
|
||||
`HelmRepository` and `HelmChart`, as well as for SOPS decryption
|
||||
in the `Kustomization` API and Azure Event Hubs in the
|
||||
`Provider` API.
|
||||
* In Flux 2.7 object-level workload identity was introduced for all
|
||||
the remaining APIs that support cloud providers, i.e. `Bucket`,
|
||||
`GitRepository` and `ImageUpdateAutomation`, and also all the
|
||||
remaining types for the `Provider` API, i.e. `azuredevops` and
|
||||
`googlepubsub`. In addition, support for controller and
|
||||
object-level workload identity was introduced for the
|
||||
`Kustomization` and `HelmRelease` APIs for remote cluster
|
||||
access.
|
||||
|
||||
<!--
|
||||
Major milestones in the lifecycle of the RFC such as:
|
||||
- The first Flux release where an initial version of the RFC was available.
|
||||
- The version of Flux where the RFC graduated to general availability.
|
||||
- The version of Flux where the RFC was retired or superseded.
|
||||
-->
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# RFC-0011: OpenTelemetry Tracing
|
||||
|
||||
**Status:** implemented
|
||||
**Status:** provisional
|
||||
|
||||
<!--
|
||||
Status represents the current state of the RFC.
|
||||
Must be one of `provisional`, `implementable`, `implemented`, `deferred`, `rejected`, `withdrawn`, or `replaced`.
|
||||
-->
|
||||
|
||||
**Creation date:** 2025-04-24
|
||||
|
||||
**Last update:** 2026-03-13
|
||||
**Last update:** 2025-08-13
|
||||
|
||||
## Summary
|
||||
The aim is to be able to collect traces via OpenTelemetry (OTel) across all Flux related objects, such as HelmReleases, Kustomizations and among others. These may be sent towards a tracing provider where may be potentially stored and visualized. Flux does not have any responsibility on storing and visualizing those, it keeps being completely stateless. Thereby, being seamless for the user, the implementation is going to be part of the already existing `Alert` API Type. Therefore, `EventSources` is going to discriminate the events belonging to the specific sources, which are going to be looked up to and send them out towards the `Provider` set. In this way, it could facilitate the observability and monitoring of Flux related objects.
|
||||
@@ -205,4 +210,9 @@ This design ensures trace continuity even in challenging distributed environment
|
||||
|
||||
## Implementation History
|
||||
|
||||
* RFC implemented and generally available in Flux [v2.7.0](https://github.com/fluxcd/flux2/releases/tag/v2.7.0)
|
||||
<!--
|
||||
Major milestones in the lifecycle of the RFC such as:
|
||||
- The first Flux release where an initial version of the RFC was available.
|
||||
- The version of Flux where the RFC graduated to general availability.
|
||||
- The version of Flux where the RFC was retired or superseded.
|
||||
-->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# RFC-0012 External Artifact
|
||||
|
||||
**Status:** implemented
|
||||
**Status:** provisional
|
||||
|
||||
**Creation date:** 2025-04-08
|
||||
|
||||
**Last update:** 2026-03-13
|
||||
**Last update:** 2025-09-03
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -319,4 +319,9 @@ control the adoption of the `ExternalArtifact` feature in their clusters.
|
||||
|
||||
## Implementation History
|
||||
|
||||
* RFC implemented and generally available in Flux [v2.7.0](https://github.com/fluxcd/flux2/releases/tag/v2.7.0)
|
||||
<!--
|
||||
Major milestones in the lifecycle of the RFC such as:
|
||||
- The first Flux release where an initial version of the RFC was available.
|
||||
- The version of Flux where the RFC graduated to general availability.
|
||||
- The version of Flux where the RFC was retired or superseded.
|
||||
-->
|
||||
|
||||
@@ -11,10 +11,10 @@ require (
|
||||
github.com/fluxcd/image-reflector-controller/api v1.0.4
|
||||
github.com/fluxcd/kustomize-controller/api v1.7.3
|
||||
github.com/fluxcd/notification-controller/api v1.7.5
|
||||
github.com/fluxcd/pkg/apis/event v0.25.0
|
||||
github.com/fluxcd/pkg/apis/meta v1.26.0
|
||||
github.com/fluxcd/pkg/git v0.46.0
|
||||
github.com/fluxcd/pkg/runtime v0.103.0
|
||||
github.com/fluxcd/pkg/apis/event v0.24.1
|
||||
github.com/fluxcd/pkg/apis/meta v1.25.1
|
||||
github.com/fluxcd/pkg/git v0.43.1
|
||||
github.com/fluxcd/pkg/runtime v0.100.4
|
||||
github.com/fluxcd/source-controller/api v1.7.4
|
||||
github.com/fluxcd/test-infra/tftestenv v0.0.0-20250626232827-e0ca9c3f8d7b
|
||||
github.com/go-git/go-git/v5 v5.16.5
|
||||
@@ -66,9 +66,9 @@ require (
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0 // indirect
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.16.0 // indirect
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.15.1 // indirect
|
||||
github.com/fluxcd/pkg/ssh v0.24.0 // indirect
|
||||
github.com/fluxcd/pkg/version v0.14.0 // indirect
|
||||
github.com/fluxcd/pkg/version v0.12.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||
|
||||
@@ -136,22 +136,22 @@ github.com/fluxcd/notification-controller/api v1.7.5 h1:6CO5bKyjodiK9exQFOdBcz0X
|
||||
github.com/fluxcd/notification-controller/api v1.7.5/go.mod h1:IciwSg8Q0pVtdbsyDyEXx/MxBKWeagxAazpm64C8oCE=
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0 h1:wBpgsKT+jcyZEcM//OmZr9RiF8klL3ebrDp2u2ThsnA=
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0/go.mod h1:TttNS+gocsGLwnvmgVi3/Yscwqrjc17+vhgYfqkfrV4=
|
||||
github.com/fluxcd/pkg/apis/event v0.25.0 h1:zdwytvDhG+fk+Ywl5DOtv7TklkrVgM21WHm1f+YhleE=
|
||||
github.com/fluxcd/pkg/apis/event v0.25.0/go.mod h1:TlK8HWYrTwl0raqBRC+ROoNpYW5fdVnwcwOBOx5Kzw8=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.16.0 h1:PhWXEhqQqsisIpwp1/wHvTvo+MO+GGzsBPoN0ZnRE3Y=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.16.0/go.mod h1:IZOy4CCtR/hxMGb7erK1RfbGnczVv4/dRBoVD37AywI=
|
||||
github.com/fluxcd/pkg/apis/meta v1.26.0 h1:dxP1FfBpTCYso6odzRcltVnnRuBb2VyhhgV0VX9YbUE=
|
||||
github.com/fluxcd/pkg/apis/meta v1.26.0/go.mod h1:c7o6mJGLCMvNrfdinGZehkrdZuFT9vZdZNrn66DtVD0=
|
||||
github.com/fluxcd/pkg/git v0.46.0 h1:QMh0+ZzQ2jO6rIGj4ffR5trZ8g/cxvt8cVajReJ8Iyw=
|
||||
github.com/fluxcd/pkg/git v0.46.0/go.mod h1:iHcIjx9c8zye3PQiajTJYxgOMRiy7WCs+hfLKDswpfI=
|
||||
github.com/fluxcd/pkg/gittestserver v0.26.0 h1:+RZrCzFRsE+d5WaqAoqaPCEgcgv/jZp6+f7DS0+Ynb8=
|
||||
github.com/fluxcd/pkg/gittestserver v0.26.0/go.mod h1:7fybYb0yej1fFNiF1ohs0Jr0XzyaZQ/cRh3AFEoCtuc=
|
||||
github.com/fluxcd/pkg/runtime v0.103.0 h1:J5y5GPhWdkyqIUBlaI1FP2N02TtZmsjbWhhZubuTSFk=
|
||||
github.com/fluxcd/pkg/runtime v0.103.0/go.mod h1:mbo2f3azo3yVQgm7XZGxQB6/2zvzQ5Wgtd8TjRRwwAw=
|
||||
github.com/fluxcd/pkg/apis/event v0.24.1 h1:TClVdn02aiq3sAl9BuzLjjTIxm3JJ83fJ9nchtBa4qg=
|
||||
github.com/fluxcd/pkg/apis/event v0.24.1/go.mod h1:TlK8HWYrTwl0raqBRC+ROoNpYW5fdVnwcwOBOx5Kzw8=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.15.1 h1:t9QZh+3ZS8EKmlxrnnbcKZcGTrg8FDvMF1T8BHMCuqI=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.15.1/go.mod h1:IZOy4CCtR/hxMGb7erK1RfbGnczVv4/dRBoVD37AywI=
|
||||
github.com/fluxcd/pkg/apis/meta v1.25.1 h1:WG1GIC/SOz0GjxT0uVuO6AMicQ3yFsk6bDozCnq+fto=
|
||||
github.com/fluxcd/pkg/apis/meta v1.25.1/go.mod h1:c7o6mJGLCMvNrfdinGZehkrdZuFT9vZdZNrn66DtVD0=
|
||||
github.com/fluxcd/pkg/git v0.43.1 h1:lw29P44wueKzQk79KnYyvisfw//cxg0S4cDeTYx+Slo=
|
||||
github.com/fluxcd/pkg/git v0.43.1/go.mod h1:3R/AjCe7ee7FqWcAG+2IiuJPOCxrGHF4SCGkuvKS6OQ=
|
||||
github.com/fluxcd/pkg/gittestserver v0.25.1 h1:40Ridmy1xKxBM9ItDn012R4VKmaoDqzvGaC5g7xv+mw=
|
||||
github.com/fluxcd/pkg/gittestserver v0.25.1/go.mod h1:7fybYb0yej1fFNiF1ohs0Jr0XzyaZQ/cRh3AFEoCtuc=
|
||||
github.com/fluxcd/pkg/runtime v0.100.4 h1:rwvbeoeWN0BTJORJBISJJEkWn6DVfmWwynFl2GseWns=
|
||||
github.com/fluxcd/pkg/runtime v0.100.4/go.mod h1:M6LjRJ1hIe2s6E2ykFfae1Xy/rLvOFQf2QquMKmN350=
|
||||
github.com/fluxcd/pkg/ssh v0.24.0 h1:hrPlxs0hhXf32DRqs68VbsXs0XfQMphyRVIk0rYYJa4=
|
||||
github.com/fluxcd/pkg/ssh v0.24.0/go.mod h1:xWammEqalrpurpcMiixJRXtynRQtBEoqheyU5F/vWrg=
|
||||
github.com/fluxcd/pkg/version v0.14.0 h1:T3llSc8sUnsuFrW5ng2ePSfXwGXUKv0YG9QXf0ErhWw=
|
||||
github.com/fluxcd/pkg/version v0.14.0/go.mod h1:YHdg/78kzf+kCqS+SqSOiUxum5AjxlixiqwpX6AUZB8=
|
||||
github.com/fluxcd/pkg/version v0.12.0 h1:MGbdbNf2D5wazMqAkNPn+Lh5j+oY0gxQJFTGyet5Hfc=
|
||||
github.com/fluxcd/pkg/version v0.12.0/go.mod h1:YHdg/78kzf+kCqS+SqSOiUxum5AjxlixiqwpX6AUZB8=
|
||||
github.com/fluxcd/source-controller/api v1.7.4 h1:+EOVnRA9LmLxOx7J273l7IOEU39m+Slt/nQGBy69ygs=
|
||||
github.com/fluxcd/source-controller/api v1.7.4/go.mod h1:ruf49LEgZRBfcP+eshl2n9SX1MfHayCcViAIGnZcaDY=
|
||||
github.com/fluxcd/test-infra/tftestenv v0.0.0-20250626232827-e0ca9c3f8d7b h1:FSPtvaVgL8azcyweqLmD71elAw4vozuXH/QvsJQ7tg0=
|
||||
|
||||
Reference in New Issue
Block a user