Skip to content

Support multi-cluster monorepos (branch-per-cluster and folder-per-cluster) #54

Description

@onedr0p

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:

  1. 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.
  2. 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

  1. branch-mode grouping/labeling (small; mostly UI + existing base: data).
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    staleMarked stale by automation due to inactivity

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions