Skip to content

Add multi-cluster union view with --union-context and --union-set#172

Open
zapman449 wants to merge 22 commits into
janosmiko:mainfrom
zapman449:zapman449/support-multi-cluster-patterns
Open

Add multi-cluster union view with --union-context and --union-set#172
zapman449 wants to merge 22 commits into
janosmiko:mainfrom
zapman449:zapman449/support-multi-cluster-patterns

Conversation

@zapman449
Copy link
Copy Markdown

@zapman449 zapman449 commented May 7, 2026

Summary

Support showing resources from several clusters/contexts at once.

Type of change

  • feat: new user-facing feature

Related issue

Changes

  • New --union-context flag (repeatable, capped at 8 clusters) and
    --union-set for named sets in config; mutually exclusive with
    --context.
  • Per-cluster color tiles via union_sets[].contexts[].color, rendered as
    a 1-cell leading swatch in the merged table.
  • Allow Pod delete / force-delete / force-finalize and workload Restart
    at the merged level; everything else mutating stays blocked there.
  • Cluster-aware cursor restoration, multi-select keys, and bulk dispatch
    route per-row by ClusterName so mixed-cluster selections target the
    correct apiserver per item.
  • Async discovery-cache preload so kubeconfigs with thousands of
    contexts (block-sre-operator-style) don't block startup.

Testing

  • go build ./... succeeds
  • go test ./... passes
  • golangci-lint run passes
  • Manually verified against a real cluster (when behavior changed)

