diff --git a/.gitignore b/.gitignore index e08f55a..f369e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ web/playwright-report/ .DS_Store .env .env.local +.superpowers/ web/tsconfig.tsbuildinfo HANDOFF.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..22dd3dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +All notable changes to kubeside are recorded here, in the style of +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +Release notes on GitHub are generated from commit history by GoReleaser +(`changelog: use: github` in `.goreleaser.yaml`) and remain the record of +what shipped in each tagged version. This file exists for the same +information in one place across releases, kept in plain language rather than +raw commit subjects. + +## [Unreleased] + +### Added +- Fleet screen (Screen 7): one app, one row per cluster, answering whether + every cluster is running the newest version. Reachable from the command + palette, the app detail screen, and a split promotion cell. +- Five distinct fleet states — present, absent, denied, unreachable, + pending — so a cluster that refused a read, one that never answered, and + one that genuinely does not run the app never render the same way. +- A denied fleet row names the RBAC rule that would fix it, not just that + access was refused. +- A component test harness (jsdom, `@testing-library/react`) for decision + logic that lives inside a `.tsx` file, where a plain unit test cannot + reach it. + +### Fixed +- The promotion matrix silently discarded every context after the first in + a multi-context environment and reported one cluster's version as the + whole environment's. Every bound context is now read, and a genuine + disagreement renders as a distinct split state instead of collapsing to + a guess. +- An environment nobody could read rendered "not deployed here" instead of + naming that it was unreadable, in both the promotion matrix and, by a + related fix, when only some of its clusters answered. +- An unread cluster that produced no disagreement counted as a severe + drift the same way a genuine version disagreement did; it now renders + as an unknown, not a defect, and no longer inflates drift or the split + count. +- A refused RBAC read (an app-carrying resource kind the reader could not + list) was indistinguishable from the app simply not existing there, in + both the promotion matrix and the fleet screen. +- A context that connected once and later failed to reconnect could be + read as fully readable off a stale session; `Service.Promotion` now + checks the connection error directly rather than trusting the client + handle alone. +- Two concurrent connection attempts for the same kubeconfig context could + both dial, racing to overwrite each other's result; connections for one + context now serialize. +- The fleet screen classified environments by matching literal `qa`/`stg`/ + `prod` strings, so any real cluster name (`prod-us-east`, `production`, + `staging-eks`, ...) rendered as an unclassified, unstyled row instead of + its actual risk color. + +### Documentation +- Corrected multi-cluster docs that described per-context aggregation and a + cluster-UID dedupe mechanism that were never implemented; documented what + actually shipped instead. +- Added Argo CD to the tool comparison in the problem statement. +- Recorded why kubeside has no terminal UI. diff --git a/README.md b/README.md index e218101..744a007 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ already enough of. ## Status Released. The app list, whole-workload logs, the reconstructed timeline, -resolved configuration, cross-environment diff, the promotion matrix, +resolved configuration, cross-environment diff, the promotion matrix, fleet, port-forward, the command palette, prod guardrails, and exec are built, tested, and driven against real clusters. @@ -183,7 +183,8 @@ The full site is at [keyboard](docs/guide/09-keyboard.md) · [the config file](docs/guide/10-config-file.md) · [permissions](docs/guide/11-permissions.md) · -[when things are missing](docs/guide/12-troubleshooting.md) +[when things are missing](docs/guide/12-troubleshooting.md) · +[fleet, one app across every cluster](docs/guide/13-fleet.md) **Design notes** — [the problem](docs/01-problem.md) · [personas](docs/02-personas.md) · diff --git a/docs/01-problem.md b/docs/01-problem.md index 9ade741..9d7ced4 100644 --- a/docs/01-problem.md +++ b/docs/01-problem.md @@ -179,15 +179,29 @@ Assume Radar gets topology right. The kubeside wedge is narrower and different: the developer's four questions, resolved configuration, whole-workload logs, and the same application compared across environments. +Argo CD came up first on the [Show HN thread](https://news.ycombinator.com/item?id=49139573) +and it is a different kind of tool, not a missed competitor. It runs 1:1 against +one cluster and shows sync state against git: is the live state what the repo +says it should be. That is a delivery question, and its reader is the person who +owns the pipeline. kubeside shows the app a developer owns across every +environment it runs in, qa, stg, and prod side by side, which is a different +question asked by a different reader. The two coexist rather than compete: +kubeside already reads the `argocd.argoproj.io/instance` label as a grouping +signal, documented in [03-product-spec.md](03-product-spec.md). + ## Reference points | Tool | Stars | Open issues | License | Note | | --- | --- | --- | --- | --- | | k9s | 34,182 | 135 | Apache-2.0 | Terminal, single maintainer | +| Argo CD | 23,835 | 4,318 | Apache-2.0 | GitOps delivery, 1:1 with the cluster, not a comparison | | kubernetes/dashboard | 15,427 | 166 | Apache-2.0 | Archived 2026-01-21 | | Headlamp | 6,920 | 834 | Apache-2.0 | SIG UI successor, auth-heavy backlog | | Freelens | 5,311 | 190 | MIT | Electron, Lens lineage, metrics bugs | | Kite | 2,937 | 39 | Apache-2.0 | Web, platform positioning | | Radar | 2,645 | 50 | Apache-2.0 | Topology and timeline, direct competitor | | KHI | 2,071 | 21 | Apache-2.0 | Google, log timeline only | + +Argo CD's row was read 2026-08-04, after the rest of this research; every other +row still reflects 2026-07-22, as stated at the top of this document. diff --git a/docs/03-product-spec.md b/docs/03-product-spec.md index d8fcca0..457481d 100644 --- a/docs/03-product-spec.md +++ b/docs/03-product-spec.md @@ -23,9 +23,17 @@ One sentence for the README, the launch post, and every scope argument: 6. Teach kubectl, do not replace it. Every view exposes the equivalent command. 7. Trust the numbers. A metric that could be wrong is not displayed. -## The four screens +## The screens -Nothing else ships in v1. +Nine views ship in v1. Four get a full design here, since their content +belongs to one app in one place: the app list, app detail, resolved +configuration, and logs. The rest live where their content forces them to: +promotion, fleet, and the cross-environment config diff need every +environment already modeled, so [04-multi-cluster.md](04-multi-cluster.md) +specifies them; the command palette is under Cross-cutting below, and exec is +specified with the actions it belongs to. + +Nothing beyond these nine ships in v1. ### Screen 1: Apps @@ -187,6 +195,31 @@ Every view exposes "show kubectl", printing the equivalent command. Environment switching and comparison are covered in [04-multi-cluster.md](04-multi-cluster.md). +Screen numbering, for cross-reference: 1 the app list, 2 app detail, 3 +resolved configuration, 3b the cross-environment config diff, 4 logs, 5 +promotion, 6 this palette, 7 fleet, and exec, which carries no number because +it is an action surface rather than a view. + +### Screen 7: Fleet + +One app, one row per cluster, answering a question promotion cannot phrase: +is every cluster running the latest version. Promotion compares environments +side by side; a team running prod across two regions asks about the clusters +inside one environment, or about clusters that sit outside the environment +axis entirely. One row per cluster instead of one column per environment. + +Fleet declares its own five states: present, absent, denied, unreachable, and +pending. It does not share promotion's, which are comparisons between columns +rather than facts about one cluster. Only `absent` and `denied` are spelled the +same in both, and they do not mean the same thing: promotion has no +`unreachable`, so a cluster that never answered reads as denied there and as +unreachable here. What fleet does reuse from promotion is the machinery, +`CompareTags`, `Identity`, and `Orderable`, so one app is one app and one +version ordering governs both screens. + +Opened from a split promotion cell or the app detail screen. +Guide: `docs/guide/13-fleet.md`. + ## Explicit non-goals for v1 No node view. No PersistentVolume or StorageClass browsing. No RBAC editor. No diff --git a/docs/04-multi-cluster.md b/docs/04-multi-cluster.md index eb4cca5..44c484c 100644 --- a/docs/04-multi-cluster.md +++ b/docs/04-multi-cluster.md @@ -18,8 +18,12 @@ Three concepts, deliberately separate. | App | A logical service, present in zero or more environments. Identity is namespace plus name within a cluster, matched across environments by the rules below. | One environment maps to one or more contexts. A team running prod across two -regions gets one `prod` environment holding `prod-us-east` and `prod-eu-west`, -and the promotion view aggregates them with per-context detail on expansion. +regions gets one `prod` environment holding `prod-us-east` and `prod-eu-west`. +The promotion cell for that environment collapses to one version only when +every context inside it agrees on tag and digest. When they disagree, the cell +renders `split` and names the disagreement instead of guessing at a version. +Per-context detail lives on its own screen, fleet: one app, one row per +cluster, opened from the split cell or from the app detail screen. ### An environment is not always a context @@ -328,10 +332,10 @@ cluster behind a VPN never blocks the rest of the grid. | --- | --- | | Prod reachable only over VPN, currently off | Two states, never conflated. Connected earlier this session: in-memory snapshot with its age. Never connected this session: "nothing known yet" with a reconnect action. Other environments unaffected either way. | | Credential plugin prompts for SSO in a browser | Inline prompt on the panel. No modal blocking the whole app. | -| Two contexts point at the same cluster | Detected by API server URL and cluster UID, merged with a notice. | -| Context renamed in kubeconfig | Match on cluster UID first, name second, so history survives a rename. | +| Two contexts point at the same cluster | Detected by API server URL alone (`config.NormalizeURL`), merged with a notice. No cluster UID exists anywhere in kubeside: reading one needs `get` on a `kube-system` namespace object, a permission kubeside does not request and a namespace-scoped developer would be refused for asking. | +| Context renamed in kubeconfig | Environment classification matches on API server URL first, context name second (`Config.Environment`), so a rename keeps its environment and guardrails. There is no stored history for a rename to survive: kubeside writes nothing to disk, and the timeline is reconstructed from the live cluster on every read. | | Same app name, unrelated services in two environments | Config `apps.match` overrides. A mismatch warning appears when image repositories differ entirely. | -| An environment has 40 contexts | Environment panel paginates and the promotion view aggregates, with per-context detail on expansion. | +| An environment has 40 contexts | Environment panel paginates. The promotion cell for it collapses only if all 40 agree; otherwise it splits. Fleet lists all 40 with their own state. | ## Resolved questions diff --git a/docs/05-architecture.md b/docs/05-architecture.md index d90fd74..23874ee 100644 --- a/docs/05-architecture.md +++ b/docs/05-architecture.md @@ -57,8 +57,17 @@ web/ React frontend, embedded via embed.FS permission cache, and circuit breaker. One goroutine per connection. A dead cluster never blocks a request for another. -`contextID` derives from cluster UID first and kubeconfig name second, so a -context rename in kubeconfig preserves stored history. +`contextID` is the kubeconfig context name. Two contexts aimed at one cluster +are recognized by API server URL, normalized by `config.NormalizeURL`, which is +what the fleet view merges on. + +An earlier draft said this derived from a cluster UID, and that a rename +"preserves stored history". Both were wrong. No cluster UID is read anywhere: +obtaining one means reading the `kube-system` namespace UID, a permission +kubeside does not request and a namespace-scoped developer would be refused. And +there is no stored history to preserve, which is the point of +[04-multi-cluster.md](04-multi-cluster.md)'s decision to write nothing to disk. +A rename costs the session's in-memory buffer, and nothing else. Watch tiers, per [04-multi-cluster.md](04-multi-cluster.md): diff --git a/docs/guide/12-troubleshooting.md b/docs/guide/12-troubleshooting.md index ff06e8c..6e3297c 100644 --- a/docs/guide/12-troubleshooting.md +++ b/docs/guide/12-troubleshooting.md @@ -83,3 +83,27 @@ manages your RBAC. See [permissions](11-permissions.html). Look for the edge. `ended` means the container exited, `gone` means the pod was deleted, `error` carries the reason. A log view that simply went quiet would be the failure worth worrying about. + +## Is there a TUI, like k9s? + +No, and this is a decision, not an oversight. Asked on the same +[Show HN thread](https://news.ycombinator.com/item?id=49139573) that raised the +Argo CD question. + +The four questions kubeside answers are layout problems before they are +anything else. A timeline needs a horizontal axis with markers on it. A +side-by-side config diff needs two columns a reader can scan against each +other. A promotion matrix needs a grid with color and drift indicators. A +monospace grid renders none of them well, and forcing them into one would +mean designing worse versions of screens that already work in a browser. + +k9s also already owns the terminal, and it does that job well. Competing with +it on keyboard speed would be a fight against a tool people already like, +over ground kubeside does not need. + +If your kubeconfig is not local, for example a shared cluster with no kubectl +access from your machine, `--serve` mode is the answer. It runs the same +binary and the same screens against the cluster directly, over OIDC. It is +listed under Beyond v1 in +[the roadmap](https://github.com/dynaum/kubeside/blob/main/docs/06-roadmap.md) +and has not shipped yet. diff --git a/docs/guide/13-fleet.md b/docs/guide/13-fleet.md new file mode 100644 index 0000000..d76ae94 --- /dev/null +++ b/docs/guide/13-fleet.md @@ -0,0 +1,84 @@ +# Fleet + +Is every cluster running the latest version? One app, one row per cluster. + +Promotion compares environments side by side. Fleet answers a question +promotion cannot phrase: a team running prod across two regions, or any +environment holding more than one cluster, needs to know whether those +clusters agree with each other, not only whether prod agrees with stg. + +## Opening it + +Four ways in: + +- The command palette (`cmd+k`), from any app: "*app* across every cluster". +- The "Fleet" button on the app detail screen. +- Directly, at `#fleet//`. +- From the promotion screen, click a `split` cell. A split cell means the + clusters inside that environment disagree, and fleet is where you see which + ones. + +Opening this screen wakes every context in your kubeconfig, not only the one +you were looking at. The app list and app detail connect lazily, one context at +a time, as you look at them. Fleet asks all of them at once, on open, because +"every cluster" is the question it exists to answer. + +Promotion also connects every context, so fleet is not alone in that. Two +things make fleet heavier. It connects them concurrently rather than one after +another, and it reads pod status on every cluster it reaches, which promotion +does only for the apps you are comparing. Pod status is where the image digest +lives, and the digest is what catches one tag running as two different builds. + +On a kubeconfig with forty contexts, that is forty connections at once, each +possibly running a credential plugin. Worth knowing before you bind the palette +command to muscle memory. + +## Reading the table + +| State | Means | +| --- | --- | +| `present` | The cluster runs the app. | +| `absent` | The cluster answered, and does not run it. A schedule, not a problem: the app just is not deployed there yet. | +| `denied` | The cluster refused the read. The note names the RBAC rule that would fix it, for example "cannot read Deployment here; the role needs: list deployments in team-a". | +| `unreachable` | The cluster did not answer at all. VPN, DNS, or the cluster is down. | +| `pending` | Still asking. | + +The distinction that matters most is `unreachable` against `absent`. A prod +cluster reachable only over VPN, currently off, is `unreachable`: kubeside +never saw it this time, so it never claims the app is missing there. Only a +cluster that actually answered and does not run the app gets `absent`. +Conflating the two, "I could not look" read as "it is not there", is the +mistake this screen exists to prevent, and it is the reason opening fleet +wakes every context instead of trusting whatever was last connected. + +A present row can also carry: + +- **behind** — an older version than the newest tag another cluster in the + fleet runs. +- **mutable tag** — this cluster's tag also resolves to a different digest + somewhere else in the fleet. Two clusters agree on the version number and + are not running the same code. This outranks behind: a cluster openly a + version behind is a schedule, two clusters claiming agreement while running + different builds is a defect. + +Rows sort by how much the cluster told you, worst first: mutable tag, then +unreachable, then behind, then denied, then present with no comparable +version, then pending. Absent sits with the healthy rows at the bottom, +because "not deployed here" is not a disagreement. + +## Version unknown + +An image pinned by digest (`myapp@sha256:...`) carries no tag. There is +nothing to compare it against, so the row reads "version unknown: the image +is pinned by digest" rather than folding it into the newest tag or treating +it as a match. Pinning by digest is not a defect; reading it as agreement +would be. + +## Clusters that are really one cluster + +A kubeconfig commonly holds one cluster under two context names, often +because one of them carries credentials that stopped working. Fleet detects +this by API server URL and merges the two into one row, so the same cluster +is never counted twice. The row names what it merged: "also +prod-us-east-arn" beside the context it kept. The row it keeps is whichever +context answered with the most, so a stale duplicate never hides a live one. diff --git a/docs/superpowers/plans/2026-08-02-fleet.md b/docs/superpowers/plans/2026-08-02-fleet.md new file mode 100644 index 0000000..69d222f --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-fleet.md @@ -0,0 +1,2212 @@ +# Fleet Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show one app across every cluster, and stop the promotion matrix reporting a version it never verified. + +**Architecture:** A new `internal/fleet` package derives "which clusters run the newest version" from one placement per cluster. `internal/api` gathers placements by walking every kubeconfig context, connecting lazily, and deduping on API server URL. Separately, the promotion path stops discarding contexts: `service.go` gathers every context in an environment instead of the first, and `promotion.row` renders a split cell when they disagree. + +**Tech Stack:** Go 1.26.2, client-go informers, React 19 with a custom design system, Vitest, Playwright. Module path `github.com/dynaum/kubeside`. + +## Global Constraints + +- No commit trailers of any kind. No `Co-Authored-By`, no `Generated with`. +- Tests written before implementation. +- `go build ./...` and `go test ./...` pass before every commit. +- One commit per task, message ending with `Closes #N` once issues are filed. +- Never write to disk at runtime. +- Never hide a control for lack of permission. Disable it and name the verb. +- Never render an unknown window as an empty one. +- Port UI from the Claude Design project named `kubeside`. Do not invent new UI. +- Spec: `docs/superpowers/specs/2026-08-02-fleet-design.md`. + +--- + +### Task 1: Export the shared version and identity helpers + +`internal/fleet` must order tags the same way `internal/promotion` does. Two comparators disagreeing about which version is newer would be the worst defect this feature can carry. This task exports, and changes no behavior. + +**Files:** +- Modify: `internal/promotion/promotion.go:238-261` (identity), `:294-297` (compareTags) +- Test: `internal/promotion/promotion_test.go` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `promotion.CompareTags(a, b string) int` — negative when `a` is older, positive when newer, zero when the two cannot be ordered + - `promotion.Identity(app, namespace string) string` — the cross-environment key + - `promotion.StripEnvToken(ns string) string` — namespace with `qa`, `prod`, and friends removed + +- [ ] **Step 1: Write the failing test** + +Add to `internal/promotion/promotion_test.go`: + +```go +func TestIdentityMatchesAcrossEnvSuffixedNamespaces(t *testing.T) { + qa := Identity("checkout", "team-a-qa") + prod := Identity("checkout", "team-a-prod") + if qa != prod { + t.Errorf("Identity qa = %q, prod = %q; one team's namespace in two places is one app", qa, prod) + } +} + +func TestCompareTagsIsExported(t *testing.T) { + if CompareTags("v1.2.0", "v1.10.0") >= 0 { + t.Error("v1.2.0 should order below v1.10.0") + } + if CompareTags("sha-abc", "sha-def") != 0 { + t.Error("build ids cannot be ordered, and claiming a direction invents one") + } +} + +func TestStripEnvTokenIsExported(t *testing.T) { + if got := StripEnvToken("team-a-prod"); got != "team-a" { + t.Errorf("StripEnvToken = %q, want team-a", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/promotion/ -run 'TestIdentity|TestCompareTagsIsExported|TestStripEnvTokenIsExported' -v` +Expected: FAIL, `undefined: Identity`, `undefined: CompareTags`, `undefined: StripEnvToken` + +- [ ] **Step 3: Rename to exported, keep the unexported call sites working** + +In `internal/promotion/promotion.go`, replace the `identity`, `stripEnvToken`, and `compareTags` definitions: + +```go +// Identity is how one app is recognized across environments. +// +// Name plus namespace, with an environment suffix or prefix tolerated, because +// team-a-qa and team-a-prod are one team's namespace in two places and lining +// those up is the whole point of the view. +func Identity(app, namespace string) string { + return app + "|" + StripEnvToken(namespace) +} + +func identity(in Instance) string { return Identity(in.App, in.Namespace) } + +// StripEnvToken removes the environment token from a namespace, so that +// team-a-qa and team-a-prod resolve to one identity. +func StripEnvToken(ns string) string { + parts := strings.Split(ns, "-") + kept := make([]string, 0, len(parts)) + for _, p := range parts { + if envTokens[strings.ToLower(strings.TrimRight(p, "0123456789"))] { + continue + } + kept = append(kept, p) + } + if len(kept) == 0 { + return ns + } + return strings.Join(kept, "-") +} + +// CompareTags orders two version tags, returning zero when they cannot be +// ordered. Claiming a direction we cannot establish would be worse than saying +// they merely differ. +func CompareTags(a, b string) int { + an, aok := versionParts(a) + bn, bok := versionParts(b) + // ... body unchanged from compareTags +} +``` + +Then replace the one call site at `promotion.go:204`, `compareTags(in.Tag, upstream.Tag)`, with `CompareTags(in.Tag, upstream.Tag)`. Delete the old `stripEnvToken` and `compareTags` definitions. + +- [ ] **Step 4: Run the whole promotion suite** + +Run: `go test ./internal/promotion/ -v` +Expected: PASS, including every pre-existing test. This task changes no behavior, so a failure here is a rename mistake. + +- [ ] **Step 5: Build and commit** + +```bash +go build ./... && go test ./... +git add internal/promotion/ +git commit -m "refactor(promotion): export tag ordering and app identity for reuse + +internal/fleet must order tags the same way the promotion matrix does. Two +comparators disagreeing about which version is newer would be the worst +defect the fleet view can carry, so it reuses these rather than growing a +second implementation. No behavior change." +``` + +--- + +### Task 2: The promotion engine renders a split cell + +The engine stops assuming one context per environment. A cell collapses only when every context agrees. + +**Files:** +- Modify: `internal/promotion/promotion.go:23-32` (states), `:34-39` (Env), `:41-60` (Instance), `:62-96` (Cell, Summary), `:99-110` (Summarize), `:143-188` (row) +- Test: `internal/promotion/promotion_test.go` + +**Interfaces:** +- Consumes: `promotion.CompareTags` from Task 1 +- Produces: + - `promotion.StateSplit = "split"` + - `promotion.Env{Name, Risk string, Context string, Contexts []string, Unreadable []string}` + - `promotion.Instance` gains `Context string` + - `promotion.Cell` gains `Clusters int` + - `promotion.Summary` gains `Split int` + +- [ ] **Step 1: Write the failing tests** + +Add to `internal/promotion/promotion_test.go`: + +```go +func multiEnv() []Env { + return []Env{ + {Name: "qa", Contexts: []string{"qa-cluster"}}, + {Name: "prod", Contexts: []string{"prod-us-east", "prod-eu-west", "prod-ap-south"}}, + } +} + +func prodInstance(ctx, tag, digest string) Instance { + return Instance{ + Env: "prod", Context: ctx, App: "checkout", Namespace: "shop", + Present: true, Image: "reg/checkout:" + tag, Tag: tag, Digest: digest, + Health: "healthy", Ready: "3/3", + } +} + +func cellFor(t *testing.T, rows []Row, env string) Cell { + t.Helper() + for _, c := range rows[0].Cells { + if c.Env == env { + return c + } + } + t.Fatalf("no cell for env %q", env) + return Cell{} +} + +func TestAgreeingContextsCollapseToOneVersion(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + {Env: "qa", Context: "qa-cluster", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.14.0", Digest: "sha256:aa"}, + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", "sha256:bb"), + prodInstance("prod-ap-south", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateBehind { + t.Errorf("state = %q, want %q: three clusters agreeing is one version", c.State, StateBehind) + } + if c.Tag != "v2.13.1" { + t.Errorf("tag = %q, want v2.13.1", c.Tag) + } +} + +func TestDisagreeingContextsNeverCollapse(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.12.0", "sha256:cc"), + prodInstance("prod-ap-south", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit { + t.Fatalf("state = %q, want %q", c.State, StateSplit) + } + if c.Tag != "" { + t.Errorf("tag = %q; a split cell must not pick a winner", c.Tag) + } + if !c.Severe { + t.Error("a split prod is severe") + } + if c.Clusters != 3 { + t.Errorf("clusters = %d, want 3", c.Clusters) + } + if !strings.Contains(c.Note, "2 versions") { + t.Errorf("note = %q, want it to name the version count", c.Note) + } +} + +func TestSameTagDifferentDigestSplitsLoudly(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", "sha256:cc"), + prodInstance("prod-ap-south", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit || !c.Severe { + t.Fatalf("state = %q severe = %v, want a severe split", c.State, c.Severe) + } + if !strings.Contains(c.Note, "digest") { + t.Errorf("note = %q, want it to name the mutable tag", c.Note) + } +} + +func TestPendingDigestDoesNotSplitAnAgreement(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", ""), + prodInstance("prod-ap-south", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State == StateSplit { + t.Fatal("a digest still in flight is not a disagreement") + } + if !c.DigestPending { + t.Error("a missing digest must read as pending, never as a match") + } + if c.Digest != "" { + t.Errorf("digest = %q; claiming one cluster's digest for the pair would be a guess", c.Digest) + } +} + +func TestPartialDeploymentSplits(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit { + t.Fatalf("state = %q; deployed in two of three clusters is not deployed", c.State) + } + if !strings.Contains(c.Note, "2 of 3") { + t.Errorf("note = %q, want it to name the coverage", c.Note) + } +} + +func TestUnreadableContextBlocksCollapse(t *testing.T) { + envs := multiEnv() + envs[1].Unreadable = []string{"prod-ap-south"} + rows := Build(envs, []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit { + t.Fatalf("state = %q; a cluster nobody could read is not an agreement", c.State) + } + if !strings.Contains(c.Note, "readable") { + t.Errorf("note = %q, want it to say how many clusters answered", c.Note) + } +} + +func TestSplitCellIsNotUpstreamForTheNextColumn(t *testing.T) { + envs := []Env{ + {Name: "qa", Contexts: []string{"qa-cluster"}}, + {Name: "stg", Contexts: []string{"stg-a", "stg-b"}}, + {Name: "prod", Contexts: []string{"prod-only"}}, + } + rows := Build(envs, []Instance{ + {Env: "qa", Context: "qa-cluster", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.0.0", Digest: "sha256:aa"}, + {Env: "stg", Context: "stg-a", App: "checkout", Namespace: "shop", Present: true, Tag: "v3.0.0", Digest: "sha256:bb"}, + {Env: "stg", Context: "stg-b", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:cc"}, + {Env: "prod", Context: "prod-only", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.0.0", Digest: "sha256:aa"}, + }) + c := cellFor(t, rows, "prod") + if c.State != StateSame { + t.Errorf("prod state = %q, want %q: prod matches qa, and the split stg never became the basis", c.State, StateSame) + } +} + +func TestSingleContextEnvironmentIsUnchanged(t *testing.T) { + envs := []Env{ + {Name: "qa", Contexts: []string{"qa-cluster"}}, + {Name: "prod", Contexts: []string{"prod-cluster"}}, + } + rows := Build(envs, []Instance{ + {Env: "qa", Context: "qa-cluster", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.0.0", Digest: "sha256:aa"}, + {Env: "prod", Context: "prod-cluster", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:bb"}, + }) + c := cellFor(t, rows, "prod") + if c.State != StateBehind { + t.Errorf("state = %q, want %q: one context per environment behaves exactly as before", c.State, StateBehind) + } +} +``` + +Add `"strings"` to the test file's imports if absent. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/promotion/ -run 'TestAgreeing|TestDisagreeing|TestSameTag|TestPending|TestPartial|TestUnreadable|TestSplitCell|TestSingleContext' -v` +Expected: FAIL, `unknown field Contexts in struct literal`, `undefined: StateSplit` + +- [ ] **Step 3: Add the state, the fields, and the grouping** + +In `internal/promotion/promotion.go`, add the state to the const block: + +```go + StateDenied = "denied" + // StateSplit is an environment whose clusters do not agree. It never + // carries a version, because picking one would be the guess this state + // exists to prevent. + StateSplit = "split" +``` + +Widen `Env`: + +```go +// Env is one column. +type Env struct { + Name string `json:"name"` + Risk string `json:"risk"` + // Context is the first context bound to this environment, kept so a cell + // can still be opened. Contexts is all of them. + Context string `json:"context,omitempty"` + Contexts []string `json:"contexts,omitempty"` + // Unreadable names the contexts that did not answer. A cell in an + // environment with any unreadable context can never claim agreement, + // because the clusters nobody read might disagree. + Unreadable []string `json:"unreadable,omitempty"` +} +``` + +Add `Context` to `Instance`, after `Env`: + +```go +type Instance struct { + Env string + Context string + App string + // ... rest unchanged +``` + +Add `Clusters` to `Cell`, after `Env`: + +```go + // Clusters is how many contexts back this cell. Zero and one both mean a + // single cluster; the UI shows the count only above one. + Clusters int `json:"clusters,omitempty"` +``` + +Add `Split` to `Summary` and count it in `Summarize`: + +```go +type Summary struct { + Apps int `json:"apps"` + Drifted int `json:"drifted"` + Ahead int `json:"ahead"` + Split int `json:"split"` +} + +func Summarize(rows []Row) Summary { + s := Summary{Apps: len(rows)} + for _, r := range rows { + if r.Drift > 0 { + s.Drifted++ + } + if r.Ahead { + s.Ahead++ + } + for _, c := range r.Cells { + if c.State == StateSplit { + s.Split++ + break + } + } + } + return s +} +``` + +- [ ] **Step 4: Rewrite `row` to group rather than overwrite** + +Replace `row` at `internal/promotion/promotion.go:143-188`: + +```go +func row(envs []Env, instances []Instance) Row { + byEnv := map[string][]Instance{} + for _, in := range instances { + byEnv[in.Env] = append(byEnv[in.Env], in) + } + + out := Row{App: instances[0].App, Namespace: instances[0].Namespace} + + // upstream is the last environment that actually told us something. An + // unreadable environment is not an agreement, and neither is a split one, + // so neither becomes the thing the next column is compared against. + var upstream *Instance + + for _, env := range envs { + rep, cell, ok := resolve(env, byEnv[env.Name], upstream) + out.Cells = append(out.Cells, cell) + // Absent and denied are not comparisons: there is nothing to compare + // against, or nothing we were allowed to read. Only a cell that was + // actually evaluated moves the drift count. A split cell was + // evaluated, and disagreed, so it counts. + if cell.State != StateSame && cell.State != StateAbsent && cell.State != StateDenied { + out.Drift++ + } + if cell.State == StateAhead { + out.Ahead = true + } + if ok { + copyOf := rep + upstream = ©Of + } + } + return out +} + +// resolve turns one environment's clusters into one cell. +// +// The third return says whether the cell may serve as upstream for the next +// column. Absent, denied, and split all say no: a column the matrix could not +// read, or whose clusters disagree, is not a version the next column can be +// compared against. +func resolve(env Env, group []Instance, upstream *Instance) (Instance, Cell, bool) { + clusters := len(env.Contexts) + if clusters == 0 { + clusters = 1 + } + + var present []Instance + denied := 0 + for _, in := range group { + switch { + case in.Denied: + denied++ + case in.Present: + present = append(present, in) + } + } + + if len(present) == 0 { + if denied > 0 { + reason := "not readable" + for _, in := range group { + if in.Denied && in.DeniedReason != "" { + reason = in.DeniedReason + break + } + } + return Instance{}, Cell{Env: env.Name, State: StateDenied, Note: reason, Clusters: clusters}, false + } + return Instance{}, Cell{Env: env.Name, State: StateAbsent, Note: "not deployed here", Clusters: clusters}, false + } + + readable := clusters - len(env.Unreadable) + if rep, agreed := consensus(present); agreed && len(present) == readable && len(env.Unreadable) == 0 { + c := compare(rep, upstream) + c.Clusters = clusters + return rep, c, true + } + + return Instance{}, split(env, present, clusters, readable), false +} + +// consensus collapses clusters that agree. +// +// Tags must match exactly. Digests must match only when both arrived, because +// a digest still in flight is not a disagreement. The representative carries +// no digest unless every cluster reported the same one, so a pending fetch +// renders as pending instead of borrowing one cluster's answer for the rest. +func consensus(present []Instance) (Instance, bool) { + rep := present[0] + anyPending := rep.Digest == "" + for _, in := range present[1:] { + if in.Tag != rep.Tag { + return Instance{}, false + } + if in.Digest == "" { + anyPending = true + continue + } + if rep.Digest != "" && in.Digest != rep.Digest { + return Instance{}, false + } + if rep.Digest == "" { + rep.Digest = in.Digest + } + } + if anyPending { + rep.Digest = "" + } + rep.Context = "" + return rep, true +} + +// split is the cell for an environment whose clusters do not agree. It names +// the disagreement and never names a version. +func split(env Env, present []Instance, clusters, readable int) Cell { + c := Cell{Env: env.Name, State: StateSplit, Severe: true, Clusters: clusters} + if len(present) > 0 { + c.Namespace = present[0].Namespace + } + + tags := map[string]bool{} + digests := map[string]bool{} + for _, in := range present { + tags[in.Tag] = true + if in.Digest != "" { + digests[in.Digest] = true + } + } + + switch { + case len(env.Unreadable) > 0: + c.Note = fmt.Sprintf("%d of %d clusters readable; the rest did not answer", readable, clusters) + case len(tags) == 1 && len(digests) > 1: + // A mutable tag outranks partial coverage. One tag resolving to two + // digests is a defect; a cluster not having the app yet is a + // schedule, and the defect is the worse fact to hide. + c.Tag = "" + c.Note = fmt.Sprintf("one tag across %d clusters but %d digests: the tag is mutable and these are not the same code", clusters, len(digests)) + case len(present) < readable: + c.Note = fmt.Sprintf("deployed in %d of %d clusters", len(present), clusters) + default: + c.Note = fmt.Sprintf("%d versions across %d clusters", len(tags), clusters) + } + return c +} +``` + +- [ ] **Step 5: Run the whole promotion suite** + +Run: `go test ./internal/promotion/ -v` +Expected: PASS. `TestSingleContextEnvironmentIsUnchanged` and every pre-existing test are the regression guard; a failure there means the collapse path drifted. + +- [ ] **Step 6: Commit** + +```bash +go build ./... && go test ./... +git add internal/promotion/ +git commit -m "feat(promotion): a multi-context environment stops collapsing a disagreement + +byEnv[in.Env] = in kept the last context and dropped the rest, so a prod +environment holding three clusters rendered one confident version nobody +verified. The cell now collapses only when every cluster agrees on tag and +digest, and renders a split naming the disagreement otherwise. A split never +becomes upstream for the next column, for the same reason an unreadable +column never did." +``` + +--- + +### Task 3: The service gathers every context in an environment + +The engine can now render a split. It never will until the service stops skipping contexts. + +**Files:** +- Modify: `internal/api/service.go:992-1047` (Promotion), `:1055-1083` (instanceOf) +- Test: `internal/api/service_test.go` + +**Interfaces:** +- Consumes: `promotion.Env{Contexts, Unreadable}` and `promotion.Instance{Context}` from Task 2 +- Produces: `Service.Promotion()` returning `PromotionView` whose `Envs` carry every bound context + +- [ ] **Step 1: Write the failing test** + +Read `internal/api/service_test.go` first to reuse its existing fake-cluster construction rather than inventing a second one. Add: + +```go +func TestPromotionKeepsEveryContextInAnEnvironment(t *testing.T) { + // Two contexts bound to one environment, running different versions. + // Build the service with both contexts pointing at prod, the second + // serving checkout at v2.12.0 and the first at v2.13.1. + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-us-east": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.13.1"}}}, + "prod-eu-west": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.12.0"}}}, + }) + + v := s.Promotion() + + if len(v.Envs) != 1 { + t.Fatalf("envs = %d, want 1: both contexts bind to prod", len(v.Envs)) + } + if got := len(v.Envs[0].Contexts); got != 2 { + t.Errorf("prod contexts = %d, want 2: the second context was dropped", got) + } + if len(v.Rows) != 1 { + t.Fatalf("rows = %d, want 1", len(v.Rows)) + } + c := v.Rows[0].Cells[0] + if c.State != promotion.StateSplit { + t.Errorf("state = %q, want %q: two clusters on different versions is not one version", c.State, promotion.StateSplit) + } +} + +func TestPromotionMarksAnUnreachableContextOnItsEnvironment(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-us-east": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.13.1"}}}, + "prod-eu-west": {env: "prod", unreachable: true}, + }) + + v := s.Promotion() + + if len(v.Envs) != 1 { + t.Fatalf("envs = %d, want 1", len(v.Envs)) + } + if got := v.Envs[0].Unreadable; len(got) != 1 || got[0] != "prod-eu-west" { + t.Errorf("unreadable = %v, want [prod-eu-west]", got) + } + if v.Rows[0].Cells[0].State != promotion.StateSplit { + t.Error("a cluster nobody could read is not an agreement") + } +} +``` + +Add `serviceWithContexts`, `fakeCluster`, and `fakeApp` helpers only if the file has no equivalent. If it does, adapt the tests to the existing helpers rather than adding a parallel set. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/api/ -run 'TestPromotionKeepsEveryContext|TestPromotionMarksAnUnreachable' -v` +Expected: FAIL, `prod contexts = 1, want 2` + +- [ ] **Step 3: Replace the first-one-wins skip with grouping** + +Replace `Service.Promotion` at `internal/api/service.go:992-1047`: + +```go +// Promotion compares every app across every environment. +// +// Opening this view is what connects the environments: the matrix cannot answer +// "is the fix in prod yet" without reading prod. An environment that will not +// connect becomes a column of unreadable cells naming the reason, never an +// empty column that reads as "nothing is deployed there". +// +// Every context bound to an environment is read, not the first. A team running +// prod in three regions has three answers, and reporting one of them as the +// environment's version is the defect this view exists to prevent. +func (s *Service) Promotion() PromotionView { + ctx, cancel := context.WithTimeout(context.Background(), s.timeout) + defer cancel() + + type accum struct { + risk string + contexts []string + unreadable []string + } + byEnv := map[string]*accum{} + var order []string + + var instances []promotion.Instance + var unreachable []string + + for _, name := range s.mgr.ConnectOrder() { + kctx := s.cfg.MustGet(name) + env := s.conf.Environment(kctx) + + acc, ok := byEnv[env.Name] + if !ok { + acc = &accum{risk: env.Risk.String()} + byEnv[env.Name] = acc + order = append(order, env.Name) + } + acc.contexts = append(acc.contexts, name) + + reason := "" + if err := s.mgr.Connect(ctx, name); err != nil { + reason = err.Error() + } + client, ok := s.mgr.ClientFor(name) + if !ok { + acc.unreadable = append(acc.unreadable, name) + unreachable = append(unreachable, env.Name) + instances = append(instances, promotion.Instance{ + Env: env.Name, Context: name, App: "", Denied: true, + DeniedReason: orElseString(reason, "not connected"), + }) + continue + } + + snap, err := clusters.Fetch(ctx, client, kctx, clusters.FetchOptions{Tier: s.mgr.Tier(name)}) + if err != nil { + acc.unreadable = append(acc.unreadable, name) + unreachable = append(unreachable, env.Name) + continue + } + for _, a := range snap.Apps { + in := instanceOf(env.Name, a) + in.Context = name + instances = append(instances, in) + } + } + + envs := make([]promotion.Env, 0, len(order)) + for _, name := range order { + acc := byEnv[name] + envs = append(envs, promotion.Env{ + Name: name, Risk: acc.risk, + Context: acc.contexts[0], Contexts: acc.contexts, Unreadable: acc.unreadable, + }) + } + + // Instances with no app name are placeholders for a cluster nobody could + // read; they marked the environment unreadable and nothing else. + real := instances[:0] + for _, in := range instances { + if in.App != "" { + real = append(real, in) + } + } + + rows := promotion.Build(envs, real) + return PromotionView{Envs: envs, Rows: rows, Summary: promotion.Summarize(rows), Unreachable: dedupeStrings(unreachable)} +} + +// dedupeStrings keeps the first occurrence of each value. An environment with +// three unreachable clusters is one unreachable environment in the banner. +func dedupeStrings(in []string) []string { + if len(in) == 0 { + return nil + } + seen := map[string]bool{} + out := make([]string, 0, len(in)) + for _, v := range in { + if seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + return out +} +``` + +- [ ] **Step 4: Run the api suite** + +Run: `go test ./internal/api/ -v` +Expected: PASS. Pre-existing promotion tests in this package assert single-context behavior and must still pass. + +- [ ] **Step 5: Commit** + +```bash +go build ./... && go test ./... +git add internal/api/ +git commit -m "fix(promotion): read every context in an environment, not the first + +service.go skipped a second context bound to the same environment before it +was ever connected, on the assumption two contexts in one environment are +identical. A team running prod in three regions disproves that, and the +matrix rendered one region's version as prod's. Every context is now read, +and the ones that do not answer are named on the environment." +``` + +--- + +### Task 4: The fleet derivation + +**Files:** +- Create: `internal/fleet/fleet.go`, `internal/fleet/fleet_test.go` + +**Interfaces:** +- Consumes: `promotion.CompareTags` from Task 1 +- Produces: + - `fleet.Placement` struct, states `StatePresent`, `StateAbsent`, `StateDenied`, `StateUnreachable`, `StatePending` + - `fleet.Row{Placement, Behind bool, MutableTag bool, Note string}` + - `fleet.View{App, Namespace, Rows, Newest, Behind, Present, Clusters, MutableTag}` + - `fleet.Build(app, namespace string, ps []Placement) View` + +- [ ] **Step 1: Write the failing test** + +Create `internal/fleet/fleet_test.go`: + +```go +package fleet + +import "testing" + +func present(ctx, server, env, tag, digest string) Placement { + return Placement{ + Context: ctx, ClusterID: server, Env: env, Namespace: "shop", + State: StatePresent, Image: "reg/checkout:" + tag, Tag: tag, Digest: digest, + Health: "healthy", Ready: "3/3", + } +} + +func rowFor(t *testing.T, v View, ctx string) Row { + t.Helper() + for _, r := range v.Rows { + if r.Context == ctx { + return r + } + } + t.Fatalf("no row for context %q", ctx) + return Row{} +} + +func TestNewestIsTheHighestTagAcrossPresentClusters(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("prod-us-east", "https://a", "prod", "v2.13.1", "sha256:bb"), + present("prod-eu-west", "https://b", "prod", "v2.12.0", "sha256:cc"), + present("qa-cluster", "https://c", "qa", "v2.14.0", "sha256:dd"), + }) + if v.Newest != "v2.14.0" { + t.Errorf("newest = %q, want v2.14.0", v.Newest) + } + if v.Behind != 2 { + t.Errorf("behind = %d, want 2", v.Behind) + } + if !rowFor(t, v, "prod-eu-west").Behind { + t.Error("prod-eu-west runs v2.12.0 and is behind") + } + if rowFor(t, v, "qa-cluster").Behind { + t.Error("the newest cluster is not behind itself") + } +} + +func TestUnorderableTagsAreNeverCalledBehind(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "qa", "sha-abc123", "sha256:aa"), + present("b", "https://b", "qa", "sha-def456", "sha256:bb"), + }) + if v.Behind != 0 { + t.Errorf("behind = %d, want 0: two build ids cannot be ordered, and claiming one is behind invents a direction", v.Behind) + } +} + +func TestSameTagDifferentDigestOutranksBehind(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "prod", "v2.13.1", "sha256:bb"), + present("b", "https://b", "prod", "v2.13.1", "sha256:cc"), + present("c", "https://c", "prod", "v2.12.0", "sha256:dd"), + }) + if !v.MutableTag { + t.Fatal("one tag resolving to two digests is the loudest state on the screen") + } + if v.Rows[0].Context != "a" && v.Rows[0].Context != "b" { + t.Errorf("first row = %q; the mutable-tag clusters sort above the behind one", v.Rows[0].Context) + } +} + +func TestTwoContextsOnOneClusterMergeToOneRow(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + {Context: "prod-alias", ClusterID: "https://a", Env: "prod", State: StateUnreachable, Reason: "credentials expired"}, + present("prod-us-east", "https://a", "prod", "v2.13.1", "sha256:bb"), + }) + if len(v.Rows) != 1 { + t.Fatalf("rows = %d, want 1: one cluster reached by two contexts is one cluster", len(v.Rows)) + } + if v.Rows[0].State != StatePresent { + t.Errorf("state = %q, want %q: the context that answered wins over the one that did not", v.Rows[0].State, StatePresent) + } + if v.Clusters != 1 { + t.Errorf("clusters = %d, want 1; counting it twice inflates the headline number", v.Clusters) + } +} + +func TestTheFiveStatesStayDistinct(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "qa", "v1.0.0", "sha256:aa"), + {Context: "b", ClusterID: "https://b", Env: "stg", State: StateAbsent}, + {Context: "c", ClusterID: "https://c", Env: "prod", State: StateDenied, Reason: "pods list refused"}, + {Context: "d", ClusterID: "https://d", Env: "prod", State: StateUnreachable, Reason: "dial timeout"}, + {Context: "e", ClusterID: "https://e", Env: "prod", State: StatePending}, + }) + if v.Clusters != 5 { + t.Fatalf("clusters = %d, want 5", v.Clusters) + } + if v.Present != 1 { + t.Errorf("present = %d, want 1", v.Present) + } + seen := map[string]bool{} + for _, r := range v.Rows { + seen[r.State] = true + } + for _, s := range []string{StatePresent, StateAbsent, StateDenied, StateUnreachable, StatePending} { + if !seen[s] { + t.Errorf("state %q was collapsed into another; absent, denied, and unreachable are different facts", s) + } + } +} + +func TestNoClusterRunsTheAppAtAll(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + {Context: "a", ClusterID: "https://a", Env: "qa", State: StateAbsent}, + {Context: "b", ClusterID: "https://b", Env: "prod", State: StateAbsent}, + }) + if v.Newest != "" { + t.Errorf("newest = %q, want empty", v.Newest) + } + if v.Clusters != 2 || v.Present != 0 { + t.Errorf("clusters = %d present = %d, want 2 and 0: the screen names what it asked", v.Clusters, v.Present) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/fleet/ -v` +Expected: FAIL, no such package + +- [ ] **Step 3: Write the implementation** + +Create `internal/fleet/fleet.go`: + +```go +// Package fleet answers "is every cluster running the latest version". +// +// One app, one row per cluster, environment demoted to a column. The promotion +// matrix compares environments side by side; this compares the clusters inside +// and across them, which is the question a team running prod in three regions +// asks and the matrix cannot phrase. +// +// Two facts carry the screen: the newest tag among the clusters that answered, +// and every cluster behind it. Everything else is there so a cluster that did +// not answer is never mistaken for a cluster without the app. +package fleet + +import ( + "fmt" + "sort" + + "github.com/dynaum/kubeside/internal/promotion" +) + +// Placement states. +const ( + StatePresent = "present" + StateAbsent = "absent" + StateDenied = "denied" + StateUnreachable = "unreachable" + StatePending = "pending" +) + +// Placement is one app as it exists in one cluster. +type Placement struct { + Context string `json:"context"` + ClusterID string `json:"clusterId"` + Env string `json:"env"` + Namespace string `json:"namespace,omitempty"` + + State string `json:"state"` + Reason string `json:"reason,omitempty"` + + Image string `json:"image,omitempty"` + Tag string `json:"tag,omitempty"` + Digest string `json:"digest,omitempty"` + // DigestPending marks a placement whose digest has not arrived. A missing + // digest never reads as a match. + DigestPending bool `json:"digestPending,omitempty"` + + Health string `json:"health,omitempty"` + Ready string `json:"ready,omitempty"` + RevisionAt string `json:"revisionAt,omitempty"` + // Aliases names other kubeconfig contexts pointing at this same cluster, + // so a merged row says what it merged. + Aliases []string `json:"aliases,omitempty"` +} + +// Row is one cluster, judged against the newest version. +type Row struct { + Placement + Behind bool `json:"behind,omitempty"` + // MutableTag marks a cluster carrying the newest tag with a digest another + // cluster's copy of that tag does not share. + MutableTag bool `json:"mutableTag,omitempty"` + Note string `json:"note,omitempty"` +} + +// View is the whole screen. +type View struct { + App string `json:"app"` + Namespace string `json:"namespace"` + Rows []Row `json:"rows"` + + Newest string `json:"newest,omitempty"` + Clusters int `json:"clusters"` + Present int `json:"present"` + Behind int `json:"behind"` + // MutableTag is true when one tag resolved to more than one digest. It + // outranks being behind: a cluster openly on an older version is a + // schedule, two clusters claiming one version while running different + // code is a defect. + MutableTag bool `json:"mutableTag,omitempty"` +} + +// Build derives the screen from one placement per context. +func Build(app, namespace string, ps []Placement) View { + merged := mergeByCluster(ps) + + v := View{App: app, Namespace: namespace, Clusters: len(merged)} + + newest := "" + for _, p := range merged { + if p.State != StatePresent { + continue + } + v.Present++ + if p.Tag == "" { + continue + } + if newest == "" || promotion.CompareTags(p.Tag, newest) > 0 { + newest = p.Tag + } + } + v.Newest = newest + + // A tag carrying two digests is worse than a cluster openly behind. + digests := map[string]bool{} + for _, p := range merged { + if p.State == StatePresent && p.Tag == newest && p.Digest != "" { + digests[p.Digest] = true + } + } + v.MutableTag = len(digests) > 1 + + v.Rows = make([]Row, 0, len(merged)) + for _, p := range merged { + r := Row{Placement: p} + switch p.State { + case StatePresent: + r.DigestPending = p.Digest == "" + if p.Tag == newest { + if v.MutableTag && p.Digest != "" { + r.MutableTag = true + r.Note = fmt.Sprintf("runs %s, and so does another cluster with a different digest", newest) + } + break + } + // Behind only when the tags can be ordered. Two build ids differ + // without one being older, and calling either behind would invent + // a direction. + if p.Tag != "" && newest != "" && promotion.CompareTags(p.Tag, newest) < 0 { + r.Behind = true + v.Behind++ + r.Note = fmt.Sprintf("behind %s", newest) + } else if p.Tag != newest { + r.Note = fmt.Sprintf("runs %s, which cannot be ordered against %s", p.Tag, newest) + } + case StateUnreachable: + r.Note = orElse(p.Reason, "the cluster did not answer") + case StateDenied: + r.Note = orElse(p.Reason, "not readable here") + case StateAbsent: + r.Note = "not deployed here" + case StatePending: + r.Note = "asking" + } + v.Rows = append(v.Rows, r) + } + + sort.SliceStable(v.Rows, func(i, j int) bool { + si, sj := severity(v.Rows[i]), severity(v.Rows[j]) + if si != sj { + return si > sj + } + return v.Rows[i].Context < v.Rows[j].Context + }) + return v +} + +// severity sorts disagreement to the top, matching the promotion matrix's +// default of showing what needs attention first. +func severity(r Row) int { + switch { + case r.MutableTag: + return 5 + case r.Behind: + return 4 + case r.State == StateUnreachable: + return 3 + case r.State == StateDenied: + return 2 + case r.State == StatePending: + return 1 + default: + return 0 + } +} + +// mergeByCluster collapses contexts pointing at one cluster. +// +// A kubeconfig commonly holds the same cluster twice under two names, and one +// of the two may carry credentials that no longer work. Counting it twice +// inflates every number on the screen, so the most informative answer wins and +// the other context is recorded as an alias. +func mergeByCluster(ps []Placement) []Placement { + at := map[string]int{} + out := make([]Placement, 0, len(ps)) + for _, p := range ps { + key := p.ClusterID + if key == "" { + key = "ctx:" + p.Context + } + i, seen := at[key] + if !seen { + at[key] = len(out) + out = append(out, p) + continue + } + if informativeness(p.State) > informativeness(out[i].State) { + p.Aliases = append(p.Aliases, out[i].Context) + p.Aliases = append(p.Aliases, out[i].Aliases...) + out[i] = p + continue + } + out[i].Aliases = append(out[i].Aliases, p.Context) + } + return out +} + +// informativeness ranks what a cluster told us. Present beats absent, because +// a version is more than a confirmed nothing. Absent beats denied and +// unreachable, because the cluster answered at all. +func informativeness(state string) int { + switch state { + case StatePresent: + return 4 + case StateAbsent: + return 3 + case StateDenied: + return 2 + case StateUnreachable: + return 1 + default: + return 0 + } +} + +func orElse(v, fallback string) string { + if v == "" { + return fallback + } + return v +} +``` + +Confirm the module path in `go.mod` and correct the `promotion` import if it differs from `github.com/dynaum/kubeside`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/fleet/ -v` +Expected: PASS, all six tests + +- [ ] **Step 5: Commit** + +```bash +go build ./... && go test ./... +git add internal/fleet/ +git commit -m "feat(fleet): derive which clusters run the newest version + +One app, one row per cluster. Newest tag across the clusters that answered, +and every cluster behind it. Five states stay distinct so a cluster behind a +VPN is never read as a cluster without the app, and two contexts aimed at one +cluster merge so the headline count stays honest." +``` + +--- + +### Task 5: Gather placements across every context + +> **Corrected 2026-08-03, after review of the shipped commit. The Step 4 code +> below is WRONG in three ways and is kept only as a record of what was tried. +> Read `internal/api/fleet.go` for what actually ships.** +> +> 1. `len(snap.Partial) > 0` is constant true. `clusters/fetch.go:292` appends +> `"Pod"` to `Partial` at any non-active tier, deliberately unread and +> explicitly not a refusal, and `Fleet` runs every context at +> `TierBackground`. So `StateAbsent` was dead code and every app simply not +> deployed somewhere reported as denied. The denial test must name only the +> kinds that could carry the app: Deployment, StatefulSet, DaemonSet, +> CronJob, Job. Never Pod or ReplicaSet, which hide health rather than +> existence. The fetch also moves to `clusters.TierActive`, matching +> `cmd/kubeside/main.go:194`, because the digest lives only in pod status and +> without it the mutable-tag detection can never fire. +> 2. The `Scope` clause `len(snap.Scope.Namespaces) == 0 && !snap.Scope.ClusterWide` +> is unreachable. `fallbackScope` always sets exactly one namespace. The real +> condition is `!ClusterWide` qualified by whether the requested namespace is +> among the ones actually read. +> 3. One shared timeout across a serial loop spends the whole budget on the +> first slow clusters and invents `unreachable` rows for the rest, in the one +> view whose job is keeping unreachable honest. One goroutine and one +> `WithTimeout` per context, per `cmd/kubeside/main.go:183-196`. + +**Files:** +- Create: `internal/api/fleet.go`, `internal/api/fleet_test.go` +- Modify: `internal/config/config.go:298-305` (export `normalizeURL`) + +**Interfaces:** +- Consumes: `fleet.Build`, `fleet.Placement`, `promotion.Identity` from Tasks 1 and 4 +- Produces: `Service.Fleet(app, namespace string) fleet.View`, `config.NormalizeURL(u string) string` + +- [ ] **Step 1: Write the failing test** + +Create `internal/api/fleet_test.go` using the same fake-cluster helpers Task 3 used: + +```go +func TestFleetAsksEveryContext(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-us-east": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.13.1"}}}, + "prod-eu-west": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.12.0"}}}, + }) + + v := s.Fleet("checkout", "shop") + + if v.Clusters != 3 { + t.Fatalf("clusters = %d, want 3", v.Clusters) + } + if v.Newest != "v2.14.0" { + t.Errorf("newest = %q, want v2.14.0", v.Newest) + } + if v.Behind != 2 { + t.Errorf("behind = %d, want 2", v.Behind) + } +} + +func TestFleetNamesAnUnreachableClusterRatherThanOmittingIt(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-eu-west": {env: "prod", unreachable: true}, + }) + + v := s.Fleet("checkout", "shop") + + if v.Clusters != 2 { + t.Fatalf("clusters = %d, want 2: a cluster that did not answer is still a cluster we asked", v.Clusters) + } + var found bool + for _, r := range v.Rows { + if r.Context == "prod-eu-west" { + found = true + if r.State != fleet.StateUnreachable { + t.Errorf("state = %q, want %q", r.State, fleet.StateUnreachable) + } + if r.Note == "" { + t.Error("an unreachable cluster names why") + } + } + } + if !found { + t.Error("prod-eu-west was omitted; a blank row would read as 'not deployed there'") + } +} + +func TestFleetMatchesAcrossEnvSuffixedNamespaces(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "team-a-qa", image: "reg/checkout:v2.14.0"}}}, + "prod-cluster": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "team-a-prod", image: "reg/checkout:v2.13.1"}}}, + }) + + v := s.Fleet("checkout", "team-a-qa") + + if v.Present != 2 { + t.Errorf("present = %d, want 2: team-a-qa and team-a-prod are one app in two places", v.Present) + } +} + +func TestARefusedReadIsDeniedNotAbsent(t *testing.T) { + // clusters.Fetch returns no error when RBAC refuses a list; it records the + // kind in Snapshot.Partial. An app missing from that snapshot was never + // looked for, so calling it absent would be a lie. + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-cluster": {env: "prod", refusedKinds: []string{"Deployment"}}, + }) + + v := s.Fleet("checkout", "shop") + + for _, r := range v.Rows { + if r.Context != "prod-cluster" { + continue + } + if r.State != fleet.StateDenied { + t.Fatalf("state = %q, want %q: we were not allowed to look, which is not the same as it not being there", r.State, fleet.StateDenied) + } + if !strings.Contains(r.Note, "Deployment") { + t.Errorf("note = %q, want it to name the kind that was refused", r.Note) + } + } +} + +func TestFleetFoundNowhereStillNamesWhatItAsked(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa"}, + "prod-cluster": {env: "prod"}, + }) + + v := s.Fleet("typo", "shop") + + if v.Clusters != 2 { + t.Errorf("clusters = %d, want 2: never render an unknown window as an empty one", v.Clusters) + } + if len(v.Rows) != 2 { + t.Errorf("rows = %d, want 2", len(v.Rows)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/api/ -run TestFleet -v` +Expected: FAIL, `s.Fleet undefined` + +- [ ] **Step 3: Export the URL normalizer** + +In `internal/config/config.go`, rename `normalizeURL` to `NormalizeURL`, update its doc comment and all call sites in the package (`config.go:282` among them): + +```go +// NormalizeURL makes server URLs comparable across the small differences that +// mean nothing: a trailing slash, or a host written in a different case. +func NormalizeURL(u string) string { + // body unchanged +} +``` + +- [ ] **Step 4: Write the gatherer** + +Create `internal/api/fleet.go`: + +```go +package api + +import ( + "context" + + "github.com/dynaum/kubeside/internal/apps" + "github.com/dynaum/kubeside/internal/clusters" + "github.com/dynaum/kubeside/internal/config" + "github.com/dynaum/kubeside/internal/fleet" + "github.com/dynaum/kubeside/internal/promotion" +) + +// Fleet answers "is every cluster running the latest version" for one app. +// +// Opening this view states the intent to ask every cluster, so unlike the app +// list it deliberately wakes every context in the kubeconfig. Rows land as +// clusters answer and a cluster behind a VPN never blocks the rest. +func (s *Service) Fleet(app, namespace string) fleet.View { + ctx, cancel := context.WithTimeout(context.Background(), s.timeout) + defer cancel() + + want := promotion.Identity(app, namespace) + var placements []fleet.Placement + + for _, name := range s.mgr.ConnectOrder() { + kctx := s.cfg.MustGet(name) + env := s.conf.Environment(kctx) + + p := fleet.Placement{ + Context: name, + ClusterID: config.NormalizeURL(kctx.Server), + Env: env.Name, + } + + if err := s.mgr.Connect(ctx, name); err != nil { + p.State = fleet.StateUnreachable + p.Reason = err.Error() + placements = append(placements, p) + continue + } + client, ok := s.mgr.ClientFor(name) + if !ok { + p.State = fleet.StateUnreachable + p.Reason = "not connected" + placements = append(placements, p) + continue + } + + snap, err := clusters.Fetch(ctx, client, kctx, clusters.FetchOptions{Tier: s.mgr.Tier(name)}) + if err != nil { + p.State = fleet.StateUnreachable + p.Reason = err.Error() + placements = append(placements, p) + continue + } + + match, found := findApp(snap.Apps, want) + if !found { + // Not in the snapshot has two causes and they are not the same + // fact. clusters.Fetch never errors on an RBAC refusal: it records + // the kinds it could not read in Snapshot.Partial and returns what + // it got. So an app missing from a snapshot with refused kinds is + // an app we were not allowed to look for, and calling that "not + // deployed here" is the conflation this screen exists to prevent. + // internal/api/service.go:196 already draws the same line for the + // apps list. + if len(snap.Partial) > 0 || len(snap.Scope.Namespaces) == 0 && !snap.Scope.ClusterWide { + p.State = fleet.StateDenied + p.Reason = refusedReason(snap) + } else { + p.State = fleet.StateAbsent + } + placements = append(placements, p) + continue + } + + in := instanceOf(env.Name, match) + p.State = fleet.StatePresent + p.Namespace = in.Namespace + p.Image = in.Image + p.Tag = in.Tag + p.Digest = in.Digest + p.DigestPending = in.Digest == "" + p.Health = in.Health + p.Ready = in.Ready + p.RevisionAt = in.RevisionAt + placements = append(placements, p) + } + + return fleet.Build(app, namespace, placements) +} + +// findApp matches by the same identity the promotion matrix uses, so one app +// is one app on both screens. +func findApp(list []apps.App, want string) (apps.App, bool) { + for _, a := range list { + if promotion.Identity(a.Key.Name, a.Key.Namespace) == want { + return a, true + } + } + return apps.App{}, false +} + +// refusedReason names what the cluster would not let us read, so a denied row +// says which verb to ask for rather than just refusing to answer. +func refusedReason(snap clusters.Snapshot) string { + if len(snap.Partial) > 0 { + return "could not read " + strings.Join(snap.Partial, ", ") + } + if snap.Scope.Reason != "" { + return snap.Scope.Reason + } + return "no readable namespace" +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./internal/api/ -run TestFleet -v` +Expected: PASS, all four tests + +- [ ] **Step 6: Commit** + +```bash +go build ./... && go test ./... +git add internal/api/fleet.go internal/api/fleet_test.go internal/config/ +git commit -m "feat(fleet): gather one app from every context + +Walks every kubeconfig context, connects lazily, and matches by the same +identity the promotion matrix uses. A cluster that will not answer becomes an +unreachable row naming the reason, never an omitted one, because a missing +row reads as 'not deployed there'. Deduping keys off the API server URL, +which config already normalizes." +``` + +--- + +### Task 6: Transport + +**Files:** +- Modify: `internal/api/server.go:73-103` (API interface), `:142` (routes), add `handleFleet` +- Modify: `internal/api/view.go` (re-export the view type for the OpenAPI-shaped surface) +- Test: `internal/api/server_test.go` + +**Interfaces:** +- Consumes: `Service.Fleet` from Task 5 +- Produces: `GET /api/fleet?app=&namespace=` returning `fleet.View` as JSON + +- [ ] **Step 1: Write the failing test** + +Add to `internal/api/server_test.go`, following the existing handler-test pattern in that file: + +```go +func TestFleetEndpointRequiresAnApp(t *testing.T) { + s := newTestServer(t) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/fleet", nil)) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400: the screen is about one app and needs its name", rec.Code) + } +} + +func TestFleetEndpointReturnsTheView(t *testing.T) { + s := newTestServer(t) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/fleet?app=checkout&namespace=shop", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var v fleet.View + if err := json.NewDecoder(rec.Body).Decode(&v); err != nil { + t.Fatalf("decode: %v", err) + } + if v.App != "checkout" { + t.Errorf("app = %q, want checkout", v.App) + } +} +``` + +Add `Fleet(app, namespace string) fleet.View` to the fake API the test file already uses. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/api/ -run TestFleetEndpoint -v` +Expected: FAIL, 404 rather than 400 + +- [ ] **Step 3: Add the interface method, the route, and the handler** + +In `internal/api/server.go`, add to the `API` interface after `Promotion()`: + +```go + // Fleet is one app across every cluster, for the question the matrix + // cannot phrase: are all of them on the newest version. + Fleet(app, namespace string) fleet.View +``` + +Register the route beside the others at `:142`: + +```go + mux.HandleFunc("/api/fleet", s.handleFleet) +``` + +Add the handler beside `handlePromotion`: + +```go +// handleFleet answers "is every cluster running the latest version". +func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) { + app := r.URL.Query().Get("app") + if app == "" { + writeError(w, http.StatusBadRequest, "app is required") + return + } + writeJSON(w, http.StatusOK, s.api.Fleet(app, r.URL.Query().Get("namespace"))) +} +``` + +Match `writeError` to whatever the file's existing error helper is named; reuse it rather than adding one. + +- [ ] **Step 4: Run the api suite** + +Run: `go test ./internal/api/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +go build ./... && go test ./... +git add internal/api/ +git commit -m "feat(fleet): serve the fleet view over HTTP + +GET /api/fleet?app=&namespace=. A request without an app is a 400 rather +than an empty screen, because the view is about one app by construction." +``` + +--- + +### Task 7: Author the fleet screen in the design project + +CLAUDE.md requires porting UI from the Claude Design project and forbids inventing it. The project's `screens/` has no fleet screen. This task creates it and gates the React work on human review. + +**Files:** +- Create, in design project `6546cea1-fa99-441a-97c6-4317b127d9a7`: `screens/fleet.html` +- Reference: `screens/promotion.html`, `tokens.css`, `components/tables-timeline.html` + +- [ ] **Step 1: Read the existing promotion screen** + +Use `DesignSync` with `method: get_file`, `path: screens/promotion.html`. Read `tokens.css` and `components/tables-timeline.html` the same way. Build the fleet screen from the row, status glyph, and environment-color patterns already there. Introduce no new color, type size, or spacing value. + +- [ ] **Step 2: Write the screen** + +Author `screens/fleet.html` locally with the first line carrying the card marker: + +```html + +``` + +Content: the topbar and page-head structure from `promotion.html`, then a table with columns CLUSTER, ENV, VERSION, HEALTH, and NOTE. Render every row state at least once so the card demonstrates all five: present on newest, behind, mutable-tag, unreachable, denied, absent. Include the header summary line reading `N clusters · M behind`. + +- [ ] **Step 3: Publish it** + +``` +DesignSync method=finalize_plan projectId=6546cea1-fa99-441a-97c6-4317b127d9a7 writes=["screens/fleet.html"] localDir= +DesignSync method=write_files planId= files=[{path: "screens/fleet.html", localPath: "screens/fleet.html"}] +``` + +- [ ] **Step 4: Human review gate** + +Stop here. Ask for review of the published screen before any React work begins. Building the UI first and backfilling the design inverts the rule the project runs on. + +- [ ] **Step 5: Commit nothing** + +This task touches no repository file. It ends with an approved screen in the design project. + +--- + +### Task 8: The fleet screen in React + +**Files:** +- Create: `web/src/FleetScreen.tsx` +- Modify: `web/src/api.ts` (types and client), `web/src/route.ts:4-42`, `web/src/App.tsx:53-126` +- Test: `web/src/route.test.ts` + +**Interfaces:** +- Consumes: `GET /api/fleet` from Task 6, the approved `screens/fleet.html` from Task 7 +- Produces: route `{ screen: "fleet"; app: string; namespace: string }` at hash `#fleet//`, `api.fleet(app, namespace)` + +- [ ] **Step 1: Write the failing route test** + +Add to `web/src/route.test.ts`: + +```ts +it("round-trips the fleet route", () => { + const r = { screen: "fleet", app: "checkout", namespace: "shop" } as const; + expect(parseRoute(routeHash(r))).toEqual(r); +}); + +it("falls back to apps when the fleet route is missing its app", () => { + expect(parseRoute("#fleet")).toEqual({ screen: "apps" }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && npx vitest run src/route.test.ts` +Expected: FAIL + +- [ ] **Step 3: Add the route** + +In `web/src/route.ts`, add to the `Route` union: + +```ts + | { screen: "fleet"; app: string; namespace: string } +``` + +Add to `parseRoute`, before the `apps` branch: + +```ts + if (parts[0] === "fleet" && parts.length >= 3) { + return { screen: "fleet", app: parts[1], namespace: parts[2] }; + } +``` + +Add to `routeHash`, before the promotion branch: + +```ts + if (r.screen === "fleet") return `#fleet/${enc(r.app)}/${enc(r.namespace)}`; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && npx vitest run src/route.test.ts` +Expected: PASS + +- [ ] **Step 5: Add the API types and client** + +In `web/src/api.ts`, after `PromotionView`: + +```ts +export interface FleetRow { + context: string; + clusterId: string; + env: string; + namespace?: string; + state: string; // present, absent, denied, unreachable, pending + reason?: string; + image?: string; + tag?: string; + digest?: string; + digestPending?: boolean; + health?: string; + ready?: string; + revisionAt?: string; + aliases?: string[]; + behind?: boolean; + mutableTag?: boolean; + note?: string; +} + +export interface FleetView { + app: string; + namespace: string; + rows: FleetRow[]; + newest?: string; + clusters: number; + present: number; + behind: number; + mutableTag?: boolean; +} +``` + +Add the client method beside `promotion()`, matching that method's existing fetch and error-handling shape: + +```ts + fleet: (app: string, namespace: string): Promise => + get(`/api/fleet?app=${encodeURIComponent(app)}&namespace=${encodeURIComponent(namespace)}`), +``` + +Read `api.ts` to confirm the helper is named `get`; reuse whatever the file already uses. + +- [ ] **Step 6: Write the screen** + +Create `web/src/FleetScreen.tsx`, porting the markup and class names from the approved `screens/fleet.html`. Follow `PromotionScreen.tsx` for the loading, error, and empty-state structure. + +```tsx +import { useEffect, useState } from "react"; +import { api, type FleetView } from "./api"; +import { Glyph } from "./Status"; +import { envToken } from "./health"; + +// Screen 7. Is every cluster running the latest version. +// +// The promotion matrix compares environments side by side. This compares the +// clusters inside and across them, which is what a team running prod in three +// regions asks and the matrix cannot phrase. +// +// Five states, never conflated. A cluster behind a VPN is not a cluster +// without the app, and rendering both blank would answer the screen's own +// question with a guess. + +export function FleetScreen({ app, namespace }: { app: string; namespace: string }) { + const [view, setView] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + setView(null); + setErr(null); + api.fleet(app, namespace) + .then((v) => { if (alive) setView(v); }) + .catch((e) => { if (alive) setErr(String(e.message ?? e)); }); + return () => { alive = false; }; + }, [app, namespace]); + + return ( + <> +
+
+ {app} + · + every cluster +
+ + {view && ( + + {view.clusters} clusters · {view.present} running it · {view.behind} behind + + )} +
+ +
+
+

