From 3165b10817df6d78ae9240c9ffccec23258dd357 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 14:29:06 +0000 Subject: [PATCH 1/2] feat(examples): compare native, full and spec sources with measurements One host (gateway/kube/examples/comparison) serves the same Widgets and Secrets from one origin through a credential-holding /k8s native proxy and four gateway routes (krm-full/v1 and krm-spec/v1, unshared and shared), with projected save endpoints, a session check on every path, a refused session, deterministic workloads (quiet, burst, churn, forced-disconnect), forced downstream disconnects and low-cardinality counters, including a counting Backend wrapper per route instead of cluster-wide API-server gauges. examples/comparison adds a side-by-side page that edits through the existing native and conditional editors and keep-local recipe on both entry points, and measure.ts, which drives the real connectors and stores from a configurable library build, gates every run on convergence with the cluster and writes JSON and markdown. measure.sh runs it against a fresh host, and with --baseline-ref builds an earlier library for a native before/after. Tasks: build-compare-page, compare, compare-measure, compare-native-baseline. docs/facts/comparison-2026-10-05.md records the baseline run and leaves the native AFTER section for the resumable connector. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 5 +- Taskfile.yml | 57 +- docs/facts/comparison-2026-10-05.md | 332 ++++++++ docs/field-reports/third-our-identity.md | 28 +- ...006-stream-and-save-implementation-plan.md | 7 +- docs/saving.md | 2 +- examples/README.md | 8 +- examples/comparison/.gitignore | 4 + examples/comparison/README.md | 162 ++++ examples/comparison/build.mjs | 32 + examples/comparison/index.html | 142 ++++ examples/comparison/measure.sh | 77 ++ examples/comparison/measure.ts | 743 ++++++++++++++++++ examples/comparison/page.ts | 494 ++++++++++++ examples/comparison/tsconfig.json | 16 + .../examples/comparison/cmd/compare/main.go | 169 ++++ .../examples/comparison/comparison_test.go | 267 +++++++ gateway/kube/examples/comparison/gateway.go | 135 ++++ gateway/kube/examples/comparison/host.go | 513 ++++++++++++ gateway/kube/examples/comparison/metrics.go | 167 ++++ gateway/kube/examples/comparison/native.go | 204 +++++ gateway/kube/examples/comparison/objects.go | 207 +++++ gateway/kube/examples/comparison/save.go | 193 +++++ gateway/kube/examples/comparison/workload.go | 259 ++++++ 24 files changed, 4207 insertions(+), 16 deletions(-) create mode 100644 docs/facts/comparison-2026-10-05.md create mode 100644 examples/comparison/.gitignore create mode 100644 examples/comparison/README.md create mode 100644 examples/comparison/build.mjs create mode 100644 examples/comparison/index.html create mode 100755 examples/comparison/measure.sh create mode 100644 examples/comparison/measure.ts create mode 100644 examples/comparison/page.ts create mode 100644 examples/comparison/tsconfig.json create mode 100644 gateway/kube/examples/comparison/cmd/compare/main.go create mode 100644 gateway/kube/examples/comparison/comparison_test.go create mode 100644 gateway/kube/examples/comparison/gateway.go create mode 100644 gateway/kube/examples/comparison/host.go create mode 100644 gateway/kube/examples/comparison/metrics.go create mode 100644 gateway/kube/examples/comparison/native.go create mode 100644 gateway/kube/examples/comparison/objects.go create mode 100644 gateway/kube/examples/comparison/save.go create mode 100644 gateway/kube/examples/comparison/workload.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81656c9..e85e76c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,8 +19,8 @@ Start with watch streams and authoritative resource views; add editing as an opt The [README](README.md) explains current capabilities and how to choose a native or gateway source. Current API examples must match this branch. Native viewing is LIST/WATCH on the shared connection lifecycle and resumes ordinary reconnects from a per-handle checkpoint, and native editing -writes conditional merge patches back through the same proxy; streaming lists, pagination, editor -cleanup and larger comparisons are separate. Save-progress evaluation remains a proposal until implemented. +writes conditional merge patches back through the same proxy; the comparison example measures the +sources side by side. Streaming lists, pagination and editor cleanup are separate. Save-progress evaluation remains a proposal until implemented. - Keep the core gateway free of `client-go`; Kubernetes integration belongs in `gateway/kube`. - Keep the browser client framework-free and free of runtime dependencies. @@ -66,6 +66,7 @@ the rule it protects. | `task e2e-browser` | Native `EventSource`, unbundled ESM and the native viewer and editor pages in Chromium. | | `task cluster-facts` | Record observed Kubernetes behavior for the supported cluster version. | | `task test-real-api` | Compose the save path and native viewing and editing against a real API server: gateway or host proxy, host endpoint and browser store. | +| `task compare-measure` | Measure native, full and spec sources, unshared and shared, under identical workloads against a real API server; `task compare-native-baseline BASELINE_REF=` compares the native connector with an earlier build. Results are evidence for the run, recorded in `docs/facts/`. | | `task test-cluster` | Exercise the Kubernetes backend against a real API server. | The cluster tasks need Docker and take longer; the fixture suites are the per-pull-request baseline. diff --git a/Taskfile.yml b/Taskfile.yml index 7a6c975..ac50e9b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -385,7 +385,7 @@ tasks: cmds: # .task/ is Task's checksum store: what makes a task say "up to date". Remove it when one is wrong. - rm -rf .task - - rm -rf {{.CLIENT_DIR}}/node_modules {{.CLIENT_DIR}}/dist examples/native-editor/dist + - rm -rf {{.CLIENT_DIR}}/node_modules {{.CLIENT_DIR}}/dist examples/native-editor/dist examples/comparison/dist - rm -rf examples/vanilla-browser/node_modules # Go's test cache is content-addressed and sound: a cached `ok` means those inputs really did # pass. Use `go test -count=1` to watch a suite run; `go clean -cache` buys nothing but minutes. @@ -478,3 +478,58 @@ tasks: # -p 1: the shared-host example measures a CLUSTER-WIDE API-server watch gauge, which the # backend suite's own real watches corrupt if their packages run concurrently. - go test -tags e2e -v -count=1 -p 1 -timeout 10m ./... + + # ------------------------------------------------------------- comparison -- + # One host serves the same Widgets and Secrets through every source — native /k8s, krm-full/v1 and + # krm-spec/v1, unshared and shared — so a page and a driver can compare them under identical churn. + # Port 8110, so it never collides with `task demo` or a browser suite on 8100. See + # examples/comparison/README.md and the dated runs in docs/facts/comparison-*.md. + build-compare-page: + desc: "Build the library and compile the comparison page's controller, with the editors and recipe it reuses, into examples/comparison/dist." + deps: [build-client] + sources: + - examples/comparison/build.mjs + - examples/comparison/page.ts + - examples/native-editor/editor.ts + - examples/conditional-save/editor.ts + - examples/editor-recipes/keepLocal.ts + generates: + - examples/comparison/dist/page.js + status: + - test -f examples/comparison/dist/page.js + cmds: + # Types stripped, library import renamed to the published package; see build.mjs. + - node examples/comparison/build.mjs + + compare: + desc: "Serve the comparison page against the spike cluster: native vs krm-full/v1 vs krm-spec/v1, with editing and counters." + deps: [cluster-up, build-compare-page] + dir: gateway/kube + env: + KUBECONFIG: '{{.KUBECONFIG_PATH}}' + cmds: + # The browser address is the TLS one: HTTP/2, so the page's six streams do not use up the six + # HTTP/1.1 connections a browser allows one origin. The certificate is self-signed at startup. + - echo "→ https://127.0.0.1:8111/ (also ?entry=bundle, ?variant=shared; plain http://127.0.0.1:8110/ is for drivers)" + # Foreground, so Ctrl-C reaches the host, which deletes its scratch namespace on the way out. + - go run ./examples/comparison/cmd/compare --addr 127.0.0.1:8110 {{.CLI_ARGS}} + + compare-measure: + desc: "Measure every source under every workload against the spike cluster (CLI_ARGS go to measure.ts)." + # Not cached, and it must not be: it measures a cluster, not files. + deps: [cluster-up, build-client] + env: + KUBECONFIG: '{{.KUBECONFIG_PATH}}' + cmds: + - bash examples/comparison/measure.sh {{.CLI_ARGS}} + + compare-native-baseline: + desc: "Native before/after: measure the native source with the library built at BASELINE_REF (default main), then with this tree's." + deps: [cluster-up, build-client] + vars: + # Name the commit BEFORE the change under test. Once main contains it, main is not a baseline. + BASELINE_REF: '{{.BASELINE_REF | default "main"}}' + env: + KUBECONFIG: '{{.KUBECONFIG_PATH}}' + cmds: + - bash examples/comparison/measure.sh --baseline-ref {{.BASELINE_REF}} {{.CLI_ARGS}} diff --git a/docs/facts/comparison-2026-10-05.md b/docs/facts/comparison-2026-10-05.md new file mode 100644 index 0000000..55df538 --- /dev/null +++ b/docs/facts/comparison-2026-10-05.md @@ -0,0 +1,332 @@ +# Observed: native, krm-full/v1 and krm-spec/v1 under identical workloads + +> **Recorded from runs of the comparison harness on 2026-10-05.** Like +> [shared-host-rehearsal.md](shared-host-rehearsal.md), this is a witness to particular runs on one +> disposable cluster and one machine, not a capacity guarantee, a latency distribution or a support +> statement. Every number below comes from a **real API server**; nothing in this file is simulated. + +## What was run + +| | | +|---|---| +| Commit | `d259394` (`main`) plus the uncommitted harness from this change; the library under test in the matrix below is `d259394`'s `packages/krm-stream` (native connector re-lists on every reconnect). The native AFTER section measures the resuming connector. | +| Harness | [examples/comparison](../../examples/comparison/README.md): the host in [gateway/kube/examples/comparison](../../gateway/kube/examples/comparison/) and the driver [measure.ts](../../examples/comparison/measure.ts) | +| Command | `task compare-measure` (defaults: workloads quiet, burst, churn, forced-disconnect; N = 1 and 10; 3 repetitions; 20 s per workload; seed 1; 2 forced reconnects per workload; connector `retryDelayMs` 0, `healthyResetMs` 1 s) | +| Library entry | `packages/krm-stream/dist/index.js`, loaded by node 24 | +| Cluster | k3d `v5.9.0`, one server node, k3s `v1.36.4+k3s1` with embedded etcd (`task cluster-up`); `kubectl version`: client `v1.37.0`, server `v1.36.4+k3s1` | +| Toolchain | Go `1.27.1`, Node `v24.21.0`, Docker `29.2.1` (Docker-outside-Docker from the devcontainer) | +| Machine | AMD Ryzen 9 9900X, 16 vCPUs visible to the devcontainer, 47 GiB RAM, Linux `6.17.0-41-generic`. Shared with other work: another agent's test suites may have run against the same cluster during these runs. | +| Host config | Gateway `WriteTimeout` 5 s, `ReauthorizationInterval` 5 s, `ReauthorizationTimeout` 5 s; the host's client-go `QPS` 200 / `Burst` 400 (declared fixture capacity, not a library default); one identity (the kubeconfig's) for every upstream request | +| Objects | 8 Widgets (CRD with a status subresource) and 3 Opaque Secrets (2 keys each) in one scratch namespace | + +All five sources run **concurrently** in every run, as N subscribers each, against the same writes: +native, full, spec, full-shared and spec-shared. Each subscriber watches the Widgets and the Secrets +with one connection and one `LiveResourceStore` (read-only policy) per collection. Counts in the +tables cover the **workload phase only** — from the moment every subscriber is live to the end of the +workload — and are **summed over the N subscribers**. Cells are median (min–max) over the 3 +repetitions; a single value means all three agreed. + +Every run passed the correctness gate before it was counted: after the workload every store held +what the cluster held — for native, each object's exact resourceVersion, spec, status and Secret +data; for full and spec, the same membership and spec (and status for full), no Secret values, and +exactly the cluster's Secret keys as redacted paths. + +## Results + +Matrix run 2026-10-05 14:18–14:26 UTC (8 min 16 s), all 24 runs passed the correctness gate. The refusal check that precedes the matrix passed: each of the four gateway routes ended the refused session with a terminal `FORBIDDEN`, and the native proxy saw no request from that session afterwards. + +"Writes performed" are the workload's own writes; a forced disconnect closes every downstream native watch and SSE stream of every source at once. `events added` includes the re-snapshot after each forced reconnect (11 objects per subscriber per reconnect). + +### quiet + +Writes performed (one run): `{"disconnect":2}` + +Client side (what the subscribers consumed): + +| source | N | events added | modified | deleted | resets | notifications | renders | KiB down | reconnects | time-to-live ms (median) | time-to-live ms (max) | Secret values seen changing | redaction rev bumps | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| native | 1 | 22 | 0 | 0 | 4 | 22 | 4 | 19 | 4 | 10 (9.5–12) | 10 (10–105) | 0 | 0 | +| full | 1 | 22 | 0 | 0 | 4 | 22 | 5 (4–6) | 10.3 | 4 | 7.5 (7–9) | 10 (8–12) | 0 | 0 | +| spec | 1 | 22 | 0 | 0 | 4 | 22 | 5 (5–6) | 9.3 | 4 | 7 (6.5–10.5) | 8 (7–107) | 0 | 0 | +| full-shared | 1 | 22 | 0 | 0 | 4 | 22 | 4 | 10.3 | 4 | 7.5 (6.5–9.5) | 9 (8–106) | 0 | 0 | +| spec-shared | 1 | 22 | 0 | 0 | 4 | 22 | 4 (4–5) | 9.3 | 4 | 8 (6–10) | 8 (8–107) | 0 | 0 | +| native | 10 | 220 | 0 | 0 | 40 | 220 | 40 | 190 | 40 | 45.5 (37.5–49) | 54 (42–56) | 0 | 0 | +| full | 10 | 220 | 0 | 0 | 40 | 220 | 45 (43–49) | 103 | 40 | 34 (31–34) | 47 (36–52) | 0 | 0 | +| spec | 10 | 220 | 0 | 0 | 40 | 220 | 44 (41–46) | 93.4 | 40 | 34 (31.5–42.5) | 44 (37–53) | 0 | 0 | +| full-shared | 10 | 220 | 0 | 0 | 40 | 220 | 41 (40–42) | 103 | 40 | 32 (26–32.5) | 47 (36–49) | 0 | 0 | +| spec-shared | 10 | 220 | 0 | 0 | 40 | 220 | 41 (40–41) | 93.4 | 40 | 31 (26–35) | 45 (35–53) | 0 | 0 | + +Host side, native proxy: + +| source | N | LIST | WATCH | WATCH after LIST | WATCH resumed | watch frames | ERROR frames | KiB to browser | KiB from API server | active watches at end | session checks | +|---|---|---|---|---|---|---|---|---|---|---|---| +| native | 1 | 4 | 4 | 4 | 0 | 0 | 0 | 19 | 19 | 2 | 8 | +| native | 10 | 40 | 40 | 40 | 0 | 0 | 0 | 190 | 190 | 20 | 80 | + +Host side, gateway routes: + +| source | N | upstream watches opened | active upstream at end | cycles | consumer resyncs | events emitted | suppressed | shared subscriptions | authorizer calls | of which timed | KiB SSE | KiB from API server | session checks | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| full | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 0 | 10 | 6 | 10.3 | 20 (20–20.1) | 4 | +| spec | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 0 | 10 | 6 | 9.3 | 20 | 4 | +| full-shared | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 4 | 10 | 6 | 10.3 | 20 | 4 | +| spec-shared | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 4 | 10 | 6 | 9.3 | 20 | 4 | +| full | 10 | 40 | 20 | 40 | 0 | 300 | 0 | 0 | 100 | 60 | 103 | 200 (200–201) | 40 | +| spec | 10 | 40 | 20 | 40 | 0 | 300 | 0 | 0 | 100 | 60 | 93.4 | 200 (200–201) | 40 | +| full-shared | 10 | 4 | 2 | 40 | 0 | 300 | 0 | 40 | 100 | 60 | 103 | 20 (20–20.1) | 40 | +| spec-shared | 10 | 4 | 2 | 40 | 0 | 300 | 0 | 40 | 100 | 60 | 93.4 | 20 (20–20.1) | 40 | + +### burst + +Writes performed (one run): `{"disconnect":2,"spec":34,"status":26}` + +Client side (what the subscribers consumed): + +| source | N | events added | modified | deleted | resets | notifications | renders | KiB down | reconnects | time-to-live ms (median) | time-to-live ms (max) | Secret values seen changing | redaction rev bumps | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| native | 1 | 22 | 60 | 0 | 4 | 82 | 64 | 78.8 (78.8–78.8) | 4 | 7 (6–10) | 8 (7–12) | 0 | 0 | +| full | 1 | 22 | 60 | 0 | 4 | 82 | 65 (64–65) | 38.4 (38.3–38.4) | 4 | 5.5 (5.5–8.5) | 7 (6–9) | 0 | 0 | +| spec | 1 | 22 | 34 | 0 | 4 | 56 | 40 | 23.1 (23.1–23.1) | 4 | 5.5 (5–8) | 6 (6–8) | 0 | 0 | +| full-shared | 1 | 22 | 60 | 0 | 4 | 82 | 64 (64–65) | 38.4 (38.3–38.4) | 4 | 6.5 (6–6.5) | 7 (6–8) | 0 | 0 | +| spec-shared | 1 | 22 | 34 | 0 | 4 | 56 | 38 (38–39) | 23.1 (23.1–23.1) | 4 | 6 (5–8) | 6 (6–9) | 0 | 0 | +| native | 10 | 220 | 600 | 0 | 40 | 820 | 640 | 789 (789–789) | 40 | 37 (30–42) | 50 (36–130) | 0 | 0 | +| full | 10 | 220 | 600 | 0 | 40 | 820 | 644 (643–648) | 384 (384–384) | 40 | 28 (19.5–30) | 41 (26–135) | 0 | 0 | +| spec | 10 | 220 | 340 | 0 | 40 | 560 | 388 (384–390) | 231 (231–231) | 40 | 28 (20–36) | 46 (27–134) | 0 | 0 | +| full-shared | 10 | 220 | 600 | 0 | 40 | 820 | 642 (641–642) | 384 (384–384) | 40 | 29 (19–31.5) | 44 (27–137) | 0 | 0 | +| spec-shared | 10 | 220 | 340 | 0 | 40 | 560 | 381 (380–381) | 231 (231–231) | 40 | 22.5 (19.5–31) | 48 (27–129) | 0 | 0 | + +Host side, native proxy: + +| source | N | LIST | WATCH | WATCH after LIST | WATCH resumed | watch frames | ERROR frames | KiB to browser | KiB from API server | active watches at end | session checks | +|---|---|---|---|---|---|---|---|---|---|---|---| +| native | 1 | 4 | 4 | 4 | 0 | 60 | 0 | 78.8 (78.8–78.8) | 78.8 (78.8–78.8) | 2 | 8 | +| native | 10 | 40 | 40 | 40 | 0 | 600 | 0 | 789 (789–789) | 789 (789–789) | 20 | 80 | + +Host side, gateway routes: + +| source | N | upstream watches opened | active upstream at end | cycles | consumer resyncs | events emitted | suppressed | shared subscriptions | authorizer calls | of which timed | KiB SSE | KiB from API server | session checks | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| full | 1 | 4 | 2 | 4 | 0 | 90 | 0 | 0 | 10 | 6 | 38.4 (38.3–38.4) | 79.8 (79.8–79.9) | 4 | +| spec | 1 | 4 | 2 | 4 | 0 | 64 | 26 | 0 | 10 | 6 | 23.1 (23.1–23.1) | 79.8 (79.8–79.9) | 4 | +| full-shared | 1 | 4 | 2 | 4 | 0 | 90 | 0 | 4 | 10 | 6 | 38.4 (38.3–38.4) | 79.8 (79.8–79.9) | 4 | +| spec-shared | 1 | 4 | 2 | 4 | 0 | 64 | 26 | 4 | 10 | 6 | 23.1 (23.1–23.1) | 79.8 (79.8–79.9) | 4 | +| full | 10 | 40 | 20 | 40 | 0 | 900 | 0 | 0 | 100 | 60 | 384 (384–384) | 800 (798–800) | 40 | +| spec | 10 | 40 | 20 | 40 | 0 | 640 | 260 | 0 | 100 | 60 | 231 (231–231) | 800 (798–800) | 40 | +| full-shared | 10 | 4 | 2 | 40 | 0 | 900 | 0 | 40 | 100 | 60 | 384 (384–384) | 80 (79.8–80) | 40 | +| spec-shared | 10 | 4 | 2 | 40 | 0 | 640 | 260 | 40 | 100 | 60 | 231 (231–231) | 80 (79.8–80) | 40 | + +### churn + +Writes performed (one run): `{"disconnect":2,"secret":6,"spec":9,"status":199}` + +Client side (what the subscribers consumed): + +| source | N | events added | modified | deleted | resets | notifications | renders | KiB down | reconnects | time-to-live ms (median) | time-to-live ms (max) | Secret values seen changing | redaction rev bumps | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| native | 1 | 22 | 214 | 0 | 4 | 236 | 218 | 230 | 4 | 6 (6–7) | 8 (6–9) | 6 | 0 | +| full | 1 | 22 | 214 | 0 | 4 | 236 | 222 (219–223) | 110 | 4 | 5 (5–6) | 7 (5–9) | 0 | 6 | +| spec | 1 | 22 | 15 | 0 | 4 | 37 | 20 (20–21) | 15.2 | 4 | 6 (5–6) | 6 (6–9) | 0 | 6 | +| full-shared | 1 | 22 | 214 | 0 | 4 | 236 | 219 | 110 | 4 | 6 (5.5–7) | 6 (6–9) | 0 | 6 | +| spec-shared | 1 | 22 | 15 | 0 | 4 | 37 | 19 (19–20) | 15.2 | 4 | 5.5 (5–7.5) | 6 (6–10) | 0 | 6 | +| native | 10 | 220 | 2131 (2130–2140) | 0 | 40 | 2351 (2350–2360) | 2171 (2170–2179) | 2293 (2292–2302) | 40 | 28 (24–31) | 110 (33–110) | 60 | 0 | +| full | 10 | 220 | 2136 (2131–2140) | 0 | 40 | 2356 (2351–2360) | 2178 (2178–2182) | 1099 (1097–1101) | 40 | 21 (20.5–31) | 106 (29–111) | 0 | 60 | +| spec | 10 | 220 | 150 | 0 | 40 | 370 | 195 (193–203) | 152 | 40 | 19 (17–31) | 29 (25–111) | 0 | 60 | +| full-shared | 10 | 220 | 2140 (2134–2140) | 0 | 40 | 2360 (2354–2360) | 2179 (2175–2181) | 1101 (1098–1101) | 40 | 17.5 (14–23) | 26 (21–33) | 0 | 60 | +| spec-shared | 10 | 220 | 150 | 0 | 40 | 370 | 191 (190–192) | 152 | 40 | 16 (16–22.5) | 26 (19–33) | 0 | 60 | + +Host side, native proxy: + +| source | N | LIST | WATCH | WATCH after LIST | WATCH resumed | watch frames | ERROR frames | KiB to browser | KiB from API server | active watches at end | session checks | +|---|---|---|---|---|---|---|---|---|---|---|---| +| native | 1 | 4 | 4 | 4 | 0 | 214 | 0 | 230 | 230 | 2 | 8 | +| native | 10 | 40 | 40 | 40 | 0 | 2131 (2130–2140) | 0 | 2293 (2292–2302) | 2293 (2292–2302) | 20 | 80 | + +Host side, gateway routes: + +| source | N | upstream watches opened | active upstream at end | cycles | consumer resyncs | events emitted | suppressed | shared subscriptions | authorizer calls | of which timed | KiB SSE | KiB from API server | session checks | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| full | 1 | 4 | 2 | 4 | 0 | 244 | 0 | 0 | 10 | 6 | 110 | 232 | 4 | +| spec | 1 | 4 | 2 | 4 | 0 | 45 | 199 | 0 | 10 | 6 | 15.2 | 232 | 4 | +| full-shared | 1 | 4 | 2 | 4 | 0 | 244 | 0 | 4 | 10 | 6 | 110 | 232 | 4 | +| spec-shared | 1 | 4 | 2 | 4 | 0 | 45 | 199 | 4 | 10 | 6 | 15.2 | 232 | 4 | +| full | 10 | 40 | 20 | 40 | 0 | 2436 (2431–2440) | 0 | 0 | 100 | 60 | 1099 (1097–1101) | 2312 (2306–2315) | 40 | +| spec | 10 | 40 | 20 | 40 | 0 | 450 | 1988 (1981–1990) | 0 | 100 | 60 | 152 | 2314 (2306–2315) | 40 | +| full-shared | 10 | 4 | 2 | 40 | 0 | 2440 (2434–2440) | 0 | 40 | 100 | 60 | 1101 (1098–1101) | 232 (231–232) | 40 | +| spec-shared | 10 | 4 | 2 | 40 | 0 | 450 | 1990 (1989–1990) | 40 | 100 | 60 | 152 | 232 (231–232) | 40 | + +### forced-disconnect + +Writes performed (one run): `{"disconnect":6,"secret":18,"spec":24,"status":38}` + +Client side (what the subscribers consumed): + +| source | N | events added | modified | deleted | resets | notifications | renders | KiB down | reconnects | time-to-live ms (median) | time-to-live ms (max) | Secret values seen changing | redaction rev bumps | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| native | 1 | 66 | 80 | 0 | 12 | 146 | 92 | 129 | 12 | 13.5 (12–14.5) | 19 (18–19) | 18 | 0 | +| full | 1 | 66 | 80 | 0 | 12 | 146 | 97 (96–97) | 66.7 | 12 | 11.5 (11.5–12) | 16 (15–18) | 0 | 18 | +| spec | 1 | 66 | 42 | 0 | 12 | 108 | 58 (55–61) | 44.3 | 12 | 11 (10–13) | 17 (17–20) | 0 | 18 | +| full-shared | 1 | 66 | 80 | 0 | 12 | 146 | 93 (92–95) | 66.7 | 12 | 12 (10.5–13.5) | 17 (16–21) | 0 | 18 | +| spec-shared | 1 | 66 | 42 | 0 | 12 | 108 | 54 (54–56) | 44.3 | 12 | 11.5 (10–12.5) | 19 (16–21) | 0 | 18 | +| native | 10 | 660 | 800 | 0 | 120 | 1460 | 920 | 1295 (1295–1295) | 120 | 30 (29–36.5) | 54 (41–54) | 180 | 0 | +| full | 10 | 660 | 800 | 0 | 120 | 1460 | 932 (928–961) | 667 (667–667) | 120 | 21 (19–24) | 51 (37–54) | 0 | 180 | +| spec | 10 | 660 | 420 | 0 | 120 | 1080 | 557 (555–574) | 443 (443–444) | 120 | 20 (19.5–24) | 51 (35–60) | 0 | 180 | +| full-shared | 10 | 660 | 800 | 0 | 120 | 1460 | 928 (927–928) | 667 (667–667) | 120 | 20 (17–23) | 48 (29–48) | 0 | 180 | +| spec-shared | 10 | 660 | 420 | 0 | 120 | 1080 | 547 (544–548) | 443 (443–444) | 120 | 20 (19–23) | 48 (32–59) | 0 | 180 | + +Host side, native proxy: + +| source | N | LIST | WATCH | WATCH after LIST | WATCH resumed | watch frames | ERROR frames | KiB to browser | KiB from API server | active watches at end | session checks | +|---|---|---|---|---|---|---|---|---|---|---|---| +| native | 1 | 12 | 12 | 12 | 0 | 80 | 0 | 129 | 129 | 2 | 24 | +| native | 10 | 120 | 120 | 120 | 0 | 800 | 0 | 1295 (1295–1295) | 1295 (1295–1295) | 20 | 240 | + +Host side, gateway routes: + +| source | N | upstream watches opened | active upstream at end | cycles | consumer resyncs | events emitted | suppressed | shared subscriptions | authorizer calls | of which timed | KiB SSE | KiB from API server | session checks | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| full | 1 | 12 | 2 | 12 | 0 | 170 | 0 | 0 | 12 | 0 | 66.7 | 133 (133–134) | 12 | +| spec | 1 | 12 | 2 | 12 | 0 | 132 | 38 | 0 | 12 | 0 | 44.3 | 133 (133–134) | 12 | +| full-shared | 1 | 12 | 2 | 12 | 0 | 170 | 0 | 12 | 12 | 0 | 66.7 | 133 (133–134) | 12 | +| spec-shared | 1 | 12 | 2 | 12 | 0 | 132 | 38 | 12 | 12 | 0 | 44.3 | 133 (133–134) | 12 | +| full | 10 | 120 | 20 | 120 | 0 | 1700 | 0 | 0 | 120 | 0 | 667 (667–667) | 1333 (1332–1334) | 120 | +| spec | 10 | 120 | 20 | 120 | 0 | 1320 | 380 | 0 | 120 | 0 | 443 (443–444) | 1333 (1332–1334) | 120 | +| full-shared | 10 | 12 | 2 | 120 | 0 | 1700 | 0 | 120 | 120 | 0 | 667 (667–667) | 133 (133–133) | 120 | +| spec-shared | 10 | 12 | 2 | 120 | 0 | 1320 | 380 | 120 | 120 | 0 | 443 (443–444) | 133 (133–133) | 120 | + +## Reading the numbers + +Everything here restates the tables; nothing is extrapolated beyond these objects, rates and +subscriber counts. + +- **Events.** Native and full delivered the same number of events in every workload (churn, N = 1: + 214 `modified` each). Spec delivered 15: the 199 status-only writes were suppressed + (`suppressed` = 199) and only the 9 spec writes and 6 Secret rotations arrived. Under burst, spec + delivered 34 of 60 updates (the 26 status writes suppressed). +- **Bytes to the browser.** Churn, N = 10: native 2293 KiB, full 1099 KiB, spec 152 KiB. Quiet, where + only the re-snapshots after the two forced reconnects travel: native 190 KiB, full 103 KiB, spec 93 KiB. + The full and spec views remove `managedFields`, Secret values and (spec) status, which native carries. +- **Upstream watches and API-server bytes.** The unshared routes open one upstream watch per stream per + snapshot cycle: 40 at N = 10 in quiet (10 subscribers × 2 collections × 2 reconnects). The shared + routes opened 4 at both N = 1 and N = 10 — every subscriber left at the same forced disconnect, so the + shared watch closed and reopened once per collection per reconnect. Under churn at N = 10 the + unshared routes read 2312 KiB from the API server and the shared routes 232 KiB. The native proxy + reads exactly what it forwards: 2293 KiB, with one LIST and one WATCH per collection per subscriber + per reconnect. +- **Reconnects (BEFORE, native).** With this library every native reconnect re-LISTs: `LIST` = + `WATCH` = `WATCH after LIST` and `WATCH resumed` = 0 in every workload, each followed by a `reset` + and an `added` per object. The gateway connector also requests a fresh snapshot on every reconnect, + so the gateway sources re-snapshot just as often; the difference is what each snapshot costs + upstream and on the wire. Median time-to-live after a forced disconnect, with no backoff and all + five sources reconnecting at once: 5–14 ms at N = 1 for every source; at N = 10, 28–46 ms for native + and 16–34 ms for the gateway sources. +- **Authorization work.** The gateway's Authorizer runs per subscriber, shared or not: 100 calls at + N = 10 in quiet, burst and churn on every route, 60 of them timed rechecks (5 s interval). In + forced-disconnect no stream stayed live for 5 s, so there were no timed rechecks — only the 120 + cycle checks. The host's session check runs once per native request (2 per collection per + reconnect) and once per gateway stream request (1). +- **Secret rotations.** Under churn each native subscriber saw the 6 rotated Secret values change; each + full and spec subscriber saw 6 redaction revision bumps and never a value. Every projected store + passed the "no Secret values" part of the correctness gate. + +## Native AFTER: resumed watches + +Measured 2026-10-05 14:29–14:46 UTC on the same machine and cluster, with + +```bash +task compare-native-baseline BASELINE_REF=d259394 +``` + +which built `d259394`'s library (re-list on every reconnect) in a scratch directory, measured the +native source with it, then with this branch's library (`packages/krm-stream` as in the commit +"feat(client): resume native watches from a consumed checkpoint"; the run's label `a391faa` is a +pre-rebase SHA of the same source), using identical defaults: 4 workloads × N 1 and 10 × 3 +repetitions × 20 s, 2 forced reconnects per workload (12 in forced-disconnect), connector +`retryDelayMs` 0. Only the native source ran in these two passes, so time-to-live is not comparable +with the five-source tables above. All 48 runs passed the correctness gate: every store held each +object's exact resourceVersion, spec, status and Secret data after the workload, with resumed watches +and no re-snapshot. Cells are medians over 3 repetitions, summed over the N subscribers; every count +except time-to-live was identical across repetitions. + +| workload | N | LIST before → after | WATCH resumed | resets | events added | notifications | KiB down (= KiB from API server) | time-to-live median ms | +|---|---|---|---|---|---|---|---|---| +| quiet | 1 | 4 → 0 | 0 → 4 | 4 → 0 | 22 → 0 | 22 → 0 | 19 → 0 | 7.5 → 3.5 | +| quiet | 10 | 40 → 0 | 0 → 40 | 40 → 0 | 220 → 0 | 220 → 0 | 190 → 0 | 34.5 → 8 | +| burst | 1 | 4 → 0 | 0 → 4 | 4 → 0 | 22 → 0 | 82 → 60 | 78.8 → 59.8 | 8.5 → 2.5 | +| burst | 10 | 40 → 0 | 0 → 40 | 40 → 0 | 220 → 0 | 820 → 600 | 789 → 598 | 22.5 → 6.5 | +| churn | 1 | 4 → 0 | 0 → 4 | 4 → 0 | 22 → 0 | 236 → 214 | 230 → 211 | 6 → 2.5 | +| churn | 10 | 40 → 0 | 0 → 40 | 40 → 0 | 220 → 0 | 2360 → 2140 | 2302 → 2113 | 16 → 5 | +| forced-disconnect | 1 | 12 → 0 | 0 → 12 | 12 → 0 | 66 → 0 | 146 → 80 | 129 → 72.3 | 8 → 3 | +| forced-disconnect | 10 | 120 → 0 | 0 → 120 | 120 → 0 | 660 → 0 | 1460 → 800 | 1295 → 723 | 14 → 5 | + +Unchanged in every row: the number of WATCH requests and reconnects, `modified` events and watch +frames (every write still arrives once per subscriber), Secret values seen changing, and active +watches at the end. Host session checks halved (one per resumed WATCH instead of LIST + WATCH). + +What this shows, for these objects (11 per subscriber) and rates: a resumed reconnect transfers no +snapshot, so the saving per reconnect is one LIST of the collection and its re-application to the +store — proportional to collection size and reconnect frequency, not to write rate. What it does not +show: reconnects caused by expiry (410), which still re-list by design and were not part of these +workloads — the real-API test `TestRealAPINativeResumeThroughHostProxy` covers that path for +correctness, not cost; reconnects after real API-server watch timeouts (30–60 min) rather than forced +disconnects; larger collections; and backoff, since `retryDelayMs` was 0. + +## What this does NOT show + +- **API-server-side authorization of native requests.** Kubernetes authorizes every forwarded native + LIST, WATCH, GET and PATCH as the host's identity, inside the API server. The host cannot see those + decisions and the harness does not count them. The gateway's `Authorizer` calls are the host's own + checks, a different thing. +- **Per-caller identities.** Every upstream request uses one identity. A production host serving the + unshared routes or the native proxy would act as each caller (impersonation or the caller's own + token); that costs the same watches but adds per-caller credentials, which were not exercised. +- **Browser cost.** The driver is node, not a browser: there is no paint, layout or main-thread + contention. "Renders" are coalesced macrotasks in which a store notified, not frames. Memory and CPU + of the browser, the host and the API server were not measured. +- **API-server work.** Upstream bytes and watch counts are measured at the host; watch-cache, + etcd and serialization cost inside the API server are not. +- **Concurrency effects.** The five sources share one host process, one node process and one cluster in + every run, so time-to-live includes their contention: at N = 10 each forced disconnect reconnects + 100 connections at once. Run with `--sources ` for an isolated figure. +- **Reconnect latency with backoff.** `retryDelayMs` was 0, so time-to-live is reconnect work only. A + browser with the default 500 ms backoff waits 250–500 ms more before the first retry. +- **Scale.** 11 objects, at most 10 subscribers per source, 20-second workloads, 3 repetitions. No + percentiles beyond the per-run median and maximum; no soak. +- **Save progress under churn** (proposal 0006, order 5) was not measured. The page exercises editing + on every source, below; the driver measures viewing only. + +## Smoke checks of the page + +Run 2026-10-05 against `task compare` (the host on `https://127.0.0.1:8111/`, HTTP/2, self-signed), +driven headlessly by a throwaway Playwright script in Chromium (the Playwright already installed in +`examples/vanilla-browser`; no spec was added to that suite). **18 of 18 checks passed on each entry +point**, `dist/index.js` (default) and `dist/krm-stream.js` (`?entry=bundle`): + +| Check | index | bundle | +|---|---|---| +| Every source live with all 8 Widgets; spec shows no status, full does | pass | pass | +| Native shows Secret values; full and spec show `/data/token ••• rev N`, never a value | pass | pass | +| Native edit saved (`saved`) and echoed on all three sources | pass | pass | +| Full edit saved and echoed; typing in **another** field during the save kept as a dirty draft, no conflict; a second Save writes it | pass | pass | +| Spec edit saved and echoed; typing in the **same** field during the save surfaces as a conflict when the write's own echo arrives; "Keep mine" then Save writes it | pass | pass | +| A competing write conflicts with native and spec drafts; "Keep mine" + Save (native) and "Take theirs" (spec) resolve them | pass | pass | +| A conflicted gateway save is refused with `draft-conflict` | pass | pass | +| "Disconnect all": every source back to live, reconnects counted | pass | pass | +| Refused session: terminal `FORBIDDEN` from the gateway, 0 native requests by that session | pass | pass | +| Shared routes (`?variant=shared` selector) live; host metrics table rendered | pass | pass | +| No page errors other than Chromium's log of the native watches the forced disconnect aborted (`ERR_HTTP2_PROTOCOL_ERROR`) | pass | pass | + +The same-field case is the store's documented three-way behavior: the echo moves `spec.note` from +the base to the saved value while the draft holds a later, different value, and the store cannot tell +its own write's echo from anyone else's. Over plain HTTP/1.1 (`http://127.0.0.1:8110/`) the first +native save never completed: the page's six streams held all six connections Chromium allows one +origin. The host's TLS listener exists for that reason. + +## Reproducing it + +```bash +task cluster-up # once +task compare-measure # this file's matrix: about 9 minutes +task compare # the page: https://127.0.0.1:8111/ +``` + +`examples/comparison/results/` keeps each run's JSON (every counter of every run) and markdown. diff --git a/docs/field-reports/third-our-identity.md b/docs/field-reports/third-our-identity.md index 7eacc95..779954e 100644 --- a/docs/field-reports/third-our-identity.md +++ b/docs/field-reports/third-our-identity.md @@ -1,7 +1,7 @@ # Watch streams, optional editing and native access -**Request of 2026-10-05. Slice 1, the native viewer, slice 2, native editing, and native watch -resumption are implemented; the other follow-ups below remain open.** It builds on the connector separation, Go API cleanup and real-API save tests. Use +**Request of 2026-10-05. Slice 1, the native viewer, slice 2, native editing, native watch +resumption and the comparison example are implemented; the other follow-ups below remain open.** It builds on the connector separation, Go API cleanup and real-API save tests. Use [proposal 0006](../proposals/0006-stream-and-save-implementation-plan.md#open-work-and-delivery-order) for completed work, delivery order and independent tracks. @@ -156,7 +156,7 @@ Each is separate from the slices above and from the others: - Native resume — **implemented**, see [below](#native-watch-resumption); streaming lists and pagination remain open. -- Comparative view and sharing measurements. +- Comparative view and sharing measurements — **implemented**, see [below](#show-the-value-of-each-path). - Independent gateway continuation and save-progress evaluation. - The independently proposed editor API cleanup in proposal 0009. @@ -206,10 +206,24 @@ and not implemented. ## Show the value of each path -After the viewer works, compare native/full/spec and shared/unshared gateway runs using the same -objects and churn. Demonstrate Secret disclosure, downstream bytes/events, notifications/renders, -snapshots, authorization work and upstream watches. Include editing on both sources. -Keep this comparison and the larger benchmark outside slice 1; begin with the small viewer. +**Implemented** as the [comparison example](../../examples/comparison/README.md), with dated runs in +[comparison-2026-10-05](../facts/comparison-2026-10-05.md). One host serves the same Widgets and +Secrets through a credential-holding native proxy and `krm-full/v1` and `krm-spec/v1` gateway routes, +unshared and shared, behind one session check; a refused session ends with a terminal refusal and +never falls back to native. The page edits every source with the existing editors and keep-local +recipe (later typing during a save, conflicts, recovery) and shows Secret disclosure, status-only +suppression and hidden rotations. `measure.ts` runs identical quiet, burst, churn and +forced-disconnect schedules with the real connectors and stores, gates every run on convergence with +the cluster, and reports LIST/WATCH requests, snapshots, upstream watches, downstream bytes/events, +store notifications, coalesced renders and authorizer/session checks. Every number in the report is +real-API evidence from one disposable cluster; API-server-side RBAC on native requests, browser +paint, memory/CPU and save progress under churn are not measured. A general dashboard or benchmark +framework remains out of scope. + +The native before/after run in that report measures resumption under the same four workloads: with +10 subscribers and 12 forced reconnects, LISTs fell from 120 to 0, resets from 120 to 0 and bytes to +the browser from 1295 KiB to 723 KiB, with the same writes delivered and every store converged. +Expiry still re-lists by design and was not part of those workloads. ## Save progress under suppressed churn diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index 274d72b..05744e0 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -2,8 +2,8 @@ **Status: active delivery plan, updated 2026-10-05.** This is the single inventory of completed work, open work and ordering. The implementation includes connector separation, Go API cleanup, -real-API save-composition tests, the native viewer, native editing with a browser page and native -watch resumption. Detailed +real-API save-composition tests, the native viewer, native editing with a browser page, native +watch resumption and a comparison example with measurements. Detailed contracts stay in the linked proposals and guides rather than becoming another roadmap. Start with [watching resources](../why-a-gateway.md); add the [editor](../client-state-model.md) only @@ -33,6 +33,7 @@ remain separate delivery steps. | Native viewer (slice 1) | `connectNativeWatch` and `nativeCollectionURL`: unpaginated LIST, WATCH from the collection RV, fresh LIST on reconnect (superseded by resumption below), native 410 recovery and terminal refusals on a lifecycle shared with the gateway connector; fake-fetch, Chromium and real-API tests; minimal example | [Request](../field-reports/third-our-identity.md#slice-1-a-native-viewer), [example](../../examples/native-viewer/README.md), [client](../../packages/krm-stream/README.md#native-connections) | | Native editing (slice 2) | Machinery (`managedFields`, last-applied annotation) read-only in the store under every policy; maps holding a protected path merged key by key; `nativeObjectURL`; `gateway.ValidateNativeMergePatch` for host proxies; a native conditional editor with guarded native recovery reads; fake-fetch and real-API tests; a minimal one-ConfigMap page accepted in Chromium on both entry points | [Request](../field-reports/third-our-identity.md#slice-2-native-editing), [example](../../examples/native-editor/README.md), [saving](../saving.md#native-editing-through-a-host-proxy) | | Native watch resumption | A per-handle checkpoint advanced only after a consumed event or bookmark; ordinary reconnects resume the WATCH without LIST, reset or synced; 410, malformed input and unclassifiable errors discard it for a fresh snapshot; fake-fetch, Chromium (both entry points) and real-API resume/expiry tests | [Request](../field-reports/third-our-identity.md#native-watch-resumption), [client](../../packages/krm-stream/README.md#native-connections) | +| Comparative frontend and measurements | One host serving native, `krm-full/v1` and `krm-spec/v1`, unshared and shared, with editing on every source, refusal without native fallback, Secret disclosure, status-only suppression and hidden rotations; a driver with identical workloads, a convergence gate and request/byte/event/notification/render/authorization counters; native before/after resumption | [Example](../../examples/comparison/README.md), [results](../facts/comparison-2026-10-05.md) | | Watch-first documentation | README, watch/edit/save guides, native-fetch request and compact decision records; duplicate/superseded guides removed | [README](../../README.md), [current request](../field-reports/third-our-identity.md) | ## Open work and delivery order @@ -47,7 +48,7 @@ before starting a later one. Deliver separately reviewable changes and use the p | 3 | Native reconnect efficiency — **resumption completed** | See [completed work](#completed-work). Streaming-list initialization and pagination are evaluated [below](#3-native-streaming-lists-and-pagination) and remain open; implement them only with their own membership, checkpoint and recovery tests. | | 4 | Gateway upstream continuation | Independent track with a design below; review reopen/credential bounds, record a baseline, implement and measure. No dependency on native transport. | | 5 | Save progress during suppressed churn | Independent evaluation using existing real-API fixtures; bounded submitted-intent recovery first, optional version delivery second. Implementation depends on measured benefit and reviewed scope. | -| 6 | Comparative frontend and measurements | Follow working native viewing and editing. Compare native/full/spec and shared/unshared workloads; the larger project is outside slice 1. | +| 6 | Comparative frontend and measurements — **completed** | See [completed work](#completed-work). Save progress under churn (order 5) and memory/CPU remain unmeasured; rerun `task compare-measure` for new evidence rather than extending the example into a benchmark framework. | | Separate | Editor API cleanup in proposal 0009 | Independently proposed. Keep current standalone `applyStreamEvent` in slice 1; neither bound methods nor a new read-only store are required for native transport. | | Per change | Validation, release and host adoption | Release ready increments under the existing lockstep policy; do not wait for every track. Check each change's final-commit CI, artifact/API notes and host acceptance. | diff --git a/docs/saving.md b/docs/saving.md index b171ba4..8fc2dbe 100644 --- a/docs/saving.md +++ b/docs/saving.md @@ -161,7 +161,7 @@ and any receipt or Git workflow; connection state is not a save guarantee. | `draft-conflict` | Show local and current values at each conflicting field. | Offer explicit resolution: `revert` takes the server's value, the [keep-local recipe](../examples/editor-recipes/README.md#keep-the-local-value-in-a-conflict) keeps the person's. Keep the rest of the form visible. | | Connection `retrying` / `syncing` | “Reconnecting. Your unsaved changes are still here.” | Disable writes until live. | | Example editor outcome `recovering` | “Refreshing configuration. Your edits are still here.” | A usable base is not established; require an accepted guarded read before another write. This is not a connection status. | -| `saved`, watch confirmation pending | “Saved to Kubernetes; waiting for live confirmation.” | Preserve later typing. Track any receipt separately from draft state. | +| `saved`, watch confirmation pending | “Saved to Kubernetes; waiting for live confirmation.” | Preserve later typing. Track any receipt separately from draft state. Typing in the **same** field while its save is in flight makes the save's own echo a `draft-conflict` at that field: the merge cannot tell the echo from another writer's change. Keep-local and another Save resolve it. | | Session expiry or access denial | Explain sign-in or access outcome. | Handle identity-scoped recovery; do not retry terminal auth failures indefinitely or label them field conflicts. | | `unavailable`, deleted/recreated UID | “This configuration was removed. A replacement must be opened separately.” | Offer copy-out from a retained recovery copy; never apply the old draft to the replacement. | diff --git a/examples/README.md b/examples/README.md index 3fe2a1f..1e908cf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,8 +8,12 @@ through an existing host proxy, such as `kubectl proxy`, with the shared lifecyc The checked browser example in [`vanilla-browser/`](vanilla-browser/) runs against the gateway source over SSE. [Native editor](native-editor/README.md) adds conditional saves through the same proxy, with machinery protection and guarded native recovery reads, and a minimal page that edits one -ConfigMap with them. Both native pages run in Chromium under `task e2e-browser`. The larger -view/sharing comparison follows separately. +ConfigMap with them. Both native pages run in Chromium under `task e2e-browser`, through watch +resumption and expiry recovery. +[Comparison](comparison/README.md) serves the same Widgets and Secrets through native access, +`krm-full/v1` and `krm-spec/v1`, unshared and shared, with editing on every source, host counters and +a measurement driver against the spike cluster; dated results are in +[docs/facts](../docs/facts/comparison-2026-10-05.md). For gateway host integration patterns, use these small recipes: - [Same-origin cookie application](../docs/adopting.md#1-mount-the-stream-endpoint): fetch diff --git a/examples/comparison/.gitignore b/examples/comparison/.gitignore new file mode 100644 index 0000000..35fc1a4 --- /dev/null +++ b/examples/comparison/.gitignore @@ -0,0 +1,4 @@ +# Built by build.mjs (task build-compare-page). +dist/ +# Written by measure.ts; the dated report in docs/facts/ records the runs that matter. +results/ diff --git a/examples/comparison/README.md b/examples/comparison/README.md new file mode 100644 index 0000000..8435012 --- /dev/null +++ b/examples/comparison/README.md @@ -0,0 +1,162 @@ +# Native, krm-full/v1 and krm-spec/v1 compared + +One host serves **the same objects** through every source krm-stream offers, from one origin, so a +page and a measurement driver can compare them under identical objects and identical churn. It +composes the existing connectors, stores, editors and recipes; it adds no library API. + +| Source | Route | What reaches the browser | +|---|---|---| +| native | `/k8s/…` host proxy | The API server's own LIST and WATCH responses, Secret values and machinery included. Writes are conditional merge PATCHes through the same proxy. | +| full | `/stream/full` | `krm-full/v1` over SSE: machinery removed, Secret values withheld (paths and redaction revisions only), bookkeeping-only updates suppressed. One upstream watch per stream. | +| spec | `/stream/spec` | `krm-spec/v1`: as full, and `status` omitted, so status-only updates are suppressed. One upstream watch per stream. | +| full-shared, spec-shared | `/stream/full-shared`, `/stream/spec-shared` | The same views through `gateway.NewSharedBackend`: one upstream watch per scope, shared by every subscriber. | + +Gateway editing goes through host save endpoints, `/save/{full,spec}/widgets/{name}`, built like +[conditional-save](../conditional-save/README.md): a projected GET for guarded recovery reads and a +PATCH checked by `gateway.ValidateMergePatch` for that projection, carrying the captured UID and +resourceVersion as preconditions. Native editing uses the [native editor](../native-editor/README.md) +through the proxy, which forwards only PATCHes `gateway.ValidateNativeMergePatch` accepts. + +The objects live in a scratch namespace the host creates and deletes: 8 Widgets (a CRD with `spec` +and a real `status` subresource — the definition the real-API suite installs) and 3 Secrets. + +## Files + +- [gateway/kube/examples/comparison](../../gateway/kube/examples/comparison/) — the host: native proxy + ([native.go](../../gateway/kube/examples/comparison/native.go)), the four stream routes with a + counting `Backend` wrapper ([gateway.go](../../gateway/kube/examples/comparison/gateway.go)), save + endpoints ([save.go](../../gateway/kube/examples/comparison/save.go)), deterministic workloads + ([workload.go](../../gateway/kube/examples/comparison/workload.go)), counters + ([metrics.go](../../gateway/kube/examples/comparison/metrics.go)) and + [cmd/compare](../../gateway/kube/examples/comparison/cmd/compare/main.go). Its unit test needs no + cluster: schedules, the proxy's policy and counters, and the gateway route's refusal. +- [index.html](index.html) and [page.ts](page.ts) — the page. [build.mjs](build.mjs) compiles + `page.ts`, with the native editor, the conditional editor and the keep-local recipe it reuses, into + `dist/page.js`, importing the library as `@configbutler/krm-stream` through the page's import map. +- [measure.ts](measure.ts) — the measurement driver, run by node with types stripped. + [measure.sh](measure.sh) starts the host, runs it and stops the host. +- [tsconfig.json](tsconfig.json) — typechecks both TypeScript files with the client package's + compiler: `packages/krm-stream/node_modules/.bin/tsc -p examples/comparison`. + +## Run the page + +```bash +task compare # needs the spike cluster: `task cluster-up` +``` + +Open **https://127.0.0.1:8111/** and accept the self-signed certificate (made at startup, for +loopback only). The host also serves plain HTTP/1.1 on `http://127.0.0.1:8110/`, but a browser +allows six HTTP/1.1 connections per origin and this page holds six streams (two collections per +source), so over HTTP/1.1 its saves and metrics polls would queue behind them. TLS gives the browser +HTTP/2, which multiplexes them. `/` signs in as the viewer session (a cookie) and opens the page. +Add `?entry=bundle` for the single-file bundle and `?variant=shared` for the shared gateway routes. + +The page shows, per source: connection state, a table of the Widgets, an editor for the selected +Widget's `spec.note` and `spec.replicas`, conflicts with "Take theirs" (`store.revert`) and "Keep +mine" (the [keep-local recipe](../editor-recipes/keepLocal.ts)), the Secrets, and counters — events +by type, store notifications, renders (one per animation frame in which anything changed), bytes the +connector read and reconnects. Below them are the host's own metrics, controls to run a workload, +make a competing write to the selected Widget, or force-disconnect native, gateway or all downstream +connections, and a panel that opens the gateway as the refused session. + +Switching between unshared and shared opens new stores: a store is never reused across sources, +routes or identities. + +## Measure + +```bash +task compare-measure # the matrix: ~10 minutes +task compare-measure -- --workloads churn --subscribers 1,25 --reps 5 --duration-ms 30000 +task compare-native-baseline BASELINE_REF= # native before/after +``` + +`compare-measure` starts the host on `127.0.0.1:8110` (plain HTTP; the driver is node), runs +[measure.ts](measure.ts) against this tree's built library, and writes JSON and a markdown table to +`examples/comparison/results/`. For every workload, subscriber count N and repetition it opens N +subscribers per source — each watches the Widgets and the Secrets, one connection and one store per +collection — waits until all are live, runs the workload through the host, and then requires every +store to converge on the cluster's state. That is the correctness gate: a native store must hold +each object's exact resourceVersion, spec, status and Secret data; a projected store must hold the +same membership, spec (and status, for full), no Secret values, and exactly the cluster's Secret keys +as redacted paths. A run that does not converge within `--settle-ms` is reported as failed and left +out of the tables. All five sources run concurrently in each run, so every source sees the same +writes; pass `--sources` to isolate one. + +`compare-native-baseline` builds the library as it is at `BASELINE_REF` (default `main`) into a +scratch directory, measures the native source against it and then against this tree's library with +the same options, and writes a before/after table. Name the commit **before** the change under test: +once `main` contains it, `main` is not a baseline. Every option of `measure.ts` is documented at the +top of the file; `--lib DIR` and `--entry index|bundle` load any built library. + +### Workloads + +Deterministic for a given duration, rate and seed, and identical for every source. Every workload +includes forced reconnects — 2 by default, spread evenly — so reconnect cost is measured under each. + +| Workload | Writes | Forced disconnects | +|---|---|---| +| quiet | none | the 2 evenly spread | +| burst | every 4 s, 12 writes 20 ms apart, half spec and half status (`--rate` is writes per burst) | the 2 evenly spread | +| churn | status-only writes at 10/s, a spec write every 2 s, a Secret rotation every 3 s | the 2 evenly spread | +| forced-disconnect | mixed writes at 4/s (¼ spec, ½ status, ¼ Secret) | every 4 s, plus the 2 | + +A disconnect closes every downstream native watch and SSE stream, as a dropped connection would; it +never touches the upstream API-server watches directly. The driver sets the connector's +`retryDelayMs` to 0 (by default) so time-to-live measures reconnect work rather than backoff jitter, +and `healthyResetMs` to 1 s so frequent forced disconnects never exhaust the retry budget. + +## What each counter means + +Host counters (`GET /metrics`, reset with `POST /metrics/reset`; both need the viewer session). Every +key is built from constants, a route name, an event type or an HTTP status — never a UID, user, +namespace or resourceVersion. + +| Key | Meaning | +|---|---| +| `native.list_requests` | LIST requests forwarded. | +| `native.watch_requests` | WATCH requests forwarded. | +| `native.watch_after_list` / `native.watch_resumed` | A WATCH that followed a LIST for the same collection and session, or one with no LIST waiting for it: a watch resumed from a checkpoint the browser kept. The pairing is per collection and session, so it is exact in aggregate, not per tab. | +| `native.watch_streaming_list`, `native.watch_bookmarks_requested`, `native.watch_without_resource_version` | The watch parameters the connector chose. | +| `native.watch_frames.{ADDED,MODIFIED,DELETED,BOOKMARK,ERROR}` | Watch frames the proxy passed to browsers. | +| `native.bytes.{list,watch,get,patch}`, `native.upstream_bytes` | Bytes to browsers per request class; bytes read from the API server. | +| `native.{get,patch}_requests`, `native.*_status.` | Editor reads and writes, and their answers. | +| `native.refused_session_requests` | Requests made by the refused session. After a gateway refusal it must stay 0. | +| `gateway..upstream_watch_calls`, `…upstream_watches_active` (gauge) | Watches the route's own kube Backend opened, counted by a wrapper beneath any SharedBackend. Nothing else using the cluster can move them, unlike API-server gauges. | +| `gateway..upstream_bytes` | Bytes the route's client read from the API server. | +| `gateway..obs.[.]` | Gateway Observations: `cycle_started`, `consumer_resync`, `event_emitted.`, `event_suppressed.`, `shared_subscription_opened`, `stream_opened`, errors by code. | +| `gateway..authorizer_calls.{cycle,timed}`, `…authorizer_refusals` | Authorizer calls: at each snapshot cycle (opening or resync) and the timed rechecks (every 5 s here, `--reauthorize-every`). | +| `gateway..sse_bytes`, `…sse_active` (gauge) | Bytes of SSE written to browsers; open streams. | +| `native.session_checks`, `gateway..session_checks`, `sessions..` | Host session checks: one per native request, one per gateway stream request. | +| `save.{full,spec}.*` | Save endpoint GETs, PATCHes, answers and bytes. | + +Driver and page counters: **events** by type as the consumer received them; **store +notifications** (`store.subscribe` callbacks); **renders** — in the driver, one per macrotask in +which the store notified (the first notification after the previous render schedules the next on +`setTimeout(0)`), in the page one per animation frame; **bytes** the connector read, counted by a +wrapping `fetch`; **reconnects** (transitions to `retrying`); **time-to-live**, from a connection +leaving `live` to being `live` again; **Secret values seen changing** (native) and **redaction rev +bumps** (full/spec) — a rotation is a value change natively and only a revision through the gateway. + +Not measured: Kubernetes' own authorization of each forwarded native request (it happens inside the +API server, invisible to the host), browser paint and layout cost, memory and CPU of the host, the +driver or the API server, and API-server-side watch-cache work. + +## Safety notes + +- **A gateway refusal is final.** The page and the driver never retry a refused source through + `/k8s`: native access has no projection, so it would disclose what the gateway withheld. The + refused session's panel and `native.refused_session_requests` show it. +- **Native access discloses Secret values.** The native column shows them; full and spec name + redacted paths and revisions only. Do not offer native access to Secrets a user may not read. +- **One store per source, scope and identity.** Two connections must never feed one store, and a + store from one source must never be given another source's responses or editor. +- The host holds the only Kubernetes credential. Its admin and metrics routes are harness controls + guarded only by the viewer session: keep it on loopback. + +## Smoke checks + +Recorded in [docs/facts/comparison-2026-10-05.md](../../docs/facts/comparison-2026-10-05.md): the page +driven headlessly in Chromium over HTTPS/HTTP/2 on both entry points — live state on every source, +Secret disclosure per source, a native edit and gateway edits (full and spec) saved and echoed, +later typing during a save, conflicts and their resolution, a forced disconnect recovering, the +shared routes, and the refused session ending terminally with no native request. diff --git a/examples/comparison/build.mjs b/examples/comparison/build.mjs new file mode 100644 index 0000000..b3c9f0d --- /dev/null +++ b/examples/comparison/build.mjs @@ -0,0 +1,32 @@ +// Compile the comparison page's controller (page.ts) — with the native editor, the conditional +// editor and the keep-local recipe it reuses — into one browser-loadable module, dist/page.js. Types +// are stripped; the library import, a relative path to the TypeScript source, becomes +// "@configbutler/krm-stream", which the page's import map points at one built entry point. The page, +// the editors and the recipe therefore share one copy of the library: index.js or the bundle. +// +// Run with `task build-compare-page`. esbuild is the client package's own devDependency. + +import { createRequire } from "node:module"; + +const { build } = createRequire(new URL("../../packages/krm-stream/package.json", import.meta.url))("esbuild"); + +const library = { + name: "published-library", + setup(build) { + build.onResolve({ filter: /\/packages\/krm-stream\/src\/index\.ts$/ }, () => ({ + path: "@configbutler/krm-stream", + external: true, + })); + }, +}; + +await build({ + entryPoints: [new URL("page.ts", import.meta.url).pathname], + outfile: new URL("dist/page.js", import.meta.url).pathname, + bundle: true, + format: "esm", + target: "es2022", + legalComments: "none", + plugins: [library], + logLevel: "warning", +}); diff --git a/examples/comparison/index.html b/examples/comparison/index.html new file mode 100644 index 0000000..d658184 --- /dev/null +++ b/examples/comparison/index.html @@ -0,0 +1,142 @@ + + + + + + + krm-stream — native, full and spec compared + + + + +

