Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,19 @@ 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.

Expand All @@ -55,11 +54,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.

Expand Down
12 changes: 6 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +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.

| Version | Supported |
| Release line | Receives security fixes |
|---|---|
| 0.1.x | yes |
| < 0.1 | no |
| Latest released minor | Yes |
| Older minors | No |

## What counts as a vulnerability here

Expand All @@ -34,8 +34,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

Expand Down
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 6 additions & 16 deletions conformance/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
# 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
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/
Expand Down Expand Up @@ -84,11 +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/) rather than a
reading of anyone's memory. That distinction has already cost us two bugs.
`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 |
|---|---|---|
Expand Down Expand Up @@ -178,7 +172,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.
3 changes: 0 additions & 3 deletions conformance/bodies/secret-token.v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 4 additions & 20 deletions conformance/fixtures/bookmark-absorbed.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 3 additions & 15 deletions conformance/fixtures/partial-object-refused.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 5 additions & 25 deletions conformance/fixtures/resourceversion-bignum.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions conformance/gen/fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down Expand Up @@ -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"
],
Expand Down Expand Up @@ -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"
],
Expand Down
Loading