{app}

+ {view?.newest ? `newest is ${view.newest}` : "asking every cluster"} +
+ + {err && ( +
+
Could not ask the clusters
+
{err}
+
+ )} + {!err && !view && ( +
asking every cluster…
+ )} + + {view?.mutableTag && ( +
+ + + One tag resolves to more than one digest. These clusters claim the same + version and are not running the same code. + +
+ )} + + {view && view.present === 0 && ( +
+
Not found in any of {view.clusters} clusters
+
+ Every cluster below answered. Check the name, or the namespace it lives in. +
+
+ )} + + {view && ( + + + + + + + + {view.rows.map((r) => ( + + + + + + + + ))} + +
ClusterEnvVersionHealth
+ {r.context} + {r.aliases && r.aliases.length > 0 && ( + also {r.aliases.join(", ")} + )} + {r.env} + {r.state === "present" ? (r.tag || "—") : "—"} + {r.digestPending && digest pending} + + {r.state === "present" + ? <> {r.ready} + : {r.state}} + {r.note}
+ )} +
+ + ); +} +``` + +Replace `className` values with the exact ones from the approved design screen wherever they differ. Confirm `Glyph` and `envToken` signatures against `Status.tsx` and `health.ts` before use. + +- [ ] **Step 7: Render it from App** + +In `web/src/App.tsx`, add a branch beside the promotion one: + +```tsx + {route.screen === "fleet" && ( + + )} +``` + +Update the `route.screen !== "promotion" && !current && ` guard at `App.tsx:126` so the fleet screen does not render the no-selection placeholder underneath itself: + +```tsx + {route.screen !== "promotion" && route.screen !== "fleet" && !current && } +``` + +Check the `App.tsx:53` route-narrowing branch and add `fleet` alongside `promotion` there too. + +- [ ] **Step 8: Run the web suite and build** + +Run: `cd web && npx vitest run && npm run build` +Expected: PASS, and a clean build + +- [ ] **Step 9: Commit** + +```bash +go build ./... && go test ./... +git add web/src/ +git commit -m "feat(fleet): screen 7, one app across every cluster + +Ported from screens/fleet.html in the design project. Five row states render +distinctly, an app found nowhere names the clusters it asked rather than +showing an empty window, and a tag resolving to two digests raises a banner +above everything else on the page." +``` + +--- + +### Task 9: The split cell in the promotion screen + +**Files:** +- Modify: `web/src/api.ts` (PromotionCell, PromotionView.envs, Summary), `web/src/PromotionScreen.tsx` + +**Interfaces:** +- Consumes: `promotion.StateSplit`, `Cell.Clusters`, `Summary.Split` from Task 2, the fleet route from Task 8 +- Produces: a split cell rendering its note and linking to the fleet screen + +- [ ] **Step 1: Extend the types** + +In `web/src/api.ts`: + +```ts +export interface PromotionCell { + env: string; + namespace?: string; + state: string; // same, behind, ahead, differs, digest-differs, absent, denied, split + clusters?: number; + // ... rest unchanged +} +``` + +```ts +export interface PromotionView { + envs: { name: string; risk: string; context?: string; contexts?: string[]; unreadable?: string[] }[]; + rows: PromotionRow[]; + summary: { apps: number; drifted: number; ahead: number; split: number }; + unreachable?: string[]; +} +``` + +- [ ] **Step 2: Render the split cell** + +In `web/src/PromotionScreen.tsx`, find the cell-rendering branch and add `split` beside `absent` and `denied`. A split cell shows no version, shows its note, carries the severe styling the screen already applies via `cell.severe`, and links to fleet: + +```tsx +{cell.state === "split" && ( + +)} +``` + +Thread `onOpenFleet` in from `App.tsx` the same way `onOpenApp` already is, wiring it to `go({ screen: "fleet", app, namespace })`. + +- [ ] **Step 3: Extend the header count** + +Update the summary line at `PromotionScreen.tsx:36` to name splits, since an environment whose clusters disagree is the reason to open the page: + +```tsx +{view.summary.apps} apps · {view.summary.drifted} with drift · {view.summary.ahead} ahead of upstream +{view.summary.split > 0 && ` · ${view.summary.split} split across clusters`} +``` + +- [ ] **Step 4: Run the web suite and build** + +Run: `cd web && npx vitest run && npm run build` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add web/src/ +git commit -m "feat(promotion): render the split cell and link it to fleet + +An environment whose clusters disagree shows the disagreement and offers the +screen that explains it, rather than a version the matrix never verified." +``` + +--- + +### Task 10: Reaching the screen + +**Files:** +- Modify: `web/src/commands.ts`, `web/src/AppDetailScreen.tsx` +- Test: `web/src/commands.test.ts` + +**Interfaces:** +- Consumes: the fleet route from Task 8 +- Produces: a palette command and an app-detail action, both opening fleet for the app in hand + +- [ ] **Step 1: Write the failing test** + +Read `web/src/commands.test.ts` for the existing command-list assertion shape, then add: + +```ts +it("offers fleet for the app in hand", () => { + const cmds = buildCommands({ ...baseContext, app: { context: "qa", namespace: "shop", workload: "checkout" } }); + const fleet = cmds.find((c) => c.id === "fleet"); + expect(fleet).toBeTruthy(); + expect(fleet!.title.toLowerCase()).toContain("cluster"); +}); + +it("hides fleet when no app is selected", () => { + const cmds = buildCommands(baseContext); + expect(cmds.find((c) => c.id === "fleet")).toBeUndefined(); +}); +``` + +Adapt the argument shape to whatever `buildCommands` actually takes. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && npx vitest run src/commands.test.ts` +Expected: FAIL + +- [ ] **Step 3: Add the command** + +In `web/src/commands.ts`, add a command matching the file's existing entry shape: + +```ts + if (ctx.app) { + out.push({ + id: "fleet", + title: `${ctx.app.workload} across every cluster`, + hint: "asks every context in your kubeconfig", + run: () => ctx.go({ screen: "fleet", app: ctx.app!.workload, namespace: ctx.app!.namespace }), + }); + } +``` + +- [ ] **Step 4: Add the app-detail entry point** + +In `web/src/AppDetailScreen.tsx`, add an action beside the existing ones opening the fleet route for the current app. Match the surrounding button markup exactly. + +- [ ] **Step 5: Run the web suite and build** + +Run: `cd web && npx vitest run && npm run build` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add web/src/ +git commit -m "feat(fleet): reach the screen from the palette and app detail + +The command appears only with an app in hand, because the view is about one +app and a palette entry that opens an empty one is a dead end." +``` + +--- + +### Task 11: Degraded-mode and browser coverage + +**Files:** +- Modify: `internal/api/degraded_test.go` +- Modify: the Playwright suite from #30 (locate with `git log --oneline --all -- '*spec.ts' | head`) + +**Interfaces:** +- Consumes: everything above + +- [ ] **Step 1: Write the degraded-mode test** + +Add to `internal/api/degraded_test.go`, following the file's existing construction: + +```go +func TestFleetFillsWithOneClusterUnreachableAndOneDenied(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-us-east": {env: "prod", unreachable: true}, + "prod-eu-west": {env: "prod", denied: true}, + }) + + v := s.Fleet("checkout", "shop") + + if v.Clusters != 3 { + t.Fatalf("clusters = %d, want 3: every cluster asked appears", v.Clusters) + } + if v.Newest != "v2.14.0" { + t.Errorf("newest = %q; the reachable cluster still answers", v.Newest) + } + states := map[string]string{} + for _, r := range v.Rows { + states[r.Context] = r.State + } + if states["prod-us-east"] == states["prod-eu-west"] { + t.Error("unreachable and denied render the same; they are different facts") + } +} +``` + +If the fake cluster helper has no `denied` field, add one that makes `clusters.Fetch` return a 403-shaped error. + +- [ ] **Step 2: Run it** + +Run: `go test ./internal/api/ -run TestFleetFills -v` +Expected: PASS once the helper supports `denied`, FAIL first if it does not + +- [ ] **Step 3: Add the Playwright screenshot** + +Add a spec navigating to `#fleet/checkout/shop` against the existing fixture server, waiting for the table, and taking a screenshot into the diff gate. Mirror the promotion spec in that suite exactly. + +- [ ] **Step 4: Run both suites** + +Run: `go test ./... && cd web && npx playwright test` +Expected: PASS. A new screenshot baseline is expected on first run; commit it. + +- [ ] **Step 5: Commit** + +```bash +git add internal/api/ web/ +git commit -m "test(fleet): degraded mode and a screenshot baseline + +One cluster unreachable, one denied, and the screen still fills. The two +render differently, because conflating them is the failure the five states +exist to prevent." +``` + +--- + +### Task 12: Documentation + +**Files:** +- Modify: `docs/03-product-spec.md`, `docs/04-multi-cluster.md:20-22`, `:331`, `:334`, `README.md` +- Create: `docs/guide/13-fleet.md` + +- [ ] **Step 1: Correct the aggregation promise** + +`docs/04-multi-cluster.md:20-22` currently reads that the promotion view "aggregates them with per-context detail on expansion." Replace with the shipped behavior: the cell collapses only on full agreement, renders a split otherwise, and the fleet screen carries per-cluster detail. + +Do the same for the table row at `:334`. + +- [ ] **Step 2: Correct the dedupe claim** + +`docs/04-multi-cluster.md:331` claims detection "by API server URL and cluster UID". Only the URL ships. Rewrite to say so and record why: no cluster UID exists in the codebase, and reading one needs a `kube-system` namespace permission kubeside does not request and a namespace-scoped developer would be refused. + +- [ ] **Step 3: Add the screen to the product spec** + +`docs/03-product-spec.md` commits to four screens while the code carries seven numbered views. Reconcile the count and add fleet as Screen 7 with its job: is every cluster running the latest version. + +- [ ] **Step 4: Write the guide page** + +Create `docs/guide/13-fleet.md` following the structure of `docs/guide/07-promotion.md`: what the screen answers, how to open it, what each state means, and the explicit warning that opening it wakes every context in the kubeconfig. + +- [ ] **Step 5: Commit** + +```bash +git add docs/ README.md +git commit -m "docs(fleet): record what shipped, and correct two claims that did not + +04-multi-cluster promised per-context expansion inside the promotion view; +what shipped is a split cell plus a dedicated screen. It also claimed dedupe +by API server URL and cluster UID; only the URL ships, because no cluster UID +exists here and reading one needs a permission a namespace-scoped developer +would be refused." +``` + +--- + +### Task 13: Argo CD enters the comparison + +Answers the first objection the Show HN thread raised. Standalone, no dependency on the fleet work. + +**Files:** +- Modify: `docs/01-problem.md:176`, `:184-193` + +- [ ] **Step 1: Add the reference row** + +Add Argo CD to the table at `docs/01-problem.md:184`, with current star and open-issue counts read from GitHub at the time of writing rather than estimated. + +- [ ] **Step 2: Write the distinction** + +Extend the prose near `:176`. The argument, in the maintainer's own words from the thread: Argo CD is 1:1 with Kubernetes and is the tool for operators. It shows sync state against git, which is a delivery question. kubeside shows the app a developer owns across the environments it runs in, which is a different question with a different reader. State that kubeside reads the `argocd.argoproj.io/instance` label as a grouping signal, per `docs/03-product-spec.md:44`, so the two coexist. + +- [ ] **Step 3: Commit** + +```bash +git add docs/01-problem.md +git commit -m "docs: answer the Argo CD question on the page, not in a comment thread + +It was the first objection raised on Show HN and the comparison never +mentioned it. Sync state against git is a delivery question; the app across +its environments is a different one." +``` + +--- + +### Task 14: Record the TUI decline + +**Files:** +- Modify: `docs/guide/12-troubleshooting.md` + +- [ ] **Step 1: Add the entry** + +Add a short section answering "Is there a TUI, like k9s?" with the reasoning already recorded at `docs/05-architecture.md:20`: the four gaps are layout problems, a monospace grid cannot render a timeline, a side-by-side config diff, or a promotion matrix, and k9s owns the terminal. Note that `--serve` mode, listed under Beyond v1 in `docs/06-roadmap.md:111`, is the answer for reaching kubeside without a local kubeconfig. + +- [ ] **Step 2: Commit** + +```bash +git add docs/guide/12-troubleshooting.md +git commit -m "docs: record why there is no TUI + +Asked on Show HN. 05-architecture already rejected a terminal UI and the +reasoning was not reachable from the docs a user reads." +``` + +--- + +## Self-review + +**Spec coverage.** Model, Task 4. Screen 7, Tasks 7 and 8. Data flow and wake policy, Task 5. Cluster dedupe, Tasks 4 and 5. Matrix fix, Tasks 2, 3, and 9. Five states, Tasks 4 and 11. App found nowhere, Tasks 4, 5, and 8. Same tag different digest, Tasks 2, 4, and 8. Testing, every task plus 11. Design constraint, Task 7. Argo CD, Task 13. TUI, Task 14. Doc changes, Task 12. + +**Two spec items with no task, resolved.** The spec mentions a 403 on the namespace list falling through the discovery chain and expired SSO reusing the inline prompt from #37. Both are existing behavior in `clusters.Fetch` and the connect path, reached unchanged by `Service.Fleet`, which surfaces whatever error they return as an `unreachable` or `denied` row. No new work, and Task 11 covers the denied case. + +**Type consistency.** `promotion.CompareTags`, `promotion.Identity`, and `promotion.StripEnvToken` are defined in Task 1 and consumed in Tasks 2, 4, and 5. `fleet.Placement`, `fleet.Row`, `fleet.View`, and the five state constants are defined in Task 4 and consumed in Tasks 5, 6, 8, and 11. `config.NormalizeURL` is defined in Task 5 and consumed there. `Env.Contexts` and `Env.Unreadable` are defined in Task 2, populated in Task 3, and read in Task 9. `Cell.Clusters` and `Summary.Split` are defined in Task 2 and read in Task 9. The fleet route shape is defined in Task 8 and consumed in Tasks 9 and 10. + +**Known gaps the implementer must close.** These are verified, not suspected. + +`serviceWithContexts`, `fakeCluster`, and `fakeApp` do not exist. The closest is `serviceWith` at `internal/api/tier_test.go:34`, which builds a `*Service` from one `*fake.Clientset` and a clock. Every task needing several contexts builds on it; Task 3 creates it and Tasks 5 and 11 reuse it. + +Corrected 2026-08-02, after Task 3 landed: `clusters.Fetch` has NO path returning a hard error for an RBAC refusal. Every list reads `if l, err := ...List(); err == nil`, and refusals are recorded in `Snapshot.Partial` while the call returns a nil error. So a `denied` fixture cannot be built by making Fetch fail. It is built by making the fake clientset refuse a list, which populates `Partial`. Task 5 reads `Partial` to tell denied from absent, and Task 11's fixture needs `refusedKinds` rather than a fabricated error. + +`newTestServer` at `internal/api/server_test.go:214` does exist and Task 6 uses it as written. + +Task 6's `writeError` and Task 8's `get` are stand-in names for helpers those files already carry under some name. Read `internal/api/server.go` and `web/src/api.ts` and use the real ones. diff --git a/docs/superpowers/specs/2026-08-02-fleet-design.md b/docs/superpowers/specs/2026-08-02-fleet-design.md new file mode 100644 index 0000000..cb53a6a --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-fleet-design.md @@ -0,0 +1,363 @@ +# Fleet: one app across every cluster + +Design spec, 2026-08-02. Source: the Show HN thread for kubeside, +[item 49139573](https://news.ycombinator.com/item?id=49139573), 25 points and 8 +comments. + +## Why + +Two people asked the same question independently in the thread. + +maxweisel runs singular apps deployed across multiple clusters and wants to see +"if all clusters are running the latest version." The maintainer confirmed the +same pain from the other side: clusters per team, plus uat, plus prod, and the +recurring need to check one service across all of them. + +A third commenter read the README tagline, "Your apps, across every cluster, +without thinking in ReplicaSets," and assumed the capability already shipped. + +It has not, and the current behavior is worse than absent. + +## The defect this uncovers + +`docs/04-multi-cluster.md:20` states one environment maps to one or more +contexts, and the promotion view "aggregates them with per-context detail on +expansion." Line 334 repeats it as a handled failure mode for an environment +holding 40 contexts. + +`internal/promotion/promotion.go:144` builds its per-environment map with +`byEnv[in.Env] = in`. Last write wins. A `prod` environment holding +`prod-us-east` and `prod-eu-west` renders one cell showing whichever context +arrived last, and discards the other silently. + +`promotion.Env` carries a single `Context string`. `promotion.Instance` carries +no context at all. The aggregation was specified and never built. + +The overwrite is the second place the data dies. The first is +`internal/api/service.go:1006`: + +```go +// Two contexts bound to one environment would produce two identical +// columns; the first one wins, which is what the binding is for. +if seen[env.Name] { + continue +} +``` + +A second context in an environment is skipped before it is connected or +fetched. Its assumption, that two contexts in one environment are identical, is +exactly what a team running prod in three regions disproves. Both layers get +fixed, and the service layer comes first, since the matrix cannot render data +nobody gathered. + +So the promotion view answers "is the fix in prod" with a confident version it +did not verify. Compare that against the rule at +`docs/04-multi-cluster.md:216`, where unauthorized renders distinctly from +absent because "I cannot see it" and "it is not there" are different facts. +Reporting one cluster's version as the environment's version breaks the same +principle in a way harder to notice. + +## Scope + +In: + +- A fleet screen: one app, one row per cluster. +- The promotion matrix refusing to collapse a disagreement. +- Argo CD entering the tool comparison. +- The TUI request declined in writing. + +Out: + +- `--serve` in-cluster mode. It stays under Beyond v1 in + `docs/06-roadmap.md:111`. The thread raised demand for it. Demand does not + shrink the OIDC and Helm work it requires. + +## Anti-persona check + +The fleet screen is app-scoped across clusters. It adds no node view, no +capacity data, no RBAC visualization, and no cost attribution. It clears A1, A2, +and A3 in `docs/02-personas.md:249`. It serves Rafael and Bruno, the same +personas the promotion view serves. + +## Model + +New package `internal/fleet`. One type carries one app in one cluster. + +```go +// Placement is one app as it exists in one cluster. +type Placement struct { + Context string // kubeconfig context name, personal and unstable + ClusterID string // API server URL, from kubeconfig.Context.Server + // Env is the resolved environment name, untouched: environments.Classify + // matches by keyword token and returns the name it was handed, so + // "prod-us-east" is high risk while spelling no tier a reader could + // compare against. EnvColor and EnvRisk carry that classification, so the + // UI maps a decision rather than re-deriving one from the string. + Env string + EnvColor string // red | amber | green | violet + EnvRisk string // low | medium | high, unknown collapsing to high + + Namespace string + + State string // present | absent | denied | unreachable | pending + Reason string + + Image string + Tag string + Digest string + // DigestPending marks a placement whose digest has not arrived. A missing + // digest never reads as a match. + DigestPending bool + + Health string + Ready string + RevisionAt string +} +``` + +The view derives two facts across the set: the newest tag among present +placements, and every placement behind it. "Are all clusters on the latest +version" reduces to one count. + +Tag ordering already exists and is tested in `promotion.compareTags`. Fleet +reuses it. Two comparators disagreeing about which version is newer would be the +worst defect this feature can carry, so `CompareTags` and the namespace-token +stripping inside `promotion.identity` get exported rather than reimplemented. + +Cross-cluster app matching reuses the existing rules: name plus namespace, +tolerating environment-suffix conventions, with `apps.match` in the config file +overriding. No new matching mechanism. + +## Screen 7: fleet + +One app, one row per cluster, environment demoted to a column. + +``` +checkout FLEET + +CLUSTER ENV VERSION HEALTH +qa-cluster qa v2.14.0 ok 2/2 +staging-eks stg v2.13.1 ok 3/3 +prod-us-east prod v2.13.1 ok 6/6 +prod-eu-west prod v2.12.0 ok 4/4 behind +prod-ap-south prod v2.13.1 ok 3/3 +team-b unclas no access pods list denied +``` + +An unmatched context renders as `unclassified` and carries prod guardrails, per +`docs/04-multi-cluster.md:70`. Unknown risk stays high risk. + +Sorting puts disagreement first, matching the promotion view's default. Nothing +sits ahead of the newest tag, so `behind` is the only relative state the screen +carries. The promotion view keeps `ahead` because it compares against the +environment to its left rather than against a maximum. + +## Data flow + +Opening the screen states intent to ask every cluster. Under the connection +lifecycle in `docs/04-multi-cluster.md:168` this is the one screen where waking +every context is the point rather than an accident. + +1. The server walks every context in the kubeconfig chain, honoring `KUBECONFIG`. +2. Each context connects lazily and resolves the app by the existing identity + rules. +3. Pod metadata for the digest is fetched on demand and memoized for the + session, the same mechanism the promotion view uses at + `docs/04-multi-cluster.md:225`. +4. Each context streams its own answer over the existing websocket. Rows land as + clusters reply. +5. A cluster behind a VPN never blocks the rest. +6. Idle disconnect resumes when the screen closes. + +Two contexts pointing at one cluster merge on `ClusterID` before rendering, per +the failure mode at `docs/04-multi-cluster.md:331`. Without the merge one +cluster appears twice and inflates the behind count, which turns the feature's +headline number into a lie. + +That line names two signals, API server URL and cluster UID. Only the first +ships here. `kubeconfig.Context.Server` already holds the URL and costs nothing +to read. No cluster UID exists anywhere in the codebase, and obtaining one means +reading the `kube-system` namespace UID, which needs a permission kubeside does +not currently request and which a namespace-scoped developer would be refused. +The URL alone covers the case the failure mode describes, two kubeconfig entries +aimed at one server. `docs/04-multi-cluster.md:331` gets corrected to say so. + +## The promotion matrix fix + +`promotion.Env` gains `Contexts []string`. `promotion.Instance` gains `Context`. +The overwrite at `promotion.go:144` becomes a grouping. + +Cell behavior for a multi-context environment: + +| Contexts in the environment | Cell | +| --- | --- | +| All present and agreeing on tag and digest | Collapses to one version, identical to today | +| Present, disagreeing on tag or digest | `StateSplit`, severe, naming the count, linking to fleet | +| Mixed present and absent | `StateSplit`. Deployed in two of three clusters is not deployed | +| Mixed readable and denied, at least one present | `StateSplit`. Partial visibility never collapses | +| No instances present, some or all contexts unread | `StateDenied`, naming what was read and what was not. Never `StateAbsent` | +| All contexts read, no instances present | `StateAbsent`, identical to today | + +The no-instances-present row was wrong until issue #75. `Service.Promotion` +builds its denied placeholders with an empty app name, the `in.App != ""` +filter strips them before `promotion.Build` sees them, and `resolve()` +received an empty group with no way to tell "nobody answered" from "everybody +answered and found nothing". It returned `StateAbsent` with the note "not +deployed here" in both cases. Only a banner said otherwise. + +The same path also mislabeled a partially readable environment: an app absent +from the clusters that answered rendered "not deployed here" while an unread +cluster might be running it. + +That conflated the two facts this whole feature exists to keep apart, and it +broke the hard rule against rendering an unknown window as an empty one. +`resolve()` now consults `Env.Unreadable` before deciding: any unread context +in the group, full or partial, renders `StateDenied` instead, with a note +naming how many clusters answered and how many did not. `StateSplit` was +considered for the partial case and rejected, because `StateSplit` means +instances were observed and disagreed; here the readable clusters unanimously +agree the app is absent; the only open question is a cluster nobody read, and +that is what `StateDenied` already means everywhere else in this matrix. Only +an environment where every context actually answered, and none of them have +the app, may still say "not deployed here". Fixed and closed. + +A split cell never becomes the upstream for the next column. This follows the +existing rule at `promotion.go:151`, where an unreadable environment is not an +agreement and so does not become the comparison basis. + +A single-context environment behaves exactly as it does today. That is the +regression guard protecting every closed issue in the promotion feature. + +## Error handling + +Five row states, none conflated. + +| State | Meaning | +| --- | --- | +| `present` | The app is deployed here and readable | +| `absent` | The cluster answered and the app is not there | +| `denied` | RBAC refused the read. Different from absent | +| `unreachable` | The cluster did not answer. Different from absent | +| `pending` | The request is in flight | + +`unreachable` is new to the codebase and carries the weight here. A cluster +behind a VPN is not a cluster missing the app, and rendering both blank answers +"is it everywhere" with a guess. + +Further cases: + +- The app found in no cluster renders the count of clusters asked and their + states. Never an empty window, per the hard rule in CLAUDE.md. A mistyped app + name reaches this case immediately. +- The same tag resolving to different digests across clusters is the loudest + state on the screen, ranked above behind. Two clusters claiming `v2.13.1` + while running different code is worse than one cluster openly sitting on + `v2.12.0`. +- A cluster nobody reached sorts above a cluster whose older version you can + see. The promotion matrix already decides this way: neither agreement nor a + defect can be claimed from clusters we never saw, so the version we could not + read might be worse than the old one we could. Both screens rank the same + facts by one rule. +- A present cluster whose image is pinned by digest has no tag to compare. It + reads as a version nobody could name, never as a cluster on the newest one, + and a tag that does not exist is never called mutable. +- A 403 on the namespace list falls through the existing discovery chain and + names the active mode on the row. +- Expired SSO credentials reuse the inline prompt shipped in #37 rather than + failing the row. + +## Testing + +Tests before implementation, per CLAUDE.md. + +`internal/fleet`: + +- Newest-tag derivation across present placements. +- Behind counting. +- `ClusterID` dedupe when two contexts reach one cluster. +- The five states staying distinct. +- A pending digest never reading as a match. +- Same tag with different digests ranking above behind. + +`internal/promotion`: + +- A single-context environment unchanged. This test comes first. +- Collapse on full agreement. +- Split on tag disagreement, on digest disagreement, on mixed presence, and on + partial denial. +- A split cell refusing to serve as upstream for the next column. + +Degraded mode, extending the suite from #31: one context unreachable, one +denied, one slow. The screen fills. + +Playwright screenshot gate for the new screen, extending the suite from #30. + +## The design constraint + +CLAUDE.md requires porting from the Claude Design project named `kubeside` and +forbids inventing new UI. That project's `screens/` holds apps, app-detail, +config, config-diff, logs, palette, promotion, and first-run-light. There is no +fleet screen. + +The first issue therefore authors `screens/fleet.html` in the design project, +built from `tokens.css` and the row patterns already in `screens/promotion.html`. +Human review of that screen gates the React work. Building the UI first and +backfilling the design would invert the rule the project runs on. + +## Issues + +Under a new `feature:fleet` label, dependencies before dependents. + +| # | Issue | Package | +| --- | --- | --- | +| 1 | Author `screens/fleet.html` in the design project, human-reviewed | design | +| 2 | Export `CompareTags` and identity matching from `promotion` | `internal/promotion` | +| 3 | `internal/fleet`: placement model and derivation | `internal/fleet` | +| 4 | Fleet query across contexts, lazy connect, `ClusterID` dedupe | `internal/fleet`, `internal/api` | +| 5 | Promotion matrix stops collapsing a disagreement | `internal/promotion`, `internal/api`, web | +| 6 | Fleet deltas over the existing websocket | `internal/api` | +| 7 | Screen 7: fleet, ported from issue 1 | web | +| 8 | Palette entry and app-detail entry point | web | +| 9 | Degraded-mode and Playwright coverage | tests | +| 10 | Docs: spec, multi-cluster, and a guide page | docs | + +Issue 2 blocks 3 and 5. Issue 3 blocks 4. Issue 1 blocks 7. Issues 2 and 5 touch +`internal/promotion` and stay sequential, per the parallelism rule in CLAUDE.md. + +Standalone, outside the label: + +| # | Issue | +| --- | --- | +| 11 | Argo CD joins the reference table in `docs/01-problem.md:184` | +| 12 | TUI declined in writing against `docs/05-architecture.md:20` | + +Issue 11 answers the first objection a reader raised, and answering it in a +comment thread does not scale. The prose separates GitOps sync state, which is +1:1 with the cluster, from the developer's app view. + +Issue 12 records the decline rather than dropping it. `docs/05-architecture.md:20` +already rejects a terminal UI: the four gaps are layout problems a monospace grid +cannot render, and k9s owns the terminal. The decline lands as a closed issue and +a `docs/guide/12-troubleshooting.md` entry, since the question will recur. + +## Doc changes this forces + +- `docs/03-product-spec.md` commits to four screens. The code already carries + seven numbered views: apps, app detail, config, diff as 3b, logs, promotion, + and the palette. Fleet becomes Screen 7 and the spec's count gets reconciled + with what shipped. +- `docs/04-multi-cluster.md:20` and `:334` promise aggregation with per-context + expansion inside the promotion view. That promise is replaced by the split + cell plus the fleet screen, and both lines get rewritten. +- `docs/04-multi-cluster.md:331` claims dedupe by API server URL and cluster + UID. Only the URL ships. The line gets corrected rather than left aspirational. +- `README.md:5` carries the tagline a commenter read as a shipped promise. It + stays once the feature ships. + +## Done condition + +A developer opens kubeside against a kubeconfig holding three prod clusters, +selects one app, and sees which clusters run the newest version and which do +not, with unreachable and denied clusters named rather than blank. The promotion +matrix, on the same data, refuses to show a single version for the environment +holding those three clusters. diff --git a/internal/api/degraded_test.go b/internal/api/degraded_test.go index ece20a6..63cd503 100644 --- a/internal/api/degraded_test.go +++ b/internal/api/degraded_test.go @@ -9,6 +9,7 @@ import ( "github.com/dynaum/kubeside/internal/clusters" "github.com/dynaum/kubeside/internal/config" + "github.com/dynaum/kubeside/internal/fleet" "github.com/dynaum/kubeside/internal/kubeconfig" "github.com/dynaum/kubeside/internal/timeline" appsv1 "k8s.io/api/apps/v1" @@ -419,6 +420,44 @@ func TestExecRefusesOnAnUnreachableCluster(t *testing.T) { } } +// The fleet screen exists precisely because a developer's clusters are never +// uniformly reachable: one is behind an expired VPN session, one refuses an +// RBAC-scoped read, and the rest just answer. All three sit on one screen at +// once, and the two kinds of silence must not look like each other. +func TestFleetFillsWithOneClusterUnreachableAndOneDenied(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-us-east": {env: "prod", unreachable: true}, + // refusedKinds is the genuine denial fixture: clusters.Fetch never + // errors on an RBAC refusal, it records the kind in Snapshot.Partial. + // Deployment can carry an app, unlike Pod or ReplicaSet, which the + // fleet sweep deliberately excludes from denial. + "prod-eu-west": {env: "prod", refusedKinds: []string{"Deployment"}}, + }) + + v := s.Fleet("checkout", "shop") + + if v.Clusters != 3 { + t.Fatalf("clusters = %d, want 3: every cluster asked appears", v.Clusters) + } + if v.Newest != "v2.14.0" { + t.Errorf("newest = %q; the reachable cluster still answers", v.Newest) + } + states := map[string]string{} + for _, r := range v.Rows { + states[r.Context] = r.State + } + if states["prod-us-east"] != fleet.StateUnreachable { + t.Errorf("prod-us-east state = %q, want %q", states["prod-us-east"], fleet.StateUnreachable) + } + if states["prod-eu-west"] != fleet.StateDenied { + t.Errorf("prod-eu-west state = %q, want %q", states["prod-eu-west"], fleet.StateDenied) + } + if states["prod-us-east"] == states["prod-eu-west"] { + t.Error("unreachable and denied render the same; they are different facts") + } +} + // denied answers a SelfSubjectAccessReview with a no, the way a cluster does // for a reader whose role does not cover the question. func denied(a ktesting.Action) runtime.Object { diff --git a/internal/api/fleet.go b/internal/api/fleet.go new file mode 100644 index 0000000..53398f1 --- /dev/null +++ b/internal/api/fleet.go @@ -0,0 +1,265 @@ +package api + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + + "github.com/dynaum/kubeside/internal/apps" + "github.com/dynaum/kubeside/internal/clusters" + "github.com/dynaum/kubeside/internal/config" + "github.com/dynaum/kubeside/internal/fleet" + "github.com/dynaum/kubeside/internal/promotion" +) + +// Fleet answers "is every cluster running the latest version" for one app. +// +// Opening this view states the intent to ask every cluster, so unlike the app +// list it deliberately wakes every context in the kubeconfig. +// +// Each context gets its own goroutine and its own copy of the timeout, the way +// cmd/kubeside's gather does, because the flag means "per-cluster connect and +// fetch". One budget spent serially would let a few slow clusters exhaust it +// and report every cluster behind them as unreachable — an invented unreachable +// on the one screen whose job is keeping unreachable honest, and one whose +// answer would change with ConnectOrder. +// +// Rows are still assembled once, when every context has answered or given up. +// Each goroutine writes to its own index rather than appending, so the view is +// built from the kubeconfig's order and never from the scheduler's. +// +// Every context is read at clusters.TierActive. This screen compares digests +// across clusters and the digest exists only in pod status, so a background +// read, which skips pods, would leave every digest permanently pending and make +// the worst thing this screen can find — one tag running as two different +// images — impossible to find. The tier is passed to the read rather than set +// on the connection: a sweep is not attention, and promoting ten contexts would +// keep them all out of the idle reaper. +func (s *Service) Fleet(app, namespace string) fleet.View { + order := s.mgr.ConnectOrder() + want := promotion.Identity(app, namespace) + + placements := make([]fleet.Placement, len(order)) + var wg sync.WaitGroup + for i, name := range order { + wg.Add(1) + go func(i int, name string) { + defer wg.Done() + + ctx, cancel := context.WithTimeout(context.Background(), s.timeout) + defer cancel() + + placements[i] = s.placeApp(ctx, name, app, namespace, want) + }(i, name) + } + wg.Wait() + + return fleet.Build(app, namespace, placements) +} + +// placeApp asks one cluster about one app and always returns a row. +func (s *Service) placeApp(ctx context.Context, name, app, namespace, want string) fleet.Placement { + kctx := s.cfg.MustGet(name) + env := s.conf.Environment(kctx) + + // ClusterID is read from the kubeconfig cluster entry before any + // connection attempt, on every path including the failure paths + // below. fleet.mergeByCluster keys on this to collapse two contexts + // aimed at one cluster; populating it from a successful connection + // instead would leave an unreachable duplicate with an empty + // ClusterID, which fails to merge with its reachable twin and + // inflates Clusters in exactly the case the merge exists to prevent. + // Colour and risk travel with the name on every path below, including the + // failure paths: a cluster that refused to answer is exactly the row a + // developer needs to place as prod at a glance, and the resolved name alone + // does not say so. + p := fleet.Placement{ + Context: name, + ClusterID: config.NormalizeURL(kctx.Server), + Env: env.Name, + EnvColor: env.Color, + EnvRisk: env.Risk.String(), + } + + if err := s.mgr.Connect(ctx, name); err != nil { + p.State = fleet.StateUnreachable + p.Reason = err.Error() + return p + } + client, ok := s.mgr.ClientFor(name) + if !ok { + p.State = fleet.StateUnreachable + p.Reason = "not connected" + return p + } + + snap, err := clusters.Fetch(ctx, client, kctx, clusters.FetchOptions{Tier: clusters.TierActive}) + if err != nil { + p.State = fleet.StateUnreachable + p.Reason = err.Error() + return p + } + + match, skipped, found := findApp(snap.Apps, app, namespace, want) + if !found { + p.State, p.Reason = missingBecause(snap, app, namespace, want) + return p + } + + in := instanceOf(env.Name, match) + p.State = fleet.StatePresent + p.Reason = skipped + p.Namespace = in.Namespace + p.Image = in.Image + p.Tag = in.Tag + p.Digest = in.Digest + p.DigestPending = in.Digest == "" + p.Health = in.Health + p.Ready = in.Ready + p.RevisionAt = in.RevisionAt + return p +} + +// findApp matches by the same identity the promotion matrix uses, so one app is +// one app on both screens. +// +// Two namespaces in one cluster can strip to a single identity — team-a-qa and +// team-a-prod side by side, the arrangement StripEnvToken exists for — and a +// row holds one placement, so one match has to win. The namespace actually +// asked for wins; failing that, the first in name order, so the answer never +// depends on the order the snapshot happened to arrive in. The runner-up is +// returned rather than dropped: a match that disappears without a word is the +// failure this screen exists to prevent. +func findApp(list []apps.App, app, namespace, want string) (match apps.App, skipped string, found bool) { + var matches []apps.App + for _, a := range list { + if promotion.Identity(a.Key.Name, a.Key.Namespace) == want { + matches = append(matches, a) + } + } + if len(matches) == 0 { + return apps.App{}, "", false + } + sort.SliceStable(matches, func(i, j int) bool { + exactI, exactJ := matches[i].Key.Namespace == namespace, matches[j].Key.Namespace == namespace + if exactI != exactJ { + return exactI + } + return matches[i].Key.Namespace < matches[j].Key.Namespace + }) + if len(matches) == 1 { + return matches[0], "", true + } + + others := make([]string, 0, len(matches)-1) + for _, a := range matches[1:] { + others = append(others, a.Key.Namespace) + } + return matches[0], fmt.Sprintf("%d namespaces here hold this app; showing %s, not %s", + len(matches), matches[0].Key.Namespace, strings.Join(others, ", ")), true +} + +// missingBecause tells "we were not allowed to look" apart from "it is not +// here", which is the conflation this screen exists to prevent. +// +// clusters.Fetch never returns an error for an RBAC refusal: it records the +// kinds it could not read in Snapshot.Partial and returns what it got. Two +// things, and only two, turn a miss into a denial: +// +// - A kind that could carry the app went unread. Snapshot.Partial unions two +// different facts, a refusal and a deliberate skip, and only the first +// implies denial — so the test is on the kinds apps.Group builds apps out +// of. A refused Pod or ReplicaSet list hides how healthy an app is, never +// whether it exists. +// - The cluster refused to enumerate its namespaces, so the read fell back to +// a single namespace. If the app lives anywhere else we never looked, which +// is denied. If the namespace we did read is the one asked about, we looked +// and found nothing, and absent is the honest answer. +// +// Service.Promotion is not a precedent for either line: it never inspects +// Partial, so a refused read reads as absent there. Reconciling the two screens +// is its own change, not this one. +func missingBecause(snap clusters.Snapshot, app, namespace, want string) (state, reason string) { + if refused := refusedCarriers(snap.Partial); len(refused) > 0 { + return fleet.StateDenied, refusedReason(refused, snap.Scope) + } + if !scopeCovers(snap.Scope, app, want) { + return fleet.StateDenied, unreadNamespaceReason(snap.Scope, namespace) + } + return fleet.StateAbsent, "" +} + +// appCarrying maps the kinds an app can be built out of to the resource names a +// Role spells them with. Pod and ReplicaSet are deliberately absent: a +// ReplicaSet whose Deployment went unread still names its owner, and a pod list +// says nothing about whether a workload exists. +var appCarrying = map[string]string{ + "Deployment": "deployments", + "StatefulSet": "statefulsets", + "DaemonSet": "daemonsets", + "CronJob": "cronjobs", + "Job": "jobs", +} + +// refusedCarriers keeps only the unread kinds that could have hidden the app. +func refusedCarriers(partial []string) []string { + out := make([]string, 0, len(partial)) + for _, kind := range partial { + if _, ok := appCarrying[kind]; ok { + out = append(out, kind) + } + } + return out +} + +// refusedReason names what to ask for, not only what failed. An RBAC message a +// developer can act on carries a verb, a resource and a scope, because that is +// the shape of a Role rule; a bare kind name is half of it and the wrong half. +func refusedReason(kinds []string, scope clusters.Scope) string { + resources := make([]string, 0, len(kinds)) + for _, kind := range kinds { + resources = append(resources, appCarrying[kind]) + } + return fmt.Sprintf("cannot read %s here; the role needs: list %s %s", + strings.Join(kinds, ", "), strings.Join(resources, ", "), scopePhrase(scope)) +} + +// unreadNamespaceReason explains a miss nobody could have seen: the cluster +// refused to list its namespaces, so only the fallback namespace was read, and +// the one asked about was never opened. +func unreadNamespaceReason(scope clusters.Scope, namespace string) string { + reason := scope.Reason + if reason == "" { + reason = "the namespace list was not readable" + } + return fmt.Sprintf("%s; only %s was read, so %s was never looked in", + reason, strings.Join(scope.Namespaces, ", "), namespace) +} + +// scopeCovers answers whether a read could have found the app at all. +// +// A cluster-wide read looked everywhere. A fallback read looked in one +// namespace, and that only counts if the app would have carried the identity we +// asked for had it been there. promotion.Identity is the rule findApp matches +// with, so asking it about the namespace we did read answers "would we have +// seen it" without guessing at environment suffixes. +func scopeCovers(scope clusters.Scope, app, want string) bool { + if scope.ClusterWide { + return true + } + for _, ns := range scope.Namespaces { + if promotion.Identity(app, ns) == want { + return true + } + } + return false +} + +func scopePhrase(scope clusters.Scope) string { + if scope.ClusterWide || len(scope.Namespaces) == 0 { + return "across the cluster" + } + return "in " + strings.Join(scope.Namespaces, ", ") +} diff --git a/internal/api/fleet_test.go b/internal/api/fleet_test.go new file mode 100644 index 0000000..2ce0788 --- /dev/null +++ b/internal/api/fleet_test.go @@ -0,0 +1,451 @@ +package api + +import ( + "strings" + "testing" + "time" + + "github.com/dynaum/kubeside/internal/fleet" +) + +// rowFor returns the row for one context, failing the test when the sweep lost +// it: every context asked gets a row, so a missing one is the bug itself. +func rowFor(t *testing.T, v fleet.View, ctx string) fleet.Row { + t.Helper() + for _, r := range v.Rows { + if r.Context == ctx { + return r + } + } + t.Fatalf("no row for %q; rows = %+v", ctx, v.Rows) + return fleet.Row{} +} + +// TestFleetAsksEveryContext proves the basic shape: one row per cluster, the +// newest tag named, and every cluster behind it counted. +func TestFleetAsksEveryContext(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-us-east": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.13.1"}}}, + "prod-eu-west": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.12.0"}}}, + }) + + v := s.Fleet("checkout", "shop") + + if v.Clusters != 3 { + t.Fatalf("clusters = %d, want 3", v.Clusters) + } + if v.Newest != "v2.14.0" { + t.Errorf("newest = %q, want v2.14.0", v.Newest) + } + if v.Behind != 2 { + t.Errorf("behind = %d, want 2", v.Behind) + } +} + +// TestFleetNamesAnUnreachableClusterRatherThanOmittingIt proves a cluster that +// never answered still gets a row: dropping it would read as "not deployed +// there", which is a different fact from "we could not ask". +func TestFleetNamesAnUnreachableClusterRatherThanOmittingIt(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-eu-west": {env: "prod", unreachable: true}, + }) + + v := s.Fleet("checkout", "shop") + + if v.Clusters != 2 { + t.Fatalf("clusters = %d, want 2: a cluster that did not answer is still a cluster we asked", v.Clusters) + } + var found bool + for _, r := range v.Rows { + if r.Context == "prod-eu-west" { + found = true + if r.State != fleet.StateUnreachable { + t.Errorf("state = %q, want %q", r.State, fleet.StateUnreachable) + } + if r.Note == "" { + t.Error("an unreachable cluster names why") + } + } + } + if !found { + t.Error("prod-eu-west was omitted; a blank row would read as 'not deployed there'") + } +} + +// TestFleetMatchesAcrossEnvSuffixedNamespaces proves the fleet view matches +// by the same identity the promotion matrix uses, so team-a-qa and +// team-a-prod are one app in two places rather than two different apps. +func TestFleetMatchesAcrossEnvSuffixedNamespaces(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "team-a-qa", image: "reg/checkout:v2.14.0"}}}, + "prod-cluster": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "team-a-prod", image: "reg/checkout:v2.13.1"}}}, + }) + + v := s.Fleet("checkout", "team-a-qa") + + if v.Present != 2 { + t.Errorf("present = %d, want 2: team-a-qa and team-a-prod are one app in two places", v.Present) + } +} + +// TestARefusedReadIsDeniedNotAbsent proves the line the brief draws: clusters.Fetch +// never returns an error for an RBAC refusal, it records the kind in +// Snapshot.Partial. An app missing from that snapshot was never looked for, so +// calling it absent would be a lie. +func TestARefusedReadIsDeniedNotAbsent(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-cluster": {env: "prod", refusedKinds: []string{"Deployment"}}, + }) + + v := s.Fleet("checkout", "shop") + + for _, r := range v.Rows { + if r.Context != "prod-cluster" { + continue + } + if r.State != fleet.StateDenied { + t.Fatalf("state = %q, want %q: we were not allowed to look, which is not the same as it not being there", r.State, fleet.StateDenied) + } + if !strings.Contains(r.Note, "Deployment") { + t.Errorf("note = %q, want it to name the kind that was refused", r.Note) + } + } +} + +// TestFleetFoundNowhereStillNamesWhatItAsked proves a typo'd or genuinely +// absent app still renders one row per cluster asked, never an empty screen: +// never render an unknown window as an empty one. +func TestFleetFoundNowhereStillNamesWhatItAsked(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa"}, + "prod-cluster": {env: "prod"}, + }) + + v := s.Fleet("typo", "shop") + + if v.Clusters != 2 { + t.Errorf("clusters = %d, want 2: never render an unknown window as an empty one", v.Clusters) + } + if len(v.Rows) != 2 { + t.Errorf("rows = %d, want 2", len(v.Rows)) + } + for _, r := range v.Rows { + if r.State != fleet.StateAbsent { + t.Errorf("%s state = %q, want %q: nobody refused anything, the app simply is not there", + r.Context, r.State, fleet.StateAbsent) + } + } +} + +// TestFleetMergesAnUnreachableDuplicateWithItsReachableTwin pins the invariant +// fleet.mergeByCluster documents: ClusterID must come from the kubeconfig +// cluster entry, read before any connection attempt, on every path including +// the failure paths. Populate it from a successful connection instead and an +// unreachable duplicate context arrives with an empty ClusterID, fails to +// merge with its reachable twin, and inflates Clusters in exactly the case +// the merge exists to prevent. +func TestFleetMergesAnUnreachableDuplicateWithItsReachableTwin(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-primary": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-stale": {env: "prod", unreachable: true}, + }) + // Both contexts in serviceWithContexts get distinct servers by + // construction ("https://" + name), so point the stale one at the same + // server the primary uses to simulate one cluster reachable under two + // kubeconfig entries. + for i := range s.cfg.Contexts { + if s.cfg.Contexts[i].Name == "prod-stale" { + s.cfg.Contexts[i].Server = "https://prod-primary" + } + } + + v := s.Fleet("checkout", "shop") + + if v.Clusters != 1 { + t.Fatalf("clusters = %d, want 1: an unreachable duplicate context on the same server must merge with its reachable twin", v.Clusters) + } + if len(v.Rows) != 1 { + t.Fatalf("rows = %d, want 1", len(v.Rows)) + } + if v.Rows[0].State != fleet.StatePresent { + t.Errorf("state = %q, want %q: the reachable twin's answer must win the merge", v.Rows[0].State, fleet.StatePresent) + } + found := false + for _, a := range v.Rows[0].Aliases { + if a == "prod-stale" { + found = true + } + } + if !found { + t.Errorf("aliases = %v, want prod-stale recorded as an alias of the merged row", v.Rows[0].Aliases) + } +} + +// TestADigestPinnedImageIsPresentWithNoTagToCompare proves an image pinned by +// digest is reported as running with its version openly unknown. Fabricating a +// tag would be an invention, and letting it match the newest would render the +// deepest unknown on the screen as the calmest row on it. +func TestADigestPinnedImageIsPresentWithNoTagToCompare(t *testing.T) { + const digest = "sha256:9f2c0a1b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8" + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-cluster": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout@" + digest}}}, + }) + + v := s.Fleet("checkout", "shop") + + r := rowFor(t, v, "prod-cluster") + if r.State != fleet.StatePresent { + t.Fatalf("state = %q, want %q: a pinned image is running, whatever it is called", r.State, fleet.StatePresent) + } + if r.Tag != "" { + t.Errorf("tag = %q, want empty: there is no tag in a digest-pinned image to invent one from", r.Tag) + } + if !strings.Contains(r.Note, "version unknown") { + t.Errorf("note = %q, want it to say the version is unknown", r.Note) + } + if r.Behind { + t.Error("a version nobody can name is not a version behind: that would invent a direction") + } + if v.Newest != "v2.14.0" { + t.Errorf("newest = %q, want v2.14.0: an untagged image must not become the yardstick", v.Newest) + } +} + +// TestAResolvedDigestLeavesNothingUnverified proves the digest actually arrives. +// It lives only in pod status, so a sweep that skips pods reports every row's +// digest as pending forever, and one tag running as two different images — the +// worst thing this screen can find — becomes impossible to find. +func TestAResolvedDigestLeavesNothingUnverified(t *testing.T) { + const digest = "sha256:11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff" + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa", apps: []fakeApp{{ + name: "checkout", ns: "shop", + image: "reg/checkout:v2.14.0", + imageID: "reg/checkout@" + digest, + }}}, + }) + + v := s.Fleet("checkout", "shop") + + r := rowFor(t, v, "qa-cluster") + if r.Digest != digest { + t.Fatalf("digest = %q, want %q: the pod resolved it and the row must carry it", r.Digest, digest) + } + if r.DigestPending { + t.Error("digestPending = true for a digest that arrived") + } + if v.DigestUnverified != 0 { + t.Errorf("digestUnverified = %d, want 0: nothing is in flight once the pod answered", v.DigestUnverified) + } +} + +// TestOneTagRunningTwoDigestsIsFound is the top of the severity ladder and it +// is only reachable if digests arrive. Two clusters claiming one version while +// running different code is a defect, not a schedule. +func TestOneTagRunningTwoDigestsIsFound(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-us-east": {env: "prod", apps: []fakeApp{{ + name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0", + imageID: "reg/checkout@sha256:aaaa000000000000000000000000000000000000000000000000000000000000", + }}}, + "prod-eu-west": {env: "prod", apps: []fakeApp{{ + name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0", + imageID: "reg/checkout@sha256:bbbb000000000000000000000000000000000000000000000000000000000000", + }}}, + }) + + v := s.Fleet("checkout", "shop") + + if !v.MutableTag { + t.Fatal("mutableTag = false: one tag resolved to two digests and the screen said nothing") + } +} + +// TestADeniedRowNamesOnlyKindsThatWereRefused proves the note is a fact rather +// than an artefact of how much the sweep chose to read. Snapshot.Partial unions +// refusals with deliberate skips, and a row that names a kind nobody refused +// sends a developer to ask for permission they already have. +func TestADeniedRowNamesOnlyKindsThatWereRefused(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-cluster": {env: "qa"}, + "prod-cluster": {env: "prod", refusedKinds: []string{"CronJob"}}, + }) + + v := s.Fleet("checkout", "shop") + + denied := rowFor(t, v, "prod-cluster") + if denied.State != fleet.StateDenied { + t.Fatalf("state = %q, want %q", denied.State, fleet.StateDenied) + } + if !strings.Contains(denied.Note, "CronJob") { + t.Errorf("note = %q, want it to name CronJob, the kind that was refused", denied.Note) + } + for _, kind := range []string{"Pod", "ReplicaSet", "Deployment"} { + if strings.Contains(denied.Note, kind) { + t.Errorf("note = %q names %s, which nobody refused", denied.Note, kind) + } + } + // Verb, resource and scope: what a Role rule is made of. A bare kind name + // is half an RBAC message and the wrong half. + if !strings.Contains(denied.Note, "list cronjobs") { + t.Errorf("note = %q, want the verb and resource a developer would ask for", denied.Note) + } + + clean := rowFor(t, v, "qa-cluster") + if clean.State != fleet.StateAbsent { + t.Fatalf("state = %q, want %q: nothing was refused here", clean.State, fleet.StateAbsent) + } + if strings.Contains(clean.Note, "Pod") { + t.Errorf("note = %q mentions Pod, which was never asked for", clean.Note) + } +} + +// TestANamespaceWeNeverOpenedIsDeniedNotAbsent covers the case a cluster that +// refuses to list its namespaces produces: the read falls back to one namespace, +// and an app living anywhere else was never looked for. Calling that "not +// deployed here" is a claim nobody earned. +func TestANamespaceWeNeverOpenedIsDeniedNotAbsent(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-cluster": {env: "prod", refusedKinds: []string{"Namespace"}}, + }) + + v := s.Fleet("checkout", "shop") + + r := rowFor(t, v, "prod-cluster") + if r.State != fleet.StateDenied { + t.Fatalf("state = %q, want %q: only the fallback namespace was read, and shop was not it", + r.State, fleet.StateDenied) + } + if !strings.Contains(r.Note, "namespace list forbidden") { + t.Errorf("note = %q, want the refusal that caused the fallback", r.Note) + } + if !strings.Contains(r.Note, "shop") { + t.Errorf("note = %q, want it to name the namespace nobody opened", r.Note) + } +} + +// TestTheFallbackNamespaceWeDidReadIsAbsentNotDenied is the other half: a +// namespace-scoped read that covered exactly the namespace asked about did look, +// and found nothing. Denied there would be the same conflation in reverse. +func TestTheFallbackNamespaceWeDidReadIsAbsentNotDenied(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-cluster": {env: "prod", refusedKinds: []string{"Namespace"}}, + }) + + v := s.Fleet("checkout", "default") + + r := rowFor(t, v, "prod-cluster") + if r.State != fleet.StateAbsent { + t.Fatalf("state = %q, want %q: default is the namespace the fallback read", r.State, fleet.StateAbsent) + } +} + +// TestTwoNamespacesInOneClusterSayWhichOneWon covers the arrangement +// StripEnvToken exists for, colocated in one cluster: team-a-qa and +// team-a-prod are one identity, a row holds one placement, and the match that +// did not win must not vanish without a word. +func TestTwoNamespacesInOneClusterSayWhichOneWon(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "shared-cluster": {env: "prod", apps: []fakeApp{ + {name: "checkout", ns: "team-a-prod", image: "reg/checkout:v2.13.1"}, + {name: "checkout", ns: "team-a-qa", image: "reg/checkout:v2.14.0"}, + }}, + }) + + v := s.Fleet("checkout", "team-a-qa") + + r := rowFor(t, v, "shared-cluster") + if r.Namespace != "team-a-qa" { + t.Errorf("namespace = %q, want team-a-qa: the namespace asked for wins the tie", r.Namespace) + } + if !strings.Contains(r.Note, "team-a-prod") { + t.Errorf("note = %q, want it to name the match that was not shown", r.Note) + } +} + +// TestEveryRowCarriesTheEnvironmentColour pins the fact the UI cannot infer. +// +// environments.Classify matches by keyword token and returns the name it was +// given, so "production", "staging" and "sandbox" are red, amber and green +// while being none of the three tier strings a screen could compare against. +// A UI left to guess from the name alone paints a prod cluster violet and calls +// it unclassified, which is a false statement about the safety property +// docs/04-multi-cluster.md makes environment colour carry. +// +// Every state path goes through one placement literal, so a cluster that never +// answered and a cluster that refused to be read keep their colour too. Those +// are the rows a developer most needs to place. +func TestEveryRowCarriesTheEnvironmentColour(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-us-east": {env: "production", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "staging-eks": {env: "staging", unreachable: true}, + "qa-cluster": {env: "sandbox", refusedKinds: []string{"Deployment"}}, + "dr-frankfurt": {env: "dr-frankfurt"}, + }) + + v := s.Fleet("checkout", "shop") + + for _, want := range []struct { + ctx, state, env, color, risk string + }{ + {"prod-us-east", fleet.StatePresent, "production", "red", "high"}, + {"staging-eks", fleet.StateUnreachable, "staging", "amber", "medium"}, + {"qa-cluster", fleet.StateDenied, "sandbox", "green", "low"}, + {"dr-frankfurt", fleet.StateAbsent, "dr-frankfurt", "violet", "high"}, + } { + r := rowFor(t, v, want.ctx) + if r.State != want.state { + t.Errorf("%s state = %q, want %q", want.ctx, r.State, want.state) + } + if r.Env != want.env { + t.Errorf("%s env = %q, want %q: the resolved name travels untouched", want.ctx, r.Env, want.env) + } + if r.EnvColor != want.color { + t.Errorf("%s envColor = %q, want %q: the UI must not re-derive the tier from the name", + want.ctx, r.EnvColor, want.color) + } + if r.EnvRisk != want.risk { + t.Errorf("%s envRisk = %q, want %q", want.ctx, r.EnvRisk, want.risk) + } + } +} + +// TestFleetSpendsTheTimeoutPerClusterNotAcrossThem pins what the flag means. +// Four clusters that each take a moment must cost one moment, not four: a +// serial sweep on one shared budget hands the last clusters an expired deadline +// and reports them unreachable, which invents the exact fact this screen exists +// to keep honest, and does it differently depending on ConnectOrder. +func TestFleetSpendsTheTimeoutPerClusterNotAcrossThem(t *testing.T) { + const delay = 200 * time.Millisecond + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-a": {env: "prod", slow: delay, apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-b": {env: "prod", slow: delay, apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-c": {env: "prod", slow: delay, apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + "prod-d": {env: "prod", slow: delay, apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.14.0"}}}, + }) + // Under the serial sweep this budget expires two clusters in; under a + // per-cluster one every cluster gets the whole of it. + s.timeout = 3 * delay + + start := time.Now() + v := s.Fleet("checkout", "shop") + elapsed := time.Since(start) + + if elapsed > s.timeout { + t.Errorf("sweep took %v with a %v per-cluster timeout: four slow clusters spent one budget in turn", + elapsed, s.timeout) + } + if v.Present != 4 { + t.Errorf("present = %d, want 4", v.Present) + } + for _, r := range v.Rows { + if r.State == fleet.StateUnreachable { + t.Errorf("%s = unreachable (%q): a cluster that answered was reported as one that did not", + r.Context, r.Note) + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 3887633..b869ac5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -33,6 +33,7 @@ import ( "github.com/coder/websocket" "github.com/coder/websocket/wsjson" "github.com/dynaum/kubeside/internal/exec" + "github.com/dynaum/kubeside/internal/fleet" "github.com/dynaum/kubeside/internal/forward" "github.com/dynaum/kubeside/internal/logs" ) @@ -94,6 +95,9 @@ type API interface { StopForward(id string) error // Promotion compares every app across every environment. Promotion() PromotionView + // Fleet is one app across every cluster, for the question the matrix + // cannot phrase: are all of them on the newest version. + Fleet(app, namespace string) fleet.View // Capabilities resolves what this reader may do in one namespace, so every // control renders disabled-with-a-reason rather than hidden. Capabilities(contextName, namespace string) CapabilitiesView @@ -140,6 +144,7 @@ func New(a API, ui http.Handler, opts ...Option) (*Server, error) { mux.HandleFunc("/api/diff", s.handleDiff) mux.HandleFunc("/api/forwards", s.handleForwards) mux.HandleFunc("/api/promotion", s.handlePromotion) + mux.HandleFunc("/api/fleet", s.handleFleet) mux.HandleFunc("/api/can", s.handleCapabilities) mux.HandleFunc("/api/gate", s.handleGate) mux.HandleFunc("/api/exec", s.handleExec) @@ -565,6 +570,21 @@ func (s *Server) handlePromotion(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.api.Promotion()) } +// handleFleet answers "is every cluster running the latest version". The app +// is required because the view is about one app by construction: an app-less +// request would have nothing to compare across clusters, and an empty screen +// would read as "this app runs nowhere" rather than as an unaddressed +// question. Namespace is optional; Service.Fleet accepts "" and matches +// against whichever namespace each cluster actually holds the app in. +func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) { + app := r.URL.Query().Get("app") + if app == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "app is required"}) + return + } + writeJSON(w, http.StatusOK, s.api.Fleet(app, r.URL.Query().Get("namespace"))) +} + func (s *Server) handleForwards(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 5e7e410..30e8107 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/dynaum/kubeside/internal/exec" + "github.com/dynaum/kubeside/internal/fleet" "github.com/dynaum/kubeside/internal/forward" "github.com/dynaum/kubeside/internal/guard" "github.com/dynaum/kubeside/internal/logs" @@ -92,6 +93,13 @@ func (s stubAPI) Promotion() PromotionView { return PromotionView{Envs: envs, Rows: rows, Summary: promotion.Summarize(rows)} } +func (s stubAPI) Fleet(app, namespace string) fleet.View { + return fleet.View{App: app, Namespace: namespace, Clusters: 2, Present: 2, Newest: "v2", Rows: []fleet.Row{ + {Placement: fleet.Placement{Context: "qa", State: fleet.StatePresent, Tag: "v2"}}, + {Placement: fleet.Placement{Context: "prod", State: fleet.StatePresent, Tag: "v2"}}, + }} +} + func (s stubAPI) StartForward(req ForwardRequest) (forward.Forward, error) { if req.RemotePort == 0 { return forward.Forward{}, errors.New("a container port is required") @@ -1039,3 +1047,59 @@ func TestAWrongCookieIsRefused(t *testing.T) { t.Fatalf("code = %d, want 401", rec.Code) } } + +// TestFleetEndpointRequiresAnApp proves a request naming no app is a 400, not +// an empty screen: the view is about one app by construction, and an empty +// fleet screen would read as "this app runs nowhere", the exact conflation +// this feature exists to prevent. +func TestFleetEndpointRequiresAnApp(t *testing.T) { + s := newTestServer(t) + rec := do(t, s, "GET", "/api/fleet?"+tokenParam+"="+s.Token(), nil) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400: the screen is about one app and needs its name", rec.Code) + } +} + +func TestFleetEndpointReturnsTheView(t *testing.T) { + s := newTestServer(t) + rec := do(t, s, "GET", "/api/fleet?app=checkout&namespace=shop&"+tokenParam+"="+s.Token(), nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var v fleet.View + if err := json.NewDecoder(rec.Body).Decode(&v); err != nil { + t.Fatalf("decode: %v", err) + } + if v.App != "checkout" { + t.Errorf("app = %q, want checkout", v.App) + } + if v.Namespace != "shop" { + t.Errorf("namespace = %q, want shop", v.Namespace) + } +} + +// TestFleetEndpointNamespaceIsOptional proves the endpoint does not reject an +// absent namespace: Service.Fleet accepts "" and answers across whichever +// namespace each cluster holds the app in, so the transport must not add a +// requirement the service itself does not have. +func TestFleetEndpointNamespaceIsOptional(t *testing.T) { + s := newTestServer(t) + rec := do(t, s, "GET", "/api/fleet?app=checkout&"+tokenParam+"="+s.Token(), nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var v fleet.View + if err := json.NewDecoder(rec.Body).Decode(&v); err != nil { + t.Fatalf("decode: %v", err) + } + if v.Namespace != "" { + t.Errorf("namespace = %q, want empty when not asked for", v.Namespace) + } +} + +func TestFleetEndpointNeedsTheToken(t *testing.T) { + s := newTestServer(t) + if got := do(t, s, "GET", "/api/fleet?app=checkout", nil).Code; got != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", got) + } +} diff --git a/internal/api/service.go b/internal/api/service.go index 0234bbc..8069671 100644 --- a/internal/api/service.go +++ b/internal/api/service.go @@ -989,52 +989,117 @@ func (s *Service) Close() { s.forwards.StopAll() } // "is the fix in prod yet" without reading prod. An environment that will not // connect becomes a column of unreadable cells naming the reason, never an // empty column that reads as "nothing is deployed there". +// +// Every context bound to an environment is read, not the first. A team running +// prod in three regions has three answers, and reporting one of them as the +// environment's version is the defect this view exists to prevent. func (s *Service) Promotion() PromotionView { ctx, cancel := context.WithTimeout(context.Background(), s.timeout) defer cancel() - var envs []promotion.Env + type accum struct { + risk string + contexts []string + unreadable []string + } + byEnv := map[string]*accum{} + var order []string + var instances []promotion.Instance - var unreachable []string - seen := map[string]bool{} for _, name := range s.mgr.ConnectOrder() { kctx := s.cfg.MustGet(name) env := s.conf.Environment(kctx) - // Two contexts bound to one environment would produce two identical - // columns; the first one wins, which is what the binding is for. - if seen[env.Name] { - continue - } - seen[env.Name] = true - envs = append(envs, promotion.Env{Name: env.Name, Risk: env.Risk.String(), Context: name}) - reason := "" + acc, ok := byEnv[env.Name] + if !ok { + // The first context seen for this environment sets its risk; every + // context bound to the same environment name carries the same + // config-derived risk today, so which one "wins" has no effect + // unless a config-bound name later collides with a + // Classify-derived one. + acc = &accum{risk: env.Risk.String()} + byEnv[env.Name] = acc + order = append(order, env.Name) + } + acc.contexts = append(acc.contexts, name) + + // A failed Connect must short-circuit here, before ClientFor is ever + // consulted. Connect does not clear a prior session on failure, so a + // context that connected once and later lost its token, an expired + // credential, a VPN drop, an open breaker, still returns a client + // from ClientFor. Trusting that client would read a stale connection + // as fully readable and let the matrix report "not deployed here" + // for an environment it never actually verified this round (issue + // #82). Service.Fleet already avoids this by checking the error the + // same way. if err := s.mgr.Connect(ctx, name); err != nil { - reason = err.Error() + acc.unreadable = append(acc.unreadable, name) + instances = append(instances, promotion.Instance{ + Env: env.Name, Context: name, App: "", Denied: true, + DeniedReason: err.Error(), + }) + continue } + client, ok := s.mgr.ClientFor(name) if !ok { - unreachable = append(unreachable, env.Name) + acc.unreadable = append(acc.unreadable, name) instances = append(instances, promotion.Instance{ - Env: env.Name, App: "", Denied: true, - DeniedReason: orElseString(reason, "not connected"), + Env: env.Name, Context: name, App: "", Denied: true, + DeniedReason: "not connected", }) continue } snap, err := clusters.Fetch(ctx, client, kctx, clusters.FetchOptions{Tier: s.mgr.Tier(name)}) if err != nil { - unreachable = append(unreachable, env.Name) + acc.unreadable = append(acc.unreadable, name) continue } + + // clusters.Fetch never errors on an RBAC refusal: a forbidden list + // lands in snap.Partial with a nil error, and the apps it would have + // carried are simply missing from snap.Apps, indistinguishable from + // apps that genuinely do not exist here. So this context is only + // partially trustworthy for the environment it belongs to. Marking + // it unreadable, alongside whatever instances this read DID find, + // lets resolve() tell "not deployed here" apart from "might be here, + // we were refused" the same way it already does for a context that + // never answered at all (issue #77). The namespace-scoped precision + // fleet.go has for one app does not translate here: Promotion + // compares every app in bulk, and flagging every namespace-scoped + // RBAC context (the common case) as unreadable would bury the signal + // this view exists to surface. + if refused := refusedCarriers(snap.Partial); len(refused) > 0 { + acc.unreadable = append(acc.unreadable, name) + } for _, a := range snap.Apps { - instances = append(instances, instanceOf(env.Name, a)) + in := instanceOf(env.Name, a) + in.Context = name + instances = append(instances, in) } } - // Instances with no app name are placeholders for an environment nobody - // could read; they gave us the column and nothing else. + // Unreachable names only the environments where every bound context + // failed. A split cell and Env.Unreadable already tell the story for an + // environment that is merely partly degraded; adding it here too would + // have the banner call a column unknown while it renders real data. + envs := make([]promotion.Env, 0, len(order)) + var unreachable []string + for _, name := range order { + acc := byEnv[name] + envs = append(envs, promotion.Env{ + Name: name, Risk: acc.risk, + Context: acc.contexts[0], Contexts: acc.contexts, Unreadable: acc.unreadable, + }) + if len(acc.unreadable) > 0 && len(acc.unreadable) == len(acc.contexts) { + unreachable = append(unreachable, name) + } + } + + // Instances with no app name are placeholders for a cluster nobody could + // read; they marked the environment unreadable and nothing else. real := instances[:0] for _, in := range instances { if in.App != "" { @@ -1043,7 +1108,25 @@ func (s *Service) Promotion() PromotionView { } rows := promotion.Build(envs, real) - return PromotionView{Envs: envs, Rows: rows, Summary: promotion.Summarize(rows), Unreachable: unreachable} + return PromotionView{Envs: envs, Rows: rows, Summary: promotion.Summarize(rows), Unreachable: dedupeStrings(unreachable)} +} + +// dedupeStrings keeps the first occurrence of each value. An environment with +// three unreachable clusters is one unreachable environment in the banner. +func dedupeStrings(in []string) []string { + if len(in) == 0 { + return nil + } + seen := map[string]bool{} + out := make([]string, 0, len(in)) + for _, v := range in { + if seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + return out } // instanceOf reads one app's version from the snapshot. @@ -1099,13 +1182,6 @@ func mustTime(v string) time.Time { return t } -func orElseString(v, fallback string) string { - if v == "" { - return fallback - } - return v -} - // The actions the UI renders controls for. Named here so one list drives both // the permission check and the buttons, and a control cannot quietly appear // without a permission behind it. diff --git a/internal/api/service_test.go b/internal/api/service_test.go index 749c483..763e594 100644 --- a/internal/api/service_test.go +++ b/internal/api/service_test.go @@ -1,8 +1,10 @@ package api import ( + "fmt" "os" "path/filepath" + "sort" "strings" "testing" "time" @@ -10,10 +12,374 @@ import ( "github.com/dynaum/kubeside/internal/clusters" "github.com/dynaum/kubeside/internal/config" "github.com/dynaum/kubeside/internal/kubeconfig" + "github.com/dynaum/kubeside/internal/promotion" "github.com/dynaum/kubeside/internal/session" "github.com/dynaum/kubeside/internal/timeline" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" ) +// fakeApp is one workload to seed into a fake cluster: a Deployment running +// one image, which is all the promotion matrix needs to compare versions. +type fakeApp struct { + name, ns, image string + // imageID seeds a running pod owned by the Deployment, reporting this as + // the image it actually resolved to. The digest lives in pod status and + // nowhere else, so a fixture without one can only ever produce a placement + // whose digest is still pending. + imageID string +} + +// fakeCluster is one kubeconfig context's worth of fixture: which environment +// it binds to, what it runs, and whether it can be reached or read at all. +type fakeCluster struct { + env string + apps []fakeApp + // unreachable fails the connection itself, the way an expired token, a + // refused credential, or an off-VPN cluster does. clusters.Fetch has no + // path that returns a hard error, so every connect-time failure takes + // this one branch in Service.Promotion() and Service.Fleet() regardless + // of which of those causes produced it; the distinction between them is + // not one either method can observe. + unreachable bool + // refusedKinds fails List for exactly these kinds, once connected, the + // way a namespace-scoped role refuses a cluster-scoped read. Unlike + // unreachable, clusters.Fetch has no error path for this: the refused + // kinds land in Snapshot.Partial and Fetch returns a nil error, which is + // what lets a caller tell "denied" apart from "absent". + refusedKinds []string + // slow delays the connection by this much, the way a cluster behind a VPN + // that is off does. It is what separates a sweep that spends one budget + // serially from one that gives every cluster its own. + slow time.Duration +} + +// serviceWithContexts builds a Service over several kubeconfig contexts, each +// bound to an environment and backed by its own fake clientset. It is the +// multi-cluster sibling of serviceWith: where that helper proves one context +// works, this one proves several contexts bound to the same environment are +// all read, not just the first. +func serviceWithContexts(t *testing.T, byContext map[string]fakeCluster) *Service { + t.Helper() + + names := make([]string, 0, len(byContext)) + for name := range byContext { + names = append(names, name) + } + sort.Strings(names) + if len(names) == 0 { + t.Fatal("serviceWithContexts needs at least one context") + } + + var envOrder []string + envContexts := map[string][]string{} + contexts := make([]kubeconfig.Context, 0, len(names)) + for _, name := range names { + fc := byContext[name] + if _, ok := envContexts[fc.env]; !ok { + envOrder = append(envOrder, fc.env) + } + envContexts[fc.env] = append(envContexts[fc.env], name) + contexts = append(contexts, kubeconfig.Context{ + Name: name, IsCurrent: name == names[0], Server: "https://" + name, + }) + } + + var b strings.Builder + b.WriteString("environments:\n") + for _, env := range envOrder { + b.WriteString(" - name: " + env + "\n") + b.WriteString(" contexts: [" + strings.Join(envContexts[env], ", ") + "]\n") + } + + p := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + conf, err := config.Load(config.Options{Path: p}) + if err != nil { + t.Fatalf("config.Load: %v", err) + } + + connector := clusters.KubeConnector{ + NewClient: func(kctx kubeconfig.Context, _ kubeconfig.Options) (kubernetes.Interface, error) { + fc := byContext[kctx.Name] + if fc.slow > 0 { + time.Sleep(fc.slow) + } + if fc.unreachable { + return nil, fmt.Errorf("dial %s: connection refused", kctx.Name) + } + return fakeClientFor(fc.apps, fc.refusedKinds), nil + }, + } + + kcfg := &kubeconfig.Config{Current: names[0], Contexts: contexts} + mgr := clusters.New(kcfg, connector, clusters.Options{}) + t.Cleanup(mgr.Close) + return NewService(kcfg, mgr, kubeconfig.Options{}, conf, time.Second) +} + +// fakeClientFor builds a fake clientset with one Deployment per app, mirroring +// clusterWithApp: a selector matching its own pod template is enough for the +// grouping engine to recognize it, and no pod is needed for a version compare. +// +// refusedKinds makes List fail for exactly those kinds with the same +// apierrors.NewForbidden a namespace-scoped role produces, so a caller can +// build the "connected, but this kind is unreadable" fixture that a plain +// unreachable context cannot: clusters.Fetch turns this into an entry in +// Snapshot.Partial rather than an error. +func fakeClientFor(apps []fakeApp, refusedKinds []string) *fake.Clientset { + objs := make([]runtime.Object, 0, len(apps)) + for _, a := range apps { + objs = append(objs, &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: a.name, Namespace: a.ns, UID: types.UID(a.ns + "/" + a.name)}, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": a.name}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": a.name}}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app", Image: a.image}}}, + }, + }, + }) + } + for _, a := range apps { + if a.imageID == "" { + continue + } + // The pod is owned by its Deployment, so the grouping engine walks up + // to the same app rather than inventing a second one, and its container + // status carries the digest the node actually pulled. + yes := true + objs = append(objs, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: a.name + "-abc123", Namespace: a.ns, UID: types.UID(a.ns + "/" + a.name + "/pod"), + Labels: map[string]string{"app": a.name}, + OwnerReferences: []metav1.OwnerReference{{ + Kind: "Deployment", Name: a.name, + UID: types.UID(a.ns + "/" + a.name), Controller: &yes, + }}, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app", Image: a.image}}}, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}, + ContainerStatuses: []corev1.ContainerStatus{{ + Name: "app", Image: a.image, ImageID: a.imageID, Ready: true, + }}, + }, + }) + } + client := fake.NewSimpleClientset(objs...) + for _, kind := range refusedKinds { + resource := resourceOfKind(kind) + client.PrependReactor("list", resource, func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden(schema.GroupResource{Resource: resource}, "", nil) + }) + } + return client +} + +// resourceOfKind maps the kind names clusters.Fetch reports in Snapshot.Partial +// to the plural resource name a fake clientset reactor matches on. +func resourceOfKind(kind string) string { + switch kind { + case "Deployment": + return "deployments" + case "StatefulSet": + return "statefulsets" + case "DaemonSet": + return "daemonsets" + case "CronJob": + return "cronjobs" + case "Job": + return "jobs" + case "ReplicaSet": + return "replicasets" + case "Pod": + return "pods" + } + return strings.ToLower(kind) + "s" +} + +// Two contexts bound to one environment, running different versions. Build +// the service with both contexts pointing at prod, the second serving +// checkout at v2.12.0 and the first at v2.13.1. +func TestPromotionKeepsEveryContextInAnEnvironment(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-us-east": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.13.1"}}}, + "prod-eu-west": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.12.0"}}}, + }) + + v := s.Promotion() + + if len(v.Envs) != 1 { + t.Fatalf("envs = %d, want 1: both contexts bind to prod", len(v.Envs)) + } + if got := len(v.Envs[0].Contexts); got != 2 { + t.Errorf("prod contexts = %d, want 2: the second context was dropped", got) + } + if len(v.Rows) != 1 { + t.Fatalf("rows = %d, want 1", len(v.Rows)) + } + c := v.Rows[0].Cells[0] + if c.State != promotion.StateSplit { + t.Errorf("state = %q, want %q: two clusters on different versions is not one version", c.State, promotion.StateSplit) + } +} + +func TestPromotionMarksAnUnreachableContextOnItsEnvironment(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-us-east": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.13.1"}}}, + "prod-eu-west": {env: "prod", unreachable: true}, + }) + + v := s.Promotion() + + if len(v.Envs) != 1 { + t.Fatalf("envs = %d, want 1", len(v.Envs)) + } + if got := v.Envs[0].Unreadable; len(got) != 1 || got[0] != "prod-eu-west" { + t.Errorf("unreadable = %v, want [prod-eu-west]", got) + } + // Issue #76: the one readable cluster has nothing to disagree with — it + // is the only cluster that answered. There is no witnessed disagreement + // here, only an unrelated cluster that never spoke, so this must render + // StateDenied (an unknown), not StateSplit (a disagreement). It must + // still never become upstream, since prod-eu-west might run something + // different. + if v.Rows[0].Cells[0].State != promotion.StateDenied { + t.Errorf("state = %q, want denied: one cluster answered and has nothing to disagree with", v.Rows[0].Cells[0].State) + } +} + +// A partially-unreachable environment (one context down, one reads fine) must +// not appear in PromotionView.Unreachable: the split cell and Env.Unreadable +// already say a cluster there could not be read, and the banner claiming the +// whole column is unknown while it renders real split data would contradict +// what is on screen. A fully-unreachable environment, where every bound +// context failed, still belongs in the banner. +func TestPromotionUnreachableOnlyForFullyUnreadableEnvironments(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-us-east": {env: "prod", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v2.13.1"}}}, + "prod-eu-west": {env: "prod", unreachable: true}, + "stg-a": {env: "stg", unreachable: true}, + "stg-b": {env: "stg", unreachable: true}, + }) + + v := s.Promotion() + + for _, name := range v.Unreachable { + if name == "prod" { + t.Errorf("Unreachable = %v, must not include prod: one of its two contexts reads fine", v.Unreachable) + } + } + found := false + for _, name := range v.Unreachable { + if name == "stg" { + found = true + } + } + if !found { + t.Errorf("Unreachable = %v, want stg: every context bound to it failed", v.Unreachable) + } +} + +// Issue #77: clusters.Fetch never errors on an RBAC refusal — a forbidden +// list lands in Snapshot.Partial with a nil error, and Service.Promotion +// used to discard Partial entirely. So an app hidden by a refused Deployment +// list looked exactly like an app that genuinely does not exist, and the +// matrix rendered "not deployed here" for a cluster it was never allowed to +// check. qa genuinely has checkout; prod-a's Deployment listing is refused, +// so checkout is invisible there for a reason that has nothing to do with +// whether it is deployed. +func TestPromotionMarksARefusedKindDeniedNotAbsent(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "qa-a": {env: "qa", apps: []fakeApp{{name: "checkout", ns: "shop", image: "reg/checkout:v1.0.0"}}}, + "prod-a": {env: "prod", refusedKinds: []string{"Deployment"}}, + }) + + v := s.Promotion() + + if len(v.Rows) != 1 { + t.Fatalf("rows = %d, want 1", len(v.Rows)) + } + prodEnv := -1 + for i, e := range v.Envs { + if e.Name == "prod" { + prodEnv = i + } + } + if prodEnv == -1 { + t.Fatal("no prod environment in the view") + } + if got := v.Envs[prodEnv].Unreadable; len(got) != 1 || got[0] != "prod-a" { + t.Errorf("prod unreadable = %v, want [prod-a]: the Deployment list was refused, not merely empty", got) + } + c := v.Rows[0].Cells[prodEnv] + if c.State == promotion.StateAbsent { + t.Fatal("state = absent; a refused list means we could not check, not that checkout is missing") + } + if c.State != promotion.StateDenied { + t.Errorf("state = %q, want denied", c.State) + } +} + +// Issue #82, and its relationship to #78: Manager.Connect does not clear a +// prior session on failure, and ClientFor returns any session that exists +// regardless of state. Service.Promotion now checks Connect's own error and +// short-circuits before ever consulting ClientFor, matching the pattern +// Service.Fleet already used. This pins that the check fires directly off +// Connect's return value. +// +// Note on reachability: the state where a session survives while state is no +// longer Live was primarily reachable through the double-dial race fixed in +// #78 — the losing dial's failure could overwrite the winning dial's Live +// state without touching the session the winner had already set. With #78's +// dialMu serializing concurrent dials per context, that specific window is +// closed. This test cannot reproduce a genuinely stale session through the +// public Connect/ClientFor surface for that reason; it proves the defensive +// check itself is wired correctly, which costs nothing and guards against +// any future path that reintroduces the precondition. +func TestPromotionUsesConnectsOwnErrorBeforeClientFor(t *testing.T) { + s := serviceWithContexts(t, map[string]fakeCluster{ + "prod-a": {env: "prod", unreachable: true}, + }) + + v := s.Promotion() + + if len(v.Rows) != 0 { + t.Fatalf("rows = %d, want 0: nothing was ever found", len(v.Rows)) + } + if len(v.Envs) != 1 || len(v.Envs[0].Unreadable) != 1 { + t.Fatalf("unreadable = %v, want [prod-a]", v.Envs[0].Unreadable) + } +} + +// dedupeStrings is what keeps an environment with several unreachable +// contexts down to one banner entry. Exercise it directly, since nothing +// about the code path it serves proves the collapsing behavior on its own. +func TestDedupeStrings(t *testing.T) { + got := dedupeStrings([]string{"prod", "stg", "prod", "prod", "qa"}) + want := []string{"prod", "stg", "qa"} + if len(got) != len(want) { + t.Fatalf("dedupeStrings = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("dedupeStrings = %v, want %v", got, want) + } + } +} + func serviceFor(t *testing.T, body string, contexts ...kubeconfig.Context) *Service { t.Helper() diff --git a/internal/api/stream_test.go b/internal/api/stream_test.go index da20c07..3cb98f7 100644 --- a/internal/api/stream_test.go +++ b/internal/api/stream_test.go @@ -13,6 +13,7 @@ import ( "github.com/coder/websocket" "github.com/coder/websocket/wsjson" "github.com/dynaum/kubeside/internal/exec" + "github.com/dynaum/kubeside/internal/fleet" "github.com/dynaum/kubeside/internal/forward" "github.com/dynaum/kubeside/internal/guard" "github.com/dynaum/kubeside/internal/logs" @@ -113,6 +114,10 @@ func (s *streamStub) Promotion() PromotionView { return PromotionView{Envs: envs, Rows: rows, Summary: promotion.Summarize(rows)} } +func (s *streamStub) Fleet(app, namespace string) fleet.View { + return fleet.View{App: app, Namespace: namespace} +} + func (s *streamStub) StartForward(req ForwardRequest) (forward.Forward, error) { if req.RemotePort == 0 { return forward.Forward{}, errors.New("a container port is required") diff --git a/internal/clusters/manager.go b/internal/clusters/manager.go index ef84f9f..f8b6e37 100644 --- a/internal/clusters/manager.go +++ b/internal/clusters/manager.go @@ -46,6 +46,15 @@ type conn struct { failures int retryAfter time.Time session Session + + // dialMu serializes Connect for this context alone. mu is released while + // the dial itself is in flight, so without this a second concurrent + // Connect call would see a state other than StateLive and start a + // redundant dial, racing the first to overwrite session. Holding dialMu + // for the whole check-dial-update sequence means a second caller waits + // for the first to finish and then re-checks state under mu, seeing the + // first dial's outcome instead of starting its own. + dialMu sync.Mutex } // Manager owns one connection per kubeconfig context. @@ -124,12 +133,22 @@ func (m *Manager) ConnectOrder() []string { // Connect opens a session for one context, respecting the circuit breaker. // Calling it for an already-live context is a no-op. +// +// Two concurrent calls for the same context never both dial: dialMu +// serializes the whole check-dial-update sequence per context, so a second +// caller blocks until the first finishes and then observes its outcome +// (state, error, or backoff) rather than racing it. Different contexts have +// independent conns and independent dialMu locks, so a stuck dial on one +// context can never block Connect for another. func (m *Manager) Connect(ctx context.Context, name string) error { c, ok := m.conn(name) if !ok { return errUnknownContext(name) } + c.dialMu.Lock() + defer c.dialMu.Unlock() + c.mu.Lock() if c.state == StateLive { c.mu.Unlock() diff --git a/internal/clusters/manager_test.go b/internal/clusters/manager_test.go index 85bfe1b..5a9d2a5 100644 --- a/internal/clusters/manager_test.go +++ b/internal/clusters/manager_test.go @@ -347,6 +347,38 @@ func TestOneStuckClusterDoesNotBlockAnother(t *testing.T) { close(gate) } +// Issue #78: two simultaneous Connect calls for the same context could both +// pass the state check (neither sees StateLive yet) and both dial, the +// second overwriting session set by the first. Twenty concurrent callers for +// one context must still produce exactly one dial. +func TestConcurrentConnectsToOneContextDialOnce(t *testing.T) { + fc := newConnector() + m, _ := newManager(t, fc) + + var wg sync.WaitGroup + errs := make([]error, 20) + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = m.Connect(context.Background(), "qa") + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("caller %d: %v", i, err) + } + } + if got := fc.attemptsFor("qa"); got != 1 { + t.Fatalf("attempts = %d, want 1: twenty callers for one context must serialize to a single dial", got) + } + if got := mustStatus(t, m, "qa").State; got != StateLive { + t.Fatalf("qa = %s, want live", got) + } +} + func TestConcurrentConnectsAreRaceFree(t *testing.T) { fc := newConnector() m, _ := newManager(t, fc) diff --git a/internal/config/config.go b/internal/config/config.go index 1242739..c9bdc05 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -177,7 +177,7 @@ func (c *Config) apply(f file, path string) error { c.byContext[name] = i } for _, u := range e.Clusters { - k := normalizeURL(u) + k := NormalizeURL(u) if prev, dup := c.byCluster[k]; dup { return fmt.Errorf("%s: cluster %q is bound to both %q and %q", path, u, c.Environments[prev].Name, env.Name) @@ -279,7 +279,7 @@ func writeForRisk(r environments.Risk) environments.WritePolicy { // classifies by name, so configuring one environment does not blind kubeside to // the others. func (c *Config) Environment(kctx kubeconfig.Context) environments.Environment { - if i, ok := c.byCluster[normalizeURL(kctx.Server)]; ok { + if i, ok := c.byCluster[NormalizeURL(kctx.Server)]; ok { return c.Environments[i] } if i, ok := c.byContext[kctx.Name]; ok { @@ -295,9 +295,9 @@ func (c *Config) AppRef(app, env string) (Ref, bool) { return r, ok } -// normalizeURL makes server URLs comparable across the small differences that +// NormalizeURL makes server URLs comparable across the small differences that // mean nothing: a trailing slash, or a host written in a different case. -func normalizeURL(u string) string { +func NormalizeURL(u string) string { s := strings.TrimSpace(u) if s == "" { return "" diff --git a/internal/fleet/fleet.go b/internal/fleet/fleet.go new file mode 100644 index 0000000..7f9f87d --- /dev/null +++ b/internal/fleet/fleet.go @@ -0,0 +1,347 @@ +// Package fleet answers "is every cluster running the latest version". +// +// One app, one row per cluster, environment demoted to a column. The promotion +// matrix compares environments side by side; this compares the clusters inside +// and across them, which is the question a team running prod in three regions +// asks and the matrix cannot phrase. +// +// Two facts carry the screen: the newest tag among the clusters that answered, +// and every cluster behind it. Everything else is there so a cluster that did +// not answer is never mistaken for a cluster without the app. +package fleet + +import ( + "fmt" + "sort" + + "github.com/dynaum/kubeside/internal/promotion" +) + +// Placement states. +const ( + StatePresent = "present" + StateAbsent = "absent" + StateDenied = "denied" + StateUnreachable = "unreachable" + StatePending = "pending" +) + +// Placement is one app as it exists in one cluster. +type Placement struct { + Context string `json:"context"` + ClusterID string `json:"clusterId"` + Env string `json:"env"` + // EnvColor and EnvRisk carry the classification the resolved name cannot. + // environments.Classify matches by keyword token and returns the name it + // was handed, so "prod-us-east" and "production" are red without spelling + // any tier a reader could compare against. Shipping the decision instead of + // the name keeps that rule in one language: a UI re-deriving it from the + // string disagrees with this binary the first time a keyword is added. + EnvColor string `json:"envColor,omitempty"` + EnvRisk string `json:"envRisk,omitempty"` + Namespace string `json:"namespace,omitempty"` + + State string `json:"state"` + // Reason explains what the state cannot: why a cluster went unreachable or + // denied, and on a present row, anything the caller had to choose between + // to produce it. + Reason string `json:"reason,omitempty"` + + Image string `json:"image,omitempty"` + Tag string `json:"tag,omitempty"` + Digest string `json:"digest,omitempty"` + // DigestPending marks a placement whose digest has not arrived. A missing + // digest never reads as a match. + DigestPending bool `json:"digestPending,omitempty"` + + Health string `json:"health,omitempty"` + Ready string `json:"ready,omitempty"` + RevisionAt string `json:"revisionAt,omitempty"` + // Aliases names other kubeconfig contexts pointing at this same cluster, + // so a merged row says what it merged. + Aliases []string `json:"aliases,omitempty"` +} + +// Row is one cluster, judged against the newest version. +type Row struct { + Placement + Behind bool `json:"behind,omitempty"` + // MutableTag marks a cluster whose tag another cluster also runs under a + // different digest. It is not restricted to the newest tag: an older tag + // resolving to two digests is the same defect. + MutableTag bool `json:"mutableTag,omitempty"` + Note string `json:"note,omitempty"` +} + +// View is the whole screen. +type View struct { + App string `json:"app"` + Namespace string `json:"namespace"` + Rows []Row `json:"rows"` + + Newest string `json:"newest,omitempty"` + Clusters int `json:"clusters"` + Present int `json:"present"` + Behind int `json:"behind"` + // MutableTag is true when one tag resolved to more than one digest. It + // outranks being behind: a cluster openly on an older version is a + // schedule, two clusters claiming one version while running different + // code is a defect. + MutableTag bool `json:"mutableTag,omitempty"` + // DigestUnverified counts present rows whose digest never arrived. A + // mutable tag cannot be ruled out from a cluster whose image nobody + // resolved, so a headline reporting agreement can say how much of that + // agreement rests on digests still in flight. + DigestUnverified int `json:"digestUnverified,omitempty"` +} + +// Build derives the screen from one placement per context. +func Build(app, namespace string, ps []Placement) View { + merged := mergeByCluster(ps) + + v := View{App: app, Namespace: namespace, Clusters: len(merged)} + + // The newest tag is picked from orderable tags only. A build id loses to + // nothing, because promotion.CompareTags refuses to order it, so letting + // it into the maximum would leave it standing as a yardstick every real + // version then "cannot be ordered against" — one odd tag hiding the whole + // fleet's drift, and doing it differently depending on the order the + // caller happened to pass the placements in. + newest := "" + for _, p := range merged { + if p.State != StatePresent { + continue + } + v.Present++ + if !promotion.Orderable(p.Tag) { + continue + } + if newest == "" || promotion.CompareTags(p.Tag, newest) > 0 { + newest = p.Tag + } + } + v.Newest = newest + + // A tag carrying two digests is worse than a cluster openly behind, and it + // is that whichever tag it happens to. An older tag resolving to two + // digests is the same defect as the newest one doing it, so every tag is + // grouped, not only the leader. An absent tag is grouped with nothing: a + // tag that does not exist cannot be mutable. + byTag := map[string]map[string]bool{} + for _, p := range merged { + if p.State != StatePresent || p.Tag == "" || p.Digest == "" { + continue + } + if byTag[p.Tag] == nil { + byTag[p.Tag] = map[string]bool{} + } + byTag[p.Tag][p.Digest] = true + } + mutable := map[string]bool{} + for tag, digests := range byTag { + if len(digests) > 1 { + mutable[tag] = true + v.MutableTag = true + } + } + + v.Rows = make([]Row, 0, len(merged)) + for _, p := range merged { + r := Row{Placement: p} + switch p.State { + case StatePresent: + // A caller that already knows the digest is in flight is believed; + // a row with no digest is pending whether or not it said so. + r.DigestPending = p.DigestPending || p.Digest == "" + if r.DigestPending { + v.DigestUnverified++ + } + r.MutableTag = p.Digest != "" && mutable[p.Tag] + + verdict := "" + switch { + case p.Tag == "": + // An image pinned by digest carries no tag to compare, and + // pretending it matches the newest would render the deepest + // unknown on the screen as the calmest row on it. + verdict = "version unknown: the image is pinned by digest, so there is no tag to compare" + case p.Tag == newest: + // On the newest version anyone could name. + case promotion.CompareTags(p.Tag, newest) < 0: + r.Behind = true + v.Behind++ + verdict = fmt.Sprintf("behind %s", newest) + case !promotion.Orderable(p.Tag): + // Two build ids differ without one being older, and calling + // either behind would invent a direction. + if newest == "" { + verdict = fmt.Sprintf("runs %s, which cannot be ordered against any other version here", p.Tag) + } else { + verdict = fmt.Sprintf("runs %s, which cannot be ordered against %s", p.Tag, newest) + } + default: + // Orderable, not behind, yet not the newest string: the same + // version spelled differently, such as v1.0 beside v1.0.0. + verdict = fmt.Sprintf("runs %s, the same version as %s", p.Tag, newest) + } + + switch { + case r.MutableTag && verdict != "": + r.Note = fmt.Sprintf("%s, and another cluster runs %s with a different digest", verdict, p.Tag) + case r.MutableTag: + r.Note = fmt.Sprintf("runs %s, and so does another cluster with a different digest", p.Tag) + default: + r.Note = verdict + } + + // A present row can still carry a reason: the caller had two + // candidates for this one row and says which it took. It is appended + // rather than substituted, because the version verdict is what the + // screen is for and a choice nobody sees is a choice nobody can + // challenge. + if p.Reason != "" { + if r.Note == "" { + r.Note = p.Reason + } else { + r.Note += "; " + p.Reason + } + } + case StateUnreachable: + r.Note = orElse(p.Reason, "the cluster did not answer") + case StateDenied: + r.Note = orElse(p.Reason, "not readable here") + case StateAbsent: + r.Note = "not deployed here" + case StatePending: + r.Note = "asking" + default: + // A state nobody set is a question nobody answered. Rendering it + // as a blank row would put an unknown cluster among the healthy + // ones, so it reads as pending and says why. + r.State = StatePending + r.Note = "state not reported; treated as not yet answered" + } + v.Rows = append(v.Rows, r) + } + + sort.SliceStable(v.Rows, func(i, j int) bool { + si, sj := severity(v.Rows[i]), severity(v.Rows[j]) + if si != sj { + return si > sj + } + return v.Rows[i].Context < v.Rows[j].Context + }) + return v +} + +// severity sorts disagreement to the top, matching the promotion matrix's +// default of showing what needs attention first. +// +// A cluster nobody reached outranks a cluster whose older version you can see. +// The promotion matrix decides the same way for the same reason: "neither +// agreement nor a defect can be claimed from clusters we never saw", so the +// version we could not read might be worse than the old one we could. The two +// screens rank the same facts by one rule. +// +// The same rule orders the rest: the less a cluster told us, the higher it +// sits. A denied read says nothing about the app, so it outranks a present +// cluster whose version could not be named, which at least confirmed the app +// runs there. +// +// Absent shares the bottom bucket with a cluster on the newest version. That is +// deliberate: an app not deployed somewhere is a schedule, not a disagreement, +// so "not deployed here" rows interleave alphabetically with the healthy ones +// rather than climbing the screen. The states stay distinct in the data. +func severity(r Row) int { + switch { + case r.MutableTag: + return 6 + case r.State == StateUnreachable: + return 5 + case r.Behind: + return 4 + case r.State == StateDenied: + return 3 + case r.State == StatePresent && r.Tag == "": + return 2 + case r.State == StatePending: + return 1 + default: + return 0 + } +} + +// mergeByCluster collapses contexts pointing at one cluster. +// +// A kubeconfig commonly holds the same cluster twice under two names, and one +// of the two may carry credentials that no longer work. Counting it twice +// inflates every number on the screen, so the most informative answer wins and +// the other context is recorded as an alias. +// +// A placement with no ClusterID keys on its context name instead, so each +// unidentified context keeps its own row. Sharing one empty key would merge +// every unidentified cluster into a single row and delete clusters outright, +// which is the worse error for a package whose job is to lose none. This puts +// an invariant on whoever builds the placements: ClusterID must come from the +// kubeconfig cluster entry, not from a successful connection. Populate it from +// a connection and an unreachable duplicate context arrives with an empty +// ClusterID, fails to merge with its reachable twin, and inflates Clusters in +// exactly the case this merge exists to prevent. +func mergeByCluster(ps []Placement) []Placement { + at := map[string]int{} + out := make([]Placement, 0, len(ps)) + for _, p := range ps { + key := p.ClusterID + if key == "" { + key = "ctx:" + p.Context + } + i, seen := at[key] + if !seen { + at[key] = len(out) + out = append(out, p) + continue + } + // Aliases are rebuilt into a fresh slice on both paths. Appending to + // the caller's slice would write through the backing array it still + // holds. + if informativeness(p.State) > informativeness(out[i].State) { + p.Aliases = appendAliases(p.Aliases, append([]string{out[i].Context}, out[i].Aliases...)...) + out[i] = p + continue + } + out[i].Aliases = appendAliases(out[i].Aliases, p.Context) + } + return out +} + +// appendAliases returns a new slice, never extending the one passed in. +func appendAliases(existing []string, add ...string) []string { + out := make([]string, 0, len(existing)+len(add)) + out = append(out, existing...) + return append(out, add...) +} + +// informativeness ranks what a cluster told us. Present beats absent, because +// a version is more than a confirmed nothing. Absent beats denied and +// unreachable, because the cluster answered at all. +func informativeness(state string) int { + switch state { + case StatePresent: + return 4 + case StateAbsent: + return 3 + case StateDenied: + return 2 + case StateUnreachable: + return 1 + default: + return 0 + } +} + +func orElse(v, fallback string) string { + if v == "" { + return fallback + } + return v +} diff --git a/internal/fleet/fleet_test.go b/internal/fleet/fleet_test.go new file mode 100644 index 0000000..71dd356 --- /dev/null +++ b/internal/fleet/fleet_test.go @@ -0,0 +1,440 @@ +package fleet + +import "testing" + +func present(ctx, server, env, tag, digest string) Placement { + return Placement{ + Context: ctx, ClusterID: server, Env: env, Namespace: "shop", + State: StatePresent, Image: "reg/checkout:" + tag, Tag: tag, Digest: digest, + Health: "healthy", Ready: "3/3", + } +} + +func rowFor(t *testing.T, v View, ctx string) Row { + t.Helper() + for _, r := range v.Rows { + if r.Context == ctx { + return r + } + } + t.Fatalf("no row for context %q", ctx) + return Row{} +} + +func TestNewestIsTheHighestTagAcrossPresentClusters(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("prod-us-east", "https://a", "prod", "v2.13.1", "sha256:bb"), + present("prod-eu-west", "https://b", "prod", "v2.12.0", "sha256:cc"), + present("qa-cluster", "https://c", "qa", "v2.14.0", "sha256:dd"), + }) + if v.Newest != "v2.14.0" { + t.Errorf("newest = %q, want v2.14.0", v.Newest) + } + if v.Behind != 2 { + t.Errorf("behind = %d, want 2", v.Behind) + } + if !rowFor(t, v, "prod-eu-west").Behind { + t.Error("prod-eu-west runs v2.12.0 and is behind") + } + if rowFor(t, v, "qa-cluster").Behind { + t.Error("the newest cluster is not behind itself") + } +} + +func TestUnorderableTagsAreNeverCalledBehind(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "qa", "sha-abc123", "sha256:aa"), + present("b", "https://b", "qa", "sha-def456", "sha256:bb"), + }) + if v.Behind != 0 { + t.Errorf("behind = %d, want 0: two build ids cannot be ordered, and claiming one is behind invents a direction", v.Behind) + } +} + +func TestSameTagDifferentDigestOutranksBehind(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "prod", "v2.13.1", "sha256:bb"), + present("b", "https://b", "prod", "v2.13.1", "sha256:cc"), + present("c", "https://c", "prod", "v2.12.0", "sha256:dd"), + }) + if !v.MutableTag { + t.Fatal("one tag resolving to two digests is the loudest state on the screen") + } + if v.Rows[0].Context != "a" && v.Rows[0].Context != "b" { + t.Errorf("first row = %q; the mutable-tag clusters sort above the behind one", v.Rows[0].Context) + } +} + +func TestTwoContextsOnOneClusterMergeToOneRow(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + {Context: "prod-alias", ClusterID: "https://a", Env: "prod", State: StateUnreachable, Reason: "credentials expired"}, + present("prod-us-east", "https://a", "prod", "v2.13.1", "sha256:bb"), + }) + if len(v.Rows) != 1 { + t.Fatalf("rows = %d, want 1: one cluster reached by two contexts is one cluster", len(v.Rows)) + } + if v.Rows[0].State != StatePresent { + t.Errorf("state = %q, want %q: the context that answered wins over the one that did not", v.Rows[0].State, StatePresent) + } + if v.Clusters != 1 { + t.Errorf("clusters = %d, want 1; counting it twice inflates the headline number", v.Clusters) + } +} + +func TestTheFiveStatesStayDistinct(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "qa", "v1.0.0", "sha256:aa"), + {Context: "b", ClusterID: "https://b", Env: "stg", State: StateAbsent}, + {Context: "c", ClusterID: "https://c", Env: "prod", State: StateDenied, Reason: "pods list refused"}, + {Context: "d", ClusterID: "https://d", Env: "prod", State: StateUnreachable, Reason: "dial timeout"}, + {Context: "e", ClusterID: "https://e", Env: "prod", State: StatePending}, + }) + if v.Clusters != 5 { + t.Fatalf("clusters = %d, want 5", v.Clusters) + } + if v.Present != 1 { + t.Errorf("present = %d, want 1", v.Present) + } + seen := map[string]bool{} + for _, r := range v.Rows { + seen[r.State] = true + } + for _, s := range []string{StatePresent, StateAbsent, StateDenied, StateUnreachable, StatePending} { + if !seen[s] { + t.Errorf("state %q was collapsed into another; absent, denied, and unreachable are different facts", s) + } + } +} + +func TestNoClusterRunsTheAppAtAll(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + {Context: "a", ClusterID: "https://a", Env: "qa", State: StateAbsent}, + {Context: "b", ClusterID: "https://b", Env: "prod", State: StateAbsent}, + }) + if v.Newest != "" { + t.Errorf("newest = %q, want empty", v.Newest) + } + if v.Clusters != 2 || v.Present != 0 { + t.Errorf("clusters = %d present = %d, want 2 and 0: the screen names what it asked", v.Clusters, v.Present) + } +} + +// C1: an unorderable tag must never become the yardstick, whatever order the +// caller hands the placements over in. +func TestOneUnorderableTagDoesNotSilenceTheDrift(t *testing.T) { + orders := map[string][]Placement{ + "build id first": { + present("a", "https://a", "qa", "sha-x", "sha256:aa"), + present("b", "https://b", "stg", "v1.0.0", "sha256:bb"), + present("c", "https://c", "prod", "v2.0.0", "sha256:cc"), + }, + "build id last": { + present("b", "https://b", "stg", "v1.0.0", "sha256:bb"), + present("c", "https://c", "prod", "v2.0.0", "sha256:cc"), + present("a", "https://a", "qa", "sha-x", "sha256:aa"), + }, + } + for name, ps := range orders { + t.Run(name, func(t *testing.T) { + v := Build("checkout", "shop", ps) + if v.Newest != "v2.0.0" { + t.Errorf("newest = %q, want v2.0.0: a tag nothing can be ordered against is not the newest version", v.Newest) + } + if v.Behind != 1 { + t.Errorf("behind = %d, want 1: v1.0.0 is behind v2.0.0 whether or not a build id is in the fleet", v.Behind) + } + if !rowFor(t, v, "b").Behind { + t.Error("b runs v1.0.0 while c runs v2.0.0 and is behind") + } + if rowFor(t, v, "a").Behind { + t.Error("a build id is not behind anything; claiming it is invents a direction") + } + if got, want := rowFor(t, v, "a").Note, "runs sha-x, which cannot be ordered against v2.0.0"; got != want { + t.Errorf("note = %q, want %q", got, want) + } + if got, want := rowFor(t, v, "b").Note, "behind v2.0.0"; got != want { + t.Errorf("note = %q, want %q", got, want) + } + if got := rowFor(t, v, "c").Note; got != "" { + t.Errorf("note = %q, want empty: c runs the newest version", got) + } + }) + } +} + +// C1: with nothing orderable anywhere, there is no newest and no row claims to +// be on it. +func TestAllBuildIdsLeaveNoNewestAndNoBehind(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "qa", "sha-abc123", "sha256:aa"), + present("b", "https://b", "qa", "sha-def456", "sha256:bb"), + }) + if v.Newest != "" { + t.Errorf("newest = %q, want empty: neither build id can be ordered against the other", v.Newest) + } + if v.Behind != 0 { + t.Errorf("behind = %d, want 0", v.Behind) + } + if got, want := rowFor(t, v, "a").Note, "runs sha-abc123, which cannot be ordered against any other version here"; got != want { + t.Errorf("note = %q, want %q", got, want) + } +} + +// C2a: a fleet of digest-pinned images knows nothing, and must not render as a +// fleet that agrees. +func TestPresentWithoutATagIsNotUpToDate(t *testing.T) { + untagged := func(ctx, server, digest string) Placement { + return Placement{ + Context: ctx, ClusterID: server, Env: "prod", Namespace: "shop", + State: StatePresent, Image: "reg/checkout@" + digest, Digest: digest, + Health: "healthy", Ready: "3/3", + } + } + v := Build("checkout", "shop", []Placement{ + untagged("a", "https://a", "sha256:aa"), + untagged("b", "https://b", "sha256:bb"), + }) + if v.MutableTag { + t.Error("mutableTag = true, want false: there is no tag to call mutable") + } + if v.Behind != 0 { + t.Errorf("behind = %d, want 0", v.Behind) + } + want := "version unknown: the image is pinned by digest, so there is no tag to compare" + for _, ctx := range []string{"a", "b"} { + r := rowFor(t, v, ctx) + if r.Note != want { + t.Errorf("%s note = %q, want %q", ctx, r.Note, want) + } + if r.MutableTag { + t.Errorf("%s mutableTag = true, want false", ctx) + } + } + if severity(v.Rows[0]) == 0 { + t.Error("a cluster whose version nobody could name sorts with the up-to-date clusters") + } +} + +// C2b: the untagged row's note must not ship an empty version into prose. +func TestUntaggedClusterBesideAKnownNewest(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "prod", "v2.14.0", "sha256:aa"), + {Context: "b", ClusterID: "https://b", Env: "prod", Namespace: "shop", + State: StatePresent, Image: "reg/checkout@sha256:bb", Digest: "sha256:bb"}, + }) + if v.Newest != "v2.14.0" { + t.Errorf("newest = %q, want v2.14.0", v.Newest) + } + got := rowFor(t, v, "b").Note + if got == "runs , which cannot be ordered against v2.14.0" { + t.Fatalf("note = %q: an empty tag reached the UI as prose", got) + } + if want := "version unknown: the image is pinned by digest, so there is no tag to compare"; got != want { + t.Errorf("note = %q, want %q", got, want) + } + if rowFor(t, v, "b").Behind { + t.Error("a version nobody could read is not a version behind") + } +} + +// I1: a mutable tag is a defect wherever it happens, not only on the newest tag. +func TestMutableTagOnAnOlderTagIsStillADefect(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "prod", "v2.12.0", "sha256:aa"), + present("b", "https://b", "prod", "v2.12.0", "sha256:zz"), + present("c", "https://c", "prod", "v2.12.0", "sha256:aa"), + present("d", "https://d", "qa", "v2.14.0", "sha256:dd"), + }) + if !v.MutableTag { + t.Fatal("one tag resolving to two digests is a defect on v2.12.0 as much as on the newest tag") + } + for _, ctx := range []string{"a", "b", "c"} { + if !rowFor(t, v, ctx).MutableTag { + t.Errorf("%s carries the conflicting tag and is not marked", ctx) + } + } + if rowFor(t, v, "d").MutableTag { + t.Error("d runs a tag with one digest and must not be marked") + } + if got, want := rowFor(t, v, "a").Note, "behind v2.14.0, and another cluster runs v2.12.0 with a different digest"; got != want { + t.Errorf("note = %q, want %q", got, want) + } + if v.Rows[0].Context == "d" { + t.Error("the newest cluster sorts above a mutable tag; the defect is the louder fact") + } +} + +// I2: a caller-supplied DigestPending survives, and the view says how much of +// the answer rests on digests that never arrived. +func TestUnverifiedDigestsAreCountedAndNotOverwritten(t *testing.T) { + claimed := present("a", "https://a", "prod", "v2.13.1", "sha256:aa") + claimed.DigestPending = true + v := Build("checkout", "shop", []Placement{ + claimed, + present("b", "https://b", "prod", "v2.13.1", ""), + present("c", "https://c", "prod", "v2.13.1", "sha256:cc"), + {Context: "d", ClusterID: "https://d", Env: "prod", State: StateAbsent}, + }) + if !rowFor(t, v, "a").DigestPending { + t.Error("a caller that says the digest is still in flight is ignored") + } + if !rowFor(t, v, "b").DigestPending { + t.Error("a present cluster with no digest is pending") + } + if rowFor(t, v, "c").DigestPending { + t.Error("c reported a digest and is not pending") + } + if v.DigestUnverified != 2 { + t.Errorf("digestUnverified = %d, want 2: the headline rests on two digests nobody confirmed", v.DigestUnverified) + } +} + +// I3: a merged row says what it merged, through every reshuffle. +func TestMergedRowNamesEveryContextItMerged(t *testing.T) { + t.Run("winner holds", func(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + {Context: "prod-alias", ClusterID: "https://a", Env: "prod", State: StateUnreachable, Reason: "credentials expired"}, + present("prod-us-east", "https://a", "prod", "v2.13.1", "sha256:bb"), + }) + assertAliases(t, v.Rows[0], "prod-alias") + }) + t.Run("three contexts, one cluster", func(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + {Context: "a", ClusterID: "https://a", Env: "prod", State: StateUnreachable}, + present("b", "https://a", "prod", "v2.13.1", "sha256:bb"), + {Context: "c", ClusterID: "https://a", Env: "prod", State: StateDenied}, + }) + if len(v.Rows) != 1 { + t.Fatalf("rows = %d, want 1", len(v.Rows)) + } + if v.Rows[0].Context != "b" { + t.Errorf("context = %q, want b: the context that answered wins", v.Rows[0].Context) + } + assertAliases(t, v.Rows[0], "a", "c") + }) + t.Run("winner changes twice", func(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + {Context: "a", ClusterID: "https://a", Env: "prod", State: StateUnreachable}, + {Context: "b", ClusterID: "https://a", Env: "prod", State: StateDenied}, + present("c", "https://a", "prod", "v2.13.1", "sha256:bb"), + }) + if len(v.Rows) != 1 { + t.Fatalf("rows = %d, want 1", len(v.Rows)) + } + if v.Rows[0].Context != "c" { + t.Errorf("context = %q, want c", v.Rows[0].Context) + } + assertAliases(t, v.Rows[0], "b", "a") + }) +} + +func assertAliases(t *testing.T, r Row, want ...string) { + t.Helper() + if len(r.Aliases) != len(want) { + t.Fatalf("aliases = %v, want %v: a merged row says what it merged", r.Aliases, want) + } + for i, w := range want { + if r.Aliases[i] != w { + t.Errorf("aliases = %v, want %v", r.Aliases, want) + return + } + } +} + +// M5: merging must not write through the caller's slice. +func TestMergeDoesNotWriteThroughTheCallersAliasArray(t *testing.T) { + backing := make([]string, 1, 4) + backing[0] = "seed" + winner := present("winner", "https://a", "prod", "v1.0.0", "sha256:aa") + winner.Aliases = backing + + Build("checkout", "shop", []Placement{ + {Context: "loser", ClusterID: "https://a", Env: "prod", State: StateUnreachable}, + winner, + }) + + if grown := backing[:cap(backing)]; grown[1] != "" { + t.Errorf("Build wrote %q into the caller's alias array", grown[1]) + } +} + +// I4: every sentence the screen shows is a promise, so every sentence is tested. +func TestEveryStateNamesItselfInTheNote(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "qa", "v1.0.0", "sha256:aa"), + {Context: "b", ClusterID: "https://b", Env: "stg", State: StateAbsent}, + {Context: "c", ClusterID: "https://c", Env: "prod", State: StateDenied, Reason: "pods list refused"}, + {Context: "d", ClusterID: "https://d", Env: "prod", State: StateUnreachable, Reason: "dial timeout"}, + {Context: "e", ClusterID: "https://e", Env: "prod", State: StatePending}, + }) + want := map[string]string{ + "a": "", + "b": "not deployed here", + "c": "pods list refused", + "d": "dial timeout", + "e": "asking", + } + for ctx, w := range want { + if got := rowFor(t, v, ctx).Note; got != w { + t.Errorf("%s note = %q, want %q", ctx, got, w) + } + } +} + +// I4: with no reason given, the note still says something true. +func TestNotesFallBackWhenNoReasonArrives(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + {Context: "c", ClusterID: "https://c", Env: "prod", State: StateDenied}, + {Context: "d", ClusterID: "https://d", Env: "prod", State: StateUnreachable}, + }) + if got, want := rowFor(t, v, "c").Note, "not readable here"; got != want { + t.Errorf("denied note = %q, want %q", got, want) + } + if got, want := rowFor(t, v, "d").Note, "the cluster did not answer"; got != want { + t.Errorf("unreachable note = %q, want %q", got, want) + } +} + +// M4: a cluster nobody read outranks one whose older version you can see. The +// promotion matrix takes the same position for the same reason. +func TestUnreadableSortsAboveBehind(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("behind", "https://a", "prod", "v1.0.0", "sha256:aa"), + {Context: "silent", ClusterID: "https://b", Env: "prod", State: StateUnreachable, Reason: "dial timeout"}, + present("newest", "https://c", "prod", "v2.0.0", "sha256:cc"), + }) + if v.Rows[0].Context != "silent" { + t.Errorf("first row = %q, want silent: the version we could not read might be worse than the old one we could", v.Rows[0].Context) + } + if v.Rows[1].Context != "behind" { + t.Errorf("second row = %q, want behind", v.Rows[1].Context) + } +} + +// M2: a placement whose state nobody set is unknown, not healthy. +func TestUnrecognizedStateIsNotRenderedAsHealthy(t *testing.T) { + v := Build("checkout", "shop", []Placement{ + present("a", "https://a", "prod", "v1.0.0", "sha256:aa"), + {Context: "b", ClusterID: "https://b", Env: "prod"}, + }) + r := rowFor(t, v, "b") + if r.Note == "" { + t.Error("a row with no state renders blank beside a healthy one") + } + if r.State != StatePending { + t.Errorf("state = %q, want %q: an unrecognized state is one nobody has answered yet", r.State, StatePending) + } + if v.Present != 1 { + t.Errorf("present = %d, want 1", v.Present) + } +} + +func TestNoPlacementsAtAll(t *testing.T) { + v := Build("checkout", "shop", nil) + if v.Rows == nil { + t.Error("rows is nil and marshals as null; an empty fleet is an empty list") + } + if v.Clusters != 0 || v.Present != 0 || v.Newest != "" { + t.Errorf("clusters = %d present = %d newest = %q, want 0, 0, empty", v.Clusters, v.Present, v.Newest) + } +} diff --git a/internal/promotion/promotion.go b/internal/promotion/promotion.go index b1bfb4a..9f04f2c 100644 --- a/internal/promotion/promotion.go +++ b/internal/promotion/promotion.go @@ -29,18 +29,30 @@ const ( StateDigestDiffers = "digest-differs" StateAbsent = "absent" StateDenied = "denied" + // StateSplit is an environment whose clusters do not agree. It never + // carries a version, because picking one would be the guess this state + // exists to prevent. + StateSplit = "split" ) // Env is one column. type Env struct { - Name string `json:"name"` - Risk string `json:"risk"` - Context string `json:"context,omitempty"` + Name string `json:"name"` + Risk string `json:"risk"` + // Context is the first context bound to this environment, kept so a cell + // can still be opened. Contexts is all of them. + Context string `json:"context,omitempty"` + Contexts []string `json:"contexts,omitempty"` + // Unreadable names the contexts that did not answer. A cell in an + // environment with any unreadable context can never claim agreement, + // because the clusters nobody read might disagree. + Unreadable []string `json:"unreadable,omitempty"` } // Instance is one app as it exists in one environment. type Instance struct { Env string + Context string App string Namespace string @@ -77,6 +89,9 @@ type Cell struct { // Severe marks the states that deserve the error hue rather than a warning: // ahead of upstream, and a tag that resolves to a different digest. Severe bool `json:"severe,omitempty"` + // Clusters is how many contexts back this cell. Zero and one both mean a + // single cluster; the UI shows the count only above one. + Clusters int `json:"clusters,omitempty"` } // Row is one app across every environment. @@ -93,6 +108,7 @@ type Summary struct { Apps int `json:"apps"` Drifted int `json:"drifted"` Ahead int `json:"ahead"` + Split int `json:"split"` } // Summarize counts the rows worth looking at. @@ -105,6 +121,12 @@ func Summarize(rows []Row) Summary { if r.Ahead { s.Ahead++ } + for _, c := range r.Cells { + if c.State == StateSplit { + s.Split++ + break + } + } } return s } @@ -141,50 +163,254 @@ func Build(envs []Env, instances []Instance) []Row { } func row(envs []Env, instances []Instance) Row { - byEnv := map[string]Instance{} + byEnv := map[string][]Instance{} for _, in := range instances { - byEnv[in.Env] = in + byEnv[in.Env] = append(byEnv[in.Env], in) } out := Row{App: instances[0].App, Namespace: instances[0].Namespace} // upstream is the last environment that actually told us something. An - // unreadable environment is not an agreement, so it does not become the - // thing the next column is compared against. + // unreadable environment is not an agreement, and neither is a split one, + // so neither becomes the thing the next column is compared against. var upstream *Instance for _, env := range envs { - in, ok := byEnv[env.Name] - if !ok { - out.Cells = append(out.Cells, Cell{Env: env.Name, State: StateAbsent, Note: "not deployed here"}) - continue + rep, cell, ok := resolve(env, byEnv[env.Name], upstream) + out.Cells = append(out.Cells, cell) + // Absent and denied are not comparisons: there is nothing to compare + // against, or nothing we were allowed to read. Only a cell that was + // actually evaluated moves the drift count. A split cell was + // evaluated, and disagreed, so it counts. + if cell.State != StateSame && cell.State != StateAbsent && cell.State != StateDenied { + out.Drift++ } - if in.Denied { - out.Cells = append(out.Cells, Cell{ - Env: env.Name, Namespace: in.Namespace, State: StateDenied, - Note: orElse(in.DeniedReason, "not readable"), - }) - continue + if cell.State == StateAhead { + out.Ahead = true } - if !in.Present { - out.Cells = append(out.Cells, Cell{ - Env: env.Name, Namespace: in.Namespace, State: StateAbsent, Note: "not deployed here", - }) - continue + if ok { + copyOf := rep + upstream = ©Of } + } + return out +} - c := compare(in, upstream) - out.Cells = append(out.Cells, c) - if c.State != StateSame { - out.Drift++ +// resolve turns one environment's clusters into one cell. +// +// The third return says whether the cell may serve as upstream for the next +// column. Absent, denied, and split all say no: a column the matrix could not +// read, or whose clusters disagree, is not a version the next column can be +// compared against. +func resolve(env Env, group []Instance, upstream *Instance) (Instance, Cell, bool) { + clusters := len(env.Contexts) + if clusters == 0 { + clusters = 1 + } + // Unreadable can outgrow the declared Contexts in a synthetically built + // Env (Env.Contexts unset but Unreadable populated); service.go never + // produces that shape today, since acc.contexts is always a superset of + // acc.unreadable, but resolve() must not print a cluster count smaller + // than the number of contexts it names as unread. + if unread := len(env.Unreadable); unread > clusters { + clusters = unread + } + + var present []Instance + denied := 0 + for _, in := range group { + switch { + case in.Denied: + denied++ + case in.Present: + present = append(present, in) } - if c.State == StateAhead { - out.Ahead = true + } + + // Namespace, when the instance is known: an absent or denied cell still + // names where it would have lived, because the app identity that got us + // here came from a real instance somewhere in this group. Take the first + // non-empty one, since a denied instance can carry an unknown (empty) + // namespace even when a later instance in the same group knows it. + ns := "" + for _, in := range group { + if in.Namespace != "" { + ns = in.Namespace + break } - copyOf := in - upstream = ©Of } - return out + + if len(present) == 0 { + if denied > 0 { + reason := "not readable" + for _, in := range group { + if in.Denied && in.DeniedReason != "" { + reason = in.DeniedReason + break + } + } + return Instance{}, Cell{Env: env.Name, Namespace: ns, State: StateDenied, Note: reason, Clusters: clusters}, false + } + + // service.go's denied placeholders carry no App name and are filtered + // out before Build ever sees them, so a fully or partially unread + // environment reaches here with denied == 0 and an empty group. Absent + // requires every context to have actually answered; anything less is a + // fact this cell must not erase. + if len(env.Unreadable) > 0 { + unread := len(env.Unreadable) + if unread >= clusters { + note := fmt.Sprintf("%d of %d clusters did not answer", unread, clusters) + return Instance{}, Cell{Env: env.Name, Namespace: ns, State: StateDenied, Note: note, Clusters: clusters}, false + } + // Some, but not all, contexts answered, and none of them have the + // app. That is not "not deployed here": the clusters that stayed + // silent might. Name what was read and what was not, same as the + // fully-unread case, rather than inventing a disagreement no + // instance actually showed. Terse, like every other split() note: + // this renders inside a matrix cell. + readable := clusters - unread + note := fmt.Sprintf("not in the %d clusters read; %d did not answer", readable, unread) + return Instance{}, Cell{Env: env.Name, Namespace: ns, State: StateDenied, Note: note, Clusters: clusters}, false + } + + return Instance{}, Cell{Env: env.Name, Namespace: ns, State: StateAbsent, Note: "not deployed here", Clusters: clusters}, false + } + + // More present instances than the environment declares clusters means the + // declared count cannot be trusted here: this is typically two namespaces + // (e.g. "shop" and "shop-prod") collapsing to one app identity inside a + // single environment, not genuinely distinct clusters. A coverage + // fraction built from that count would be a number known to be wrong, so + // decide on consensus alone and never cite the cluster count. + // + // But an unreadable context still wins over all of that: the clusters + // nobody read might disagree, so this can never collapse to agreement + // just because the ones that did answer agree with each other. + if len(present) > clusters { + if len(env.Unreadable) == 0 { + if rep, agreed := consensus(present); agreed { + return rep, compare(rep, upstream), true + } + } + return Instance{}, splitOnConsensusOnly(env, present), false + } + + readable := clusters - len(env.Unreadable) + if rep, agreed := consensus(present); agreed && len(present) == readable { + if len(env.Unreadable) == 0 { + c := compare(rep, upstream) + c.Clusters = clusters + return rep, c, true + } + // The readable clusters agree with each other and none of them is + // missing the app, but some other cluster never answered. That is an + // unknown, not a disagreement: there is nothing to flag about the + // clusters that spoke, so this must not read as a witnessed defect + // the way split() does — same treatment as the present==0 unreadable + // case above (issue #75), and it cannot become upstream for the same + // reason: the silent cluster might run something different. + note := fmt.Sprintf("%d of %d clusters agree on %s; %d did not answer", len(present), clusters, rep.Tag, len(env.Unreadable)) + return Instance{}, Cell{Env: env.Name, Namespace: ns, State: StateDenied, Tag: rep.Tag, Note: note, Clusters: clusters}, false + } + + return Instance{}, split(env, present, clusters, readable), false +} + +// consensus collapses clusters that agree. +// +// Tags must match exactly. Digests must match only when both arrived, because +// a digest still in flight is not a disagreement. The representative carries +// no digest unless every cluster reported the same one, so a pending fetch +// renders as pending instead of borrowing one cluster's answer for the rest. +func consensus(present []Instance) (Instance, bool) { + rep := present[0] + anyPending := rep.Digest == "" + for _, in := range present[1:] { + if in.Tag != rep.Tag { + return Instance{}, false + } + if in.Digest == "" { + anyPending = true + continue + } + if rep.Digest != "" && in.Digest != rep.Digest { + return Instance{}, false + } + if rep.Digest == "" { + rep.Digest = in.Digest + } + } + if anyPending { + rep.Digest = "" + } + rep.Context = "" + return rep, true +} + +// split is the cell for an environment whose clusters do not agree. It names +// the disagreement and never names a version. +func split(env Env, present []Instance, clusters, readable int) Cell { + c := Cell{Env: env.Name, State: StateSplit, Severe: true, Clusters: clusters} + if len(present) > 0 { + c.Namespace = present[0].Namespace + } + + tags := map[string]bool{} + digests := map[string]bool{} + for _, in := range present { + tags[in.Tag] = true + if in.Digest != "" { + digests[in.Digest] = true + } + } + + // A witnessed disagreement is a real defect, and it must not hide behind + // an unrelated cluster that simply never answered. resolve() already + // routes the shape where the readable clusters fully agree and some + // other one is unread to a StateDenied cell before this function is ever + // called (issue #76), so every case below fires only because the + // clusters that DID answer disagree with each other. An unread cluster + // is still worth naming, just never as the reason. + unreadSuffix := "" + if unread := len(env.Unreadable); unread > 0 { + unreadSuffix = fmt.Sprintf("; %d cluster(s) did not answer", unread) + } + + switch { + case len(tags) == 1 && len(digests) > 1: + // A mutable tag outranks partial coverage. One tag resolving to two + // digests is a defect; a cluster not having the app yet is a + // schedule, and the defect is the worse fact to hide. Cite the + // clusters that actually reported the tag, not the declared total — + // this branch is reachable under partial coverage too, where fewer + // clusters answered than the environment declares. + c.Tag = "" + c.Note = fmt.Sprintf("one tag across %d clusters but %d digests: the tag is mutable and these are not the same code%s", len(present), len(digests), unreadSuffix) + case len(present) < readable: + c.Note = fmt.Sprintf("deployed in %d of %d clusters%s", len(present), clusters, unreadSuffix) + default: + c.Note = fmt.Sprintf("%d versions across %d clusters%s", len(tags), clusters, unreadSuffix) + } + return c +} + +// splitOnConsensusOnly is the cell for an environment where more instances +// are present than the environment declares clusters for (see resolve). The +// declared cluster count is not meaningful in that case, so this never prints +// a coverage fraction or a cluster count. +func splitOnConsensusOnly(env Env, present []Instance) Cell { + c := Cell{Env: env.Name, State: StateSplit, Severe: true} + if len(present) > 0 { + c.Namespace = present[0].Namespace + } + + tags := map[string]bool{} + for _, in := range present { + tags[in.Tag] = true + } + c.Note = fmt.Sprintf("%d versions present in %s", len(tags), env.Name) + return c } func compare(in Instance, upstream *Instance) Cell { @@ -201,7 +427,7 @@ func compare(in Instance, upstream *Instance) Cell { } if in.Tag != upstream.Tag { - switch order := compareTags(in.Tag, upstream.Tag); { + switch order := CompareTags(in.Tag, upstream.Tag); { case order < 0: c.State = StateBehind c.Note = fmt.Sprintf("behind %s, which runs %s", upstream.Env, upstream.Tag) @@ -230,22 +456,26 @@ func compare(in Instance, upstream *Instance) Cell { return c } -// identity is how one app is recognized across environments. +// Identity is how one app is recognized across environments. // // Name plus namespace, with an environment suffix or prefix tolerated, because // team-a-qa and team-a-prod are one team's namespace in two places and lining // those up is the whole point of the view. -func identity(in Instance) string { - return in.App + "|" + stripEnvToken(in.Namespace) +func Identity(app, namespace string) string { + return app + "|" + StripEnvToken(namespace) } +func identity(in Instance) string { return Identity(in.App, in.Namespace) } + var envTokens = map[string]bool{ "qa": true, "stg": true, "stage": true, "staging": true, "prod": true, "production": true, "prd": true, "dev": true, "test": true, "uat": true, "preprod": true, "sandbox": true, } -func stripEnvToken(ns string) string { +// StripEnvToken removes the environment token from a namespace, so that +// team-a-qa and team-a-prod resolve to one identity. +func StripEnvToken(ns string) string { parts := strings.Split(ns, "-") kept := make([]string, 0, len(parts)) for _, p := range parts { @@ -291,10 +521,10 @@ var versionLike = regexp.MustCompile(`^v?\d+(\.\d+)*([-+._].*)?$`) var numeric = regexp.MustCompile(`\d+`) -// compareTags orders two version tags, returning zero when they cannot be +// CompareTags orders two version tags, returning zero when they cannot be // ordered. Claiming a direction we cannot establish would be worse than saying // they merely differ. -func compareTags(a, b string) int { +func CompareTags(a, b string) int { an, aok := versionParts(a) bn, bok := versionParts(b) if !aok || !bok { @@ -329,6 +559,17 @@ func compareTags(a, b string) int { return 0 } +// Orderable reports whether a tag can take part in an ordering at all. +// +// CompareTags returns zero both for two tags that are equal and for a pair it +// refuses to order, so zero alone cannot tell those apart. A caller picking a +// maximum needs the difference: an unorderable tag that wins the maximum by +// never losing would become a yardstick nothing can be measured against. +func Orderable(tag string) bool { + _, ok := versionParts(tag) + return ok +} + func versionParts(tag string) ([]int, bool) { if !versionLike.MatchString(tag) { return nil, false @@ -357,10 +598,3 @@ func hasPrerelease(tag string) bool { } return false } - -func orElse(v, fallback string) string { - if v == "" { - return fallback - } - return v -} diff --git a/internal/promotion/promotion_test.go b/internal/promotion/promotion_test.go index 48b1580..e268da2 100644 --- a/internal/promotion/promotion_test.go +++ b/internal/promotion/promotion_test.go @@ -1,6 +1,9 @@ package promotion -import "testing" +import ( + "strings" + "testing" +) func envs() []Env { return []Env{{Name: "qa", Risk: "low"}, {Name: "stg", Risk: "medium"}, {Name: "prod", Risk: "high"}} @@ -235,3 +238,671 @@ func TestTagIsReadFromTheImage(t *testing.T) { t.Errorf("tag = %q, want none when the image is unknown", got) } } + +func TestIdentityMatchesAcrossEnvSuffixedNamespaces(t *testing.T) { + qa := Identity("checkout", "team-a-qa") + prod := Identity("checkout", "team-a-prod") + if qa != prod { + t.Errorf("Identity qa = %q, prod = %q; one team's namespace in two places is one app", qa, prod) + } +} + +func TestCompareTagsIsExported(t *testing.T) { + if CompareTags("v1.2.0", "v1.10.0") >= 0 { + t.Error("v1.2.0 should order below v1.10.0") + } + if CompareTags("sha-abc", "sha-def") != 0 { + t.Error("build ids cannot be ordered, and claiming a direction invents one") + } +} + +func TestOrderableSeparatesEqualFromIncomparable(t *testing.T) { + // CompareTags returns zero for both, which is why Orderable exists. + if CompareTags("v1.0.0", "v1.0.0") != 0 || CompareTags("sha-abc", "sha-def") != 0 { + t.Fatal("CompareTags no longer returns zero for equal and for incomparable alike") + } + for _, tag := range []string{"v1.0.0", "1.0", "2.14.0-rc1", "3"} { + if !Orderable(tag) { + t.Errorf("Orderable(%q) = false, want true", tag) + } + } + for _, tag := range []string{"sha-abc123", "latest", "main", ""} { + if Orderable(tag) { + t.Errorf("Orderable(%q) = true, want false", tag) + } + } +} + +func TestStripEnvTokenIsExported(t *testing.T) { + if got := StripEnvToken("team-a-prod"); got != "team-a" { + t.Errorf("StripEnvToken = %q, want team-a", got) + } +} + +func multiEnv() []Env { + return []Env{ + {Name: "qa", Contexts: []string{"qa-cluster"}}, + {Name: "prod", Contexts: []string{"prod-us-east", "prod-eu-west", "prod-ap-south"}}, + } +} + +func prodInstance(ctx, tag, digest string) Instance { + return Instance{ + Env: "prod", Context: ctx, App: "checkout", Namespace: "shop", + Present: true, Image: "reg/checkout:" + tag, Tag: tag, Digest: digest, + Health: "healthy", Ready: "3/3", + } +} + +func cellFor(t *testing.T, rows []Row, env string) Cell { + t.Helper() + for _, c := range rows[0].Cells { + if c.Env == env { + return c + } + } + t.Fatalf("no cell for env %q", env) + return Cell{} +} + +func TestAgreeingContextsCollapseToOneVersion(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + {Env: "qa", Context: "qa-cluster", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.14.0", Digest: "sha256:aa"}, + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", "sha256:bb"), + prodInstance("prod-ap-south", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateBehind { + t.Errorf("state = %q, want %q: three clusters agreeing is one version", c.State, StateBehind) + } + if c.Tag != "v2.13.1" { + t.Errorf("tag = %q, want v2.13.1", c.Tag) + } +} + +func TestDisagreeingContextsNeverCollapse(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.12.0", "sha256:cc"), + prodInstance("prod-ap-south", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit { + t.Fatalf("state = %q, want %q", c.State, StateSplit) + } + if c.Tag != "" { + t.Errorf("tag = %q; a split cell must not pick a winner", c.Tag) + } + if !c.Severe { + t.Error("a split prod is severe") + } + if c.Clusters != 3 { + t.Errorf("clusters = %d, want 3", c.Clusters) + } + if !strings.Contains(c.Note, "2 versions") { + t.Errorf("note = %q, want it to name the version count", c.Note) + } +} + +func TestSameTagDifferentDigestSplitsLoudly(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", "sha256:cc"), + prodInstance("prod-ap-south", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit || !c.Severe { + t.Fatalf("state = %q severe = %v, want a severe split", c.State, c.Severe) + } + if !strings.Contains(c.Note, "digest") { + t.Errorf("note = %q, want it to name the mutable tag", c.Note) + } +} + +func TestPendingDigestDoesNotSplitAnAgreement(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", ""), + prodInstance("prod-ap-south", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State == StateSplit { + t.Fatal("a digest still in flight is not a disagreement") + } + if !c.DigestPending { + t.Error("a missing digest must read as pending, never as a match") + } + if c.Digest != "" { + t.Errorf("digest = %q; claiming one cluster's digest for the pair would be a guess", c.Digest) + } +} + +func TestPartialDeploymentSplits(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", "sha256:bb"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit { + t.Fatalf("state = %q; deployed in two of three clusters is not deployed", c.State) + } + if !strings.Contains(c.Note, "2 of 3") { + t.Errorf("note = %q, want it to name the coverage", c.Note) + } +} + +// Issue #76: the readable clusters here fully agree with each other, so there +// is nothing to flag about them. Before the fix this rendered StateSplit, +// Severe, and counted toward drift and Summary.Split — the same treatment as +// a genuine disagreement, decided only by an unrelated cluster that never +// answered. It must render exactly like the present==0 unreadable case (#75): +// StateDenied, not severe, no drift, and it still must never become upstream, +// because the silent cluster might disagree with the two that spoke. +func TestUnreadableContextBlocksCollapse(t *testing.T) { + envs := multiEnv() + envs[1].Unreadable = []string{"prod-ap-south"} + rows := Build(envs, []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", "sha256:bb"), + }) + r := at(t, rows, "checkout") + c := cell(r, "prod") + if c.State != StateDenied { + t.Fatalf("state = %q, want denied: the readable clusters agree, and the unread one is an unknown, not a disagreement", c.State) + } + if c.Severe { + t.Error("an unread cluster among clusters that agree is not severe") + } + if !strings.Contains(c.Note, "2") || !strings.Contains(c.Note, "1") { + t.Errorf("note = %q, want it to name both the 2 that agreed and the 1 that did not answer", c.Note) + } + if r.Drift != 0 { + t.Fatalf("drift = %d, want 0: agreement plus an unknown must not float this row up the matrix", r.Drift) + } + if s := Summarize(rows); s.Split != 0 { + t.Fatalf("split = %d, want 0: this is not a split cell", s.Split) + } +} + +// The mirror of the case above: a real disagreement among the clusters that +// DID answer must still be reported as a severe split, and must not hide +// behind an unrelated cluster that never answered. Before the fix, split() +// checked len(env.Unreadable) > 0 first and reported only "N of M readable", +// silently dropping the witnessed tag disagreement. +func TestGenuineDisagreementSurvivesAnUnreadableContext(t *testing.T) { + envs := multiEnv() + envs[1].Unreadable = []string{"prod-ap-south"} + rows := Build(envs, []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.12.0", "sha256:cc"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit || !c.Severe { + t.Fatalf("state = %q severe = %v, want a severe split: two readable clusters disagree", c.State, c.Severe) + } + if !strings.Contains(c.Note, "versions") { + t.Errorf("note = %q, want the disagreement named", c.Note) + } + if !strings.Contains(c.Note, "1") || !strings.Contains(c.Note, "did not answer") { + t.Errorf("note = %q, want the unread cluster named too, not just the disagreement", c.Note) + } +} + +// Same principle for a mutable tag: the defect observed among the readable +// clusters must survive an unrelated unread cluster. +func TestMutableTagSurvivesAnUnreadableContext(t *testing.T) { + envs := multiEnv() + envs[1].Unreadable = []string{"prod-ap-south"} + rows := Build(envs, []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", "sha256:cc"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit || !c.Severe { + t.Fatalf("state = %q severe = %v, want a severe split: same tag, different digest", c.State, c.Severe) + } + if !strings.Contains(c.Note, "digest") { + t.Errorf("note = %q, want the mutable tag named", c.Note) + } + if !strings.Contains(c.Note, "did not answer") { + t.Errorf("note = %q, want the unread cluster named alongside the digest mismatch", c.Note) + } +} + +// And for partial coverage: one readable cluster has the app, another +// readable cluster explicitly does not, and a third cluster never answered. +// The coverage gap between the readable clusters is the real finding. +func TestPartialCoverageSurvivesAnUnreadableContext(t *testing.T) { + envs := []Env{ + {Name: "qa", Contexts: []string{"qa-cluster"}}, + {Name: "prod", Contexts: []string{"prod-a", "prod-b", "prod-c", "prod-d"}, Unreadable: []string{"prod-d"}}, + } + rows := Build(envs, []Instance{ + {Env: "prod", Context: "prod-a", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:aa"}, + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit || !c.Severe { + t.Fatalf("state = %q severe = %v, want a severe split: present in one of three readable clusters", c.State, c.Severe) + } + if !strings.Contains(c.Note, "1 of 4") { + t.Errorf("note = %q, want it to name the coverage against the declared total", c.Note) + } + if !strings.Contains(c.Note, "did not answer") { + t.Errorf("note = %q, want the unread cluster named alongside the coverage gap", c.Note) + } +} + +func TestSplitCellIsNotUpstreamForTheNextColumn(t *testing.T) { + envs := []Env{ + {Name: "qa", Contexts: []string{"qa-cluster"}}, + {Name: "stg", Contexts: []string{"stg-a", "stg-b"}}, + {Name: "prod", Contexts: []string{"prod-only"}}, + } + rows := Build(envs, []Instance{ + {Env: "qa", Context: "qa-cluster", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.0.0", Digest: "sha256:aa"}, + {Env: "stg", Context: "stg-a", App: "checkout", Namespace: "shop", Present: true, Tag: "v3.0.0", Digest: "sha256:bb"}, + {Env: "stg", Context: "stg-b", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:cc"}, + {Env: "prod", Context: "prod-only", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.0.0", Digest: "sha256:aa"}, + }) + c := cellFor(t, rows, "prod") + if c.State != StateSame { + t.Errorf("prod state = %q, want %q: prod matches qa, and the split stg never became the basis", c.State, StateSame) + } +} + +func TestSingleContextEnvironmentIsUnchanged(t *testing.T) { + envs := []Env{ + {Name: "qa", Contexts: []string{"qa-cluster"}}, + {Name: "prod", Contexts: []string{"prod-cluster"}}, + } + rows := Build(envs, []Instance{ + {Env: "qa", Context: "qa-cluster", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.0.0", Digest: "sha256:aa"}, + {Env: "prod", Context: "prod-cluster", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:bb"}, + }) + c := cellFor(t, rows, "prod") + if c.State != StateBehind { + t.Errorf("state = %q, want %q: one context per environment behaves exactly as before", c.State, StateBehind) + } +} + +// --- Regression tests added while fixing review findings on task #62 --- + +// Finding 1: absent and denied cells are not comparisons, so they must not +// move the drift count. The old (pre-multi-cluster) code `continue`d past the +// drift increment for both; the multi-cluster rewrite briefly regressed this. +func TestAbsentAndDeniedDoNotCountAsDrift(t *testing.T) { + rows := Build(envs(), []Instance{ + running("qa", "notifications", "v0.9.1"), + {Env: "stg", App: "notifications", Namespace: "team-a", Denied: true, DeniedReason: "needs get deployments"}, + }) + r := at(t, rows, "notifications") + if cell(r, "stg").State != StateDenied { + t.Fatalf("stg cell = %+v, want denied", cell(r, "stg")) + } + if cell(r, "prod").State != StateAbsent { + t.Fatalf("prod cell = %+v, want absent", cell(r, "prod")) + } + if r.Drift != 0 { + t.Fatalf("drift = %d, want 0: a denied cell and an absent cell are not disagreements to count", r.Drift) + } +} + +// A split cell is a genuine disagreement, unlike absent or denied, and must +// still count toward drift. +func TestSplitCellCountsAsDrift(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.12.0", "sha256:cc"), + prodInstance("prod-ap-south", "v2.13.1", "sha256:bb"), + }) + r := at(t, rows, "checkout") + if r.Drift != 1 { + t.Fatalf("drift = %d, want 1: a split prod cell is a real disagreement, and qa is absent (not counted)", r.Drift) + } +} + +// Finding 2: Identity() strips the environment token from the namespace, so +// "shop" and "shop-prod" in the SAME cluster/environment collapse to one app +// identity with two instances in one env. Env.Contexts is empty here, so +// clusters defaults to 1 and len(present) (2) exceeds it. The collapse guard +// must not treat that as "1 of 1 clusters"; it must decide on consensus +// alone. +func TestMoreInstancesThanClustersCollapsesOnAgreement(t *testing.T) { + rows := Build([]Env{{Name: "prod"}}, []Instance{ + {Env: "prod", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:aa"}, + {Env: "prod", App: "checkout", Namespace: "shop-prod", Present: true, Tag: "v1.0.0", Digest: "sha256:aa"}, + }) + c := cellFor(t, rows, "prod") + if c.State == StateSplit { + t.Fatalf("state = %q; two agreeing instances must not split just because there are more of them than the environment declares clusters", c.State) + } + if c.Severe { + t.Error("agreeing instances should not render severe") + } +} + +func TestMoreInstancesThanClustersSplitsOnDisagreementWithoutABogusCount(t *testing.T) { + rows := Build([]Env{{Name: "prod"}}, []Instance{ + {Env: "prod", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:aa"}, + {Env: "prod", App: "checkout", Namespace: "shop-prod", Present: true, Tag: "v2.0.0", Digest: "sha256:bb"}, + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit || !c.Severe { + t.Fatalf("state = %q severe = %v, want a severe split: two instances in one environment disagree", c.State, c.Severe) + } + if strings.Contains(c.Note, "1 cluster") || strings.Contains(c.Note, "of 1") { + t.Errorf("note = %q; the environment declares one cluster but two instances exist here, so citing that count would be nonsense on a red cell", c.Note) + } + if c.Clusters != 0 { + t.Errorf("clusters = %d, want 0: the declared cluster count cannot be trusted once instances outnumber it", c.Clusters) + } +} + +// Finding 3: a mutable tag (same tag, different digest) is a defect. Partial +// coverage is a schedule. When both are true at once, the defect must win the +// note, not the coverage fraction. +func TestDigestMismatchOutranksPartialCoverage(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.13.1", "sha256:cc"), + }) + c := cellFor(t, rows, "prod") + if c.State != StateSplit || !c.Severe { + t.Fatalf("state = %q severe = %v, want a severe split", c.State, c.Severe) + } + if !strings.Contains(c.Note, "digest") { + t.Errorf("note = %q, want the digest mismatch named even though coverage is also partial", c.Note) + } + if strings.Contains(c.Note, "of 3") { + t.Errorf("note = %q; a mutable tag is a defect and should outrank a coverage fraction, not share the note with it", c.Note) + } +} + +// Finding 4: Summary.Split is a named deliverable with no assertions on it. +// Summarize breaks on the first split cell in a row, so a row with two split +// cells must still count once. +func TestSummaryCountsARowWithTwoSplitCellsOnce(t *testing.T) { + twoSplitEnvs := []Env{ + {Name: "stg", Contexts: []string{"stg-a", "stg-b"}}, + {Name: "prod", Contexts: []string{"prod-a", "prod-b"}}, + } + rows := Build(twoSplitEnvs, []Instance{ + {Env: "stg", Context: "stg-a", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:aa"}, + {Env: "stg", Context: "stg-b", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.0.0", Digest: "sha256:bb"}, + {Env: "prod", Context: "prod-a", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:aa"}, + {Env: "prod", Context: "prod-b", App: "checkout", Namespace: "shop", Present: true, Tag: "v3.0.0", Digest: "sha256:cc"}, + }) + s := Summarize(rows) + if s.Split != 1 { + t.Fatalf("split = %d, want 1: one row with two split cells still counts once", s.Split) + } +} + +func TestSummaryCountsOneSplitRow(t *testing.T) { + rows := Build(multiEnv(), []Instance{ + prodInstance("prod-us-east", "v2.13.1", "sha256:bb"), + prodInstance("prod-eu-west", "v2.12.0", "sha256:cc"), + prodInstance("prod-ap-south", "v2.13.1", "sha256:bb"), + }) + s := Summarize(rows) + if s.Split != 1 { + t.Fatalf("split = %d, want 1", s.Split) + } +} + +// Finding 5: the old code set Namespace on both absent and denied cells when +// the instance was known. resolve() dropped it. +func TestDeniedCellKeepsNamespace(t *testing.T) { + rows := Build(envs(), []Instance{ + running("qa", "notifications", "v0.9.1"), + {Env: "prod", App: "notifications", Namespace: "team-a", Denied: true, DeniedReason: "needs get deployments"}, + }) + c := cell(at(t, rows, "notifications"), "prod") + if c.Namespace != "team-a" { + t.Errorf("namespace = %q, want team-a: the instance is known, even though it is denied", c.Namespace) + } +} + +func TestAbsentCellKeepsNamespaceWhenTheInstanceIsKnown(t *testing.T) { + rows := Build(envs(), []Instance{ + running("qa", "notifications", "v0.9.1"), + {Env: "prod", App: "notifications", Namespace: "team-a", Present: false}, + }) + c := cell(at(t, rows, "notifications"), "prod") + if c.Namespace != "team-a" { + t.Errorf("namespace = %q, want team-a: the instance is known even though it is not present", c.Namespace) + } +} + +// --- Regression tests added while fixing round-2 review findings --- + +// Finding A: round 1's "more present instances than declared clusters" +// branch checked len(present) > clusters and decided on consensus alone, +// but returned before env.Unreadable was consulted. An environment with an +// unreadable context must never collapse to agreement, no matter how many +// instances are present, because the clusters nobody read might disagree. +// It must also never become upstream for the next column. +func TestUnreadableBlocksCollapseEvenWhenInstancesOutnumberClusters(t *testing.T) { + env := Env{Name: "prod", Contexts: []string{"prod-a"}, Unreadable: []string{"prod-a"}} + group := []Instance{ + {Env: "prod", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:aa"}, + {Env: "prod", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:aa"}, + } + _, c, ok := resolve(env, group, nil) + if ok { + t.Fatal("ok = true; an environment with an unreadable context must never become upstream, even when the present instances agree and outnumber the declared clusters") + } + if c.State != StateSplit { + t.Fatalf("state = %q, want split: unreadable must block collapse even when instances outnumber declared clusters", c.State) + } +} + +// Finding B: the digest-mismatch note in split() cited the declared cluster +// count, not the number of clusters that actually reported the tag. Round 1 +// made this branch reachable under partial coverage, where those two numbers +// differ, and the note ends up describing a set of clusters that isn't the +// one it's talking about. +func TestDigestMismatchNoteCitesReportedClustersNotDeclared(t *testing.T) { + env := Env{Name: "prod", Contexts: []string{"prod-a", "prod-b", "prod-c"}} + group := []Instance{ + {Env: "prod", Context: "prod-a", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:aa"}, + {Env: "prod", Context: "prod-b", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:bb"}, + } + _, c, _ := resolve(env, group, nil) + if !strings.Contains(c.Note, "across 2 clusters") { + t.Errorf("note = %q, want it to cite the 2 clusters that actually reported the tag", c.Note) + } + if strings.Contains(c.Note, "across 3 clusters") { + t.Errorf("note = %q; 3 is the declared total, not the count that reported the tag", c.Note) + } +} + +// Finding C: resolve() took group[0].Namespace unconditionally, so a denied +// instance with an unknown (empty) namespace ahead of a later instance that +// does know its namespace still dropped the namespace from the cell. +func TestNamespaceIsFirstNonEmptyInTheGroup(t *testing.T) { + env := Env{Name: "prod"} + group := []Instance{ + {Env: "prod", App: "notifications", Namespace: "", Denied: true, DeniedReason: "needs get deployments"}, + {Env: "prod", App: "notifications", Namespace: "team-a", Denied: true, DeniedReason: "still denied"}, + } + _, c, _ := resolve(env, group, nil) + if c.Namespace != "team-a" { + t.Errorf("namespace = %q, want team-a: the first non-empty namespace in the group, not always group[0]", c.Namespace) + } +} + +// --- Issue #75: an unread environment must not render "not deployed here" --- +// +// service.go builds its denied placeholders with an empty App name, and the +// `in.App != ""` filter strips them before promotion.Build ever sees them. So +// resolve()'s len(present) == 0 path saw denied == 0 for an environment +// nobody could read, and returned StateAbsent. Env.Unreadable now carries +// exactly the information resolve() was ignoring. + +// A fully unreadable environment (every context in Unreadable) with no +// present instances must render denied, not absent, and must name how many +// clusters did not answer. +func TestFullyUnreadableEnvironmentIsDeniedNotAbsent(t *testing.T) { + env := Env{Name: "prod", Contexts: []string{"prod-a", "prod-b", "prod-c"}, Unreadable: []string{"prod-a", "prod-b", "prod-c"}} + _, c, ok := resolve(env, nil, nil) + if c.State == StateAbsent { + t.Fatalf("state = %q, want anything but absent: no context in this environment answered", c.State) + } + if c.State != StateDenied { + t.Fatalf("state = %q, want denied: every context failed to answer, same fact as a single denied cluster", c.State) + } + if !strings.Contains(c.Note, "3") { + t.Errorf("note = %q, want it to name how many clusters did not answer", c.Note) + } + if ok { + t.Error("ok = true; a fully unreadable environment must never become the upstream for the next column") + } +} + +// The broader form of the same bug: an app absent from the READABLE clusters +// of a partially readable environment might still be running in the cluster +// nobody read. This must never render absent either. +func TestPartiallyUnreadableEnvironmentIsNotAbsentEither(t *testing.T) { + env := Env{Name: "prod", Contexts: []string{"prod-a", "prod-b", "prod-c"}, Unreadable: []string{"prod-c"}} + _, c, ok := resolve(env, nil, nil) + if c.State == StateAbsent { + t.Fatalf("state = %q, want anything but absent: prod-c never answered and might have it", c.State) + } + // Round-1 review finding: this test asserted only "not absent," so a + // future refactor routing this case through StateSplit (a genuine + // disagreement) instead of StateDenied (an unknown) would flip + // Row.Drift, Summary.Split, and the matrix sort order, and this test + // would stay green. Pin the exact state. + if c.State != StateDenied { + t.Fatalf("state = %q, want denied: the silent cluster is an unknown, not a disagreement observed among present instances", c.State) + } + if c.Note == "" { + t.Fatal("a cell that cannot claim absence should say what was read and what was not") + } + if !strings.Contains(c.Note, "2") || !strings.Contains(c.Note, "1") { + t.Errorf("note = %q, want it to name both the clusters read (2) and the cluster that did not answer (1)", c.Note) + } + // Round-1 review finding 3: the original note ("not found in the 2 of 3 + // clusters read; 1 did not answer and may have it") was the longest + // string in the file and renders inside a matrix cell. Every other split() + // note is terse; this one must carry the same two facts without the + // padding. + if c.Note != "not in the 2 clusters read; 1 did not answer" { + t.Errorf("note = %q, want a terse note carrying the same two facts (clusters read, clusters unread)", c.Note) + } + if ok { + t.Error("ok = true; a partially unreadable environment must never become the upstream for the next column") + } +} + +// Round-1 review finding 1: the drift behavior the commit and spec both rest +// on (routing the unread case through StateDenied avoids wrongly inflating +// drift) was never asserted. An app present in one environment, with the +// other environment partially unreadable and nothing present there, must not +// count as drift, and must not be counted as a split row either: the silent +// cluster is a fact nobody observed, not a disagreement. +func TestPartiallyUnreadableEnvironmentDoesNotInflateDriftOrSplit(t *testing.T) { + envs := []Env{ + {Name: "qa", Contexts: []string{"qa-a"}}, + {Name: "prod", Contexts: []string{"p-a", "p-b", "p-c"}, Unreadable: []string{"p-c"}}, + } + rows := Build(envs, []Instance{ + {Env: "qa", Context: "qa-a", App: "checkout", Namespace: "shop", Present: true, Tag: "v1.0.0", Digest: "sha256:aa"}, + }) + r := at(t, rows, "checkout") + prod := cell(r, "prod") + if prod.State != StateDenied { + t.Fatalf("prod state = %q, want denied", prod.State) + } + if r.Drift != 0 { + t.Fatalf("drift = %d, want 0: the unread cluster is an unknown, not a disagreement, and must not float this row up the matrix", r.Drift) + } + s := Summarize(rows) + if s.Split != 0 { + t.Fatalf("split = %d, want 0: a denied cell is not a split cell and must not be counted in Summary.Split", s.Split) + } +} + +// Regression guard: when every context in the environment actually answered +// and none of them have the app, "not deployed here" still stands. This is +// the ordinary case the fix must not disturb. +func TestFullyReadableAbsentEnvironmentStillReadsAbsent(t *testing.T) { + env := Env{Name: "prod", Contexts: []string{"prod-a", "prod-b", "prod-c"}} + _, c, ok := resolve(env, nil, nil) + if c.State != StateAbsent || c.Note != "not deployed here" { + t.Fatalf("cell = %+v, want absent/\"not deployed here\": every context answered and found nothing", c) + } + if ok { + t.Error("ok = true; an absent cell must never become upstream") + } +} + +// A cell rendered denied because its environment was unreadable (fully or +// partially, with zero present instances) must not become the basis the +// next column is compared against, exactly like the pre-existing denied and +// absent paths. +func TestUnreadableEmptyEnvironmentDoesNotBecomeUpstream(t *testing.T) { + envs := []Env{ + {Name: "qa", Contexts: []string{"qa-cluster"}}, + {Name: "stg", Contexts: []string{"stg-a", "stg-b", "stg-c"}, Unreadable: []string{"stg-a", "stg-b", "stg-c"}}, + {Name: "prod", Contexts: []string{"prod-cluster"}}, + } + rows := Build(envs, []Instance{ + {Env: "qa", Context: "qa-cluster", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.0.0", Digest: "sha256:aa"}, + {Env: "prod", Context: "prod-cluster", App: "checkout", Namespace: "shop", Present: true, Tag: "v2.0.0", Digest: "sha256:aa"}, + }) + c := cellFor(t, rows, "prod") + if c.State != StateSame { + t.Errorf("prod state = %q, want %q: prod matches qa, and the unreadable stg column never became the basis", c.State, StateSame) + } + stg := cell(at(t, rows, "checkout"), "stg") + if stg.State != StateDenied { + t.Errorf("stg state = %q, want denied", stg.State) + } +} + +// Finding-5-style guard: the pre-existing denied path, where a real named +// instance carries Denied: true, must be untouched by this fix. +func TestNamedDeniedInstanceStillDeniedAlongsideUnreadable(t *testing.T) { + env := Env{Name: "prod", Contexts: []string{"prod-a", "prod-b"}, Unreadable: []string{"prod-a"}} + group := []Instance{ + {Env: "prod", App: "checkout", Namespace: "shop", Denied: true, DeniedReason: "needs get deployments"}, + } + _, c, ok := resolve(env, group, nil) + if c.State != StateDenied || c.Note != "needs get deployments" { + t.Fatalf("cell = %+v, want the named denial reason preserved untouched", c) + } + if ok { + t.Error("ok = true; a denied cell must never become upstream") + } +} + +// --- Round-1 review findings on task #75 --- + +// Finding 2: the fully-unread branch compared unread against len(env.Contexts) +// but printed clusters (max(len(env.Contexts), 1)). An Env with no Contexts +// but a populated Unreadable — the shape most direct-call tests in this file +// use — printed a nonsensical "2 of 1 clusters did not answer". clusters must +// account for Unreadable outgrowing the declared Contexts, and the comparison +// and the message must use that same number. +func TestUnreadableCountNeverExceedsPrintedClusterCount(t *testing.T) { + env := Env{Name: "prod", Unreadable: []string{"prod-a", "prod-b"}} + _, c, ok := resolve(env, nil, nil) + if c.State != StateDenied { + t.Fatalf("state = %q, want denied", c.State) + } + if strings.Contains(c.Note, "of 1") { + t.Errorf("note = %q; 1 is the bogus single-cluster default, not the 2 unread contexts actually named", c.Note) + } + if !strings.Contains(c.Note, "2 of 2") { + t.Errorf("note = %q, want \"2 of 2 clusters did not answer\": the comparison and the message must use the same cluster count", c.Note) + } + if c.Clusters != 2 { + t.Errorf("clusters = %d, want 2: the declared count must grow to cover the named unreadable contexts", c.Clusters) + } + if ok { + t.Error("ok = true; a denied cell must never become upstream") + } +} diff --git a/web/dist/index.html b/web/dist/index.html index bdc8a71..fd2d50a 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -7,7 +7,7 @@ kubeside - + diff --git a/web/e2e/fixtures.mjs b/web/e2e/fixtures.mjs index 52d5f97..f74b17a 100644 --- a/web/e2e/fixtures.mjs +++ b/web/e2e/fixtures.mjs @@ -108,7 +108,12 @@ export const configView = { }; export const promotionView = { - envs: [{ name: "qa", risk: "low", context: "qa1" }, { name: "prod", risk: "high", context: "prod-us-east" }], + envs: [ + { name: "qa", risk: "low", context: "qa1" }, + // Three contexts, so the split cell below (#81) represents a real shape: + // an environment whose clusters disagree, not a single-context column. + { name: "prod", risk: "high", context: "prod-us-east", contexts: ["prod-us-east", "prod-eu-west", "prod-ap-south"] }, + ], rows: [ { app: "search-indexer", namespace: "team-b", drift: 1, ahead: true, @@ -117,6 +122,28 @@ export const promotionView = { { env: "prod", state: "ahead", tag: "v3.1.0-rc4", health: "failed", ready: "0/3", severe: true, note: "ahead of qa; nothing promoted this", revisionAt: "2026-07-25T02:07:00Z" }, ], }, + { + // The only row exercising PromotionScreen.tsx's split branch, added + // for #81: nothing in the Playwright suite rendered it before, and it + // puts a full backend sentence into a matrix column sized for a + // version string. Sorted here (drift 1, ahead false) alphabetically + // ahead of notifications and payments, its drift-1 siblings. + app: "api-gateway", namespace: "team-a", drift: 1, + cells: [ + { env: "qa", state: "same", tag: "v5.2.0", health: "healthy", ready: "2/2", revisionAt: "2026-07-20T09:00:00Z" }, + { + env: "prod", state: "split", severe: true, clusters: 3, + note: "one tag across 3 clusters but 2 digests: the tag is mutable and these are not the same code", + }, + ], + }, + { + app: "notifications", namespace: "team-b", drift: 1, + cells: [ + { env: "qa", state: "same", tag: "v0.9.1", health: "healthy", ready: "2/2", revisionAt: "2026-07-24T12:00:00Z" }, + { env: "prod", state: "absent", note: "not deployed here" }, + ], + }, { app: "payments", namespace: "team-a", drift: 1, cells: [ @@ -131,15 +158,53 @@ export const promotionView = { { env: "prod", state: "same", tag: "v2.14.0", health: "healthy", ready: "6/6", digestPending: true, revisionAt: "2026-07-25T00:00:00Z" }, ], }, + ], + summary: { apps: 5, drifted: 4, ahead: 1, split: 1 }, +}; + +// One row per reachable state the fleet screen distinguishes: present (one +// healthy and current, one present but behind with its digest still +// unverified), absent, denied, and unreachable. Pending has no producer from +// Service.Fleet by construction, so it is not represented here. +// Row order matches internal/fleet.severity(): unreachable(5), behind(4), +// denied(3), then present-and-current/absent tie at 0, broken alphabetically +// by context name. See #80 — a screenshot approving a row order the backend +// cannot produce approves nothing. +export const fleetView = { + app: "payments", namespace: "team-a", + newest: "v1.8.2", clusters: 5, present: 2, behind: 1, mutableTag: false, digestUnverified: 1, + rows: [ { - app: "notifications", namespace: "team-b", drift: 1, - cells: [ - { env: "qa", state: "same", tag: "v0.9.1", health: "healthy", ready: "2/2", revisionAt: "2026-07-24T12:00:00Z" }, - { env: "prod", state: "absent", note: "not deployed here" }, - ], + context: "prod-eu-west", clusterId: "", env: "prod-eu-west", envColor: "red", envRisk: "high", + state: "unreachable", note: "the cluster did not answer", + }, + { + // The env column prints the resolved name verbatim rather than a + // qa/stg/prod token, and this is the row the layout bug lived on: + // "prod-us-east" uppercase with .09em tracking overflowed the old + // 84px Env column and pushed Verdict out of place. + context: "prod-us-east", clusterId: "prod-us-east", env: "prod-us-east", envColor: "red", envRisk: "high", + namespace: "team-a", state: "present", + image: "ghcr.io/acme/payments:1.8.0", tag: "v1.8.0", digestPending: true, + health: "degraded", ready: "5/6", revisionAt: "2026-07-14T09:12:00Z", + behind: true, note: "behind v1.8.2", + }, + { + context: "stg1", clusterId: "stg1", env: "stg", envColor: "amber", envRisk: "medium", + state: "denied", note: "list deployments forbidden; ask for list deployments in team-a", + }, + { + context: "dr-frankfurt", clusterId: "dr-frankfurt", env: "dr-frankfurt", envColor: "violet", envRisk: "high", + namespace: "team-a", state: "absent", note: "not deployed here", + }, + { + context: "qa1", clusterId: "qa1", env: "qa", envColor: "green", envRisk: "low", + namespace: "team-a", state: "present", + image: "ghcr.io/acme/payments:1.8.2", tag: "v1.8.2", + digest: "sha256:aaaa000000000000000000000000000000000000000000000000000000000000", + health: "healthy", ready: "2/2", revisionAt: "2026-07-24T09:00:00Z", behind: false, }, ], - summary: { apps: 4, drifted: 3, ahead: 1 }, }; export const capabilities = { diff --git a/web/e2e/screens.spec.ts b/web/e2e/screens.spec.ts index 32fdce8..d103605 100644 --- a/web/e2e/screens.spec.ts +++ b/web/e2e/screens.spec.ts @@ -67,9 +67,42 @@ test("promotion matrix", async ({ page }) => { await expect(page.locator("table.matrix tbody tr").first()).toContainText("search-indexer"); await expect(page.locator(".drift-flag.ahead")).toHaveText("ahead of upstream"); await expect(page.locator(".cell-none")).toHaveCount(1); + // A split cell renders the backend's note verbatim, in the error hue, and + // clicking it opens the fleet screen for the app it belongs to — this is + // the only promotion UI the fleet branch added, and until #81 nothing in + // this suite rendered it. + const split = page.locator(".cell-tag", { hasText: "one tag across 3 clusters but 2 digests" }); + await expect(split).toBeVisible(); + await expect(split).toHaveCSS("color", "rgb(229, 72, 77)"); // var(--err) + await split.click(); + await expect(page).toHaveURL(/#fleet\/api-gateway\/team-a/); + await page.goBack(); await expect(page).toHaveScreenshot("promotion.png", { fullPage: true }); }); +test("fleet", async ({ page }) => { + await page.goto(`/${token}#fleet/payments/team-a`); + await expect(page.getByRole("heading", { name: "payments" })).toBeVisible(); + await expect(page.locator("table.tbl tbody tr")).toHaveCount(5); + // Sorted by what needs attention, not by fixture order: unreachable first, + // then behind, then denied, then the two clusters with nothing to report. + await expect(page.locator("table.tbl tbody tr").first()).toContainText("prod-eu-west"); + await expect(page.locator("table.tbl tbody tr").nth(1)).toContainText("prod-us-east"); + await expect(page.locator("table.tbl tbody tr").nth(2)).toContainText("stg1"); + await expect(page.locator("table.tbl tbody tr").last()).toContainText("qa1"); + // Unreachable and denied are different facts and must read differently, not + // as two spellings of "no answer". + await expect(page.locator("table.tbl tbody tr", { hasText: "prod-eu-west" })).toContainText("no answer"); + await expect(page.locator("table.tbl tbody tr", { hasText: "stg1" })).toContainText("no access"); + // A cluster the app is not deployed to is neither: a plain dash, no note. + await expect(page.locator("table.tbl tbody tr", { hasText: "dr-frankfurt" }).locator(".cell-none")).toHaveCount(2); + // The row behind the newest tag says so, and the env chip for a resolved + // name like prod-us-east fits its column rather than pushing Verdict out of + // place. + await expect(page.locator("table.tbl tbody tr", { hasText: "prod-us-east" })).toContainText("behind"); + await expect(page).toHaveScreenshot("fleet.png", { fullPage: true }); +}); + test("command palette", async ({ page }) => { await page.goto(`/${token}#apps/qa1`); await expect(page.locator("table.tbl tbody tr")).toHaveCount(4); diff --git a/web/e2e/screens.spec.ts-snapshots/fleet-chromium-darwin.png b/web/e2e/screens.spec.ts-snapshots/fleet-chromium-darwin.png new file mode 100644 index 0000000..e06a117 Binary files /dev/null and b/web/e2e/screens.spec.ts-snapshots/fleet-chromium-darwin.png differ diff --git a/web/e2e/screens.spec.ts-snapshots/fleet-chromium-linux.png b/web/e2e/screens.spec.ts-snapshots/fleet-chromium-linux.png new file mode 100644 index 0000000..899c696 Binary files /dev/null and b/web/e2e/screens.spec.ts-snapshots/fleet-chromium-linux.png differ diff --git a/web/e2e/screens.spec.ts-snapshots/promotion-chromium-darwin.png b/web/e2e/screens.spec.ts-snapshots/promotion-chromium-darwin.png index 952e896..96191ba 100644 Binary files a/web/e2e/screens.spec.ts-snapshots/promotion-chromium-darwin.png and b/web/e2e/screens.spec.ts-snapshots/promotion-chromium-darwin.png differ diff --git a/web/e2e/screens.spec.ts-snapshots/promotion-chromium-linux.png b/web/e2e/screens.spec.ts-snapshots/promotion-chromium-linux.png index 41d1233..d8b0274 100644 Binary files a/web/e2e/screens.spec.ts-snapshots/promotion-chromium-linux.png and b/web/e2e/screens.spec.ts-snapshots/promotion-chromium-linux.png differ diff --git a/web/e2e/server.mjs b/web/e2e/server.mjs index 84afd47..2fa154e 100644 --- a/web/e2e/server.mjs +++ b/web/e2e/server.mjs @@ -36,6 +36,7 @@ const server = createServer(async (req, res) => { case "/api/app": return json(res, fx.appDetail); case "/api/config": return json(res, fx.configView); case "/api/promotion": return json(res, fx.promotionView); + case "/api/fleet": return json(res, fx.fleetView); case "/api/can": return json(res, fx.capabilities); case "/api/forwards": return json(res, fx.forwards); case "/api/timeline": return json(res, fx.appDetail.timeline); diff --git a/web/package-lock.json b/web/package-lock.json index 7e2bf25..bdb81d9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,15 +14,78 @@ }, "devDependencies": { "@playwright/test": "1.61.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^30.0.1", "marked": "^18.0.7", "typescript": "^5.7.2", "vite": "^6.0.7", "vitest": "^3.2.7" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -257,6 +320,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -305,6 +378,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -747,6 +973,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1484,6 +1728,99 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1725,6 +2062,31 @@ "addons/*" ] }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -1737,6 +2099,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1760,6 +2132,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -1859,6 +2241,27 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1866,6 +2269,35 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1884,6 +2316,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1894,12 +2333,30 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.396", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", @@ -1907,6 +2364,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2038,6 +2508,36 @@ "node": ">=6" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2045,6 +2545,57 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2088,6 +2639,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2111,6 +2673,23 @@ "node": ">= 20" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2119,9 +2698,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2147,6 +2726,19 @@ "node": ">=18" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2260,6 +2852,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -2281,6 +2899,14 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2360,6 +2986,30 @@ } } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -2405,6 +3055,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2452,6 +3115,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -2472,6 +3148,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2533,6 +3216,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2553,6 +3282,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2798,6 +3537,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2815,6 +3602,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index a6c97a8..d92d295 100644 --- a/web/package.json +++ b/web/package.json @@ -20,9 +20,12 @@ }, "devDependencies": { "@playwright/test": "1.61.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^30.0.1", "marked": "^18.0.7", "typescript": "^5.7.2", "vite": "^6.0.7", diff --git a/web/src/App.tsx b/web/src/App.tsx index ffc36cd..c408236 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,6 +8,7 @@ import { ConfigScreen } from "./ConfigScreen"; import { DiffScreen } from "./DiffScreen"; import { ExecScreen } from "./ExecScreen"; import { PromotionScreen } from "./PromotionScreen"; +import { FleetScreen } from "./FleetScreen"; import { envToken } from "./health"; import { parseRoute, routeHash, type Route } from "./route"; import { Palette } from "./Palette"; @@ -50,7 +51,7 @@ export function App() { // The current context leads; select it so the developer's usual // workspace renders first, unless a permalink named another. setRoute((r) => { - if (r.screen === "promotion") return r; + if (r.screen === "promotion" || r.screen === "fleet") return r; if (r.context && cs.some((c) => c.name === r.context)) return r; const cur = cs.find((c) => c.current) ?? cs[0]; return cur ? { screen: "apps", context: cur.name } : r; @@ -93,7 +94,7 @@ export function App() { ); } - const current = route.screen === "promotion" + const current = route.screen === "promotion" || route.screen === "fleet" ? null : (contexts.find((c) => c.name === route.context) ?? null); @@ -121,9 +122,13 @@ export function App() { onOpenApp={(context, namespace, workload) => context && go({ screen: "app", context, namespace, workload }) } + onOpenFleet={(app, namespace) => go({ screen: "fleet", app, namespace })} /> )} - {route.screen !== "promotion" && !current && } + {route.screen === "fleet" && ( + + )} + {route.screen !== "promotion" && route.screen !== "fleet" && !current && } {current && route.screen === "apps" && ( go({ screen, context: current.name, namespace: route.namespace, workload: route.workload }) } + onOpenFleet={() => go({ screen: "fleet", app: route.workload, namespace: route.namespace })} onBack={() => go({ screen: "apps", context: current.name })} /> )} diff --git a/web/src/AppDetailScreen.tsx b/web/src/AppDetailScreen.tsx index 8c80c24..4bb5097 100644 --- a/web/src/AppDetailScreen.tsx +++ b/web/src/AppDetailScreen.tsx @@ -22,12 +22,14 @@ export function AppDetailScreen({ namespace, workload, onNavigate, + onOpenFleet, onBack, }: { context: ContextView; namespace: string; workload: string; onNavigate: (screen: "config" | "logs" | "diff" | "exec") => void; + onOpenFleet: () => void; onBack: () => void; }) { const [view, setView] = useState(null); @@ -154,6 +156,9 @@ export function AppDetailScreen({ + {confirmingForward && ( diff --git a/web/src/FleetScreen.test.tsx b/web/src/FleetScreen.test.tsx new file mode 100644 index 0000000..acbd2e0 --- /dev/null +++ b/web/src/FleetScreen.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +// +// Issue #83: no .tsx file was reachable by vitest before this, so the +// component's own wiring — as opposed to the pure helpers beside it — shipped +// a real bug undetected. This test proves the harness would have caught it: +// an environment named "production" must resolve to the red/prod token and +// print its name verbatim, not fall back to "unc" the way the shipped bug did. + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { FleetScreen } from "./FleetScreen"; +import { api } from "./api"; +import type { FleetView } from "./api"; + +vi.mock("./api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { ...actual.api, fleet: vi.fn() } }; +}); + +afterEach(cleanup); + +const view: FleetView = { + app: "checkout", + namespace: "shop", + clusters: 1, + present: 1, + behind: 0, + newest: "v2.14.0", + rows: [ + { + context: "prod-us-east", + clusterId: "prod-us-east", + env: "production", + envColor: "red", + envRisk: "high", + namespace: "shop", + state: "present", + tag: "v2.14.0", + health: "healthy", + ready: "3/3", + }, + ], +}; + +test("an environment named production reads as prod, not unclassified", async () => { + vi.mocked(api.fleet).mockResolvedValue(view); + + render(); + + const row = await screen.findByText("production"); + const tr = row.closest("tr"); + if (!tr) throw new Error("no row rendered"); + + // The bug this harness exists to catch: a helper that matched the literal + // strings "qa"/"stg"/"prod" fell back to "unc" for every real environment + // name, since Classify keeps the raw name and only qa/stg/prod match + // verbatim. envToken({color, risk}) is the fix — it must resolve red/high + // to "prod" regardless of what the name spells. + expect(tr).toHaveAttribute("data-env", "prod"); + expect(tr).not.toHaveAttribute("data-env", "unc"); + + // The chip prints the resolved name verbatim, the way every other screen + // does, rather than a synthesized tier label. + expect(row.textContent).toBe("production"); +}); diff --git a/web/src/FleetScreen.tsx b/web/src/FleetScreen.tsx new file mode 100644 index 0000000..c2a40bc --- /dev/null +++ b/web/src/FleetScreen.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState } from "react"; +import { api, type FleetRow, type FleetView } from "./api"; +import { Glyph } from "./Status"; +import { routeHash } from "./route"; +import { envToken, loudestEnv } from "./health"; +import { notPresentLabel, readySplit, verdictBadge } from "./fleet"; + +// Screen 7. Is every cluster running the latest version. +// +// The promotion matrix compares environments side by side. This compares the +// clusters inside and across them, which is what a team running prod in three +// regions asks and the matrix cannot phrase. +// +// Five states, never conflated. A cluster behind a VPN is not a cluster +// without the app, and rendering both blank would answer the screen's own +// question with a guess. +// +// Environment colour is read off the row, never derived from its name. The +// backend classifies by keyword token and returns the name untouched, so +// prod-us-east and production are red while spelling no tier this screen could +// compare against. The chip prints the resolved name verbatim, the way every +// other screen does, and data-env carries the colour. + +export function FleetScreen({ app, namespace }: { app: string; namespace: string }) { + const [view, setView] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + setView(null); + setErr(null); + api.fleet(app, namespace) + .then((v) => { if (alive) setView(v); }) + .catch((e) => { if (alive) setErr(String(e.message ?? e)); }); + return () => { alive = false; }; + }, [app, namespace]); + + return ( + <> +
+
+ {app} + · + every cluster +
+ + +
+ +
+
+

{app}

+ + {view + ? `${view.clusters} clusters · ${view.present} running it · ${view.behind} behind${view.newest ? ` · newest is ${view.newest}` : ""}` + : "asking every cluster"} + +
+ + {err && ( +
+
Could not ask the clusters
+
{err}
+
+ )} + {!err && !view && ( +
asking every cluster…
+ )} + + {view?.mutableTag && ( +
r.mutableTag).map((r) => ({ color: r.envColor, risk: r.envRisk })))} + style={{ marginBottom: "var(--s4)" }} + > + + + One tag resolves to more than one digest. These clusters claim the same + version and are not running the same code. + +
+ )} + + {view && view.clusters === 0 && ( +
+
No clusters to ask
+
+ The kubeconfig names no contexts, so nothing was asked about {app}. A table of nothing would + read as an answer, and there is none yet. +
+
+ )} + + {view && view.clusters > 0 && view.present === 0 && ( +
+
Not found in any of {view.clusters} clusters
+
+ The table below shows what each cluster answered. Check the name, or the namespace it lives in. +
+
+ )} + + {view && view.clusters > 0 && ( + <> +
+
+ + + + + + + + + + + + {view.rows.map((r) => ( + + + + + + + + ))} + +
ClusterEnvVersionHealthVerdict
+ {r.context} + {r.aliases && r.aliases.length > 0 && ( + also {r.aliases.join(", ")} + )} + {/* The match is by identity, which strips the environment token, so + a row can report a different namespace from the one asked for. + Nothing else on the screen names it, and a substitution nobody + sees is a substitution nobody can challenge. */} + {r.namespace && r.namespace !== namespace && ( + in {r.namespace}, not {namespace} + )} + {r.env} + + + +
+
+
+ +
+
+
Sorted by what needs attention
+

A mutable tag sits at the top, and below it the less a cluster told us the higher it sits: + the version you cannot see could be worse than the old one you can. A cluster the app is not + deployed to stays with the healthy ones, because that is a schedule and not a disagreement.

+
+
+
Four ways to have no version
+

A cluster can carry no version for four different reasons: it did not answer, it refused to + let us look, its image is pinned by digest, or the app simply is not deployed there. Rendering + those the same would answer the screen's own question with a guess.

+
+
+
The refusal names the verb
+

A denied row carries the rule that would fix it, not just the word no. A developer can paste + it into a Role request without a second round trip.

+
+
+
Pending is not agreement
+ +
+
+ + )} +
+ + ); +} + +// A fleet with every digest resolved has no count to report, and printing +// "0 present rows still have no digest" states a problem nobody has. The rule +// still belongs on the screen, so it falls back to the rule itself. +function DigestNote({ count }: { count: number }) { + if (count === 0) { + return ( +