Checklist

  • Commits follow conventional commit style (feat:, fix:, refactor:, docs:, test:, chore:, perf:, ci:)
  • README / docs/ updated when user-visible behavior or config changed
  • docs/keybindings.md and the in-app help screen updated when keybindings changed
  • No secrets, tokens, or personal data included in diffs, logs, or screenshots
  • PR is opened from a feature branch on my fork (not from my fork's main)

Screenshots / recordings

@zapman449 zapman449 requested a review from janosmiko as a code owner May 7, 2026 01:17
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR implements a multi-cluster "union" view: new CLI flags and config (union_sets), model/state and sentinel ("__union__"), async discovery-cache preload, Client.GetResourcesUnion for parallel merge (Cluster column + ClusterName), effectiveContext()/helpers to avoid sending the sentinel to APIs, per-item routing for actions/logs/YAML, union-aware read-only/bulk gating and menus, UI table cluster-tile support, session/navigation/bookmark/new-tab guards, cluster-aware event grouping, partial-error handling and cursor restoration, plus tests and docs.

Suggested reviewers

  • janosmiko

@zapman449
Copy link
Copy Markdown
Author

zapman449 commented May 7, 2026

Some large enterprises divide their workloads across multiple clusters. This provides blast-radius protection for cluster level changes.

This PR amends lfk to support this pattern. Existing users should see no changes. However there's a new top-level flag --union-context. Users would specify this multiple times, once per cluster. Example:

lfk \
    --union-context foo-staging-green-us-west-2 \
    --union-context foo-staging-yellow-us-west-2 \
    --union-context foo-staging-blue-us-west-2 \
    --namespace baz

That's a bit verbose, so we also support a config driven union-set:

lfk --union-set staging-west --namespace foo

The config file entry looks like:

union_sets:
  - name: foo-staging-west
    contexts:
      - context: operator/biz-sre-operator/foo-staging-green-us-west-2
        color: green
      - context: operator/biz-sre-operator/foo-staging-yellow-us-west-2
        color: yellow
      - context: operator/biz-sre-operator/foo-staging-blue-us-west-2
        color: blue
  - name: bar-staging-west
    contexts:
      - context: operator/biz-sre-operator/bar-staging-cell-01-us-west-2
        color: blue
      - context: operator/biz-sre-operator/bar-staging-cell-02-us-west-2
        color: green

The color: X bit places a 1 character background color space to indicate which cluster owns which resource (useful for pods or replicasets, etc). It is optional. You can simplify the config for contexts: being a list of strings.

Major use cases are overwhelmingly read-only (changes should be in argo/infra-as-code), but deleting pods and restarting deployments are supported.

This change went deeper into the dispatch plumbing than I was happy with, but it does work. Happy to discuss alternative implementation paths or suggestions. The CLI flags used/chosen were picked to avoid existing workflows/patterns and be "net additive".

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/app/events_group.go (1)

64-68: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve existing GroupedRefs when regrouping Event rows.

Line 68 rebuilds GroupedRefs from just the current row, and Line 98 appends only src’s top-level identity. If groupEvents ever receives already-grouped rows — which the bulk-expansion path explicitly supports for older cached grouped rows — you'll silently drop underlying Event refs and bulk delete will miss part of the group.

Proposed fix
 		clone := cloneEventItem(it)
 		// Seed GroupedRefs with the first item in the group so bulk
 		// operations can expand the grouped row back into individual
 		// delete calls covering every underlying Event object.
-		clone.GroupedRefs = []model.GroupedRef{{Name: it.Name, Namespace: it.Namespace, ClusterName: it.ClusterName}}
+		if len(it.GroupedRefs) > 0 {
+			clone.GroupedRefs = append([]model.GroupedRef(nil), it.GroupedRefs...)
+		} else {
+			clone.GroupedRefs = []model.GroupedRef{{Name: it.Name, Namespace: it.Namespace, ClusterName: it.ClusterName}}
+		}
@@
 func mergeEventInto(dst *model.Item, src model.Item) {
 	addEventCount(dst, readEventCount(src))
 	mergeFirstSeen(dst, src)
 	mergeLastSeen(dst, src)
-	dst.GroupedRefs = append(dst.GroupedRefs, model.GroupedRef{Name: src.Name, Namespace: src.Namespace, ClusterName: src.ClusterName})
+	if len(src.GroupedRefs) > 0 {
+		dst.GroupedRefs = append(dst.GroupedRefs, src.GroupedRefs...)
+	} else {
+		dst.GroupedRefs = append(dst.GroupedRefs, model.GroupedRef{Name: src.Name, Namespace: src.Namespace, ClusterName: src.ClusterName})
+	}
 }

Also applies to: 94-99

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/app/events_group.go` around lines 64 - 68, When regrouping Event
rows in groupEvents, the code currently replaces clone.GroupedRefs with only the
current row identity (in clone := cloneEventItem(it) then clone.GroupedRefs =
...), and later appends only src's top-level identity — which discards any
pre-existing grouped refs. Fix by preserving and merging existing GroupedRefs:
after clone := cloneEventItem(it) initialize clone.GroupedRefs as a copy of
it.GroupedRefs (if non-nil), then ensure you append the current identity {Name:
it.Name, Namespace: it.Namespace, ClusterName: it.ClusterName} only if not
already present; likewise, in the code that appends src, merge src.GroupedRefs
(or src's identity) into dst.GroupedRefs instead of overwriting so all
underlying Event refs are retained for bulk expansion.
🧹 Nitpick comments (2)
UNION-CONTEXT.md (2)

99-111: 💤 Low value

Add language specifier to data flow code block.

This pseudocode/diagram block should have a language identifier for consistency with markdownlint rules.

Proposed fix
-```
+```text
 watch tick / user navigates to Pods
     → loadResources(false)
         → union branch: nav.Context == "__union__" && unionMode
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@UNION-CONTEXT.md` around lines 99 - 111, The markdown code block in
UNION-CONTEXT.md lacks a language specifier; update the opening fence to include
a language (e.g., change ``` to ```text) for the pseudocode block that starts
with "watch tick / user navigates to Pods" so that the diagram that includes
symbols like nav.Context == "__union__" && unionMode, GetResourcesUnion,
GetResources, ClusterName, and itemCache["__union__/pods"] is fenced with a
language identifier.

7-9: 💤 Low value

Add language specifier to fenced code block.

The code block is missing a language identifier, which helps syntax highlighting and accessibility tools.

Proposed fix
-```
+```shell
 lfk --union-context blue --union-context green --union-context yellow --namespace cloud-cd
-```
+```
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@UNION-CONTEXT.md` around lines 7 - 9, The fenced code block containing the
CLI example (the block with "lfk --union-context blue --union-context green
--union-context yellow --namespace cloud-cd") lacks a language specifier; update
that fenced block to include a language identifier (e.g., "shell" or "bash")
immediately after the opening triple backticks so the block reads as a shell
snippet to enable proper syntax highlighting and accessibility tools.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/usage.md`:
- Line 34: Replace the ambiguous heading "Mutex with --context and
--union-context." in the docs (the heading string "Mutex with --context and
--union-context.") with clearer CLI wording such as "Mutually exclusive with
--context and --union-context." to avoid misreading; update the heading text in
docs/usage.md accordingly so examples and surrounding text remain consistent
with the new phrasing.

In `@internal/app/app_helpers.go`:
- Around line 55-57: In activeContext(), guard the isUnionSentinel() branch so
it never falls through to return the sentinel nav.Context when unionContexts is
empty: inside the existing check of m.isUnionSentinel() use the length of
m.unionContexts and if >0 return m.unionContexts[0], otherwise return a safe
fallback (e.g. empty string) instead of m.nav.Context; update references in
activeContext(), isUnionSentinel(), m.unionContexts and m.nav.Context
accordingly so callers do not receive the invalid "__union__" context.

In `@internal/app/tabs.go`:
- Around line 75-77: Update the sentinel detection in Model.isUnionSentinel to
only treat the union sentinel when the navigator is at the resources level: add
a check that m.nav.Level == model.LevelResources in addition to m.unionMode and
m.nav.Context == UnionContextSentinel so a real kube context named "__union__"
isn't misclassified; adjust the logic in isUnionSentinel (and any callers like
effectiveContext if they rely on this) to use the tightened condition.

In `@internal/k8s/client.go`:
- Around line 329-334: The sort comparator used in the sort.Slice call over the
merged slice only compares Name and ClusterName, so items with identical Name
and ClusterName but different Namespace are treated as equal and may be
reordered; update the comparator used where sort.Slice(merged, func(i, j int)
bool { ... }) is defined to include Namespace as a third key (i.e., compare
Name, then ClusterName, then Namespace) to guarantee stable ordering, and add a
regression test that constructs two entries with the same Name and ClusterName
but different Namespace and asserts their deterministic order after the
merge/refresh operation.

---

