Summary
konflate today renders a PR against a single cluster: one KONFLATE_CLUSTER_PATH (one Flux Kustomization tree), diffed between the PR's base and head. This issue scopes how it could support multi-cluster monorepos, which show up in two distinct shapes:
- branch-per-cluster — each cluster tracks a long-lived branch (e.g.
staging, production).
- folder-per-cluster — each cluster is an entrypoint directory in one branch (e.g.
joryirving/home-ops: kubernetes/clusters/{main,test,utility} sharing kubernetes/apps/* + components/).
Background: how konflate renders today
engine.Diff does mirror.Trees(ctx, pr.HeadRef, pr.BaseRef) then renders both sides with flate (changed-only mode) from a single ClusterPath, and pairs the outputs into one DiffResult. There is no notion of more than one cluster per PR.
Reframe: a cluster is a render target = (ref, path)
The two models are just different coordinates of the same primitive:
| Model |
What varies |
Status today |
| branch-per-cluster |
the ref (base branch = cluster) |
already correct per-PR (renders against pr.BaseRef) |
| folder-per-cluster |
the path (entrypoint = cluster) |
conflates all clusters into one render |
That tells us where the work is: branch-mode is almost free; folder-mode is the real lift.
Model A: branch-per-cluster (nearly already supported)
A git PR targets exactly one base branch, so every PR is inherently single-cluster — its base branch is its cluster, and konflate already diffs head against that branch's full tree (no collisions; each branch is an independent cluster definition). ClusterPath stays singular.
Gaps are organizational only, and the data already exists (pr.baseRef is in the model + the base: filter facet):
- Group/label the PR list by base branch ("cluster: production / staging"), optionally only treating a configured set of branches as clusters (so feature branches don't masquerade as clusters).
- The "grouped change across clusters" case does not arise here — a cert-manager bump across 3 cluster-branches is 3 separate PRs. (Optional, low-priority nicety: correlate them by title/branch-prefix; the forge doesn't link them, so probably punt.)
This is the cheap win; do it first.
Model B: folder-per-cluster (the real work)
The flate constraint
flate's orchestrator.Config.Path is both "the directory to scan for Flux objects" and the GitRepository root that ./kubernetes/... spec.path values resolve against. So for a joryirving-style tree:
Path = repo root → flate discovers all three clusters' root Kustomizations at once.
Path = clusters/main → only main's roots are scanned, but ./kubernetes/apps/main now resolves against clusters/main/ → broken.
The collision problem
Different clusters deliberately reuse Flux object identities (e.g. Kustomization flux-system/cluster-apps, a cert-manager/cert-manager HelmRelease) because in reality they run on separate API servers. Rendered together in one repo, those identities collide — at the flate level (duplicate CR identity) and again in konflate's pairChanges keying. So each cluster must be rendered in isolation, scoped to its entrypoint.
The clean fix is a small flate addition: separate entrypoint (scan from clusters/<name>) from git-root (repo root, for path resolution). Then konflate renders each cluster as its own changed-only pass and tags the output by cluster.
Grouped vs single-cluster PRs → cluster is a dimension, not a filter
The same source change can render differently per cluster — a bump to apps/base/cert-manager is overlaid by each cluster with its own values/version pins, so it might be v1.16→v1.17 in main, the same in test, and unchanged in utility (pinned). The reviewer needs the per-cluster effect, so cluster has to be a first-class dimension: one PR → an array of cluster-diffs.
The grouped-vs-single distinction then falls out of changed-only mode for free:
- single-cluster PR (
apps/main/cert-manager) → only main's reachable sources changed → main renders real work; test/utility produce empty change-sets and collapse.
- grouped PR (
apps/base/cert-manager, overlaid everywhere) → every overlaying cluster renders → N non-empty cluster-diffs shown side-by-side.
Cost is therefore ∝ clusters touched, not total clusters.
Cost control
Rendering N clusters even to discover emptiness still costs N bootstraps. Two levers:
- Path pre-filter (cheap, heuristic): map the PR's changed file paths to clusters — files under
clusters/X/ or apps/X/ → cluster X; files under shared apps/base/ or components/ → all clusters. Skip bootstrapping clearly-unaffected clusters.
- Safe fallback: when a
substituteFrom/dependsOn edge a path-filter can't see is in play, render changed-only and rely on flate's empty short-circuit. Log which clusters were skipped so "0 changes" never silently means "didn't render."
Proposed shape
- Config:
KONFLATE_CLUSTERS accepts either a branch list (branch mode) or a path glob like kubernetes/clusters/* (folder mode), mode inferred or explicit. Empty = today's single-cluster behavior (back-compat).
- Engine: generalize "one
(base,head) render" → "a set of render targets → []ClusterDiff{name, changes, images, warnings, failures}." Branch mode = one target per PR; folder mode = one per configured folder (changed-only, empties collapsed).
- Data model:
DiffResult gains a cluster dimension; pairChanges/lint/images run per cluster.
- UI: a cluster level above the existing tree (Cluster → parent → kind → resource), collapsing to today's view when there's one non-empty cluster; PR-list signals aggregate ("danger in 2/3 clusters").
Adjacent mechanism
flate's ResourceSet (matrix/permute, already expanded via expandResourceSetsPostRun) is a third multi-cluster style — one definition templated into per-cluster instances inside a single entrypoint. That mostly "just works" through folder mode since flate expands it during render.
First spike / open question
The linchpin for folder-mode is the flate entrypoint-vs-git-root decision. Cheapest high-signal experiment: render a joryirving-style tree at the repo root and confirm it merges clusters (and whether pruning sibling clusters/* scopes a render cleanly). That result decides whether folder-mode is a konflate-only change or needs the small flate addition.
Suggested phasing
- branch-mode grouping/labeling (small; mostly UI + existing
base: data).
- folder-mode: flate entrypoint/git-root split → per-cluster changed-only render →
[]ClusterDiff model + cluster dimension in the tree, with the path pre-filter as the cost guard.
🤖 Generated with Claude Code
Summary
konflate today renders a PR against a single cluster: one
KONFLATE_CLUSTER_PATH(one Flux Kustomization tree), diffed between the PR's base and head. This issue scopes how it could support multi-cluster monorepos, which show up in two distinct shapes:staging,production).joryirving/home-ops:kubernetes/clusters/{main,test,utility}sharingkubernetes/apps/*+components/).Background: how konflate renders today
engine.Diffdoesmirror.Trees(ctx, pr.HeadRef, pr.BaseRef)then renders both sides with flate (changed-only mode) from a singleClusterPath, and pairs the outputs into oneDiffResult. There is no notion of more than one cluster per PR.Reframe: a cluster is a render target =
(ref, path)The two models are just different coordinates of the same primitive:
pr.BaseRef)That tells us where the work is: branch-mode is almost free; folder-mode is the real lift.
Model A: branch-per-cluster (nearly already supported)
A git PR targets exactly one base branch, so every PR is inherently single-cluster — its base branch is its cluster, and konflate already diffs head against that branch's full tree (no collisions; each branch is an independent cluster definition).
ClusterPathstays singular.Gaps are organizational only, and the data already exists (
pr.baseRefis in the model + thebase:filter facet):This is the cheap win; do it first.
Model B: folder-per-cluster (the real work)
The flate constraint
flate's
orchestrator.Config.Pathis both "the directory to scan for Flux objects" and the GitRepository root that./kubernetes/...spec.pathvalues resolve against. So for a joryirving-style tree:Path= repo root → flate discovers all three clusters' root Kustomizations at once.Path=clusters/main→ only main's roots are scanned, but./kubernetes/apps/mainnow resolves againstclusters/main/→ broken.The collision problem
Different clusters deliberately reuse Flux object identities (e.g.
Kustomization flux-system/cluster-apps, acert-manager/cert-managerHelmRelease) because in reality they run on separate API servers. Rendered together in one repo, those identities collide — at the flate level (duplicate CR identity) and again in konflate'spairChangeskeying. So each cluster must be rendered in isolation, scoped to its entrypoint.The clean fix is a small flate addition: separate entrypoint (scan from
clusters/<name>) from git-root (repo root, for path resolution). Then konflate renders each cluster as its own changed-only pass and tags the output by cluster.Grouped vs single-cluster PRs → cluster is a dimension, not a filter
The same source change can render differently per cluster — a bump to
apps/base/cert-manageris overlaid by each cluster with its own values/version pins, so it might bev1.16→v1.17inmain, the same intest, and unchanged inutility(pinned). The reviewer needs the per-cluster effect, so cluster has to be a first-class dimension: one PR → an array of cluster-diffs.The grouped-vs-single distinction then falls out of changed-only mode for free:
apps/main/cert-manager) → onlymain's reachable sources changed →mainrenders real work;test/utilityproduce empty change-sets and collapse.apps/base/cert-manager, overlaid everywhere) → every overlaying cluster renders → N non-empty cluster-diffs shown side-by-side.Cost is therefore ∝ clusters touched, not total clusters.
Cost control
Rendering N clusters even to discover emptiness still costs N bootstraps. Two levers:
clusters/X/orapps/X/→ cluster X; files under sharedapps/base/orcomponents/→ all clusters. Skip bootstrapping clearly-unaffected clusters.substituteFrom/dependsOnedge a path-filter can't see is in play, render changed-only and rely on flate's empty short-circuit. Log which clusters were skipped so "0 changes" never silently means "didn't render."Proposed shape
KONFLATE_CLUSTERSaccepts either a branch list (branch mode) or a path glob likekubernetes/clusters/*(folder mode), mode inferred or explicit. Empty = today's single-cluster behavior (back-compat).(base,head)render" → "a set of render targets →[]ClusterDiff{name, changes, images, warnings, failures}." Branch mode = one target per PR; folder mode = one per configured folder (changed-only, empties collapsed).DiffResultgains a cluster dimension;pairChanges/lint/images run per cluster.Adjacent mechanism
flate's ResourceSet (matrix/permute, already expanded via
expandResourceSetsPostRun) is a third multi-cluster style — one definition templated into per-cluster instances inside a single entrypoint. That mostly "just works" through folder mode since flate expands it during render.First spike / open question
The linchpin for folder-mode is the flate entrypoint-vs-git-root decision. Cheapest high-signal experiment: render a joryirving-style tree at the repo root and confirm it merges clusters (and whether pruning sibling
clusters/*scopes a render cleanly). That result decides whether folder-mode is a konflate-only change or needs the small flate addition.Suggested phasing
base:data).[]ClusterDiffmodel + cluster dimension in the tree, with the path pre-filter as the cost guard.🤖 Generated with Claude Code