From 1a094ab8be7c7e5e8dcdbc877cb26f971ef3fa2a Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 11 Sep 2026 12:40:13 +0000 Subject: [PATCH 1/3] docs: trim obsolete history and align current integration guidance --- README.md | 21 +- SECURITY.md | 9 +- Taskfile.yml | 2 +- conformance/README.md | 12 +- conformance/bodies/secret-token.v1.yaml | 3 - conformance/fixtures/bookmark-absorbed.yaml | 24 +- .../fixtures/partial-object-refused.yaml | 18 +- .../fixtures/resourceversion-bignum.yaml | 30 +- conformance/gen/fixtures.json | 6 +- docs/adopting.md | 25 +- docs/alternatives.md | 137 ++------ docs/auth.md | 206 ++++------- docs/client-state-model.md | 25 +- docs/facts/kubernetes-api-concepts.md | 324 +++++------------- docs/glossary.md | 21 +- docs/proposals/0001-watch-ops.md | 28 -- docs/proposals/0002-real-cluster.md | 27 -- docs/proposals/0003-validate-patch.md | 28 -- docs/proposals/0004-views-and-bytes.md | 11 +- ...05-kubernetes-stream-and-save-semantics.md | 62 +--- ...006-stream-and-save-implementation-plan.md | 14 +- docs/releasing.md | 4 +- docs/saving.md | 61 +--- docs/why-a-gateway.md | 89 ++--- examples/README.md | 8 +- examples/vanilla-browser/README.md | 3 +- gateway/conformance.go | 4 +- spec/v1.md | 14 +- test/cluster/sample-apiserver/README.md | 19 +- 29 files changed, 313 insertions(+), 922 deletions(-) delete mode 100644 docs/proposals/0001-watch-ops.md delete mode 100644 docs/proposals/0002-real-cluster.md delete mode 100644 docs/proposals/0003-validate-patch.md diff --git a/README.md b/README.md index 4075eee..ea63717 100644 --- a/README.md +++ b/README.md @@ -32,20 +32,18 @@ product can show live cluster state while people are editing it. **Probably not, if:** -- You just want a **generic three-way merge library**. This one knows what a `resourceVersion` is, - that `spec.containers` is keyed by `name` and not by index, and that a redacted field must never be - written back. That knowledge is the whole point; if you do not want it, it is weight. +- You just want a **generic three-way merge library**. This store includes KRM identity, projection + and redaction rules, plus optional schema-based keyed-list merging. - You want a **ready-made Kubernetes dashboard**. Use [Headlamp](https://headlamp.dev/). See [alternatives](docs/alternatives.md). - You want to **write to the cluster from the browser**. krm-stream is the read-and-edit half: it - hands your application a validated merge patch, and your application performs the write. Though if - you are doing that, you probably want this library anyway, because it is the thing that tells you - the patch is safe to apply. See [saving edits safely](docs/saving.md). + captures a merge patch and version together. Your application validates and performs the write. See [saving edits safely](docs/saving.md). ## What is KRM? **KRM** is the Kubernetes Resource Model: the shape every Kubernetes object has (`apiVersion`, -`kind`, `metadata`, a desired `spec`, an observed `status`). Custom resources use the same shape, +`kind`, `metadata`, and kind-specific fields such as `spec`, `status` or ConfigMap `data`). Custom +resources use the same conventions, which is why this works for your product's own objects, a `Database`, a `FeatureFlag`, a `Tenant`, and not only for cluster infrastructure. @@ -55,11 +53,10 @@ Never touched a cluster? The [glossary for frontend developers](docs/glossary.md Kubernetes already has a good change feed: a watch, documented under [efficient detection of changes](https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes). -A browser cannot use it directly. Watching requires a cluster credential, the API server serves no -CORS, and a watch hands back whole objects including `Secret` data. The gateway holds the credential, -withholds what the browser should not see, and re-frames the stream as SSE that `EventSource` reads -natively. It also shares one upstream watch per scope, so ten tabs are not ten watches on the API -server. +Direct browser access requires exposing cluster credentials and arranging cross-origin access. +A raw watch also carries whole objects, including Secret values. The embedded gateway uses host-owned +credentials, enforces the selected disclosure policy and emits SSE. Hosts can opt into one shared +upstream watch per scope with per-subscriber authorization. [Why a gateway](docs/why-a-gateway.md) works through this in full. diff --git a/SECURITY.md b/SECURITY.md index 836bb56..5608400 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,11 +14,6 @@ not. Pre-1.0. Only the latest minor version receives fixes. The protocol and the API may still change. -| Version | Supported | -|---|---| -| 0.1.x | yes | -| < 0.1 | no | - ## What counts as a vulnerability here This library sits between a Kubernetes API server and a browser, so the interesting failures are @@ -34,8 +29,8 @@ almost all *disclosure* failures. The things we would treat as security bugs: not a bug, it is a disclosure. - **A merge patch writing a field the browser was never shown.** `ValidateMergePatch` exists to make this impossible; a way around it is a vulnerability, not a feature request. -- **A scope, target or credential accepted from the caller.** The gateway must never let a browser - choose which API server it talks to. +- **An unvalidated scope or raw API-server address or credential accepted from the caller.** A + browser may select only host-allowlisted target identifiers and authorized scopes. ## What does not diff --git a/Taskfile.yml b/Taskfile.yml index ab68d31..f921a4a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -365,7 +365,7 @@ tasks: # The one rung a fake watch cannot reach. Everything the gateway BELIEVES about Kubernetes — that a # streaming list ends with an `initial-events-end` bookmark, that a 410 arrives as a watch error, # that resourceVersions are orderable decimals — is unverified until something asks a real API - # server. See docs/proposals/0002-real-cluster.md. + # server. See CONTRIBUTING.md#test-levels and docs/facts/observed-v1.36.2+k3s1.md. cluster-up: desc: "A real Kubernetes (k3d, {{.K3S_IMAGE}}) — with real etcd, because that is what we are verifying." status: diff --git a/conformance/README.md b/conformance/README.md index cc8ce35..cfde0ee 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,9 +1,6 @@ # conformance — the shared contract, executable -This directory is the reason `krm-stream` is one repo and not three. - -A protocol is only as real as the tests both sides run. Here, **one YAML file describes one scenario -end to end**: what the Kubernetes watch does, what the gateway must therefore put on the wire, and what +**One YAML file describes one scenario end to end**: what the Kubernetes watch does, what the gateway must therefore put on the wire, and what a client that consumed that wire (plus some local edits) must then be holding. The Go suite and the TypeScript suite load the *same* files. A protocol change that breaks either side fails both, in the same commit. @@ -87,8 +84,7 @@ name does not) obvious at a glance. `watch:` models conditions handled across the gateway pipeline — API-server watch behavior, browser disconnects, and client-go cache tombstones. Where an operation maps to Kubernetes API behavior, the reference is [docs/facts/kubernetes-api-concepts.md](../docs/facts/kubernetes-api-concepts.md), which is a reading -of the [API concepts page](https://kubernetes.io/docs/reference/using-api/api-concepts/) rather than a -reading of anyone's memory. That distinction has already cost us two bugs. +of the [API concepts page](https://kubernetes.io/docs/reference/using-api/api-concepts/) with links to upstream documentation and separate real-cluster evidence. | op | means | the gateway must | |---|---|---| @@ -178,7 +174,3 @@ and original delivered object under all three built-in projections. The symmetric [final Secret rotation](fixtures/final-redaction-rotation.yaml) must emit its changed redaction revision; that same client test checks the held records at each delivered upsert. Existing snapshot, pruning, ordering and redaction tests remain part of `task test`. - -This narrows the promised invariant without changing wire emissions. The conventional `fix:` commit -records the clarification for Release Please's generated release notes; no manual changelog entry -is maintained. Adoption recipes, real-API save hardening and watch continuation remain subsequent work. diff --git a/conformance/bodies/secret-token.v1.yaml b/conformance/bodies/secret-token.v1.yaml index 69f7861..1fd8aa5 100644 --- a/conformance/bodies/secret-token.v1.yaml +++ b/conformance/bodies/secret-token.v1.yaml @@ -3,9 +3,6 @@ # This is the gateway's INPUT — what arrives on the watch. What goes on the WIRE is # secret-token.v1-wire.yaml, and the difference between the two files IS the projection. # -# They used to be the same file, which only worked because masking an already-masked value produces -# the same masked value. That accident hid the fact that the corpus never actually watched a Secret -# with a real value in it. Now it does, and `krm-full/v1` has to genuinely remove something. apiVersion: v1 kind: Secret metadata: diff --git a/conformance/fixtures/bookmark-absorbed.yaml b/conformance/fixtures/bookmark-absorbed.yaml index 728af50..330eae4 100644 --- a/conformance/fixtures/bookmark-absorbed.yaml +++ b/conformance/fixtures/bookmark-absorbed.yaml @@ -1,26 +1,10 @@ id: bookmark-absorbed title: A routine watch BOOKMARK is absorbed — never forwarded, never mistaken for `synced`. why: > - Kubernetes, verbatim: "The document representing the BOOKMARK event is of the type requested by the - request, but only includes a .metadata.resourceVersion field." So an object with no uid, no name, no - spec and no status is not an exotic case someone contrived — it is on EVERY conforming watch stream - that asked for bookmarks, and the gateway must ask (allowWatchBookmarks=true is how the snapshot - boundary arrives at all). - - Two ways to get this wrong, and this fixture fails on both: - - - forward the bookmark's object as `modified`. The consumer REPLACES on modified — that is the - protocol's single most important rule — so it would replace a live ConfigMap with a husk that - has only a resourceVersion. The screen goes blank. - - treat every bookmark as the snapshot boundary and emit `synced`. Pruning is gated on `synced`, - so a mid-cycle `synced` prunes objects the snapshot had not reached yet: half the user's - resources vanish, and reappear on the next relist. - - Only the bookmark that TERMINATES the initial events is `synced` (spec §5). Every other - one is absorbed for its resourceVersion and never spoken of again (spec §2). - - See docs/facts/kubernetes-api-concepts.md §1.1 — this rule is a quote from the API docs, not an - opinion about them. + Bookmarks carry checkpoints, not complete resources. Forwarding one as an upsert would replace + a resource with a partial object. Only the initial-events-end bookmark may emit synced; a routine + bookmark must not end a snapshot early or cause pruning. See the watch-events section of + docs/facts/kubernetes-api-concepts.md and spec §§2, 5. suites: [gateway] scope: { target: demo, version: v1, resource: configmaps, namespace: app } projection: krm-full/v1 diff --git a/conformance/fixtures/partial-object-refused.yaml b/conformance/fixtures/partial-object-refused.yaml index ec3232b..9e07795 100644 --- a/conformance/fixtures/partial-object-refused.yaml +++ b/conformance/fixtures/partial-object-refused.yaml @@ -1,21 +1,9 @@ id: partial-object-refused title: A metadata-only object is never forwarded as an upsert — the gateway resnapshots instead. why: > - Kubernetes serves PartialObjectMetadata on request (Accept: application/json;as=PartialObjectMetadata): - "the returned objects only contain the `metadata` field. The `spec` and `status` fields are omitted." - - This fixture exists because it caught a real mistake in our own gateway. The guard was "an object - with no uid is partial" — but a PartialObjectMetadata HAS a uid. It has a whole metadata block. What - it does not have is a spec or a status. So the guard looked at the wrong field, the object sailed - through, and the consumer — whose model is REPLACE, never merge — swapped a live Deployment for a - husk: the status view goes blank and the editor silently loses the user's spec. - - The honest check is the KIND (PartialObjectMetadata / PartialObjectMetadataList, group meta.k8s.io), - not the presence of a uid. A partial object delivered as an update would blank a consumer's state — - spec §2 says so, and it is right; we were simply not enforcing what we had written down. - - The recovery is a new snapshot cycle: it is the one response that is ALWAYS correct, because it - re-establishes the truth rather than guessing at it. + PartialObjectMetadata contains metadata.uid but no resource body. A UID alone therefore cannot + establish completeness. The gateway must recognize metadata-only kinds and start a fresh snapshot + rather than replace a complete resource with a partial object (spec §2). suites: [gateway] scope: { target: demo, group: apps, version: v1, resource: deployments, namespace: app } projection: krm-full/v1 diff --git a/conformance/fixtures/resourceversion-bignum.yaml b/conformance/fixtures/resourceversion-bignum.yaml index 55855f4..747c948 100644 --- a/conformance/fixtures/resourceversion-bignum.yaml +++ b/conformance/fixtures/resourceversion-bignum.yaml @@ -1,31 +1,11 @@ id: resourceversion-bignum title: A 40-digit resourceVersion orders correctly — and a stale replay is still dropped. why: > - Kubernetes: "Resource versions are compared as arbitrary bitsize decimal integers... The bitsize - must not be assumed to be some fixed amount." Its own worked example is 40 digits long. - - strconv.ParseInt tops out at 19. The gateway used to compare resource versions with it, so against a - server like this one the parse simply failed — and the per-object monotonicity check (spec §6) gave - up. The user-visible symptom is silently dropped live updates, which in a status view is - indistinguishable from "Kubernetes is being slow", and is therefore the kind of bug that survives for - years. - - Note the KIND. This is a Flunder — Kubernetes' own sample aggregated API — and not a ConfigMap, on - purpose: kube-apiserver's resourceVersion is an etcd revision, so it fits in an int64 and you will - never meet a 40-digit one there. A server with a different backing store is where such a value - actually comes from, and a fixture that pretended otherwise would be teaching the rule with an - example that cannot happen. - - The comparison Kubernetes prescribes: "If they are not of equal length, the longer one is greater... - If they are of equal length, the lexicographically greater one is greater." That rules out a plain - lexicographic compare, which would call the 41-digit version OLDER than the 40-digit one. - - So, both halves at once: - - the 41-digit v2 is NEWER than the 40-digit v1 and must be delivered; - - replaying v1 afterwards is STALE and must be dropped (§6: never emit, within a cycle, a state - older than one already emitted for that uid). - - These are decimals, so they ARE orderable: the default (OrderingStrict) handles them, and must. + Kubernetes resource versions are arbitrary-size decimals within one resource type. The 41-digit + revision is newer than the 40-digit revision and must be delivered; replaying the older revision + afterward must be dropped (spec §6). Compare length, then equal-length strings lexicographically. + A fixed-width integer parse or plain lexicographic comparison fails this case. The fixture uses + an aggregated Flunder to model an upstream with versions wider than kube-apiserver's etcd revisions. suites: [gateway] scope: { target: demo, group: wardle.example.com, version: v1alpha1, resource: flunders, namespace: app } projection: krm-full/v1 diff --git a/conformance/gen/fixtures.json b/conformance/gen/fixtures.json index 69d51a9..94d875e 100644 --- a/conformance/gen/fixtures.json +++ b/conformance/gen/fixtures.json @@ -132,7 +132,7 @@ { "id": "bookmark-absorbed", "title": "A routine watch BOOKMARK is absorbed — never forwarded, never mistaken for `synced`.", - "why": "Kubernetes, verbatim: \"The document representing the BOOKMARK event is of the type requested by the request, but only includes a .metadata.resourceVersion field.\" So an object with no uid, no name, no spec and no status is not an exotic case someone contrived — it is on EVERY conforming watch stream that asked for bookmarks, and the gateway must ask (allowWatchBookmarks=true is how the snapshot boundary arrives at all).\nTwo ways to get this wrong, and this fixture fails on both:\n\n - forward the bookmark's object as `modified`. The consumer REPLACES on modified — that is the\n protocol's single most important rule — so it would replace a live ConfigMap with a husk that\n has only a resourceVersion. The screen goes blank.\n - treat every bookmark as the snapshot boundary and emit `synced`. Pruning is gated on `synced`,\n so a mid-cycle `synced` prunes objects the snapshot had not reached yet: half the user's\n resources vanish, and reappear on the next relist.\n\nOnly the bookmark that TERMINATES the initial events is `synced` (spec §5). Every other one is absorbed for its resourceVersion and never spoken of again (spec §2).\nSee docs/facts/kubernetes-api-concepts.md §1.1 — this rule is a quote from the API docs, not an opinion about them.\n", + "why": "Bookmarks carry checkpoints, not complete resources. Forwarding one as an upsert would replace a resource with a partial object. Only the initial-events-end bookmark may emit synced; a routine bookmark must not end a snapshot early or cause pruning. See the watch-events section of docs/facts/kubernetes-api-concepts.md and spec §§2, 5.\n", "suites": [ "gateway" ], @@ -892,7 +892,7 @@ { "id": "partial-object-refused", "title": "A metadata-only object is never forwarded as an upsert — the gateway resnapshots instead.", - "why": "Kubernetes serves PartialObjectMetadata on request (Accept: application/json;as=PartialObjectMetadata): \"the returned objects only contain the `metadata` field. The `spec` and `status` fields are omitted.\"\nThis fixture exists because it caught a real mistake in our own gateway. The guard was \"an object with no uid is partial\" — but a PartialObjectMetadata HAS a uid. It has a whole metadata block. What it does not have is a spec or a status. So the guard looked at the wrong field, the object sailed through, and the consumer — whose model is REPLACE, never merge — swapped a live Deployment for a husk: the status view goes blank and the editor silently loses the user's spec.\nThe honest check is the KIND (PartialObjectMetadata / PartialObjectMetadataList, group meta.k8s.io), not the presence of a uid. A partial object delivered as an update would blank a consumer's state — spec §2 says so, and it is right; we were simply not enforcing what we had written down.\nThe recovery is a new snapshot cycle: it is the one response that is ALWAYS correct, because it re-establishes the truth rather than guessing at it.\n", + "why": "PartialObjectMetadata contains metadata.uid but no resource body. A UID alone therefore cannot establish completeness. The gateway must recognize metadata-only kinds and start a fresh snapshot rather than replace a complete resource with a partial object (spec §2).\n", "suites": [ "gateway" ], @@ -1023,7 +1023,7 @@ { "id": "resourceversion-bignum", "title": "A 40-digit resourceVersion orders correctly — and a stale replay is still dropped.", - "why": "Kubernetes: \"Resource versions are compared as arbitrary bitsize decimal integers... The bitsize must not be assumed to be some fixed amount.\" Its own worked example is 40 digits long.\nstrconv.ParseInt tops out at 19. The gateway used to compare resource versions with it, so against a server like this one the parse simply failed — and the per-object monotonicity check (spec §6) gave up. The user-visible symptom is silently dropped live updates, which in a status view is indistinguishable from \"Kubernetes is being slow\", and is therefore the kind of bug that survives for years.\nNote the KIND. This is a Flunder — Kubernetes' own sample aggregated API — and not a ConfigMap, on purpose: kube-apiserver's resourceVersion is an etcd revision, so it fits in an int64 and you will never meet a 40-digit one there. A server with a different backing store is where such a value actually comes from, and a fixture that pretended otherwise would be teaching the rule with an example that cannot happen.\nThe comparison Kubernetes prescribes: \"If they are not of equal length, the longer one is greater... If they are of equal length, the lexicographically greater one is greater.\" That rules out a plain lexicographic compare, which would call the 41-digit version OLDER than the 40-digit one.\nSo, both halves at once:\n - the 41-digit v2 is NEWER than the 40-digit v1 and must be delivered;\n - replaying v1 afterwards is STALE and must be dropped (§6: never emit, within a cycle, a state\n older than one already emitted for that uid).\n\nThese are decimals, so they ARE orderable: the default (OrderingStrict) handles them, and must.\n", + "why": "Kubernetes resource versions are arbitrary-size decimals within one resource type. The 41-digit revision is newer than the 40-digit revision and must be delivered; replaying the older revision afterward must be dropped (spec §6). Compare length, then equal-length strings lexicographically. A fixed-width integer parse or plain lexicographic comparison fails this case. The fixture uses an aggregated Flunder to model an upstream with versions wider than kube-apiserver's etcd revisions.\n", "suites": [ "gateway" ], diff --git a/docs/adopting.md b/docs/adopting.md index 0090ce5..c1f3cfe 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -31,25 +31,12 @@ if errors.As(err, &serr) { } ``` -They return the interface rather than `*StreamError` on purpose. A fallible function returning a -concrete pointer type is the Go typed-nil trap: assign its result into a variable already declared as -`error` and a *successful* call comes back non-nil, because an interface holding a typed nil is not -nil. In a scope check that is a refusal you cannot explain. In an authorization check written the -same way, with the condition inverted, it is an admission you never see. - -## 1b. Targets, and hosts that carry a path - -A `rest.Config` whose `Host` includes a path prefix works. A kcp workspace URL -(`https://kcp.example/clusters/root:org:ws`) is an ordinary host as far as client-go is concerned, and -the dynamic client appends `/apis/...` to it correctly. Nothing in the gateway parses, rewrites or -second-guesses that URL: the host resolves a target to a backend, and the backend is whatever -`rest.Config` you built. - -Build that URL with `kube.NewBackendForConfig(cfg)` rather than `kube.NewBackend(dynamicClient)` when -you can. Both work, but only the former knows the address it dialed, so an upstream failure can name -it. That matters most exactly here: get a path-prefixed URL subtly wrong (two `/clusters/` segments, -say) and the API server answers `the server could not find the requested resource`, which is -indistinguishable from "that CRD is not installed" until the error says which URL it asked. +### Targets with a path prefix + +A `rest.Config.Host` may include a path prefix, such as a kcp workspace URL +(`https://kcp.example/clusters/root:org:ws`). The dynamic client preserves it when constructing API +requests. Use `kube.NewBackendForConfig(cfg)` to include the endpoint in upstream error diagnostics; +`kube.NewBackend(dynamicClient)` also works when a dynamic client already exists. ## 2. Mount the same-origin cookie endpoint diff --git a/docs/alternatives.md b/docs/alternatives.md index 35e4500..d2096e6 100644 --- a/docs/alternatives.md +++ b/docs/alternatives.md @@ -1,109 +1,28 @@ -# Alternatives and prior art - -Where krm-stream sits relative to existing work, and what it does not try to be. - -krm-stream is two things: a wire contract for streaming a scoped, redacted projection of KRM -resources into a browser, and a client store that keeps server truth and local drafts separate so a -user can keep typing while the cluster changes underneath them. Most neighbouring projects solve one -half and leave the other to the application. - -## Kubernetes client libraries - -**[@kubernetes/client-node](https://github.com/kubernetes-client/javascript)** is the official -JavaScript client. It covers watches and informers, including the parts that are easy to get wrong: -`resourceVersion` bookkeeping, bookmarks, `410 Gone` and relist. It is built for Node, speaks -Kubernetes API concepts directly, and ships credential and kubeconfig handling that does not belong -in a browser bundle. It has no notion of a draft, a conflict, or a redacted projection. The -krm-stream gateway sits on this class of library rather than replacing it. - -**[kube-watch](https://github.com/subk/kube-watch)** and similar wrappers are the same story with -less coverage: an event emitter over the watch verb, server-side. - -**[Raw Kubernetes watch](https://kubernetes.io/docs/reference/using-api/api-concepts/)** gives you -`ADDED`, `MODIFIED`, `DELETED`, bookmarks and streaming initial events. It is the machinery -underneath everything here, not a browser-facing contract. A client still has to solve reconnect, -snapshot completion, history gaps and reconciliation with local state itself. That is the work -krm-stream packages up. - -## Browser Kubernetes UIs - -**[Headlamp](https://headlamp.dev/)** is the closest architectural precedent for the gateway. Its -browser opens a single WebSocket to `headlamp-server`, which fans out to the cluster API servers. -That is the same posture krm-stream takes with SSE: the API server is never exposed to the browser. -Headlamp also exposes TypeScript APIs (`apiProxy`, `streamResults`, object hooks) to plugin authors. -The differences are that it is a Kubernetes UI and plugin host, its client APIs are React-shaped and -coupled to the Headlamp runtime, and its streaming model is watch-and-replace. Editing is a YAML -editor with `resourceVersion` optimistic concurrency, not a draft that survives a concurrent server -change. If you want a Kubernetes dashboard, use Headlamp. krm-stream is for embedding KRM-backed -live state in an application that is not a Kubernetes UI. - -**[@hawtio/kubernetes-api](https://github.com/hawtio/hawtio-kubernetes-api)** is the historical -precedent: browser-side Angular client, WebSocket watch, in-memory collections, CRUD. It talks -directly to the API server and needs CORS configured on the cluster. Its model is to watch and -replace the collection. No drafts, no three-way merge. - -**Lens, Skooner, and the Kubernetes Dashboard** are applications, not libraries. Nothing reusable is -published for embedding. - -## Config-as-data systems - -These share the premise that configuration is data, queryable and mutable through an API, rather -than templates to be rendered. They operate at the package and delivery layer rather than the -live-editing layer. - -**[kpt](https://kpt.dev/guides/rationale/) and [Porch](https://github.com/kptdev/porch)** are the -reference Configuration-as-Data implementation, and where the term comes from: configuration data is -the source of truth, stored separately from live state, with the code that acts on it kept out of -the data. They manage the lifecycle of KRM packages in Git, from Draft through Proposed to -Published, with KRM functions mutating packages. - -The vocabulary overlaps. Porch has drafts too, but a Porch draft is a package revision moving -through approval gates over minutes or days, not a form field a user is holding while a controller -updates `.status`. Porch is asynchronous and Git-backed. krm-stream is sub-second and -cluster-backed. - -**[gitops-reverser](https://reversegitops.dev)** is a complement, not an alternative, and it is why -the krm-stream save boundary looks the way it does. Reverse GitOps puts an API in front and lets Git -remember: a validated write lands on a user-facing CRD, and the accepted intent is recorded to Git -as a manifest, with the actor as commit author, for Flux or Argo CD to distribute. krm-stream is the -read and edit half of that loop. It streams those CRDs into a browser and produces an RFC 7386 merge -patch when the user saves. The two meet at the API: krm-stream never writes, it hands the host a -patch to validate, and the host's write is what reverse GitOps records. - -## Local-first and merge libraries - -Automerge, Yjs, and the CRDT family solve concurrent editing, and generic JSON-merge libraries solve -structural merging. None of them know what a `resourceVersion` is, that `spec.containers` is keyed -by `name` rather than by index, that a snapshot has a completion point, or that a redacted field -must not be sent back on save. They are ingredients, not alternatives. - -The krm-stream merge is deliberately not a CRDT. KRM has one authoritative writer, the API server, -so last-write-wins with the conflict surfaced to the user is the model that matches the data. - -**TanStack Query, SWR and Apollo** are the closest analogue in application code: a server cache plus -optimistic updates. They have no streaming Kubernetes source and no snapshot semantics, and their -optimistic update is discarded on refetch, which is the failure krm-stream exists to prevent. - -## Summary - -| | Streams KRM to browser | Browser-safe (no direct API server) | Framework-independent | Server truth vs. local draft | Conflict-aware three-way merge | -|---|---|---|---|---|---| -| [krm-stream](https://github.com/ConfigButler/krm-stream) | yes | yes (gateway) | yes | yes | yes | -| [@kubernetes/client-node](https://github.com/kubernetes-client/javascript) | no (Node only) | n/a | yes | no | no | -| [Headlamp](https://headlamp.dev/) | yes | yes (headlamp-server) | no (React/plugin host) | no | no | -| [@hawtio/kubernetes-api](https://github.com/hawtio/hawtio-kubernetes-api) | yes | no (CORS to API server) | no (Angular) | no | no | -| [kpt](https://kpt.dev/guides/rationale/) / [Porch](https://github.com/kptdev/porch) | no | n/a | n/a | package drafts, not field drafts | no | -| [Automerge](https://automerge.org/) / [Yjs](https://yjs.dev/) and merge libs | no | n/a | yes | yes | yes, but KRM-unaware | - -[gitops-reverser](https://reversegitops.dev) is absent from that table on purpose. It is the write -and record half of the same loop, not a competing way to do this half. - -## The claim we can defend - -Not "the first Kubernetes streaming library". Watch clients and browser dashboards have existed for -years, and the gateway stands on them. - -What is new is the combination: a library for conflict-aware live editing of KRM resources in -browser applications, independent of any UI framework. It is the state layer between Kubernetes -client libraries and application form state. Stated more cautiously: to our knowledge, the first -open browser client and gateway designed for that. +# Alternatives + +krm-stream combines a scoped KRM read stream with a browser store for live state, local drafts and +conflicts. Choose tools according to which part of that problem your application needs. + +| Need | Relevant approach | Where krm-stream fits | +|---|---|---| +| Kubernetes API access from a server | Kubernetes client libraries and raw watches | The Go adapter uses `client-go`; the gateway adds projections and browser snapshot framing. | +| A complete Kubernetes UI | Dashboard applications and their plugin APIs | krm-stream supplies state and transport; the host builds the UI. | +| Review and deliver configuration packages | KRM package and GitOps systems | The host can record accepted writes in its delivery workflow. | +| Collaborative document editing | CRDT and generic merge libraries | This store reconciles drafts against an authoritative Kubernetes object and surfaces conflicts. | +| Cache server data in a frontend | Query/cache libraries | krm-stream adds the KRM stream lifecycle, redaction metadata and draft reconciliation. | + +## Related projects + +- [Kubernetes JavaScript client](https://github.com/kubernetes-client/javascript): server-side API + access. Browser integration still needs host credentials, disclosure policy and stream handling. +- [Headlamp](https://headlamp.dev/): a Kubernetes UI with a plugin system, useful when extending a + dashboard is the goal. +- [kpt](https://kpt.dev/guides/rationale/) and [Porch](https://github.com/kptdev/porch): KRM package + workflows. A package revision and an in-progress browser form serve different purposes. +- [Automerge](https://automerge.org/) and [Yjs](https://yjs.dev/): collaborative data structures for + applications with different synchronization and conflict models. +- [gitops-reverser](https://reversegitops.dev): a complementary write-and-record workflow. The host + connects accepted Kubernetes writes to Git; krm-stream supplies the read and edit side. + +The [architecture overview](../README.md#how-it-fits) shows the library/host split. The +[saving guide](saving.md) describes the conditional-write boundary and its limitations. diff --git a/docs/auth.md b/docs/auth.md index 96c315a..4a66b7c 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -1,180 +1,112 @@ -# Authentication & authorization +# Authentication and authorization -**krm-stream never holds a credential, and it is not an authorization boundary. Kubernetes is.** +The host owns sessions, Kubernetes credentials and authorization policy. The gateway enforces the +host's scope and projection decisions. How Kubernetes checks a caller depends on the backend: -That is the whole stance. Everything below is a consequence of it, plus the one physical constraint -that decides how a browser can authenticate at all. - ---- - -## The constraint that decides everything - -**A browser's `EventSource` cannot send an `Authorization` header.** It is not an oversight we can -work around; it is what the API is. So a browser holding an OIDC access token *cannot put it on a -native SSE request*. - -Everything else follows from that one sentence. +| Backend | Kubernetes identity | Host responsibility | +|---|---|---| +| Per-user | The caller's token or an impersonated user | Resolve the caller, authorize the scope and supply their client. | +| Shared | One service identity | Authorize every subscriber before serving cached objects; use `kube.SubjectAccessReviewAuthorizer` for Kubernetes RBAC decisions. | -## Recommended browser route: OIDC via Dex, with a same-origin cookie +## Browser sessions -For browser applications, use the same-origin route that native `EventSource` permits: +Use a same-origin session cookie for browser applications. The host handles OIDC with its identity +provider, keeps the tokens server-side and issues a secure session cookie. The managed fetch connector +sends that cookie; native `EventSource` can use the same route. ```mermaid sequenceDiagram - autonumber participant B as Browser - participant S as Your Go server
(krm-stream gateway) - participant D as Dex (OIDC) + participant S as Your Go application + participant D as OIDC provider participant K as Kubernetes API - - B->>S: 1. GET / (no session) - S->>D: 2. OIDC redirect - D-->>B: 3. user logs in - B->>S: 4. callback with code - S->>D: 5. exchange code → id/access token - Note over S: your server CUSTODIES the token.
krm-stream never sees, stores or logs it. - S-->>B: 6. Set-Cookie: session (HttpOnly, SameSite) - - B->>S: 7. EventSource("/resource-stream/v1?…")
carries the cookie, and nothing else - S->>S: 8. Principal(r) — cookie → session → this user + their token - S->>K: 9. ClientFor(ctx, target, principal) — a client bearing THEIR token - K-->>S: 10. watch … or 403, if their RBAC says no - S-->>B: 11. reset · added · synced · … + B->>S: Sign in + S-->>B: Redirect to identity provider + B->>D: Authenticate + D-->>B: Redirect to host callback with code + B->>S: Callback with code + S->>D: Exchange code + D-->>S: Tokens + S-->>B: Secure, HttpOnly, SameSite session cookie + B->>S: Open stream with session cookie + S->>S: Resolve principal and authorize scope + S->>K: Open watch using caller's client + K-->>S: Watch events or access refusal + S-->>B: Projected SSE stream or terminal error ``` -The browser authenticates to **your** server. Your server custodies the token. The SSE request -carries **nothing but a same-origin `HttpOnly` cookie** — no token in JavaScript, no token in a URL, -nothing an XSS can read. +Native `EventSource` cannot send an `Authorization` header. Use `connectManagedResourceStream` with +explicit headers for an intentionally token-bearing client. The host must enforce trusted HTTPS +endpoints and redirect handling; the connector delegates those transport decisions to fetch. +See [adoption](adopting.md#3-browser-client). -Then step 9 is the one that matters: **the upstream watch is opened as the user.** If they may not -watch Secrets in that namespace, the API server refuses. No bug in this library can change that. - -> A fetch-based reader (`connectResourceStream`) can send an `Authorization` header for a deliberate -> token-bearing client. The cookie route above is the safer default for browser applications. - -## The three seams, and what each is for +## Host seams ```go gateway.Handler(gateway.Options{ - // WHO is calling? Your cookie → your session → your user. Opaque to us. - Principal: func(r *http.Request) (gateway.Principal, error) { return sessionUser(r) }, - - // MAY they? Checked BEFORE any watch opens, and again on every snapshot cycle. - Authorizer: myAuthz, - - // Reach the cluster AS them — their token, their RBAC, their audit trail. + Principal: sessionUser, + Authorizer: authorizeScope, Clients: func(_ context.Context, target string, p gateway.Principal) (gateway.Backend, error) { - return kube.NewBackend(dynamicClientBearing(p.(*User).Token)), nil + return kube.NewBackend(dynamicClientFor(target, p.(*User))), nil }, - Scopes: myScopePolicy, + Scopes: scopePolicy, }) ``` -| seam | what it is | what it is **not** | -|---|---|---| -| `Principal` | whatever your session says the caller is. The library treats it as opaque (`any`), and never inspects, persists or logs it | not a credential *we* manage | -| `Authorizer` | **fail-fast, defence in depth.** Denies *before* the watch opens, so the existence of an object is never leaked to someone who may not see it | **not the boundary** — see below | -| `ClientFor` | the boundary. It hands back a client acting **as the caller**, so Kubernetes' own RBAC enforces | not a place to put a privileged god-client (unless you have read the sharing section) | - -The gateway holds **no privileged client of its own**. It therefore cannot bypass RBAC even if it had -a bug that wanted to — authorization is not something this library *does*, it is something it -structurally *cannot avoid delegating*. - -### Bearer token or impersonation? - -`ClientFor` supports both, and it is a real choice: +- `Principal` resolves the request to an opaque application identity. +- `Authorizer` denies unauthorized scopes before a watch opens and on subsequent checks. +- `Clients` is a `ClientFor` callback supplying the backend for that identity and target. +- `Scopes` allowlists targets and resources. A browser cannot supply a raw API-server URL. -- **The user's bearer token** — the blast radius is exactly that user's. Preferred. -- **Impersonation** (a service account sending `Impersonate-User`) — keeps Kubernetes as the boundary - just as well, but requires your server to hold impersonate rights, which is a large privilege whose - compromise is total. +A per-user backend can use the user's bearer token or Kubernetes impersonation. Impersonation requires +explicit host credentials with impersonation rights. Scope and disclosure policy remain host-owned +in either case; a projection does not grant permission to read or write a resource. ## Long streams, short tokens -An SSE stream lives as long as an open dashboard tab — **hours**. An OIDC access token lives 5–60 -minutes. So the credential you captured when the stream opened is *not* one you may keep using. - -The gateway therefore **re-authorizes on every snapshot cycle**, and re-invokes `ClientFor` there -too: - -- **Revocation is noticed.** Take a user's access away and their open stream ends with a **terminal - `FORBIDDEN`**. Terminal matters: `EventSource` reconnects on its own, so a non-terminal refusal - would leave a revoked user hammering a forbidden scope forever. -- **`ClientFor` is your refresh point.** It is called again each cycle, so you can hand back a client - bearing a *fresh* token. - -Set `ReauthorizationInterval` to bound how long a quiet stream runs without checking entitlement: +The gateway rechecks authorization and projection policy on every snapshot cycle and calls `Clients` +again so the host can provide refreshing credentials. Cycle-only checks do not bound revocation time +on a quiet stream. Set a timed recheck when the host needs that bound: ```go options.ReauthorizationInterval = 30 * time.Second options.ReauthorizationTimeout = 5 * time.Second ``` -Timed checks are per subscriber and recheck both `Authorizer` and the projection policy. During a -check that subscriber's object delivery pauses. Denial, timeout or policy failure terminates only -that stream; other subscribers and the shared upstream continue. A changed projection terminates -the old stream so it cannot keep disclosing its previous view. Zero interval preserves cycle-only -checks; zero timeout uses 10 seconds. The bound assumes host callbacks honor context cancellation -and sinks do not block indefinitely. The check uses the principal captured at stream open: resolve -current session/account validity inside the host authorizer if those can change independently of RBAC. +Timed checks run per subscriber and pause that subscriber's object delivery. Denial, timeout, policy +failure or a changed projection terminates only that stream; other subscribers continue. Zero +interval keeps cycle-only checks; zero timeout uses 10 seconds. The bound assumes callbacks honor +context cancellation and sinks do not block indefinitely. -For 200 subscribers, a 30-second interval adds roughly 13 SubjectAccessReviews/second (list and watch -per subscriber), plus opening/cycle checks. Choose an interval and timeout for your revocation budget -and API-server capacity; checks are not cached across identities. `ClientFor` still runs per snapshot -cycle so the host can return a client backed by refreshing credentials. +Checks use the principal captured at stream open. Resolve current session/account validity inside the +host authorizer. Timed checks do not invoke `Clients`; credential refresh remains per snapshot cycle +or inside the supplied client. -## Two things that are easy to confuse +With 200 subscribers, a 30-second interval adds roughly 13 SubjectAccessReviews per second (list and +watch per subscriber), plus opening/cycle checks. Choose intervals for the host's revocation budget +and API-server capacity; checks are not cached across identities. -**A projection is not authorization.** Redaction is a *tighter disclosure layer on top of* RBAC: a -user who is fully entitled to read a Secret still does not get its value in a browser. It must -**never** be relied on to hide something the caller could not have read anyway — that is Kubernetes' -job. Confusing the two is how you end up with a "secure" viewer whose only protection is a mask. +## Shared-watch authorization -**Sharing a watch moves the boundary — so give it back.** `SharedBackend` opens one upstream watch per -scope, so it opens it **once**, so it opens it as **one identity** — your service account. At that -moment your `Authorizer` stops being defence in depth and becomes *the only thing* between a caller -and the objects. That is why it is opt-in, and why it is not the default. - -If you turn it on, use **`kube.SubjectAccessReviewAuthorizer`**, and Kubernetes is the boundary again: +`SharedBackend` opens one upstream watch per scope as one service identity. Every subscriber must +be authorized independently before receiving the shared cache: ```go -shared := gateway.NewSharedBackend(serviceAccountBackend) // one watch, one identity… -opts.Authorizer = kube.SubjectAccessReviewAuthorizer(clientset, subjectOf) // …but RBAC still decides +shared := gateway.NewSharedBackend(serviceAccountBackend) +opts.Authorizer = kube.SubjectAccessReviewAuthorizer(clientset, subjectOf) opts.Clients = func(context.Context, string, gateway.Principal) (gateway.Backend, error) { return shared, nil } ``` -Before a subscriber is served from the shared cache, it asks the API server — with a -`SubjectAccessReview` — *"may this user `list` and `watch` this resource, in this namespace?"* — and -lets it answer. `subjectOf` is yours: it maps your opaque `Principal` onto the Kubernetes user and -groups that RBAC binds against (the OIDC `username` and `groups` claims). - -Three things it does that are easy to get accidentally permissive, all tested: - -- it asks about **both `list` and `watch`**. A snapshot cycle is a list *then* a watch — literally so - on the list-then-watch path — so a caller who may watch but not list could otherwise be handed, in - the snapshot, exactly the objects RBAC refused to let them enumerate; -- a review it could not **complete** is not an allow. If the API server cannot say whether you may - look, the answer is no; -- an explicit `Denied` wins over an `Allowed`. - -It needs your server's service account to hold `create` on `subjectaccessreviews` (the standard -`system:auth-delegator` role). It does **not** need impersonate rights: it asks a question *about* a -user, it does not act *as* one. And because the gateway re-authorizes every snapshot cycle, this is -also how a revocation reaches a stream that is already open. Timed checks bound quiet-stream revocation. -Use `SubjectAccessReviewAuthorizer`; it creates SubjectAccessReview requests, not SelfSubjectAccessReview. - -## What this library never does +`subjectOf` maps the principal to the Kubernetes username and groups. The adapter checks both `list` +and `watch`. An incomplete review is refused, and an explicit `Denied` wins over `Allowed`. -- It never **mints, refreshes, stores, inspects or logs** a credential. -- It never accepts an API-server address, endpoint or credential **from the caller** — such a query - parameter is *refused*, not ignored (spec §8.1). -- It never **writes**. Saves go through the Kubernetes API from your own handler ([spec §3](../spec/v1.md)) — - and *your* save endpoint carries the duty to refuse a patch touching a redacted path, because a - mask written back would overwrite the real Secret. +The service account needs `create` on `subjectaccessreviews`; `system:auth-delegator` supplies that +permission. Reviews do not require impersonation rights. These are SubjectAccessReview requests, +not SelfSubjectAccessReview requests. Cycle and timed checks use the same authorizer. ## Save boundary -Projection is not authorization, and it is not a write policy. The host owns its save endpoint and -must validate the active projection before sending a Kubernetes patch. Use -`gateway.ValidateMergePatch` to reject redacted and projection-removed paths, then apply a narrow -merge patch as the authenticated user. See [saving.md](saving.md). +The host owns writes, CSRF protection, audit and write authorization. Before a merge PATCH, call +`gateway.ValidateMergePatch` with the effective projection and current object, and include the +captured UID and resourceVersion preconditions. Project any resource returned to the browser. +See [saving](saving.md) for the complete flow. diff --git a/docs/client-state-model.md b/docs/client-state-model.md index 90f3d2d..b1eaf65 100644 --- a/docs/client-state-model.md +++ b/docs/client-state-model.md @@ -8,7 +8,7 @@ silently overwrites an edit. | Value | Meaning | |---|---| -| `server(id)` | The latest complete projected object. Every stream update replaces it. | +| `server(id)` | The latest delivered complete projected object. Every upsert replaces it. | | `draft(id)` | The object rendered and edited by the UI. Editable regions are reconciled with server changes. | | `conflicts(id)` | Server values that changed concurrently with a different local edit. | | `redactions(id)` | Paths known to exist upstream but intentionally withheld by the selected projection. | @@ -37,12 +37,12 @@ The default editable regions are `spec`, `metadata.labels`, `metadata.annotation When a new server object arrives, the store compares three values at each editable path: -| Base | Draft | Incoming server | Result | -|---|---|---|---| -| unchanged | any | changed | follow the server | -| changed | local edit | unchanged | keep the draft | -| changed | same value | same value | converge and clear conflict | -| changed | different value | different value | keep the draft and record a conflict | +| Draft differs from base | Incoming server differs from base | Result | +|---|---|---| +| no | yes | follow the server | +| yes | no | keep the draft | +| yes | yes, matching the draft | converge and clear conflict | +| yes | yes, differing from the draft | keep the draft and record a conflict | `isDirty` and `changes` are derived from `draft` versus `server`; neither is a cache that can drift after a stream update. `revert` or `takeTheirs` restores the current server value. @@ -133,10 +133,11 @@ The store has no rendering dependency. Subscribe once, then query `draft`, `stat const unsubscribe = store.subscribe(() => render(store)); store.setValue(uid, ["spec", "replicas"], 3); -const patch = store.patch(uid); -if (patch) await save(patch); +// Save from an explicit user action while the connection is live. +const intent = store.captureSave(uid); +if (intent) await hostSave(intent); ``` -Use `adoptSaved` with the object returned by a successful host save to clear local dirtiness before -the watch echo arrives. See [`packages/krm-stream/`](../packages/krm-stream/) for the public API and -[`conformance/`](../conformance/) for executable behavior examples. +Use the [conditional editor](../examples/conditional-save/README.md) for conflict checks, serialized +saves and guarded asynchronous responses. `adoptSaved` is for synchronous adoption or newly created +objects; a delayed response must use a reconciliation guard. See [saving](saving.md). diff --git a/docs/facts/kubernetes-api-concepts.md b/docs/facts/kubernetes-api-concepts.md index d6e252b..5529684 100644 --- a/docs/facts/kubernetes-api-concepts.md +++ b/docs/facts/kubernetes-api-concepts.md @@ -1,274 +1,108 @@ -# Facts: the Kubernetes API, as Kubernetes actually documents it +# Kubernetes API reference notes -> **Source:** , read in full from the -> upstream markdown (`kubernetes/website`, `content/en/docs/reference/using-api/api-concepts.md`) on -> **2026-07-11**. This file is a summary *with citations to that page*, plus — kept strictly -> separate — what each fact means for this repo. -> -> **Why this file exists.** `spec/v1.md` and `gateway/README.md` make claims about what a Kubernetes -> watch does. This reference grounds those claims in Kubernetes documentation and distinguishes them -> from behavior verified only by the real-cluster suite. -> -> **What is NOT in this file:** anything the page does not say. Where we rely on behaviour that lives -> in `client-go`/`apimachinery` rather than in the documentation, it is called out as **[unverified -> against docs]** and is a job for the real-cluster suite. +Source: [Kubernetes API concepts](https://kubernetes.io/docs/reference/using-api/api-concepts/), +reviewed from upstream markdown on **2026-07-11**. These notes explain the upstream assumptions behind +[the protocol](../../spec/v1.md). They are separate from the +[recorded v1.36.2 cluster observations](observed-v1.36.2+k3s1.md), which establish behavior only for +that tested environment. ---- +## Watch events and partial objects -## 1. The watch event vocabulary +A watch sends JSON notifications with `type` and `object`. `ADDED`, `MODIFIED` and `DELETED` describe +resources; synthetic `ADDED` events can establish initial state. -The page documents a watch as a stream of change notifications, each a JSON document of the form -`{"type": …, "object": …}`, and names these types: +[Bookmarks](https://kubernetes.io/docs/reference/using-api/api-concepts/#watch-bookmarks) carry a +resource-version checkpoint, not a complete resource. They are opt-in, have no guaranteed cadence +and need not arrive even when requested. A gateway must absorb them rather than replace a consumer's +object with their partial payload. -| type | in the docs | -|---|---| -| `ADDED` | yes — including **synthetic** ADDEDs that establish initial state | -| `MODIFIED` | yes | -| `DELETED` | yes (as an operation; the event type appears in the examples) | -| `BOOKMARK` | yes — a whole section, [Watch bookmarks](https://kubernetes.io/docs/reference/using-api/api-concepts/#watch-bookmarks) | - -**`ERROR` is not enumerated on this page.** It exists in `k8s.io/apimachinery`'s `watch.Event` -(`watch.Error`, carrying a `metav1.Status`), and it is how a `410 Gone` reaches a watch that is -already open. **[unverified against docs]** - -### 1.1 A BOOKMARK's object is a PARTIAL OBJECT. This is the headline. - -> "It is a special kind of event to mark that all changes up to a given `resourceVersion` the client -> is requesting have already been sent. The document representing the `BOOKMARK` event is of the type -> requested by the request, **but only includes a `.metadata.resourceVersion` field**." - -Their example, verbatim: - -```json -{ "type": "BOOKMARK", - "object": {"kind": "Pod", "apiVersion": "v1", "metadata": {"resourceVersion": "12746"} } } -``` - -So an object with **no `uid`, no `name`, no `spec`, no `status`** is not a pathological edge case -someone might contrive — **it is on every conforming watch stream that asked for bookmarks.** A -gateway that forwards a BOOKMARK's object as `added`/`modified` hands its consumer a fragment; a -consumer whose model is "replace, never merge" (ours, and correctly so) then blanks the resource on -screen. - -Two further rules from the same section: - -- Bookmarks are **opt-in**: `allowWatchBookmarks=true` on the watch request. -- "You **shouldn't assume bookmarks are returned at any specific interval**, nor can clients assume - that the API server will send any `BOOKMARK` event even when requested." → nothing may be built on - a bookmark *arriving*. It is a hint, not a heartbeat. - -## 2. Resource versions — and the bug this page found in our gateway - -> "Resource version strings are **orderable as monotonically increasing integers within the same -> resource type**… Both resource versions must be from objects of the same API group and resource -> type." - -So far so good — that is what `spec/v1.md` §6 assumes. But then: - -> "Both must start with a digit 1-9 and contain only digits 0-9. **Resource versions are compared as -> arbitrary bitsize decimal integers**… **The bitsize must not be assumed to be some fixed amount.**" - -And the page's own worked example is a **40-digit** resource version: - -> `"2345678901234567890123456789012345678901" > "345678901234567890123456789012345678901"` - -The prescribed comparison is **lexicographic-by-length**: - -> "If they are not of equal length, the longer one is greater (for example, "123" > "23"). If they -> are of equal length, the lexicographically greater one is greater." - -**We were parsing `resourceVersion` with `strconv.ParseInt` (int64 — 19 digits).** A 40-digit -resource version overflows it, `ParseInt` fails, and our staleness check silently gives up — or -worse, on a value that *does* parse but has wrapped, compares nonsense. The failure mode is -**dropped live updates**, which in a status-watch UI looks exactly like "Kubernetes is slow." - -And the case that has no integer at all: +Metadata-only requests can return `meta.k8s.io/v1 PartialObjectMetadata`, including a UID. A missing +UID is therefore not the only partial-object signal: the gateway also checks the kind. Forwarding +such an object as an upsert would erase the resource's visible body. -> "If you are using API resources served by an **extension API server**… If either of two resource -> version strings does not parse as a decimal number, the two strings can be checked for **equality** -> but you **cannot** rely on comparisons for ordering." +An `ERROR` watch event is defined in `apimachinery` and can carry a Kubernetes Status. The recorded +cluster run verifies that an expired watch revision arrives as `ERROR` with code 410 (F3). Informer +`DeletedFinalStateUnknown` tombstones are a `client-go` construct, distinct from API-server deletes. -→ For a non-numeric `resourceVersion`, **ordering is undefined** and the only safe thing to do is not -order. Not "guess". Not "fall back to string compare". +## Resource-version ordering -### 2.1 …but from 1.35, orderability is a **conformance requirement** +The reference defines ordering within the same API group and resource type using arbitrary-size +decimal integers. Compare length first, then lexicographically for equal lengths; a fixed-width +integer parse is insufficient. Do not compare a Pod's version with a Deployment's version. -This is the sentence that decides the design, and it is stronger than the caveat above: +For Kubernetes 1.35+, orderable resource versions are a conformance requirement for built-in and +custom resources. Extension/aggregated APIs have a separate caveat: non-decimal versions can be +checked for equality but cannot be reliably ordered. -> "Starting with Kubernetes 1.35, orderability of resource versions for all Kubernetes types is -> included in **Certified Kubernetes requirements**. Base API objects **and custom resources** **must** -> be orderable as a monotonically increasing integer for any 1.35+ APIServer implementation in order to -> pass conformance tests." - -So on a supported cluster, an unorderable `resourceVersion` **cannot occur** — not for built-ins, not -for CRDs. The "may not parse as a decimal" escape is scoped to **extension / aggregated API servers**, -which are third-party implementations that this conformance test does not cover. - -That makes "can I trust `resourceVersion` to increase?" a real decision rather than a shrug, and this -library takes the strong side: **it REQUIRES Kubernetes 1.35+, trusts orderability, and refuses loudly -when the upstream lies** (`Gateway.Ordering = OrderingStrict`, the default), with an explicit -`OrderingLenient` for an aggregated API. Degrading *silently* on every cluster in order to accommodate -one is the wrong trade: a consumer that was promised per-object monotonicity and is quietly no longer -getting it is worse off than one that has been told. - -**Where you actually meet an unorderable resourceVersion — and where you do not.** This distinction is -now baked into the corpus, because getting it wrong means writing a fixture for a scenario that cannot -occur: - -| server | orderable? | why | -|---|---|---| -| kube-apiserver, built-in types | **yes**, guaranteed | 1.35 conformance | -| kube-apiserver, **CRDs** | **yes**, guaranteed | 1.35 conformance says "base API objects **and custom resources**" | -| **aggregated / extension** API server | **not guaranteed** | a third-party implementation; the conformance test does not cover it. This is the *only* case the docs' equality-only carve-out is written for | - -And a related fact worth stating, because it decides the *other* fixture: **kube-apiserver's -`resourceVersion` is an etcd revision** — an int64, at most 19 digits. The docs' 40-digit example -therefore cannot have come from kube-apiserver; it is a value only a server with a different backing -store produces. Both facts point the same way, so both `resourceversion-*` fixtures use a **`Flunder`** -(`wardle.example.com/v1alpha1`, Kubernetes' own [sample-apiserver](https://github.com/kubernetes/sample-apiserver)) -rather than a ConfigMap. - -Also, and we get this right already: - -- **Opaque to clients.** "Resource versions must be passed unmodified back to the server." -- Comparison is only valid **within one resource type**. A Pod's RV and a Deployment's are not - comparable. (Our high-water map is keyed by uid within one scope — one GVR — so this holds.) -- On a **list**, `.metadata.resourceVersion` of the *collection* is the version the collection was - constructed at — which is **not** related to the `.metadata.resourceVersion` of the items in it. - -## 3. Streaming lists (`sendInitialEvents`) — the snapshot boundary - -> "the initial state can be requested by specifying `sendInitialEvents=true`… the API server starts -> the watch stream with synthetic init events (of type `ADDED`) to build the whole state of all -> existing objects **followed by a `BOOKMARK` event (if requested via `allowWatchBookmarks=true`)**. -> The bookmark event includes the resource version to which is synced. After sending the bookmark -> event, the API server continues as for any other watch request." - -Preconditions, stated as requirements: - -- `sendInitialEvents=true` **requires** `resourceVersionMatch=NotOlderThan`. -- `resourceVersion` empty or absent ⇒ a **consistent read**; the bookmark is sent once the state is - synced at least to the moment the request began being processed. -- `allowWatchBookmarks=true` is what makes the terminating bookmark appear at all. - -This maps **one-to-one** onto the protocol: the synthetic ADDEDs are the snapshot, the terminating -bookmark **is** `synced`, and everything after it is live. - -**One claim of ours the page does NOT support — now settled against a real cluster.** -The gateway identifies the terminating bookmark with -`metadata.annotations["k8s.io/initial-events-end"] == "true"`. **That annotation appears nowhere on -this page.** It is `metav1.InitialEventsAnnotationKey` in `apimachinery` (KEP-3157), and it was the -single load-bearing assumption in the whole gateway — if it were wrong, `synced` would fire at the -wrong moment, or never, and the browser would never paint. - -> ✅ **CONFIRMED** on Kubernetes **v1.36.2** — see [observed-v1.36.2+k3s1.md](observed-v1.36.2+k3s1.md), -> F1. The bookmark arrives, and it carries `k8s.io/initial-events-end: "true"`. -> -> And a detail the docs get *slightly* wrong, which we only know because we looked: the page says a -> bookmark's object "only includes a `.metadata.resourceVersion` field", but the real terminating -> bookmark also carries `metadata.annotations` (it has to — that is where the marker lives). What it -> does **not** carry is a `uid`, which is what the gateway's partial-object guard actually keys on. -> The guard is correct, but for a reason one shade more precise than the sentence it was written from. - -Note also: with `resourceVersion` **unset** (no `sendInitialEvents` at all), a watch is "Get State and -Start at Most Recent" and *also* "begins with synthetic 'Added' events for all resource instances that -exist at the starting resource version" — but with **no terminating bookmark**, so you cannot tell -where the snapshot ends. That is the whole reason `sendInitialEvents` + `allowWatchBookmarks` exist, -and the whole reason a list-then-watch fallback has to synthesize the boundary itself. - -## 4. Losing continuity: `410 Gone` +| Upstream | Gateway choice | +|---|---| +| Supported conformant Kubernetes API | Default `OrderingStrict`; refuse an unorderable version. | +| Known aggregated API without orderable versions | Explicit `OrderingLenient`; do not drop updates whose order cannot be established. | -> "A given Kubernetes server will only preserve a historical record of changes for a limited time. -> **Clusters using etcd 3 preserve changes in the last 5 minutes by default.** When the requested -> watch operations fail because the historical version of that resource is not available, clients -> must handle the case by recognizing the status code `410 Gone`, **clearing their local cache, -> performing a new get or list operation, and starting the watch from the `resourceVersion` that was -> returned**." +Browser consumers treat all resource versions as opaque strings. A collection's resourceVersion +marks the list boundary; it is not the version of any particular item. -"Clear the cache, re-list, restart the watch" **is** our `reset` … `synced` cycle. The five-minute -window is why `resync-midstream` is a fixture and not a curiosity: a browser tab left open on a quiet -namespace, behind a laptop lid, will hit this routinely. +The `resourceversion-bignum` and `resourceversion-unorderable` fixtures use an aggregated `Flunder` +to test these boundaries. The observed sample-apiserver used small decimal versions (F4/F6), so the +real-cluster run does not replace either fixture. -Also: `resourceVersion="0"` on a watch means "Get State and Start at **Any**", which the page warns -"may return **arbitrarily stale** data" and can **rewind** to a version the client already observed. -→ **Never open our watches with `resourceVersion=0`.** Per-object monotonicity would be violated by -the upstream itself, and our own high-water map would then (correctly, but uselessly) drop half the -snapshot. +## Streaming lists and snapshot completion -## 5. Deletion is two-phase, and the UI must show it +The reference's streaming-list request uses: -> "When a client first sends a **delete**… the `.metadata.deletionTimestamp` is set to the current -> time. Once the `.metadata.deletionTimestamp` is set, external controllers that act on finalizers may -> start performing their cleanup work… **Once the last finalizer is removed, the resource is actually -> removed from etcd.**" +- `sendInitialEvents=true`; +- `resourceVersionMatch=NotOlderThan`; +- `allowWatchBookmarks=true`; +- an empty or absent resourceVersion for a consistent initial read. -So a delete of a finalized object appears on the watch as **`MODIFIED` (now carrying -`.metadata.deletionTimestamp` and `.metadata.finalizers`) — possibly for a long time — and only later -as `DELETED`.** An object that is "Terminating" is a first-class, observable state, and it arrives -through the ordinary upsert path. A live status view that does not surface it is lying about the -cluster. +Synthetic `ADDED` events establish the snapshot, followed by its terminating bookmark and live +updates. The adapter recognizes the boundary using +`metadata.annotations["k8s.io/initial-events-end"] == "true"`, defined by +`metav1.InitialEventsAnnotationKey` in `apimachinery`. Observation F1 verifies the marker on v1.36.2; +it is not established by the API concepts page alone. -The page says nothing about whether a `DELETED` event's object is complete, and nothing about -informer deletion tombstones (`cache.DeletedFinalStateUnknown`) — that is a `client-go` construct. -**[unverified against docs]** +Observation F6 shows the sample aggregated API rejecting `sendInitialEvents`. The adapter therefore +also supports list-then-watch at the collection resourceVersion, synthesizing the snapshot boundary +from the completed list. The fallback is needed for aggregated APIs as well as older configurations. -## 6. Partial objects are a first-class, client-requested thing +## Lost continuity -> "To request partial object metadata, you can request metadata only responses in the `Accept` -> header… `Accept: application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1`… **the returned -> objects only contain the `metadata` field. The `spec` and `status` fields are omitted.**" +When retained history no longer contains a requested revision, Kubernetes returns `410 Gone` and the +client must reinitialize from a fresh read. The gateway maps continuity loss to `reset` … `synced`; +the browser retains its old entries until the new snapshot completes and only then prunes unseen UIDs. -The returned objects have `kind: PartialObjectMetadata`, `apiVersion: meta.k8s.io/v1` — and a full -`metadata`, *including a `uid`*. This matters for us: **a "partial object" is not always detectable by -a missing uid.** A `PartialObjectMetadata` has one. The reliable check is the **kind**, and the -consequence of missing it is that a consumer replaces a real object with one that has no `spec` and no -`status` — the status view goes blank and the editor loses the user's `spec`. +A watch opened at `resourceVersion="0"` can serve stale state and rewind. The adapter avoids that +mode. Downstream v1 connections always begin with a snapshot; browser object versions do not provide +resume. Upstream continuation remains tracked in the +[work plan](../proposals/0006-stream-and-save-implementation-plan.md#4-measured-upstream-continuation). -(An aggregated API server may not support partial fetches at all and returns `406`.) +## Deletion -## 7. Writes +An object with finalizers may first arrive as `MODIFIED` carrying `deletionTimestamp` and finalizers, +then later as `DELETED`. A UI can render the terminating state through ordinary object updates. +Observation F7 records a complete deleted object with a UID. A missing or ambiguous tombstone UID +still requires snapshot recovery; the gateway never guesses identity. -Four PATCH content types, and the page names them exactly: +## Writes -| `Content-Type` | what | +| Content type | Operation | |---|---| -| `application/apply-patch+yaml` | Server-Side Apply (create-or-patch) | -| `application/json-patch+json` | RFC 6902 | -| **`application/merge-patch+json`** | **RFC 7386** ← what `patch(id)` builds | -| `application/strategic-merge-patch+json` | k8s-specific; **not usable with CRDs** | - -- **PUT** requires the client to send `resourceVersion`; a stale one gets **`409 Conflict`**. The page - independently warns that a PUT "might accidentally drop fields… you could receive fields that your - client does not know how to handle - and then drop them as part of your update" — which is the exact - argument `spec/v1.md` §3 makes for never round-tripping a projected object. Good: the protocol's - most opinionated rule is one Kubernetes itself makes. -- A PATCH may carry `resourceVersion` as a precondition against lost updates. Hosts choose their own - stale-write policy because a suppressed projection can intentionally leave the streamed value stale. -- Strategic Merge Patch is superseded by Server-Side Apply and cannot be used with CRDs. The client - uses structural OpenAPI metadata only for local keyed-list reconciliation; its save format remains - RFC 7386 merge patch. +| `application/apply-patch+yaml` | Server-side apply | +| `application/json-patch+json` | RFC 6902 JSON Patch | +| `application/merge-patch+json` | RFC 7386 merge patch, produced by the client store | +| `application/strategic-merge-patch+json` | Kubernetes-specific strategic merge patch; unavailable for CRDs | -`x-kubernetes-list-type` / `x-kubernetes-list-map-keys` are not on this page; they live in the -Server-Side Apply reference. `withOpenAPIKeyedLists` consumes the structural subset supplied by the -host. +Whole-object PUT can lose fields the client omitted. PATCH can carry a resourceVersion precondition +against lost updates. The host must capture the patch and its version together, validate the active +projection and handle stale-write rejection; see [saving](../saving.md). ---- +Local keyed-list reconciliation uses host-supplied structural OpenAPI metadata. It does not change +the wire save format: RFC 7386 still replaces arrays in full. The field-ownership and omission rules +for apply are separate; see [SSA tradeoffs](../proposals/0005-kubernetes-stream-and-save-semantics.md#why-ssa-is-an-option-not-a-replacement-guarantee). -## What this changes in this repo +## Executable evidence -| # | Fact | Consequence | Status | -|---|---|---|---| -| 1 | RVs are arbitrary-bitsize integers; a 40-digit one is legal | `strconv.ParseInt` in `isStale` **overflows** → silently drops live updates | **fixed** — string compare, length-then-lexicographic | -| 2 | **1.35+ requires orderability** (built-ins *and* CRDs) | trust it: `OrderingStrict` is the default, and an unorderable RV is a **terminal error naming the fix**, not a silent degradation | **done** — fixture `resourceversion-unorderable` | -| 2b | Aggregated/extension API servers are **not** covered by that conformance test | they need an escape hatch: `OrderingLenient` orders nothing it cannot order, and **drops nothing** | **done** | -| 3 | A BOOKMARK's object has only `.metadata.resourceVersion` | a partial object is on **every** conforming stream; forwarding it blanks the consumer | **fixed** + fixture `bookmark-absorbed` | -| 4 | `PartialObjectMetadata` has a **uid** | "no uid" is not a sufficient partial-object check; check the **kind** | **fixed** + fixture `partial-object-refused` | -| 5 | Bookmarks may never arrive | nothing may depend on a bookmark's *arrival*, only on its meaning | holds — we only use the terminating one | -| 6 | `k8s.io/initial-events-end` is not in the docs | our snapshot boundary rests on it | ✅ **CONFIRMED on v1.36.2** — [observed](observed-v1.36.2+k3s1.md) F1 | -| 6b | **An aggregated API REJECTS `sendInitialEvents`** ("forbidden … unless the WatchList feature gate is enabled") | the streaming list is not universal; list-then-watch is required for these upstreams | **implemented** — [observed](observed-v1.36.2+k3s1.md) F6 | -| 6c | An aggregated API has **its own** resourceVersion space, starting at ~1, and its store may be ephemeral | RVs can go **backwards** across a restart of that API server | ✅ survived — the high-water map is per-**cycle**, so a restart's new cycle clears it. A per-stream map would have swallowed every event after a restart | -| 7 | Delete is two-phase; `deletionTimestamp` arrives as `MODIFIED` | "Terminating" is an observable state a consumer may render before deletion | documented behavior | -| 8 | `rv=0` watches may rewind and serve stale data | never open a watch with `resourceVersion=0` | implemented | -| 9 | RFC 7386 merge patch is a real k8s content type | `patch(id)` is directly usable, `application/merge-patch+json` | holds | -| 10 | PATCH may carry `resourceVersion` as a lost-update precondition | stale-write policy is host-owned | documented in `saving.md` | -| 11 | SMP is superseded and CRD-incompatible | local keyed-list reconciliation uses host-supplied OpenAPI metadata; saves remain RFC 7386 merge patches | **implemented** | +The [conformance corpus](../../conformance/README.md) covers framing, projection and deliberately +adversarial watch inputs. `task cluster-facts` records API observations; `task test-cluster` exercises +the adapter against both streaming-list and aggregated-API fallback paths. See +[verification tasks](../../CONTRIBUTING.md#test-levels) for their prerequisites and limits. diff --git a/docs/glossary.md b/docs/glossary.md index 36b5937..2a40944 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -50,8 +50,8 @@ snapshot never prunes state. text events. `EventSource` is built into every browser. It is one-directional, which is all a read stream needs. -**Gateway** is the server-side piece you mount in your own Go application. It holds the Kubernetes -credentials, decides who may see what, and turns a watch into a scoped SSE stream. The browser never +**Gateway** is the server-side piece you mount in your own Go application. It uses host-owned Kubernetes +clients, enforces host authorization and turns a watch into a scoped SSE stream. The browser never receives a cluster credential or an API-server URL. **Projection** is the subset of a resource the gateway sends. What the browser receives may be less @@ -84,13 +84,13 @@ change this field? Only the last row needs a human. Without this, a controller updating one annotation would discard the text you were typing in an unrelated field. -**Conflict** is the fourth row above. It is not an error and it does not block a save. The draft -still wins, and `conflicts(id)` gives the UI what it needs to show the server value alongside it, -with `takeTheirs` or `revert` as the ways out. +**Conflict** is the fourth row above. The draft is retained, and `conflicts(id)` exposes the server +value for review. `takeTheirs` and `revert` restore server values. The store leaves save policy to the +host; the conditional editor requires conflicts to be resolved before saving. **Associative list** is a Kubernetes array that behaves as a map. `spec.containers` is keyed by -`name`, not by index. A merge that treats it as an array corrupts it when two people change -different containers. The store merges these by key. +`name`, not by index. The store merges these by key only when configured with +`withOpenAPIKeyedLists` and the host-supplied structural schema. Arrays are atomic by default. **RFC 7386 merge patch** is the save format: a JSON document containing only what changed, where `null` means delete. The store builds it by diffing draft against server over editable paths only. @@ -113,14 +113,15 @@ The read path, in the order the words appear: The write path is not the library's: -5. On save, `patch(id)` returns an **RFC 7386 merge patch**, or `null` when nothing changed. -6. You send that to your own save endpoint. The store never writes to Kubernetes. +5. On save, `captureSave(id)` captures an **RFC 7386 merge patch**, UID and base resourceVersion + together, or returns `null` when nothing changed. +6. You send that intent to your own save endpoint. The store never writes to Kubernetes. 7. Your handler calls [`gateway.ValidateMergePatch`](../gateway/patch.go), which rejects a patch touching anything the effective projection withheld or stripped: a redacted path, `metadata.managedFields`, the last-applied annotation, and `status` under `ProjectionSpec`. It is what stops a buggy or hostile browser from destroying what it was never shown. Do not skip it on the grounds that the store is careful, because the store runs on the caller's machine. -8. The write goes to the API. The watch sees it, it returns down the stream as an ordinary update, +8. The host writes with the captured UID and resourceVersion preconditions. The watch sees it, it returns down the stream as an ordinary update, and the merge converges your draft with it. Your own write needs no special handling. If you know TanStack Query or SWR, this is the same server cache with local edits, with two diff --git a/docs/proposals/0001-watch-ops.md b/docs/proposals/0001-watch-ops.md deleted file mode 100644 index 038bd5b..0000000 --- a/docs/proposals/0001-watch-ops.md +++ /dev/null @@ -1,28 +0,0 @@ -# Proposal 0001: conformance watch operations - -**Status:** implemented. - -## Decision - -The conformance fixture language includes `bookmark`, `partial`, and `tombstone` watch operations in -addition to normal list, upsert, delete, relist, and disconnect operations. - -| Operation | Required gateway behavior | -|---|---| -| `bookmark` | Absorb routine bookmarks. Only the initial-events-end bookmark produces `synced`. | -| `partial` | Reject metadata-only objects and begin a new snapshot cycle. | -| `tombstone` | Never guess a deleted object's identity; begin a new snapshot cycle instead. | - -## Rationale - -Each case is possible in Kubernetes or `client-go` and cannot be safely inferred from the usual watch -operations. Expressing them in shared fixtures keeps the gateway's producer behavior covered without -changing the browser protocol. - -## Related behavior - -The same work established arbitrary-size decimal `resourceVersion` comparison. Strict ordering rejects -unorderable values; `OrderingLenient` is the explicit compatibility mode for upstreams that cannot -provide that guarantee. - -See [conformance/README.md](../../conformance/README.md) and [spec/v1.md](../../spec/v1.md). diff --git a/docs/proposals/0002-real-cluster.md b/docs/proposals/0002-real-cluster.md deleted file mode 100644 index 24dea23..0000000 --- a/docs/proposals/0002-real-cluster.md +++ /dev/null @@ -1,27 +0,0 @@ -# Proposal 0002: real-cluster verification - -**Status:** implemented. - -## Decision - -Maintain a real Kubernetes test rung alongside fixture-based tests. It verifies the assumptions that a -scripted watch cannot prove: streaming-list boundaries, real SSE behavior, Kubernetes resource-version -semantics, and fallback behavior for aggregated APIs. - -## Scope - -- `task cluster-facts` records observed API behavior for the supported Kubernetes version. -- `task test-cluster` exercises the real gateway and `gateway/kube` backend against a cluster. -- The Kubernetes sample aggregated API remains part of the test environment because it can reject - streaming lists even when the core API server supports them. - -The backend therefore supports both streaming-list and list-then-watch at a pinned -`resourceVersion`. The latter is a compatibility path for APIs that cannot serve a streaming list. - -## Non-goal - -The real-cluster rung does not replace deterministic fixtures. Bookmarks, partial metadata, ambiguous -tombstones, and deliberately unorderable versions still need focused unit and conformance cases. - -See [observed cluster facts](../facts/observed-v1.36.2+k3s1.md) and -[sample-apiserver setup](../../test/cluster/sample-apiserver/README.md). diff --git a/docs/proposals/0003-validate-patch.md b/docs/proposals/0003-validate-patch.md deleted file mode 100644 index c04adfa..0000000 --- a/docs/proposals/0003-validate-patch.md +++ /dev/null @@ -1,28 +0,0 @@ -# Proposal 0003: projection-aware patch validation - -**Status:** implemented. - -## Decision - -Keep writes outside `krm-stream`, but provide `gateway.ValidateMergePatch` for host save handlers. -The helper validates an RFC 7386 object merge patch against the active projection and current server -object before the host sends the patch to Kubernetes. - -It rejects: - -- redacted paths and deletion of a parent that contains one; -- `metadata.managedFields` and the last-applied-configuration annotation; -- `status` when the effective projection is `krm-spec/v1`; -- malformed or non-object merge patches. - -## Rationale - -Projected objects are intentionally incomplete. A host must never allow a browser to write a field it -was not shown, and should never use whole-object `PUT` for this flow. The helper removes a repeated -high-risk check without taking ownership of authorization, auditing, resource retrieval, or writes. - -Secret values are omitted from projected objects rather than represented by placeholders. `redacted` -in the event envelope records their existence without introducing a value that a client could send -back. - -See [saving.md](../saving.md) for the host endpoint recipe. diff --git a/docs/proposals/0004-views-and-bytes.md b/docs/proposals/0004-views-and-bytes.md index 1b6b40f..a8906b7 100644 --- a/docs/proposals/0004-views-and-bytes.md +++ b/docs/proposals/0004-views-and-bytes.md @@ -6,21 +6,12 @@ The gateway sends a named, host-authorized projection of each Kubernetes object and suppresses an upstream update when projected content excluding `metadata.resourceVersion`, plus redaction records, -is unchanged. The goal is not merely smaller status events; a consumer that does not render status receives **no event** for status-only churn. +is unchanged. A consumer using `krm-spec/v1` receives **no event** for status-only churn. The object remains a strict subset of the API-server object. The gateway may remove values but never add or replace an object value. Information about removed values belongs in the event envelope, never in the Kubernetes object. -This decision made the following pre-release protocol changes: - -- `krm-editor/v1` is renamed to `krm-full/v1`. -- `redactedPaths: string[]` is replaced with `redacted: [{ path, rev }]`. -- Every event carries `seq`. - -The Go gateway, TypeScript client, schema, fixtures, and documentation move together. There are no -external users to support yet, so retaining two wire shapes would add ambiguity without benefit. - ## Projection model A projection applies one of three actions to each path. diff --git a/docs/proposals/0005-kubernetes-stream-and-save-semantics.md b/docs/proposals/0005-kubernetes-stream-and-save-semantics.md index 4839fce..6d61e99 100644 --- a/docs/proposals/0005-kubernetes-stream-and-save-semantics.md +++ b/docs/proposals/0005-kubernetes-stream-and-save-semantics.md @@ -1,11 +1,9 @@ # Proposal 0005: Stream and conditional-save tradeoffs -**Status: design rationale. [Normative convergence clarification](../../spec/v1.md#6-ordering-delivery--the-state-guarantee) adopted.** +**Status: design rationale for the remaining stream and save work.** -[Proposal 0006](0006-stream-and-save-implementation-plan.md) owns the remaining work and acceptance -criteria. Current adoption behavior belongs in the [saving guide](../saving.md). Managed recovery, -bounded reauthorization and the conditional editor shipped in 0.3.0; their implementation phases -are superseded by the current work plan. +[Proposal 0006](0006-stream-and-save-implementation-plan.md) owns work order and acceptance criteria. +Use the [saving guide](../saving.md) for current host integration. Kubernetes owns identity and conditional writes. The library supplies projected reads and one draft store; the host owns credentials, write policy and presentation. The sections below explain the @@ -13,37 +11,9 @@ tradeoffs that still constrain the remaining work. ## Quiet views and write versions -Consider a Deployment editor using `krm-spec/v1`. The numbers below are illustrative labels. The -browser echoes resourceVersion strings; it must not parse them or infer ordering from them. - -```mermaid -sequenceDiagram - participant C as Controller - participant K as Kubernetes - participant G as Gateway - participant E as Editor and store - participant H as Host endpoint - K->>G: Object A, resourceVersion 100 - G->>E: Projected object A, resourceVersion 100 - E->>E: User edits spec - C->>K: Update status - K->>G: Same spec, new status, resourceVersion 101 - G->>G: Visible digest unchanged, suppress event - E->>H: Captured patch with resourceVersion 100 - H->>K: Conditional PATCH at 100 - K-->>H: 409: version precondition failed - H-->>E: HTTP 409 - E->>H: Most-recent projected GET - H->>K: GET - K-->>H: Object A, resourceVersion 101 - H-->>E: Same visible spec, resourceVersion 101 - E->>E: Guard accepts GET, base advances, no draft conflict - Note over C,E: Another status write before the next PATCH can repeat the 409 -``` - -The accepted GET already refreshes the base. A snapshot is not required for this example to recover. -The problem is that another write can invalidate that base before the next PATCH. This is possible -with any watch-fed editor; suppression makes it especially common and less visible. +The [quiet-stream example](../saving.md#why-a-quiet-stream-can-still-reject-a-save) shows why a +suppressed update can leave a valid but stale write precondition. A guarded GET can refresh the base +without a snapshot, but another write can invalidate it before the next PATCH. There are three different facts a UI must not collapse into one “conflict” label: @@ -54,19 +24,7 @@ There are three different facts a UI must not collapse into one “conflict” l - **An ownership conflict exists:** an apply operation disputes another field manager's ownership. The [saving guide](../saving.md#what-the-person-editing-sees) maps these distinctions to the -shipped editor outcomes. A refreshed base enables review; it cannot promise the next save succeeds. - -## Convergence needs precise equality - -The former §6 invariant allowed a reader to expect whole-object equality, including resourceVersion, -while the implemented suppression comparison already excluded it. A final suppressed metadata/status -write could leave the held version behind indefinitely. The “corresponding logical stream position” -wording limited when equality applied, but did not define the right comparison. - -[Spec §6](../../spec/v1.md#6-ordering-delivery--the-state-guarantee) now keeps that positional guarantee -and defines its comparison explicitly. [Executable evidence](../../conformance/README.md#convergence-evidence) -covers both suppressed final writes and delivered redaction changes. Wire emissions are unchanged; -the narrowed guarantee is recorded through the conventional-commit release process. +editor outcomes. A refreshed base enables review; it cannot promise the next save succeeds. ## Host write strategies @@ -116,8 +74,8 @@ This example is an inference from ownership semantics: using one manager for 200 provide user-to-user optimistic locking. Conversely, one manager per tab changes ownership and managedFields growth; it is not a free concurrency fix. A manager name is not authentication or RBAC. -Spec §3 permits SSA but describes saves as constrained writes over edited paths. Clarify that SSA -needs the host's intended managed field set and omission/deletion policy. `ValidateMergePatch` and +Spec §3 requires the host to define its intended managed field set and omission/deletion policy for +SSA. `ValidateMergePatch` and `captureSave().patch` remain merge-patch-specific. The store's local keyed-list merge does not turn that output into strategic merge patch or apply configuration. @@ -180,7 +138,7 @@ Upstream continuation is a named follow-up, ahead of any downstream replay desig | Add a write-base/read-ticket abstraction | New host/server protocol | Could coordinate reads and intended writes | State, expiration, identity binding and replay concerns; too much core machinery now | | Move host to SSA or targeted JSON Patch | No SSE change | Different write tradeoffs | Host policy/validation work; not interchangeable concurrency semantics | -**Recommendation:** take the first option now. Prefer complete existing event shapes over a new +**Current choice:** retain existing emissions and explicit host save outcomes. Prefer complete existing event shapes over a new version-only event if measurements later justify version delivery. If changing a named projection's promised suppression behavior, use an explicit new contract/projection identity or a coordinated pre-1.0 change; do not silently repurpose `krm-spec/v1`. Pre-release naming flexibility is useful, diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index a7aa78d..b56875a 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -1,6 +1,6 @@ # Proposal 0006: Remaining stream and save work -**Status: active follow-up plan, reviewed 2026-09-11 against 0.3.0 and adopter feedback.** +**Status: active follow-up plan.** Follow the standing [design rules](../../CONTRIBUTING.md#design-rules) and [release policy](../releasing.md). [Proposal 0005](0005-kubernetes-stream-and-save-semantics.md) @@ -8,9 +8,7 @@ explains the unresolved tradeoffs; this document owns work order and acceptance ## Baseline and order -Managed recovery, bounded reauthorization, differentiated save outcomes and the copyable Vue adapter -shipped in [0.3.0](../../packages/krm-stream/CHANGELOG.md). Use the -[adoption guide](../adopting.md), [saving guide](../saving.md) and +Use the [adoption guide](../adopting.md), [saving guide](../saving.md) and [conditional editor](../../examples/conditional-save/README.md) for current behavior. | Priority | Remaining work | Completion evidence | @@ -21,9 +19,7 @@ shipped in [0.3.0](../../packages/krm-stream/CHANGELOG.md). Use the | 4 | Measure and implement upstream continuation | Same-workload comparison proves continuity, bounded recovery and authorization. | Review the remaining priorities as separate changes. Baseline measurement can run alongside -priorities 2–3. The normative amendment is complete; see priority 1's evidence. Adopter-reported -unit tests support adoption, but do not establish real-cluster composition, consumer readiness or -200-attendee capacity. +priorities 2–3. Unit tests do not establish real-cluster composition, consumer readiness or capacity. ## 1. Define convergence precisely @@ -132,8 +128,8 @@ Documentation-only edits need link and diagram checks, not a cluster rebuild. Consumer acceptance remains separate: pin npm and both Go modules, check consumer CI/image toolchains, and exercise concurrent editing, later typing during saves, recovery, session expiry and -UID replacement in the browser. Voter's 30s recheck / 5s timeout and 60s termination target require -measurement under its actual 200-attendee workload with bounded callbacks and sinks. +UID replacement in the browser. Reauthorization intervals, timeouts and termination targets require measurement under the host +workload with bounded callbacks and sinks. Version-only events, independent content/delivery switches, downstream replay, write tickets, automatic conflict-free retry and a general SSA abstraction remain deferred until a concrete use diff --git a/docs/releasing.md b/docs/releasing.md index 0d04daf..6635671 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -22,9 +22,7 @@ produces these tags and packages: | Go Kubernetes adapter | `gateway/kube/vX.Y.Z` tag | | Official browser client | `@configbutler/krm-stream` on npm | -The historical `krm-stream@0.1.0` publication is outside the maintained release surface. Its local -forwarding package has been removed; install `@configbutler/krm-stream`. Removing local source does -not change an already published npm artifact. +Only `@configbutler/krm-stream` is maintained on npm. Do not publish to the unscoped `krm-stream` name. Before 1.0, remove superseded API names and forwarding packages instead of maintaining compatibility shims. Record each removal and its replacement in release notes, and update repository callers, diff --git a/docs/saving.md b/docs/saving.md index bd0b588..c41d5ac 100644 --- a/docs/saving.md +++ b/docs/saving.md @@ -90,8 +90,7 @@ resume: a new connection starts a complete snapshot. The [normative contract](../spec/v1.md#6-ordering-delivery--the-state-guarantee) defines the exact comparison and its guarantee at each delivered stream position; it does not promise zero transport latency. -Sustained invisible churn can prevent save progress, but a 409 is a failed version precondition, -not necessarily a disagreement at an editable field. An accepted projected GET advances the base +Sustained invisible churn can prevent save progress. An accepted projected GET advances the base without requiring a snapshot. Render actual draft conflicts separately; when none exist, explain the refreshed base and offer a newly captured save. If reconciliation is refused, preserve the draft and recover before writing again. @@ -165,50 +164,16 @@ stages the *intent*; your endpoint performs the *write*. See the store keys on uid and has no merge for these, so the consumer aggregates staged create/delete with `changes()` into one review list. -```go -// POST /console/configmaps — create -func (s *server) createConfigMap(w http.ResponseWriter, r *http.Request) { - user := userFromSession(r) - scope := authorizedScope(user, r) - object := readObject(r) // the new object the browser assembled - - // Validate on the host, before the write — pin the GVK, the authorized scope and name, and an - // allowlist of the fields a browser may set. Never trust the assembled object as-is. - if err := validateCreate(object, scope); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - created, err := s.dynamicFor(user).Resource(configMaps).Namespace(scope.Namespace). - Create(r.Context(), object, metav1.CreateOptions{}) - if err != nil { - http.Error(w, "create failed", http.StatusBadGateway) - return - } - - // 204 and let the watch echo it — the same recommendation as save. To reflect it now instead, - // project it first and return it; the browser calls store.adoptSaved(projected). - _ = created - w.WriteHeader(http.StatusNoContent) -} - -// DELETE /console/configmaps/{name} — delete -func (s *server) deleteConfigMap(w http.ResponseWriter, r *http.Request) { - user := userFromSession(r) - scope := authorizedScope(user, r) - if err := s.dynamicFor(user).Resource(configMaps).Namespace(scope.Namespace). - Delete(r.Context(), scope.Name, metav1.DeleteOptions{}); err != nil { - http.Error(w, "delete failed", http.StatusBadGateway) - return - } - // 204; the `deleted` event prunes it from every open stream. To reflect it now instead, the - // browser calls store.removeResource(uid) with the uid it already tracks. - w.WriteHeader(http.StatusNoContent) -} -``` +The host must: + +- Authorize the operation and pin the target, resource kind, namespace and name. +- Validate create bodies against the allowed fields and schema before calling Kubernetes. A create + has no existing object for `ValidateMergePatch` to compare; that helper validates merge patches. +- Bind a delete to the intended UID with a Kubernetes delete precondition so a replacement object + under the same name is not removed by an old request. +- Return 204 or a receipt and let the watch reflect the result. Project any returned resource before + sending it to the browser, and preserve meaningful Kubernetes error categories. -`ValidateMergePatch` guards a *patch*. A create sends a whole object, so validate it yourself before -the call — the `validateCreate` above stands in for a schema check or a field allowlist — and pass only -the sanitized object to `Create`. A projected or redacted field must no more ride in on a create body -than in a patch. API-server admission sits behind this as defense in depth, not as a substitute for the -host-side check. A delete carries no body to guard. +The host also clears its own pending-create/delete entries when a write succeeds. The store does not +own those staging lists. See the [client state model](client-state-model.md#reflecting-the-result) +for synchronous adoption and optimistic-delete caveats. diff --git a/docs/why-a-gateway.md b/docs/why-a-gateway.md index fdc8eeb..9aed032 100644 --- a/docs/why-a-gateway.md +++ b/docs/why-a-gateway.md @@ -1,63 +1,30 @@ # Why a gateway -Why krm-stream puts a server between the browser and the Kubernetes API, rather than letting the -browser watch the API server itself. - -## The mechanism it builds on - -Kubernetes has an efficient change feed. A `GET` with `?watch=1` streams `ADDED`, `MODIFIED` and -`DELETED` events for a resource, with `resourceVersion` as the position in the stream, bookmarks to -keep that position cheap, and `410 Gone` when the position has aged out of the server's cache. It is -documented under -[efficient detection of changes](https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes), -and it is what the gateway consumes upstream. Nothing here replaces it. - -## Why the browser cannot use it directly - -Not for transport reasons. A watch is an ordinary chunked HTTP response carrying newline-delimited -JSON. It is not a protocol upgrade; upgrades are what `exec`, `attach` and `port-forward` need, and a -watch is not one of them. The obstacles are around the stream rather than in it. - -**It needs a cluster credential.** A watch means presenting a bearer token or a client certificate -that Kubernetes RBAC recognises. Shipping either to a browser gives every tab, and anything running -in it, an identity in your cluster. There is no restriction you can attach in the browser that the -browser cannot also remove. - -**`EventSource` cannot read it.** A watch is newline-delimited JSON, not SSE framing, and -`EventSource` cannot set an `Authorization` header. Consuming a watch in a browser means `fetch`, a -`ReadableStream`, and your own reconnection and resume logic, which returns you to the credential -problem with more code around it. - -**The API server serves no CORS.** Reaching it cross-origin requires `--cors-allowed-origins` set -cluster-wide on the API server, for your web application. That is the concession -`@hawtio/kubernetes-api` required (see [alternatives](alternatives.md)), and most operators will not -make it. - -**The browser would see whole objects.** A watch returns everything: `Secret` data, `managedFields`, -`status`, fields belonging to other tenants of the same namespace. Withholding those has to happen -somewhere the user does not control, which means the server. - -The gateway is that server. It holds the credential, applies the projection and its redactions, -enforces the scope, and re-frames the result as SSE, which the browser reads natively with no bundler -and no reconnection logic in your application. - -## Why watches are shared - -A watch is not free upstream. Each one is a connection and a registered watcher on the API server, -delivering every event in its scope. Ten tabs on the same namespace, watching directly, are ten -watches, ten snapshots and ten copies of the same object graph. Reopen a floor of laptops at once and -that reconnect storm hits the API server multiplied by the number of tabs. - -[`gateway.SharedBackend`](../gateway/shared.go) opens one upstream watch per scope rather than per -tab, and serves every subscriber from its cache. A tab joining a scope that is already open gets its -`reset`…`synced` snapshot from that warm cache without reaching the API server at all. - -Sharing is opt-in, because the trade is real. A shared watch can be opened only once, so it runs as -one identity: your service account. Without sharing, the client acts as the caller and Kubernetes RBAC -enforces the boundary, so no bug in this library can hand someone an object they may not see. With -sharing, your `Authorizer` is the only thing between a caller and the cache. - -You can have both. Pair `SharedBackend` with [`kube.SubjectAccessReviewAuthorizer`](../gateway/kube/authz.go), which -asks the API server through a SubjectAccessReview whether this user may list and watch this resource -here, before serving them from the shared cache. Kubernetes decides again, per user, per snapshot -cycle, at the cost of one round-trip. Read [auth.md](auth.md) before wiring it. +The gateway embeds Kubernetes reads in a browser application while keeping credentials and policy +on the host. It also gives the browser a snapshot boundary and a stable event vocabulary. + +## From Kubernetes watch to browser stream + +A Kubernetes watch is a chunked HTTP response containing newline-delimited JSON. It carries object +updates, bookmarks and history-expiry errors. Native `EventSource` expects SSE framing and cannot set +an authorization header, so it cannot consume that response directly. + +Direct browser access would also require cluster credentials and appropriate API-server CORS +configuration. Raw watch objects include fields such as Secret values and managed fields. The host +must decide which scopes and fields it can disclose before sending them to the browser. + +The gateway uses a host-supplied Kubernetes client, applies the selected projection and emits +`reset` … `synced` snapshots followed by live updates. The managed connector handles bounded browser +reconnection; a new connection receives a fresh snapshot. See the +[architecture diagram](../README.md#how-it-fits) and [protocol](../spec/v1.md). + +## Optional watch sharing + +Without sharing, each stream uses its own backend watch. `SharedBackend` can instead keep one +upstream watch per scope and serve each subscriber from its cache. A joining subscriber still +receives a complete projected snapshot, so sharing saves upstream work without eliminating browser +transfer or reconciliation costs. + +A shared watch uses one service identity. Pair it with `kube.SubjectAccessReviewAuthorizer` to check +each subscriber's Kubernetes permissions before serving the cache, and configure timed checks when +quiet-stream revocation must be bounded. See [authorization](auth.md) and [operations](operations.md). diff --git a/examples/README.md b/examples/README.md index 7c1725e..bdbf4f8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,11 +3,11 @@ The checked browser example in [`vanilla-browser/`](vanilla-browser/) runs against the replay gateway. For host integration patterns, use these small recipes: -- [Same-origin cookie application](../docs/adopting.md#2-mount-the-same-origin-cookie-endpoint): native - EventSource, session cookie, dynamic client acting as the user. -- [Bearer-token fetch client](../docs/adopting.md#3-browser-client): `connectResourceStream` with an +- [Same-origin cookie application](../docs/adopting.md#2-mount-the-same-origin-cookie-endpoint): managed + fetch stream, session cookie, dynamic client acting as the user. +- [Bearer-token fetch client](../docs/adopting.md#3-browser-client): `connectManagedResourceStream` with an explicit `Authorization` header for a deliberate token-bearing client. -- [Shared backend with SSAR](../docs/adopting.md#4-share-watches-only-with-kubernetes-backed-authorization): +- [Shared backend with SubjectAccessReview](../docs/adopting.md#4-share-watches-only-with-kubernetes-backed-authorization): one service-account watch plus Kubernetes SubjectAccessReviews for each subscriber. [Conditional save](conditional-save/README.md) composes a managed connection with atomic save capture, diff --git a/examples/vanilla-browser/README.md b/examples/vanilla-browser/README.md index d65e17f..0382f1b 100644 --- a/examples/vanilla-browser/README.md +++ b/examples/vanilla-browser/README.md @@ -16,7 +16,8 @@ Secret values. Useful fixtures include: | Fixture | Demonstrates | |---|---| -| `status-only-churn` | Status updates while an editable draft remains intact. | +| `status-follow-live` | Live status updates while an editable draft remains intact. | +| `status-only-churn` | Spec projection suppresses status updates while retaining edits. | | `conflict-and-converge` | A real conflict followed by server convergence. | | `edit-vs-unrelated-change` | An unrelated server update preserves the local edit. | | `secret-redaction` | Redacted values remain unavailable and read-only. | diff --git a/gateway/conformance.go b/gateway/conformance.go index a1600e2..253101b 100644 --- a/gateway/conformance.go +++ b/gateway/conformance.go @@ -47,8 +47,8 @@ type Fixture struct { // even though the SSE connection is perfectly healthy // disconnect the consumer's connection dropped; the next list is a fresh cycle // -// And three that say what Kubernetes really does, added by docs/proposals/0001-watch-ops.md because -// the corpus could not otherwise express three of the gateway's own MUST NOTs: +// Additional operations cover the projection and recovery rules described in +// conformance/README.md: // // bookmark a routine BOOKMARK. Its object carries ONLY metadata.resourceVersion — that is not an // edge case, it is what the API server sends, on every stream that asked for bookmarks. diff --git a/spec/v1.md b/spec/v1.md index ac126be..bbe0bd5 100644 --- a/spec/v1.md +++ b/spec/v1.md @@ -188,8 +188,7 @@ because a UI legitimately wants to animate an arrival differently from an update > projected object for that uid. An *editing* consumer MAY separately reconcile that representation > against local edits. -This is the single most important consumer rule, and the previous draft of this document got it wrong. -A generic deep-merge of complete server objects is **incorrect**: it resurrects ghosts. +A generic deep-merge of complete server objects is **incorrect**: it resurrects deleted fields. ```jsonc old: { "spec": { "a": 1, "b": 2 } } @@ -369,10 +368,9 @@ object RV nor `seq` provides downstream resume; every new connection requires a > **gateway** relies on this internally — to coalesce safely, to drop a stale replay after a relist, > and to assert it never hands a consumer a state older than one already sent. It is deliberately > **not** exposed as an ordering primitive on the wire: it is not comparable across targets or across -> aggregated API servers, and a browser has no business doing revision arithmetic. See the gateway -> spec §3. +> aggregated API servers. See the [gateway guide](../gateway/README.md#stream-behavior). > -> **Three things an implementer must get right here, and we got the first two wrong** (see +> **Resource-version comparison constraints** (see > [docs/facts/kubernetes-api-concepts.md](../docs/facts/kubernetes-api-concepts.md)): > > - A `resourceVersion` is an **arbitrary-bitsize** decimal. Kubernetes' own documented example is 40 @@ -435,10 +433,8 @@ delivered once per cycle on `reset`. ### 8.1 The encoding — normative -v1.0 named these fields and never said how they reach the server. That omission had a cost: each end -invented its own spelling and nothing compared them. The encoding is therefore pinned here, and the -shared corpus (`conformance/scopes.yaml`) is what holds both ends to it — the client builds the -query, the gateway parses that exact query back. +The shared corpus (`conformance/scopes.yaml`) checks both ends: the client builds the query and the +gateway parses it back. A scope is carried as **URL query parameters** on the stream request, one per field, spelled exactly as above (`target`, `group`, `version`, `resource`, `namespace`, `name`, `labelSelector`). A client diff --git a/test/cluster/sample-apiserver/README.md b/test/cluster/sample-apiserver/README.md index 8f603b0..a9f9632 100644 --- a/test/cluster/sample-apiserver/README.md +++ b/test/cluster/sample-apiserver/README.md @@ -3,22 +3,17 @@ Kubernetes' own [sample-apiserver](https://github.com/kubernetes/sample-apiserver) (`wardle.example.com`, kinds `Flunder` and `Fischer`), installed as a genuine **aggregated API** behind an `APIService`. -It is here for one question, and it is a question no amount of reading answers: **an aggregated API -server is the one upstream Kubernetes' conformance rules do not cover** (see -[docs/facts/kubernetes-api-concepts.md](../../../docs/facts/kubernetes-api-concepts.md)), so it is the -only place `Gateway.OrderingLenient` could ever be needed — and the only place the gateway's -`sendInitialEvents` assumption might not hold. +This exercises the list-then-watch fallback against an aggregated API that can reject streaming +lists. See the [recorded observations](../../../docs/facts/observed-v1.36.2+k3s1.md) for the tested +server version and results. The `resourceversion-*` fixtures separately cover arbitrary-size and +unorderable versions; a real sample-apiserver run does not establish either case. -The `resourceversion-bignum` and `resourceversion-unorderable` fixtures already model a `Flunder`. -This makes it real. - -These manifests are derived from **upstream's `artifacts/example/`**, not from any ConfigButler repo — -this library depends on nothing of ours, and that rule includes YAML. +The manifests are derived from upstream's `artifacts/example/`. Two things worth knowing: - **The etcd sidecar is not optional.** `sample-apiserver`'s `--etcd-servers` will not take an empty value; it needs a real store. Upstream's own example uses the sidecar, and the data does not need to outlive the pod. -- **The image tag lags.** Only `1.33.8` is published; there is no `1.35`/`1.36` tag. That is fine — an - `APIService` aggregates over HTTP, and the skew is precisely the sort of thing a real deployment has. +- **The manifest pins `1.33.8`.** This aggregated server has its own feature gates and runs behind an + `APIService`; its behavior is measured separately from the cluster API server. From fd81feaf759d1696c41ab4e9cb13fcbe6d2adf72 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 11 Sep 2026 12:48:05 +0000 Subject: [PATCH 2/3] docs: retain security consequences and convergence rationale --- README.md | 3 +- SECURITY.md | 7 ++- conformance/README.md | 14 +++--- docs/auth.md | 20 +++++--- docs/facts/kubernetes-api-concepts.md | 9 ++-- docs/glossary.md | 3 +- ...05-kubernetes-stream-and-save-semantics.md | 19 +++++-- docs/saving.md | 30 +++++++++-- docs/why-a-gateway.md | 19 ++++--- gateway/kube/authz.go | 2 +- gateway/kube/authz_test.go | 2 +- gateway/kube/backend.go | 50 ++++++------------- gateway/kube/backend_test.go | 10 ++-- gateway/kube/e2e_test.go | 12 ++--- gateway/scripted.go | 2 +- gateway/seams.go | 8 +-- 16 files changed, 121 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index ea63717..bb77334 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ product can show live cluster state while people are editing it. - You want a **ready-made Kubernetes dashboard**. Use [Headlamp](https://headlamp.dev/). See [alternatives](docs/alternatives.md). - You want to **write to the cluster from the browser**. krm-stream is the read-and-edit half: it - captures a merge patch and version together. Your application validates and performs the write. See [saving edits safely](docs/saving.md). + captures a merge patch and version together. Your application validates and performs the write. + See [saving edits safely](docs/saving.md). ## What is KRM? diff --git a/SECURITY.md b/SECURITY.md index 5608400..6b52977 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,12 @@ not. ## Supported versions -Pre-1.0. Only the latest minor version receives fixes. The protocol and the API may still change. +Pre-1.0. The protocol and API may still change. + +| Release line | Receives security fixes | +|---|---| +| Latest released minor | Yes | +| Older minors | No | ## What counts as a vulnerability here diff --git a/conformance/README.md b/conformance/README.md index cfde0ee..28782cf 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,9 +1,8 @@ # conformance — the shared contract, executable -**One YAML file describes one scenario end to end**: what the Kubernetes watch does, what the gateway must therefore put on the wire, and what -a client that consumed that wire (plus some local edits) must then be holding. The Go suite and the -TypeScript suite load the *same* files. A protocol change that breaks either side fails both, in the -same commit. +**One YAML file describes one scenario end to end**: the Kubernetes watch input, the gateway's wire +output, and the client's resulting state after applying events and local edits. The Go and TypeScript +suites load the same files, so a contract change is checked on both sides in the same commit. ``` conformance/ @@ -81,10 +80,9 @@ name does not) obvious at a glance. ## The watch ops -`watch:` models conditions handled across the gateway pipeline — API-server watch behavior, browser -disconnects, and client-go cache tombstones. Where an operation maps to Kubernetes API behavior, the reference is -[docs/facts/kubernetes-api-concepts.md](../docs/facts/kubernetes-api-concepts.md), which is a reading -of the [API concepts page](https://kubernetes.io/docs/reference/using-api/api-concepts/) with links to upstream documentation and separate real-cluster evidence. +`watch:` models API-server events, browser disconnects and client-go cache tombstones. +The [API reference notes](../docs/facts/kubernetes-api-concepts.md) distinguish claims from the +Kubernetes API concepts page, client-go implementation details and recorded cluster observations. | op | means | the gateway must | |---|---|---| diff --git a/docs/auth.md b/docs/auth.md index 4a66b7c..849f73a 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -58,9 +58,11 @@ gateway.Handler(gateway.Options{ - `Clients` is a `ClientFor` callback supplying the backend for that identity and target. - `Scopes` allowlists targets and resources. A browser cannot supply a raw API-server URL. -A per-user backend can use the user's bearer token or Kubernetes impersonation. Impersonation requires -explicit host credentials with impersonation rights. Scope and disclosure policy remain host-owned -in either case; a projection does not grant permission to read or write a resource. +A per-user backend can use the user's bearer token or Kubernetes impersonation. Impersonation +requires explicit host credentials with impersonation rights. Scope and disclosure policy remain +host-owned in either case; a projection does not grant permission to read or write a resource. +Redaction is an additional disclosure restriction for an authorized caller, never a substitute for +verifying that caller may read the resource. ## Long streams, short tokens @@ -88,8 +90,10 @@ and API-server capacity; checks are not cached across identities. ## Shared-watch authorization -`SharedBackend` opens one upstream watch per scope as one service identity. Every subscriber must -be authorized independently before receiving the shared cache: +[`SharedBackend`](../gateway/shared.go) opens one upstream watch per scope as one service identity. +The host's `Authorizer` is then the only access check between a subscriber and the cached objects: +an overly permissive authorizer exposes the service identity's data to that subscriber. This is why +sharing is opt-in. Every subscriber must be authorized independently before receiving the cache: ```go shared := gateway.NewSharedBackend(serviceAccountBackend) @@ -97,8 +101,10 @@ opts.Authorizer = kube.SubjectAccessReviewAuthorizer(clientset, subjectOf) opts.Clients = func(context.Context, string, gateway.Principal) (gateway.Backend, error) { return shared, nil } ``` -`subjectOf` maps the principal to the Kubernetes username and groups. The adapter checks both `list` -and `watch`. An incomplete review is refused, and an explicit `Denied` wins over `Allowed`. +The [`SubjectAccessReviewAuthorizer`](../gateway/kube/authz.go) adapter delegates the decision to +Kubernetes. `subjectOf` maps the principal to the Kubernetes username and groups. The adapter checks +both `list` and `watch`. An incomplete review is refused, and an explicit `Denied` wins over +`Allowed`. The service account needs `create` on `subjectaccessreviews`; `system:auth-delegator` supplies that permission. Reviews do not require impersonation rights. These are SubjectAccessReview requests, diff --git a/docs/facts/kubernetes-api-concepts.md b/docs/facts/kubernetes-api-concepts.md index 5529684..18db430 100644 --- a/docs/facts/kubernetes-api-concepts.md +++ b/docs/facts/kubernetes-api-concepts.md @@ -1,10 +1,11 @@ # Kubernetes API reference notes Source: [Kubernetes API concepts](https://kubernetes.io/docs/reference/using-api/api-concepts/), -reviewed from upstream markdown on **2026-07-11**. These notes explain the upstream assumptions behind -[the protocol](../../spec/v1.md). They are separate from the -[recorded v1.36.2 cluster observations](observed-v1.36.2+k3s1.md), which establish behavior only for -that tested environment. +reviewed from upstream markdown on **2026-07-11**. These notes explain the upstream assumptions +behind [the protocol](../../spec/v1.md). They are separate from the [recorded v1.36.2 cluster +observations](observed-v1.36.2+k3s1.md), which establish behavior only for that tested environment. +API-concepts claims are distinguished below from `client-go`/`apimachinery` details, which are +attributed explicitly and are **not established by that page**. ## Watch events and partial objects diff --git a/docs/glossary.md b/docs/glossary.md index 2a40944..9d1d382 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -121,7 +121,8 @@ The write path is not the library's: `metadata.managedFields`, the last-applied annotation, and `status` under `ProjectionSpec`. It is what stops a buggy or hostile browser from destroying what it was never shown. Do not skip it on the grounds that the store is careful, because the store runs on the caller's machine. -8. The host writes with the captured UID and resourceVersion preconditions. The watch sees it, it returns down the stream as an ordinary update, +8. The host writes with the captured UID and resourceVersion preconditions. The watch sees it, it + returns down the stream as an ordinary update, and the merge converges your draft with it. Your own write needs no special handling. If you know TanStack Query or SWR, this is the same server cache with local edits, with two diff --git a/docs/proposals/0005-kubernetes-stream-and-save-semantics.md b/docs/proposals/0005-kubernetes-stream-and-save-semantics.md index 6d61e99..95fd130 100644 --- a/docs/proposals/0005-kubernetes-stream-and-save-semantics.md +++ b/docs/proposals/0005-kubernetes-stream-and-save-semantics.md @@ -26,6 +26,20 @@ There are three different facts a UI must not collapse into one “conflict” l The [saving guide](../saving.md#what-the-person-editing-sees) maps these distinctions to the editor outcomes. A refreshed base enables review; it cannot promise the next save succeeds. +## Why convergence excludes resourceVersion + +The earlier invariant could be read as whole-object equality, including resourceVersion. That +contradicted suppression: a final bookkeeping-only write, or status-only write under `krm-spec/v1`, +can advance the upstream version without changing anything the browser needs to receive. The held +version can therefore remain older indefinitely. + +[Spec §6](../../spec/v1.md#6-ordering-delivery--the-state-guarantee) retains equality at each +delivered logical stream position, but compares projected content excluding resourceVersion plus the +connection's redaction records. This narrows the stated guarantee to match existing emissions. +Restoring whole-object equality would require a different emission policy, not just simpler wording. +The [final-write fixtures](../../conformance/README.md#convergence-evidence) defend both version +suppression and delivery of changed redaction records. + ## Host write strategies The following is a design comparison, not a proposal to implement more save engines. @@ -75,9 +89,8 @@ provide user-to-user optimistic locking. Conversely, one manager per tab changes managedFields growth; it is not a free concurrency fix. A manager name is not authentication or RBAC. Spec §3 requires the host to define its intended managed field set and omission/deletion policy for -SSA. `ValidateMergePatch` and -`captureSave().patch` remain merge-patch-specific. The store's local keyed-list merge does not turn -that output into strategic merge patch or apply configuration. +SSA. `ValidateMergePatch` and `captureSave().patch` remain merge-patch-specific. The store's local +keyed-list merge does not turn that output into strategic merge patch or apply configuration. The [SSA design proposal](https://github.com/kubernetes/enhancements/blob/master/keps/sig-api-machinery/555-server-side-apply/README.md) also makes field management and schema topology central to this API. diff --git a/docs/saving.md b/docs/saving.md index c41d5ac..d148e79 100644 --- a/docs/saving.md +++ b/docs/saving.md @@ -75,9 +75,10 @@ sequenceDiagram ``` The displayed server content is right even though the held version is older. The local draft is the -person's proposed change; it is not part of stream convergence. A failed version precondition does -not by itself mean the person and server disagree about an editable field. Follow the -[save outcomes](#what-the-person-editing-sees) to refresh, reconcile and capture a newly reviewed intent. +person's proposed change; it is not part of stream convergence. In this conditional merge-PATCH +flow, a 409 signals a failed version precondition, not necessarily a disagreement at an editable +field. Follow the [save outcomes](#what-the-person-editing-sees) to refresh, reconcile and capture a +newly reviewed intent. | Final upstream change | What the gateway delivers | What the browser holds | |---|---|---| @@ -152,7 +153,8 @@ Kubernetes response: project it first and provide the correct redaction metadata redacted resources can return `redactedPaths` directly from `gateway.Project`. The guard retains known stream revisions for paths still present and removes paths absent from that list. Omitted redaction metadata preserves existing protections. Unknown paths reject the entire response: open a -later authoritative upsert for that UID or a fresh stream snapshot before retrying reconciliation. Never invent revision counters for a GET. +later authoritative upsert for that UID or a fresh stream snapshot before retrying reconciliation. +Never invent revision counters for a GET. An explicit `redacted` array is still supported when the host has authoritative stream revisions. ## Creating and deleting whole objects @@ -174,6 +176,26 @@ The host must: - Return 204 or a receipt and let the watch reflect the result. Project any returned resource before sending it to the browser, and preserve meaningful Kubernetes error categories. +For an authorized delete, preserve the UID captured when the user selected the object; do not +replace it with a newer GET's UID. This fragment uses the host's caller-scoped `dynamic.Interface`, +validated resource/namespace/name, and the request context: + +```go +// metav1: k8s.io/apimachinery/pkg/apis/meta/v1 +// types: k8s.io/apimachinery/pkg/types +uid := types.UID(capturedUID) +err := client.Resource(resource).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{ + Preconditions: &metav1.Preconditions{UID: &uid}, +}) +if err != nil { + return err // The host maps the structured Kubernetes error to its HTTP response. +} +``` + +This binds the delete to object identity. A host that also requires unchanged content can add a +captured resourceVersion precondition. For the complete GET/PATCH save path, use the [compiled +conditional-save handler](../gateway/kube/examples/conditionalsave/handler.go). + The host also clears its own pending-create/delete entries when a write succeeds. The store does not own those staging lists. See the [client state model](client-state-model.md#reflecting-the-result) for synchronous adoption and optimistic-delete caveats. diff --git a/docs/why-a-gateway.md b/docs/why-a-gateway.md index 9aed032..b8c6570 100644 --- a/docs/why-a-gateway.md +++ b/docs/why-a-gateway.md @@ -20,11 +20,14 @@ reconnection; a new connection receives a fresh snapshot. See the ## Optional watch sharing -Without sharing, each stream uses its own backend watch. `SharedBackend` can instead keep one -upstream watch per scope and serve each subscriber from its cache. A joining subscriber still -receives a complete projected snapshot, so sharing saves upstream work without eliminating browser -transfer or reconciliation costs. - -A shared watch uses one service identity. Pair it with `kube.SubjectAccessReviewAuthorizer` to check -each subscriber's Kubernetes permissions before serving the cache, and configure timed checks when -quiet-stream revocation must be bounded. See [authorization](auth.md) and [operations](operations.md). +Without sharing, each stream uses its own backend watch. [`SharedBackend`](../gateway/shared.go) can +instead keep one upstream watch per scope and serve each subscriber from its cache. A joining +subscriber still receives a complete projected snapshot, so sharing saves upstream work without +eliminating browser transfer or reconciliation costs. + +A shared watch uses one service identity, so the host's `Authorizer` becomes the only access check +between each subscriber and cached objects; a permissive check can disclose the service identity's +data. Sharing is opt-in for this reason. Pair it with +[`kube.SubjectAccessReviewAuthorizer`](../gateway/kube/authz.go) to check each subscriber's +Kubernetes permissions, and configure timed checks when quiet-stream revocation must be bounded. See +[authorization](auth.md) and [operations](operations.md). diff --git a/gateway/kube/authz.go b/gateway/kube/authz.go index f5aa094..9baba9e 100644 --- a/gateway/kube/authz.go +++ b/gateway/kube/authz.go @@ -55,7 +55,7 @@ type SubjectFor func(gateway.Principal) (Subject, error) // caller may `list` and `watch` that resource. // // BOTH verbs, and that is not belt-and-braces: a snapshot cycle is a list followed by a watch — quite -// literally so on the §3b path, where the gateway issues a real LIST — so a caller who may watch but +// literally so on the list-then-watch path, where the gateway issues a real LIST — so a caller who may watch but // not list can still be served objects by the list. Checking only `watch` would authorize half of // what we are about to do. // diff --git a/gateway/kube/authz_test.go b/gateway/kube/authz_test.go index ba38214..fbd671b 100644 --- a/gateway/kube/authz_test.go +++ b/gateway/kube/authz_test.go @@ -61,7 +61,7 @@ func TestSubjectAccessReviewAsksKubernetesTheRightQuestion(t *testing.T) { t.Fatalf("an allowed caller was refused: %v", err) } - // BOTH verbs. A snapshot cycle is a list THEN a watch — literally so on the §3b path, where the + // BOTH verbs. A snapshot cycle is a list THEN a watch — literally so on the list-then-watch path, where the // gateway issues a real LIST — so a caller who may watch but not list can still be handed objects // by the list. Checking only `watch` authorizes half of what we are about to do. verbs := map[string]bool{} diff --git a/gateway/kube/backend.go b/gateway/kube/backend.go index 7b74d0f..792002b 100644 --- a/gateway/kube/backend.go +++ b/gateway/kube/backend.go @@ -9,35 +9,18 @@ // // # Two paths, and both are required // -// The obvious reading of the Kubernetes docs is that a modern cluster gives you a streaming list -// (§3a) and that list-then-watch (§3b) is a compatibility shim for old ones. A real cluster says -// otherwise. `task cluster-facts` (F6) pointed a §3a request at Kubernetes' own sample-apiserver — -// an ordinary aggregated API on a current cluster — and it was refused outright: +// The backend supports streaming lists and list-then-watch. An aggregated API has its own feature +// gates and can reject sendInitialEvents even when the cluster API server accepts it. The backend +// detects that rejection and selects the fallback. See docs/facts/observed-v1.36.2+k3s1.md, F6. // -// ListOptions.meta.k8s.io "" is invalid: sendInitialEvents: Forbidden: -// sendInitialEvents is forbidden for watch unless the WatchList feature gate is enabled +// Both paths deliver WatchAdded snapshot objects, a WatchBookmark with InitialEventsEnd set, then +// live updates. Streaming lists receive the boundary from the API server; list-then-watch +// synthesizes it from the completed list. See spec/v1.md, Snapshot cycles. // -// An aggregated API server is a separate binary with its own feature gates; WatchList being on in -// kube-apiserver says nothing about it. So a backend that implements only §3a cannot open a stream -// for an aggregated resource AT ALL — and it would have failed in a user's cluster, not in our -// tests. Both paths ship, and the choice between them is DETECTED rather than configured: nobody -// should have to know which of their APIs is aggregated in order to watch it. -// -// What the two paths have in common is the only thing the gateway cares about: the snapshot arrives -// as WatchAdded events terminated by a bookmark whose InitialEventsEnd is set, and everything after -// that bookmark is live. On the §3a path the API server hands us that boundary. On the §3b path we -// synthesize it. That is precisely why the protocol names the BOUNDARY and not the mechanism. -// -// # The failure mode this does NOT defend against, and why -// -// A server could ACCEPT `sendInitialEvents` and then quietly ignore it — no synthetic ADDEDs, no -// terminating bookmark, so `synced` never fires and a browser never paints. We do not guard against -// that, and the omission is deliberate: the only possible guard is a timeout ("no bookmark in N -// seconds ⇒ assume §3b"), and N would be a guess that turns a slow cluster into a corrupt one. What -// we have instead is a stated environment: this gateway requires Kubernetes 1.35+ (README §3), where -// the option is not silently droppable. A server that accepts an option and ignores it is broken in -// a way that is not ours to paper over — and the honest response to a broken upstream is to be -// diagnosable, not to guess. +// A server that accepts sendInitialEvents but silently ignores it can leave a stream waiting for +// its snapshot boundary. The backend does not guess a fallback timeout: a slow snapshot must not +// be mistaken for an unsupported feature. See README.md, Requirements and maturity, for the +// supported Kubernetes environment. package kube import ( @@ -166,14 +149,13 @@ func (b *Backend) Watch(ctx context.Context, scope gateway.Scope) (gateway.Watch return nil, fmt.Errorf("krm-stream/kube: streaming list for %s: %w", b.upstream(scope), err) } - // F6, in production. This API is aggregated (or otherwise has WatchList off); §3b is not a + // F6, in production. This API is aggregated (or otherwise has WatchList off); list-then-watch is not a // fallback here, it is the only way in. b.rememberListThenWatch(gv) return b.listThenWatchStream(ctx, ri, scope) } -// streamingListOptions is §3a, and it is exactly the request the fact-finder verified against a real -// API server. ResourceVersion: "" means "the freshest state" — a consistent read — and is what makes +// streamingListOptions builds the request verified against the real API server. ResourceVersion: "" means "the freshest state" — a consistent read — and is what makes // the snapshot a snapshot rather than a replay from a stale point. func streamingListOptions(scope gateway.Scope) metav1.ListOptions { o := selectors(scope) @@ -221,7 +203,7 @@ func (b *Backend) rememberListThenWatch(gv schema.GroupVersion) { b.listThenWatch[gv] = true } -// listThenWatchStream is §3b: list at a resourceVersion, then watch from exactly there. +// listThenWatchStream lists at a resourceVersion, then watches from exactly there. // // The gap that everyone worries about does not exist, and the reason is worth stating: the watch is // opened at the LIST's resourceVersion, so the API server replays anything that happened in @@ -242,7 +224,7 @@ func (b *Backend) listThenWatchStream(ctx context.Context, ri dynamic.ResourceIn b.upstream(scope), list.GetResourceVersion(), err) } - // The snapshot, in the shape §3a would have delivered it — including the boundary bookmark, + // The snapshot, in the shape a streaming list would deliver — including the boundary bookmark, // which here is OURS to synthesize because the API server would not. The gateway cannot tell the // difference, and that is the entire point of the seam. snapshot := make([]gateway.WatchEvent, 0, len(list.Items)+1) @@ -260,7 +242,7 @@ func (b *Backend) listThenWatchStream(ctx context.Context, ri dynamic.ResourceIn return &prologueWatcher{queue: snapshot, live: &channelWatcher{w: w}}, nil } -// isSendInitialEventsRefused recognises the one refusal that means "this server cannot do §3a". +// isSendInitialEventsRefused recognises the one refusal that means "this server cannot serve a streaming list". // // Observed on a real aggregated API (F6) as a 422 Invalid naming the `sendInitialEvents` field. We // are liberal about the status code — a different server may say 400 or 403, and the docs promise @@ -318,7 +300,7 @@ func (c *channelWatcher) Next(ctx context.Context) (gateway.WatchEvent, error) { func (c *channelWatcher) Stop() { c.w.Stop() } // prologueWatcher plays a queue of events (the synthesized snapshot) and then delegates to the live -// watch. It exists so that §3b's caller — the stream loop — sees exactly the §3a event sequence. +// watch. The stream loop sees the same event sequence as it would from a streaming list. type prologueWatcher struct { queue []gateway.WatchEvent live gateway.Watcher diff --git a/gateway/kube/backend_test.go b/gateway/kube/backend_test.go index a6f9a77..a6a79ba 100644 --- a/gateway/kube/backend_test.go +++ b/gateway/kube/backend_test.go @@ -75,7 +75,7 @@ func newStub(t *testing.T) (*stubClient, *stubResource) { return &stubClient{ns: &stubNamespaceable{stubResource: res}}, res } -// refusal is the error a REAL aggregated API server returned when handed the §3a request +// refusal is the error a REAL aggregated API server returned when handed the streaming-list request // (docs/facts/observed-v1.36.2+k3s1.md, F6). Reproducing its exact shape is the point: the fallback // hangs off recognising it, and a hand-waved "some error" would prove nothing. func refusal() error { @@ -129,7 +129,7 @@ func drain(t *testing.T, w gateway.Watcher, n int) []gateway.WatchEvent { return got } -// §3a, the primary path: the exact request a real v1.36.2 API server accepted. +// Streaming list, the primary path: the exact request a real v1.36.2 API server accepted. func TestStreamingListSendsTheOptionsTheClusterVerified(t *testing.T) { client, res := newStub(t) fake := watch.NewFakeWithChanSize(3, false) @@ -201,7 +201,7 @@ func TestRoutineBookmarkIsNotTheBoundary(t *testing.T) { } } -// F6, and the bug this rung exists to have caught: an aggregated API refuses §3a outright. A gateway +// F6, and the bug this rung exists to have caught: an aggregated API refuses streaming-list outright. A gateway // that implemented only the streaming list could not open a stream for a Flunder AT ALL. func TestAggregatedAPIRefusalFallsBackToListThenWatch(t *testing.T) { client, res := newStub(t) @@ -229,7 +229,7 @@ func TestAggregatedAPIRefusalFallsBackToListThenWatch(t *testing.T) { defer w.Stop() // The live watch must resume at EXACTLY the list's resourceVersion — that is what closes the gap - // between the two calls, and it is the whole reason §3b is correct rather than merely plausible. + // between the two calls, and it is the whole reason list-then-watch is correct rather than merely plausible. live := res.watchOpts[1] if live.ResourceVersion != "42" { t.Errorf("the live watch opened at resourceVersion %q, want \"42\" (the list's) — that is a GAP", live.ResourceVersion) @@ -243,7 +243,7 @@ func TestAggregatedAPIRefusalFallsBackToListThenWatch(t *testing.T) { fake.Modify(obj("a", "uid-a", "43")) - // The stream loop must not be able to tell this from §3a: added, added, boundary, then live. + // The stream loop must not be able to tell this from streaming-list: added, added, boundary, then live. got := drain(t, w, 4) if got[0].Type != gateway.WatchAdded || got[0].Object.UID() != "uid-a" { t.Errorf("event 0 = %+v, want added uid-a", got[0]) diff --git a/gateway/kube/e2e_test.go b/gateway/kube/e2e_test.go index 1301086..cddfd79 100644 --- a/gateway/kube/e2e_test.go +++ b/gateway/kube/e2e_test.go @@ -89,7 +89,7 @@ func scratchNamespace(t *testing.T, cs kubernetes.Interface) string { return ns } -// stream runs a real Gateway over the real KubeBackend and hands back its events. +// stream runs a real Gateway over the real kube.Backend and hands back its events. func stream(t *testing.T, dyn dynamic.Interface, scope gateway.Scope) <-chan gateway.Event { t.Helper() ctx, cancel := context.WithCancel(context.Background()) @@ -144,7 +144,7 @@ func named(want gateway.EventType, name string) func(gateway.Event) bool { } } -// §3a against kube-apiserver: the streaming list, the path F1 verified. +// Streaming list against kube-apiserver, the path F1 verified. func TestRealClusterStreamingList(t *testing.T) { cs, dyn := clients(t) namespace := scratchNamespace(t, cs) @@ -205,7 +205,7 @@ func TestRealClusterStreamingList(t *testing.T) { } } -// §3b against an AGGREGATED API: the path that is not optional. +// List-then-watch against an AGGREGATED API: the path that is not optional. // // This is F6 as an executable claim. The test first proves the API server REFUSES the streaming list // — so that a future cluster quietly gaining WatchList cannot make this test pass for the wrong @@ -229,7 +229,7 @@ func TestRealClusterAggregatedAPIFallsBack(t *testing.T) { t.Fatalf("create fl-a: %v", err) } - // The premise, asserted rather than assumed: this API server does NOT do §3a. + // The premise, asserted rather than assumed: this API server refuses streaming lists. _, err := dyn.Resource(flunders).Namespace(namespace).Watch(ctx, metav1.ListOptions{ SendInitialEvents: ptr.To(true), AllowWatchBookmarks: true, @@ -237,9 +237,9 @@ func TestRealClusterAggregatedAPIFallsBack(t *testing.T) { }) if err == nil { t.Fatal("the aggregated API ACCEPTED sendInitialEvents — this cluster no longer reproduces F6, " + - "and this test is now proving nothing. Re-run `task cluster-facts` and re-read §3b.") + "and this test is now proving nothing. Re-run `task cluster-facts` and review docs/facts/observed-v1.36.2+k3s1.md.") } - t.Logf("as expected, the aggregated API refused §3a: %v", err) + t.Logf("as expected, the aggregated API refused the streaming list: %v", err) // And yet the gateway serves it: reset … added … synced, with a boundary WE synthesized. ch := stream(t, dyn, gateway.Scope{ diff --git a/gateway/scripted.go b/gateway/scripted.go index a025d6c..5460721 100644 --- a/gateway/scripted.go +++ b/gateway/scripted.go @@ -11,7 +11,7 @@ import ( // serves fixtures over real SSE with exactly this backend, so a browser can be pointed at a scripted // cluster that behaves identically every time. // -// It models a MODERN streaming list (gateway spec §3a), because that is what the gateway is written +// It models a streaming list (see spec/v1.md, Snapshot cycles), because that is what the gateway is written // against: the objects in scope arrive as synthetic ADDEDs, terminated by a bookmark whose // InitialEventsEnd is set. That bookmark IS `synced`. A `relist` op ends the watch with a // continuity-losing error, which is what a 410 Gone looks like from in here — and the gateway must diff --git a/gateway/seams.go b/gateway/seams.go index 12240de..b67981a 100644 --- a/gateway/seams.go +++ b/gateway/seams.go @@ -20,7 +20,7 @@ import ( // Principal is whoever the host says is calling. The library never inspects it, never persists it, // and never logs it — it carries it back to the host on ClientFor, so the host can reach the API -// server AS that caller (gateway spec §6). `any` is not laziness here: the moment this library has +// server AS that caller (see docs/auth.md). `any` is not laziness here: the moment this library has // an opinion about what an identity looks like, it has an opinion about someone's auth system. type Principal any @@ -61,9 +61,9 @@ type ClientFor func(ctx context.Context, target string, principal Principal) (Ba // Backend is the upstream: one Kubernetes API server (or anything that behaves like one). // // Watch opens a snapshot-then-live stream for a scope. The gateway expects it to behave like a -// modern streaming list (gateway spec §3a): the objects currently in scope arrive as WatchAdded, +// streaming list (see spec/v1.md, Snapshot cycles): the objects currently in scope arrive as WatchAdded, // terminated by a WatchBookmark whose InitialEventsEnd is true, and everything after that bookmark -// is live. A list-then-watch backend synthesizes exactly the same shape (§3b) — which is the point +// is live. A list-then-watch backend synthesizes exactly the same shape — which is the point // of naming the boundary rather than the mechanism. type Backend interface { Watch(ctx context.Context, scope Scope) (Watcher, error) @@ -74,7 +74,7 @@ type Backend interface { // Pull, not a channel, and this is a considered choice: Next returning is the gateway's proof that // it finished with the previous event, which makes both the conformance replay and the coalescing // logic deterministic instead of racy. A channel-based client-go watch adapts to this in ten lines -// (see KubeBackend); the reverse — recovering a synchronisation point from a channel — is not +// (see gateway/kube.Backend); the reverse — recovering a synchronisation point from a channel — is not // possible at all. type Watcher interface { // Next blocks until the next upstream event, ctx is done, or the watch ends. From 189ef777b52b739cc55a0ce923adb2d57a4b1dad Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 11 Sep 2026 12:57:25 +0000 Subject: [PATCH 3/3] docs: define measurable lifecycle acceptance criteria --- .../0006-stream-and-save-implementation-plan.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index b56875a..e089f73 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -128,8 +128,19 @@ Documentation-only edits need link and diagram checks, not a cluster rebuild. Consumer acceptance remains separate: pin npm and both Go modules, check consumer CI/image toolchains, and exercise concurrent editing, later typing during saves, recovery, session expiry and -UID replacement in the browser. Reauthorization intervals, timeouts and termination targets require measurement under the host -workload with bounded callbacks and sinks. +UID replacement in the browser. Before lifecycle testing, record the host's reauthorization interval, +check timeout, maximum revocation-to-stream-closure time and concurrent subscriber workload. +Use a reference acceptance profile of 30-second rechecks, a 5-second check timeout and closure within +60 seconds at 200 subscribers. These are measurement targets, not library defaults or guarantees; +hosts choosing another profile must declare their limits before testing. + +Under the declared workload, revoke access or expire a session just after a successful check, and +separately stall an authorization callback until its context expires. Pass only if every affected +stream terminates within the declared closure limit, measured from revocation/session expiry or +the start of the stalled check, respectively. Verify callbacks honor the configured check deadline +and sinks have bounded completion times. Exercise quiet and active streams; cycle-only checks cannot +meet a bounded quiet-stream revocation target. See [authorization lifecycle](../auth.md#long-streams-short-tokens) +for configuration and host responsibilities. Version-only events, independent content/delivery switches, downstream replay, write tickets, automatic conflict-free retry and a general SSA abstraction remain deferred until a concrete use