Skip to content

fix(kubeconfig): isolate each file to stop shared-name collisions - #524

Merged
nadaverell merged 3 commits into
mainfrom
fix-kubeconfig-merge-collisions
Apr 22, 2026
Merged

nadaverell merged 3 commits into
mainfrom
fix-kubeconfig-merge-collisions

Conversation

@nadaverell

Copy link
Copy Markdown
Contributor

Fixes #519. Also fixes #411 and fixes #514 — the three are the same root-cause bug viewed from different angles.

The bug

Radar handed every discovered kubeconfig file to client-go's ClientConfigLoadingRules.Precedence merger. That merge resolves clusters, users, and contexts on a first-wins-by-name basis and silently drops later duplicates. So when two files share a user name (e.g. both define user: oidc or user: me) or a cluster name, the second file's definition is thrown away at load time. Switching to the "second" context then ends up sending the first file's token to the first file's server — which is why:

The existing "Kubeconfig loaded: files=34, contexts=18" log line was the only hint: 16 contexts silently vanished during merge.

The fix

Replace the merge with a per-file context registry. Each kubeconfig is loaded via ExplicitPath as its own island. GetAvailableContexts enumerates the registry across all files; SwitchContext looks up the qualified context name, resolves to the source file + original-name inside it, and loads only that file. Shared user/cluster names across files can't collide because they're never asked to — each ExplicitPath load sees only one file's maps.

When two files happen to share a context name (not just a user or cluster name), the registry qualifies the second one with its source filename, e.g. my-ctx (staging). The common case — distinct context names with shared user/cluster names — sees no rename and no visible UI change. That matches the #519 repro: both files have user: me but contexts kas-107 and kas-108, which now both work under their original names.

KUBECONFIG env vars with multiple colon-separated paths go through the same isolated loader. Single-file mode is unchanged. The CAPI flow (MergeAndSwitchContext) promotes to isolated-load mode on first use and returns the qualified context name so the caller switches correctly even if the incoming CAPI context name collides with an existing one.

Verification

  • Four new table-driven tests in context_registry_test.go cover no-collision, the exact Kubeconfig directory option merges configs #519 shared-user-and-cluster case, context-name collision (gets qualified), and a three-way collision (numeric-suffix fallback).
  • Ran the full Kubeconfig directory option merges configs #519 repro end-to-end against a live binary: two files with identical cluster: shared-cluster / user: shared-user but different server URLs. Both contexts are visible in the dropdown under their original names; switching to each one correctly routes to that file's server URL. Under the old code, both would have hit the first file's server.
  • Full test suite (go test ./...) green.

Diagnostic logging is now honest: the contexts=N number is the sum across all files rather than the post-merge count, so "files discovered vs contexts reachable" is always N == M unless a file fails to parse.

…isions

Radar passed every discovered kubeconfig file to client-go's Precedence
merger, which resolves cluster/user/context names on a first-wins-by-name
basis and silently drops later duplicates. When two files shared a user
name (e.g. both defined `user: oidc`), the second file's credentials were
discarded at load time; switching to that "second" context then sent the
first file's token to the second file's server — hence the auth / network
errors in the linked issues.

Replace the merge with a per-file context registry. Each kubeconfig is
loaded via ExplicitPath as its own island, and SwitchContext routes to a
single file by looking up the qualified context name. Shared user/cluster
names across files are now structurally impossible to collide. When two
files happen to share a context name, the registry qualifies the later
one with its source filename (e.g. "my-ctx (staging)"); the common case
— distinct context names, shared user/cluster names — sees no rename.

Covers #519 directly, plus #411 (can't switch clusters with multiple
kubeconfigs) and #514 (OIDC only works for the first cluster), which
share the same root cause.

Also: KUBECONFIG env var with multiple paths uses the same isolated
loader; single-file mode is unchanged. The CAPI MergeAndSwitchContext
path promotes to isolated-load mode on first use and returns the
qualified context name so callers switch correctly even when the CAPI
context name collides with an existing registered one.