Native, krm-full/v1 and krm-spec/v1 — compared

+

+ + +
+ + + +
+
+ + + + + + + +
+

+ +
+
+
+
+
+ +

Host metrics (since the last reset; gateway rows count every route, whichever this page shows)

+ + + +
+

+ Kubernetes' own authorization of each forwarded native request happens inside the API server and + is not visible to this host. +

+ +

A refused session

+

+ + +

+

+ Native requests made by the refused session: 0. A refusal is final: + this page never retries a refused source through /k8s. +

+ + + +

+ The native column shows Secret values: native access returns objects exactly as the API server + does. The gateway columns name redacted paths and their revisions, never values. +

+ + + + diff --git a/examples/comparison/measure.sh b/examples/comparison/measure.sh new file mode 100755 index 0000000..7802c19 --- /dev/null +++ b/examples/comparison/measure.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Start the comparison host against the kubeconfig's cluster, run measure.ts against it, stop it. +# +# measure.sh [measure.ts options] +# The matrix, against this tree's built library (task build-client). +# +# measure.sh --baseline-ref REF [measure.ts options] +# Native before/after. Builds the library as it is at REF (default for the task: main) in a +# scratch directory, measures the native source against it, then against this tree's library, +# with identical options, and writes a before/after table. Pass the commit BEFORE the change +# under test: once main contains the change, `main` is no longer a baseline. +# +# Results land in examples/comparison/results/ (or --out). The host listens on $COMPARE_ADDR +# (default 127.0.0.1:8110) and deletes its scratch namespace when it stops. +set -euo pipefail + +repo="$(cd "$(dirname "$0")/../.." && pwd)" +addr="${COMPARE_ADDR:-127.0.0.1:8110}" +ref="" +if [[ "${1:-}" == "--baseline-ref" ]]; then + ref="${2:?--baseline-ref needs a git ref}" + shift 2 +fi + +scratch="$(mktemp -d -t krm-compare-XXXX)" +bin="$scratch/compare" +log="$scratch/host.log" + +# BUILD, then run the binary — not `go run`, whose child would outlive the pid we know about and keep +# the port. Its output goes to a file so a background process never holds our stdout open. +(cd "$repo/gateway/kube" && go build -o "$bin" ./examples/comparison/cmd/compare) +"$bin" --addr "$addr" --tls-addr "" --dist "$repo/packages/krm-stream/dist" --page "$repo/examples/comparison" >"$log" 2>&1 & +pid=$! +cleanup() { + # SIGINT, then wait: the host deletes its namespace on the way out. + kill -INT "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + rm -rf "$scratch" +} +trap cleanup EXIT + +for _ in $(seq 240); do + curl -sf "http://$addr/healthz" >/dev/null && break + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 +done +curl -sf "http://$addr/healthz" >/dev/null || { echo "the comparison host never came up:"; cat "$log"; exit 1; } +head -1 "$log" + +measure=(node "$repo/examples/comparison/measure.ts" --host "http://$addr") +current="current-$(git -C "$repo" rev-parse --short HEAD)$(git -C "$repo" diff --quiet HEAD -- packages/krm-stream/src || echo -dirty)" + +if [[ -z "$ref" ]]; then + "${measure[@]}" --label "$current" "$@" >/dev/null + exit +fi + +# The baseline library: REF's packages/krm-stream, built in the scratch directory. The current +# tree's node_modules is reused when REF's lockfile is identical, so no download is needed. +sha="$(git -C "$repo" rev-parse --short "$ref^{commit}")" +pkg="$scratch/baseline/packages/krm-stream" +mkdir -p "$scratch/baseline" +git -C "$repo" archive "$sha" packages/krm-stream | tar -x -C "$scratch/baseline" +if cmp -s "$pkg/package-lock.json" "$repo/packages/krm-stream/package-lock.json"; then + ln -s "$repo/packages/krm-stream/node_modules" "$pkg/node_modules" +else + (cd "$pkg" && npm ci --no-audit --no-fund) +fi +(cd "$pkg" && npm run --silent build) +echo "baseline library: $ref ($sha) → $pkg/dist" + +status=0 +before="$("${measure[@]}" --lib "$pkg/dist" --sources native --label "baseline-$sha" "$@" | tail -1)" || status=$? +after="$("${measure[@]}" --sources native --label "$current" "$@" | tail -1)" || status=$? +node "$repo/examples/comparison/measure.ts" --compare "$before" "$after" --out "${after%.json}-vs-baseline-$sha.md" +echo "before/after table: ${after%.json}-vs-baseline-$sha.md" +exit "$status" diff --git a/examples/comparison/measure.ts b/examples/comparison/measure.ts new file mode 100644 index 0000000..3d982ec --- /dev/null +++ b/examples/comparison/measure.ts @@ -0,0 +1,743 @@ +// The comparison's measurement driver. Node runs it directly (types are stripped), like the e2e +// drivers: the REAL connectors and stores, loaded from a configurable library build, against the +// comparison host (gateway/kube/examples/comparison) and its real API server. +// +// For every workload, subscriber count and repetition it opens N subscribers per source — each one a +// page watching the Widgets and the Secrets, one store per collection — waits until every one is +// live, triggers the workload through the host, waits for every store to converge on the cluster's +// state (the correctness gate: a run whose stores diverge is reported as failed, not measured), +// collects the host's counters and closes everything. It writes JSON and a markdown table. +// +// node examples/comparison/measure.ts [options] +// node examples/comparison/measure.ts --compare before.json after.json [--out file.md] +// +// Options (defaults in brackets): +// --host URL the comparison host [http://127.0.0.1:8110] +// --lib DIR built library directory [packages/krm-stream/dist] +// --entry index|bundle which entry point of it to load [index] +// --sources LIST native,full,spec,full-shared,spec-shared [all five] +// --subscribers LIST subscriber counts [1,10] +// --workloads LIST quiet,burst,churn,forced-disconnect [all four] +// --reps N repetitions [3] +// --duration-ms N workload duration [20000] +// --seed N workload seed [1] +// --reconnects N forced reconnects in every workload [2] +// --retry-delay-ms N connector retryDelayMs; 0 measures reconnect work without backoff [0] +// --settle-ms N how long stores may take to converge after a workload [20000] +// --label TEXT names this run in the output [current] +// --out DIR where results go [examples/comparison/results] +// --session TOKEN the viewer session [viewer-session] +// --refused-session TOKEN the refused session [refused-session] + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +type Lib = typeof import("../../packages/krm-stream/src/index.ts"); +type Store = InstanceType; +type Handle = ReturnType; +type StateEvent = Parameters[1]>[0]; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const SOURCES = ["native", "full", "spec", "full-shared", "spec-shared"] as const; +type Source = (typeof SOURCES)[number]; + +// ------------------------------------------------------------------------------- arguments -- + +const argv = process.argv.slice(2); +function flag(name: string, fallback: string): string { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1]! : fallback; +} +const list = (value: string) => value.split(",").filter((v) => v !== ""); + +const opts = { + host: flag("host", "http://127.0.0.1:8110").replace(/\/+$/, ""), + lib: resolve(flag("lib", resolve(repo, "packages/krm-stream/dist"))), + entry: flag("entry", "index"), + sources: list(flag("sources", SOURCES.join(","))) as Source[], + subscribers: list(flag("subscribers", "1,10")).map(Number), + workloads: list(flag("workloads", "quiet,burst,churn,forced-disconnect")), + reps: Number(flag("reps", "3")), + durationMs: Number(flag("duration-ms", "20000")), + seed: Number(flag("seed", "1")), + reconnects: Number(flag("reconnects", "2")), + retryDelayMs: Number(flag("retry-delay-ms", "0")), + settleMs: Number(flag("settle-ms", "20000")), + label: flag("label", "current"), + out: resolve(flag("out", resolve(repo, "examples/comparison/results"))), + session: flag("session", "viewer-session"), + refusedSession: flag("refused-session", "refused-session"), +}; +for (const s of opts.sources) if (!SOURCES.includes(s)) throw new Error(`unknown source ${s}`); + +const libFile = resolve(opts.lib, opts.entry === "bundle" ? "krm-stream.js" : "index.js"); +// Loaded by main(): a --compare invocation needs neither the library nor the host. +let lib: Lib; + +// ------------------------------------------------------------------------------ the host -- + +interface PageConfig { + namespace: string; + group: string; + widgets: string[]; + secrets: string[]; +} +interface Metrics { + elapsedMs: number; + counters: Record; + gauges: Record; +} +interface StateObject { + kind: string; + uid: string; + name: string; + resourceVersion: string; + spec?: unknown; + status?: unknown; + data?: Record; +} + +async function host(path: string, init: RequestInit = {}, session = opts.session): Promise { + const res = await fetch(`${opts.host}${path}`, { + ...init, + headers: { "X-Compare-Session": session, "Content-Type": "application/json", ...init.headers }, + }); + if (!res.ok) throw new Error(`${init.method ?? "GET"} ${path}: HTTP ${res.status} ${await res.text()}`); + return (await res.json()) as T; +} + +let config: PageConfig; + +// ----------------------------------------------------------------------------- a subscriber -- + +/** What one collection feed saw. Counts are cumulative from the moment the feed opened. */ +interface FeedCounts { + events: Record; + notifications: number; + renders: number; + bytes: number; + reconnects: number; + errors: Record; + /** Native: a Secret's value changed. Full/spec: a redaction revision moved. */ + secretValueChanges: number; + redactionRevBumps: number; +} + +const emptyCounts = (): FeedCounts => ({ + events: {}, + notifications: 0, + renders: 0, + bytes: 0, + reconnects: 0, + errors: {}, + secretValueChanges: 0, + redactionRevBumps: 0, +}); + +/** A fetch that counts the response bytes the connector reads. */ +function countingFetch(onBytes: (n: number) => void): typeof fetch { + return async (input, init) => { + const res = await fetch(input, init); + if (!res.body || [101, 204, 205, 304].includes(res.status)) return res; + const counted = res.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + onBytes(chunk.byteLength); + controller.enqueue(chunk); + }, + }), + ); + return new Response(counted, { status: res.status, statusText: res.statusText, headers: res.headers }); + }; +} + +/** One collection, one connection, one store. */ +class Feed { + readonly source: Source; + readonly resource: "widgets" | "secrets"; + readonly store: Store; + readonly handle: Handle; + readonly counts = emptyCounts(); + /** Milliseconds from leaving `live` to being `live` again, once per recovery. */ + readonly recoveries: number[] = []; + firstLiveMs: number | undefined; + #lostAt: number | undefined; + #renderPending = false; + #secretValues = new Map(); + #revs = new Map(); + + constructor(source: Source, resource: "widgets" | "secrets", session: string) { + this.source = source; + this.resource = resource; + // A viewer: every region read-only. The store applies events exactly as an editing page's does. + this.store = new lib.LiveResourceStore(lib.readOnlyPolicy); + this.store.subscribe(() => { + this.counts.notifications++; + // A render is one per macrotask in which the store notified: the first notification after the + // previous render schedules the next, as the next timer task. + if (!this.#renderPending) { + this.#renderPending = true; + setTimeout(() => { + this.#renderPending = false; + this.counts.renders++; + }, 0); + } + }); + const scope = { + group: resource === "widgets" ? config.group : undefined, + version: "v1", + resource, + namespace: config.namespace, + }; + const options = { + fetch: countingFetch((n) => { + this.counts.bytes += n; + }), + headers: { "X-Compare-Session": session }, + retryDelayMs: opts.retryDelayMs, + // Forced disconnects every few seconds would otherwise exhaust the default retry budget, which + // resets only after 30 s live. + healthyResetMs: 1000, + onError: (code: string) => { + this.counts.errors[code] = (this.counts.errors[code] ?? 0) + 1; + }, + }; + const consume = (event: StateEvent) => { + this.counts.events[event.type] = (this.counts.events[event.type] ?? 0) + 1; + lib.applyStreamEvent(this.store, event); + if (resource === "secrets" && (event.type === "added" || event.type === "modified")) this.#secret(event); + }; + const started = performance.now(); + this.handle = + source === "native" + ? lib.connectNativeWatch(lib.nativeCollectionURL(`${opts.host}/k8s`, scope), consume, options) + : lib.connectResourceStream(lib.resourceStreamURL(`${opts.host}/stream/${source}`, scope), consume, options); + this.handle.subscribe((state) => { + const now = performance.now(); + if (state.status === "live") { + this.firstLiveMs ??= now - started; + if (this.#lostAt !== undefined) this.recoveries.push(now - this.#lostAt); + this.#lostAt = undefined; + } else if (this.#lostAt === undefined && this.firstLiveMs !== undefined) { + this.#lostAt = now; + } + if (state.status === "retrying") this.counts.reconnects++; + }); + this.handle.closed.catch((error: unknown) => { + this.counts.errors.HOST_EXCEPTION = (this.counts.errors.HOST_EXCEPTION ?? 0) + 1; + console.error(`${source}/${resource}: consumer failed`, error); + }); + } + + /** Track what a Secret event disclosed: a value (native) or a revision (projected). */ + #secret(event: Extract) { + const uid = event.object.metadata.uid; + const value = JSON.stringify(event.object.data ?? null); + const before = this.#secretValues.get(uid); + if (before !== undefined && before !== value) this.counts.secretValueChanges++; + this.#secretValues.set(uid, value); + for (const r of event.redacted ?? []) { + const key = `${uid} ${r.path}`; + const prev = this.#revs.get(key); + if (prev !== undefined && r.rev > prev) this.counts.redactionRevBumps++; + this.#revs.set(key, r.rev); + } + } + + get live(): boolean { + return this.handle.state.status === "live"; + } + + snapshot(): FeedCounts { + return structuredClone(this.counts); + } + + close(): Promise { + this.handle.close(); + return this.handle.closed.catch(() => {}); + } +} + +// ------------------------------------------------------------------------- correctness gate -- + +/** Why a source's store does not hold what the cluster holds, or undefined when it does. */ +function divergence(feed: Feed, state: StateObject[]): string | undefined { + const kind = feed.resource === "widgets" ? "Widget" : "Secret"; + const want = state.filter((o) => o.kind === kind); + const ids = feed.store.ids(); + if (ids.length !== want.length || !want.every((o) => ids.includes(o.uid))) { + return `${kind} membership: store ${ids.length}, cluster ${want.length}`; + } + const projected = feed.source !== "native"; + const specOnly = feed.source.startsWith("spec"); + for (const o of want) { + const got = feed.store.server(o.uid); + if (kind === "Widget") { + if (!lib.deepEqual(got.spec, o.spec)) return `${o.name}: spec differs`; + if (specOnly ? got.status !== undefined : !lib.deepEqual(got.status, o.status)) { + return `${o.name}: status ${specOnly ? "present in a spec projection" : "differs"}`; + } + } else if (projected) { + // Never a value; exactly the cluster's keys, named as redacted paths. + if (got.data !== undefined) return `${o.name}: a projected Secret carries values`; + const paths = feed.store + .redactions(o.uid) + .map((r) => r.path.join("/")) + .sort(); + const keys = Object.keys(o.data ?? {}) + .map((k) => `data/${k}`) + .sort(); + if (!lib.deepEqual(paths, keys)) return `${o.name}: redacted paths ${paths} != ${keys}`; + } else if (!lib.deepEqual(got.data, o.data)) { + return `${o.name}: data differs`; + } + // A native store holds the cluster's exact version. A projection may legitimately hold an older + // one: a suppressed update advances the cluster without an event. + if (!projected && got.metadata.resourceVersion !== o.resourceVersion) return `${o.name}: resourceVersion differs`; + } + return undefined; +} + +// -------------------------------------------------------------------------------- one run -- + +interface SourceResult { + connectMs: number[]; + recoveriesMs: number[]; + /** Client counters summed over the source's subscribers, during the workload only. */ + client: FeedCounts; + /** Host counters for this source, during the workload only. */ + host: Record; + /** Host counters for this source during the initial connect. */ + hostConnect: Record; + /** Gauges at the end of the workload, before closing. */ + gauges: Record; + divergence?: string; +} + +interface RunResult { + workload: string; + subscribers: number; + rep: number; + ok: boolean; + failure?: string; + settleMs: number; + workloadResult: unknown; + sessions: Record; + sources: Partial>; +} + +function sumCounts(all: FeedCounts[]): FeedCounts { + const out = emptyCounts(); + for (const c of all) { + for (const [k, v] of Object.entries(c.events)) out.events[k] = (out.events[k] ?? 0) + v; + for (const [k, v] of Object.entries(c.errors)) out.errors[k] = (out.errors[k] ?? 0) + v; + out.notifications += c.notifications; + out.renders += c.renders; + out.bytes += c.bytes; + out.reconnects += c.reconnects; + out.secretValueChanges += c.secretValueChanges; + out.redactionRevBumps += c.redactionRevBumps; + } + return out; +} + +function minus(after: FeedCounts, before: FeedCounts): FeedCounts { + const out = emptyCounts(); + for (const k of new Set([...Object.keys(after.events), ...Object.keys(before.events)])) + out.events[k] = (after.events[k] ?? 0) - (before.events[k] ?? 0); + for (const k of new Set([...Object.keys(after.errors), ...Object.keys(before.errors)])) + out.errors[k] = (after.errors[k] ?? 0) - (before.errors[k] ?? 0); + out.notifications = after.notifications - before.notifications; + out.renders = after.renders - before.renders; + out.bytes = after.bytes - before.bytes; + out.reconnects = after.reconnects - before.reconnects; + out.secretValueChanges = after.secretValueChanges - before.secretValueChanges; + out.redactionRevBumps = after.redactionRevBumps - before.redactionRevBumps; + return out; +} + +/** The host counters that belong to one source. */ +function hostFor(source: Source, counters: Record): Record { + const prefix = source === "native" ? "native." : `gateway.${source}.`; + return Object.fromEntries( + Object.entries(counters) + .filter(([k]) => k.startsWith(prefix)) + .map(([k, v]) => [k.slice(prefix.length), v]), + ); +} + +function diff(after: Record, before: Record): Record { + const out: Record = {}; + for (const k of new Set([...Object.keys(after), ...Object.keys(before)])) { + const d = (after[k] ?? 0) - (before[k] ?? 0); + if (d !== 0) out[k] = d; + } + return out; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function waitFor(what: string, timeoutMs: number, done: () => boolean | Promise) { + const deadline = performance.now() + timeoutMs; + while (!(await done())) { + if (performance.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await sleep(50); + } +} + +async function runOnce(workload: string, n: number, rep: number): Promise { + await host("/admin/reset", { method: "POST" }); + await host("/metrics/reset", { method: "POST" }); + const feeds = new Map(); + for (const source of opts.sources) { + const list: Feed[] = []; + for (let i = 0; i < n; i++) { + list.push(new Feed(source, "widgets", opts.session), new Feed(source, "secrets", opts.session)); + } + feeds.set(source, list); + } + const all = [...feeds.values()].flat(); + const result: RunResult = { + workload, + subscribers: n, + rep, + ok: false, + settleMs: 0, + workloadResult: undefined, + sessions: {}, + sources: {}, + }; + try { + await waitFor("every subscriber live", 60_000, () => all.every((f) => f.live)); + await sleep(250); // let the last snapshot's renders run before the baseline is taken + const m0 = await host("/metrics"); + const before = new Map(all.map((f) => [f, f.snapshot()])); + + result.workloadResult = await host("/admin/workload", { + method: "POST", + body: JSON.stringify({ + name: workload, + durationMs: opts.durationMs, + seed: opts.seed, + reconnects: opts.reconnects, + }), + }); + + // The correctness gate: every store converges on the cluster, or the run failed. + const settleStart = performance.now(); + let state: StateObject[] = []; + let last: string | undefined; + try { + await waitFor("convergence", opts.settleMs, async () => { + state = await host("/admin/state"); + last = undefined; + for (const f of all) { + if (!f.live) { + last = `${f.source}/${f.resource} not live (${f.handle.state.status})`; + return false; + } + const why = divergence(f, state); + if (why) { + last = `${f.source}/${f.resource}: ${why}`; + return false; + } + } + return true; + }); + result.ok = true; + } catch { + result.failure = `stores did not converge within ${opts.settleMs} ms: ${last}`; + } + result.settleMs = Math.round(performance.now() - settleStart); + await sleep(250); + const m1 = await host("/metrics"); + result.sessions = Object.fromEntries(Object.entries(m1.counters).filter(([k]) => k.startsWith("sessions."))); + for (const [source, list] of feeds) { + const client = sumCounts(list.map((f) => minus(f.snapshot(), before.get(f)!))); + result.sources[source] = { + connectMs: list.map((f) => Math.round(f.firstLiveMs ?? -1)), + recoveriesMs: list.flatMap((f) => f.recoveries.map(Math.round)), + client, + host: diff(hostFor(source, m1.counters), hostFor(source, m0.counters)), + hostConnect: hostFor(source, m0.counters), + gauges: hostFor(source, m1.gauges), + divergence: list.map((f) => divergence(f, state)).find((d) => d !== undefined), + }; + } + } finally { + await Promise.all(all.map((f) => f.close())); + } + return result; +} + +// ---------------------------------------------------------- refusal is never a native fallback -- + +/** Opens every gateway route as the refused session and checks that each ends terminally, and that + * nothing reached the native proxy for that session afterwards. This driver never falls back; the + * host's counter is the independent witness. */ +async function refusalCheck() { + const before = await host("/metrics"); + const routes = ["full", "spec", "full-shared", "spec-shared"]; + const outcomes: Record = {}; + await Promise.all( + routes.map(async (route) => { + let error = ""; + const handle = lib.connectResourceStream( + lib.resourceStreamURL(`${opts.host}/stream/${route}`, { + group: config.group, + version: "v1", + resource: "widgets", + namespace: config.namespace, + }), + () => {}, + { + headers: { "X-Compare-Session": opts.refusedSession }, + onError: (code, _message, terminal) => { + error = `${code}${terminal ? " (terminal)" : ""}`; + }, + }, + ); + await handle.closed.catch(() => {}); + outcomes[route] = `${error}, final state ${handle.state.status}`; + }), + ); + const after = await host("/metrics"); + const nativeAfter = + (after.counters["native.refused_session_requests"] ?? 0) - + (before.counters["native.refused_session_requests"] ?? 0); + const ok = + nativeAfter === 0 && + Object.values(outcomes).every((o) => o.startsWith("FORBIDDEN (terminal), final state terminal")); + return { ok, outcomes, nativeRequestsByRefusedSessionAfterRefusal: nativeAfter }; +} + +// ------------------------------------------------------------------------------- reporting -- + +const median = (xs: number[]) => { + if (xs.length === 0) return Number.NaN; + const s = [...xs].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2; +}; + +function fmt(xs: number[]): string { + if (xs.length === 0 || xs.every((x) => Number.isNaN(x))) return "–"; + const ys = xs.filter((x) => !Number.isNaN(x)); + const round = (x: number) => (Math.abs(x) >= 100 ? Math.round(x).toString() : (Math.round(x * 10) / 10).toString()); + const lo = Math.min(...ys); + const hi = Math.max(...ys); + return lo === hi ? round(lo) : `${round(median(ys))} (${round(lo)}–${round(hi)})`; +} + +const kb = (x: number) => x / 1024; + +/** The metrics each table reports, per source run. */ +const clientColumns: [string, (s: SourceResult) => number][] = [ + ["events added", (s) => s.client.events.added ?? 0], + ["modified", (s) => s.client.events.modified ?? 0], + ["deleted", (s) => s.client.events.deleted ?? 0], + ["resets", (s) => s.client.events.reset ?? 0], + ["notifications", (s) => s.client.notifications], + ["renders", (s) => s.client.renders], + ["KiB down", (s) => kb(s.client.bytes)], + ["reconnects", (s) => s.client.reconnects], + ["time-to-live ms (median)", (s) => median(s.recoveriesMs)], + ["time-to-live ms (max)", (s) => (s.recoveriesMs.length ? Math.max(...s.recoveriesMs) : Number.NaN)], + ["Secret values seen changing", (s) => s.client.secretValueChanges], + ["redaction rev bumps", (s) => s.client.redactionRevBumps], +]; + +const hostColumns: Record<"native" | "gateway", [string, (s: SourceResult) => number][]> = { + native: [ + ["LIST", (s) => s.host.list_requests ?? 0], + ["WATCH", (s) => s.host.watch_requests ?? 0], + ["WATCH after LIST", (s) => s.host.watch_after_list ?? 0], + ["WATCH resumed", (s) => s.host.watch_resumed ?? 0], + ["watch frames", (s) => sumPrefix(s.host, "watch_frames.")], + ["ERROR frames", (s) => s.host["watch_frames.ERROR"] ?? 0], + ["KiB to browser", (s) => kb(sumPrefix(s.host, "bytes."))], + ["KiB from API server", (s) => kb(s.host.upstream_bytes ?? 0)], + ["active watches at end", (s) => s.gauges.watches_active ?? 0], + ["session checks", (s) => s.host.session_checks ?? 0], + ], + gateway: [ + ["upstream watches opened", (s) => s.host.upstream_watch_calls ?? 0], + ["active upstream at end", (s) => s.gauges.upstream_watches_active ?? 0], + ["cycles", (s) => s.host["obs.cycle_started"] ?? 0], + ["consumer resyncs", (s) => s.host["obs.consumer_resync"] ?? 0], + ["events emitted", (s) => sumPrefix(s.host, "obs.event_emitted.")], + ["suppressed", (s) => sumPrefix(s.host, "obs.event_suppressed.")], + ["shared subscriptions", (s) => s.host["obs.shared_subscription_opened"] ?? 0], + ["authorizer calls", (s) => (s.host["authorizer_calls.cycle"] ?? 0) + (s.host["authorizer_calls.timed"] ?? 0)], + ["of which timed", (s) => s.host["authorizer_calls.timed"] ?? 0], + ["KiB SSE", (s) => kb(s.host.sse_bytes ?? 0)], + ["KiB from API server", (s) => kb(s.host.upstream_bytes ?? 0)], + ["session checks", (s) => s.host.session_checks ?? 0], + ], +}; + +function sumPrefix(m: Record, prefix: string): number { + return Object.entries(m) + .filter(([k]) => k.startsWith(prefix)) + .reduce((a, [, v]) => a + v, 0); +} + +function table( + runs: RunResult[], + workload: string, + sources: Source[], + columns: [string, (s: SourceResult) => number][], +): string { + const lines = [ + `| source | N | ${columns.map(([c]) => c).join(" | ")} |`, + `|---|---|${columns.map(() => "---|").join("")}`, + ]; + for (const n of [...new Set(runs.map((r) => r.subscribers))]) { + for (const source of sources) { + const mine = runs.filter((r) => r.workload === workload && r.subscribers === n && r.ok && r.sources[source]); + if (mine.length === 0) continue; + const cells = columns.map(([, get]) => fmt(mine.map((r) => get(r.sources[source]!)))); + lines.push(`| ${source} | ${n} | ${cells.join(" | ")} |`); + } + } + return lines.join("\n"); +} + +function markdown(results: Results): string { + const runs = results.runs; + const out: string[] = [ + `# Comparison run: ${results.label}`, + "", + `Library: \`${results.library}\` · host: ${results.host} · ${results.startedAt} → ${results.finishedAt}`, + "", + `Workloads ${opts.workloads.join(", ")}; subscribers ${opts.subscribers.join(", ")}; ${opts.reps} repetitions; ` + + `duration ${opts.durationMs} ms; seed ${opts.seed}; ${opts.reconnects} forced reconnects per workload; ` + + `retryDelayMs ${opts.retryDelayMs}. Cells are median (min–max) over the repetitions that passed the ` + + "correctness gate; client and host counts cover the workload phase only, summed over the N subscribers " + + "(each subscriber watches the Widgets and the Secrets, one connection and one store each).", + "", + `Refusal check: ${results.refusal.ok ? "passed" : "FAILED"} — ${JSON.stringify(results.refusal.outcomes)}; ` + + `native requests by the refused session afterwards: ${results.refusal.nativeRequestsByRefusedSessionAfterRefusal}.`, + "", + ]; + const failed = runs.filter((r) => !r.ok); + out.push( + failed.length === 0 + ? `All ${runs.length} runs passed the correctness gate.` + : `**${failed.length} of ${runs.length} runs FAILED the correctness gate and are excluded:** ` + + failed.map((r) => `${r.workload}/N=${r.subscribers}/rep ${r.rep}: ${r.failure}`).join("; "), + "", + ); + const gatewaySources = opts.sources.filter((s) => s !== "native"); + for (const workload of opts.workloads) { + const w = runs.find((r) => r.workload === workload)?.workloadResult as { done?: object } | undefined; + out.push(`## ${workload}`, "", `Writes performed (one run): \`${JSON.stringify(w?.done ?? {})}\``, ""); + out.push( + "Client side (what the subscribers consumed):", + "", + table(runs, workload, opts.sources, clientColumns), + "", + ); + if (opts.sources.includes("native")) { + out.push("Host side, native proxy:", "", table(runs, workload, ["native"], hostColumns.native), ""); + } + if (gatewaySources.length) { + out.push("Host side, gateway routes:", "", table(runs, workload, gatewaySources, hostColumns.gateway), ""); + } + } + return out.join("\n"); +} + +interface Results { + label: string; + library: string; + host: string; + startedAt: string; + finishedAt: string; + options: typeof opts; + refusal: Awaited>; + runs: RunResult[]; +} + +/** A native before/after table from two result files. */ +function compareFiles(beforePath: string | undefined, afterPath: string | undefined, out: string) { + if (!beforePath || !afterPath) throw new Error("usage: measure.ts --compare before.json after.json [--out file.md]"); + const before = JSON.parse(readFileSync(beforePath, "utf8")) as Results; + const after = JSON.parse(readFileSync(afterPath, "utf8")) as Results; + const columns = [...clientColumns, ...hostColumns.native]; + const lines = [ + `# Native before/after: ${before.label} → ${after.label}`, + "", + `Before: \`${before.library}\`. After: \`${after.library}\`. Cells are median (min–max) over passing repetitions.`, + "", + ]; + for (const workload of before.options.workloads) { + lines.push(`## ${workload}`, "", `| metric | N | ${before.label} | ${after.label} |`, "|---|---|---|---|"); + for (const n of before.options.subscribers) { + for (const [name, get] of columns) { + const cell = (r: Results) => + fmt( + r.runs + .filter((x) => x.workload === workload && x.subscribers === n && x.ok && x.sources.native) + .map((x) => get(x.sources.native!)), + ); + lines.push(`| ${name} | ${n} | ${cell(before)} | ${cell(after)} |`); + } + } + lines.push(""); + } + const failed = [before, after].flatMap((r) => r.runs.filter((x) => !x.ok).map((x) => `${r.label}: ${x.failure}`)); + if (failed.length) lines.push(`**Failed runs (excluded):** ${failed.join("; ")}`, ""); + const text = lines.join("\n"); + if (out) writeFileSync(out, text); + process.stdout.write(`${text}\n`); +} + +// ------------------------------------------------------------------------------------ main -- + +if (argv[0] === "--compare") { + compareFiles(argv[1], argv[2], flag("out", "")); +} else { + await main(); +} + +async function main() { + lib = (await import(pathToFileURL(libFile).href)) as Lib; + config = await host("/config"); + const startedAt = new Date().toISOString(); + console.error(`library ${libFile}; namespace ${config.namespace}`); + const refusal = await refusalCheck(); + console.error(`refusal check: ${refusal.ok ? "passed" : "FAILED"} ${JSON.stringify(refusal)}`); + const runs: RunResult[] = []; + for (const workload of opts.workloads) { + for (const n of opts.subscribers) { + for (let rep = 1; rep <= opts.reps; rep++) { + const t0 = performance.now(); + const run = await runOnce(workload, n, rep); + runs.push(run); + console.error( + `${workload} N=${n} rep ${rep}: ${run.ok ? "ok" : `FAILED ${run.failure}`} ` + + `(${Math.round((performance.now() - t0) / 1000)} s, settled in ${run.settleMs} ms)`, + ); + } + } + } + const results: Results = { + label: opts.label, + library: libFile, + host: opts.host, + startedAt, + finishedAt: new Date().toISOString(), + options: opts, + refusal, + runs, + }; + mkdirSync(opts.out, { recursive: true }); + const stem = resolve(opts.out, `${startedAt.replace(/[:.]/g, "-")}-${opts.label}`); + writeFileSync(`${stem}.json`, `${JSON.stringify(results, null, 2)}\n`); + writeFileSync(`${stem}.md`, `${markdown(results)}\n`); + console.error(`wrote ${stem}.json and ${stem}.md`); + process.stdout.write(`${stem}.json\n`); + process.exit(refusal.ok && runs.every((r) => r.ok) ? 0 : 1); +} diff --git a/examples/comparison/page.ts b/examples/comparison/page.ts new file mode 100644 index 0000000..f434064 --- /dev/null +++ b/examples/comparison/page.ts @@ -0,0 +1,494 @@ +// The comparison page's controller: the same Widgets and Secrets through three sources side by side +// — native, krm-full/v1 and krm-spec/v1 — with editing on every one of them, using the existing +// connectors, stores, editors and recipes. build.mjs compiles it into dist/page.js. +// +// One store per source, collection and identity. A source's store is never fed by another source, +// and the page never opens a native connection for a session the gateway refused. + +import { + applyStreamEvent, + type ConnectionState, + connectNativeWatch, + connectResourceStream, + type KRMObject, + LiveResourceStore, + nativeCollectionURL, + type Path, + type ResourceStateEvent, + type ResourceStreamHandle, + resourceStreamURL, +} from "../../packages/krm-stream/src/index.ts"; +import { conditionalEditor } from "../conditional-save/editor.ts"; +import { keepLocal } from "../editor-recipes/keepLocal.ts"; +import { nativeEditor } from "../native-editor/editor.ts"; + +interface PageConfig { + namespace: string; + group: string; + widgets: string[]; + secrets: string[]; + refusedSession: string; + workloads: string[]; +} + +type Kind = "native" | "full" | "spec"; +type Editor = { readonly saving: boolean; save(): Promise }; + +const $ = (selector: string, root: ParentNode = document) => + root.querySelector(selector) as T; +const el = (tag: K, text = "", attrs: Record = {}) => { + const node = document.createElement(tag); + node.textContent = text; + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v); + return node; +}; + +const params = new URLSearchParams(location.search); +const config = (await (await fetch("/config", { cache: "no-store" })).json()) as PageConfig; +$("#target").textContent = + `namespace ${config.namespace} · entry ${params.get("entry") === "bundle" ? "dist/krm-stream.js" : "dist/index.js"}`; +// This page holds six streams. Over HTTP/1.1 a browser allows six connections per origin, so every +// save and metrics poll would wait behind them; the host's TLS listener gives the browser HTTP/2. +if (location.protocol === "http:") { + $("#protocol").textContent = + "Served over plain HTTP/1.1: this page's six streams use every connection the browser allows this " + + "origin, so saves will wait. Open the host's https:// address (HTTP/2) instead."; +} + +const widgetScope = { group: config.group, version: "v1", resource: "widgets", namespace: config.namespace }; +const secretScope = { version: "v1", resource: "secrets", namespace: config.namespace }; +// The host's fetch: same-origin, so the session cookie goes with every request. Add CSRF here. +const request: typeof fetch = (input, init) => fetch(input, init); + +/** A fetch that counts the response bytes a connector reads. */ +function countingFetch(onBytes: (n: number) => void): typeof fetch { + return async (input, init) => { + const res = await fetch(input, init); + if (!res.body || [101, 204, 205, 304].includes(res.status)) return res; + const counted = res.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + onBytes(chunk.byteLength); + controller.enqueue(chunk); + }, + }), + ); + return new Response(counted, { status: res.status, statusText: res.statusText, headers: res.headers }); + }; +} + +interface Feed { + store: LiveResourceStore; + handle: ResourceStreamHandle; +} + +/** One source: its two collections, its counters and its editor. */ +class Column { + readonly kind: Kind; + readonly root: HTMLElement; + route = ""; + widgets?: Feed; + secrets?: Feed; + counts = { events: {} as Record, notifications: 0, renders: 0, bytes: 0, reconnects: 0 }; + error = ""; + editor?: Editor; + editorUid?: string; + #renderQueued = false; + + constructor(kind: Kind, root: HTMLElement) { + this.kind = kind; + this.root = root; + } + + /** (Re)open both collections on a route with NEW stores: a store is never reused across routes. */ + open(route: string) { + this.close(); + this.route = route; + this.editor = undefined; + this.editorUid = undefined; + this.counts = { events: {}, notifications: 0, renders: 0, bytes: 0, reconnects: 0 }; + this.error = ""; + this.widgets = this.#feed(widgetScope); + this.secrets = this.#feed(secretScope); + $("[data-role=route]", this.root).textContent = this.kind === "native" ? "/k8s (host proxy)" : `/stream/${route}`; + this.render(); + } + + #feed(scope: typeof widgetScope | typeof secretScope): Feed { + const store = new LiveResourceStore(); + store.subscribe(() => { + this.counts.notifications++; + this.render(); + }); + const consume = (event: ResourceStateEvent) => { + this.counts.events[event.type] = (this.counts.events[event.type] ?? 0) + 1; + applyStreamEvent(store, event); + }; + const options = { + fetch: countingFetch((n) => { + this.counts.bytes += n; + this.render(); + }), + onError: (code: string, message: string, terminal: boolean) => { + this.error = `${code}${terminal ? " (terminal)" : ""}: ${message}`; + this.render(); + }, + }; + const handle = + this.kind === "native" + ? connectNativeWatch(nativeCollectionURL("/k8s", scope), consume, options) + : connectResourceStream(resourceStreamURL(`/stream/${this.route}`, scope), consume, options); + handle.subscribe((state: Readonly) => { + if (state.status === "retrying") this.counts.reconnects++; + this.render(); + }); + handle.closed.catch((error: unknown) => { + this.error = `page error: ${error}`; + this.render(); + }); + return { store, handle }; + } + + close() { + this.widgets?.handle.close(); + this.secrets?.handle.close(); + } + + live(): boolean { + return this.widgets?.handle.state.status === "live"; + } + + /** The widget being edited in this column, by the name selected for every column. */ + selected(): string | undefined { + const store = this.widgets?.store; + const name = $("#widget").value; + return store?.ids().find((id) => store.server(id).metadata.name === name); + } + + editorFor(uid: string): Editor { + if (this.editor && this.editorUid === uid) return this.editor; + const store = this.widgets!.store; + const live = () => this.live(); + const name = store.server(uid).metadata.name; + this.editor = + this.kind === "native" + ? nativeEditor(store, uid, { proxy: "/k8s", scope: widgetScope }, request, live) + : conditionalEditor(store, uid, `/save/${this.kind}/widgets/${name}`, request, live); + this.editorUid = uid; + return this.editor; + } + + async save() { + const uid = this.selected(); + if (!uid) return; + const outcome = $("[data-role=outcome]", this.root); + outcome.textContent = "saving…"; + this.render(); + try { + const result = await this.editorFor(uid).save(); + outcome.textContent = result; + } catch (error) { + outcome.textContent = `error: ${error instanceof Error ? error.message : String(error)}`; + } + outcome.dataset.at = String(Date.now()); + this.render(); + } + + /** One render per animation frame in which anything changed. */ + render() { + if (this.#renderQueued) return; + this.#renderQueued = true; + requestAnimationFrame(() => { + this.#renderQueued = false; + this.counts.renders++; + this.#draw(); + }); + } + + #draw() { + const r = this.root; + const w = this.widgets?.handle.state.status ?? "closed"; + const s = this.secrets?.handle.state.status ?? "closed"; + $("[data-role=state]", r).textContent = w === s ? w : `widgets ${w} · secrets ${s}`; + $("[data-role=state]", r).dataset.status = w === "live" && s === "live" ? "live" : w; + $("[data-role=error]", r).textContent = this.error; + const ev = this.counts.events; + $("[data-role=counters]", r).textContent = + `events reset ${ev.reset ?? 0} · added ${ev.added ?? 0} · modified ${ev.modified ?? 0} · deleted ${ev.deleted ?? 0}` + + ` · notifications ${this.counts.notifications} · renders ${this.counts.renders}` + + ` · ${(this.counts.bytes / 1024).toFixed(1)} KiB · reconnects ${this.counts.reconnects}`; + if (!this.widgets || !this.secrets) return; + this.#drawWidgets(this.widgets.store); + this.#drawEditor(this.widgets.store); + this.#drawSecrets(this.secrets.store); + } + + #drawWidgets(store: LiveResourceStore) { + const rows = $("[data-role=rows]", this.root); + rows.replaceChildren( + ...store + .ids() + .map((id) => store.server(id)) + .sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)) + .map((o: KRMObject) => { + const spec = (o.spec ?? {}) as Record; + const status = o.status as Record | undefined; + const tr = el("tr", "", { "data-name": o.metadata.name }); + tr.append( + el("td", o.metadata.name), + el("td", o.metadata.resourceVersion ?? ""), + el("td", String(spec.replicas ?? "")), + el("td", String(spec.revision ?? "")), + el("td", String(spec.note ?? ""), { "data-role": "server-note" }), + el("td", status ? `${status.phase} #${status.heartbeat}` : "— (not in view)"), + ); + return tr; + }), + ); + } + + #drawEditor(store: LiveResourceStore) { + const r = this.root; + const uid = this.selected(); + const fields = $("[data-role=editor]", r); + fields.disabled = uid === undefined; + if (!uid) return; + const editor = this.editorFor(uid); + const draft = store.draft(uid).spec as Record; + for (const [field, input] of [ + ["note", $("[data-role=note]", r)], + ["replicas", $("[data-role=replicas]", r)], + ] as const) { + // Never overwrite what the person is typing. + if (document.activeElement !== input) input.value = String(draft[field] ?? ""); + const path: Path = ["spec", field]; + input.classList.toggle("dirty", store.isDirty(uid, path)); + input.classList.toggle( + "conflicted", + store.conflicts(uid).some((c) => c.path.join("/") === path.join("/")), + ); + } + const save = $("[data-role=save]", r); + save.disabled = !this.live() || editor.saving; + $("[data-role=dirty]", r).textContent = store + .changes(uid) + .map((c) => `${c.path.join(".")}: ${JSON.stringify(c.old)} → ${JSON.stringify(c.new)}`) + .join("; "); + const conflicts = $("[data-role=conflicts]", r); + conflicts.replaceChildren( + ...store.conflicts(uid).map((c) => { + const li = el("li", `${c.path.join(".")}: server has ${JSON.stringify(c.theirs)} `, { + "data-path": c.path.join("."), + }); + const theirs = el("button", "Take theirs", { type: "button", "data-role": "take-theirs" }); + theirs.onclick = () => store.revert(uid, c.path); + const mine = el("button", "Keep mine", { type: "button", "data-role": "keep-mine" }); + mine.onclick = () => keepLocal(store, uid, c.path); + li.append(theirs, mine); + return li; + }), + ); + } + + #drawSecrets(store: LiveResourceStore) { + const list = $("[data-role=secrets]", this.root); + list.replaceChildren( + ...store + .ids() + .map((id) => ({ id, o: store.server(id) })) + .sort((a, b) => a.o.metadata.name.localeCompare(b.o.metadata.name)) + .map(({ id, o }) => { + const li = el("li", "", { "data-name": o.metadata.name }); + const data = o.data as Record | undefined; + if (data) { + // Native: the values themselves, decoded. This is what native access discloses. + li.textContent = `${o.metadata.name}: ${Object.entries(data) + .map(([k, v]) => `${k}=${safeDecode(v)}`) + .join(", ")}`; + li.dataset.disclosure = "values"; + } else { + // Full/spec: which paths exist and how often they changed. Never a value. + li.textContent = `${o.metadata.name}: ${store + .redactions(id) + .map((x) => `/${x.path.join("/")} ••• rev ${x.rev}`) + .join(", ")}`; + li.dataset.disclosure = "redacted"; + } + return li; + }), + ); + } +} + +function safeDecode(value: string): string { + try { + return atob(value); + } catch { + return value; + } +} + +// ------------------------------------------------------------------------------ the columns -- + +const columns = (["native", "full", "spec"] as const).map((kind) => { + const root = $(`[data-source=${kind}]`); + root.append(($("#column").content.cloneNode(true) as DocumentFragment).firstElementChild!); + $("[data-role=title]", root).textContent = + kind === "native" ? "native" : kind === "full" ? "gateway krm-full/v1" : "gateway krm-spec/v1"; + const column = new Column(kind, root); + $("[data-role=save]", root).addEventListener("click", () => void column.save()); + for (const field of ["note", "replicas"] as const) { + $(`[data-role=${field}]`, root).addEventListener("input", (e) => { + const uid = column.selected(); + if (!uid || !column.widgets) return; + const value = (e.target as HTMLInputElement).value; + column.widgets.store.setValue(uid, ["spec", field], field === "replicas" ? Number(value) : value); + }); + } + return column; +}); + +const select = $("#widget"); +select.replaceChildren(...config.widgets.map((name) => el("option", name, { value: name }))); +select.addEventListener("change", () => { + for (const c of columns) c.render(); +}); + +const variant = $("#variant"); +variant.value = params.get("variant") === "shared" ? "shared" : "unshared"; +function openAll() { + const suffix = variant.value === "shared" ? "-shared" : ""; + for (const c of columns) c.open(c.kind === "native" ? "native" : `${c.kind}${suffix}`); +} +variant.addEventListener("change", openAll); +openAll(); + +// --------------------------------------------------------------------------- host controls -- + +async function admin(path: string, body?: unknown): Promise { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`${path}: HTTP ${res.status} ${text}`); + return JSON.parse(text); +} + +const report = (text: string) => { + $("#admin-result").textContent = text; +}; +const workload = $("#workload"); +workload.replaceChildren(...config.workloads.map((name) => el("option", name, { value: name }))); +$("#run").addEventListener("click", async () => { + report(`running ${workload.value}…`); + try { + const durationMs = Number($("#duration").value); + report(JSON.stringify(await admin("/admin/workload", { name: workload.value, durationMs }))); + } catch (error) { + report(String(error)); + } +}); +for (const target of ["native", "gateway", "all"]) { + $(`#disconnect-${target}`).addEventListener("click", async () => { + report(JSON.stringify(await admin(`/admin/disconnect?target=${target}`))); + }); +} +$("#competing").addEventListener("click", async () => { + const note = `server-${Date.now() % 100000}`; + await admin("/admin/write", { name: select.value, note }); + report(`another writer set ${select.value} spec.note = ${note}`); +}); +$("#reset-counters").addEventListener("click", async () => { + await admin("/metrics/reset"); + for (const c of columns) { + c.counts = { events: {}, notifications: 0, renders: 0, bytes: 0, reconnects: 0 }; + c.render(); + } +}); + +// ----------------------------------------------------------------------------- host metrics -- + +const metricRows: [string, string][] = [ + ["native", "native."], + ["full", "gateway.full."], + ["spec", "gateway.spec."], + ["full-shared", "gateway.full-shared."], + ["spec-shared", "gateway.spec-shared."], +]; +const metricColumns: [string, (c: Record, g: Record) => number][] = [ + ["LIST / upstream watches", (c) => c.list_requests ?? c.upstream_watch_calls ?? 0], + ["WATCH (resumed)", (c) => c.watch_requests ?? Number.NaN], + ["active upstream", (_c, g) => g.watches_active ?? g.upstream_watches_active ?? 0], + ["events sent", (c) => sum(c, "watch_frames.") + sum(c, "obs.event_emitted.")], + ["suppressed", (c) => (c.list_requests === undefined ? sum(c, "obs.event_suppressed.") : Number.NaN)], + ["KiB to browsers", (c) => Math.round((sum(c, "bytes.") + (c.sse_bytes ?? 0)) / 1024)], + ["authorizer calls", (c) => (c.list_requests === undefined ? sum(c, "authorizer_calls.") : Number.NaN)], + ["session checks", (c) => c.session_checks ?? 0], +]; +function sum(m: Record, prefix: string) { + return Object.entries(m) + .filter(([k]) => k.startsWith(prefix)) + .reduce((a, [, v]) => a + v, 0); +} +async function pollMetrics() { + try { + const res = await fetch("/metrics", { cache: "no-store" }); + const { counters, gauges } = (await res.json()) as { + counters: Record; + gauges: Record; + }; + const pick = (m: Record, prefix: string) => + Object.fromEntries( + Object.entries(m) + .filter(([k]) => k.startsWith(prefix)) + .map(([k, v]) => [k.slice(prefix.length), v]), + ); + $("#metrics").replaceChildren( + ...metricRows.map(([name, prefix]) => { + const c = pick(counters, prefix); + const g = pick(gauges, prefix); + const tr = el("tr", "", { "data-route": name }); + const cell = (v: number) => el("td", Number.isNaN(v) ? "–" : String(v)); + tr.append(el("th", name), ...metricColumns.map(([, get]) => cell(get(c, g)))); + if (name === "native") + (tr.children[2] as HTMLElement).textContent = `${c.watch_requests ?? 0} (${c.watch_resumed ?? 0})`; + return tr; + }), + ); + $("#refused-native").textContent = String(counters["native.refused_session_requests"] ?? 0); + } catch { + // The host is gone or restarting; the next poll tries again. + } +} +$("#metrics-head").replaceChildren(el("th", "source"), ...metricColumns.map(([name]) => el("th", name))); +setInterval(pollMetrics, 2000); +void pollMetrics(); + +// ------------------------------------------------- a refused session never falls back to native -- + +$("#refused-open").addEventListener("click", () => { + const out = $("#refused-result"); + out.textContent = "connecting as the refused session…"; + // A second identity gets its own store, and only ever the source it asked for. When the gateway + // refuses it, the page reports the refusal and stops: it does not try /k8s instead. + const store = new LiveResourceStore(); + const handle = connectResourceStream( + resourceStreamURL("/stream/full", widgetScope), + (event) => applyStreamEvent(store, event), + { + headers: { "X-Compare-Session": config.refusedSession }, + onError: (code, message, terminal) => { + out.textContent = `gateway refused: ${code}${terminal ? " (terminal)" : ""} — ${message}. No native request was made.`; + out.dataset.code = code; + }, + }, + ); + handle.closed + .catch(() => {}) + .then(() => { + out.dataset.final = handle.state.status; + void pollMetrics(); + }); +}); diff --git a/examples/comparison/tsconfig.json b/examples/comparison/tsconfig.json new file mode 100644 index 0000000..f6c5463 --- /dev/null +++ b/examples/comparison/tsconfig.json @@ -0,0 +1,16 @@ +{ + // Typechecks the comparison page and the measurement driver with the client package's compiler and + // strictness. Nothing is emitted: build.mjs strips the page's types and node strips the driver's. + // + // packages/krm-stream/node_modules/.bin/tsc -p examples/comparison + "extends": "../../packages/krm-stream/tsconfig.json", + "compilerOptions": { + "noEmit": true, + // node runs the driver by stripping types: only erasable syntax. + "erasableSyntaxOnly": true, + "rootDir": "../..", + "types": ["node"], + "typeRoots": ["../../packages/krm-stream/node_modules/@types"] + }, + "include": ["*.ts"] +} diff --git a/gateway/kube/examples/comparison/cmd/compare/main.go b/gateway/kube/examples/comparison/cmd/compare/main.go new file mode 100644 index 0000000..3ce1951 --- /dev/null +++ b/gateway/kube/examples/comparison/cmd/compare/main.go @@ -0,0 +1,169 @@ +// Command compare serves the comparison host: the native /k8s proxy, four gateway stream routes, +// the save endpoints, the page and its counters, against the kubeconfig's cluster, in a scratch +// namespace it deletes on exit. +// +// task compare # serve the page at http://127.0.0.1:8110/ +// task compare-measure # run the measurement matrix against it +// +// It listens twice. --tls-addr serves HTTPS with HTTP/2 under a self-signed certificate made at +// startup: that is the address for a browser. A page holding six streams over HTTP/1.1 would have +// used every connection a browser allows one origin, and its saves would wait behind them. --addr +// serves plain HTTP/1.1 for drivers such as measure.ts and curl. +// +// It prints one line, `ready namespace=`, once it is serving. +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "flag" + "fmt" + "log" + "math/big" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/ConfigButler/krm-stream/gateway/kube/examples/comparison" + "k8s.io/client-go/tools/clientcmd" +) + +func main() { + defaultKubeconfig := os.Getenv("KUBECONFIG") + if defaultKubeconfig == "" { + defaultKubeconfig = filepath.Join(os.Getenv("HOME"), ".kube", "config") + } + kubeconfig := flag.String("kubeconfig", defaultKubeconfig, "path to the kubeconfig whose current context is the cluster") + addr := flag.String("addr", "127.0.0.1:8110", "plain HTTP/1.1 listen address for drivers; keep it on loopback, the admin routes have no other protection") + tlsAddr := flag.String("tls-addr", "127.0.0.1:8111", "HTTPS (HTTP/2, self-signed) listen address for browsers; empty disables it") + dist := flag.String("dist", "../../packages/krm-stream/dist", "built library directory served at /packages/krm-stream/dist/") + page := flag.String("page", "../../examples/comparison", "the comparison page directory served at /examples/comparison/") + widgets := flag.Int("widgets", 8, "number of Widgets") + secrets := flag.Int("secrets", 3, "number of Secrets") + viewer := flag.String("viewer-session", "viewer-session", "session token every source accepts") + refused := flag.String("refused-session", "refused-session", "session token that is authenticated and refused by every source") + reauthorize := flag.Duration("reauthorize-every", 5*time.Second, "gateway timed authorization recheck interval") + prefix := flag.String("namespace-prefix", "krm-compare", "scratch namespace prefix") + flag.Parse() + + if err := run(*kubeconfig, *addr, *tlsAddr, comparison.Config{ + NamespacePrefix: *prefix, Widgets: *widgets, Secrets: *secrets, + ViewerToken: *viewer, RefusedToken: *refused, ReauthorizeEvery: *reauthorize, + Dist: *dist, Page: *page, + }); err != nil { + log.Fatal(err) + } +} + +func run(kubeconfig, addr, tlsAddr string, cfg comparison.Config) error { + cluster, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return fmt.Errorf("kubeconfig: %w", err) + } + cfg.Cluster = cluster + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + setup, cancel := context.WithTimeout(ctx, 2*time.Minute) + host, err := comparison.New(setup, cfg) + cancel() + if err != nil { + return fmt.Errorf("host: %w", err) + } + defer func() { + cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := host.Close(cleanup); err != nil { + log.Printf("cleanup: %v", err) + return + } + log.Printf("deleted namespace %s", host.Namespace()) + }() + + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("listen: %w", err) + } + plain := &http.Server{Handler: host, ReadHeaderTimeout: 10 * time.Second} + servers := []*http.Server{plain} + serve := []func() error{func() error { return plain.Serve(listener) }} + urls := "http://" + listener.Addr().String() + "/" + if tlsAddr != "" { + cert, err := selfSigned() + if err != nil { + return err + } + tlsListener, err := net.Listen("tcp", tlsAddr) + if err != nil { + return fmt.Errorf("listen: %w", err) + } + secure := &http.Server{ + Handler: host, + ReadHeaderTimeout: 10 * time.Second, + TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, + } + servers = append(servers, secure) + // ServeTLS negotiates HTTP/2. + serve = append(serve, func() error { return secure.ServeTLS(tlsListener, "", "") }) + urls += " https://" + tlsListener.Addr().String() + "/" + } + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for _, s := range servers { + _ = s.Shutdown(shutdown) + } + }() + fmt.Printf("ready %s namespace=%s\n", urls, host.Namespace()) + errs := make(chan error, len(serve)) + for _, s := range serve { + go func() { errs <- s() }() + } + var first error + for range serve { + if err := <-errs; err != nil && !errors.Is(err, http.ErrServerClosed) && first == nil { + first = fmt.Errorf("serve: %w", err) + stop() + } + } + return first +} + +// selfSigned makes a short-lived certificate for loopback, so a browser can speak HTTP/2 to this +// host. The browser warns once; accept it for 127.0.0.1 only. +func selfSigned() (tls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, err + } + serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) + if err != nil { + return tls.Certificate{}, err + } + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "krm-stream comparison (loopback)"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + DNSNames: []string{"localhost"}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, err + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil +} diff --git a/gateway/kube/examples/comparison/comparison_test.go b/gateway/kube/examples/comparison/comparison_test.go new file mode 100644 index 0000000..e80eb5b --- /dev/null +++ b/gateway/kube/examples/comparison/comparison_test.go @@ -0,0 +1,267 @@ +package comparison + +import ( + "bufio" + "context" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/ConfigButler/krm-stream/gateway" +) + +// No cluster here: the schedule, the native proxy's policy and counters, and the gateway routes' +// refusal are checked against stand-ins. The real-API runs are the measurement itself +// (examples/comparison/measure.ts). + +func TestPlanIsDeterministicAndShaped(t *testing.T) { + for _, name := range Workloads { + req := WorkloadRequest{Name: name, DurationMs: 20000, Seed: 7} + a, err := NewPlan(req, 8, 3) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + b, _ := NewPlan(req, 8, 3) + if !reflect.DeepEqual(a, b) { + t.Fatalf("%s: the same request planned two different schedules", name) + } + counts := a.Counts() + if counts[StepDisconnect] < 2 { + t.Errorf("%s: %d forced reconnects, want at least 2 in every workload", name, counts[StepDisconnect]) + } + for i := 1; i < len(a.Steps); i++ { + if a.Steps[i].At < a.Steps[i-1].At { + t.Fatalf("%s: steps out of order", name) + } + } + for _, s := range a.Steps { + if s.At <= 0 || s.At >= 20*time.Second+time.Second { + t.Fatalf("%s: step at %v is outside the run", name, s.At) + } + } + switch name { + case WorkloadQuiet: + if len(a.Steps) != counts[StepDisconnect] { + t.Errorf("quiet writes: %v", counts) + } + case WorkloadChurn: + // Status-only churn dominates: that is what krm-spec/v1 suppresses. + if counts[StepStatus] < 150 || counts[StepSpec] == 0 || counts[StepSecret] == 0 || counts[StepStatus] < 10*counts[StepSpec] { + t.Errorf("churn shape: %v", counts) + } + case WorkloadBurst: + if counts[StepSpec]+counts[StepStatus] != 5*12 || counts[StepSecret] != 0 { + t.Errorf("burst shape: %v", counts) + } + case WorkloadForcedDisconnect: + if counts[StepDisconnect] < 2+4 { + t.Errorf("forced-disconnect has %d disconnects", counts[StepDisconnect]) + } + } + } + other, _ := NewPlan(WorkloadRequest{Name: WorkloadChurn, DurationMs: 20000, Seed: 8}, 8, 3) + same, _ := NewPlan(WorkloadRequest{Name: WorkloadChurn, DurationMs: 20000, Seed: 7}, 8, 3) + if reflect.DeepEqual(other.Steps, same.Steps) { + t.Error("a different seed planned the same objects") + } + if _, err := NewPlan(WorkloadRequest{Name: "storm"}, 8, 3); err == nil { + t.Error("an unknown workload was planned") + } + if _, err := NewPlan(WorkloadRequest{Name: WorkloadQuiet, Target: "everything"}, 8, 3); err == nil { + t.Error("an unknown disconnect target was accepted") + } +} + +func testHost() *Host { + return &Host{ + cfg: Config{ViewerToken: "v", RefusedToken: "r"}, + namespace: "ns", + metrics: NewMetrics(), + connections: newConnections(), + reauthorizeEvery: time.Hour, + } +} + +func TestNativeProxyPolicyAndCounters(t *testing.T) { + var upstream atomic.Int64 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstream.Add(1) + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("watch") == "1" { + _, _ = w.Write([]byte(`{"type":"ADDED","object":{}}` + "\n" + `{"type":"BOOKMARK","object":{}}` + "\n")) + return + } + _, _ = w.Write([]byte(`{"kind":"SecretList","metadata":{"resourceVersion":"5"},"items":[]}`)) + })) + defer api.Close() + h := testHost() + target, _ := url.Parse(api.URL) + proxy := newNativeProxy(h, target, http.DefaultTransport) + collection := "/k8s/api/v1/namespaces/ns/secrets" + + do := func(method, path, session, body string) int { + t.Helper() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + if session != "" { + r.Header.Set(SessionHeader, session) + } + if method == http.MethodPatch { + r.Header.Set("Content-Type", "application/merge-patch+json") + } + w := httptest.NewRecorder() + proxy.ServeHTTP(w, r) + return w.Code + } + + // Refusals never reach the API server. + for _, c := range []struct { + method, path, session, body string + want int + }{ + {http.MethodGet, collection, "", "", http.StatusUnauthorized}, + {http.MethodGet, collection, "r", "", http.StatusForbidden}, + {http.MethodGet, "/k8s/api/v1/namespaces/other/secrets", "v", "", http.StatusForbidden}, + {http.MethodGet, collection + "/s-0/status", "v", "", http.StatusForbidden}, + {http.MethodDelete, collection + "/s-0", "v", "", http.StatusMethodNotAllowed}, + {http.MethodPatch, collection + "/s-0", "v", `{"metadata":{"uid":"u","resourceVersion":"1"}}`, http.StatusMethodNotAllowed}, + {http.MethodPatch, "/k8s/apis/" + WidgetGroup + "/v1/namespaces/ns/widgets/w-00", "v", `{"spec":{"note":"x"}}`, http.StatusBadRequest}, + } { + if got := do(c.method, c.path, c.session, c.body); got != c.want { + t.Errorf("%s %s as %q: %d, want %d", c.method, c.path, c.session, got, c.want) + } + } + if n := upstream.Load(); n != 0 { + t.Fatalf("%d refused requests reached the API server", n) + } + + // A LIST then its WATCH, then a WATCH with no LIST before it: a resumed watch. + do(http.MethodGet, collection, "v", "") + do(http.MethodGet, collection+"?watch=1&resourceVersion=5", "v", "") + do(http.MethodGet, collection+"?watch=1&resourceVersion=9", "v", "") + c := h.metrics.Snapshot().Counters + want := map[string]int64{ + "native.list_requests": 1, "native.watch_requests": 2, "native.watch_after_list": 1, "native.watch_resumed": 1, + "native.watch_frames.ADDED": 2, "native.watch_frames.BOOKMARK": 2, "native.refused_session_requests": 1, + } + for key, n := range want { + if c[key] != n { + t.Errorf("%s = %d, want %d (all: %v)", key, c[key], n, c) + } + } + if c["native.bytes.watch"] == 0 || c["native.bytes.list"] == 0 { + t.Errorf("downstream bytes not counted: %v", c) + } + if g := h.metrics.Snapshot().Gauges["native.watches_active"]; g != 0 { + t.Errorf("native.watches_active = %d after every watch ended", g) + } +} + +// stubBackend serves one object and a completed snapshot, then holds the watch open. +type stubBackend struct{ watches atomic.Int64 } + +func (b *stubBackend) Watch(_ context.Context, _ gateway.Scope) (gateway.Watcher, error) { + b.watches.Add(1) + return &stubWatcher{events: []gateway.WatchEvent{ + {Type: gateway.WatchAdded, Object: gateway.KRMObject{ + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": map[string]any{"name": "s-0", "namespace": "ns", "uid": "u1", "resourceVersion": "3"}, + "data": map[string]any{"token": "c2VjcmV0"}, //nolint:gosec // a test value, base64 of "secret" + }}, + {Type: gateway.WatchBookmark, InitialEventsEnd: true}, + }}, nil +} + +type stubWatcher struct{ events []gateway.WatchEvent } + +func (w *stubWatcher) Next(ctx context.Context) (gateway.WatchEvent, error) { + if len(w.events) > 0 { + ev := w.events[0] + w.events = w.events[1:] + return ev, nil + } + <-ctx.Done() + return gateway.WatchEvent{}, ctx.Err() +} + +func (w *stubWatcher) Stop() {} + +func TestGatewayRouteRefusesAndDisconnects(t *testing.T) { + h := testHost() + backend := &stubBackend{} + counted := &countingBackend{inner: backend, metrics: h.metrics, route: "full"} + server := httptest.NewServer(h.streamRoute(Routes[0], counted)) + defer server.Close() + streamURL := server.URL + "?version=v1&resource=secrets&namespace=ns" + + open := func(session string) (*http.Response, *bufio.Reader) { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, streamURL, nil) + req.Header.Set(SessionHeader, session) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return res, bufio.NewReader(res.Body) + } + frame := func(r *bufio.Reader) string { + t.Helper() + for { + line, err := r.ReadString('\n') + if err != nil { + return "" + } + if data, ok := strings.CutPrefix(line, "data: "); ok { + return data + } + } + } + + // The refused session: a terminal FORBIDDEN, and no upstream watch was opened for it. + res, r := open("r") + if got := frame(r); !strings.Contains(got, `"FORBIDDEN"`) || !strings.Contains(got, `"terminal":true`) { + t.Fatalf("refused session got %s", got) + } + _ = res.Body.Close() + if n := backend.watches.Load(); n != 0 { + t.Fatalf("a refused session opened %d upstream watches", n) + } + + // The viewer: a projected snapshot. The Secret's value is withheld, and its path is named. + res, r = open("v") + defer func() { _ = res.Body.Close() }() + frame(r) // reset + added := frame(r) + if strings.Contains(added, "c2VjcmV0") || !strings.Contains(added, `"path":"/data/token"`) { + t.Fatalf("full projection leaked or lost the redaction: %s", added) + } + if !strings.Contains(frame(r), `"synced"`) { + t.Fatal("no synced") + } + if g := h.metrics.Snapshot().Gauges["gateway.full.upstream_watches_active"]; g != 1 { + t.Fatalf("upstream_watches_active = %d, want 1", g) + } + + // A forced disconnect ends the stream. + if n := h.connections.cancel("gateway"); n != 1 { + t.Fatalf("closed %d gateway connections, want 1", n) + } + if _, err := r.ReadString(0); err == nil { + t.Fatal("the stream survived a forced disconnect") + } + deadline := time.Now().Add(5 * time.Second) + for h.metrics.Snapshot().Gauges["gateway.full.upstream_watches_active"] != 0 { + if time.Now().After(deadline) { + t.Fatal("the upstream watch was not stopped after its stream ended") + } + time.Sleep(10 * time.Millisecond) + } + c := h.metrics.Snapshot().Counters + if c["gateway.full.authorizer_refusals"] != 1 || c["sessions.gateway.refused"] != 1 || c["gateway.full.upstream_watch_calls"] != 1 { + t.Fatalf("counters: %v", c) + } +} diff --git a/gateway/kube/examples/comparison/gateway.go b/gateway/kube/examples/comparison/gateway.go new file mode 100644 index 0000000..77b4d32 --- /dev/null +++ b/gateway/kube/examples/comparison/gateway.go @@ -0,0 +1,135 @@ +package comparison + +import ( + "context" + "net/http" + "sync" + "time" + + "github.com/ConfigButler/krm-stream/gateway" +) + +// The gateway source: four stream routes, one per projection and sharing choice. Each has its own +// upstream Backend, Observer and counters, so one route's work never shows up in another's numbers. +// +// /stream/full krm-full/v1, one upstream watch per stream +// /stream/spec krm-spec/v1, one upstream watch per stream +// /stream/full-shared krm-full/v1 through gateway.NewSharedBackend: one upstream watch per scope +// /stream/spec-shared krm-spec/v1 through gateway.NewSharedBackend +// +// Every upstream watch here is opened as the HOST's identity. A production host serving the unshared +// routes would act as each caller instead (see docs/auth.md); it would cost the same watches. + +// Route describes one gateway stream route. +type Route struct { + Name string `json:"name"` + Path string `json:"path"` + Projection gateway.Projection `json:"projection"` + Shared bool `json:"shared"` +} + +// Routes are the four gateway routes, in display order. +var Routes = []Route{ + {Name: "full", Path: "/stream/full", Projection: gateway.ProjectionFull}, + {Name: "spec", Path: "/stream/spec", Projection: gateway.ProjectionSpec}, + {Name: "full-shared", Path: "/stream/full-shared", Projection: gateway.ProjectionFull, Shared: true}, + {Name: "spec-shared", Path: "/stream/spec-shared", Projection: gateway.ProjectionSpec, Shared: true}, +} + +// countingBackend counts the upstream watches a route opens and how many are open now. It wraps the +// route's own kube.Backend, below any SharedBackend, so it counts what reaches the API server from +// this route alone: unlike a cluster-wide API-server gauge, nothing else running against the same +// cluster can move it. +type countingBackend struct { + inner gateway.Backend + metrics *Metrics + route string +} + +func (b *countingBackend) Watch(ctx context.Context, scope gateway.Scope) (gateway.Watcher, error) { + b.metrics.Add("gateway."+b.route+".upstream_watch_calls", 1) + w, err := b.inner.Watch(ctx, scope) + if err != nil { + b.metrics.Add("gateway."+b.route+".upstream_watch_errors", 1) + return nil, err + } + b.metrics.Gauge("gateway."+b.route+".upstream_watches_active", 1) + return &countingWatcher{Watcher: w, done: func() { b.metrics.Gauge("gateway."+b.route+".upstream_watches_active", -1) }}, nil +} + +type countingWatcher struct { + gateway.Watcher + once sync.Once + done func() +} + +func (w *countingWatcher) Stop() { + w.Watcher.Stop() + w.once.Do(w.done) +} + +// principal is the gateway's view of a host session. +type principal struct{ name string } + +// streamRoute builds one route's handler over its backend. +func (h *Host) streamRoute(route Route, backend gateway.Backend) http.Handler { + m := h.metrics + prefix := "gateway." + route.Name + obs := observer(m, route.Name) + if route.Shared { + backend = gateway.NewSharedBackend(backend, gateway.SharedOptions{Observer: obs}) + } + stream := gateway.Handler(gateway.Options{ + Principal: func(r *http.Request) (gateway.Principal, error) { + m.Add(prefix+".session_checks", 1) + session, err := h.sessionOf(r, "gateway") + if err != nil { + return nil, gateway.Unauthenticated("sign in to watch these objects") + } + return principal{name: session.name}, nil + }, + Scopes: gateway.ScopePolicy{ + Targets: []string{""}, + Resources: []gateway.GroupResource{ + {Group: WidgetGroup, Resource: "widgets", Scope: gateway.ResourceScopeNamespaced}, + {Resource: "secrets", Scope: gateway.ResourceScopeNamespaced}, + }, + }, + StreamConfig: gateway.StreamConfig{ + Authorizer: gateway.AuthorizerFunc(func(ctx context.Context, p gateway.Principal, s gateway.Scope) error { + // A timed recheck runs under ReauthorizationTimeout; an opening or snapshot-cycle check + // runs on the request's own context, which this host gives no deadline. + kind := "cycle" + if _, ok := ctx.Deadline(); ok { + kind = "timed" + } + m.Add(prefix+".authorizer_calls."+kind, 1) + caller, _ := p.(principal) + if caller.name != sessionViewer { + m.Add(prefix+".authorizer_refusals", 1) + return gateway.Forbidden("this session may not watch these objects") + } + if s.Namespace != h.namespace { + m.Add(prefix+".authorizer_refusals", 1) + return gateway.Forbidden("outside this host's namespace") + } + return nil + }), + Clients: func(context.Context, string, gateway.Principal) (gateway.Backend, error) { return backend, nil }, + Projections: gateway.StaticProjection(route.Projection), + Observer: obs, + WriteTimeout: 5 * time.Second, + ReauthorizationInterval: h.reauthorizeEvery, + ReauthorizationTimeout: 5 * time.Second, + }, + }) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.Add(prefix+".http_requests", 1) + ctx, release := h.connections.open(r.Context(), "gateway") + defer release() + m.Gauge(prefix+".sse_active", 1) + defer m.Gauge(prefix+".sse_active", -1) + writer := &countingWriter{ResponseWriter: w, add: func(n int64) { m.Add(prefix+".sse_bytes", n) }} + stream.ServeHTTP(writer, r.WithContext(ctx)) + }) +} diff --git a/gateway/kube/examples/comparison/host.go b/gateway/kube/examples/comparison/host.go new file mode 100644 index 0000000..3c1e7e2 --- /dev/null +++ b/gateway/kube/examples/comparison/host.go @@ -0,0 +1,513 @@ +// Package comparison is a host that serves ONE set of objects through every source krm-stream +// offers, from one origin, so a page and a measurement driver can compare them under identical +// objects and identical churn: +// +// - native: the API server's own LIST and WATCH through a credential-holding /k8s proxy, and +// conditional merge PATCHes back through it; +// - gateway: krm-full/v1 and krm-spec/v1 streams, each unshared and shared, with host save +// endpoints for projected editing. +// +// It owns one scratch namespace, a fixed set of Widgets (a CRD with a status subresource) and +// Secrets, a deterministic workload runner, a forced-disconnect switch and low-cardinality counters. +// It is an example and a measurement harness, not a library API; see examples/comparison/README.md. +package comparison + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/ConfigButler/krm-stream/gateway" + "github.com/ConfigButler/krm-stream/gateway/kube" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +// The two sessions this host knows. A real host resolves sessions from its own store; these are +// fixed so a page and a driver can use them without a login system. +const ( + sessionViewer = "viewer" + sessionRefused = "refused" + // SessionCookie carries the page's session; SessionHeader carries a driver's, or a page panel + // that deliberately opens a second identity. + SessionCookie = "krm-compare-session" + SessionHeader = "X-Compare-Session" +) + +// Config configures a Host. +type Config struct { + // Cluster is the host's own identity. Every upstream request uses it. + Cluster *rest.Config + // NamespacePrefix names the scratch namespace, created by New and deleted by Close. + NamespacePrefix string + // Widgets and Secrets size the fixed object set. Defaults 8 and 3. + Widgets, Secrets int + // ViewerToken and RefusedToken are the session values the host accepts. The viewer may use + // every source; the refused session is authenticated but every source refuses it. + ViewerToken, RefusedToken string + // ReauthorizeEvery is the gateway's timed recheck interval. Default 5s: short, so a 20-second + // workload shows the per-subscriber cost of timed checks between its forced reconnects. + ReauthorizeEvery time.Duration + // Dist is the built library directory served at /packages/krm-stream/dist/. Page is + // examples/comparison, served at /examples/comparison/. + Dist, Page string +} + +// Host is the comparison host. +type Host struct { + cfg Config + namespace string + core kubernetes.Interface + objects *objects + metrics *Metrics + connections *connections + reauthorizeEvery time.Duration + mux *http.ServeMux + workload sync.Mutex +} + +// New creates the scratch namespace and the objects, and builds every route. Call Close to delete +// the namespace. +func New(ctx context.Context, cfg Config) (*Host, error) { + if cfg.Cluster == nil { + return nil, errors.New("comparison: a cluster configuration is required") + } + if cfg.Widgets <= 0 { + cfg.Widgets = 8 + } + if cfg.Secrets <= 0 { + cfg.Secrets = 3 + } + if cfg.NamespacePrefix == "" { + cfg.NamespacePrefix = "krm-compare" + } + if cfg.ViewerToken == "" || cfg.RefusedToken == "" || cfg.ViewerToken == cfg.RefusedToken { + return nil, errors.New("comparison: two distinct session tokens are required") + } + if cfg.ReauthorizeEvery <= 0 { + cfg.ReauthorizeEvery = 5 * time.Second + } + // A declared fixture capacity, not a library default: client-go's own default (5 QPS) would + // throttle ten unshared streams reconnecting at once, and the measurement would show the limiter. + cluster := rest.CopyConfig(cfg.Cluster) + cluster.QPS, cluster.Burst = 200, 400 + + httpClient, err := kube.HTTPClientFor(cluster) + if err != nil { + return nil, err + } + core, err := kubernetes.NewForConfigAndClient(cluster, httpClient) + if err != nil { + return nil, err + } + writer, err := dynamic.NewForConfigAndClient(cluster, httpClient) + if err != nil { + return nil, err + } + if err := ensureWidgetCRD(ctx, writer); err != nil { + return nil, err + } + ns, err := core.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{GenerateName: cfg.NamespacePrefix + "-"}, + }, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("create the scratch namespace: %w", err) + } + h := &Host{ + cfg: cfg, + namespace: ns.Name, + core: core, + objects: &objects{dyn: writer, namespace: ns.Name, widgets: cfg.Widgets, secrets: cfg.Secrets}, + metrics: NewMetrics(), + connections: newConnections(), + reauthorizeEvery: cfg.ReauthorizeEvery, + } + if err := h.objects.create(ctx); err != nil { + _ = h.Close(context.Background()) + return nil, err + } + if err := h.routes(cluster); err != nil { + _ = h.Close(context.Background()) + return nil, err + } + return h, nil +} + +// Namespace is the scratch namespace. +func (h *Host) Namespace() string { return h.namespace } + +// Close closes every downstream connection and deletes the scratch namespace. +func (h *Host) Close(ctx context.Context) error { + h.connections.cancel("all") + err := h.core.CoreV1().Namespaces().Delete(ctx, h.namespace, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("delete namespace %s: %w", h.namespace, err) + } + return nil +} + +// ServeHTTP serves every route. +func (h *Host) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } + +func (h *Host) routes(cluster *rest.Config) error { + mux := http.NewServeMux() + + // Native: the proxy forwards with the host's transport, counted only for the bytes it reads + // back from the API server. + transport, err := rest.TransportFor(cluster) + if err != nil { + return err + } + target, err := url.Parse(cluster.Host) + if err != nil { + return err + } + native := newNativeProxy(h, target, countingTransport(transport, h.metrics, "native.upstream_bytes")) + mux.Handle("/k8s/", native) + + // Gateway: one kube.Backend per route, each on its own client so each route's upstream bytes + // are its own. + for _, route := range Routes { + routeConfig := rest.CopyConfig(cluster) + key := "gateway." + route.Name + ".upstream_bytes" + routeConfig.Wrap(func(rt http.RoundTripper) http.RoundTripper { return countingTransport(rt, h.metrics, key) }) + client, err := kube.HTTPClientFor(routeConfig) + if err != nil { + return err + } + dyn, err := dynamic.NewForConfigAndClient(routeConfig, client) + if err != nil { + return err + } + backend := &countingBackend{inner: kube.NewBackend(dyn), metrics: h.metrics, route: route.Name} + mux.Handle(route.Path, h.streamRoute(route, backend)) + } + + mux.HandleFunc("/save/", h.saveHandler) + + mux.HandleFunc("GET /login", h.login) + mux.HandleFunc("GET /config", h.config) + mux.HandleFunc("GET /metrics", h.admin(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, h.metrics.Snapshot()) + })) + mux.HandleFunc("POST /metrics/reset", h.admin(func(w http.ResponseWriter, _ *http.Request) { + h.metrics.Reset() + writeJSON(w, h.metrics.Snapshot()) + })) + mux.HandleFunc("POST /admin/disconnect", h.admin(func(w http.ResponseWriter, r *http.Request) { + target := r.URL.Query().Get("target") + if target != "native" && target != "gateway" && target != "all" { + http.Error(w, "target must be native, gateway or all", http.StatusBadRequest) + return + } + writeJSON(w, map[string]int{"closed": h.disconnect(target)}) + })) + mux.HandleFunc("POST /admin/workload", h.admin(h.runWorkload)) + mux.HandleFunc("POST /admin/reset", h.admin(func(w http.ResponseWriter, r *http.Request) { + if err := h.objects.reset(r.Context()); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, map[string]bool{"reset": true}) + })) + mux.HandleFunc("POST /admin/write", h.admin(h.competingWrite)) + mux.HandleFunc("GET /admin/state", h.admin(func(w http.ResponseWriter, r *http.Request) { + state, err := h.objects.state(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, state) + })) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + + // The page and the library it imports. The page addresses the library as + // ../../packages/krm-stream/dist/, like the other examples, so a configurable directory can + // stand in for the repository's own build. + if h.cfg.Page != "" { + mux.Handle("GET /examples/comparison/", http.StripPrefix("/examples/comparison/", staticDir(h.cfg.Page))) + } + if h.cfg.Dist != "" { + mux.Handle("GET /packages/krm-stream/dist/", http.StripPrefix("/packages/krm-stream/dist/", staticDir(h.cfg.Dist))) + } + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/login", http.StatusFound) + }) + h.mux = mux + return nil +} + +// staticDir serves files, never directory listings, uncached so a rebuilt library is what loads. +// ServeContent rather than a FileServer, which redirects index.html to its directory. +func staticDir(dir string) http.Handler { + root := http.Dir(dir) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if path == "" { + path = "index.html" + } + // http.Dir confines the name to dir, as a file server does. + f, err := root.Open("/" + path) + if err != nil { + http.NotFound(w, r) + return + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil || info.IsDir() { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "no-store") + http.ServeContent(w, r, info.Name(), info.ModTime(), f) + }) +} + +// --------------------------------------------------------------------------------- sessions -- + +type session struct { + name string + refused bool +} + +var errNoSession = errors.New("no session") + +// sessionOf resolves the caller's session from the header, else the cookie, and counts the check +// under the path that asked. +func (h *Host) sessionOf(r *http.Request, path string) (session, error) { + token := r.Header.Get(SessionHeader) + if token == "" { + if cookie, err := r.Cookie(SessionCookie); err == nil { + token = cookie.Value + } + } + switch token { + case h.cfg.ViewerToken: + h.metrics.Add("sessions."+path+".viewer", 1) + return session{name: sessionViewer}, nil + case h.cfg.RefusedToken: + h.metrics.Add("sessions."+path+".refused", 1) + return session{name: sessionRefused, refused: true}, nil + default: + h.metrics.Add("sessions."+path+".missing", 1) + return session{}, errNoSession + } +} + +// authenticate answers 401 for a request without a session. +func (h *Host) authenticate(w http.ResponseWriter, r *http.Request, path string) (session, bool) { + s, err := h.sessionOf(r, path) + if err != nil { + refuse(w, http.StatusUnauthorized, "sign in first") + return session{}, false + } + return s, true +} + +// admin guards the metrics and admin routes with the viewer session. They are harness controls; +// bind the host to loopback. +func (h *Host) admin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := h.authenticate(w, r, "admin") + if !ok { + return + } + if s.refused { + refuse(w, http.StatusForbidden, "not an administrator") + return + } + next(w, r) + } +} + +// login stands in for the host's sign-in: it sets the viewer session cookie and opens the page. +func (h *Host) login(w http.ResponseWriter, r *http.Request) { + // Secure on the TLS listener. The plain listener exists for drivers on loopback, where a Secure + // cookie could not be set. + http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: h.cfg.ViewerToken, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode}) //nolint:gosec // see above + // Only the page's own parameters are passed on. + query := url.Values{} + for _, name := range []string{"entry", "variant"} { + if v := r.URL.Query().Get(name); v == "bundle" || v == "shared" { + query.Set(name, v) + } + } + target := "/examples/comparison/index.html" + if len(query) > 0 { + target += "?" + query.Encode() + } + http.Redirect(w, r, target, http.StatusFound) +} + +// PageConfig is what the page needs to address the objects. +type PageConfig struct { + Namespace string `json:"namespace"` + Group string `json:"group"` + Widgets []string `json:"widgets"` + Secrets []string `json:"secrets"` + Routes []Route `json:"routes"` + RefusedToken string `json:"refusedSession"` + Workloads []string `json:"workloads"` +} + +func (h *Host) config(w http.ResponseWriter, r *http.Request) { + s, ok := h.authenticate(w, r, "page") + if !ok { + return + } + if s.refused { + refuse(w, http.StatusForbidden, "this session may not read these objects") + return + } + c := PageConfig{Namespace: h.namespace, Group: WidgetGroup, Routes: Routes, RefusedToken: h.cfg.RefusedToken, Workloads: Workloads} + for i := range h.cfg.Widgets { + c.Widgets = append(c.Widgets, WidgetName(i)) + } + for i := range h.cfg.Secrets { + c.Secrets = append(c.Secrets, SecretName(i)) + } + writeJSON(w, c) +} + +// ----------------------------------------------------------------------------------- admin -- + +func (h *Host) disconnect(target string) int { + n := h.connections.cancel(target) + h.metrics.Add("admin.disconnects."+target, 1) + h.metrics.Add("admin.connections_closed", int64(n)) + return n +} + +func (h *Host) runWorkload(w http.ResponseWriter, r *http.Request) { + var req WorkloadRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil { + http.Error(w, "invalid workload request", http.StatusBadRequest) + return + } + plan, err := NewPlan(req, h.cfg.Widgets, h.cfg.Secrets) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !h.workload.TryLock() { + http.Error(w, "a workload is already running", http.StatusConflict) + return + } + defer h.workload.Unlock() + h.metrics.Add("admin.workloads."+plan.Request.Name, 1) + writeJSON(w, h.objects.run(r.Context(), plan, h.disconnect)) +} + +// competingWrite changes one Widget's spec.note as another writer would: the page uses it to make +// a conflict with a draft on purpose. +func (h *Host) competingWrite(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Note string `json:"note"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil || req.Name == "" { + http.Error(w, "name and note are required", http.StatusBadRequest) + return + } + if err := h.objects.patch(r.Context(), h.objects.widgetClient(), req.Name, map[string]any{"spec": map[string]any{"note": req.Note}}); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, map[string]bool{"written": true}) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(v) +} + +// ------------------------------------------------------------------------------ connections -- + +// connections tracks the downstream connections a forced disconnect can close: native watch +// responses and gateway SSE streams. +type connections struct { + mu sync.Mutex + next int + conns map[int]openConnection +} + +type openConnection struct { + kind string + cancel context.CancelFunc +} + +func newConnections() *connections { return &connections{conns: map[int]openConnection{}} } + +// open registers a connection of kind and returns its context and its release. +func (c *connections) open(parent context.Context, kind string) (context.Context, func()) { + ctx, cancel := context.WithCancel(parent) + c.mu.Lock() + id := c.next + c.next++ + c.conns[id] = openConnection{kind: kind, cancel: cancel} + c.mu.Unlock() + return ctx, func() { + c.mu.Lock() + delete(c.conns, id) + c.mu.Unlock() + cancel() + } +} + +// cancel closes every connection of kind ("all" for every kind) and reports how many. +func (c *connections) cancel(kind string) int { + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for id, conn := range c.conns { + if kind == "all" || conn.kind == kind { + conn.cancel() + delete(c.conns, id) + n++ + } + } + return n +} + +// ---------------------------------------------------------------------------- upstream bytes -- + +// countingTransport counts the response bytes a client reads from the API server. +func countingTransport(rt http.RoundTripper, m *Metrics, key string) http.RoundTripper { + return roundTripper(func(r *http.Request) (*http.Response, error) { + res, err := rt.RoundTrip(r) + if err != nil || res.Body == nil { + return res, err + } + res.Body = &countingBody{ReadCloser: res.Body, add: func(n int64) { m.Add(key, n) }} + return res, nil + }) +} + +type roundTripper func(*http.Request) (*http.Response, error) + +func (f roundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type countingBody struct { + io.ReadCloser + add func(int64) +} + +func (b *countingBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + if n > 0 { + b.add(int64(n)) + } + return n, err +} + +var _ gateway.Backend = (*countingBackend)(nil) diff --git a/gateway/kube/examples/comparison/metrics.go b/gateway/kube/examples/comparison/metrics.go new file mode 100644 index 0000000..69ca2a0 --- /dev/null +++ b/gateway/kube/examples/comparison/metrics.go @@ -0,0 +1,167 @@ +package comparison + +import ( + "bytes" + "maps" + "net/http" + "strconv" + "sync" + "time" + + "github.com/ConfigButler/krm-stream/gateway" +) + +// Metrics is the host's own record of what each source cost. Every key is built from constants, a +// route name, an event type or an HTTP status: never a UID, a user, a namespace or a resourceVersion, +// so the set of keys is small and fixed by the code, as a real metrics backend needs it to be. +// +// Counters reset with Reset; gauges (what is open right now) do not, because the connections they +// count outlive any measurement window. +type Metrics struct { + mu sync.Mutex + since time.Time + counters map[string]int64 + gauges map[string]int64 +} + +// NewMetrics starts an empty record. +func NewMetrics() *Metrics { + return &Metrics{since: time.Now(), counters: map[string]int64{}, gauges: map[string]int64{}} +} + +// Add increments a counter. +func (m *Metrics) Add(key string, n int64) { + m.mu.Lock() + m.counters[key] += n + m.mu.Unlock() +} + +// Gauge moves a gauge by delta. +func (m *Metrics) Gauge(key string, delta int64) { + m.mu.Lock() + m.gauges[key] += delta + m.mu.Unlock() +} + +// Snapshot is the JSON the metrics endpoint serves. +type Snapshot struct { + Since time.Time `json:"since"` + ElapsedMs int64 `json:"elapsedMs"` + Counters map[string]int64 `json:"counters"` + Gauges map[string]int64 `json:"gauges"` +} + +// Snapshot copies the current values. +func (m *Metrics) Snapshot() Snapshot { + m.mu.Lock() + defer m.mu.Unlock() + return Snapshot{ + Since: m.since, + ElapsedMs: time.Since(m.since).Milliseconds(), + Counters: maps.Clone(m.counters), + Gauges: maps.Clone(m.gauges), + } +} + +// Reset clears the counters and starts a new window. Gauges are kept. +func (m *Metrics) Reset() { + m.mu.Lock() + m.counters = map[string]int64{} + m.since = time.Now() + m.mu.Unlock() +} + +// observer maps gateway Observations onto one route's counters. Observations carry a Scope, which +// holds a namespace and a name; only the kind and the event type become part of a key. +func observer(m *Metrics, route string) gateway.Observer { + return gateway.ObserverFunc(func(o gateway.Observation) { + prefix := "gateway." + route + ".obs." + switch o.Kind { + case gateway.ObservationEventEmitted, gateway.ObservationEventSuppressed: + m.Add(prefix+string(o.Kind)+"."+string(o.EventType), 1) + case gateway.ObservationStreamOpened: + m.Add(prefix+string(o.Kind), 1) + m.Gauge("gateway."+route+".streams_active", 1) + case gateway.ObservationStreamClosed: + m.Add(prefix+string(o.Kind), 1) + m.Gauge("gateway."+route+".streams_active", -1) + case gateway.ObservationSharedSubscriptionOpened: + m.Add(prefix+string(o.Kind), 1) + m.Gauge("gateway."+route+".shared_subscriptions_active", 1) + case gateway.ObservationSharedSubscriptionClosed: + m.Add(prefix+string(o.Kind), 1) + m.Gauge("gateway."+route+".shared_subscriptions_active", -1) + case gateway.ObservationRetryableError, gateway.ObservationTerminalError: + m.Add(prefix+string(o.Kind)+"."+string(o.Code), 1) + default: + m.Add(prefix+string(o.Kind), 1) + } + }) +} + +// countingWriter counts the bytes a handler sends downstream. It keeps the capabilities the gateway +// and the reverse proxy rely on: FlushError for every frame, and Unwrap so http.ResponseController +// reaches the server's write deadline. +type countingWriter struct { + http.ResponseWriter + add func(int64) + status int + // frames, when set, receives each written chunk so a native watch's frame types can be counted. + frames func([]byte) +} + +func (w *countingWriter) WriteHeader(code int) { + if w.status == 0 { + w.status = code + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *countingWriter) Write(p []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + n, err := w.ResponseWriter.Write(p) + w.add(int64(n)) + if w.frames != nil && n > 0 { + w.frames(p[:n]) + } + return n, err +} + +func (w *countingWriter) FlushError() error { + return http.NewResponseController(w.ResponseWriter).Flush() +} + +func (w *countingWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +// watchFrameTypes are the native watch event types counted on the way to the browser. The API server +// writes one JSON object per line, each starting with its type. +var watchFrameTypes = []string{"ADDED", "MODIFIED", "DELETED", "BOOKMARK", "ERROR"} + +// countFrames counts native watch frames by type in one written chunk. A frame split across two +// writes would be missed; the API server writes and flushes each event whole. +func countFrames(m *Metrics, chunk []byte) { + for _, kind := range watchFrameTypes { + if n := bytes.Count(chunk, []byte(`{"type":"`+kind+`"`)); n > 0 { + m.Add("native.watch_frames."+kind, int64(n)) + } + } +} + +// statusClass keeps HTTP status labels bounded: the exact code for the few that matter here, a +// class for the rest. +func statusClass(code int) string { + switch code { + case 200, 201, 204, 400, 401, 403, 404, 409, 410, 415, 422, 502: + return strconv.Itoa(code) + } + switch { + case code >= 500: + return "5xx" + case code >= 400: + return "4xx" + default: + return "other" + } +} diff --git a/gateway/kube/examples/comparison/native.go b/gateway/kube/examples/comparison/native.go new file mode 100644 index 0000000..9018077 --- /dev/null +++ b/gateway/kube/examples/comparison/native.go @@ -0,0 +1,204 @@ +package comparison + +import ( + "bytes" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "sync" + + "github.com/ConfigButler/krm-stream/gateway" +) + +// The native source: the API server's own paths under /k8s, forwarded with the HOST's credentials. +// The browser never holds a Kubernetes credential. The host decides which paths it forwards (the +// Widgets and Secrets of one namespace), which methods (GET, and a conditional merge PATCH of a +// Widget that gateway.ValidateNativeMergePatch accepts) and for which sessions. +// +// Kubernetes authorizes each forwarded request as the host's identity. That check happens inside the +// API server and is invisible here: these counters see requests, not RBAC decisions. + +// nativeProxy forwards /k8s requests and counts them. +type nativeProxy struct { + host *Host + proxy *httputil.ReverseProxy + pending pendingLists +} + +func newNativeProxy(h *Host, target *url.URL, transport http.RoundTripper) *nativeProxy { + return &nativeProxy{ + host: h, + proxy: &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.Out.URL.Scheme = target.Scheme + r.Out.URL.Host = target.Host + r.Out.Host = target.Host + r.Out.URL.Path = strings.TrimPrefix(r.In.URL.Path, "/k8s") + r.Out.URL.RawPath = "" + }, + Transport: transport, + // A watch is a long response: every frame must reach the browser as it is written. + FlushInterval: -1, + // The default handler logs every forced disconnect; this one only answers. + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) { + refuse(w, http.StatusBadGateway, "the Kubernetes request failed") + }, + }, + pending: pendingLists{m: map[string]int{}}, + } +} + +// pendingLists pairs each WATCH with the LIST before it. The connector LISTs and then WATCHes from +// the collection's resourceVersion; a WATCH that finds no LIST waiting for its collection and session +// was opened without one, which is what a resumed watch looks like from here. The pairing is per +// collection and session, not per browser tab, so it is exact in aggregate rather than per connection. +type pendingLists struct { + mu sync.Mutex + m map[string]int +} + +func (p *pendingLists) listed(key string) { + p.mu.Lock() + p.m[key]++ + p.mu.Unlock() +} + +// take reports whether a LIST was waiting for this WATCH, consuming it. +func (p *pendingLists) take(key string) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.m[key] == 0 { + return false + } + p.m[key]-- + return true +} + +// nativeTarget is what a /k8s path addresses, or "" when this host does not forward it. +type nativeTarget struct { + resource string // "widgets" or "secrets" + name string // "" for the collection +} + +// classify accepts exactly the Widget and Secret collections of the scratch namespace, and their +// members. Everything else, including other namespaces and subresources, is refused. +func (p *nativeProxy) classify(path string) (nativeTarget, bool) { + ns := p.host.namespace + for _, c := range []struct{ prefix, resource string }{ + {"/k8s/apis/" + WidgetGroup + "/v1/namespaces/" + ns + "/widgets", "widgets"}, + {"/k8s/api/v1/namespaces/" + ns + "/secrets", "secrets"}, + } { + if path == c.prefix { + return nativeTarget{resource: c.resource}, true + } + if name, ok := strings.CutPrefix(path, c.prefix+"/"); ok && name != "" && !strings.Contains(name, "/") { + return nativeTarget{resource: c.resource, name: name}, true + } + } + return nativeTarget{}, false +} + +func (p *nativeProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m := p.host.metrics + m.Add("native.session_checks", 1) + session, ok := p.host.authenticate(w, r, "native") + if !ok { + return + } + if session.refused { + // This session may not use either source. The page must never reach here after the gateway + // refused it; this counter is how the comparison shows that it did not. + m.Add("native.refused_session_requests", 1) + refuse(w, http.StatusForbidden, "this session may not read these objects") + return + } + target, ok := p.classify(r.URL.Path) + if !ok { + m.Add("native.refused_path", 1) + refuse(w, http.StatusForbidden, "not proxied by this host") + return + } + query := r.URL.Query() + writer := &countingWriter{ResponseWriter: w, add: func(int64) {}} + var class string + switch { + case r.Method == http.MethodGet && target.name == "" && isWatch(query): + class = "watch" + m.Add("native.watch_requests", 1) + key := session.name + " " + r.URL.Path + "?" + selectorsOf(query) + switch { + case query.Get("sendInitialEvents") == "true": + m.Add("native.watch_streaming_list", 1) + case p.pending.take(key): + m.Add("native.watch_after_list", 1) + default: + // No LIST preceded it: a WATCH resumed from a checkpoint the browser kept. + m.Add("native.watch_resumed", 1) + } + if query.Get("resourceVersion") == "" { + m.Add("native.watch_without_resource_version", 1) + } + if query.Get("allowWatchBookmarks") == "true" { + m.Add("native.watch_bookmarks_requested", 1) + } + writer.frames = func(chunk []byte) { countFrames(m, chunk) } + m.Gauge("native.watches_active", 1) + defer m.Gauge("native.watches_active", -1) + // A forced disconnect cancels this request; the proxy then aborts the response, as a dropped + // connection would. + ctx, release := p.host.connections.open(r.Context(), "native") + defer release() + r = r.WithContext(ctx) + case r.Method == http.MethodGet && target.name == "": + class = "list" + m.Add("native.list_requests", 1) + p.pending.listed(session.name + " " + r.URL.Path + "?" + selectorsOf(query)) + case r.Method == http.MethodGet: + class = "get" + m.Add("native.get_requests", 1) + case r.Method == http.MethodPatch && target.resource == "widgets" && target.name != "": + class = "patch" + m.Add("native.patch_requests", 1) + // Only a JSON merge patch: a JSON Patch or an apply patch has semantics the check below does + // not cover. + if r.Header.Get("Content-Type") != "application/merge-patch+json" { + refuse(writer, http.StatusUnsupportedMediaType, "only application/merge-patch+json is forwarded") + m.Add("native.patch_status."+statusClass(writer.status), 1) + return + } + patch, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) + if err != nil { + refuse(writer, http.StatusRequestEntityTooLarge, "patch too large") + return + } + if err := gateway.ValidateNativeMergePatch(patch); err != nil { + refuse(writer, http.StatusBadRequest, err.Error()) + m.Add("native.patch_status."+statusClass(writer.status), 1) + return + } + r.Body = io.NopCloser(bytes.NewReader(patch)) + r.ContentLength = int64(len(patch)) + default: + m.Add("native.refused_method", 1) + refuse(w, http.StatusMethodNotAllowed, "not proxied") + return + } + writer.add = func(n int64) { m.Add("native.bytes."+class, n) } + defer func() { + m.Add("native."+class+"_status."+statusClass(writer.status), 1) + }() + p.proxy.ServeHTTP(writer, r) +} + +func isWatch(q url.Values) bool { + v := q.Get("watch") + return v == "1" || v == "true" +} + +// selectorsOf keeps the parts of a collection query that select members, so a LIST and its WATCH +// pair up whatever else the connector adds. +func selectorsOf(q url.Values) string { + return url.Values{"labelSelector": q["labelSelector"], "fieldSelector": q["fieldSelector"]}.Encode() +} diff --git a/gateway/kube/examples/comparison/objects.go b/gateway/kube/examples/comparison/objects.go new file mode 100644 index 0000000..57a3434 --- /dev/null +++ b/gateway/kube/examples/comparison/objects.go @@ -0,0 +1,207 @@ +package comparison + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" +) + +// The objects every source watches: a few Widgets — a CRD with spec and a REAL status subresource, so +// a controller-style status write moves resourceVersion without touching spec — and a few Secrets. +// +// The Widget definition is the one gateway/kube/composition_e2e_test.go installs, copied rather than +// imported (test code is not a library). Both create it only when it is missing and never delete it: +// it is cluster-scoped and identical, so the real-API suite and this host can share a cluster. + +// WidgetGroup is the Widget CRD's API group. +const WidgetGroup = "e2e.krm-stream.configbutler.io" + +var ( + widgets = schema.GroupVersionResource{Group: WidgetGroup, Version: "v1", Resource: "widgets"} + secrets = schema.GroupVersionResource{Version: "v1", Resource: "secrets"} + crds = schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"} +) + +// ensureWidgetCRD installs the Widget CRD when it is missing and waits until it is served. +func ensureWidgetCRD(ctx context.Context, dyn dynamic.Interface) error { + preserve := map[string]any{"type": "object", "x-kubernetes-preserve-unknown-fields": true} + crd := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]any{"name": widgets.Resource + "." + widgets.Group}, + "spec": map[string]any{ + "group": widgets.Group, + "scope": "Namespaced", + "names": map[string]any{"plural": "widgets", "singular": "widget", "kind": "Widget", "listKind": "WidgetList"}, + "versions": []any{map[string]any{ + "name": "v1", "served": true, "storage": true, + "subresources": map[string]any{"status": map[string]any{}}, + "schema": map[string]any{"openAPIV3Schema": map[string]any{ + "type": "object", + "properties": map[string]any{"spec": preserve, "status": preserve}, + }}, + }}, + }, + }} + if _, err := dyn.Resource(crds).Create(ctx, crd, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("install the Widget CRD: %w", err) + } + deadline := time.Now().Add(60 * time.Second) + for { + _, err := dyn.Resource(widgets).Namespace("default").List(ctx, metav1.ListOptions{Limit: 1}) + if err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("the Widget CRD is not served: %w", err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } +} + +// WidgetName and SecretName name the fixed objects. +func WidgetName(i int) string { return fmt.Sprintf("w-%02d", i) } + +// SecretName names the i-th Secret. +func SecretName(i int) string { return fmt.Sprintf("s-%d", i) } + +var colors = []string{"red", "green", "blue", "amber"} + +func initialSpec(i int) map[string]any { + return map[string]any{"replicas": int64(i%5 + 1), "color": colors[i%len(colors)], "note": "initial", "revision": int64(0)} +} + +func initialStatus() map[string]any { + return map[string]any{"phase": "Ready", "heartbeat": int64(0), "observedRevision": int64(0)} +} + +func initialSecretData(i int) map[string]any { + return map[string]any{ + "token": base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "initial-token-%d", i)), + "user": base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "svc-%d", i)), + } +} + +// objects is the fixed set in one namespace, written with the host's own client. +type objects struct { + dyn dynamic.Interface + namespace string + widgets int + secrets int +} + +func (o *objects) widgetClient() dynamic.ResourceInterface { + return o.dyn.Resource(widgets).Namespace(o.namespace) +} + +func (o *objects) secretClient() dynamic.ResourceInterface { + return o.dyn.Resource(secrets).Namespace(o.namespace) +} + +// create makes every object, with its initial status written through the status subresource. +func (o *objects) create(ctx context.Context) error { + for i := range o.widgets { + w := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": widgets.GroupVersion().String(), + "kind": "Widget", + "metadata": map[string]any{"name": WidgetName(i), "labels": map[string]any{"compare": "widget"}}, + "spec": initialSpec(i), + }} + if _, err := o.widgetClient().Create(ctx, w, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("create widget %s: %w", WidgetName(i), err) + } + } + for i := range o.secrets { + s := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": SecretName(i), "labels": map[string]any{"compare": "secret"}}, + "type": "Opaque", + "data": initialSecretData(i), + }} + if _, err := o.secretClient().Create(ctx, s, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("create secret %s: %w", SecretName(i), err) + } + } + return o.reset(ctx) +} + +// reset writes every object back to its initial content, so each measurement starts from the same +// state. Spec and data are replaced; status goes through its subresource. +func (o *objects) reset(ctx context.Context) error { + for i := range o.widgets { + if err := o.patch(ctx, o.widgetClient(), WidgetName(i), map[string]any{"spec": initialSpec(i)}); err != nil { + return err + } + if err := o.patch(ctx, o.widgetClient(), WidgetName(i), map[string]any{"status": initialStatus()}, "status"); err != nil { + return err + } + } + for i := range o.secrets { + if err := o.patch(ctx, o.secretClient(), SecretName(i), map[string]any{"data": initialSecretData(i)}); err != nil { + return err + } + } + return nil +} + +func (o *objects) patch(ctx context.Context, client dynamic.ResourceInterface, name string, body map[string]any, subresource ...string) error { + patch, err := json.Marshal(body) + if err != nil { + return err + } + if _, err := client.Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}, subresource...); err != nil { + return fmt.Errorf("patch %s %v: %w", name, subresource, err) + } + return nil +} + +// StateObject is one object as the cluster holds it, for the measurement's correctness gate. +type StateObject struct { + Kind string `json:"kind"` + UID string `json:"uid"` + Name string `json:"name"` + ResourceVersion string `json:"resourceVersion"` + Labels map[string]string `json:"labels,omitempty"` + Spec any `json:"spec,omitempty"` + Status any `json:"status,omitempty"` + Data map[string]any `json:"data,omitempty"` +} + +// state lists every object with a consistent read. +func (o *objects) state(ctx context.Context) ([]StateObject, error) { + var out []StateObject + for _, client := range []dynamic.ResourceInterface{o.widgetClient(), o.secretClient()} { + list, err := client.List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + for _, item := range list.Items { + data, _, _ := unstructured.NestedMap(item.Object, "data") + out = append(out, StateObject{ + Kind: item.GetKind(), + UID: string(item.GetUID()), + Name: item.GetName(), + ResourceVersion: item.GetResourceVersion(), + Labels: item.GetLabels(), + Spec: item.Object["spec"], + Status: item.Object["status"], + Data: data, + }) + } + } + return out, nil +} diff --git a/gateway/kube/examples/comparison/save.go b/gateway/kube/examples/comparison/save.go new file mode 100644 index 0000000..15e7fff --- /dev/null +++ b/gateway/kube/examples/comparison/save.go @@ -0,0 +1,193 @@ +package comparison + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/ConfigButler/krm-stream/gateway" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// The host's save endpoints for gateway editing, one per projection, built the way +// examples/conditionalsave is: a projected GET envelope for guarded recovery reads, and a PATCH that +// carries the captured UID and resourceVersion as Kubernetes preconditions, after +// gateway.ValidateMergePatch has checked it against the same projection the stream uses. +// +// GET /save/{full|spec}/widgets/{name} → {"object": , "redactedPaths": [...]} +// PATCH /save/{full|spec}/widgets/{name} ← {"uid", "resourceVersion", "patch"} → 204 +// +// Only a Widget's spec is editable. Secrets are not editable through the gateway: their values are +// redacted, and a projected editor has nothing it could write. + +type saveRequest struct { + UID string `json:"uid"` + ResourceVersion string `json:"resourceVersion"` + Patch map[string]any `json:"patch"` +} + +func (h *Host) saveHandler(w http.ResponseWriter, r *http.Request) { + m := h.metrics + w.Header().Set("Cache-Control", "no-store") + rest, _ := strings.CutPrefix(r.URL.Path, "/save/") + parts := strings.Split(rest, "/") + if len(parts) != 3 || parts[1] != "widgets" || parts[2] == "" { + http.NotFound(w, r) + return + } + var projection gateway.Projection + switch parts[0] { + case "full": + projection = gateway.ProjectionFull + case "spec": + projection = gateway.ProjectionSpec + default: + http.NotFound(w, r) + return + } + name := parts[2] + session, ok := h.authenticate(w, r, "save") + if !ok { + return + } + if session.refused { + writeStatus(w, metav1.Status{Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden, Message: "this session may not edit these objects"}) + return + } + prefix := "save." + parts[0] + writer := &countingWriter{ResponseWriter: w, add: func(n int64) { m.Add(prefix+".bytes", n) }} + method := strings.ToLower(r.Method) + if r.Method != http.MethodGet && r.Method != http.MethodPatch { + w.Header().Set("Allow", "GET, PATCH") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + m.Add(prefix+"."+method+"_requests", 1) + defer func() { m.Add(prefix+"."+method+"_status."+statusClass(writer.status), 1) }() + + client := h.objects.widgetClient() + current, err := client.Get(r.Context(), name, metav1.GetOptions{}) + if err != nil { + writeError(writer, err) + return + } + if r.Method == http.MethodGet { + projected, paths := gateway.Project(projection, current.Object) + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(struct { + Object gateway.KRMObject `json:"object"` + RedactedPaths []string `json:"redactedPaths"` + }{projected, paths}) + return + } + var request saveRequest + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + writeStatus(writer, metav1.Status{Code: http.StatusBadRequest, Message: "invalid save request"}) + return + } + if err := decoder.Decode(new(any)); err != io.EOF { + writeStatus(writer, metav1.Status{Code: http.StatusBadRequest, Message: "expected one save request"}) + return + } + if request.UID == "" || request.ResourceVersion == "" || request.Patch == nil { + writeStatus(writer, metav1.Status{Code: http.StatusBadRequest, Message: "UID, resourceVersion and patch are required"}) + return + } + if request.UID != string(current.GetUID()) { + writeReplaced(writer, name, current.GetUID()) + return + } + // Application edit policy, separate from projection protection: a Widget's spec, nothing else. + // Identity and version are never accepted inside the user's patch; the host sets the captured + // preconditions below. + for key := range request.Patch { + if key != "spec" { + writeStatus(writer, metav1.Status{Code: http.StatusBadRequest, Message: "only spec is editable here"}) + return + } + } + request.Patch["metadata"] = map[string]any{"uid": request.UID, "resourceVersion": request.ResourceVersion} + patch, err := json.Marshal(request.Patch) + if err != nil { + writeStatus(writer, metav1.Status{Code: http.StatusBadRequest, Message: "invalid patch"}) + return + } + if err := gateway.ValidateMergePatch(projection, current.Object, patch); err != nil { + writeStatus(writer, metav1.Status{Code: http.StatusBadRequest, Message: "patch touches a protected field"}) + return + } + _, err = client.Patch(r.Context(), name, types.MergePatchType, patch, metav1.PatchOptions{}) + if err != nil && (apierrors.IsConflict(err) || uidRejected(err)) { + // Kubernetes checks the captured resourceVersion before the UID, so an object deleted and + // recreated since the preflight GET fails exactly like a stale write. Ask which it was. + latest, getErr := client.Get(r.Context(), name, metav1.GetOptions{}) + switch { + case apierrors.IsNotFound(getErr): + err = getErr + case getErr == nil && latest.GetUID() != current.GetUID(): + writeReplaced(writer, name, latest.GetUID()) + return + } + } + if err != nil { + writeError(writer, err) + return + } + writer.WriteHeader(http.StatusNoContent) // The projected watch echo settles dirty state. +} + +// writeError answers with the Kubernetes Status behind err. An error that is not an API response is +// reported without its text, which can name internal addresses. +func writeError(w http.ResponseWriter, err error) { + var apiStatus apierrors.APIStatus + if errors.As(err, &apiStatus) { + writeStatus(w, apiStatus.Status()) + return + } + writeStatus(w, metav1.Status{Code: http.StatusBadGateway, Message: "the Kubernetes request failed"}) +} + +func writeReplaced(w http.ResponseWriter, name string, current types.UID) { + writeStatus(w, metav1.Status{ + Code: http.StatusConflict, + Reason: metav1.StatusReasonConflict, + Message: "object was replaced", + Details: &metav1.StatusDetails{Name: name, Kind: "widgets", UID: current}, + }) +} + +// refuse answers as the API server would, with a Status an editor can show. +func refuse(w http.ResponseWriter, code int, message string) { + writeStatus(w, metav1.Status{Code: int32(code), Message: message}) //nolint:gosec // an HTTP status code +} + +func writeStatus(w http.ResponseWriter, status metav1.Status) { + if status.Code < 400 || status.Code > 599 { + status.Code = http.StatusBadGateway + } + status.TypeMeta = metav1.TypeMeta{Kind: "Status", APIVersion: "v1"} + status.Status = metav1.StatusFailure + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(int(status.Code)) + _ = json.NewEncoder(w).Encode(status) +} + +// uidRejected reports a 422 whose cause is metadata.uid: the identity precondition. +func uidRejected(err error) bool { + var apiStatus apierrors.APIStatus + if !apierrors.IsInvalid(err) || !errors.As(err, &apiStatus) || apiStatus.Status().Details == nil { + return false + } + for _, cause := range apiStatus.Status().Details.Causes { + if cause.Field == "metadata.uid" { + return true + } + } + return false +} diff --git a/gateway/kube/examples/comparison/workload.go b/gateway/kube/examples/comparison/workload.go new file mode 100644 index 0000000..7615664 --- /dev/null +++ b/gateway/kube/examples/comparison/workload.go @@ -0,0 +1,259 @@ +package comparison + +import ( + "context" + "encoding/base64" + "fmt" + "math" + "math/rand/v2" + "slices" + "time" +) + +// A workload is a deterministic schedule of writes and forced downstream disconnects. The same +// request always yields the same schedule, so every source — and every repetition, and the baseline +// and current library — sees identical objects and identical churn. + +// The workload names. +const ( + WorkloadQuiet = "quiet" + WorkloadBurst = "burst" + WorkloadChurn = "churn" + WorkloadForcedDisconnect = "forced-disconnect" +) + +// Workloads lists the names Plan accepts. +var Workloads = []string{WorkloadQuiet, WorkloadBurst, WorkloadChurn, WorkloadForcedDisconnect} + +// WorkloadRequest parameterizes a workload. Zero values take the defaults documented on each field. +type WorkloadRequest struct { + Name string `json:"name"` + // DurationMs is how long the schedule runs. Default 20000. + DurationMs int `json:"durationMs"` + // Seed picks which objects each write touches. Default 1. + Seed uint64 `json:"seed"` + // Rate is the write rate per second: status writes for churn, mixed writes for + // forced-disconnect, writes per burst for burst. Defaults 10, 4 and 12. + Rate float64 `json:"rate"` + // Reconnects is how many forced downstream disconnects every workload includes, spread evenly. + // Default 2. forced-disconnect adds one every DisconnectEveryMs on top. + Reconnects int `json:"reconnects"` + // DisconnectEveryMs is forced-disconnect's period. Default 4000. + DisconnectEveryMs int `json:"disconnectEveryMs"` + // Target is which downstream connections a disconnect closes: native, gateway or all. Default all. + Target string `json:"target"` +} + +// StepKind is what one step does. +type StepKind string + +// The step kinds. +const ( + StepSpec StepKind = "spec" // a spec write to a Widget + StepStatus StepKind = "status" // a status-subresource write to a Widget + StepSecret StepKind = "secret" // a Secret value rotation + StepDisconnect StepKind = "disconnect" // close downstream connections +) + +// Step is one scheduled action. +type Step struct { + At time.Duration `json:"atMs"` + Kind StepKind `json:"kind"` + Object int `json:"object"` + Seq int `json:"seq"` +} + +// Plan is a request's complete schedule, sorted by time. +type Plan struct { + Request WorkloadRequest `json:"request"` + Steps []Step `json:"steps"` +} + +// Counts tallies a plan's steps by kind. +func (p Plan) Counts() map[StepKind]int { + counts := map[StepKind]int{} + for _, s := range p.Steps { + counts[s.Kind]++ + } + return counts +} + +func (r WorkloadRequest) withDefaults() WorkloadRequest { + if r.DurationMs <= 0 { + r.DurationMs = 20000 + } + if r.Seed == 0 { + r.Seed = 1 + } + if r.Reconnects <= 0 { + r.Reconnects = 2 + } + if r.DisconnectEveryMs <= 0 { + r.DisconnectEveryMs = 4000 + } + if r.Target == "" { + r.Target = "all" + } + if r.Rate <= 0 { + switch r.Name { + case WorkloadChurn: + r.Rate = 10 + case WorkloadForcedDisconnect: + r.Rate = 4 + case WorkloadBurst: + r.Rate = 12 + } + } + return r +} + +// NewPlan builds the schedule for a request against widgetCount Widgets and secretCount Secrets. +func NewPlan(req WorkloadRequest, widgetCount, secretCount int) (Plan, error) { + req = req.withDefaults() + if widgetCount < 1 || secretCount < 1 { + return Plan{}, fmt.Errorf("comparison: a workload needs at least one Widget and one Secret") + } + switch req.Target { + case "all", "native", "gateway": + default: + return Plan{}, fmt.Errorf("comparison: unknown disconnect target %q", req.Target) + } + if req.DurationMs > 10*60*1000 || req.Rate > 200 { + return Plan{}, fmt.Errorf("comparison: duration or rate out of range") + } + duration := time.Duration(req.DurationMs) * time.Millisecond + // Seeded on purpose: the point is the same schedule every time, not unpredictability. + rng := rand.New(rand.NewPCG(req.Seed, uint64(len(req.Name)))) //nolint:gosec // see above + var steps []Step + add := func(at time.Duration, kind StepKind) { + object := 0 + switch kind { + case StepSpec, StepStatus: + object = rng.IntN(widgetCount) + case StepSecret: + object = rng.IntN(secretCount) + } + steps = append(steps, Step{At: at, Kind: kind, Object: object}) + } + every := func(period time.Duration, kind StepKind) { + for at := period; at < duration; at += period { + add(at, kind) + } + } + perSecond := func(rate float64) time.Duration { + return time.Duration(math.Round(float64(time.Second) / rate)) + } + + switch req.Name { + case WorkloadQuiet: + // No writes at all: what an idle page costs each source. + case WorkloadBurst: + // A burst of spec and status writes every 4 seconds, 20 ms apart, then silence. + for start := 2 * time.Second; start < duration; start += 4 * time.Second { + for i := range int(req.Rate) { + kind := StepSpec + if rng.IntN(2) == 1 { + kind = StepStatus + } + add(start+time.Duration(i)*20*time.Millisecond, kind) + } + } + case WorkloadChurn: + // Sustained status-only writes, as a controller makes them, with an occasional spec write and + // a Secret rotation. Status churn is what krm-spec/v1 suppresses; a rotation is what full and + // spec show only as a redaction revision. + every(perSecond(req.Rate), StepStatus) + every(2*time.Second, StepSpec) + every(3*time.Second, StepSecret) + case WorkloadForcedDisconnect: + // Moderate mixed writes, with downstream connections closed every few seconds. + period := perSecond(req.Rate) + kinds := []StepKind{StepSpec, StepStatus, StepStatus, StepSecret} + for at := period / 2; at < duration; at += period { + add(at, kinds[rng.IntN(len(kinds))]) + } + every(time.Duration(req.DisconnectEveryMs)*time.Millisecond, StepDisconnect) + default: + return Plan{}, fmt.Errorf("comparison: unknown workload %q (want one of %v)", req.Name, Workloads) + } + // Every workload includes forced reconnects, so reconnect cost is measured under each one. They + // are offset by a quarter second from the even split so they never coincide with a burst start. + for i := 1; i <= req.Reconnects; i++ { + steps = append(steps, Step{At: duration*time.Duration(i)/time.Duration(req.Reconnects+1) + 250*time.Millisecond, Kind: StepDisconnect}) + } + slices.SortStableFunc(steps, func(a, b Step) int { return int(a.At - b.At) }) + for i := range steps { + steps[i].Seq = i + 1 + } + return Plan{Request: req, Steps: steps}, nil +} + +// WorkloadResult reports what a run actually did. +type WorkloadResult struct { + Plan map[StepKind]int `json:"planned"` + Done map[StepKind]int `json:"done"` + Closed int `json:"connectionsClosed"` + Errors []string `json:"errors,omitempty"` + MaxLateMs int64 `json:"maxLateMs"` + ElapsedMs int64 `json:"elapsedMs"` + DurationMs int `json:"durationMs"` + Seed uint64 `json:"seed"` + Rate float64 `json:"rate"` + Reconnects int `json:"reconnects"` + StartedUnix int64 `json:"startedUnixMs"` +} + +// run executes a plan in time order on one goroutine. A step that falls behind runs at once; how far +// behind the worst one was is reported, so an overloaded run is visible rather than silently slower. +func (o *objects) run(ctx context.Context, plan Plan, disconnect func(target string) int) WorkloadResult { + start := time.Now() + result := WorkloadResult{ + Plan: plan.Counts(), Done: map[StepKind]int{}, + DurationMs: plan.Request.DurationMs, Seed: plan.Request.Seed, Rate: plan.Request.Rate, + Reconnects: plan.Request.Reconnects, StartedUnix: start.UnixMilli(), + } + for _, step := range plan.Steps { + if wait := time.Until(start.Add(step.At)); wait > 0 { + select { + case <-ctx.Done(): + result.Errors = append(result.Errors, ctx.Err().Error()) + return result + case <-time.After(wait): + } + } else { + result.MaxLateMs = max(result.MaxLateMs, (-wait).Milliseconds()) + } + var err error + switch step.Kind { + case StepSpec: + // Always a change: the revision is the step's sequence number. + err = o.patch(ctx, o.widgetClient(), WidgetName(step.Object), map[string]any{"spec": map[string]any{ + "revision": int64(step.Seq), "replicas": int64(step.Seq%5 + 1), + }}) + case StepStatus: + err = o.patch(ctx, o.widgetClient(), WidgetName(step.Object), map[string]any{"status": map[string]any{ + "heartbeat": int64(step.Seq), "phase": []string{"Ready", "Progressing"}[step.Seq%2], + }}, "status") + case StepSecret: + err = o.patch(ctx, o.secretClient(), SecretName(step.Object), map[string]any{"data": map[string]any{ + "token": base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "rotated-%d-%d", plan.Request.Seed, step.Seq)), + }}) + case StepDisconnect: + result.Closed += disconnect(plan.Request.Target) + } + if err != nil { + result.Errors = append(result.Errors, err.Error()) + continue + } + result.Done[step.Kind]++ + } + // The schedule's tail is quiet until its full duration, so every run lasts as long as it says. + if wait := time.Until(start.Add(time.Duration(plan.Request.DurationMs) * time.Millisecond)); wait > 0 { + select { + case <-ctx.Done(): + case <-time.After(wait): + } + } + result.ElapsedMs = time.Since(start).Milliseconds() + return result +} From 8c70b4760a1bdaacd35a8cd44e5a72884506da42 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 16:06:22 +0000 Subject: [PATCH 2/2] fix(examples): stop silent overwrites and strengthen comparison gates Review fixes for the comparison example. The page skipped re-rendering a focused input, so when another writer changed a focused, unedited field the input kept the old text and the next keystroke saved it over the newer value, with no conflict. fields.ts now follows the store's draft in a focused input too, keeping the caret, and rewrites an input only when the draft moved, so half-typed numbers survive. measure.ts never checked the workload outcome: a run whose writes all failed matched the unchanged cluster and was reported as a measurement. The workload gate now rejects any run with errors or with fewer done steps than planned. A projected Secret store that missed a rotation kept the same redacted key and passed; the host now reports each Secret's last successful rotation version and the correctness gate requires it. Regressions: fields.test.ts and gates.test.ts (task test-compare, part of task test, with the page and driver typecheck), a Go workload test, and task compare-browser, which runs the focused-field case in Chromium against the real host on both entry points (6/6 pass; 6/6 fail with the old render rule). The matrix and the native before/after were rerun under both gates (72/72 passed) and the report updated. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 1 + Taskfile.yml | 29 +- docs/facts/comparison-2026-10-05.md | 277 ++++++++++-------- docs/field-reports/third-our-identity.md | 5 +- ...006-stream-and-save-implementation-plan.md | 2 +- examples/comparison/README.md | 44 ++- examples/comparison/fields.test.ts | 97 ++++++ examples/comparison/fields.ts | 61 ++++ examples/comparison/gates.test.ts | 66 +++++ examples/comparison/gates.ts | 111 +++++++ examples/comparison/measure.ts | 82 ++---- examples/comparison/page.ts | 10 +- examples/comparison/tsconfig.json | 6 +- examples/vanilla-browser/compare.config.ts | 40 +++ .../compare/comparison.spec.ts | 60 ++++ gateway/kube/examples/comparison/objects.go | 15 +- gateway/kube/examples/comparison/workload.go | 34 ++- .../kube/examples/comparison/workload_test.go | 66 +++++ 18 files changed, 779 insertions(+), 227 deletions(-) create mode 100644 examples/comparison/fields.test.ts create mode 100644 examples/comparison/fields.ts create mode 100644 examples/comparison/gates.test.ts create mode 100644 examples/comparison/gates.ts create mode 100644 examples/vanilla-browser/compare.config.ts create mode 100644 examples/vanilla-browser/compare/comparison.spec.ts create mode 100644 gateway/kube/examples/comparison/workload_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e85e76c..0910958 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,7 @@ the rule it protects. | `task e2e-browser` | Native `EventSource`, unbundled ESM and the native viewer and editor pages in Chromium. | | `task cluster-facts` | Record observed Kubernetes behavior for the supported cluster version. | | `task test-real-api` | Compose the save path and native viewing and editing against a real API server: gateway or host proxy, host endpoint and browser store. | +| `task compare-browser` | The comparison page in Chromium against the real comparison host, on both entry points. | | `task compare-measure` | Measure native, full and spec sources, unshared and shared, under identical workloads against a real API server; `task compare-native-baseline BASELINE_REF=` compares the native connector with an earlier build. Results are evidence for the run, recorded in `docs/facts/`. | | `task test-cluster` | Exercise the Kubernetes backend against a real API server. | diff --git a/Taskfile.yml b/Taskfile.yml index ac50e9b..0f2c0b3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -96,7 +96,7 @@ tasks: # -------------------------------------------------------------------- test -- test: desc: "Run both suites against the shared conformance fixtures." - deps: [test-gateway, test-kube, test-client, test-vue] + deps: [test-gateway, test-kube, test-client, test-vue, test-compare] test-gateway: desc: "Go: the gateway + its half of the conformance suite." @@ -136,6 +136,15 @@ tasks: cmds: - npm test + test-compare: + desc: "Typecheck the comparison page and driver, and test their field sync and measurement gates (no cluster)." + deps: [_client-deps] + cmds: + # The page and the driver are the only TypeScript outside the client package that nothing else + # typechecks: build.mjs and node both strip types without checking them. + - packages/krm-stream/node_modules/.bin/tsc -p examples/comparison + - node --test examples/comparison/fields.test.ts examples/comparison/gates.test.ts + _vue-deps: internal: true dir: examples/vue @@ -490,6 +499,7 @@ tasks: sources: - examples/comparison/build.mjs - examples/comparison/page.ts + - examples/comparison/fields.ts - examples/native-editor/editor.ts - examples/conditional-save/editor.ts - examples/editor-recipes/keepLocal.ts @@ -514,6 +524,23 @@ tasks: # Foreground, so Ctrl-C reaches the host, which deletes its scratch namespace on the way out. - go run ./examples/comparison/cmd/compare --addr 127.0.0.1:8110 {{.CLI_ARGS}} + compare-browser: + desc: "The comparison page in Chromium against the real comparison host, on both entry points (needs the spike cluster)." + deps: [cluster-up, build-compare-page, _example-deps] + env: + KUBECONFIG: '{{.KUBECONFIG_PATH}}' + cmds: + # BUILD the host and hand Playwright the binary: `go run` would leave its child holding the port + # and keep the scratch namespace alive when Playwright stops the parent. + - | + set -e + bin="$(mktemp -t krm-compare-XXXX)" + trap 'rm -f "$bin"' EXIT + (cd gateway/kube && go build -o "$bin" ./examples/comparison/cmd/compare) + cd examples/vanilla-browser + npx --no-install playwright install chromium --with-deps >/dev/null + COMPARE_BIN="$bin" npx --no-install playwright test -c compare.config.ts {{.CLI_ARGS}} + compare-measure: desc: "Measure every source under every workload against the spike cluster (CLI_ARGS go to measure.ts)." # Not cached, and it must not be: it measures a cluster, not files. diff --git a/docs/facts/comparison-2026-10-05.md b/docs/facts/comparison-2026-10-05.md index 55df538..b5477cc 100644 --- a/docs/facts/comparison-2026-10-05.md +++ b/docs/facts/comparison-2026-10-05.md @@ -9,7 +9,7 @@ | | | |---|---| -| Commit | `d259394` (`main`) plus the uncommitted harness from this change; the library under test in the matrix below is `d259394`'s `packages/krm-stream` (native connector re-lists on every reconnect). The native AFTER section measures the resuming connector. | +| Commit | This branch: the resuming native connector and the comparison harness with both gates (the workload gate and the correctness gate including Secret rotation versions); the library under test is this tree's `packages/krm-stream`. The native AFTER section compares it with `d259394`, the last commit that re-lists on every reconnect. | | Harness | [examples/comparison](../../examples/comparison/README.md): the host in [gateway/kube/examples/comparison](../../gateway/kube/examples/comparison/) and the driver [measure.ts](../../examples/comparison/measure.ts) | | Command | `task compare-measure` (defaults: workloads quiet, burst, churn, forced-disconnect; N = 1 and 10; 3 repetitions; 20 s per workload; seed 1; 2 forced reconnects per workload; connector `retryDelayMs` 0, `healthyResetMs` 1 s) | | Library entry | `packages/krm-stream/dist/index.js`, loaded by node 24 | @@ -26,16 +26,30 @@ tables cover the **workload phase only** — from the moment every subscriber is workload — and are **summed over the N subscribers**. Cells are median (min–max) over the 3 repetitions; a single value means all three agreed. -Every run passed the correctness gate before it was counted: after the workload every store held -what the cluster held — for native, each object's exact resourceVersion, spec, status and Secret -data; for full and spec, the same membership and spec (and status for full), no Secret values, and -exactly the cluster's Secret keys as redacted paths. +Every run passed two gates before it was counted. The **workload gate**: the host reported no failed +step and did every step it planned, of every kind (`planned` = `done`), so the cluster really changed +as scheduled. The **correctness gate**: after the workload every store held what the cluster held — +for native, each object's exact resourceVersion, spec, status and Secret data; for full and spec, the +same membership and spec (and status for full), no Secret values, exactly the cluster's Secret keys as +redacted paths, and, for every Secret the workload rotated, the resourceVersion of its final +successful rotation. The last check exists because a projected store that missed a rotation still +shows the same redacted key; intermediate rotations may coalesce. + +An earlier matrix on this date, with `d259394`'s library and a correctness gate that lacked the +workload and rotation checks, is superseded by the run below; its counts for the gateway sources were +the same. ## Results -Matrix run 2026-10-05 14:18–14:26 UTC (8 min 16 s), all 24 runs passed the correctness gate. The refusal check that precedes the matrix passed: each of the four gateway routes ended the refused session with a terminal `FORBIDDEN`, and the native proxy saw no request from that session afterwards. +Matrix run 2026-10-05 15:39–15:47 UTC (8 min 16 s) with `task compare-measure`: all 24 runs passed +both gates. The refusal check that precedes the matrix passed: each of the four gateway routes ended +the refused session with a terminal `FORBIDDEN`, and the native proxy saw no request from that +session afterwards. -"Writes performed" are the workload's own writes; a forced disconnect closes every downstream native watch and SSE stream of every source at once. `events added` includes the re-snapshot after each forced reconnect (11 objects per subscriber per reconnect). +"Writes performed" are the workload's own writes, all of which succeeded; a forced disconnect closes +every downstream native watch and SSE stream of every source at once. For the gateway sources, +`events added` includes the re-snapshot after each forced reconnect (11 objects per subscriber per +reconnect). Native subscribers resume their watches instead, so they receive no re-snapshot. ### quiet @@ -45,36 +59,36 @@ Client side (what the subscribers consumed): | source | N | events added | modified | deleted | resets | notifications | renders | KiB down | reconnects | time-to-live ms (median) | time-to-live ms (max) | Secret values seen changing | redaction rev bumps | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| native | 1 | 22 | 0 | 0 | 4 | 22 | 4 | 19 | 4 | 10 (9.5–12) | 10 (10–105) | 0 | 0 | -| full | 1 | 22 | 0 | 0 | 4 | 22 | 5 (4–6) | 10.3 | 4 | 7.5 (7–9) | 10 (8–12) | 0 | 0 | -| spec | 1 | 22 | 0 | 0 | 4 | 22 | 5 (5–6) | 9.3 | 4 | 7 (6.5–10.5) | 8 (7–107) | 0 | 0 | -| full-shared | 1 | 22 | 0 | 0 | 4 | 22 | 4 | 10.3 | 4 | 7.5 (6.5–9.5) | 9 (8–106) | 0 | 0 | -| spec-shared | 1 | 22 | 0 | 0 | 4 | 22 | 4 (4–5) | 9.3 | 4 | 8 (6–10) | 8 (8–107) | 0 | 0 | -| native | 10 | 220 | 0 | 0 | 40 | 220 | 40 | 190 | 40 | 45.5 (37.5–49) | 54 (42–56) | 0 | 0 | -| full | 10 | 220 | 0 | 0 | 40 | 220 | 45 (43–49) | 103 | 40 | 34 (31–34) | 47 (36–52) | 0 | 0 | -| spec | 10 | 220 | 0 | 0 | 40 | 220 | 44 (41–46) | 93.4 | 40 | 34 (31.5–42.5) | 44 (37–53) | 0 | 0 | -| full-shared | 10 | 220 | 0 | 0 | 40 | 220 | 41 (40–42) | 103 | 40 | 32 (26–32.5) | 47 (36–49) | 0 | 0 | -| spec-shared | 10 | 220 | 0 | 0 | 40 | 220 | 41 (40–41) | 93.4 | 40 | 31 (26–35) | 45 (35–53) | 0 | 0 | +| native | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 6.5 (6–9) | 8 (8–13) | 0 | 0 | +| full | 1 | 22 | 0 | 0 | 4 | 22 | 6 (5–6) | 10.3 | 4 | 8 (7.5–11) | 9 (9–17) | 0 | 0 | +| spec | 1 | 22 | 0 | 0 | 4 | 22 | 5 (4–5) | 9.3 | 4 | 7 (7–9) | 9 (8–14) | 0 | 0 | +| full-shared | 1 | 22 | 0 | 0 | 4 | 22 | 5 (5–6) | 10.3 | 4 | 7.5 (7–10) | 9 (9–16) | 0 | 0 | +| spec-shared | 1 | 22 | 0 | 0 | 4 | 22 | 4 (4–5) | 9.3 | 4 | 8 (8–9) | 9 (9–19) | 0 | 0 | +| native | 10 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 40 | 31 (30.5–36) | 68 (33–75) | 0 | 0 | +| full | 10 | 220 | 0 | 0 | 40 | 220 | 51 (40–65) | 103 | 40 | 42.5 (34.5–63) | 94 (60–176) | 0 | 0 | +| spec | 10 | 220 | 0 | 0 | 40 | 220 | 57 (40–57) | 93.4 | 40 | 38 (37.5–53) | 93 (60–106) | 0 | 0 | +| full-shared | 10 | 220 | 0 | 0 | 40 | 220 | 41 (40–42) | 103 | 40 | 35 (35–59) | 85 (55–88) | 0 | 0 | +| spec-shared | 10 | 220 | 0 | 0 | 40 | 220 | 40 (40–41) | 93.4 | 40 | 41.5 (31.5–58.5) | 93 (44–185) | 0 | 0 | Host side, native proxy: | source | N | LIST | WATCH | WATCH after LIST | WATCH resumed | watch frames | ERROR frames | KiB to browser | KiB from API server | active watches at end | session checks | |---|---|---|---|---|---|---|---|---|---|---|---| -| native | 1 | 4 | 4 | 4 | 0 | 0 | 0 | 19 | 19 | 2 | 8 | -| native | 10 | 40 | 40 | 40 | 0 | 0 | 0 | 190 | 190 | 20 | 80 | +| native | 1 | 0 | 4 | 0 | 4 | 0 | 0 | 0 | 0 | 2 | 4 | +| native | 10 | 0 | 40 | 0 | 40 | 0 | 0 | 0 | 0 | 20 | 40 | Host side, gateway routes: | source | N | upstream watches opened | active upstream at end | cycles | consumer resyncs | events emitted | suppressed | shared subscriptions | authorizer calls | of which timed | KiB SSE | KiB from API server | session checks | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | full | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 0 | 10 | 6 | 10.3 | 20 (20–20.1) | 4 | -| spec | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 0 | 10 | 6 | 9.3 | 20 | 4 | -| full-shared | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 4 | 10 | 6 | 10.3 | 20 | 4 | -| spec-shared | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 4 | 10 | 6 | 9.3 | 20 | 4 | -| full | 10 | 40 | 20 | 40 | 0 | 300 | 0 | 0 | 100 | 60 | 103 | 200 (200–201) | 40 | -| spec | 10 | 40 | 20 | 40 | 0 | 300 | 0 | 0 | 100 | 60 | 93.4 | 200 (200–201) | 40 | -| full-shared | 10 | 4 | 2 | 40 | 0 | 300 | 0 | 40 | 100 | 60 | 103 | 20 (20–20.1) | 40 | -| spec-shared | 10 | 4 | 2 | 40 | 0 | 300 | 0 | 40 | 100 | 60 | 93.4 | 20 (20–20.1) | 40 | +| spec | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 0 | 10 | 6 | 9.3 | 20 (20–20.1) | 4 | +| full-shared | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 4 | 10 | 6 | 10.3 | 20 (20–20.1) | 4 | +| spec-shared | 1 | 4 | 2 | 4 | 0 | 30 | 0 | 4 | 10 | 6 | 9.3 | 20 (20–20.1) | 4 | +| full | 10 | 40 | 20 | 40 | 0 | 300 | 0 | 0 | 100 | 60 | 103 | 200 | 40 | +| spec | 10 | 40 | 20 | 40 | 0 | 300 | 0 | 0 | 100 | 60 | 93.4 | 200 | 40 | +| full-shared | 10 | 4 | 2 | 40 | 0 | 300 | 0 | 40 | 100 | 60 | 103 | 20 | 40 | +| spec-shared | 10 | 4 | 2 | 40 | 0 | 300 | 0 | 40 | 100 | 60 | 93.4 | 20 | 40 | ### burst @@ -84,23 +98,23 @@ Client side (what the subscribers consumed): | source | N | events added | modified | deleted | resets | notifications | renders | KiB down | reconnects | time-to-live ms (median) | time-to-live ms (max) | Secret values seen changing | redaction rev bumps | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| native | 1 | 22 | 60 | 0 | 4 | 82 | 64 | 78.8 (78.8–78.8) | 4 | 7 (6–10) | 8 (7–12) | 0 | 0 | -| full | 1 | 22 | 60 | 0 | 4 | 82 | 65 (64–65) | 38.4 (38.3–38.4) | 4 | 5.5 (5.5–8.5) | 7 (6–9) | 0 | 0 | -| spec | 1 | 22 | 34 | 0 | 4 | 56 | 40 | 23.1 (23.1–23.1) | 4 | 5.5 (5–8) | 6 (6–8) | 0 | 0 | -| full-shared | 1 | 22 | 60 | 0 | 4 | 82 | 64 (64–65) | 38.4 (38.3–38.4) | 4 | 6.5 (6–6.5) | 7 (6–8) | 0 | 0 | -| spec-shared | 1 | 22 | 34 | 0 | 4 | 56 | 38 (38–39) | 23.1 (23.1–23.1) | 4 | 6 (5–8) | 6 (6–9) | 0 | 0 | -| native | 10 | 220 | 600 | 0 | 40 | 820 | 640 | 789 (789–789) | 40 | 37 (30–42) | 50 (36–130) | 0 | 0 | -| full | 10 | 220 | 600 | 0 | 40 | 820 | 644 (643–648) | 384 (384–384) | 40 | 28 (19.5–30) | 41 (26–135) | 0 | 0 | -| spec | 10 | 220 | 340 | 0 | 40 | 560 | 388 (384–390) | 231 (231–231) | 40 | 28 (20–36) | 46 (27–134) | 0 | 0 | -| full-shared | 10 | 220 | 600 | 0 | 40 | 820 | 642 (641–642) | 384 (384–384) | 40 | 29 (19–31.5) | 44 (27–137) | 0 | 0 | -| spec-shared | 10 | 220 | 340 | 0 | 40 | 560 | 381 (380–381) | 231 (231–231) | 40 | 22.5 (19.5–31) | 48 (27–129) | 0 | 0 | +| native | 1 | 0 | 60 | 0 | 0 | 60 | 60 | 59.8 (59.7–59.8) | 4 | 3 (2.5–3) | 3 | 0 | 0 | +| full | 1 | 22 | 60 | 0 | 4 | 82 | 66 | 38.4 (38.3–38.4) | 4 | 5 (4.5–5.5) | 5 (5–6) | 0 | 0 | +| spec | 1 | 22 | 34 | 0 | 4 | 56 | 40 (40–42) | 23.1 (23.1–23.1) | 4 | 5 | 5 (5–7) | 0 | 0 | +| full-shared | 1 | 22 | 60 | 0 | 4 | 82 | 64 | 38.4 (38.3–38.4) | 4 | 5 (5–6) | 5 (5–7) | 0 | 0 | +| spec-shared | 1 | 22 | 34 | 0 | 4 | 56 | 39 (38–39) | 23.1 (23.1–23.1) | 4 | 4.5 (4.5–5.5) | 6 (5–7) | 0 | 0 | +| native | 10 | 0 | 600 | 0 | 0 | 600 | 600 | 598 (598–598) | 40 | 25 (20–27.5) | 32 (31–35) | 0 | 0 | +| full | 10 | 220 | 600 | 0 | 40 | 820 | 641 (641–644) | 384 (384–384) | 40 | 22.5 (20.5–26) | 33 (29–39) | 0 | 0 | +| spec | 10 | 220 | 340 | 0 | 40 | 560 | 385 (384–388) | 231 (231–231) | 40 | 25 (20–27) | 33 (29–40) | 0 | 0 | +| full-shared | 10 | 220 | 600 | 0 | 40 | 820 | 641 (640–644) | 384 (384–384) | 40 | 22 (17.5–23) | 30 (27–39) | 0 | 0 | +| spec-shared | 10 | 220 | 340 | 0 | 40 | 560 | 380 (380–385) | 231 (231–231) | 40 | 25 (18–27) | 31 (27–39) | 0 | 0 | Host side, native proxy: | source | N | LIST | WATCH | WATCH after LIST | WATCH resumed | watch frames | ERROR frames | KiB to browser | KiB from API server | active watches at end | session checks | |---|---|---|---|---|---|---|---|---|---|---|---| -| native | 1 | 4 | 4 | 4 | 0 | 60 | 0 | 78.8 (78.8–78.8) | 78.8 (78.8–78.8) | 2 | 8 | -| native | 10 | 40 | 40 | 40 | 0 | 600 | 0 | 789 (789–789) | 789 (789–789) | 20 | 80 | +| native | 1 | 0 | 4 | 0 | 4 | 60 | 0 | 59.8 (59.7–59.8) | 59.8 (59.7–59.8) | 2 | 4 | +| native | 10 | 0 | 40 | 0 | 40 | 600 | 0 | 598 (598–598) | 598 (598–598) | 20 | 40 | Host side, gateway routes: @@ -110,10 +124,10 @@ Host side, gateway routes: | spec | 1 | 4 | 2 | 4 | 0 | 64 | 26 | 0 | 10 | 6 | 23.1 (23.1–23.1) | 79.8 (79.8–79.9) | 4 | | full-shared | 1 | 4 | 2 | 4 | 0 | 90 | 0 | 4 | 10 | 6 | 38.4 (38.3–38.4) | 79.8 (79.8–79.9) | 4 | | spec-shared | 1 | 4 | 2 | 4 | 0 | 64 | 26 | 4 | 10 | 6 | 23.1 (23.1–23.1) | 79.8 (79.8–79.9) | 4 | -| full | 10 | 40 | 20 | 40 | 0 | 900 | 0 | 0 | 100 | 60 | 384 (384–384) | 800 (798–800) | 40 | -| spec | 10 | 40 | 20 | 40 | 0 | 640 | 260 | 0 | 100 | 60 | 231 (231–231) | 800 (798–800) | 40 | -| full-shared | 10 | 4 | 2 | 40 | 0 | 900 | 0 | 40 | 100 | 60 | 384 (384–384) | 80 (79.8–80) | 40 | -| spec-shared | 10 | 4 | 2 | 40 | 0 | 640 | 260 | 40 | 100 | 60 | 231 (231–231) | 80 (79.8–80) | 40 | +| full | 10 | 40 | 20 | 40 | 0 | 900 | 0 | 0 | 100 | 60 | 384 (384–384) | 798 (798–800) | 40 | +| spec | 10 | 40 | 20 | 40 | 0 | 640 | 260 | 0 | 100 | 60 | 231 (231–231) | 798 (798–800) | 40 | +| full-shared | 10 | 4 | 2 | 40 | 0 | 900 | 0 | 40 | 100 | 60 | 384 (384–384) | 79.8 (79.8–80) | 40 | +| spec-shared | 10 | 4 | 2 | 40 | 0 | 640 | 260 | 40 | 100 | 60 | 231 (231–231) | 79.8 (79.8–80) | 40 | ### churn @@ -123,36 +137,36 @@ Client side (what the subscribers consumed): | source | N | events added | modified | deleted | resets | notifications | renders | KiB down | reconnects | time-to-live ms (median) | time-to-live ms (max) | Secret values seen changing | redaction rev bumps | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| native | 1 | 22 | 214 | 0 | 4 | 236 | 218 | 230 | 4 | 6 (6–7) | 8 (6–9) | 6 | 0 | -| full | 1 | 22 | 214 | 0 | 4 | 236 | 222 (219–223) | 110 | 4 | 5 (5–6) | 7 (5–9) | 0 | 6 | -| spec | 1 | 22 | 15 | 0 | 4 | 37 | 20 (20–21) | 15.2 | 4 | 6 (5–6) | 6 (6–9) | 0 | 6 | -| full-shared | 1 | 22 | 214 | 0 | 4 | 236 | 219 | 110 | 4 | 6 (5.5–7) | 6 (6–9) | 0 | 6 | -| spec-shared | 1 | 22 | 15 | 0 | 4 | 37 | 19 (19–20) | 15.2 | 4 | 5.5 (5–7.5) | 6 (6–10) | 0 | 6 | -| native | 10 | 220 | 2131 (2130–2140) | 0 | 40 | 2351 (2350–2360) | 2171 (2170–2179) | 2293 (2292–2302) | 40 | 28 (24–31) | 110 (33–110) | 60 | 0 | -| full | 10 | 220 | 2136 (2131–2140) | 0 | 40 | 2356 (2351–2360) | 2178 (2178–2182) | 1099 (1097–1101) | 40 | 21 (20.5–31) | 106 (29–111) | 0 | 60 | -| spec | 10 | 220 | 150 | 0 | 40 | 370 | 195 (193–203) | 152 | 40 | 19 (17–31) | 29 (25–111) | 0 | 60 | -| full-shared | 10 | 220 | 2140 (2134–2140) | 0 | 40 | 2360 (2354–2360) | 2179 (2175–2181) | 1101 (1098–1101) | 40 | 17.5 (14–23) | 26 (21–33) | 0 | 60 | -| spec-shared | 10 | 220 | 150 | 0 | 40 | 370 | 191 (190–192) | 152 | 40 | 16 (16–22.5) | 26 (19–33) | 0 | 60 | +| native | 1 | 0 | 214 | 0 | 0 | 214 | 214 | 211 | 4 | 3.5 (3–4) | 4 (3–4) | 6 | 0 | +| full | 1 | 22 | 214 | 0 | 4 | 236 | 220 (220–221) | 110 | 4 | 5 (3.5–5) | 6 (5–6) | 0 | 6 | +| spec | 1 | 22 | 15 | 0 | 4 | 37 | 22 (21–22) | 15.2 | 4 | 5 (3.5–5) | 5 (5–6) | 0 | 6 | +| full-shared | 1 | 22 | 214 | 0 | 4 | 236 | 218 (218–219) | 110 | 4 | 5 (4–5.5) | 5 (4–6) | 0 | 6 | +| spec-shared | 1 | 22 | 15 | 0 | 4 | 37 | 19 (19–20) | 15.2 | 4 | 4.5 (4–6) | 6 (4–6) | 0 | 6 | +| native | 10 | 0 | 2140 | 0 | 0 | 2140 | 2136 (2136–2140) | 2112 | 40 | 22 (14–26.5) | 26 (19–52) | 60 | 0 | +| full | 10 | 220 | 2138 (2137–2140) | 0 | 40 | 2358 (2357–2360) | 2181 (2176–2181) | 1100 (1100–1101) | 40 | 23 (15.5–28) | 107 (27–113) | 0 | 60 | +| spec | 10 | 220 | 150 | 0 | 40 | 370 | 196 (194–199) | 152 | 40 | 23 (16–28.5) | 106 (26–116) | 0 | 60 | +| full-shared | 10 | 220 | 2140 (2130–2140) | 0 | 40 | 2360 (2350–2360) | 2171 (2170–2183) | 1101 (1097–1101) | 40 | 23 (15–24.5) | 27 (17–61) | 0 | 60 | +| spec-shared | 10 | 220 | 150 | 0 | 40 | 370 | 190 (190–193) | 152 | 40 | 22 (14–24.5) | 27 (18–61) | 0 | 60 | Host side, native proxy: | source | N | LIST | WATCH | WATCH after LIST | WATCH resumed | watch frames | ERROR frames | KiB to browser | KiB from API server | active watches at end | session checks | |---|---|---|---|---|---|---|---|---|---|---|---| -| native | 1 | 4 | 4 | 4 | 0 | 214 | 0 | 230 | 230 | 2 | 8 | -| native | 10 | 40 | 40 | 40 | 0 | 2131 (2130–2140) | 0 | 2293 (2292–2302) | 2293 (2292–2302) | 20 | 80 | +| native | 1 | 0 | 4 | 0 | 4 | 214 | 0 | 211 | 211 | 2 | 4 | +| native | 10 | 0 | 40 | 0 | 40 | 2140 | 0 | 2112 | 2112 | 20 | 40 | Host side, gateway routes: | source | N | upstream watches opened | active upstream at end | cycles | consumer resyncs | events emitted | suppressed | shared subscriptions | authorizer calls | of which timed | KiB SSE | KiB from API server | session checks | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| full | 1 | 4 | 2 | 4 | 0 | 244 | 0 | 0 | 10 | 6 | 110 | 232 | 4 | -| spec | 1 | 4 | 2 | 4 | 0 | 45 | 199 | 0 | 10 | 6 | 15.2 | 232 | 4 | -| full-shared | 1 | 4 | 2 | 4 | 0 | 244 | 0 | 4 | 10 | 6 | 110 | 232 | 4 | -| spec-shared | 1 | 4 | 2 | 4 | 0 | 45 | 199 | 4 | 10 | 6 | 15.2 | 232 | 4 | -| full | 10 | 40 | 20 | 40 | 0 | 2436 (2431–2440) | 0 | 0 | 100 | 60 | 1099 (1097–1101) | 2312 (2306–2315) | 40 | -| spec | 10 | 40 | 20 | 40 | 0 | 450 | 1988 (1981–1990) | 0 | 100 | 60 | 152 | 2314 (2306–2315) | 40 | -| full-shared | 10 | 4 | 2 | 40 | 0 | 2440 (2434–2440) | 0 | 40 | 100 | 60 | 1101 (1098–1101) | 232 (231–232) | 40 | -| spec-shared | 10 | 4 | 2 | 40 | 0 | 450 | 1990 (1989–1990) | 40 | 100 | 60 | 152 | 232 (231–232) | 40 | +| full | 1 | 4 | 2 | 4 | 0 | 244 | 0 | 0 | 10 | 6 | 110 | 231 (231–232) | 4 | +| spec | 1 | 4 | 2 | 4 | 0 | 45 | 199 | 0 | 10 | 6 | 15.2 | 231 (231–232) | 4 | +| full-shared | 1 | 4 | 2 | 4 | 0 | 244 | 0 | 4 | 10 | 6 | 110 | 231 (231–232) | 4 | +| spec-shared | 1 | 4 | 2 | 4 | 0 | 45 | 199 | 4 | 10 | 6 | 15.2 | 231 (231–232) | 4 | +| full | 10 | 40 | 20 | 40 | 0 | 2438 (2437–2440) | 0 | 0 | 100 | 60 | 1100 (1100–1101) | 2315 (2314–2315) | 40 | +| spec | 10 | 40 | 20 | 40 | 0 | 450 | 1985 (1984–1990) | 0 | 100 | 60 | 152 | 2311 (2311–2315) | 40 | +| full-shared | 10 | 4 | 2 | 40 | 0 | 2440 (2430–2440) | 0 | 40 | 100 | 60 | 1101 (1097–1101) | 231 (231–232) | 40 | +| spec-shared | 10 | 4 | 2 | 40 | 0 | 450 | 1990 | 40 | 100 | 60 | 152 | 232 (231–232) | 40 | ### forced-disconnect @@ -162,75 +176,77 @@ Client side (what the subscribers consumed): | source | N | events added | modified | deleted | resets | notifications | renders | KiB down | reconnects | time-to-live ms (median) | time-to-live ms (max) | Secret values seen changing | redaction rev bumps | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| native | 1 | 66 | 80 | 0 | 12 | 146 | 92 | 129 | 12 | 13.5 (12–14.5) | 19 (18–19) | 18 | 0 | -| full | 1 | 66 | 80 | 0 | 12 | 146 | 97 (96–97) | 66.7 | 12 | 11.5 (11.5–12) | 16 (15–18) | 0 | 18 | -| spec | 1 | 66 | 42 | 0 | 12 | 108 | 58 (55–61) | 44.3 | 12 | 11 (10–13) | 17 (17–20) | 0 | 18 | -| full-shared | 1 | 66 | 80 | 0 | 12 | 146 | 93 (92–95) | 66.7 | 12 | 12 (10.5–13.5) | 17 (16–21) | 0 | 18 | -| spec-shared | 1 | 66 | 42 | 0 | 12 | 108 | 54 (54–56) | 44.3 | 12 | 11.5 (10–12.5) | 19 (16–21) | 0 | 18 | -| native | 10 | 660 | 800 | 0 | 120 | 1460 | 920 | 1295 (1295–1295) | 120 | 30 (29–36.5) | 54 (41–54) | 180 | 0 | -| full | 10 | 660 | 800 | 0 | 120 | 1460 | 932 (928–961) | 667 (667–667) | 120 | 21 (19–24) | 51 (37–54) | 0 | 180 | -| spec | 10 | 660 | 420 | 0 | 120 | 1080 | 557 (555–574) | 443 (443–444) | 120 | 20 (19.5–24) | 51 (35–60) | 0 | 180 | -| full-shared | 10 | 660 | 800 | 0 | 120 | 1460 | 928 (927–928) | 667 (667–667) | 120 | 20 (17–23) | 48 (29–48) | 0 | 180 | -| spec-shared | 10 | 660 | 420 | 0 | 120 | 1080 | 547 (544–548) | 443 (443–444) | 120 | 20 (19–23) | 48 (32–59) | 0 | 180 | +| native | 1 | 0 | 80 | 0 | 0 | 80 | 80 | 72.3 | 12 | 3 | 4 (4–5) | 18 | 0 | +| full | 1 | 66 | 80 | 0 | 12 | 146 | 100 (98–103) | 66.7 | 12 | 5 (4.5–5) | 6 (6–10) | 0 | 18 | +| spec | 1 | 66 | 42 | 0 | 12 | 108 | 61 (61–63) | 44.3 | 12 | 5 (4.5–6) | 6 (6–10) | 0 | 18 | +| full-shared | 1 | 66 | 80 | 0 | 12 | 146 | 92 (92–96) | 66.7 | 12 | 5 | 6 (6–11) | 0 | 18 | +| spec-shared | 1 | 66 | 42 | 0 | 12 | 108 | 56 (54–58) | 44.3 | 12 | 5 | 6 (6–11) | 0 | 18 | +| native | 10 | 0 | 800 | 0 | 0 | 800 | 800 | 723 (723–723) | 120 | 21 (18–21) | 29 (25–30) | 180 | 0 | +| full | 10 | 660 | 800 | 0 | 120 | 1460 | 942 (937–948) | 667 (667–667) | 120 | 21 (20–22) | 31 (28–110) | 0 | 180 | +| spec | 10 | 660 | 420 | 0 | 120 | 1080 | 566 (558–569) | 443 (443–444) | 120 | 22 (20–23) | 29 (29–110) | 0 | 180 | +| full-shared | 10 | 660 | 800 | 0 | 120 | 1460 | 925 (923–925) | 667 (667–667) | 120 | 21 (19–22) | 28 (27–28) | 0 | 180 | +| spec-shared | 10 | 660 | 420 | 0 | 120 | 1080 | 546 (544–546) | 443 (443–444) | 120 | 22 (20–22) | 29 (28–110) | 0 | 180 | Host side, native proxy: | source | N | LIST | WATCH | WATCH after LIST | WATCH resumed | watch frames | ERROR frames | KiB to browser | KiB from API server | active watches at end | session checks | |---|---|---|---|---|---|---|---|---|---|---|---| -| native | 1 | 12 | 12 | 12 | 0 | 80 | 0 | 129 | 129 | 2 | 24 | -| native | 10 | 120 | 120 | 120 | 0 | 800 | 0 | 1295 (1295–1295) | 1295 (1295–1295) | 20 | 240 | +| native | 1 | 0 | 12 | 0 | 12 | 80 | 0 | 72.3 | 72.3 | 2 | 12 | +| native | 10 | 0 | 120 | 0 | 120 | 800 | 0 | 723 (723–723) | 723 (723–723) | 20 | 120 | Host side, gateway routes: | source | N | upstream watches opened | active upstream at end | cycles | consumer resyncs | events emitted | suppressed | shared subscriptions | authorizer calls | of which timed | KiB SSE | KiB from API server | session checks | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| full | 1 | 12 | 2 | 12 | 0 | 170 | 0 | 0 | 12 | 0 | 66.7 | 133 (133–134) | 12 | -| spec | 1 | 12 | 2 | 12 | 0 | 132 | 38 | 0 | 12 | 0 | 44.3 | 133 (133–134) | 12 | -| full-shared | 1 | 12 | 2 | 12 | 0 | 170 | 0 | 12 | 12 | 0 | 66.7 | 133 (133–134) | 12 | -| spec-shared | 1 | 12 | 2 | 12 | 0 | 132 | 38 | 12 | 12 | 0 | 44.3 | 133 (133–134) | 12 | -| full | 10 | 120 | 20 | 120 | 0 | 1700 | 0 | 0 | 120 | 0 | 667 (667–667) | 1333 (1332–1334) | 120 | -| spec | 10 | 120 | 20 | 120 | 0 | 1320 | 380 | 0 | 120 | 0 | 443 (443–444) | 1333 (1332–1334) | 120 | -| full-shared | 10 | 12 | 2 | 120 | 0 | 1700 | 0 | 120 | 120 | 0 | 667 (667–667) | 133 (133–133) | 120 | -| spec-shared | 10 | 12 | 2 | 120 | 0 | 1320 | 380 | 120 | 120 | 0 | 443 (443–444) | 133 (133–133) | 120 | +| full | 1 | 12 | 2 | 12 | 0 | 170 | 0 | 0 | 12 | 0 | 66.7 | 134 (133–134) | 12 | +| spec | 1 | 12 | 2 | 12 | 0 | 132 | 38 | 0 | 12 | 0 | 44.3 | 134 (133–134) | 12 | +| full-shared | 1 | 12 | 2 | 12 | 0 | 170 | 0 | 12 | 12 | 0 | 66.7 | 134 (133–134) | 12 | +| spec-shared | 1 | 12 | 2 | 12 | 0 | 132 | 38 | 12 | 12 | 0 | 44.3 | 134 (133–134) | 12 | +| full | 10 | 120 | 20 | 120 | 0 | 1700 | 0 | 0 | 120 | 0 | 667 (667–667) | 1335 (1334–1336) | 120 | +| spec | 10 | 120 | 20 | 120 | 0 | 1320 | 380 | 0 | 120 | 0 | 443 (443–444) | 1335 (1334–1336) | 120 | +| full-shared | 10 | 12 | 2 | 120 | 0 | 1700 | 0 | 120 | 120 | 0 | 667 (667–667) | 134 (133–134) | 120 | +| spec-shared | 10 | 12 | 2 | 120 | 0 | 1320 | 380 | 120 | 120 | 0 | 443 (443–444) | 134 (133–134) | 120 | ## Reading the numbers Everything here restates the tables; nothing is extrapolated beyond these objects, rates and subscriber counts. -- **Events.** Native and full delivered the same number of events in every workload (churn, N = 1: - 214 `modified` each). Spec delivered 15: the 199 status-only writes were suppressed - (`suppressed` = 199) and only the 9 spec writes and 6 Secret rotations arrived. Under burst, spec - delivered 34 of 60 updates (the 26 status writes suppressed). -- **Bytes to the browser.** Churn, N = 10: native 2293 KiB, full 1099 KiB, spec 152 KiB. Quiet, where - only the re-snapshots after the two forced reconnects travel: native 190 KiB, full 103 KiB, spec 93 KiB. - The full and spec views remove `managedFields`, Secret values and (spec) status, which native carries. -- **Upstream watches and API-server bytes.** The unshared routes open one upstream watch per stream per - snapshot cycle: 40 at N = 10 in quiet (10 subscribers × 2 collections × 2 reconnects). The shared - routes opened 4 at both N = 1 and N = 10 — every subscriber left at the same forced disconnect, so the - shared watch closed and reopened once per collection per reconnect. Under churn at N = 10 the - unshared routes read 2312 KiB from the API server and the shared routes 232 KiB. The native proxy - reads exactly what it forwards: 2293 KiB, with one LIST and one WATCH per collection per subscriber - per reconnect. -- **Reconnects (BEFORE, native).** With this library every native reconnect re-LISTs: `LIST` = - `WATCH` = `WATCH after LIST` and `WATCH resumed` = 0 in every workload, each followed by a `reset` - and an `added` per object. The gateway connector also requests a fresh snapshot on every reconnect, - so the gateway sources re-snapshot just as often; the difference is what each snapshot costs - upstream and on the wire. Median time-to-live after a forced disconnect, with no backoff and all - five sources reconnecting at once: 5–14 ms at N = 1 for every source; at N = 10, 28–46 ms for native - and 16–34 ms for the gateway sources. +- **Events.** Native and full delivered the same updates in every workload (churn, N = 1: 214 + `modified` each). Spec delivered 15: the 199 status-only writes were suppressed (`suppressed` = 199) + and only the 9 spec writes and 6 Secret rotations arrived. Under burst, spec delivered 34 of 60 + updates (the 26 status writes suppressed). At N = 10 full delivered 2138 rather than 2140 in one + repetition: an update made while a stream was reconnecting arrives inside the next snapshot. +- **Bytes to the browser.** Churn, N = 10: native 2112 KiB, full 1100 KiB, spec 152 KiB. Quiet, where + only reconnects move anything: native 0 KiB (its watches resume), full 103 KiB and spec 93 KiB (each + reconnect is a fresh snapshot). The full and spec views remove `managedFields`, Secret values and + (spec) status, which native carries. +- **Upstream watches and API-server bytes.** The unshared gateway routes open one upstream watch per + stream per snapshot cycle: 40 at N = 10 in quiet (10 subscribers × 2 collections × 2 reconnects). The + shared routes opened 4 at both N = 1 and N = 10 — every subscriber left at the same forced + disconnect, so the shared watch closed and reopened once per collection per reconnect. Under churn at + N = 10 the unshared routes read about 2311–2315 KiB from the API server and the shared routes 231 KiB. + The native proxy reads exactly what it forwards (2112 KiB) and opened 40 WATCHes and no LIST: one + resumed watch per collection per subscriber per reconnect. +- **Reconnects.** Native reconnects resumed in every workload (`LIST` = 0, `WATCH resumed` = `WATCH`, + no `reset`). The gateway connector requests a fresh snapshot on every reconnect by design (v1 has no + downstream replay), so the gateway sources re-snapshot every time; the shared routes make that + cheaper upstream, not downstream. Median time-to-live after a forced disconnect, with no backoff and + all five sources reconnecting at once: 3–8 ms at N = 1, where native was the lowest in every + workload (3–6.5 ms); at N = 10, 21–42.5 ms, with no source consistently fastest — 100 connections + reconnect together, so contention dominates. - **Authorization work.** The gateway's Authorizer runs per subscriber, shared or not: 100 calls at N = 10 in quiet, burst and churn on every route, 60 of them timed rechecks (5 s interval). In - forced-disconnect no stream stayed live for 5 s, so there were no timed rechecks — only the 120 - cycle checks. The host's session check runs once per native request (2 per collection per - reconnect) and once per gateway stream request (1). + forced-disconnect no stream stayed live for 5 s, so there were no timed rechecks — only the cycle + checks. The host's session check runs once per native request (one per resumed WATCH) and once per + gateway stream request. - **Secret rotations.** Under churn each native subscriber saw the 6 rotated Secret values change; each - full and spec subscriber saw 6 redaction revision bumps and never a value. Every projected store - passed the "no Secret values" part of the correctness gate. + full and spec subscriber saw 6 redaction revision bumps and never a value, and held the version of + each Secret's final rotation at the end of every run. ## Native AFTER: resumed watches -Measured 2026-10-05 14:29–14:46 UTC on the same machine and cluster, with +Measured 2026-10-05 15:47–16:04 UTC on the same machine and cluster, with ```bash task compare-native-baseline BASELINE_REF=d259394 @@ -238,25 +254,26 @@ task compare-native-baseline BASELINE_REF=d259394 which built `d259394`'s library (re-list on every reconnect) in a scratch directory, measured the native source with it, then with this branch's library (`packages/krm-stream` as in the commit -"feat(client): resume native watches from a consumed checkpoint"; the run's label `a391faa` is a -pre-rebase SHA of the same source), using identical defaults: 4 workloads × N 1 and 10 × 3 -repetitions × 20 s, 2 forced reconnects per workload (12 in forced-disconnect), connector -`retryDelayMs` 0. Only the native source ran in these two passes, so time-to-live is not comparable -with the five-source tables above. All 48 runs passed the correctness gate: every store held each -object's exact resourceVersion, spec, status and Secret data after the workload, with resumed watches -and no re-snapshot. Cells are medians over 3 repetitions, summed over the N subscribers; every count -except time-to-live was identical across repetitions. +"feat(client): resume native watches from a consumed checkpoint"), using identical defaults: 4 +workloads × N 1 and 10 × 3 repetitions × 20 s, 2 forced reconnects per workload (12 in +forced-disconnect), connector `retryDelayMs` 0. Only the native source ran in these two passes, so +time-to-live is not comparable with the five-source tables above. All 48 runs passed both gates: +every workload did all it planned, and every store held each object's exact resourceVersion, spec, +status and Secret data afterwards, with resumed watches and no re-snapshot. Cells are medians over 3 +repetitions, summed over the N subscribers; every count except time-to-live was identical across +repetitions, and identical to an earlier pass on this date (14:29–14:46 UTC) made before the +workload and rotation gates existed. | workload | N | LIST before → after | WATCH resumed | resets | events added | notifications | KiB down (= KiB from API server) | time-to-live median ms | |---|---|---|---|---|---|---|---|---| -| quiet | 1 | 4 → 0 | 0 → 4 | 4 → 0 | 22 → 0 | 22 → 0 | 19 → 0 | 7.5 → 3.5 | -| quiet | 10 | 40 → 0 | 0 → 40 | 40 → 0 | 220 → 0 | 220 → 0 | 190 → 0 | 34.5 → 8 | -| burst | 1 | 4 → 0 | 0 → 4 | 4 → 0 | 22 → 0 | 82 → 60 | 78.8 → 59.8 | 8.5 → 2.5 | -| burst | 10 | 40 → 0 | 0 → 40 | 40 → 0 | 220 → 0 | 820 → 600 | 789 → 598 | 22.5 → 6.5 | -| churn | 1 | 4 → 0 | 0 → 4 | 4 → 0 | 22 → 0 | 236 → 214 | 230 → 211 | 6 → 2.5 | -| churn | 10 | 40 → 0 | 0 → 40 | 40 → 0 | 220 → 0 | 2360 → 2140 | 2302 → 2113 | 16 → 5 | -| forced-disconnect | 1 | 12 → 0 | 0 → 12 | 12 → 0 | 66 → 0 | 146 → 80 | 129 → 72.3 | 8 → 3 | -| forced-disconnect | 10 | 120 → 0 | 0 → 120 | 120 → 0 | 660 → 0 | 1460 → 800 | 1295 → 723 | 14 → 5 | +| quiet | 1 | 4 → 0 | 0 → 4 | 4 → 0 | 22 → 0 | 22 → 0 | 19 → 0 | 6 → 3 | +| quiet | 10 | 40 → 0 | 0 → 40 | 40 → 0 | 220 → 0 | 220 → 0 | 190 → 0 | 20 → 7.5 | +| burst | 1 | 4 → 0 | 0 → 4 | 4 → 0 | 22 → 0 | 82 → 60 | 78.8 → 59.8 | 6.5 → 3 | +| burst | 10 | 40 → 0 | 0 → 40 | 40 → 0 | 220 → 0 | 820 → 600 | 789 → 598 | 16.5 → 6.5 | +| churn | 1 | 4 → 0 | 0 → 4 | 4 → 0 | 22 → 0 | 236 → 214 | 230 → 211 | 5.5 → 2.5 | +| churn | 10 | 40 → 0 | 0 → 40 | 40 → 0 | 220 → 0 | 2360 → 2140 | 2302 → 2113 | 13 → 6 | +| forced-disconnect | 1 | 12 → 0 | 0 → 12 | 12 → 0 | 66 → 0 | 146 → 80 | 129 → 72.3 | 7.5 → 3 | +| forced-disconnect | 10 | 120 → 0 | 0 → 120 | 120 → 0 | 660 → 0 | 1460 → 800 | 1295 → 723 | 12 → 5 | Unchanged in every row: the number of WATCH requests and reconnects, `modified` events and watch frames (every write still arrives once per subscriber), Secret values seen changing, and active @@ -296,6 +313,14 @@ disconnects; larger collections; and backoff, since `retryDelayMs` was 0. ## Smoke checks of the page +**Automated since.** These manual checks missed a silent overwrite a reviewer then reproduced: the page +skipped re-rendering a focused input, so after another writer changed a focused, unedited field, the +next keystroke saved the stale text over the newer value with no conflict. The page now follows the +draft in a focused input too, keeping its caret ([fields.ts](../../examples/comparison/fields.ts)). +`task compare-browser` runs that case in Chromium against the real host on both entry points, for +native, full and spec; it passed 6 of 6 and failed 6 of 6 with the previous render rule. +`task test-compare` covers the same rule against the real store without a browser. + Run 2026-10-05 against `task compare` (the host on `https://127.0.0.1:8111/`, HTTP/2, self-signed), driven headlessly by a throwaway Playwright script in Chromium (the Playwright already installed in `examples/vanilla-browser`; no spec was added to that suite). **18 of 18 checks passed on each entry @@ -326,6 +351,8 @@ origin. The host's TLS listener exists for that reason. ```bash task cluster-up # once task compare-measure # this file's matrix: about 9 minutes +task compare-native-baseline BASELINE_REF=d259394 # the native AFTER section: about 17 minutes +task compare-browser # the page's focused-field regression, both entry points task compare # the page: https://127.0.0.1:8111/ ``` diff --git a/docs/field-reports/third-our-identity.md b/docs/field-reports/third-our-identity.md index 779954e..38571cc 100644 --- a/docs/field-reports/third-our-identity.md +++ b/docs/field-reports/third-our-identity.md @@ -213,8 +213,9 @@ unshared and shared, behind one session check; a refused session ends with a ter never falls back to native. The page edits every source with the existing editors and keep-local recipe (later typing during a save, conflicts, recovery) and shows Secret disclosure, status-only suppression and hidden rotations. `measure.ts` runs identical quiet, burst, churn and -forced-disconnect schedules with the real connectors and stores, gates every run on convergence with -the cluster, and reports LIST/WATCH requests, snapshots, upstream watches, downstream bytes/events, +forced-disconnect schedules with the real connectors and stores, rejects any run whose workload did +not do everything it planned or whose stores did not converge with the cluster (projected Secret +stores must hold each final rotation's version), and reports LIST/WATCH requests, snapshots, upstream watches, downstream bytes/events, store notifications, coalesced renders and authorizer/session checks. Every number in the report is real-API evidence from one disposable cluster; API-server-side RBAC on native requests, browser paint, memory/CPU and save progress under churn are not measured. A general dashboard or benchmark diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index 05744e0..d09c5e4 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -33,7 +33,7 @@ remain separate delivery steps. | Native viewer (slice 1) | `connectNativeWatch` and `nativeCollectionURL`: unpaginated LIST, WATCH from the collection RV, fresh LIST on reconnect (superseded by resumption below), native 410 recovery and terminal refusals on a lifecycle shared with the gateway connector; fake-fetch, Chromium and real-API tests; minimal example | [Request](../field-reports/third-our-identity.md#slice-1-a-native-viewer), [example](../../examples/native-viewer/README.md), [client](../../packages/krm-stream/README.md#native-connections) | | Native editing (slice 2) | Machinery (`managedFields`, last-applied annotation) read-only in the store under every policy; maps holding a protected path merged key by key; `nativeObjectURL`; `gateway.ValidateNativeMergePatch` for host proxies; a native conditional editor with guarded native recovery reads; fake-fetch and real-API tests; a minimal one-ConfigMap page accepted in Chromium on both entry points | [Request](../field-reports/third-our-identity.md#slice-2-native-editing), [example](../../examples/native-editor/README.md), [saving](../saving.md#native-editing-through-a-host-proxy) | | Native watch resumption | A per-handle checkpoint advanced only after a consumed event or bookmark; ordinary reconnects resume the WATCH without LIST, reset or synced; 410, malformed input and unclassifiable errors discard it for a fresh snapshot; fake-fetch, Chromium (both entry points) and real-API resume/expiry tests | [Request](../field-reports/third-our-identity.md#native-watch-resumption), [client](../../packages/krm-stream/README.md#native-connections) | -| Comparative frontend and measurements | One host serving native, `krm-full/v1` and `krm-spec/v1`, unshared and shared, with editing on every source, refusal without native fallback, Secret disclosure, status-only suppression and hidden rotations; a driver with identical workloads, a convergence gate and request/byte/event/notification/render/authorization counters; native before/after resumption | [Example](../../examples/comparison/README.md), [results](../facts/comparison-2026-10-05.md) | +| Comparative frontend and measurements | One host serving native, `krm-full/v1` and `krm-spec/v1`, unshared and shared, with editing on every source, refusal without native fallback, Secret disclosure, status-only suppression and hidden rotations; a driver with identical workloads, workload-completion and convergence gates (including final Secret rotations) and request/byte/event/notification/render/authorization counters; native before/after resumption | [Example](../../examples/comparison/README.md), [results](../facts/comparison-2026-10-05.md) | | Watch-first documentation | README, watch/edit/save guides, native-fetch request and compact decision records; duplicate/superseded guides removed | [README](../../README.md), [current request](../field-reports/third-our-identity.md) | ## Open work and delivery order diff --git a/examples/comparison/README.md b/examples/comparison/README.md index 8435012..06e13aa 100644 --- a/examples/comparison/README.md +++ b/examples/comparison/README.md @@ -28,14 +28,15 @@ and a real `status` subresource — the definition the real-API suite installs) endpoints ([save.go](../../gateway/kube/examples/comparison/save.go)), deterministic workloads ([workload.go](../../gateway/kube/examples/comparison/workload.go)), counters ([metrics.go](../../gateway/kube/examples/comparison/metrics.go)) and - [cmd/compare](../../gateway/kube/examples/comparison/cmd/compare/main.go). Its unit test needs no - cluster: schedules, the proxy's policy and counters, and the gateway route's refusal. -- [index.html](index.html) and [page.ts](page.ts) — the page. [build.mjs](build.mjs) compiles + [cmd/compare](../../gateway/kube/examples/comparison/cmd/compare/main.go). Its unit tests need no + cluster; see [Tests](#tests). +- [index.html](index.html) and [page.ts](page.ts) — the page; [fields.ts](fields.ts) keeps its inputs + on the store's draft. [build.mjs](build.mjs) compiles `page.ts`, with the native editor, the conditional editor and the keep-local recipe it reuses, into `dist/page.js`, importing the library as `@configbutler/krm-stream` through the page's import map. -- [measure.ts](measure.ts) — the measurement driver, run by node with types stripped. - [measure.sh](measure.sh) starts the host, runs it and stops the host. -- [tsconfig.json](tsconfig.json) — typechecks both TypeScript files with the client package's +- [measure.ts](measure.ts) — the measurement driver, with its gates in [gates.ts](gates.ts), run by + node with types stripped. [measure.sh](measure.sh) starts the host, runs it and stops the host. +- [tsconfig.json](tsconfig.json) — typechecks the page, the driver and their tests with the client package's compiler: `packages/krm-stream/node_modules/.bin/tsc -p examples/comparison`. ## Run the page @@ -74,12 +75,16 @@ task compare-native-baseline BASELINE_REF= # native before/after [measure.ts](measure.ts) against this tree's built library, and writes JSON and a markdown table to `examples/comparison/results/`. For every workload, subscriber count N and repetition it opens N subscribers per source — each watches the Widgets and the Secrets, one connection and one store per -collection — waits until all are live, runs the workload through the host, and then requires every -store to converge on the cluster's state. That is the correctness gate: a native store must hold -each object's exact resourceVersion, spec, status and Secret data; a projected store must hold the -same membership, spec (and status, for full), no Secret values, and exactly the cluster's Secret keys -as redacted paths. A run that does not converge within `--settle-ms` is reported as failed and left -out of the tables. All five sources run concurrently in each run, so every source sees the same +collection — waits until all are live, runs the workload through the host, and then applies two +gates ([gates.ts](gates.ts)). The **workload gate** rejects a run whose workload reported an error or +did fewer steps of any kind than it planned: a failed write leaves the cluster unchanged and the +stores would still match it. The **correctness gate** requires every store to converge on the +cluster's state: a native store must hold each object's exact resourceVersion, spec, status and +Secret data; a projected store must hold the same membership, spec (and status, for full), no Secret +values, exactly the cluster's Secret keys as redacted paths, and — for every Secret the workload +rotated — the resourceVersion of its final successful rotation, which the host reports. The redacted +paths cannot show a missed rotation; the version can. Intermediate rotations may coalesce. A run that +fails either gate is reported as failed, left out of the tables, and makes the command exit non-zero. All five sources run concurrently in each run, so every source sees the same writes; pass `--sources` to isolate one. `compare-native-baseline` builds the library as it is at `BASELINE_REF` (default `main`) into a @@ -153,6 +158,21 @@ driver or the API server, and API-server-side watch-cache work. - The host holds the only Kubernetes credential. Its admin and metrics routes are harness controls guarded only by the viewer session: keep it on loopback. +## Tests + +- `task test-compare` (part of `task test`, no cluster): typechecks the page and the driver, and runs + [fields.test.ts](fields.test.ts) — a focused field follows another writer's change so the next + keystroke extends it, an edited field keeps its text and conflicts, half-typed numbers are not + replaced — and [gates.test.ts](gates.test.ts) — failed or missing workload steps reject a run, and a + projected Secret store that missed the final rotation fails convergence. +- `task compare-browser` (needs the spike cluster): Chromium against the real host on both entry + points. Another writer changes the focused, unedited `spec.note` of each source; the input must + show the new value while focused, and the next keystroke and Save must write the new value plus the + keystroke. With the previous render rule it saved the stale text over the other writer, silently. +- `go test ./examples/comparison/` (part of `task test-kube`): schedules, the proxy's policy and + counters, the gateway route's refusal, and a workload that reports failed steps and the last + successful rotation. + ## Smoke checks Recorded in [docs/facts/comparison-2026-10-05.md](../../docs/facts/comparison-2026-10-05.md): the page diff --git a/examples/comparison/fields.test.ts b/examples/comparison/fields.test.ts new file mode 100644 index 0000000..38658da --- /dev/null +++ b/examples/comparison/fields.test.ts @@ -0,0 +1,97 @@ +// The page's form fields against a real store, without a DOM. Run by `task test-compare`. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { LiveResourceStore } from "../../packages/krm-stream/src/index.ts"; +import { draftText, editField, type FieldInput, showDraft } from "./fields.ts"; + +/** An input with a caret, as much of one as the page uses. */ +class Input implements FieldInput { + value = ""; + selectionStart: number | null = 0; + selectionEnd: number | null = 0; + readonly dataset: Record = {}; + setSelectionRange(start: number, end: number) { + this.selectionStart = start; + this.selectionEnd = end; + } + /** Type at the end, as a person whose caret is there would. */ + type(text: string) { + this.value += text; + this.selectionStart = this.selectionEnd = this.value.length; + } +} + +const widget = (rv: string, note: string, replicas = 1) => ({ + apiVersion: "e2e.krm-stream.configbutler.io/v1", + kind: "Widget", + metadata: { uid: "uid-w", name: "w-00", resourceVersion: rv }, + spec: { note, replicas }, +}); +const note = ["spec", "note"]; + +function render(store: LiveResourceStore, input: Input, path = note) { + showDraft(input, "uid-w", draftText(store, "uid-w", path), true); +} + +test("a focused field follows another writer's change, so typing extends the newer value", () => { + const store = new LiveResourceStore(); + store.applyServerEvent(widget("1", "initial")); + const input = new Input(); + render(store, input); + input.setSelectionRange(7, 7); // focused, caret at the end of "initial" + + // Another writer changes the field the person has focused but not edited. + store.applyServerEvent(widget("2", "external-new-value")); + render(store, input); + assert.equal(input.value, "external-new-value"); + assert.deepEqual([input.selectionStart, input.selectionEnd], [7, 7], "the caret stays where it was"); + + // The next keystroke extends what the store holds. Before the fix the input still said "initial" + // and this saved "initial!" at version 2, silently overwriting the other writer. + input.type("!"); + editField(store, "uid-w", note, input); + assert.deepEqual(store.captureSave("uid-w"), { + uid: "uid-w", + resourceVersion: "2", + patch: { spec: { note: "external-new-value!" } }, + }); + assert.deepEqual(store.conflicts("uid-w"), []); +}); + +test("a focused field the person edited keeps their text, and the change becomes a conflict", () => { + const store = new LiveResourceStore(); + store.applyServerEvent(widget("1", "initial")); + const input = new Input(); + render(store, input); + input.type(" mine"); + editField(store, "uid-w", note, input); + + store.applyServerEvent(widget("2", "external-new-value")); + render(store, input); + assert.equal(input.value, "initial mine"); + assert.deepEqual( + store.conflicts("uid-w").map((c) => c.path), + [note], + ); +}); + +test("text the store parsed into something else is not replaced while the draft stays put", () => { + const store = new LiveResourceStore(); + store.applyServerEvent(widget("1", "n", 3)); + const replicas = ["spec", "replicas"]; + const input = new Input(); + render(store, input, replicas); + assert.equal(input.value, "3"); + + // Clearing the field to type another number: the store holds 0, the input stays empty. + input.value = ""; + editField(store, "uid-w", replicas, input, Number); + render(store, input, replicas); + assert.equal(input.value, ""); + + // An unrelated server change to another field does not disturb it either. + store.applyServerEvent(widget("2", "other", 3)); + render(store, input, replicas); + assert.equal(input.value, ""); +}); diff --git a/examples/comparison/fields.ts b/examples/comparison/fields.ts new file mode 100644 index 0000000..b4876ea --- /dev/null +++ b/examples/comparison/fields.ts @@ -0,0 +1,61 @@ +// How the page's form fields follow the store's draft. Kept apart from page.ts so a node test can run +// it against a real LiveResourceStore with no DOM. +// +// The draft, not the input, is what Save captures. So an input must show the draft whenever the +// draft moves under it — a watch event merged into a field the person has not edited moves it, even +// while that field has focus. Skipping a focused input leaves stale text on screen, and the next +// keystroke writes that stale text plus the key over the newer server value: a silent overwrite with +// no conflict, because the store never saw the person edit the old value. A focused input is +// therefore updated too, keeping its caret. +// +// What must NOT be overwritten is text the store could not take: a half-typed number the store holds +// as something else. So an input is rewritten only when the draft has moved since the input last +// showed it or wrote it, never merely because the two differ. + +import type { LiveResourceStore, Path } from "../../packages/krm-stream/src/index.ts"; + +/** The part of an HTMLInputElement this needs. */ +export interface FieldInput { + value: string; + readonly selectionStart: number | null; + readonly selectionEnd: number | null; + setSelectionRange(start: number, end: number): void; + readonly dataset: Record; +} + +/** The draft's value at path, as an input shows it. */ +export function draftText(store: LiveResourceStore, uid: string, path: Path): string { + let value: unknown = store.draft(uid); + for (const segment of path) value = (value as Record | undefined)?.[segment]; + return value === undefined || value === null ? "" : String(value); +} + +/** What an input last showed or wrote: the object's UID and the text, so selecting another object + * counts as the draft moving. */ +const shown = (uid: string, text: string) => `${uid}\n${text}`; + +/** Show uid's draft text in input if it moved since the input last showed or wrote it. A focused + * input keeps its caret, clamped to the new text. */ +export function showDraft(input: FieldInput, uid: string, text: string, focused: boolean): void { + if (input.dataset.shown === shown(uid, text)) return; + input.dataset.shown = shown(uid, text); + if (input.value === text) return; + const { selectionStart: start, selectionEnd: end } = input; + input.value = text; + if (focused && start !== null && end !== null) { + input.setSelectionRange(Math.min(start, text.length), Math.min(end, text.length)); + } +} + +/** The person typed in input: write it to the draft, and remember what the draft now holds, so the + * next render does not replace text the store parsed into something else. */ +export function editField( + store: LiveResourceStore, + uid: string, + path: Path, + input: FieldInput, + parse: (text: string) => unknown = (text) => text, +): void { + store.setValue(uid, path, parse(input.value)); + input.dataset.shown = shown(uid, draftText(store, uid, path)); +} diff --git a/examples/comparison/gates.test.ts b/examples/comparison/gates.test.ts new file mode 100644 index 0000000..0d1f263 --- /dev/null +++ b/examples/comparison/gates.test.ts @@ -0,0 +1,66 @@ +// The measurement's gates, without a host or a cluster. Run by `task test-compare`. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { LiveResourceStore } from "../../packages/krm-stream/src/index.ts"; +import { divergence, type StateObject, workloadFailure } from "./gates.ts"; + +test("a workload that did not do everything it planned is rejected", () => { + const planned = { spec: 2, secret: 1, disconnect: 2 }; + assert.equal(workloadFailure({ planned, done: { ...planned } }), undefined); + // Every PATCH failed: the stores still match the unchanged cluster, so only this gate catches it. + assert.match( + workloadFailure({ planned, done: { disconnect: 2 }, errors: ["patch w-00: forbidden", "patch s-0: forbidden"] }) ?? + "", + /2 workload step\(s\) failed: patch w-00: forbidden \(and 1 more\)/, + ); + // A step that neither succeeded nor reported an error still leaves the run incomplete. + assert.match(workloadFailure({ planned, done: { spec: 2, disconnect: 2 } }) ?? "", /0 of 1 planned secret/); + assert.match(workloadFailure(undefined) ?? "", /no workload outcome/); +}); + +const secret = (rv: string): StateObject => ({ + kind: "Secret", + uid: "uid-s0", + name: "s-0", + resourceVersion: rv, + data: { token: btoa(`token@${rv}`) }, +}); +/** A projected Secret as full and spec deliver it: no data, the key as a redacted path. */ +const projected = (rv: string) => ({ + apiVersion: "v1", + kind: "Secret", + metadata: { uid: "uid-s0", name: "s-0", resourceVersion: rv }, + type: "Opaque", +}); + +test("a projected Secret store must hold the final successful rotation, which may coalesce", () => { + const store = new LiveResourceStore(); + store.applyServerEvent(projected("10"), { redacted: [{ path: "/data/token", rev: 1 }] }); + const feed = { source: "spec" as const, resource: "secrets" as const, store }; + const cluster = [secret("30")]; + + // Without the rotation evidence the stale store passes: same key, value withheld. + assert.equal(divergence(feed, cluster), undefined); + // With it, the store that missed the rotation is caught. + assert.match(divergence(feed, cluster, { "s-0": "30" }) ?? "", /holds version 10, not its final rotation 30/); + + // The rotation at 20 coalesced away: the store receives only the final one, at 30. + store.applyServerEvent(projected("30"), { redacted: [{ path: "/data/token", rev: 2 }] }); + assert.equal(divergence(feed, cluster, { "s-0": "30" }), undefined); + // A Secret the workload never rotated has no requirement beyond its paths. + assert.equal(divergence(feed, cluster, {}), undefined); +}); + +test("a native Secret store is still held to the cluster's exact value and version", () => { + const store = new LiveResourceStore(); + const object = secret("30"); + store.applyServerEvent({ + apiVersion: "v1", + kind: "Secret", + metadata: { uid: object.uid, name: object.name, resourceVersion: "20" }, + data: { token: btoa("token@20") }, + }); + const feed = { source: "native" as const, resource: "secrets" as const, store }; + assert.match(divergence(feed, [object], { "s-0": "30" }) ?? "", /data differs/); +}); diff --git a/examples/comparison/gates.ts b/examples/comparison/gates.ts new file mode 100644 index 0000000..0b06e94 --- /dev/null +++ b/examples/comparison/gates.ts @@ -0,0 +1,111 @@ +// The measurement's two gates, kept apart from measure.ts so they can be tested without a host or a +// cluster (gates.test.ts). A run is measured only if both pass: +// +// workloadFailure the workload did everything it planned. A failed write leaves the cluster +// unchanged, the stores still match it, and without this gate a run that measured +// nothing would be reported as a successful measurement. +// divergence every store holds what the cluster holds — and every projected Secret store holds +// the version of that Secret's final successful rotation. A projection withholds +// the value and keeps the key, so a store that missed a rotation still shows +// exactly the cluster's redacted paths; only the version tells them apart. +// +// deepEqual comes from the client's source rather than the library under measurement: it is the +// gate's own comparison, not something being measured. + +import { deepEqual } from "../../packages/krm-stream/src/deep.ts"; +import type { LiveResourceStore } from "../../packages/krm-stream/src/index.ts"; + +export type Source = "native" | "full" | "spec" | "full-shared" | "spec-shared"; + +/** One object as the cluster holds it: the host's /admin/state. */ +export interface StateObject { + kind: string; + uid: string; + name: string; + resourceVersion: string; + spec?: unknown; + status?: unknown; + data?: Record; +} + +/** What the host's /admin/workload reports a run did. */ +export interface WorkloadOutcome { + planned?: Record; + done?: Record; + errors?: string[]; + /** Per Secret name, the resourceVersion of its last successful rotation. */ + secretRotations?: Record; +} + +/** Why the workload did not do what it planned, or undefined when it did all of it. */ +export function workloadFailure(outcome: WorkloadOutcome | undefined): string | undefined { + if (outcome?.planned === undefined || outcome.done === undefined) return "the host reported no workload outcome"; + if (outcome.errors?.length) { + const more = outcome.errors.length > 1 ? ` (and ${outcome.errors.length - 1} more)` : ""; + return `${outcome.errors.length} workload step(s) failed: ${outcome.errors[0]}${more}`; + } + for (const [kind, planned] of Object.entries(outcome.planned)) { + const done = outcome.done[kind] ?? 0; + if (done !== planned) return `the workload did ${done} of ${planned} planned ${kind} step(s)`; + } + return undefined; +} + +/** The store methods the gate reads. */ +export type StoreView = Pick; + +/** One subscriber's store for one collection. */ +export interface Feed { + source: Source; + resource: "widgets" | "secrets"; + store: StoreView; +} + +/** Why a feed's store does not hold what the cluster holds, or undefined when it does. `rotations` + * is the workload's secretRotations. */ +export function divergence( + feed: Feed, + state: StateObject[], + rotations: Record = {}, +): string | undefined { + const kind = feed.resource === "widgets" ? "Widget" : "Secret"; + const want = state.filter((o) => o.kind === kind); + const ids = feed.store.ids(); + if (ids.length !== want.length || !want.every((o) => ids.includes(o.uid))) { + return `${kind} membership: store ${ids.length}, cluster ${want.length}`; + } + const projected = feed.source !== "native"; + const specOnly = feed.source.startsWith("spec"); + for (const o of want) { + const got = feed.store.server(o.uid); + if (kind === "Widget") { + if (!deepEqual(got.spec, o.spec)) return `${o.name}: spec differs`; + if (specOnly ? got.status !== undefined : !deepEqual(got.status, o.status)) { + return `${o.name}: status ${specOnly ? "present in a spec projection" : "differs"}`; + } + } else if (projected) { + // Never a value; exactly the cluster's keys, named as redacted paths. + if (got.data !== undefined) return `${o.name}: a projected Secret carries values`; + const paths = feed.store + .redactions(o.uid) + .map((r) => r.path.join("/")) + .sort(); + const keys = Object.keys(o.data ?? {}) + .map((k) => `data/${k}`) + .sort(); + if (!deepEqual(paths, keys)) return `${o.name}: redacted paths ${paths} != ${keys}`; + // The paths cannot show a rotation; the version can. Intermediate rotations may coalesce (a + // reconnect's snapshot carries only the latest), but the final one must have arrived. + const rotated = rotations[o.name]; + if (rotated !== undefined && got.metadata.resourceVersion !== rotated) { + return `${o.name}: holds version ${got.metadata.resourceVersion}, not its final rotation ${rotated}`; + } + } else if (!deepEqual(got.data, o.data)) { + return `${o.name}: data differs`; + } + // A native store holds the cluster's exact version. A projection may legitimately hold an older + // one: a suppressed update advances the cluster without an event. + if (!projected && got.metadata.resourceVersion !== o.resourceVersion) return `${o.name}: resourceVersion differs`; + } + return undefined; +} diff --git a/examples/comparison/measure.ts b/examples/comparison/measure.ts index 3d982ec..b722569 100644 --- a/examples/comparison/measure.ts +++ b/examples/comparison/measure.ts @@ -4,8 +4,9 @@ // // For every workload, subscriber count and repetition it opens N subscribers per source — each one a // page watching the Widgets and the Secrets, one store per collection — waits until every one is -// live, triggers the workload through the host, waits for every store to converge on the cluster's -// state (the correctness gate: a run whose stores diverge is reported as failed, not measured), +// live, triggers the workload through the host, rejects the run unless the workload did everything it +// planned, waits for every store to converge on the cluster's state (the correctness gate in +// gates.ts: a run whose stores diverge is reported as failed, not measured), // collects the host's counters and closes everything. It writes JSON and a markdown table. // // node examples/comparison/measure.ts [options] @@ -32,6 +33,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { divergence, type Source, type StateObject, type WorkloadOutcome, workloadFailure } from "./gates.ts"; type Lib = typeof import("../../packages/krm-stream/src/index.ts"); type Store = InstanceType; @@ -39,8 +41,7 @@ type Handle = ReturnType; type StateEvent = Parameters[1]>[0]; const repo = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); -const SOURCES = ["native", "full", "spec", "full-shared", "spec-shared"] as const; -type Source = (typeof SOURCES)[number]; +const SOURCES: readonly Source[] = ["native", "full", "spec", "full-shared", "spec-shared"]; // ------------------------------------------------------------------------------- arguments -- @@ -88,16 +89,6 @@ interface Metrics { counters: Record; gauges: Record; } -interface StateObject { - kind: string; - uid: string; - name: string; - resourceVersion: string; - spec?: unknown; - status?: unknown; - data?: Record; -} - async function host(path: string, init: RequestInit = {}, session = opts.session): Promise { const res = await fetch(`${opts.host}${path}`, { ...init, @@ -259,46 +250,6 @@ class Feed { } } -// ------------------------------------------------------------------------- correctness gate -- - -/** Why a source's store does not hold what the cluster holds, or undefined when it does. */ -function divergence(feed: Feed, state: StateObject[]): string | undefined { - const kind = feed.resource === "widgets" ? "Widget" : "Secret"; - const want = state.filter((o) => o.kind === kind); - const ids = feed.store.ids(); - if (ids.length !== want.length || !want.every((o) => ids.includes(o.uid))) { - return `${kind} membership: store ${ids.length}, cluster ${want.length}`; - } - const projected = feed.source !== "native"; - const specOnly = feed.source.startsWith("spec"); - for (const o of want) { - const got = feed.store.server(o.uid); - if (kind === "Widget") { - if (!lib.deepEqual(got.spec, o.spec)) return `${o.name}: spec differs`; - if (specOnly ? got.status !== undefined : !lib.deepEqual(got.status, o.status)) { - return `${o.name}: status ${specOnly ? "present in a spec projection" : "differs"}`; - } - } else if (projected) { - // Never a value; exactly the cluster's keys, named as redacted paths. - if (got.data !== undefined) return `${o.name}: a projected Secret carries values`; - const paths = feed.store - .redactions(o.uid) - .map((r) => r.path.join("/")) - .sort(); - const keys = Object.keys(o.data ?? {}) - .map((k) => `data/${k}`) - .sort(); - if (!lib.deepEqual(paths, keys)) return `${o.name}: redacted paths ${paths} != ${keys}`; - } else if (!lib.deepEqual(got.data, o.data)) { - return `${o.name}: data differs`; - } - // A native store holds the cluster's exact version. A projection may legitimately hold an older - // one: a suppressed update advances the cluster without an event. - if (!projected && got.metadata.resourceVersion !== o.resourceVersion) return `${o.name}: resourceVersion differs`; - } - return undefined; -} - // -------------------------------------------------------------------------------- one run -- interface SourceResult { @@ -322,7 +273,7 @@ interface RunResult { ok: boolean; failure?: string; settleMs: number; - workloadResult: unknown; + workloadResult: WorkloadOutcome | undefined; sessions: Record; sources: Partial>; } @@ -414,7 +365,7 @@ async function runOnce(workload: string, n: number, rep: number): Promise("/metrics"); const before = new Map(all.map((f) => [f, f.snapshot()])); - result.workloadResult = await host("/admin/workload", { + result.workloadResult = await host("/admin/workload", { method: "POST", body: JSON.stringify({ name: workload, @@ -424,6 +375,15 @@ async function runOnce(workload: string, n: number, rep: number): Promise divergence(f, state)).find((d) => d !== undefined), + divergence: list.map((f) => divergence(f, state, rotations)).find((d) => d !== undefined), }; } } finally { @@ -614,7 +574,7 @@ function markdown(results: Results): string { `Workloads ${opts.workloads.join(", ")}; subscribers ${opts.subscribers.join(", ")}; ${opts.reps} repetitions; ` + `duration ${opts.durationMs} ms; seed ${opts.seed}; ${opts.reconnects} forced reconnects per workload; ` + `retryDelayMs ${opts.retryDelayMs}. Cells are median (min–max) over the repetitions that passed the ` + - "correctness gate; client and host counts cover the workload phase only, summed over the N subscribers " + + "workload and correctness gates; client and host counts cover the workload phase only, summed over the N subscribers " + "(each subscriber watches the Widgets and the Secrets, one connection and one store each).", "", `Refusal check: ${results.refusal.ok ? "passed" : "FAILED"} — ${JSON.stringify(results.refusal.outcomes)}; ` + @@ -624,8 +584,8 @@ function markdown(results: Results): string { const failed = runs.filter((r) => !r.ok); out.push( failed.length === 0 - ? `All ${runs.length} runs passed the correctness gate.` - : `**${failed.length} of ${runs.length} runs FAILED the correctness gate and are excluded:** ` + + ? `All ${runs.length} runs passed the workload and correctness gates.` + : `**${failed.length} of ${runs.length} runs FAILED a gate and are excluded:** ` + failed.map((r) => `${r.workload}/N=${r.subscribers}/rep ${r.rep}: ${r.failure}`).join("; "), "", ); diff --git a/examples/comparison/page.ts b/examples/comparison/page.ts index f434064..5111f95 100644 --- a/examples/comparison/page.ts +++ b/examples/comparison/page.ts @@ -21,6 +21,7 @@ import { import { conditionalEditor } from "../conditional-save/editor.ts"; import { keepLocal } from "../editor-recipes/keepLocal.ts"; import { nativeEditor } from "../native-editor/editor.ts"; +import { draftText, editField, showDraft } from "./fields.ts"; interface PageConfig { namespace: string; @@ -254,14 +255,13 @@ class Column { fields.disabled = uid === undefined; if (!uid) return; const editor = this.editorFor(uid); - const draft = store.draft(uid).spec as Record; for (const [field, input] of [ ["note", $("[data-role=note]", r)], ["replicas", $("[data-role=replicas]", r)], ] as const) { - // Never overwrite what the person is typing. - if (document.activeElement !== input) input.value = String(draft[field] ?? ""); const path: Path = ["spec", field]; + // Follow the draft even while focused: a stale input would save its stale text. See fields.ts. + showDraft(input, uid, draftText(store, uid, path), document.activeElement === input); input.classList.toggle("dirty", store.isDirty(uid, path)); input.classList.toggle( "conflicted", @@ -341,8 +341,8 @@ const columns = (["native", "full", "spec"] as const).map((kind) => { $(`[data-role=${field}]`, root).addEventListener("input", (e) => { const uid = column.selected(); if (!uid || !column.widgets) return; - const value = (e.target as HTMLInputElement).value; - column.widgets.store.setValue(uid, ["spec", field], field === "replicas" ? Number(value) : value); + const input = e.target as HTMLInputElement; + editField(column.widgets.store, uid, ["spec", field], input, field === "replicas" ? Number : undefined); }); } return column; diff --git a/examples/comparison/tsconfig.json b/examples/comparison/tsconfig.json index f6c5463..1b56b40 100644 --- a/examples/comparison/tsconfig.json +++ b/examples/comparison/tsconfig.json @@ -1,8 +1,8 @@ { - // Typechecks the comparison page and the measurement driver with the client package's compiler and - // strictness. Nothing is emitted: build.mjs strips the page's types and node strips the driver's. + // Typechecks the comparison page, the measurement driver and their tests with the client package's + // compiler and strictness. Nothing is emitted: build.mjs strips the page's types and node strips the driver's. // - // packages/krm-stream/node_modules/.bin/tsc -p examples/comparison + // task test-compare "extends": "../../packages/krm-stream/tsconfig.json", "compilerOptions": { "noEmit": true, diff --git a/examples/vanilla-browser/compare.config.ts b/examples/vanilla-browser/compare.config.ts new file mode 100644 index 0000000..d0741ee --- /dev/null +++ b/examples/vanilla-browser/compare.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from "@playwright/test"; +import type { Entry } from "./tests/fixtures.ts"; + +// The comparison page (examples/comparison) in Chromium against the REAL comparison host and a real +// API server, on both entry points. Not part of `task e2e-browser`, which needs no cluster: run it with +// `task compare-browser`, which builds the host binary and passes it in COMPARE_BIN. +// +// One host, one scratch namespace and the same Widgets for every test, so the tests run one at a time. +// The host listens on 8120/8121, apart from `task compare` (8110/8111) and the browser suite (8100). + +const bin = process.env["COMPARE_BIN"]; +if (!bin) throw new Error("COMPARE_BIN must name a built comparison host; run `task compare-browser`"); + +export default defineConfig<{ entry: Entry }>({ + testDir: "./compare", + workers: 1, + fullyParallel: false, + reporter: [["list"]], + use: { + // HTTP/2 over TLS: the page holds six streams, which would take every HTTP/1.1 connection a + // browser allows one origin. The certificate is the host's own, self-signed at startup. + baseURL: "https://127.0.0.1:8121", + ignoreHTTPSErrors: true, + trace: "retain-on-failure", + }, + projects: [ + { name: "chromium", use: { browserName: "chromium", entry: "index" } }, + { name: "chromium-bundle", use: { browserName: "chromium", entry: "bundle" } }, + ], + webServer: { + command: `${bin} --addr 127.0.0.1:8120 --tls-addr 127.0.0.1:8121 --dist ../../packages/krm-stream/dist --page ../comparison`, + url: "http://127.0.0.1:8120/healthz", + reuseExistingServer: false, + timeout: 180_000, + // SIGINT, so the host deletes its scratch namespace on the way out. + gracefulShutdown: { signal: "SIGINT", timeout: 30_000 }, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/examples/vanilla-browser/compare/comparison.spec.ts b/examples/vanilla-browser/compare/comparison.spec.ts new file mode 100644 index 0000000..a2ab25d --- /dev/null +++ b/examples/vanilla-browser/compare/comparison.spec.ts @@ -0,0 +1,60 @@ +// The comparison page against the real comparison host and API server, on both entry points. Run by +// `task compare-browser`; see ../compare.config.ts. +// +// The regression this guards: another writer changes a field the person has FOCUSED but not edited. +// The page must show the new value in the focused input, so the next keystroke extends it. An input +// left showing the old text saved that old text plus the keystroke, at the newer version and with no +// conflict — silently overwriting the other writer. + +import type { APIRequestContext } from "@playwright/test"; +import { expect, test } from "../tests/fixtures.ts"; + +const admin = { "X-Compare-Session": "viewer-session", "Content-Type": "application/json" }; + +async function call(request: APIRequestContext, path: string, data?: unknown) { + const res = await request.post(`http://127.0.0.1:8120${path}`, { headers: admin, data }); + expect(res.ok(), `${path}: HTTP ${res.status()} ${await res.text()}`).toBe(true); +} + +async function clusterNote(request: APIRequestContext, name: string): Promise { + const res = await request.get("http://127.0.0.1:8120/admin/state", { headers: admin }); + const state = (await res.json()) as { kind: string; name: string; spec?: { note?: unknown } }[]; + return state.find((o) => o.kind === "Widget" && o.name === name)?.spec?.note; +} + +for (const source of ["native", "full", "spec"] as const) { + test(`${source}: a focused field follows another writer, and the next keystroke extends the new value`, async ({ + page, + request, + entry, + }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await call(request, "/admin/reset"); + await page.goto(`/login?entry=${entry}`); + + const column = page.locator(`[data-source=${source}]`); + await expect(column.locator("[data-role=state]")).toHaveText("live"); + const widget = await page.locator("#widget").inputValue(); + const note = column.locator("[data-role=note]"); + const initial = await clusterNote(request, widget); + await expect(note).toHaveValue(String(initial)); + + await note.click(); + await note.press("End"); + const external = `external-${source}-${entry}`; + await call(request, "/admin/write", { name: widget, note: external }); + + // The watch delivers it and the focused input shows it, still focused. + await expect(note).toHaveValue(external); + await expect(note).toBeFocused(); + await note.press("End"); + await note.pressSequentially("!"); + await expect(column.locator("[data-role=conflicts] li")).toHaveCount(0); + + await column.locator("[data-role=save]").click(); + await expect(column.locator("[data-role=outcome]")).toHaveText("saved"); + await expect.poll(() => clusterNote(request, widget)).toBe(`${external}!`); + expect(errors).toEqual([]); + }); +} diff --git a/gateway/kube/examples/comparison/objects.go b/gateway/kube/examples/comparison/objects.go index 57a3434..793e6fa 100644 --- a/gateway/kube/examples/comparison/objects.go +++ b/gateway/kube/examples/comparison/objects.go @@ -159,14 +159,21 @@ func (o *objects) reset(ctx context.Context) error { } func (o *objects) patch(ctx context.Context, client dynamic.ResourceInterface, name string, body map[string]any, subresource ...string) error { + _, err := o.patchVersion(ctx, client, name, body, subresource...) + return err +} + +// patchVersion patches and returns the resourceVersion the API server gave the written object. +func (o *objects) patchVersion(ctx context.Context, client dynamic.ResourceInterface, name string, body map[string]any, subresource ...string) (string, error) { patch, err := json.Marshal(body) if err != nil { - return err + return "", err } - if _, err := client.Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}, subresource...); err != nil { - return fmt.Errorf("patch %s %v: %w", name, subresource, err) + written, err := client.Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}, subresource...) + if err != nil { + return "", fmt.Errorf("patch %s %v: %w", name, subresource, err) } - return nil + return written.GetResourceVersion(), nil } // StateObject is one object as the cluster holds it, for the measurement's correctness gate. diff --git a/gateway/kube/examples/comparison/workload.go b/gateway/kube/examples/comparison/workload.go index 7615664..8eb1f1a 100644 --- a/gateway/kube/examples/comparison/workload.go +++ b/gateway/kube/examples/comparison/workload.go @@ -190,17 +190,21 @@ func NewPlan(req WorkloadRequest, widgetCount, secretCount int) (Plan, error) { // WorkloadResult reports what a run actually did. type WorkloadResult struct { - Plan map[StepKind]int `json:"planned"` - Done map[StepKind]int `json:"done"` - Closed int `json:"connectionsClosed"` - Errors []string `json:"errors,omitempty"` - MaxLateMs int64 `json:"maxLateMs"` - ElapsedMs int64 `json:"elapsedMs"` - DurationMs int `json:"durationMs"` - Seed uint64 `json:"seed"` - Rate float64 `json:"rate"` - Reconnects int `json:"reconnects"` - StartedUnix int64 `json:"startedUnixMs"` + Plan map[StepKind]int `json:"planned"` + Done map[StepKind]int `json:"done"` + Closed int `json:"connectionsClosed"` + Errors []string `json:"errors,omitempty"` + // SecretRotations is, per Secret name, the resourceVersion of its last successful rotation. A + // projected store shows a rotation only as a redaction revision under the same key, so this is + // the version a subscriber must hold to prove it saw the final one. + SecretRotations map[string]string `json:"secretRotations,omitempty"` + MaxLateMs int64 `json:"maxLateMs"` + ElapsedMs int64 `json:"elapsedMs"` + DurationMs int `json:"durationMs"` + Seed uint64 `json:"seed"` + Rate float64 `json:"rate"` + Reconnects int `json:"reconnects"` + StartedUnix int64 `json:"startedUnixMs"` } // run executes a plan in time order on one goroutine. A step that falls behind runs at once; how far @@ -208,7 +212,7 @@ type WorkloadResult struct { func (o *objects) run(ctx context.Context, plan Plan, disconnect func(target string) int) WorkloadResult { start := time.Now() result := WorkloadResult{ - Plan: plan.Counts(), Done: map[StepKind]int{}, + Plan: plan.Counts(), Done: map[StepKind]int{}, SecretRotations: map[string]string{}, DurationMs: plan.Request.DurationMs, Seed: plan.Request.Seed, Rate: plan.Request.Rate, Reconnects: plan.Request.Reconnects, StartedUnix: start.UnixMilli(), } @@ -235,9 +239,13 @@ func (o *objects) run(ctx context.Context, plan Plan, disconnect func(target str "heartbeat": int64(step.Seq), "phase": []string{"Ready", "Progressing"}[step.Seq%2], }}, "status") case StepSecret: - err = o.patch(ctx, o.secretClient(), SecretName(step.Object), map[string]any{"data": map[string]any{ + var rv string + rv, err = o.patchVersion(ctx, o.secretClient(), SecretName(step.Object), map[string]any{"data": map[string]any{ "token": base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "rotated-%d-%d", plan.Request.Seed, step.Seq)), }}) + if err == nil { + result.SecretRotations[SecretName(step.Object)] = rv + } case StepDisconnect: result.Closed += disconnect(plan.Request.Target) } diff --git a/gateway/kube/examples/comparison/workload_test.go b/gateway/kube/examples/comparison/workload_test.go new file mode 100644 index 0000000..a8c49ff --- /dev/null +++ b/gateway/kube/examples/comparison/workload_test.go @@ -0,0 +1,66 @@ +package comparison + +import ( + "context" + "errors" + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + clienttesting "k8s.io/client-go/testing" +) + +// The measurement driver rejects a run whose workload did not do what it planned, and requires every +// projected subscriber to hold the version of each Secret's FINAL successful rotation. Both rely on +// what run reports, so a failed write must show up as an error and a missing `done`, and only a +// successful rotation may set the version. +func TestRunReportsFailuresAndTheLastSuccessfulRotation(t *testing.T) { + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + widgets: "WidgetList", + secrets: "SecretList", + }) + // The rotations' answers in order: s-0 ok, s-1 fails, s-1 ok, s-0 fails. + versions := []string{"11", "", "13", ""} + rotation := 0 + client.PrependReactor("patch", "secrets", func(clienttesting.Action) (bool, runtime.Object, error) { + rv := versions[rotation] + rotation++ + if rv == "" { + return true, nil, errors.New("injected failure") + } + written := &unstructured.Unstructured{} + written.SetResourceVersion(rv) + return true, written, nil + }) + client.PrependReactor("patch", "widgets", func(clienttesting.Action) (bool, runtime.Object, error) { + return true, &unstructured.Unstructured{}, nil + }) + + o := &objects{dyn: client, namespace: "ns", widgets: 1, secrets: 2} + plan := Plan{Steps: []Step{ + {Kind: StepSecret, Object: 0, Seq: 1}, + {Kind: StepSecret, Object: 1, Seq: 2}, + {Kind: StepSecret, Object: 1, Seq: 3}, + {Kind: StepSpec, Object: 0, Seq: 4}, + {Kind: StepSecret, Object: 0, Seq: 5}, + }} + + got := o.run(context.Background(), plan, func(string) int { return 0 }) + + if want := map[StepKind]int{StepSecret: 4, StepSpec: 1}; !reflect.DeepEqual(got.Plan, want) { + t.Fatalf("planned: want %v, got %v", want, got.Plan) + } + if want := map[StepKind]int{StepSecret: 2, StepSpec: 1}; !reflect.DeepEqual(got.Done, want) { + t.Fatalf("done: want %v, got %v", want, got.Done) + } + if len(got.Errors) != 2 { + t.Fatalf("want both failed rotations reported, got %v", got.Errors) + } + // s-0's last rotation failed, so its last SUCCESSFUL one (rv 11) is what a subscriber must hold. + if want := map[string]string{SecretName(0): "11", SecretName(1): "13"}; !reflect.DeepEqual(got.SecretRotations, want) { + t.Fatalf("rotations: want %v, got %v", want, got.SecretRotations) + } +}