Outside diff comments:
In `@internal/app/events_group.go`:
- Around line 64-68: When regrouping Event rows in groupEvents, the code
currently replaces clone.GroupedRefs with only the current row identity (in
clone := cloneEventItem(it) then clone.GroupedRefs = ...), and later appends
only src's top-level identity — which discards any pre-existing grouped refs.
Fix by preserving and merging existing GroupedRefs: after clone :=
cloneEventItem(it) initialize clone.GroupedRefs as a copy of it.GroupedRefs (if
non-nil), then ensure you append the current identity {Name: it.Name, Namespace:
it.Namespace, ClusterName: it.ClusterName} only if not already present;
likewise, in the code that appends src, merge src.GroupedRefs (or src's
identity) into dst.GroupedRefs instead of overwriting so all underlying Event
refs are retained for bulk expansion.

---

Nitpick comments:
In `@UNION-CONTEXT.md`:
- Around line 99-111: The markdown code block in UNION-CONTEXT.md lacks a
language specifier; update the opening fence to include a language (e.g., change
``` to ```text) for the pseudocode block that starts with "watch tick / user
navigates to Pods" so that the diagram that includes symbols like nav.Context ==
"__union__" && unionMode, GetResourcesUnion, GetResources, ClusterName, and
itemCache["__union__/pods"] is fenced with a language identifier.
- Around line 7-9: The fenced code block containing the CLI example (the block
with "lfk --union-context blue --union-context green --union-context yellow
--namespace cloud-cd") lacks a language specifier; update that fenced block to
include a language identifier (e.g., "shell" or "bash") immediately after the
opening triple backticks so the block reads as a shell snippet to enable proper
syntax highlighting and accessibility tools.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 52a5eea5-adf3-47a9-82dd-f9469faf1a69

📥 Commits

Reviewing files that changed from the base of the PR and between 56b0dde and dc0a33e.

📒 Files selected for processing (48)
  • UNION-CONTEXT.md
  • docs/config-reference.md
  • docs/usage.md
  • internal/app/app.go
  • internal/app/app_helpers.go
  • internal/app/app_init.go
  • internal/app/app_init_cmd.go
  • internal/app/app_types.go
  • internal/app/commandbar_execute.go
  • internal/app/commands_bulk.go
  • internal/app/commands_dashboard.go
  • internal/app/commands_load.go
  • internal/app/commands_load_metrics.go
  • internal/app/commands_load_preview.go
  • internal/app/commands_logs.go
  • internal/app/commands_yaml.go
  • internal/app/cursor.go
  • internal/app/discovery_cache.go
  • internal/app/discovery_cache_test.go
  • internal/app/events_group.go
  • internal/app/events_group_test.go
  • internal/app/options.go
  • internal/app/readonly.go
  • internal/app/session.go
  • internal/app/tabs.go
  • internal/app/union_test.go
  • internal/app/update.go
  • internal/app/update_actions.go
  • internal/app/update_actions_misc.go
  • internal/app/update_bookmarks.go
  • internal/app/update_bookmarks_session.go
  • internal/app/update_keys.go
  • internal/app/update_keys_actions.go
  • internal/app/update_navigation.go
  • internal/app/update_overlays_confirm.go
  • internal/app/update_resources_loaded.go
  • internal/app/view_status.go
  • internal/k8s/client.go
  • internal/k8s/client_union_test.go
  • internal/model/types.go
  • internal/ui/cluster_color.go
  • internal/ui/cluster_color_tile_test.go
  • internal/ui/config.go
  • internal/ui/config_apply.go
  • internal/ui/config_load.go
  • internal/ui/config_union_sets_test.go
  • internal/ui/explorer_table.go
  • main.go

Comment thread docs/usage.md Outdated
Comment thread internal/app/app_helpers.go Outdated
Comment thread internal/app/tabs.go
Comment thread internal/k8s/client.go
@zapman449 zapman449 changed the title Zapman449/support multi cluster patterns Add multi-cluster union view with --union-context and --union-set May 7, 2026
@zapman449 zapman449 force-pushed the zapman449/support-multi-cluster-patterns branch from a53d114 to 9922476 Compare May 7, 2026 14:58
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@UNION-CONTEXT.md`:
- Around line 13-19: The CLI docs need additions: add a row documenting the
--union-set flag (usage: --union-set <name>, selects a named set from config),
note the 8-context upper bound validation for --union-context, and add a new
"Config" section describing the union_sets schema (top-level union_sets key,
named entries mapping to contexts which can be strings or objects with name and
optional color). Update UNION-CONTEXT.md to include the new table row for
--union-set, a note about the 8-context cap for --union-context, and a YAML
example showing union_sets with contexts as plain strings or objects including
the optional color field.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a5707b85-46fc-45cc-859d-219b2989ee69

📥 Commits

Reviewing files that changed from the base of the PR and between dc0a33e and a53d114.

📒 Files selected for processing (8)
  • UNION-CONTEXT.md
  • docs/usage.md
  • internal/app/app_helpers.go
  • internal/app/events_group.go
  • internal/app/events_group_test.go
  • internal/app/union_test.go
  • internal/k8s/client.go
  • internal/k8s/client_union_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • docs/usage.md
  • internal/k8s/client.go
  • internal/app/events_group.go
  • internal/app/union_test.go

Comment thread UNION-CONTEXT.md
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/ui/explorer_table.go (1)

234-234: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

collectExtraColumns missing tileW in the used-width budget.

The third argument to collectExtraColumns is the width already consumed by fixed columns. tileW is excluded here, so in union mode the function treats 1 extra character as available for extra columns. nameW (line 289) does subtract tileW, so the overall row width stays correct—but the Name column is effectively 1 character narrower than intended when extra columns are present, because the extra columns absorbed the byte that belongs to the tile cell.

Proposed fix
-		extraCols = collectExtraColumns(items, width, nsW+readyW+restartsW+ageW+statusW+markerColW, tableKind)
+		extraCols = collectExtraColumns(items, width, nsW+readyW+restartsW+ageW+statusW+markerColW+tileW, tableKind)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ui/explorer_table.go` at line 234, The call to collectExtraColumns
is missing tileW in the "used width" budget, so add tileW to the sum passed as
the third argument; specifically update the call that currently reads
collectExtraColumns(items, width, nsW+readyW+restartsW+ageW+statusW+markerColW,
tableKind) to include +tileW so the used-width accurately accounts for the tile
cell (this aligns with the subtraction done in nameW and prevents extra columns
from stealing one character from the Name column in union mode).
🧹 Nitpick comments (1)
internal/app/options.go (1)

29-35: ⚡ Quick win

Treat --union-set as a CLI override too.

Right now this only flips after ResolveUnionSet has populated UnionContexts. Adding UnionSet here would make the method match its own comment and avoid coupling override detection to resolution order.

Possible fix
 func (o StartupOptions) HasCLIOverrides() bool {
-	return o.Context != "" || len(o.Namespaces) > 0 || len(o.UnionContexts) > 0
+	return o.Context != "" || o.UnionSet != "" || len(o.Namespaces) > 0 || len(o.UnionContexts) > 0
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/app/options.go` around lines 29 - 35, HasCLIOverrides currently
ignores the raw --union-set flag and only detects overrides after
ResolveUnionSet populates UnionContexts; update StartupOptions.HasCLIOverrides
to also consider the union-set field (check o.UnionSet for a non-empty value) so
the method returns true when the user passed --union-set, avoiding coupling
override detection to ResolveUnionSet and matching the method comment (refer to
StartupOptions.HasCLIOverrides, StartupOptions.UnionSet and
StartupOptions.UnionContexts).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/app/update_actions.go`:
- Around line 55-62: The overlay filtering currently uses the global m.readOnly
when skipping mutating actions (in the loop over actions building items,
referencing variables/actions like items, actions, m.readOnly, and
isMutatingAction), which allows mutating actions to be shown for per-context
read-only clusters; update the checks to use the effective read-only state
instead: call readOnlyForContext(m.actionCtx.context) when building
single-resource menus and bulkReadOnlyContext() for bulk menus, replacing
m.readOnly in the mutating-action guard so the visible action list matches what
can actually run; also apply the same change to the other similar loop (the
actions handling around the isUnionSentinel/isUnionAllowedAction logic
referenced in the diff).

In `@internal/app/update_bookmarks_session.go`:
- Around line 21-29: The union-mode restore only sets UnionContextSentinel on
the returned restoredModel.nav.Context, leaving other saved tabs in m.tabs with
real cluster contexts; update the union-mode branch in restoreMultiTabSession
(or the function containing the shown block) to iterate m.tabs (the slice/map
populated by restoreMultiTabSession) and set each tab's nav.Context to
UnionContextSentinel when m.unionMode is true, so that background tabs also pass
isUnionSentinel() and loadResources fans out correctly; keep the existing check
for restoredModel.(Model) and still return the active tab model after fixing all
tabs.

In `@internal/app/update_resources_loaded.go`:
- Around line 279-288: When hydrating cached resource types at
LevelResourceTypes the code currently uses m.nav.Context (the union sentinel) to
look up m.discoveredResources, which misses entries stored under the real
discovery context; change the lookup to use the actual discovery context (e.g.
if m.unionContexts slice is non-empty use m.unionContexts[0], otherwise fall
back to m.nav.Context) when building merged := model.BuildSidebarItems(entries),
updating m.itemCache[...] and calling m.setMiddleItems(...), so union-mode
preloads correctly replace the seeded sidebar.

In `@UNION-CONTEXT.md`:
- Line 93: The table row currently points to internal/app/update.go but the
implementations of updateAPIResourceDiscovery and updateResourcesLoadedMain are
in internal/app/update_resources_loaded.go; update that table entry to reference
internal/app/update_resources_loaded.go so readers can find the implementations
of the functions updateAPIResourceDiscovery and updateResourcesLoadedMain.

---

Outside diff comments:
In `@internal/ui/explorer_table.go`:
- Line 234: The call to collectExtraColumns is missing tileW in the "used width"
budget, so add tileW to the sum passed as the third argument; specifically
update the call that currently reads collectExtraColumns(items, width,
nsW+readyW+restartsW+ageW+statusW+markerColW, tableKind) to include +tileW so
the used-width accurately accounts for the tile cell (this aligns with the
subtraction done in nameW and prevents extra columns from stealing one character
from the Name column in union mode).

---

Nitpick comments:
In `@internal/app/options.go`:
- Around line 29-35: HasCLIOverrides currently ignores the raw --union-set flag
and only detects overrides after ResolveUnionSet populates UnionContexts; update
StartupOptions.HasCLIOverrides to also consider the union-set field (check
o.UnionSet for a non-empty value) so the method returns true when the user
passed --union-set, avoiding coupling override detection to ResolveUnionSet and
matching the method comment (refer to StartupOptions.HasCLIOverrides,
StartupOptions.UnionSet and StartupOptions.UnionContexts).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 552f4c08-56c9-4480-8b20-eeb52ec9ead6

📥 Commits

Reviewing files that changed from the base of the PR and between a53d114 and 9922476.

📒 Files selected for processing (48)
  • UNION-CONTEXT.md
  • docs/config-reference.md
  • docs/usage.md
  • internal/app/app.go
  • internal/app/app_helpers.go
  • internal/app/app_init.go
  • internal/app/app_init_cmd.go
  • internal/app/app_types.go
  • internal/app/commandbar_execute.go
  • internal/app/commands_bulk.go
  • internal/app/commands_dashboard.go
  • internal/app/commands_load.go
  • internal/app/commands_load_metrics.go
  • internal/app/commands_load_preview.go
  • internal/app/commands_logs.go
  • internal/app/commands_yaml.go
  • internal/app/cursor.go
  • internal/app/discovery_cache.go
  • internal/app/discovery_cache_test.go
  • internal/app/events_group.go
  • internal/app/events_group_test.go
  • internal/app/options.go
  • internal/app/readonly.go
  • internal/app/session.go
  • internal/app/tabs.go
  • internal/app/union_test.go
  • internal/app/update.go
  • internal/app/update_actions.go
  • internal/app/update_actions_misc.go
  • internal/app/update_bookmarks.go
  • internal/app/update_bookmarks_session.go
  • internal/app/update_keys.go
  • internal/app/update_keys_actions.go
  • internal/app/update_navigation.go
  • internal/app/update_overlays_confirm.go
  • internal/app/update_resources_loaded.go
  • internal/app/view_status.go
  • internal/k8s/client.go
  • internal/k8s/client_union_test.go
  • internal/model/types.go
  • internal/ui/cluster_color.go
  • internal/ui/cluster_color_tile_test.go
  • internal/ui/config.go
  • internal/ui/config_apply.go
  • internal/ui/config_load.go
  • internal/ui/config_union_sets_test.go
  • internal/ui/explorer_table.go
  • main.go
✅ Files skipped from review due to trivial changes (5)
  • internal/app/app_types.go
  • internal/app/commands_dashboard.go
  • docs/usage.md
  • internal/app/readonly.go
  • internal/app/commands_load.go
🚧 Files skipped from review as they are similar to previous changes (13)
  • internal/app/update_keys_actions.go
  • internal/app/update_bookmarks.go
  • internal/ui/cluster_color.go
  • internal/app/app_init_cmd.go
  • internal/app/cursor.go
  • internal/app/discovery_cache_test.go
  • internal/app/app_init.go
  • internal/app/tabs.go
  • internal/ui/config_union_sets_test.go
  • internal/app/commands_logs.go
  • internal/app/discovery_cache.go
  • internal/app/update_actions_misc.go
  • internal/app/union_test.go

Comment thread internal/app/update_actions.go Outdated
Comment thread internal/app/update_bookmarks_session.go
Comment thread internal/app/update_resources_loaded.go
Comment thread UNION-CONTEXT.md Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
UNION-CONTEXT.md (1)

18-20: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

The union-set docs still don't match the public contract.

The table still documents --context as only conflicting with --union-context, and the YAML example models union_sets as a sequence with an inline name field. This feature is described as --union-set <name> selecting a named set, so users can copy this example and end up with a config that the lookup never resolves. Please document the --context/--union-set mutual exclusion and show the actual named-set shape.

Example adjustment
-| `--context` | mutually exclusive with `--union-context` | Returns an error if both are supplied. |
+| `--context` | mutually exclusive with `--union-context` and `--union-set` | Returns an error if both are supplied. |

 union_sets:
-  - name: staging-west
-    namespace: cloud-cd
-    contexts:
-      - blue
-      - green
-      - context: yellow
-        color: yellow
+  staging-west:
+    namespace: cloud-cd
+    contexts:
+      - blue
+      - green
+      - context: yellow
+        color: yellow

Also applies to: 24-37

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@UNION-CONTEXT.md` around lines 18 - 20, Update the docs to reflect the actual
public contract: mark --context and --union-set (not just --union-context) as
mutually exclusive and require --namespace when using --union-set unless the
named union_sets entry supplies a default; change the union_sets example from an
array of inline name fields to the real named-set structure (e.g., a mapping of
union_set names to their contexts and optional default namespace) so that the
--union-set <name> lookup will resolve correctly; ensure the table rows for
`--union-set`, `--namespace`/`-n`, and `--context` clearly state these
constraints and update corresponding YAML example to use the named key shape
used by the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/app/update_actions.go`:
- Around line 57-63: The union-mode checks currently only inspect action.Label
(see uses of isUnionAllowedAction(a.Label) and m.isUnionSentinel()), which is
too coarse; update the allowlist logic to be kind-aware and conservative by
default: replace calls to isUnionAllowedAction(a.Label) with a new helper like
isUnionAllowed(kind, actionLabel) that takes the resource kind and action label
(use the same helper in executeAction() and executeBulkAction()), have
m.isUnionSentinel() gate on !isUnionAllowed(kind, label) to block unknown/custom
actions by default, and ensure mutating filtering still uses (kind, action)
where applicable so union mode cannot perform writes on unrelated kinds.

In `@internal/app/update_bookmarks_session.go`:
- Around line 24-29: Restored union-mode tabs are being marked with
UnionContextSentinel (tm.nav.Context = UnionContextSentinel) but loadTab() still
uses m.discoveredResources[m.nav.Context], so background tabs at
LevelResourceTypes fall back to SeedResources(); update loadTab() so when the
tab's nav.Context (or m.nav.Context during reload) equals UnionContextSentinel
it looks up discovered resources from the appropriate union context (e.g.
unionContexts[0]) instead of using the sentinel key—use that discoveredResources
entry for rebuilding the sidebar and avoid calling SeedResources() for
union-restored tabs.

---

Duplicate comments:
In `@UNION-CONTEXT.md`:
- Around line 18-20: Update the docs to reflect the actual public contract: mark
--context and --union-set (not just --union-context) as mutually exclusive and
require --namespace when using --union-set unless the named union_sets entry
supplies a default; change the union_sets example from an array of inline name
fields to the real named-set structure (e.g., a mapping of union_set names to
their contexts and optional default namespace) so that the --union-set <name>
lookup will resolve correctly; ensure the table rows for `--union-set`,
`--namespace`/`-n`, and `--context` clearly state these constraints and update
corresponding YAML example to use the named key shape used by the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 33d9860c-95cd-4b08-aa6a-a514c43d51d6

📥 Commits

Reviewing files that changed from the base of the PR and between 9922476 and 7662c05.

📒 Files selected for processing (8)
  • UNION-CONTEXT.md
  • internal/app/options.go
  • internal/app/tabs.go
  • internal/app/union_test.go
  • internal/app/update_actions.go
  • internal/app/update_bookmarks_session.go
  • internal/app/update_resources_loaded.go
  • internal/ui/explorer_table.go
✅ Files skipped from review due to trivial changes (1)
  • internal/app/update_resources_loaded.go

Comment thread internal/app/update_actions.go Outdated
Comment thread internal/app/update_bookmarks_session.go
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/app/readonly.go`:
- Around line 152-171: bulkReadOnlyContext currently falls back to
m.actionCtx.context when item.ClusterName is empty which lets the union sentinel
("__union__") bypass readOnlyForContext; fix bulkReadOnlyContext so the loop
does NOT use m.actionCtx.context when item.ClusterName is empty and
m.actionCtx.context == "__union__": instead attempt to derive the cluster from
the item's metadata (e.g., a metadata cluster field on the item) and call
readOnlyForContext with that derived cluster, or if no cluster can be derived,
treat the item as read-only (return the union-safe context and true) or continue
to the next item; update the loop in Model.bulkReadOnlyContext to prefer
item.ClusterName, then derived metadata cluster, and only use
m.actionCtx.context when it is a real cluster (not "__union__"), calling
readOnlyForContext accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 69f5d7d5-3cd5-4d80-a4dd-0b98dd19d6c0

📥 Commits

Reviewing files that changed from the base of the PR and between 7662c05 and f4cfb2f.

📒 Files selected for processing (4)
  • internal/app/readonly.go
  • internal/app/tabs.go
  • internal/app/union_test.go
  • internal/app/update_actions.go
✅ Files skipped from review due to trivial changes (1)
  • internal/app/update_actions.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/app/union_test.go

Comment thread internal/app/readonly.go
@zapman449
Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@zapman449 zapman449 force-pushed the zapman449/support-multi-cluster-patterns branch from f06b346 to 179c5fa Compare May 8, 2026 13:00
@janosmiko
Copy link
Copy Markdown
Owner

Hi @zapman449 ,

Thanks for bringing this up. I really like this feature idea, but it'll need a lot of work to be coherent and straightforward. Just to mention a few features that needs additional polish to support:

  • cluster dashboard / alert dashboard
  • bookmarks
  • rbac viewers
  • running :kubectl commands
  • port forwards
  • pinned groups per cluster

And a few UX decisions I'd prefer to have:

  • define union sets on config level and show them in the context/cluster explorer as a dedicated group
  • it'd make sense to have a new column in the browser that indicates the context

@zapman449 zapman449 force-pushed the zapman449/support-multi-cluster-patterns branch from 179c5fa to 9590b02 Compare May 11, 2026 14:07
@zapman449
Copy link
Copy Markdown
Author

Ack. I'll try to get those sorted into something reasonable. Please let me know if you have input on what any of these should look like. The first parts of it are pushed (along with a rebase off latest), a few more things coming to address specific pieces.

@zapman449 zapman449 force-pushed the zapman449/support-multi-cluster-patterns branch 2 times, most recently from 7e78d84 to 8b90530 Compare May 11, 2026 20:40
@zapman449
Copy link
Copy Markdown
Author

@janosmiko

Features needing attention:

  1. cluster and monitoring dashboards: these don't seem to make sense in a "union-set" world. So if the user is in a union-set, and descend into here, they will be offered a context to chose one of the contexts to view.
  2. bookmarks: bookmarks now work. Context-free (A-Z) bookmarks use union- contexts as you would expect. Context-aware bookmarks are extended to be definable for pre-configured union-sets. Ad-hoc/anonymous union-sets can't use context aware bookmarks.
  3. rbac-viewers: works with union-sets as before with one difference. if the specific permission is not consistent between all clusters in the union-set, the user will see a yellow ? to indicate the inconsistency
  4. running :kubectl commands on a union-set is currently undefined / will produce an error. We can't know perfectly what the user will want in this case: run against one, run against all, etc. Better to error than surprise.
  5. Port forwards function in union-sets
  6. pinned groups: pre-configured union-sets support pinned groups. anonymous union-sets do not.

UX decisions:

  1. defined union-sets show up in the context selector now. If none are defined, none will show up as before.
  2. new browser column: made Context a first-class visible union-mode column

Major Notes:

  1. Union-sets presume broad similarity between the clusters. Resource discovery is based on the first context in the union.
  2. In certain areas, if you drill down far enough, you'll be viewing only a single cluster. This will be after selecting which one.
  3. Union mode requires a namespace. This is to limit how much data must be pulled before having a useable UX.
  4. Reverse Who-Can and orphan scans remain single-context operations and are blocked from the union sentinel for now.

@zapman449 zapman449 force-pushed the zapman449/support-multi-cluster-patterns branch 2 times, most recently from 8d9d152 to 3075fdf Compare May 12, 2026 14:08
zapman449 added 9 commits May 15, 2026 08:43
…union-set

- New --union-context flag (repeatable, capped at 8 clusters) and
  --union-set <name> for named sets in config; mutually exclusive with
  --context.
- Per-cluster color tiles via union_sets[].contexts[].color, rendered as
  a 1-cell leading swatch in the merged table.
- Allow Pod delete / force-delete / force-finalize and workload Restart
  at the merged level; everything else mutating stays blocked there.
- Cluster-aware cursor restoration, multi-select keys, and bulk dispatch
  route per-row by ClusterName so mixed-cluster selections target the
  correct apiserver per item.
- Async discovery-cache preload so kubeconfigs with thousands of
  contexts (block-sre-operator-style) don't block startup.

Some large enterprises divide their workloads across multiple clusters.
This provides blast-radius protection for cluster level changes.

This PR amends lfk to support this pattern.  Existing users should see
no changes.  However there's a new top-level flag `--union-context`.
Users would specify this multiple times, once per cluster.  Example:

```
lfk \
    --union-context foo-staging-green-us-west-2 \
    --union-context foo-staging-yellow-us-west-2 \
    --union-context foo-staging-blue-us-west-2 \
    --namespace baz
```

That's a bit verbose, so we also support a config driven union-set:

```
lfk --union-set staging-west --namespace foo
```

The config file entry looks like:

```
union_sets:
  - name: foo-staging-west
    contexts:
      - context: operator/biz-sre-operator/foo-staging-green-us-west-2
        color: green
      - context: operator/biz-sre-operator/foo-staging-yellow-us-west-2
        color: yellow
      - context: operator/biz-sre-operator/foo-staging-blue-us-west-2
        color: blue
  - name: bar-staging-west
    contexts:
      - context: operator/biz-sre-operator/bar-staging-cell-01-us-west-2
        color: blue
      - context: operator/biz-sre-operator/bar-staging-cell-02-us-west-2
        color: green
```

The `color: X` bit places a 1 character background color space to indicate which cluster owns which resource (useful for pods or replicasets, etc).  It is optional.  You can simplify the config for `contexts:` being a list of strings.

Major use cases are overwhelmingly read-only (changes should be in argo/infra-as-code), but deleting pods and restarting deployments are supported.

This change went deeper into the dispatch plumbing than I was happy with, but it does work.  Happy to discuss alternative implementation paths or suggestions. The CLI flags used/choosen were picked to avoid existing workflows/patterns and be "net additive".
…se does not make sense, so enumerate all in current union set, and allow drill into each to get these views
zapman449 and others added 13 commits May 15, 2026 08:43
"can-i" view has a new item: yellow `?` which indicates all the contexts in the unionset disagree as to whether this action is allowed.

I left reverse Who-Can blocked in union mode for now, because “who can do X across clusters” needs separate semantics and output layout.
…lit oversized files

Rebasing onto main pulled in three changes that needed local adaptation:

* k8s.NewClient gained a kubeconfigDirs argument; updated completion.go
  callers to pass it through.
* commandbar_execute.go grew past the 800-line limit; extracted the
  kubectl argument helpers (commandAffectsNamespaces, injectKubectlDefaults,
  commandSupportsNamespaceFlags, kubectlGlobalFlagsWithValue,
  firstNonFlagToken) to commandbar_execute_kubectl_args.go.
* update_overlays_selectors.go grew past the 800-line limit; extracted
  the colorscheme overlay handlers to update_overlays_colorscheme.go.

No behavior change.
…luster

bulkScaleResources and batchPatchLabels both used the loop-invariant
actionCtx for every iteration, unlike bulkDeleteResources/bulkRestartResources
which already routed per item.ClusterName.

This path is currently unreachable from the UI — isUnionAllowedActionForKind
excludes Scale and Labels/Annotations from the union allow-list. The fix is
defensive: it preserves the documented per-row routing contract in
model.Item.ClusterName so a future allow-list relaxation does not silently
misroute scale or label/annotation mutations across mixed-cluster
selections.

Also adds an info log to bulk patch operations matching the pattern of the
other bulk paths so the per-row context is observable in logs.

Tests: assert per-row routing via captured slog output.
Client.secretLazyLoading and Client.kubesharkNamespaceOverride were
written by SetSecretLazyLoading/SetKubesharkNamespace at startup and read
from tea.Cmd goroutines (GetResources, kubesharkNamespace) without
synchronization. `go test -race` flagged the kubeshark write/read pair
as a genuine data race once concurrent readers ran while the setter
fired — a path the new async discovery cache preload now opens at
Init() time.

Convert both fields to atomic primitives (atomic.Bool, atomic.Pointer[string])
so the read/write pairs are always race-free, including any future
runtime-config-reload path.

Adds a -race test covering both setters under concurrent reads.
…dialogs

Two related signalling gaps:

1. Bulk Restart fired immediately on the action shortcut, unlike Delete
   and Force Delete which already prompt for confirmation. In union mode
   a single keystroke could issue `kubectl rollout restart` across every
   cluster touched by the selection. Route through overlayConfirm and
   dispatch via pendingAction == "Restart" in handleConfirmOverlayKey.

2. Bulk confirm prompts (Delete, Force Delete, Restart) named only the
   resource count, not the source clusters. A user who believes they are
   operating on one cluster could mistake a 5-resource bulk delete that
   spans 3 clusters for a single-cluster operation. Append a sorted,
   deduplicated cluster list to the confirm prompt in union mode;
   single-cluster prompts are unchanged.

Updates the existing coverage-style TestCovExecuteBulkActionRestart to
reflect the new confirm-first contract.
ExpandUnionSetConfig silently kept the first non-empty per-member
namespace and discarded every subsequent one. A set with members
declaring different namespaces (e.g. dev with dev-ns plus prod with
payments) would resolve to the first one read, with no indication
that the others were ignored. Multi-cluster sets with divergent
namespaces would silently use the wrong namespace for every context
after the first.

Adds an error return on the function and rejects sets where members
declare conflicting non-empty namespaces with a message naming the
offending values. Sets with no per-member namespaces, or sets where
every member agrees on the same namespace, are unaffected.

Updates all seven call sites (CLI lookup in main, picker dispatch in
union_sets.go, bookmark resolution in update_bookmarks.go, picker
withUnionSetRows, navigateChildUnionSet, activateUnionSet, and the
table-driven test in union_test.go).
loadAllDiscoveryCaches walked every kubeconfig context to completion
regardless of session state. A user quitting during preload of a
1200-context kubeconfig had to wait for the full walk before the tea
program exited.

Plumb m.reqCtx into discoveryCachePreloadCmd and check it at the top
of every iteration. The function returns whatever it has loaded so
far when cancelled, so the message handler still merges a partial
cache rather than discarding work.
isUnionAllowedActionForKind is the gate that decides which mutating
actions remain reachable from the union sentinel. The function has
a default: return false branch, so any new action label silently
slips into the blocked set if the author forgets to consider it —
which is safe behaviour but invisible to the author.

The new test enumerates every label in mutatingActions and asserts
the union-mode policy expected for it across a representative kind
matrix. A new mutating action without a corresponding row fails the
test with the offending label, forcing the author to make an
explicit decision. The same check runs in reverse: a stale row
without a backing mutatingActions entry also fails.
Three places swallowed errors that masked real failures:

* commands_bulk.go: bulkForceDeleteResources ran a kubectl patch to
  clear finalizers, then ignored the patch's exit status with
  //nolint:errcheck. A patch failure (RBAC denial, resource gone)
  silently fell through to the --force delete, which would then
  appear to hang on the user side. Now logs a Warn including the
  resource, namespace, and context so the trail survives in lfk.log.

* update_bookmarks.go and update_resources_loaded.go: contexts, _ :=
  m.client.GetContexts() discarded the error and built navigation
  state from a nil slice on failure. Logs a Warn instead so a
  kubeconfig reload race is observable. Extract a tiny
  contextsOrEmpty helper (placed in app_helpers.go to keep
  update_resources_loaded.go under the file-length limit).

* internal/k8s/client.go: GetResourcesUnion returned only the first
  per-context error and silently dropped the rest. Two of eight
  contexts failing showed only one failure in the status bar.
  Aggregate with errors.Join so callers can render the full set.
…tinel, document Model state invariant

Three hygiene items from the review:

* navigateToBookmark ballooned to ~187 lines after the bookmark
  union work. Extract three helpers (applyBookmarkContextSwitch,
  applyBookmarkNamespace, rebuildLeftHistoryForBookmark) into
  update_bookmarks_navigate.go so the main function reads as a flat
  sequence of phases and stays under the gocyclo/length caps.

* processCanIRules (the single-context RBAC path) indexed
  discoveredResources by m.nav.Context, which is the union sentinel in
  union mode. The normal flow dispatches to processCanIRulesUnion
  before reaching here, but a future caller forgetting to do so would
  silently return an empty rules table. Add a sentinel guard that
  logs the misdispatch and bails out.

* Document the inline invariant on Model's per-context maps so
  future maintainers don't retrofit an unnecessary RWMutex.
@janosmiko janosmiko force-pushed the zapman449/support-multi-cluster-patterns branch from 3075fdf to 605aea7 Compare May 15, 2026 07:47
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.

2 participants