Skip to content

Commit c8b4c4c

Browse files
authored
Merge pull request #5831 from jtyr/jtyr-context-ns
Add `--ns-follows-kube-context` global flag for using the kubeconfig context namespace
2 parents 4f5b2fc + c031d0c commit c8b4c4c

2 files changed

Lines changed: 258 additions & 4 deletions

File tree

cmd/flux/main.go

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ Command line utility for assembling Kubernetes CD pipelines the GitOps way.`,
100100
# Uninstall Flux and delete CRDs
101101
flux uninstall`,
102102
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
103+
// If opted in via --ns-follows-kube-context flag or
104+
// FLUX_NS_FOLLOWS_KUBE_CONTEXT env var, and --namespace was not
105+
// explicitly set, respect the namespace from the kubeconfig context.
106+
if !cmd.Flags().Changed("namespace") &&
107+
(rootArgs.nsFollowsKubeContext || os.Getenv("FLUX_NS_FOLLOWS_KUBE_CONTEXT") != "") {
108+
if ctxNs := getKubeconfigContextNamespace(kubeconfigArgs); ctxNs != "" {
109+
*kubeconfigArgs.Namespace = ctxNs
110+
}
111+
}
112+
103113
ns, err := cmd.Flags().GetString("namespace")
104114
if err != nil {
105115
return fmt.Errorf("error getting namespace: %w", err)
@@ -116,10 +126,11 @@ Command line utility for assembling Kubernetes CD pipelines the GitOps way.`,
116126
var logger = stderrLogger{stderr: os.Stderr}
117127

118128
type rootFlags struct {
119-
timeout time.Duration
120-
verbose bool
121-
pollInterval time.Duration
122-
defaults install.Options
129+
timeout time.Duration
130+
verbose bool
131+
pollInterval time.Duration
132+
nsFollowsKubeContext bool
133+
defaults install.Options
123134
}
124135

125136
// RequestError is a custom error type that wraps an error returned by the flux api.
@@ -139,6 +150,8 @@ var kubeclientOptions = new(runclient.Options)
139150
func init() {
140151
rootCmd.PersistentFlags().DurationVar(&rootArgs.timeout, "timeout", 5*time.Minute, "timeout for this operation")
141152
rootCmd.PersistentFlags().BoolVar(&rootArgs.verbose, "verbose", false, "print generated objects")
153+
rootCmd.PersistentFlags().BoolVar(&rootArgs.nsFollowsKubeContext, "ns-follows-kube-context", false,
154+
"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")
142155

143156
configureDefaultNamespace()
144157
kubeconfigArgs.APIServer = nil // prevent AddFlags from configuring --server flag
@@ -205,6 +218,26 @@ func main() {
205218
}
206219
}
207220

221+
// getKubeconfigContextNamespace returns the namespace from the current
222+
// kubeconfig context, or an empty string if it cannot be determined.
223+
func getKubeconfigContextNamespace(cf *genericclioptions.ConfigFlags) string {
224+
rawConfig, err := cf.ToRawKubeConfigLoader().RawConfig()
225+
if err != nil {
226+
return ""
227+
}
228+
229+
currentContext := rawConfig.CurrentContext
230+
if cf.Context != nil && *cf.Context != "" {
231+
currentContext = *cf.Context
232+
}
233+
234+
if ctx, ok := rawConfig.Contexts[currentContext]; ok {
235+
return ctx.Namespace
236+
}
237+
238+
return ""
239+
}
240+
208241
func configureDefaultNamespace() {
209242
*kubeconfigArgs.Namespace = rootArgs.defaults.Namespace
210243
fromEnv := os.Getenv("FLUX_SYSTEM_NAMESPACE")

cmd/flux/main_context_ns_test.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/*
2+
Copyright 2026 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"os"
21+
"path/filepath"
22+
"testing"
23+
24+
. "github.com/onsi/gomega"
25+
"k8s.io/cli-runtime/pkg/genericclioptions"
26+
)
27+
28+
func TestGetKubeconfigContextNamespace(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
kubeconfig string
32+
context string
33+
expectedResult string
34+
}{
35+
{
36+
name: "returns namespace from current context",
37+
kubeconfig: `apiVersion: v1
38+
kind: Config
39+
current-context: my-context
40+
contexts:
41+
- name: my-context
42+
context:
43+
cluster: my-cluster
44+
namespace: custom-ns
45+
clusters:
46+
- name: my-cluster
47+
cluster:
48+
server: https://localhost:6443
49+
`,
50+
expectedResult: "custom-ns",
51+
},
52+
{
53+
name: "returns empty when context has no namespace",
54+
kubeconfig: `apiVersion: v1
55+
kind: Config
56+
current-context: my-context
57+
contexts:
58+
- name: my-context
59+
context:
60+
cluster: my-cluster
61+
clusters:
62+
- name: my-cluster
63+
cluster:
64+
server: https://localhost:6443
65+
`,
66+
expectedResult: "",
67+
},
68+
{
69+
name: "returns namespace from context specified via --context flag",
70+
kubeconfig: `apiVersion: v1
71+
kind: Config
72+
current-context: default-context
73+
contexts:
74+
- name: default-context
75+
context:
76+
cluster: my-cluster
77+
namespace: default-ns
78+
- name: other-context
79+
context:
80+
cluster: my-cluster
81+
namespace: other-ns
82+
clusters:
83+
- name: my-cluster
84+
cluster:
85+
server: https://localhost:6443
86+
`,
87+
context: "other-context",
88+
expectedResult: "other-ns",
89+
},
90+
{
91+
name: "returns empty when context does not exist",
92+
kubeconfig: `apiVersion: v1
93+
kind: Config
94+
current-context: non-existent
95+
contexts: []
96+
clusters: []
97+
`,
98+
expectedResult: "",
99+
},
100+
}
101+
102+
for _, tt := range tests {
103+
t.Run(tt.name, func(t *testing.T) {
104+
g := NewWithT(t)
105+
106+
// Write temporary kubeconfig.
107+
tmpDir := t.TempDir()
108+
kcPath := filepath.Join(tmpDir, "kubeconfig")
109+
g.Expect(os.WriteFile(kcPath, []byte(tt.kubeconfig), 0o600)).To(Succeed())
110+
111+
// Use a local ConfigFlags instance to avoid polluting the
112+
// package-global kubeconfigArgs (which caches a clientConfig
113+
// internally and would leak state across tests).
114+
cf := genericclioptions.NewConfigFlags(false)
115+
cf.KubeConfig = &kcPath
116+
cf.Context = &tt.context
117+
118+
got := getKubeconfigContextNamespace(cf)
119+
g.Expect(got).To(Equal(tt.expectedResult))
120+
})
121+
}
122+
}
123+
124+
func TestContextNamespaceOptIn(t *testing.T) {
125+
kubeconfig := `apiVersion: v1
126+
kind: Config
127+
current-context: my-context
128+
contexts:
129+
- name: my-context
130+
context:
131+
cluster: my-cluster
132+
namespace: context-ns
133+
clusters:
134+
- name: my-cluster
135+
cluster:
136+
server: https://localhost:6443
137+
`
138+
139+
tests := []struct {
140+
name string
141+
nsFollowsFlag bool
142+
nsFollowsEnv string
143+
envNamespace string
144+
flagNamespace string
145+
expectedNamespace string
146+
}{
147+
{
148+
name: "ignores context namespace when not opted in",
149+
expectedNamespace: rootArgs.defaults.Namespace,
150+
},
151+
{
152+
name: "uses context namespace when opted in via flag",
153+
nsFollowsFlag: true,
154+
expectedNamespace: "context-ns",
155+
},
156+
{
157+
name: "uses context namespace when opted in via env var",
158+
nsFollowsEnv: "1",
159+
expectedNamespace: "context-ns",
160+
},
161+
{
162+
name: "context namespace takes precedence over FLUX_SYSTEM_NAMESPACE when opted in",
163+
nsFollowsFlag: true,
164+
envNamespace: "env-ns",
165+
expectedNamespace: "context-ns",
166+
},
167+
{
168+
name: "FLUX_SYSTEM_NAMESPACE used when not opted in",
169+
envNamespace: "env-ns",
170+
expectedNamespace: "env-ns",
171+
},
172+
{
173+
name: "--namespace flag takes precedence over context namespace",
174+
nsFollowsFlag: true,
175+
flagNamespace: "flag-ns",
176+
expectedNamespace: "flag-ns",
177+
},
178+
}
179+
180+
for _, tt := range tests {
181+
t.Run(tt.name, func(t *testing.T) {
182+
g := NewWithT(t)
183+
184+
// Write temporary kubeconfig.
185+
tmpDir := t.TempDir()
186+
kcPath := filepath.Join(tmpDir, "kubeconfig")
187+
g.Expect(os.WriteFile(kcPath, []byte(kubeconfig), 0o600)).To(Succeed())
188+
189+
// Use a local ConfigFlags instance to avoid polluting the
190+
// package-global kubeconfigArgs.
191+
cf := genericclioptions.NewConfigFlags(false)
192+
cf.KubeConfig = &kcPath
193+
emptyCtx := ""
194+
cf.Context = &emptyCtx
195+
196+
// Mirror configureDefaultNamespace behavior on the local instance.
197+
defaultNs := rootArgs.defaults.Namespace
198+
cf.Namespace = &defaultNs
199+
200+
if tt.envNamespace != "" {
201+
t.Setenv("FLUX_SYSTEM_NAMESPACE", tt.envNamespace)
202+
envNs := tt.envNamespace
203+
cf.Namespace = &envNs
204+
}
205+
if tt.nsFollowsEnv != "" {
206+
t.Setenv("FLUX_NS_FOLLOWS_KUBE_CONTEXT", tt.nsFollowsEnv)
207+
}
208+
209+
// Simulate PersistentPreRunE behavior.
210+
if tt.flagNamespace != "" {
211+
*cf.Namespace = tt.flagNamespace
212+
} else if tt.nsFollowsFlag || os.Getenv("FLUX_NS_FOLLOWS_KUBE_CONTEXT") != "" {
213+
if ctxNs := getKubeconfigContextNamespace(cf); ctxNs != "" {
214+
*cf.Namespace = ctxNs
215+
}
216+
}
217+
218+
g.Expect(*cf.Namespace).To(Equal(tt.expectedNamespace))
219+
})
220+
}
221+
}

0 commit comments

Comments
 (0)