Merge branch 'main' into taras/aws-codecommit

This commit is contained in:
Taras 2026-04-30 10:40:07 +01:00 committed by GitHub
commit b60c1f89d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 409 additions and 14 deletions

View file

@ -100,6 +100,16 @@ Command line utility for assembling Kubernetes CD pipelines the GitOps way.`,
# Uninstall Flux and delete CRDs
flux uninstall`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// If opted in via --ns-follows-kube-context flag or
// FLUX_NS_FOLLOWS_KUBE_CONTEXT env var, and --namespace was not
// explicitly set, respect the namespace from the kubeconfig context.
if !cmd.Flags().Changed("namespace") &&
(rootArgs.nsFollowsKubeContext || os.Getenv("FLUX_NS_FOLLOWS_KUBE_CONTEXT") != "") {
if ctxNs := getKubeconfigContextNamespace(kubeconfigArgs); ctxNs != "" {
*kubeconfigArgs.Namespace = ctxNs
}
}
ns, err := cmd.Flags().GetString("namespace")
if err != nil {
return fmt.Errorf("error getting namespace: %w", err)
@ -116,10 +126,11 @@ Command line utility for assembling Kubernetes CD pipelines the GitOps way.`,
var logger = stderrLogger{stderr: os.Stderr}
type rootFlags struct {
timeout time.Duration
verbose bool
pollInterval time.Duration
defaults install.Options
timeout time.Duration
verbose bool
pollInterval time.Duration
nsFollowsKubeContext bool
defaults install.Options
}
// RequestError is a custom error type that wraps an error returned by the flux api.
@ -139,6 +150,8 @@ var kubeclientOptions = new(runclient.Options)
func init() {
rootCmd.PersistentFlags().DurationVar(&rootArgs.timeout, "timeout", 5*time.Minute, "timeout for this operation")
rootCmd.PersistentFlags().BoolVar(&rootArgs.verbose, "verbose", false, "print generated objects")
rootCmd.PersistentFlags().BoolVar(&rootArgs.nsFollowsKubeContext, "ns-follows-kube-context", false,
"use the namespace from the kubeconfig context instead of the default flux-system namespace, can also be set via FLUX_NS_FOLLOWS_KUBE_CONTEXT env var")
configureDefaultNamespace()
kubeconfigArgs.APIServer = nil // prevent AddFlags from configuring --server flag
@ -205,6 +218,26 @@ func main() {
}
}
// getKubeconfigContextNamespace returns the namespace from the current
// kubeconfig context, or an empty string if it cannot be determined.
func getKubeconfigContextNamespace(cf *genericclioptions.ConfigFlags) string {
rawConfig, err := cf.ToRawKubeConfigLoader().RawConfig()
if err != nil {
return ""
}
currentContext := rawConfig.CurrentContext
if cf.Context != nil && *cf.Context != "" {
currentContext = *cf.Context
}
if ctx, ok := rawConfig.Contexts[currentContext]; ok {
return ctx.Namespace
}
return ""
}
func configureDefaultNamespace() {
*kubeconfigArgs.Namespace = rootArgs.defaults.Namespace
fromEnv := os.Getenv("FLUX_SYSTEM_NAMESPACE")

View file

@ -0,0 +1,221 @@
/*
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 (
"os"
"path/filepath"
"testing"
. "github.com/onsi/gomega"
"k8s.io/cli-runtime/pkg/genericclioptions"
)
func TestGetKubeconfigContextNamespace(t *testing.T) {
tests := []struct {
name string
kubeconfig string
context string
expectedResult string
}{
{
name: "returns namespace from current context",
kubeconfig: `apiVersion: v1
kind: Config
current-context: my-context
contexts:
- name: my-context
context:
cluster: my-cluster
namespace: custom-ns
clusters:
- name: my-cluster
cluster:
server: https://localhost:6443
`,
expectedResult: "custom-ns",
},
{
name: "returns empty when context has no namespace",
kubeconfig: `apiVersion: v1
kind: Config
current-context: my-context
contexts:
- name: my-context
context:
cluster: my-cluster
clusters:
- name: my-cluster
cluster:
server: https://localhost:6443
`,
expectedResult: "",
},
{
name: "returns namespace from context specified via --context flag",
kubeconfig: `apiVersion: v1
kind: Config
current-context: default-context
contexts:
- name: default-context
context:
cluster: my-cluster
namespace: default-ns
- name: other-context
context:
cluster: my-cluster
namespace: other-ns
clusters:
- name: my-cluster
cluster:
server: https://localhost:6443
`,
context: "other-context",
expectedResult: "other-ns",
},
{
name: "returns empty when context does not exist",
kubeconfig: `apiVersion: v1
kind: Config
current-context: non-existent
contexts: []
clusters: []
`,
expectedResult: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
// Write temporary kubeconfig.
tmpDir := t.TempDir()
kcPath := filepath.Join(tmpDir, "kubeconfig")
g.Expect(os.WriteFile(kcPath, []byte(tt.kubeconfig), 0o600)).To(Succeed())
// Use a local ConfigFlags instance to avoid polluting the
// package-global kubeconfigArgs (which caches a clientConfig
// internally and would leak state across tests).
cf := genericclioptions.NewConfigFlags(false)
cf.KubeConfig = &kcPath
cf.Context = &tt.context
got := getKubeconfigContextNamespace(cf)
g.Expect(got).To(Equal(tt.expectedResult))
})
}
}
func TestContextNamespaceOptIn(t *testing.T) {
kubeconfig := `apiVersion: v1
kind: Config
current-context: my-context
contexts:
- name: my-context
context:
cluster: my-cluster
namespace: context-ns
clusters:
- name: my-cluster
cluster:
server: https://localhost:6443
`
tests := []struct {
name string
nsFollowsFlag bool
nsFollowsEnv string
envNamespace string
flagNamespace string
expectedNamespace string
}{
{
name: "ignores context namespace when not opted in",
expectedNamespace: rootArgs.defaults.Namespace,
},
{
name: "uses context namespace when opted in via flag",
nsFollowsFlag: true,
expectedNamespace: "context-ns",
},
{
name: "uses context namespace when opted in via env var",
nsFollowsEnv: "1",
expectedNamespace: "context-ns",
},
{
name: "context namespace takes precedence over FLUX_SYSTEM_NAMESPACE when opted in",
nsFollowsFlag: true,
envNamespace: "env-ns",
expectedNamespace: "context-ns",
},
{
name: "FLUX_SYSTEM_NAMESPACE used when not opted in",
envNamespace: "env-ns",
expectedNamespace: "env-ns",
},
{
name: "--namespace flag takes precedence over context namespace",
nsFollowsFlag: true,
flagNamespace: "flag-ns",
expectedNamespace: "flag-ns",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
// Write temporary kubeconfig.
tmpDir := t.TempDir()
kcPath := filepath.Join(tmpDir, "kubeconfig")
g.Expect(os.WriteFile(kcPath, []byte(kubeconfig), 0o600)).To(Succeed())
// Use a local ConfigFlags instance to avoid polluting the
// package-global kubeconfigArgs.
cf := genericclioptions.NewConfigFlags(false)
cf.KubeConfig = &kcPath
emptyCtx := ""
cf.Context = &emptyCtx
// Mirror configureDefaultNamespace behavior on the local instance.
defaultNs := rootArgs.defaults.Namespace
cf.Namespace = &defaultNs
if tt.envNamespace != "" {
t.Setenv("FLUX_SYSTEM_NAMESPACE", tt.envNamespace)
envNs := tt.envNamespace
cf.Namespace = &envNs
}
if tt.nsFollowsEnv != "" {
t.Setenv("FLUX_NS_FOLLOWS_KUBE_CONTEXT", tt.nsFollowsEnv)
}
// Simulate PersistentPreRunE behavior.
if tt.flagNamespace != "" {
*cf.Namespace = tt.flagNamespace
} else if tt.nsFollowsFlag || os.Getenv("FLUX_NS_FOLLOWS_KUBE_CONTEXT") != "" {
if ctxNs := getKubeconfigContextNamespace(cf); ctxNs != "" {
*cf.Namespace = ctxNs
}
}
g.Expect(*cf.Namespace).To(Equal(tt.expectedNamespace))
})
}
}

View file

@ -98,6 +98,12 @@ func parseNameVersion(s string) (string, string) {
return s, ""
}
// isDigestRef reports whether ref is a content-addressable digest
// (e.g. "sha256:06e0a38...").
func isDigestRef(ref string) bool {
return strings.HasPrefix(ref, "sha256:")
}
// newCatalogClient creates a CatalogClient that respects FLUXCD_PLUGIN_CATALOG.
func newCatalogClient() *plugin.CatalogClient {
client := plugin.NewCatalogClient()

View file

@ -23,10 +23,11 @@ import (
"github.com/spf13/cobra"
"github.com/fluxcd/flux2/v2/internal/plugin"
plugintypes "github.com/fluxcd/flux2/v2/pkg/plugin"
)
var pluginInstallCmd = &cobra.Command{
Use: "install <name>[@<version>]",
Use: "install <name>[@<version>|@<digest>]",
Short: "Install a plugin from the catalog",
Long: `The plugin install command downloads and installs a plugin from the Flux plugin catalog.
@ -35,7 +36,10 @@ Examples:
flux plugin install operator
# Install a specific version
flux plugin install operator@0.45.0`,
flux plugin install operator@0.45.0
# Install pinned to a specific digest
flux plugin install operator@sha256:06e0a38db4fa6bc9f705a577c7e58dc020bfe2618e45488599e5ef7bb62e3a8a`,
Args: cobra.ExactArgs(1),
RunE: pluginInstallCmdRun,
}
@ -46,7 +50,7 @@ func init() {
func pluginInstallCmdRun(cmd *cobra.Command, args []string) error {
nameVersion := args[0]
name, version := parseNameVersion(nameVersion)
name, ref := parseNameVersion(nameVersion)
catalogClient := newCatalogClient()
manifest, err := catalogClient.FetchManifest(name)
@ -54,14 +58,26 @@ func pluginInstallCmdRun(cmd *cobra.Command, args []string) error {
return err
}
pv, err := plugin.ResolveVersion(manifest, version)
if err != nil {
return err
}
var pv *plugintypes.Version
var plat *plugintypes.Platform
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)
if isDigestRef(ref) {
dm, err := plugin.ResolveByDigest(manifest, ref, runtime.GOOS, runtime.GOARCH)
if err != nil {
return err
}
pv = dm.Version
plat = dm.Platform
} else {
pv, err = plugin.ResolveVersion(manifest, ref)
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()

View file

@ -211,6 +211,7 @@ func TestParseNameVersion(t *testing.T) {
{"operator@0.45.0", "operator", "0.45.0"},
{"my-tool@1.0.0", "my-tool", "1.0.0"},
{"plugin@", "plugin", ""},
{"operator@sha256:abc123", "operator", "sha256:abc123"},
}
for _, tt := range tests {
@ -226,6 +227,27 @@ func TestParseNameVersion(t *testing.T) {
}
}
func TestIsDigestRef(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"sha256:06e0a38db4fa6bc9f705a577c7e58dc020bfe2618e45488599e5ef7bb62e3a8a", true},
{"0.45.0", false},
{"", false},
{"sha256", false},
{"SHA256:abc", false}, // case-sensitive
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := isDigestRef(tt.input); got != tt.want {
t.Errorf("isDigestRef(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestPluginDiscoverSkipsBuiltins(t *testing.T) {
origHandler := pluginHandler
defer func() { pluginHandler = origHandler }()

View file

@ -165,3 +165,33 @@ func ResolvePlatform(pv *plugintypes.Version, goos, goarch string) (*plugintypes
return nil, fmt.Errorf("no binary for %s/%s", goos, goarch)
}
// DigestMatch holds the version and platform resolved from a digest lookup.
type DigestMatch struct {
Version *plugintypes.Version
Platform *plugintypes.Platform
}
// ResolveByDigest scans all versions and platforms for a checksum matching
// digest. The digest must be in "algorithm:hex" format (e.g.
// "sha256:06e0a38..."). Only platforms matching goos/goarch are considered.
// Returns the first match (versions are ordered newest-first in the manifest).
func ResolveByDigest(manifest *plugintypes.Manifest, digest, goos, goarch string) (*DigestMatch, error) {
if len(manifest.Versions) == 0 {
return nil, fmt.Errorf("plugin %q has no versions", manifest.Name)
}
for i := range manifest.Versions {
for j := range manifest.Versions[i].Platforms {
p := &manifest.Versions[i].Platforms[j]
if p.OS == goos && p.Arch == goarch && p.Checksum == digest {
return &DigestMatch{
Version: &manifest.Versions[i],
Platform: p,
}, nil
}
}
}
return nil, fmt.Errorf("digest %q not found for plugin %q on %s/%s", digest, manifest.Name, goos, goarch)
}

View file

@ -239,3 +239,70 @@ func TestResolvePlatform(t *testing.T) {
}
})
}
func TestResolveByDigest(t *testing.T) {
manifest := &plugintypes.Manifest{
Name: "operator",
Versions: []plugintypes.Version{
{
Version: "0.46.0",
Platforms: []plugintypes.Platform{
{OS: "linux", Arch: "amd64", URL: "https://example.com/v46_linux.tar.gz", Checksum: "sha256:aaaa"},
{OS: "darwin", Arch: "arm64", URL: "https://example.com/v46_darwin.tar.gz", Checksum: "sha256:bbbb"},
},
},
{
Version: "0.45.0",
Platforms: []plugintypes.Platform{
{OS: "linux", Arch: "amd64", URL: "https://example.com/v45_linux.tar.gz", Checksum: "sha256:cccc"},
{OS: "darwin", Arch: "arm64", URL: "https://example.com/v45_darwin.tar.gz", Checksum: "sha256:dddd"},
},
},
},
}
t.Run("found in latest version", func(t *testing.T) {
dm, err := ResolveByDigest(manifest, "sha256:aaaa", "linux", "amd64")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dm.Version.Version != "0.46.0" {
t.Errorf("expected version '0.46.0', got %q", dm.Version.Version)
}
if dm.Platform.Checksum != "sha256:aaaa" {
t.Errorf("expected checksum 'sha256:aaaa', got %q", dm.Platform.Checksum)
}
})
t.Run("found in older version", func(t *testing.T) {
dm, err := ResolveByDigest(manifest, "sha256:cccc", "linux", "amd64")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dm.Version.Version != "0.45.0" {
t.Errorf("expected version '0.45.0', got %q", dm.Version.Version)
}
})
t.Run("wrong platform", func(t *testing.T) {
// sha256:bbbb exists for darwin/arm64, not linux/amd64.
_, err := ResolveByDigest(manifest, "sha256:bbbb", "linux", "amd64")
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("not found", func(t *testing.T) {
_, err := ResolveByDigest(manifest, "sha256:nonexistent", "linux", "amd64")
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("no versions", func(t *testing.T) {
_, err := ResolveByDigest(&plugintypes.Manifest{Name: "empty"}, "sha256:abc", "linux", "amd64")
if err == nil {
t.Fatal("expected error, got nil")
}
})
}