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
55 changes: 26 additions & 29 deletions docs/saving.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,38 @@ func (s *server) saveConfigMap(w http.ResponseWriter, r *http.Request) {
}
```

## Answer 204 and let the watch echo it
## What the guard rejects

- a value declared in `redacted`, including deletion of a parent map such as `data: null`;
- `metadata.managedFields` and the last-applied-configuration annotation, which every projection
removes;
- `status` under `krm-spec/v1`;
- a non-object or malformed JSON merge patch.

It does not grant write permission, choose a projection, fetch the object, issue a PATCH, or implement
optimistic concurrency. Those stay with the host. Do not use whole-object `PUT`: projected objects are
intentionally incomplete, and a `PUT` can delete fields the browser never saw.

`metadata.resourceVersion` may be stale when `krm-spec/v1` suppresses invisible status churn. Do not
use the streamed value as a write precondition. The client-side three-way merge surfaces conflicts in
the fields the user can see; send only the user's explicit merge-patch changes.

This is the recommended shape, and it is the one to reach for unless you have a specific reason not
to.
## Answer 204 and let the watch echo it

The object returned by a Kubernetes write is a *raw* object: `managedFields`, the last-applied
annotation, `status`, and the Secret values your projection withholds. Writing it to the response
hands the browser, through your save endpoint, precisely what the stream spent its whole design
refusing to send. The save endpoint is not covered by the projection unless you cover it.
The object a Kubernetes write returns is a *raw* object: `managedFields`, the last-applied annotation,
`status`, and the Secret values your projection withholds. Writing it to the response hands the
browser, through your own save endpoint, exactly what the stream is designed to refuse. The save
endpoint is not covered by the projection unless you cover it.

You do not need to. The write goes to the API server, the watch sees it, and it arrives back down the
stream as an ordinary `modified` eventprojected, redacted, three-way merged into the draft the user
is still holding. The store converges on its own. Dirty state is derived from `draft` versus `server`,
so there is nothing to clear and nothing to adopt: the echo settles it.
You do not need to. The write reaches the API server, the watch sees it, and it comes back down the
stream as an ordinary `modified` event: projected, redacted, and three-way merged into the draft the
user is still holding. Dirty state is derived from `draft` versus `server`, so there is nothing to
clear and nothing to adopt. The echo settles it.

## If you must answer with the object

`store.adoptSaved(object)` exists for a host that already holds a **projected** object — a host doing
its own optimistic update, or one that cannot wait a round-trip for the echo. Project it first:
`store.adoptSaved(object)` is for a host that already holds a **projected** object, such as one doing
its own optimistic update. Project it first:

```go
projected, redacted := gateway.Project(gateway.ProjectionFull, result)
Expand All @@ -64,22 +77,6 @@ writeJSON(w, projected)
`gateway.Project` applies the same projection the stream applies. Never hand `adoptSaved` an object
straight from the Kubernetes client.

The guard rejects:

- a value declared in `redacted`, including deletion of a parent map such as `data: null`;
- `metadata.managedFields` and the last-applied-configuration annotation, which every projection
removes;
- `status` under `krm-spec/v1`;
- a non-object or malformed JSON merge patch.

It does not grant write permission, choose a projection, fetch the object, issue a PATCH, or implement
optimistic concurrency. Those stay with the host. Do not use whole-object `PUT`: projected objects are
intentionally incomplete, and a `PUT` can delete fields the browser never saw.

`metadata.resourceVersion` may be stale when `krm-spec/v1` suppresses invisible status churn. Do not
use the streamed value as a write precondition. The client-side three-way merge surfaces conflicts in
the fields the user can see; send only the user's explicit merge-patch changes.

## Creating and deleting whole objects

A create and a delete are host writes exactly as a save is, and they stay host-side for the same
Expand Down
23 changes: 11 additions & 12 deletions docs/why-a-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,20 @@ 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,
and it delivers 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. Close a floor of laptop lids and
reopen them and the reconnect storm arrives at the API server multiplied by the number of tabs.
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, and the reason is a real trade. A shared watch can be opened only once, so it can
be opened as only one identity: your service account. Without sharing, the client acts as the caller
and Kubernetes RBAC is the enforcement, so no bug in this library can hand a caller an object they
may not see. With sharing, your `Authorizer` becomes the only thing between a caller and the cache.
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.

There is a way to take the fan-out without giving up the boundary. Pair `SharedBackend` with
[`kube.SSARAuthorizer`](../gateway/kube/authz.go), which asks the API server, through a
SubjectAccessReview, whether this user may list and watch this resource here, before the subscriber
is served from the shared cache. Kubernetes decides again, per user, per snapshot cycle, and the
sharing costs one round-trip. Read [auth.md](auth.md) before wiring it.
You can have both. Pair `SharedBackend` with [`kube.SSARAuthorizer`](../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.