diff --git a/cmd/flux/main.go b/cmd/flux/main.go index 0e96614d..2c4662f6 100644 --- a/cmd/flux/main.go +++ b/cmd/flux/main.go @@ -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") diff --git a/cmd/flux/main_context_ns_test.go b/cmd/flux/main_context_ns_test.go new file mode 100644 index 00000000..85fe82db --- /dev/null +++ b/cmd/flux/main_context_ns_test.go @@ -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)) + }) + } +}