Tests: four new table-driven cases in context_registry_test.go cover
the no-collision, #519 shared-user-and-cluster, context-name-collision,
and three-way-collision scenarios.
@nadaverell
nadaverell requested a review from hisco as a code owner April 22, 2026 21:55
Comment thread internal/server/server.go Fixed
Comment thread internal/server/server.go Fixed
- SwitchContext: snapshot kubeconfigPaths and perFileConfigs under clientMu
  alongside contextRegistry so aggregateExecPluginCommands reads a consistent
  set; previously a concurrent MergeAndSwitchContext mutation could trigger
  a data race (flagged by the code-reviewer and silent-failure-hunter).
- MergeAndSwitchContext: rewrite to serialize the whole reuse-or-create
  decision under one clientMu.Lock, eliminating the TOCTOU window where two
  concurrent CAPI merges could both see "no existing path" and both leak a
  temp file. Key capiKubeconfigs by the caller-provided contextName (stable
  across reconnects from the same workload cluster) rather than the qualified
  name, so the reuse path actually hits. Stage registry mutations into local
  maps and only commit to globals after successful validation — no more
  half-state if registration fails. Refresh perFileConfigs on overwrite so
  GetAvailableContexts reflects YAML changes.
- buildContextRegistry: add log.Printf + errorlog.Record when a file fails
  to parse. The doc comment promised a log line; now the code matches.
- Rename contextEntry.OriginalName → InFileName (reads better at call sites:
  entry.InFileName == cfg.CurrentContext is unambiguous).
- Rename mergedContextCount → totalContextCount. With isolated loading there
  is no merge, so the old name was actively misleading; drop the "diff churn"
  excuse comment.
- setupIsolatedLoad: clarify the comment about why unlocked global writes
  are safe (called from doInit inside initOnce, so no concurrent readers).

Tests:
- TestSwitchContextRouting_SharedNames_RoutesToCorrectFile: regression guard
  for #519 at the routing level — two files with shared user/cluster names
  but distinct tokens/servers; verifies each context's ExplicitPath load
  surfaces *its own* file's credentials, not the first file's.
- TestAggregateExecPluginCommands_EmptyCommandScopedByFile: covers the
  empty-Command aggregation path so diagnostics correctly identify which
  file holds a malformed exec block.

Verified green with `go test ./...` and `go test -race ./internal/k8s/`.
CodeQL flagged server.go's "Connected to workload cluster" log line
(alerts 230, 231) because qualifiedName is derived from user-uploaded
kubeconfig YAML — a crafted context name with CR/LF could inject
forged log lines when Radar's stderr is scraped by a log aggregator.
Radar ships as both a local tool and an in-cluster component, so the
scraped-logs scenario is realistic enough to care about.

Switch the affected format specifiers to %q across both CAPI log sites
(server.go:2526 and client.go:927, 1007 — same shape, not yet flagged
but would be on the next scan).
Comment thread internal/server/server.go
// crafted context name can't inject forged log lines when Radar's stderr
// is scraped by a log aggregator. CodeQL alert "Log entries created from
// user input".
log.Printf("[capi] Connected to workload cluster %s/%s (context: %q, kubeconfig: %q)", ns, name, qualifiedName, mergedPath)
Comment thread internal/server/server.go
// crafted context name can't inject forged log lines when Radar's stderr
// is scraped by a log aggregator. CodeQL alert "Log entries created from
// user input".
log.Printf("[capi] Connected to workload cluster %s/%s (context: %q, kubeconfig: %q)", ns, name, qualifiedName, mergedPath)
@nadaverell
nadaverell merged commit 1ccc2ae into main Apr 22, 2026
7 checks passed
@nadaverell
nadaverell deleted the fix-kubeconfig-merge-collisions branch April 22, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Kubeconfig directory option merges configs oidc auth via exec doesn't work Can't switch clusters via the desktop app

2 participants