A present row whose digest never arrived counts as unverified rather than matching. A digest that + never arrived cannot be evidence that two clusters agree.

+ ); + } + const one = count === 1; + return ( +

{count} present row{one ? "" : "s"} still {one ? "has" : "have"} no digest, so {one ? "it counts" : "they count"} as + unverified rather than matching. A digest that never arrived cannot be evidence that two clusters + agree.

+ ); +} + +function VersionCell({ r }: { r: FleetRow }) { + if (r.state !== "present") { + return ; + } + if (!r.tag) { + return no tag; + } + const color = r.mutableTag ? "var(--err)" : r.behind ? "var(--warn)" : undefined; + return {r.tag}; +} + +function HealthCell({ r }: { r: FleetRow }) { + if (r.state === "present") { + return ( + <> + + {r.ready && } + + ); + } + if (r.state === "absent") { + return ; + } + const label = notPresentLabel(r.state); + if (r.state === "denied") { + return {label}; + } + return ( + <> + + {label} + + ); +} + +function Ratio({ ready }: { ready: string }) { + const { whole, of } = readySplit(ready); + if (of === null) { + return {whole}; + } + return ( + + {whole} + {of} + + ); +} + +function Verdict({ r }: { r: FleetRow }) { + if (r.state !== "present") { + return <>{r.note}; + } + const badge = verdictBadge(r); + const flagged = badge !== null; + return ( + <> + {badge === "mutable-tag" && mutable tag} + {badge === "behind" && behind} + {r.note && {r.note}} + {r.digestPending && ( + digest pending + )} + + ); +} diff --git a/web/src/PromotionScreen.tsx b/web/src/PromotionScreen.tsx index 3b184c8..3cc2016 100644 --- a/web/src/PromotionScreen.tsx +++ b/web/src/PromotionScreen.tsx @@ -10,7 +10,13 @@ import { age } from "./detail"; // because deploying belongs to the pipeline that owns it, and a bulk action // across environments is precisely the button nobody should have. -export function PromotionScreen({ onOpenApp }: { onOpenApp: (context: string, namespace: string, workload: string) => void }) { +export function PromotionScreen({ + onOpenApp, + onOpenFleet, +}: { + onOpenApp: (context: string, namespace: string, workload: string) => void; + onOpenFleet: (app: string, namespace: string) => void; +}) { const [view, setView] = useState(null); const [err, setErr] = useState(null); @@ -34,6 +40,7 @@ export function PromotionScreen({ onOpenApp }: { onOpenApp: (context: string, na {view && ( {view.summary.apps} apps · {view.summary.drifted} with drift · {view.summary.ahead} ahead of upstream + {view.summary.split > 0 && ` · ${view.summary.split} split across clusters`} )} @@ -88,7 +95,11 @@ export function PromotionScreen({ onOpenApp }: { onOpenApp: (context: string, na onOpenApp(view.envs[i]?.context ?? "", c.namespace ?? r.namespace, r.app)} + onOpen={() => + c.state === "split" + ? onOpenFleet(r.app, c.namespace ?? r.namespace) + : onOpenApp(view.envs[i]?.context ?? "", c.namespace ?? r.namespace, r.app) + } /> ))} @@ -140,6 +151,23 @@ function CellBody({ cell: c, onOpen }: { cell: PromotionCell; onOpen: () => void ); } + if (c.state === "split") { + // A split cell carries no tag: picking one is the guess this state exists + // to prevent. The note is composed by the backend and rendered verbatim. + return ( + <> + + {c.note} + + {c.clusters ? {c.clusters} clusters : null} + + ); + } const colour = c.severe ? "var(--err)" : c.state === "behind" || c.state === "differs" ? "var(--warn)" : undefined; diff --git a/web/src/api.ts b/web/src/api.ts index fdb4355..d2d44fe 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -260,7 +260,7 @@ export interface GateView { export interface PromotionCell { env: string; namespace?: string; - state: string; // same, behind, ahead, differs, digest-differs, absent, denied + state: string; // same, behind, ahead, differs, digest-differs, absent, denied, split tag?: string; image?: string; digest?: string; @@ -270,6 +270,9 @@ export interface PromotionCell { revisionAt?: string; note?: string; severe?: boolean; + // Clusters is how many contexts back this cell. Set on split (and other) + // cells; the UI shows the count only above one. + clusters?: number; } export interface PromotionRow { @@ -281,12 +284,63 @@ export interface PromotionRow { } export interface PromotionView { - envs: { name: string; risk: string; context?: string }[]; + envs: { + name: string; + risk: string; + context?: string; + // Contexts is every context bound to this environment. Unreadable names + // the ones that did not answer; a column with any of those can never + // claim agreement, because the clusters nobody read might disagree. + contexts?: string[]; + unreadable?: string[]; + }[]; rows: PromotionRow[]; - summary: { apps: number; drifted: number; ahead: number }; + summary: { apps: number; drifted: number; ahead: number; split: number }; unreachable?: string[]; } +export interface FleetRow { + context: string; + clusterId: string; + env: string; + // The backend's classification of that environment. The name alone cannot be + // classified here: it is matched by keyword token and returned untouched, so + // prod-us-east is red while spelling no tier the UI could compare against. + envColor?: string; + envRisk?: string; + namespace?: string; + state: string; // present, absent, denied, unreachable, pending + reason?: string; + image?: string; + tag?: string; + digest?: string; + digestPending?: boolean; + health?: string; + ready?: string; + revisionAt?: string; + // Other kubeconfig contexts pointing at this same cluster, merged into this row. + aliases?: string[]; + behind?: boolean; + // This row's tag also resolves to a different digest on another cluster. + mutableTag?: boolean; + // The verdict sentence for this row, already composed by the backend. + note?: string; +} + +export interface FleetView { + app: string; + namespace: string; + rows: FleetRow[]; + newest?: string; + clusters: number; + present: number; + behind: number; + mutableTag?: boolean; + // Present rows whose digest never arrived, so cannot be ruled out of a + // mutable-tag match. + digestUnverified?: number; +} + export interface MetricsInfo { source: string; available: boolean; @@ -350,6 +404,8 @@ export const api = { reveal: (context: string, namespace: string, secret: string, key: string, workload: string) => post("/api/secret", { context, namespace, secret, key, workload }), promotion: () => get("/api/promotion"), + fleet: (app: string, namespace: string) => + get(`/api/fleet?app=${encodeURIComponent(app)}&namespace=${encodeURIComponent(namespace)}`), gate: (req: { context: string; namespace: string; diff --git a/web/src/commands.test.ts b/web/src/commands.test.ts index cc7c0ef..945abb0 100644 --- a/web/src/commands.test.ts +++ b/web/src/commands.test.ts @@ -36,6 +36,21 @@ describe("commands", () => { expect(some.filter((c) => c.group === "Actions").length).toBeGreaterThan(2); }); + // The fleet view is about one app; a command that opens it with nothing in + // hand would be a dead end, so it only appears when a screen names one. + it("offers fleet for the app in hand", () => { + const list = commands([], ctx("prod", "prod"), [], appRoute); + const fleet = list.find((c) => c.id === "action:fleet"); + expect(fleet).toBeTruthy(); + expect(fleet!.label.toLowerCase()).toContain("cluster"); + expect(fleet!.route).toEqual({ screen: "fleet", app: "payments", namespace: "team-a" }); + }); + + it("hides fleet when no app is selected", () => { + const list = commands([], ctx("prod", "prod"), [], appsRoute); + expect(list.find((c) => c.id === "action:fleet")).toBeUndefined(); + }); + it("offers the other environments as switches", () => { const list = commands([ctx("prod", "prod"), ctx("stg", "stg")], ctx("prod", "prod"), [], appsRoute); const envs = list.filter((c) => c.group === "Environments"); diff --git a/web/src/commands.ts b/web/src/commands.ts index 4030682..92c0253 100644 --- a/web/src/commands.ts +++ b/web/src/commands.ts @@ -98,6 +98,13 @@ export function commands( hint: "what changed, and when", route: { screen: "app", context: current.name, namespace, workload }, }, + { + id: "action:fleet", + group: "Actions", + label: `${workload} across every cluster`, + hint: "asks every context in your kubeconfig", + route: { screen: "fleet", app: workload, namespace }, + }, ); } @@ -162,7 +169,7 @@ export function commands( // routeTarget is the app the current screen is about, when it is about one. function routeTarget(route: Route): { namespace: string; workload: string } | null { - if (route.screen === "apps" || route.screen === "promotion") return null; + if (route.screen === "apps" || route.screen === "promotion" || route.screen === "fleet") return null; return { namespace: route.namespace, workload: route.workload }; } diff --git a/web/src/fleet.test.ts b/web/src/fleet.test.ts new file mode 100644 index 0000000..f48ef3d --- /dev/null +++ b/web/src/fleet.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { notPresentLabel, readySplit, verdictBadge } from "./fleet"; + +// These three functions were stranded inside FleetScreen.tsx: the vitest +// include in vite.config.ts does not match .tsx, and there is no jsdom in +// this package, so nothing reached from the component was ever reachable +// from a test. This file is new coverage of behavior that already shipped +// correctly; the extraction did not change what renders. + +describe("notPresentLabel", () => { + it("names the three ways a row has no version", () => { + expect(notPresentLabel("unreachable")).toBe("no answer"); + expect(notPresentLabel("denied")).toBe("no access"); + expect(notPresentLabel("pending")).toBe("asking"); + }); + + // A blank cell here would read as "checked and found nothing". A state + // nobody wrote into the map must never produce that lie, so it falls back + // to naming itself. + it("surfaces an unknown state as itself, never blank", () => { + expect(notPresentLabel("quarantined")).toBe("quarantined"); + expect(notPresentLabel("")).toBe(""); + }); +}); + +describe("readySplit", () => { + it("splits a ratio at the slash", () => { + expect(readySplit("4/4")).toEqual({ whole: "4", of: "/4" }); + }); + + it("keeps a value with no separator whole", () => { + expect(readySplit("asking")).toEqual({ whole: "asking", of: null }); + }); + + it("keeps an empty string whole rather than slicing it", () => { + expect(readySplit("")).toEqual({ whole: "", of: null }); + }); +}); + +describe("verdictBadge", () => { + // One tag resolving to two digests is the worse fact: two clusters claiming + // this version are not running the same code. Showing "behind" alongside it + // would suggest two independent problems, so mutableTag wins outright. + it("suppresses the behind badge when the tag is mutable", () => { + expect(verdictBadge({ mutableTag: true, behind: true })).toBe("mutable-tag"); + }); + + it("shows behind on its own", () => { + expect(verdictBadge({ mutableTag: false, behind: true })).toBe("behind"); + }); + + it("shows no badge when neither applies", () => { + expect(verdictBadge({ mutableTag: false, behind: false })).toBeNull(); + expect(verdictBadge({})).toBeNull(); + }); +}); diff --git a/web/src/fleet.ts b/web/src/fleet.ts new file mode 100644 index 0000000..744692f --- /dev/null +++ b/web/src/fleet.ts @@ -0,0 +1,46 @@ +/** + * Logic stranded inside FleetScreen.tsx, extracted so it can be tested without + * a DOM. web/vite.config.ts's vitest include does not match .tsx, and there is + * no jsdom in this package, so none of a component's inline logic is + * reachable from a test until it lives in a plain .ts module. + */ + +/** + * notPresentLabel names a row that never ran the app: it did not answer, it + * refused to let us look, or it is still asking. A state this map has never + * heard of surfaces as itself rather than going blank, because a blank cell + * on this screen reads as "checked and found nothing", which is a claim + * nobody earned about a state nobody recognizes. + */ +const NOT_PRESENT_LABEL: Record = { + unreachable: "no answer", + denied: "no access", + pending: "asking", +}; + +export function notPresentLabel(state: string): string { + return NOT_PRESENT_LABEL[state] ?? state; +} + +/** readySplit is where the Ratio cell's "/" lives, so the whole and the + * denominator can be styled apart. A value with no separator, or none at all, + * renders as one piece rather than losing text to a slice that assumed a "/" + * that was not there. */ +export function readySplit(ready: string): { whole: string; of: string | null } { + const i = ready.indexOf("/"); + if (i === -1) return { whole: ready, of: null }; + return { whole: ready.slice(0, i), of: ready.slice(i) }; +} + +/** + * verdictBadge picks the one flag a present row shows, in precedence order. A + * mutable tag is the worse fact: it means two clusters claiming this version + * are not running the same code, and "behind" undersells that as a mere + * schedule. Showing both would suggest they are independent problems, so + * mutableTag suppresses behind rather than sitting beside it. + */ +export function verdictBadge(r: { mutableTag?: boolean; behind?: boolean }): "mutable-tag" | "behind" | null { + if (r.mutableTag) return "mutable-tag"; + if (r.behind) return "behind"; + return null; +} diff --git a/web/src/health.test.ts b/web/src/health.test.ts index 46cff11..2351f18 100644 --- a/web/src/health.test.ts +++ b/web/src/health.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { envToken } from "./health"; +import { envToken, loudestEnv } from "./health"; +import type { FleetRow } from "./api"; describe("envToken", () => { it("maps the colours the backend names", () => { @@ -23,3 +24,56 @@ describe("envToken", () => { expect(envToken({ risk: "" })).toBe("unc"); }); }); + +// A fleet row names its environment and carries the backend's verdict on it +// under its own fields. The name alone cannot be classified here: Classify +// matches by keyword token and returns the name untouched, so prod-us-east is +// red while spelling no tier a screen could compare against. Reading colour and +// risk keeps one rule in one language. +describe("envToken on a fleet row", () => { + const row = (env: string, envColor: string, envRisk: string): FleetRow => + ({ context: env, clusterId: "https://" + env, env, envColor, envRisk, state: "present" }); + + it("colours a cluster the backend classified, whatever the cluster is called", () => { + const cases: [FleetRow, string][] = [ + [row("prod-us-east", "red", "high"), "prod"], + [row("production", "red", "high"), "prod"], + [row("staging-eks", "amber", "medium"), "stg"], + [row("uat", "amber", "medium"), "stg"], + [row("sandbox-3", "green", "low"), "qa"], + [row("dev-cluster", "green", "low"), "qa"], + [row("dr-frankfurt", "violet", "high"), "unc"], + ]; + for (const [r, want] of cases) { + expect(envToken({ color: r.envColor, risk: r.envRisk })).toBe(want); + } + }); + + // A row from an older server, or one whose environment nobody resolved, + // still lands on unclassified rather than on a tier it did not earn. + it("falls back to unclassified when a row carries no classification", () => { + const bare: FleetRow = { context: "edge", clusterId: "", env: "edge", state: "absent" }; + expect(envToken({ color: bare.envColor, risk: bare.envRisk })).toBe("unc"); + }); +}); + +// One banner speaks for several clusters, so it takes the edge of the loudest +// environment it names rather than inheriting the shell's. +describe("loudestEnv", () => { + it("takes the loudest environment in the set", () => { + expect(loudestEnv([{ color: "green" }, { color: "red" }, { color: "amber" }])).toBe("prod"); + expect(loudestEnv([{ color: "green" }, { color: "amber" }])).toBe("stg"); + expect(loudestEnv([{ color: "green" }])).toBe("qa"); + }); + + // Unclassified carries prod-strength styling, so it outranks the tiers below + // it. A named prod cluster still wins: red is the edge that reads. + it("ranks unclassified above the tiers it outranks and below prod", () => { + expect(loudestEnv([{ color: "violet" }, { color: "green" }])).toBe("unc"); + expect(loudestEnv([{ color: "violet" }, { color: "red" }])).toBe("prod"); + }); + + it("treats an empty set as unclassified", () => { + expect(loudestEnv([])).toBe("unc"); + }); +}); diff --git a/web/src/health.ts b/web/src/health.ts index bbef7ba..a557e4f 100644 --- a/web/src/health.ts +++ b/web/src/health.ts @@ -30,3 +30,18 @@ const BY_RISK: Record = { export function envToken(env: { color?: string; risk?: string }): EnvToken { return BY_COLOR[env.color ?? ""] ?? BY_RISK[env.risk ?? ""] ?? "unc"; } + +// One warning can name several clusters at once, and it belongs to the loudest +// of them rather than to whichever environment the shell happens to be in. +// +// Unclassified outranks the tiers below it, because it carries prod-strength +// styling everywhere else in the app. A cluster the backend did classify as +// prod still wins: when a red cluster is in the set, red is the edge that +// reads. An empty set is unclassified, never safe. +const LOUDNESS: Record = { prod: 3, unc: 2, stg: 1, qa: 0 }; + +export function loudestEnv(envs: { color?: string; risk?: string }[]): EnvToken { + const tokens = envs.map(envToken); + if (tokens.length === 0) return "unc"; + return tokens.reduce((a, b) => (LOUDNESS[b] > LOUDNESS[a] ? b : a)); +} diff --git a/web/src/route.test.ts b/web/src/route.test.ts index 98fbc31..0e3b355 100644 --- a/web/src/route.test.ts +++ b/web/src/route.test.ts @@ -31,4 +31,13 @@ describe("parseRoute", () => { screen: "logs", context: "arn:aws:eks:us-east-1/x", namespace: "team-a", workload: "check/out", }); }); + + it("round-trips the fleet route", () => { + const r = { screen: "fleet", app: "checkout", namespace: "shop" } as const; + expect(parseRoute(routeHash(r))).toEqual(r); + }); + + it("falls back to apps when the fleet route is missing its app", () => { + expect(parseRoute("#fleet")).toEqual({ screen: "apps" }); + }); }); diff --git a/web/src/route.ts b/web/src/route.ts index f8abfe6..833fa3b 100644 --- a/web/src/route.ts +++ b/web/src/route.ts @@ -8,7 +8,8 @@ export type Route = | { screen: "config"; context: string; namespace: string; workload: string } | { screen: "exec"; context: string; namespace: string; workload: string } | { screen: "diff"; context: string; namespace: string; workload: string; other?: string } - | { screen: "logs"; context: string; namespace: string; workload: string }; + | { screen: "logs"; context: string; namespace: string; workload: string } + | { screen: "fleet"; app: string; namespace: string }; export function parseRoute(hash: string): Route { const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean).map(decodeURIComponent); @@ -22,6 +23,9 @@ export function parseRoute(hash: string): Route { context: parts[1], namespace: parts[2], workload: parts[3], }; } + if (parts[0] === "fleet" && parts.length >= 3) { + return { screen: "fleet", app: parts[1], namespace: parts[2] }; + } if (parts[0] === "apps" && parts.length >= 2) { return { screen: "apps", context: parts[1] }; } @@ -38,5 +42,6 @@ export function routeHash(r: Route): string { return `#${r.screen}/${enc(r.context)}/${enc(r.namespace)}/${enc(r.workload)}`; } if (r.screen === "promotion") return "#promotion"; + if (r.screen === "fleet") return `#fleet/${enc(r.app)}/${enc(r.namespace)}`; return r.context ? `#apps/${enc(r.context)}` : "#apps"; } diff --git a/web/vite.config.ts b/web/vite.config.ts index c2f0b9a..87ab6c9 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -8,11 +8,36 @@ export default defineConfig({ // vitest owns the unit tests beside the source, Playwright owns e2e/. The // default vitest glob matches *.spec.ts anywhere, which is exactly the // Playwright naming convention. + // + // Three tiers of test, and where each belongs: + // - Pure logic (.test.ts) -> vitest, default node environment. Fast, no + // DOM. This is almost everything: parsing, formatting, derivations. + // - Decision logic that lives INSIDE a component (.test.tsx) -> vitest + // with jsdom, via @testing-library/react. Use this whenever a .tsx + // file makes a choice a test can't reach any other way — a chip + // mapping a raw value to a token, a label falling back for an unknown + // state, which badge wins when two conditions are both true. This is + // the tier that was missing (issue #83): the fleet screen's own + // env-token wiring shipped a bug that no .test.ts file could see, + // because the logic lived in the component, not beside it. + // - Rendering fidelity (screens.spec.ts et al) -> Playwright, in e2e/. + // Whether it actually looks right, pixel by pixel, in a real browser. + // Prefer moving decision logic OUT of a component into a plain function + // next to it (see fleet.ts) so it can be a fast .test.ts instead — reach + // for jsdom only when the logic cannot be extracted without changing + // what the component does. + // + // A .test.tsx file that needs jsdom opts in per-file with a + // `// @vitest-environment jsdom` pragma at the top (see FleetScreen.test.tsx), + // rather than a global environmentMatchGlobs here: vitest 3.2 deprecated + // that option in favour of test.projects, and per-file is exactly the + // right grain anyway — most .test.ts files never need a DOM and should not + // pay for one. test: { // .mjs beside the source too: a test that reads a file off disk needs node // APIs, which the app's tsconfig does not carry. Leaving the pattern out // does not fail, it silently collects nothing. - include: ["src/**/*.test.ts", "src/**/*.test.mjs", "site/**/*.test.mjs"], + include: ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.test.mjs", "site/**/*.test.mjs"], exclude: ["e2e/**", "node_modules/**", "dist/**"], }, plugins: [react()],