diff --git a/Taskfile.yml b/Taskfile.yml index 3f95fd8..ab55a51 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -7,8 +7,8 @@ vars: IMAGE: '{{.IMAGE | default "ghcr.io/configbutler/krm-foyer"}}' # The krm-stream browser bundle the hello example vendors, and npm's integrity for # its package. Bump both together, then `task vendor-krm-stream`. - KRM_STREAM_VERSION: 0.7.0 - KRM_STREAM_INTEGRITY: sha512-LFXyJIv4UmlBBEJEdx68wzwaqUIVai5HbDe70ACTWIpFZexlJbd/wyhzryoW5uBg55y/f+dZ4bapajybPJ7c0w== + KRM_STREAM_VERSION: 0.10.0 + KRM_STREAM_INTEGRITY: sha512-Wjl0sq6he959ximL+0wHU1on+mfVfT+MyrJucp+sfd7idepOdBFjwWSv43L34oVRsY/1+QFZcheaoiubPlmEFQ== VERSION: sh: git describe --tags --always --dirty 2>/dev/null || echo dev diff --git a/docs/investigations/adopting-krm-stream-native-watches.md b/docs/investigations/adopting-krm-stream-native-watches.md new file mode 100644 index 0000000..d160da9 --- /dev/null +++ b/docs/investigations/adopting-krm-stream-native-watches.md @@ -0,0 +1,322 @@ +# Native watches from krm-stream: what changed, how it works, and adopting it + +**Written 2026-10-06** against krm-stream `main` at `14d4ff4`, with gateway and npm +0.9.0 published and the 0.10.0 release pending in +[krm-stream#71](https://github.com/ConfigButler/krm-stream/pull/71). krm-foyer pins +**0.7.0** (Go gateway in `go.mod`, browser bundle in `Taskfile.yml`). Nothing here is +implemented in krm-foyer yet. + +**Update 2026-10-06:** krm-stream 0.10.0 is released, and krm-foyer pins it: step 1 +below is done, still on the gateway's SSE stream. Steps 2–5 remain. + +## In short + +On 2026-10-05 we asked krm-stream for a browser connector that reads a native +Kubernetes collection through a host proxy and feeds krm-stream's store and editor +([the request](krm-stream-native-connector-request.md)). krm-stream built it, in four +pieces: + +1. **One connection lifecycle** shared by every connector (0.8.0, #53). +2. **`connectNativeWatch`**: LIST, then WATCH, through a proxy such as `/k8s` + (0.8.0, #60). +3. **Native editing**: conditional merge PATCHes through the same proxy (0.8.0, #61). +4. **Resumed watches**: a dropped native watch picks up where it left off, with no + new LIST and no new snapshot (0.10.0, #66, not yet released). + +For krm-foyer this needs **no new route and no server change**: `/k8s` already serves +LIST and WATCH with the user's own token, inside the bounds. The work is a dependency +bump with renamed APIs (mechanical), an example page that uses the native connector, +browser specs that try to get past the session and RBAC boundaries, and docs that stop +calling the connector "requested". + +**Recommendation:** adopt 0.10.0, not 0.9.0. Resuming +is what makes native watches cheaper than the gateway on reconnect, and the main reason +to offer them. + +## What changed in krm-stream since 0.7.0 + +| Release | What | Effect on krm-foyer | +| --- | --- | --- | +| 0.8.0 | One browser connector, `connectResourceStream(url, callback)`; the old `connectManagedResourceStream(url, store)`, `onSynced`, `onStateChange` and friends are removed | The hello example must be rewritten to the new names | +| 0.8.0 | Gateway settings move into one embedded `gateway.StreamConfig`; `NewSharedBackendWithOptions` becomes `NewSharedBackend(upstream, options)` | `internal/stream/stream.go` and `shared.go` change, mechanically | +| 0.8.0 | `connectNativeWatch` with `nativeCollectionURL` | New: a native page needs no list/watch code of its own | +| 0.8.0 | Native editing with `nativeObjectURL`; `gateway.ValidateNativeMergePatch` for a host that wants to check patches | New; see [the patch check](#should-k8s-check-native-patches) | +| 0.8.0 | The store refuses edits to `metadata.managedFields` and the last-applied annotation under every policy; `annotations` is edited key by key | The hello example edits `spec` only, so unaffected | +| 0.8.0 | An exception in a callback, `subscribe` or `onError` stops the stream and rejects `closed` | Attach `connection.closed.catch(...)` | +| 0.9.0 | An example page for native editing | Nothing | +| 0.10.0 | Native watches resume from a checkpoint | The point of the exercise | + +krm-stream's [upgrade guide](https://github.com/ConfigButler/krm-stream/blob/main/docs/migrating.md) +lists every rename. + +## How it works now + +### Two sources, one lifecycle, one store + +```mermaid +flowchart LR + subgraph Browser + N[connectNativeWatch] --> L[shared lifecycle
states, retries, close] + G[connectResourceStream] --> L + L -->|reset / added / modified / deleted / synced| S[LiveResourceStore
server state, draft, conflicts] + end + N -->|GET /k8s/...: LIST, then WATCH| F[krm-foyer] + G -->|GET /stream/v1?...: SSE| F + F -->|user's token| K[API server] + F -->|user's token, or shared identity + SubjectAccessReview| K +``` + +Both connectors use `fetch`, so the session cookie goes along and no token reaches the +browser. Both hand the page the same five events and the same connection states +(`connecting`, `syncing`, `live`, `retrying`, `closed`, `terminal`, `exhausted`). The +page code that renders a list or an editor does not care which source fed the store. +What differs is what arrives: + +| | Native (`/k8s`) | Gateway (`/stream/v1`) | +| --- | --- | --- | +| Objects | Exactly what the API server returns: `status`, `managedFields`, Secret values | A named view: `krm-full/v1`, `krm-spec/v1` or `krm-raw/v1`, machinery always removed | +| Updates | Every change | Changes to the view only (`krm-spec/v1` drops status-only updates) | +| Watches at the API server for N tabs on one scope | N | N, or 1 when the resource is shared | +| After a dropped connection | Resumes the watch: only what changed arrives | A fresh snapshot, every time | +| Who decides what the user sees | The API server, on the user's own request | The same, or a SubjectAccessReview when shared | + +### The native connection, step by step + +1. **LIST** the collection, e.g. `GET /k8s/apis/hello.krm-foyer.example/v1/namespaces/demo/notes`. + The connector delivers `reset`, then `added` for every item. The state is `syncing`. +2. **WATCH** from the list's `resourceVersion`, with `allowWatchBookmarks=true` and the + same selectors. Only once the API server accepts the WATCH does the connector deliver + `synced` and become `live`. A page enables Save only while `live`. +3. **Live**: watch events become `added`, `modified`, `deleted`. Bookmarks change no + object. After every event the store has applied, and every bookmark, the connector + remembers that `resourceVersion` as its **checkpoint**. +4. **The watch ends** (krm-foyer's 30-minute response limit, the API server's own + timeout, a network blip, a 5xx). The connector waits its backoff and opens + `WATCH ?resourceVersion=`. No LIST, no `reset`, no `synced`. The state + goes `connecting` → `live`; the store keeps every object, draft and conflict, and + what changed meanwhile arrives as ordinary events, deletions included. +5. **History expired** (HTTP or in-stream 410): the checkpoint is thrown away, and the + connector goes back to step 1. The old objects stay visible until the new LIST is + complete, and only then are the missing ones pruned. +6. **Refused** (401, 403, any other 4xx): terminal. No retry, no fallback to another + source. The page shows why, and a new connection is the page's decision. + +The checkpoint is private to the handle: never exposed, never accepted from the page, +never shared. So it cannot be replayed under another user's session or URL. + +Two limits in this release: no **pagination** (a LIST that comes back with a +`continue` token is refused, terminally, before anything is applied) and no +**streaming lists** (`sendInitialEvents`). Both are planned upstream. krm-foyer does +not paginate unasked, so a collection is one page unless the page's code sets `limit`, +which the connector forbids anyway. + +### Why a LIST first, and not a streaming list + +A WATCH with `sendInitialEvents=true` would replace LIST + WATCH with one request. +krm-stream evaluated it and built LIST-then-WATCH first, because only that works +everywhere: an aggregated API server refused the streaming list on a real cluster +(`sendInitialEvents is forbidden for watch unless the WatchList feature gate is +enabled`, [observation F6](https://github.com/ConfigButler/krm-stream/blob/main/docs/facts/observed-v1.36.2+k3s1.md)), +so a streaming-list connector needs LIST-then-WATCH as its fallback anyway. With +resuming, a LIST is left only on the first connection and after a 410, which is all a +streaming list would make cheaper. It is planned +([proposal 0006, item 3](https://github.com/ConfigButler/krm-stream/blob/main/docs/proposals/0006-stream-and-save-implementation-plan.md)), +with its own rule: the snapshot is complete only at the `k8s.io/initial-events-end` +bookmark. Until then the connector refuses `sendInitialEvents` in a caller's URL. +Either way `/k8s` passes the request through unchanged. + +### Where the gateway fits + +The native path does not use the Go gateway: it is the browser, `/k8s` and the API +server. What the two paths share is browser code, the lifecycle and the store. The +gateway has no native handlers that change what a native watch does; its one native +function is `ValidateNativeMergePatch`, an optional check for a proxy +([not in `/k8s`](#should-k8s-check-native-patches)). In krm-foyer the gateway keeps +serving `/stream/v1`, for projected views and shared watches, unchanged. + +### Native editing + +The store and its four steps are the same as for a gateway page; what differs is who +writes the preconditions: + +```js +const intent = store.captureSave(uid); // before any await +if (intent) { + const metadata = { ...intent.patch.metadata, uid: intent.uid, resourceVersion: intent.resourceVersion }; + // In krm-foyer: through foyer.js, which adds the CSRF proof a change needs. + await k8s(nativeObjectURL('', { ...scope, namespace, name }), { + method: 'PATCH', contentType: 'application/merge-patch+json', + body: { ...intent.patch, metadata }, + }); +} +``` + +The `uid` and `resourceVersion` inside the patch make the API server refuse it with +409 if anyone wrote in between, or if the object was replaced under the same name. +Because a native source suppresses nothing, the store always holds the latest version, +so the gateway's "quiet stream" 409 (status moved on unseen under `krm-spec/v1`) +does not happen here. A 409 is a real concurrent write. + +## What improved, in numbers + +From krm-stream's +[comparison run](https://github.com/ConfigButler/krm-stream/blob/main/docs/facts/comparison-2026-10-05.md), +real API server, 11 objects per subscriber, two forced reconnects per workload. +One machine, small collections: a witness, not a capacity figure. + +**Resuming (0.10.0) against re-listing (0.9.0), native, 10 subscribers:** + +| Workload | LISTs | Snapshots | KiB to the browser | Time back to `live` (median) | +| --- | --- | --- | --- | --- | +| quiet (only the reconnects) | 40 → 0 | 40 → 0 | 190 → 0 | 20 → 7.5 ms | +| churn (214 writes) | 40 → 0 | 40 → 0 | 2302 → 2113 | 13 → 6 ms | +| forced disconnect ×6 | 120 → 0 | 120 → 0 | 1295 → 723 | 12 → 5 ms | + +What a reconnect saves is one LIST of the collection, so it grows with collection size +and reconnect frequency, not with the write rate. Every write still arrives once. + +**Native against the gateway views, same workload, 10 subscribers:** + +| Workload | Native | `krm-full/v1` | `krm-spec/v1` | +| --- | --- | --- | --- | +| quiet: KiB to the browser | 0 | 103 | 93 | +| churn: KiB to the browser | 2112 | 1100 | 152 | +| churn: object updates delivered | 2140 | 2138 | 150 | + +So neither wins outright. Native is cheapest where little changes and connections +drop; the gateway is cheapest where a controller churns status, because it removes +`managedFields` and (with `krm-spec/v1`) status updates altogether. Shared gateway +watches cut API-server bytes tenfold in that run (2311 → 231 KiB); native never +shares. + +## What it takes in krm-foyer + +### 1. Bump krm-stream to 0.10.0 (mechanical, done) + +- **Go**, `internal/stream/stream.go`: move `Authorizer`, `Clients`, `Projections`, + `Diagnostics`, `WriteTimeout` and the reauthorization fields into + `StreamConfig: gateway.StreamConfig{...}`. Assignments such as + `opts.Authorizer = sh.authorize` keep working through field promotion. + `internal/stream/shared.go`: `NewSharedBackendWithOptions` → `NewSharedBackend`. + `ServeStream`/`Stream` signature changes do not touch krm-foyer, which uses + `gateway.Handler`. +- **Browser**, `examples/hello/web/app.js`: `connectManagedResourceStream(url, store, + {onSynced, onStateChange, onError})` becomes `connectResourceStream(url, event => + applyStreamEvent(store, event), {onError})`, with `connection.subscribe(showConnection)` + for state and a `synced` event for the first snapshot. Add `closed.catch`. +- **Vendoring**: `KRM_STREAM_VERSION` and `KRM_STREAM_INTEGRITY` in `Taskfile.yml`, + then `task vendor-krm-stream`. +- **Tests**: existing unit, e2e and browser specs should pass unchanged; that is the + check that nothing behaves differently. No server behaviour changes. + +Size: small. Commit as `fix(deps):` or `feat:`; krm-foyer's own interface does not +change. + +### 2. Nothing new on the server + +`/k8s` already does what the connector needs, checked against the code: + +- **LIST and WATCH** pass through as the user, `FlushInterval: -1`, so each watch + event reaches the browser as it arrives. +- **Bounds** already count a native watch as one concurrent request + (64 per session, 2000 per replica by default), hold it to the 30-minute response + duration, and abort it when the session ends. The response duration now works + *with* the connector: the watch is cut, the connector resumes from its checkpoint, + and the user sees nothing. The same goes for the 32 MiB response-bytes bound, which + a long watch under churn can reach: cut, resumed, unnoticed. +- **Logout**: the session check aborts the watch; the connector tries to resume; + krm-foyer answers 401; the connector stops, terminally. One request, no loop. +- **Request rate**: each resume is one request against the session's 20/s (burst 100), + inside the connector's backoff and bounded retry budget. + +#### Should `/k8s` check native patches? + +krm-stream's guide says the host proxy should run `gateway.ValidateNativeMergePatch` +(both preconditions present, no machinery fields). **Recommendation: not in `/k8s`.** +`/k8s` is the Kubernetes API with the user's token and nothing more; refusing an +unconditional PATCH that RBAC allows is the second set of rules the +[design](../design.md#access) avoids, and it would break `kubectl`-style clients. The +check is not a security boundary either: the user can PATCH the same object without +the store. The store already refuses machinery edits, and the API server enforces the +preconditions when they are sent. Record this as a deliberate difference in +`docs/design.md` and say why. + +### 3. A page that uses it + +The hello example's notes are a small custom resource, which suits native access. Two +options: + +- **A source switch on hello** (`?source=native`, default stays the gateway). One page, + the same rendering and editing code, both sources under the same session, logout and + RBAC specs. Shows the point "the page does not care which source" directly. +- **A second example.** Cleaner to read, but duplicates the page and its specs. + +Recommendation: the switch. Writes on the native path go through `foyer.js`'s `k8s()` +with `nativeObjectURL('', ...)`, so the CSRF proof is added and 409/403 keep their +existing outcomes. A 409 recovers with a guarded native GET +(`store.captureReconciliation`), which `/k8s` serves as is; the gateway path keeps its +current "reopen and save again" recovery. + +### 4. Specs that try to get past the boundaries + +Browser specs (chromedp, label `browser`), each checked against a deliberately broken +example as the existing ones are: + +- A change made elsewhere appears without a reload (native). +- **Resume**: the connection is cut (a short `-max-response-duration` in a dedicated + spec, or the session-check abort path); the store keeps the draft, no new LIST + reaches krm-foyer, and a change made during the gap arrives. +- **Logout** ends the native watch, and the resume attempt is refused once, terminally: + no retry loop. The audit log witnesses no further watch for that user. +- **RBAC**: a user who may not list notes gets a terminal `FORBIDDEN` on the native + source, and the page does **not** fall back to the gateway (and the gateway page does + not fall back to native). +- **Conflict**: two users edit; the second native save gets 409 and keeps its draft. + +The rehearsal (200 identities) stays gateway-only unless we decide to measure native +watches; [watches](../watches.md) says why they were not measured. + +### 5. Docs that change in the same PR + +- [watches.md](../watches.md): the native column becomes "krm-stream's native + connector"; "requested, not yet shipped" goes; the choosing guide gains the + trade-off from [the numbers](#what-improved-in-numbers). +- [bounds.md](../bounds.md#native-watches): the client library now exists; the + response duration and logout behaviour above. +- [design.md](../design.md): the streams section, and the patch-check decision. +- [roadmap.md](../roadmap.md): a checked item under Streams. +- [The request](krm-stream-native-connector-request.md): marked delivered, with what + was and was not (pagination, streaming lists). + +### Worth checking while doing it + +- **HTTP/2 in the e2e front door.** [ingress.md](../ingress.md) asks for HTTP/2 to the + browser, because HTTP/1.1 allows six connections per origin and each watch holds + one. `test/e2e/cluster/nginx-door.conf` says `listen 8443 ssl;` without `http2 on;`, + so the fixture's browser may be on HTTP/1.1. krm-stream's comparison page hit exactly + this: six streams, and the next save never left the browser. A page with a + native and a gateway connection is fine; one with several collections is not. +- **Large collections.** Until krm-stream paginates, a native source is for + collections that fit one LIST response under krm-foyer's 32 MiB response bound. + +## What native watches do not give + +Say these plainly in `watches.md`, so nobody picks native for the wrong reason: + +- **No redaction.** A user who can read a Secret gets its values. That is RBAC's + answer, not a leak, but a page that only needs to know a Secret *changed* should use + `krm-full/v1`. +- **No suppression.** Every status update and every `managedFields` change reaches + the browser and the store. +- **No sharing.** N tabs are N watches at the API server. +- **RBAC revocation** behaves as for a per-user stream: an open watch carries on until + it ends. With krm-foyer's response bound that is at most 30 minutes; the resume is a + new request, so the API server decides again at that point. + +## Proposed order + +1. **PR 1** (done): the bump (step 1). Existing specs + prove nothing changed. +2. **PR 2**: the hello source switch, its specs and the doc updates (steps 3–5). + +Both are small; PR 2 carries the risk, and its security specs are most of its size. diff --git a/docs/investigations/krm-stream-native-connector-request.md b/docs/investigations/krm-stream-native-connector-request.md index 046e739..ffdd850 100644 --- a/docs/investigations/krm-stream-native-connector-request.md +++ b/docs/investigations/krm-stream-native-connector-request.md @@ -2,6 +2,10 @@ **Draft for the krm-stream team, 2026-10-05. Not submitted upstream.** +**Update 2026-10-06:** krm-stream built Deliverables 1 and 2 (0.8.0, and native resume +in 0.10.0), without pagination or streaming lists. What it takes to adopt +them here: [adopting native watches](adopting-krm-stream-native-watches.md). + Suggested issue title: **feat(client): add a native Kubernetes watch connector with the shared frontend lifecycle** diff --git a/docs/investigations/stream-investigate.md b/docs/investigations/stream-investigate.md new file mode 100644 index 0000000..98958bb --- /dev/null +++ b/docs/investigations/stream-investigate.md @@ -0,0 +1,250 @@ +# Could `/k8s` share watches without anyone noticing? + +**Investigation, 2026-10-06, revised the same day.** Kubernetes facts are from the +Kubernetes repository at `35fc3af1380` (`v1.38.0-alpha.1-235`, checked out in +`external/kubernetes`) and from the `k8s.io/apiserver` and `k8s.io/apimachinery` +v0.36.0 modules krm-foyer already builds against. Paths are relative to +`staging/src/k8s.io/` unless they start with `pkg/`. Nothing here is implemented. + +The first version of this document said a transparent shared `/k8s` was not +workable, because a proxy cannot compare resource versions. That was wrong for +watches: Kubernetes now ships a comparison, and Kubernetes' own client-go uses it. +This version replaces that analysis. + +## The question + +A room of 200 people scans a QR code and opens the same page. Each browser LISTs the +notes and then WATCHes them through `/k8s`, so the API server holds 200 watches of one +collection. krm-stream's gateway avoids that by serving everyone from one upstream +watch, but it does so with its own SSE protocol, which is one more way to do things. + +Could `/k8s` notice that many people make the same request, open **one** watch, and +send each of them the same data, without any client being able to tell? Is that not +the same game as the gateway's shared watches? + +## Short answer + +- **For WATCH, yes, soundly**, on resources whose resource versions are integers + (everything stored in etcd: built-in resources and CRDs). One upstream watch per + `(resource, namespace, selectors, encoding)` can serve every request that watches + from a resource version at or after the point where the shared watch started. Each + client gets exactly the events after its own version, the same events the API server + would have sent it. +- **For LIST, it is not worth it.** The API server already serves consistent LISTs + from its cache, and people in a room arrive over tens of seconds, not milliseconds. + LISTs stay pass-through. +- **Visible to an operator, not to a client.** The API server's audit log shows the + shared watch and a SelfSubjectAccessReview per user, instead of a watch per user. + krm-foyer must also start telling watches apart, using the API server's own parser. +- **It is the same game as the gateway's sharing, minus the protocol.** The gateway + can admit a subscriber at any moment because its protocol defines what a subscriber + gets: a snapshot from the gateway's cache. The native protocol defines freshness + relative to the client's own request, so the proxy may only serve what the API + server would have served. Comparable resource versions are what make that check + possible. +- **It reduces the number of ways.** Pages use one browser connector, krm-stream's + native one, against `/k8s`. Whether a resource is shared becomes a deployment + setting, the same `-shared-watch-resources` that exists today, not a choice each page + makes. The gateway remains for pages that need a projected view (redaction, + suppression). + +**Recommendation:** measure the room first (an experiment is +[below](#measure-first)). If the watches hurt, build this in `/k8s` rather than asking +pages to switch protocol. + +## Why a watch, not a LIST, is the thing to share + +### What Kubernetes already shares + +| Mechanism | What it does | Where | +| --- | --- | --- | +| **Watch cache (`Cacher`)** | "Responsible for serving WATCH and LIST requests for a given resource from its internal cache." One storage watch per resource, however many clients watch it | `apiserver/pkg/storage/cacher/cacher.go` | +| **`cachingObject`** | "Able to cache its serializations so that each of those is computed exactly once": a thousand JSON watchers of one event cost one JSON encoding | `apiserver/pkg/storage/cacher/caching_object.go` | +| **Consistent LIST from cache** | A LIST without a `resourceVersion` is served from the watch cache, which first proves it is fresh with an etcd progress notification (`RequestWatchProgress`). GA and locked on since 1.34 | `apiserver/pkg/features/kube_features.go` (`ConsistentListFromCache`) | +| **Watch history** | At least `DefaultEventFreshDuration` (bookmark frequency, one minute, plus 15 s), from 100 up to 102 400 events | `apiserver/pkg/storage/cacher/watch_cache.go` | + +A LIST is therefore a read from memory, charged by APF (API Priority and Fairness, the +API server's request throttling) by object count, and then it is over. Two hundred +LISTs of a small collection spread over a minute are not a storm. + +### What a watch keeps costing + +Per open watch, the API server keeps a watcher with its own goroutine and buffer +(`cache_watcher.go`). It filters every event of the resource for that watcher and writes +it to that connection. A watcher that cannot keep up is terminated: "Since we don't want +to block on it infinitely, we simply terminate it." + +APF makes the cost land on writers: `apiserver/pkg/util/flowcontrol/request/mutating_work_estimator.go` +charges every write `ceil(watchers / 10)` extra seats for 5 ms once its resource has +10 or more watchers (`watchesPerSeat = 10`). Its comment: "the actual work associated +with processing watch events is happening in multiple goroutines (proportional to the +number of watchers)". + +So 1800 watches on one resource (the size of krm-foyer's +[rehearsal](../bounds.md#measured-the-rehearsal)) make every write to it cost about +180 seats for 5 ms. That counts against the writer's priority level, which can be a +controller's. One shared watch makes it one watcher. **That is the load worth removing**, +and it comes from watches, not LISTs. + +## How a shared native watch stays exact + +### The missing piece: comparable resource versions + +The API documentation tells clients to treat `resourceVersion` as opaque. The API server +keeps its cache fresh by comparing versions internally. A proxy that cannot compare +cannot know whether a client's version falls inside the history it holds. + +Kubernetes has since added `apimachinery/pkg/util/resourceversion.CompareResourceVersion` +(commit `2cef54c1450`, 2025-09-29, "Add helper function for client-go to compare +resource version"). It is present in v0.36.0, which krm-foyer already depends on: + +> CompareResourceVersion runs a comparison between two ResourceVersions. This only has +> semantic meaning when the comparison is done on two objects of the same resource. +> ... The function will return an error if the resource version is not a properly +> formatted positive integer. + +Kubernetes uses it the same way a proxy would. `client-go/util/consistency` tracks +"written resource versions and ensuring that local informer caches have observed +resource versions at least as new as those written". The storage version migrator also +relies on it and fails a migration when versions are not comparable +(`pkg/controller/storageversionmigrator`). Where a version is not a well-formed integer, +as an aggregated API may serve, the helper returns an error. The proxy then simply +passes the request through. + +### The rule + +The proxy holds one upstream watch per key: resource, namespace, label selector, field +selector, `Accept` encoding. It opened that watch from version **S** and keeps a history +of every event since, in order. + +A user's `WATCH ?resourceVersion=X` for that key can join if `X ≥ S`. The proxy then +sends that user exactly the events with version **> X**: first from history, then live. +This is what the API server would have sent. A watch from X means "everything after X", +and the shared watch has received everything after S, in order, for the same key. +Events at or before X are already in the user's LIST. An event that arrives later with a +version ≤ X is skipped, for the same reason. + +There is no freshness wait. If the user's X is ahead of what the shared watch has +received so far, the missing events are still on their way, in order, and arrive like +any live event. + +Everything else passes through to the API server, as today: a LIST; a WATCH with +`X < S`, an unset or `"0"` version, `sendInitialEvents`, or a non-integer version; a +resource that is not configured for sharing. + +For the room: the first person's WATCH opens the shared watch from their LIST's +version. Everyone who arrives later has a LIST at least as new, so `X ≥ S`, and joins. +krm-stream's native connector resumes from the last version it applied, which on a +shared stream is a version the shared watch delivered, so that joins too. Two hundred +browsers, one watch at the API server. + +### What else must match + +| Native behaviour | Shared `/k8s` | Source | +| --- | --- | --- | +| Recognizing a WATCH (`watch=1`, `watch=yes`, a bare `watch`, `/watch/` paths) | Use the API server's own `RequestInfoFactory`, not a second parser ([why krm-foyer avoided this so far](../bounds.md#native-watches)) | `apiserver/pkg/endpoints/request/requestinfo.go` | +| Authorization of the user's WATCH | A SelfSubjectAccessReview **with the user's own token**, built from the same attributes the API server's authorization filter builds: verb, group, version, resource, namespace, name (from a `metadata.name` field selector), field and label selector requirements. Kubernetes keeps the SAR fields and the authorizer attributes in step with a test (`pkg/registry/authorization/util/helpers_test.go`). No `create subjectaccessreviews` grant is needed for this | `apiserver/pkg/endpoints/filters/authorization.go` (`GetAuthorizerAttributes`) | +| Authentication | The same SelfSubjectAccessReview only succeeds with a token the API server accepts | | +| A response that depends on who asks | None for list and watch in kube-apiserver's registries: their uses of the requesting user are on create and update (CSR requestor, PodCertificateRequest status, RBAC escalation, admission policy bindings, the self-reviews). Aggregated APIs may filter per user, so pass them through | `pkg/registry/**` (`UserFrom`) | +| Event bytes | Same key, same `Accept`: the frames can be relayed as received, parsed only for type and version | | +| Bookmarks | Relay those with a version > X when the client asked for bookmarks | | +| Object leaving a label selector | Keyed on the exact selector, so the API server already turns it into `DELETED` | | +| History expired (410) on the shared watch | Every subscriber receives the 410 and re-lists, as each would have | | +| Shared watch ends (API server timeout) | Reopen from the last version, unseen; the history continues | | +| Per-client `timeoutSeconds`, response bounds, logout | Applied per client, exactly as today; ending one client's response leaves the others | `internal/gate` | +| Slow client | Drop that client alone (abort). The native connector resumes and joins again from its version, as the API server's own watch cache does with a slow watcher | `cache_watcher.go` | +| Revocation | A native watch is authorized once and never rechecked. Rechecking like shared streams do is optional and stricter | | +| Audit | The API server records the shared identity's watch and each user's SelfSubjectAccessReview, not each user's watch | **Visible to operators** | + +The identity that holds the shared watch is the one shared watches already use: +`-shared-watch-token-file`, granted `list` and `watch` on the configured resources and +nothing else. With per-user SelfSubjectAccessReviews it no longer needs +`create subjectaccessreviews`. That part of the existing gateway path could be +simplified the same way. + +### What it costs krm-foyer + +- **Telling watches apart.** `bounds.md` records the decision not to parse requests. + This would reverse it for configured resources only, using the API server's own code. + That adds a dependency on `k8s.io/apiserver`, a large module, and krm-foyer must keep + it in step with the clusters it fronts. +- **A history per shared watch,** bounded in events and age like the API server's. A + client older than the history passes through, so the only effect is less sharing. +- **Two code paths in `/k8s`:** pass-through and shared. Every bound, the logout cut-off + and the response limits must hold on both, tested against the real API server. +- **Security tests that try to get through:** a user denied by RBAC joins nothing; a + shared watch never delivers to a session after logout; one user's slow or aborted + stream never stalls another's; a client with `X < S` never receives a partial history; + a non-integer version, an aggregated API and an unconfigured resource always pass + through; the shared identity is never used for anything but the watch. + +## Is it the same game as the gateway? + +| | Gateway shared stream | Shared native watch in `/k8s` | +| --- | --- | --- | +| Upstream | One watch per scope | One watch per key | +| A subscriber can join | Any time: the gateway sends a snapshot from its cache | When its version ≥ S: the proxy sends what the API server would have | +| Freshness defined by | krm-stream's protocol | The Kubernetes API | +| Browser code | `connectResourceStream` (SSE) | `connectNativeWatch`, unchanged | +| Per-user authorization | SubjectAccessReview by the shared identity, rechecked | SelfSubjectAccessReview with the user's token, at join | +| Projection, redaction, suppression | Yes | No: native objects | +| Reconnect | Fresh snapshot | Resume from the client's version | + +The fan-out is the same. What differs is who defines what a joining client gets. The +gateway can take any subscriber because it owns its protocol. The proxy must stay inside +the native one, so it may only take a request whose answer it can reproduce exactly. + +## Prior art + +| Project | Shares watches across users? | API it serves | +| --- | --- | --- | +| kube-apiserver `Cacher` | Yes: one storage watch per resource | Native. It owns consistency with etcd | +| client-go `SharedInformerFactory` | Within one process and one identity | Go objects in memory | +| client-go `util/consistency` | No, but it is the same "has my cache caught up" check, by comparing resource versions | | +| `kubectl proxy` | No | Native, passed through | +| [Rancher steve](https://github.com/rancher/steve) | Yes: its own informers, with per-user access computed from RBAC bindings | Its own WebSocket watch API, `/v1/subscribe`; its Kubernetes proxy passes through with `Impersonate-*` | +| krm-stream gateway | Yes, `SharedBackend` | Its own SSE protocol | + +I found no proxy that shares behind the native API. The ones that share define their own +protocol. Comparable resource versions are recent (2025), which may be part of why. +This is not a survey of every dashboard. + +## Fewer ways, not more + +Today a page chooses between three: a per-user stream, a shared stream (both via the +gateway's SSE) and a native watch. With shared native watches the choice becomes: + +1. **Does the page need a projected view** (Secret values withheld, status-only updates + suppressed)? Then the gateway. +2. **Otherwise, native.** One connector, `/k8s`, and whether the resource is shared is + the operator's setting, not the page's. + +Before choosing, also ask whether the gateway's per-user streams and shared streams are +still needed once native sharing exists. The gateway would keep projected views; its +sharing would serve only pages that use them. + +## Measure first + +Add a native variant to the rehearsal (`test/e2e`), next to its per-user and shared +gateway runs: + +- 200 identities, each with krm-stream's native connector on one collection through + `/k8s`, and again on nine collections; +- a steady write rate to the watched resource from a controller-like client in its own + APF priority level; +- recorded: `apiserver_registered_watchers`, `apiserver_flowcontrol_work_estimated_seats` + and wait time for those writes, write latency, API server CPU and memory. + +If 1800 native watches do not visibly delay writes or grow the API server, nothing +needs sharing. If they do, the same run is the baseline for the shared `/k8s`, which +should show one watcher and unchanged write cost. + +## Side finding: selectors in shared-watch reviews + +krm-stream's `kube.SubjectAccessReviewAuthorizer` leaves label and field selectors out +of its reviews (`gateway/kube/authz.go`), and krm-foyer's decision cache keys on the +scope without the label selector ([watches](../watches.md#what-happens-for-each-stream)). +Since `AuthorizeWithSelectors` went GA in 1.34, an authorizer (a webhook, not RBAC) may +allow a list or watch only with a certain selector. Asking without the selector asks the +broader question, so such a grant is refused. That is the safe direction: it can +over-refuse, never over-grant. A shared `/k8s` would send the selectors, as above. diff --git a/docs/watches.md b/docs/watches.md index 10a2eae..3cf6406 100644 --- a/docs/watches.md +++ b/docs/watches.md @@ -66,7 +66,7 @@ either way later: only krm-foyer's flags change, never the page. ## Choosing a projection -These views are implemented in the pinned krm-stream **0.7.0** gateway, and krm-foyer +These views are implemented in the pinned krm-stream **0.10.0** gateway, and krm-foyer accepts all three. Pass `projection` on `/stream/v1`; omitting it selects `krm-full/v1`. | Projection | Status | Secret values | Use when | diff --git a/examples/hello/README.md b/examples/hello/README.md index 98af337..860e051 100644 --- a/examples/hello/README.md +++ b/examples/hello/README.md @@ -41,15 +41,16 @@ change either way. ```js import { session, login, k8s } from '/_foyer/foyer.js'; -import { LiveResourceStore, connectManagedResourceStream, resourceStreamURL } from './krm-stream.js'; +import { LiveResourceStore, applyStreamEvent, connectResourceStream, resourceStreamURL } from './krm-stream.js'; const s = await session(); // {authenticated, email, ...}; never a token if (!s.authenticated) login(); // to the issuer, and back to this page // The notes as they are, and as they change, for what RBAC lets this user see. const store = new LiveResourceStore(); -connectManagedResourceStream(resourceStreamURL('/stream/v1', - { group: 'hello.krm-foyer.example', version: 'v1', resource: 'notes', namespace: 'hello' }), store); +connectResourceStream(resourceStreamURL('/stream/v1', + { group: 'hello.krm-foyer.example', version: 'v1', resource: 'notes', namespace: 'hello' }), + (event) => applyStreamEvent(store, event)); // A save is the user's edit alone, on the version they last saw. const { uid, resourceVersion, patch } = store.captureSave(id); diff --git a/examples/hello/web/app.js b/examples/hello/web/app.js index 7fcbd8b..f992575 100644 --- a/examples/hello/web/app.js +++ b/examples/hello/web/app.js @@ -11,7 +11,7 @@ import { session, login, logout, k8s } from '/_foyer/foyer.js'; // krm-stream's browser library, one file vendored from its npm package (task // vendor-krm-stream). It keeps what the server sent apart from what the user typed. -import { LiveResourceStore, connectManagedResourceStream, resourceStreamURL } from './krm-stream.js'; +import { LiveResourceStore, applyStreamEvent, connectResourceStream, resourceStreamURL } from './krm-stream.js'; const namespace = new URLSearchParams(location.search).get('namespace') || 'hello'; const group = 'hello.krm-foyer.example'; @@ -94,12 +94,15 @@ function showConnection(state) { function connect() { connection?.close(); return new Promise((synced) => { - connection = connectManagedResourceStream( + connection = connectResourceStream( resourceStreamURL('/stream/v1', { group, version: 'v1', resource: 'notes', namespace }), - store, + (event) => { + applyStreamEvent(store, event); + if (event.type === 'synced') { + synced(); + } + }, { - onSynced: synced, - onStateChange: showConnection, onError: (code, message, terminal) => { if (!terminal) { return; // the connection recovers on its own @@ -113,6 +116,10 @@ function connect() { }, }, ); + showConnection(connection.state); + connection.subscribe(showConnection); + // A rendering bug stops the stream; say so rather than leave it unhandled. + connection.closed.catch((err) => say(`The live view stopped: ${err.message}`, 'error')); }); } diff --git a/examples/hello/web/krm-stream.js b/examples/hello/web/krm-stream.js index 6088230..d495fe9 100644 --- a/examples/hello/web/krm-stream.js +++ b/examples/hello/web/krm-stream.js @@ -1,3 +1,220 @@ +// dist/lifecycle.js +function runConnection(transport, consume, opts) { + const maxRetries = opts.maxRetries ?? 8; + const healthyResetMs = opts.healthyResetMs ?? 3e4; + const delay = opts.retryDelayMs ?? 500; + const cap = opts.maxRetryDelayMs ?? 3e4; + if (!Number.isFinite(healthyResetMs) || healthyResetMs <= 0 || healthyResetMs > 2147483647 || !Number.isSafeInteger(maxRetries) || maxRetries < 0 || !Number.isFinite(delay) || delay < 0 || !Number.isFinite(cap) || cap < 0 || cap > 2147483647) { + throw new RangeError("krm-stream: invalid retry budget or delay"); + } + const controller = new AbortController(); + const subscribers = /* @__PURE__ */ new Set(); + let state = Object.freeze({ status: "connecting", retries: 0 }); + let terminal = false; + let hintMs; + let healthTimer; + const clearHealthTimer = () => { + clearTimeout(healthTimer); + healthTimer = void 0; + }; + let failure; + const fail = (error) => { + failure ??= { error }; + clearHealthTimer(); + controller.abort(); + }; + const call = (callback) => { + try { + callback(); + } catch (error) { + fail(error); + } + }; + const publish = (status, detail = {}) => { + state = Object.freeze({ status, retries: state.retries, ...detail }); + for (const callback of subscribers) + call(() => callback(state)); + }; + const close = () => { + clearHealthTimer(); + controller.abort(); + }; + opts.signal?.addEventListener("abort", close, { once: true }); + if (opts.signal?.aborted) + close(); + const run = async () => { + while (!controller.signal.aborted) { + publish("connecting"); + if (controller.signal.aborted) + break; + hintMs = void 0; + let gap; + await transport({ + consume: (event) => call(() => consume(event)), + opened: () => publish("syncing"), + reset: () => { + clearHealthTimer(); + publish("syncing"); + }, + synced: () => { + hintMs = void 0; + if (state.status !== "live") { + healthTimer = setTimeout(() => { + healthTimer = void 0; + if (!controller.signal.aborted && state.status === "live") { + state = { ...state, retries: 0 }; + publish("live"); + } + }, healthyResetMs); + } + publish("live"); + }, + gap: (expected, received) => { + clearHealthTimer(); + gap = Object.freeze({ expected, received }); + }, + error: (code, message, isTerminal, retryAfterMs) => { + if (isTerminal) + clearHealthTimer(); + terminal ||= isTerminal; + if (!isTerminal && retryAfterMs !== void 0) + hintMs = retryAfterMs; + call(() => opts.onError?.(code, message, isTerminal, retryAfterMs)); + } + }, controller.signal); + clearHealthTimer(); + if (controller.signal.aborted) + break; + if (terminal) + return "terminal"; + if (state.retries >= maxRetries) + return "exhausted"; + const ceiling = Math.min(cap, delay * 2 ** Math.min(state.retries, 30)); + const jittered = Math.floor(ceiling * (0.5 + Math.random() * 0.5)); + const wait = Math.min(cap, Math.max(jittered, hintMs ?? 0)); + state = { ...state, retries: state.retries + 1 }; + publish("retrying", { retryInMs: wait, ...gap && { gap } }); + await new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + controller.signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, wait); + controller.signal.addEventListener("abort", done, { once: true }); + if (controller.signal.aborted) + done(); + }); + } + return "closed"; + }; + const closed = Promise.resolve().then(async () => { + try { + const ended = await run(); + publish(failure ? "closed" : ended); + } finally { + clearHealthTimer(); + opts.signal?.removeEventListener("abort", close); + subscribers.clear(); + } + if (failure) + throw failure.error; + }); + return { + close, + closed, + get state() { + return state; + }, + subscribe(callback) { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + } + }; +} + +// dist/http.js +function request(url, accept, signal, opts) { + return (opts.fetch ?? globalThis.fetch)(url, { + signal, + headers: { Accept: accept, ...opts.headers }, + cache: "no-store", + credentials: opts.credentials ?? "same-origin" + }); +} +function refusal(status) { + const code = status === 401 ? "UNAUTHENTICATED" : status === 403 ? "FORBIDDEN" : status === 429 || status === 502 || status === 503 || status === 504 ? "UPSTREAM_UNAVAILABLE" : "INTERNAL"; + return { code, terminal: status >= 400 && status < 500 && status !== 408 && status !== 429 }; +} +var maxStatusBytes = 16 * 1024; +var statusBudgetMs = 2e3; +async function statusMessage(res, signal, budgetMs = statusBudgetMs) { + if (!res.body) + return void 0; + if (signal.aborted || !/^application\/json\b/i.test(res.headers.get("Content-Type") ?? "")) { + await res.body.cancel().catch(() => { + }); + return void 0; + } + const reader = res.body.getReader(); + let gaveUp = false; + const giveUp = () => { + gaveUp = true; + void reader.cancel().catch(() => { + }); + }; + const timer = setTimeout(giveUp, budgetMs); + signal.addEventListener("abort", giveUp, { once: true }); + const chunks = []; + let size = 0; + try { + for (; ; ) { + const { done, value } = await reader.read(); + if (gaveUp) + return void 0; + if (done) + break; + size += value.byteLength; + if (size > maxStatusBytes) + return void 0; + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const status = JSON.parse(new TextDecoder().decode(bytes)); + if (typeof status !== "object" || status === null) + return void 0; + const { kind, message } = status; + return kind === "Status" && typeof message === "string" && message !== "" ? message : void 0; + } catch { + return void 0; + } finally { + clearTimeout(timer); + signal.removeEventListener("abort", giveUp); + await reader.cancel().catch(() => { + }); + } +} +function retryAfter(header, now = Date.now()) { + if (header === null) + return void 0; + const value = header.trim(); + if (/^\d+$/.test(value)) + return Number(value) * 1e3; + const at2 = Date.parse(value); + return Number.isNaN(at2) ? void 0 : Math.max(0, at2 - now); +} + +// dist/version.js +var VERSION = "0.10.0"; +var PROTOCOL_VERSION = 1; + // dist/sse.js var SSEDecoder = class { #buffer = ""; @@ -52,61 +269,93 @@ function parseFrame(frame) { return null; } } -function applyStreamEvent(store, ev) { - switch (ev.type) { +function toStateEvent(wire) { + switch (wire.type) { case "reset": - store.beginSnapshot(); - return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] }; + return { + type: "reset", + ...wire.target === void 0 ? {} : { target: wire.target }, + ...wire.scope === void 0 ? {} : { scope: wire.scope }, + ...wire.projection === void 0 ? {} : { projection: wire.projection } + }; case "added": - case "modified": { - if (!ev.object) - return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] }; - const result = store.applyServerEvent(ev.object, { redacted: ev.redacted }); - return { type: ev.type, uid: ev.object.metadata.uid, ...result }; - } - case "deleted": { - const uid = ev.identity?.uid; - if (uid) - store.removeResource(uid); - return { type: ev.type, uid, added: false, structural: true, flashed: [], conflicts: [] }; - } + case "modified": + if (!wire.object) + return null; + return { + type: wire.type, + object: wire.object, + ...wire.redacted === void 0 ? {} : { redacted: wire.redacted } + }; + case "deleted": + if (!wire.identity?.uid) + return null; + return { type: "deleted", identity: wire.identity }; case "synced": - store.endSnapshot(); - return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] }; + return { type: "synced" }; default: - return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] }; + return null; } } -function connectResourceStream(url, store, opts = {}) { - if (opts.signal?.aborted) - return { close: () => { - }, closed: Promise.resolve() }; +async function streamOnce(url, hooks, signal, opts = {}) { const controller = new AbortController(); - const fetchImpl = opts.fetch ?? globalThis.fetch; const abort = () => controller.abort(); - opts.signal?.addEventListener("abort", abort, { once: true }); - const closed = (async () => { - const res = await fetchImpl(url, { - signal: controller.signal, - headers: { Accept: "text/event-stream", ...opts.headers }, - // The stream IS the response body; a cached one is a stream that never moves. - cache: "no-store", - credentials: opts.credentials ?? "same-origin" - }); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) + abort(); + const sequence = new StreamSequence(); + const { consume } = hooks; + const deliver2 = (wire) => { + const gap = sequence.observe(wire); + if (gap) { + hooks.gap(gap.expected, gap.received); + return false; + } + if (wire.type === "error") { + const hint = typeof wire.retryAfterMs === "number" && wire.retryAfterMs >= 0 ? wire.retryAfterMs : void 0; + hooks.error(wire.code ?? "INTERNAL", wire.message ?? "", wire.terminal ?? false, hint); + return wire.terminal !== true; + } + const event = toStateEvent(wire); + if (!event) + return true; + if (event.type === "reset") { + hooks.reset(); + if (controller.signal.aborted) + return false; + } + consume(event); + if (event.type === "synced" && !controller.signal.aborted) + hooks.synced(); + return true; + }; + try { + if (controller.signal.aborted) + return; + const res = await request(url, "text/event-stream", controller.signal, opts); if (!res.ok || !res.body) { - const code = res.status === 401 ? "UNAUTHENTICATED" : res.status === 403 ? "FORBIDDEN" : res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504 ? "UPSTREAM_UNAVAILABLE" : "INTERNAL"; - const terminal = res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429; + const { code, terminal } = refusal(res.status); const message = await statusMessage(res, controller.signal) ?? `stream: HTTP ${res.status}`; if (controller.signal.aborted) return; - opts.onError?.(code, message, terminal, retryAfter(res.headers.get("Retry-After"))); + hooks.error(code, message, terminal, retryAfter(res.headers.get("Retry-After"))); return; } if (controller.signal.aborted) { - await res.body.cancel(); + await res.body.cancel().catch(() => { + }); + return; + } + const protocol = res.headers.get("X-KRM-Stream-Protocol"); + if (protocol !== null && protocol.trim() !== String(PROTOCOL_VERSION)) { + await res.body.cancel().catch(() => { + }); + if (controller.signal.aborted) + return; + hooks.error("INTERNAL", `stream: protocol mismatch: the gateway speaks X-KRM-Stream-Protocol ${JSON.stringify(protocol)}, this client speaks ${PROTOCOL_VERSION}`, true); return; } - opts.onOpen?.(); + hooks.opened(); const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); const cancelReader = () => { void reader.cancel().catch(() => { @@ -116,7 +365,6 @@ function connectResourceStream(url, store, opts = {}) { if (controller.signal.aborted) cancelReader(); const decoder = new SSEDecoder(); - const sequence = new StreamSequence(); try { for (; ; ) { const { done, value } = await reader.read(); @@ -125,278 +373,26 @@ function connectResourceStream(url, store, opts = {}) { for (const ev of decoder.push(value)) { if (controller.signal.aborted) return; - if (feed(store, sequence, ev, opts)) { + if (!deliver2(ev)) { controller.abort(); return; } } } - } catch (err) { - if (!controller.signal.aborted) - throw err; } finally { controller.signal.removeEventListener("abort", cancelReader); await reader.cancel().catch(() => { }); } - })(); - return { - close: () => controller.abort(), - closed: closed.catch(() => { - }).finally(() => opts.signal?.removeEventListener("abort", abort)) - }; -} -var maxStatusBytes = 16 * 1024; -var statusBudgetMs = 2e3; -async function statusMessage(res, signal, budgetMs = statusBudgetMs) { - if (!res.body) - return void 0; - if (signal.aborted || !/^application\/json\b/i.test(res.headers.get("Content-Type") ?? "")) { - await res.body.cancel().catch(() => { - }); - return void 0; - } - const reader = res.body.getReader(); - let gaveUp = false; - const giveUp = () => { - gaveUp = true; - void reader.cancel().catch(() => { - }); - }; - const timer = setTimeout(giveUp, budgetMs); - signal.addEventListener("abort", giveUp, { once: true }); - const chunks = []; - let size = 0; - try { - for (; ; ) { - const { done, value } = await reader.read(); - if (gaveUp) - return void 0; - if (done) - break; - size += value.byteLength; - if (size > maxStatusBytes) - return void 0; - chunks.push(value); - } - const bytes = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - const status = JSON.parse(new TextDecoder().decode(bytes)); - if (typeof status !== "object" || status === null) - return void 0; - const { kind, message } = status; - return kind === "Status" && typeof message === "string" && message !== "" ? message : void 0; } catch { - return void 0; } finally { - clearTimeout(timer); - signal.removeEventListener("abort", giveUp); - await reader.cancel().catch(() => { - }); - } -} -function retryAfter(header, now = Date.now()) { - if (header === null) - return void 0; - const value = header.trim(); - if (/^\d+$/.test(value)) - return Number(value) * 1e3; - const at2 = Date.parse(value); - return Number.isNaN(at2) ? void 0 : Math.max(0, at2 - now); -} -function connectWithEventSource(url, store, opts = {}) { - if (opts.signal?.aborted) - return { close: () => { - }, closed: Promise.resolve() }; - const es = new EventSource(url, { withCredentials: true }); - let sequence = new StreamSequence(); - let stopped = false; - let resolve; - const closed = new Promise((r) => { - resolve = r; - }); - const shut = () => { - stopped = true; - es.close(); - opts.signal?.removeEventListener("abort", shut); - resolve(); - }; - es.onopen = () => { - if (stopped) - return; - sequence = new StreamSequence(); - opts.onOpen?.(); - }; - es.onmessage = (e) => { - if (stopped) - return; - let ev; - try { - ev = JSON.parse(e.data); - } catch { - return; - } - if (feed(store, sequence, ev, opts)) - shut(); - }; - es.onerror = () => { - if (es.readyState === EventSource.CLOSED) - shut(); - }; - opts.signal?.addEventListener("abort", shut, { once: true }); - return { close: shut, closed }; -} -function feed(store, sequence, ev, opts) { - const gap = sequence.observe(ev); - if (gap) { - opts.onGap?.(gap.expected, gap.received); - return true; + signal.removeEventListener("abort", abort); } - if (ev.type === "error") { - const hint = typeof ev.retryAfterMs === "number" && ev.retryAfterMs >= 0 ? ev.retryAfterMs : void 0; - opts.onError?.(ev.code ?? "INTERNAL", ev.message ?? "", ev.terminal ?? false, hint); - return ev.terminal === true; - } - const change = applyStreamEvent(store, ev); - if (ev.type === "synced") - opts.onSynced?.(); - opts.onChange?.(change); - return false; } // dist/connection.js -function connectManagedResourceStream(url, store, opts = {}) { - const maxRetries = opts.maxRetries ?? 8; - const healthyResetMs = opts.healthyResetMs ?? 3e4; - const delay = opts.retryDelayMs ?? 500; - const cap = opts.maxRetryDelayMs ?? 3e4; - if (!Number.isFinite(healthyResetMs) || healthyResetMs <= 0 || healthyResetMs > 2147483647 || !Number.isSafeInteger(maxRetries) || maxRetries < 0 || !Number.isFinite(delay) || delay < 0 || !Number.isFinite(cap) || cap < 0 || cap > 2147483647) { - throw new RangeError("krm-stream: invalid retry budget or delay"); - } - const controller = new AbortController(); - const subscribers = /* @__PURE__ */ new Set(); - let state = Object.freeze({ status: "connecting", retries: 0 }); - let terminal = false; - let hintMs; - let healthTimer; - const clearHealthTimer = () => { - clearTimeout(healthTimer); - healthTimer = void 0; - }; - const publish = (status, retryInMs) => { - state = Object.freeze({ status, retries: state.retries, ...retryInMs === void 0 ? {} : { retryInMs } }); - opts.onStateChange?.(state); - for (const callback of subscribers) - callback(state); - }; - const close = () => { - clearHealthTimer(); - controller.abort(); - }; - opts.signal?.addEventListener("abort", close, { once: true }); - if (opts.signal?.aborted) - close(); - const closed = Promise.resolve().then(async () => { - try { - while (!controller.signal.aborted) { - publish("connecting"); - if (controller.signal.aborted) - break; - hintMs = void 0; - const stream = connectResourceStream(url, store, { - ...opts, - signal: controller.signal, - onGap: (expected, received) => { - clearHealthTimer(); - opts.onGap?.(expected, received); - }, - onOpen: () => { - publish("syncing"); - opts.onOpen?.(); - }, - onChange: (change) => { - if (change.type === "reset") { - clearHealthTimer(); - publish("syncing"); - } - opts.onChange?.(change); - }, - onSynced: () => { - hintMs = void 0; - if (state.status !== "live") { - healthTimer = setTimeout(() => { - healthTimer = void 0; - if (!controller.signal.aborted && state.status === "live") { - state = { ...state, retries: 0 }; - publish("live"); - } - }, healthyResetMs); - } - publish("live"); - opts.onSynced?.(); - }, - onError: (code, message, isTerminal, retryAfterMs) => { - if (isTerminal) - clearHealthTimer(); - terminal ||= isTerminal; - if (!isTerminal && retryAfterMs !== void 0) - hintMs = retryAfterMs; - opts.onError?.(code, message, isTerminal, retryAfterMs); - } - }); - await stream.closed; - clearHealthTimer(); - if (controller.signal.aborted) - break; - if (terminal) { - publish("terminal"); - return; - } - if (state.retries >= maxRetries) { - publish("exhausted"); - return; - } - const ceiling = Math.min(cap, delay * 2 ** Math.min(state.retries, 30)); - const jittered = Math.floor(ceiling * (0.5 + Math.random() * 0.5)); - const wait = Math.min(cap, Math.max(jittered, hintMs ?? 0)); - state = { ...state, retries: state.retries + 1 }; - publish("retrying", wait); - await new Promise((resolve) => { - const done = () => { - clearTimeout(timer); - controller.signal.removeEventListener("abort", done); - resolve(); - }; - const timer = setTimeout(done, wait); - controller.signal.addEventListener("abort", done, { once: true }); - if (controller.signal.aborted) - done(); - }); - } - publish("closed"); - } finally { - clearHealthTimer(); - opts.signal?.removeEventListener("abort", close); - subscribers.clear(); - } - }); - return { - close, - closed, - get state() { - return state; - }, - subscribe(callback) { - subscribers.add(callback); - return () => { - subscribers.delete(callback); - }; - } - }; +function connectResourceStream(url, consume, opts = {}) { + return runConnection((hooks, signal) => streamOnce(url, hooks, signal, opts), consume, opts); } // dist/deep.js @@ -432,6 +428,369 @@ function clone(v) { return v; } +// dist/native.js +var reserved = [ + "watch", + "resourceVersion", + "resourceVersionMatch", + "limit", + "continue", + "sendInitialEvents", + "allowWatchBookmarks" +]; +function connectNativeWatch(collectionURL, consume, opts = {}) { + const url = collectionURL.split("#")[0]; + const query = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : ""); + for (const name of reserved) { + if (query.has(name)) + throw new Error(`krm-stream: the collection URL must not set ${name}; the connector does`); + } + const position = { checkpoint: void 0, type: void 0 }; + return runConnection((hooks, signal) => watchOnce(url, hooks, signal, opts, position), consume, opts); +} +var Malformed = class extends Error { +}; +var Truncated = class extends Error { +}; +var resumable = (status) => status === 408 || status === 429 || status >= 500; +async function watchOnce(url, hooks, signal, opts, position) { + const controller = new AbortController(); + const abort = () => controller.abort(); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) + abort(); + const aborted = () => controller.signal.aborted; + const { consume } = hooks; + const refused = async (phase, res) => { + if (!resumable(res.status)) + position.checkpoint = void 0; + const { code, terminal } = classify(res.status); + const message = await statusMessage(res, controller.signal) ?? `native ${phase}: HTTP ${res.status}`; + if (aborted()) + return; + hooks.error(code, message, terminal, retryAfter(res.headers.get("Retry-After"))); + }; + const malformed = (phase, error) => { + position.checkpoint = void 0; + if (!aborted()) + hooks.error("INTERNAL", `native ${phase}: ${error.message}`, false); + }; + const advance = (resourceVersion) => { + if (aborted()) + return; + position.checkpoint = typeof resourceVersion === "string" && resourceVersion !== "" ? resourceVersion : void 0; + }; + try { + if (aborted()) + return; + const resumeFrom = position.checkpoint; + let from; + if (resumeFrom === void 0) { + const listed = await request(url, "application/json", controller.signal, opts); + if (!listed.ok || !listed.body) + return await refused("list", listed); + if (aborted()) + return void await listed.body.cancel().catch(() => { + }); + hooks.opened(); + let collection; + try { + const text = await readText(listed.body, controller.signal); + if (text === void 0) + return; + collection = readCollection(text); + } catch (error) { + if (error instanceof Unpaginated) { + if (!aborted()) + hooks.error("INTERNAL", error.message, true); + return; + } + if (error instanceof Malformed) + return malformed("list", error); + throw error; + } + position.type = collection.type; + from = collection.resourceVersion; + hooks.reset(); + if (aborted()) + return; + consume({ type: "reset" }); + for (const object of collection.items) { + if (aborted()) + return; + consume({ type: "added", object }); + } + if (aborted()) + return; + } else { + from = resumeFrom; + } + const watched = await request(`${url}${url.includes("?") ? "&" : "?"}watch=1&allowWatchBookmarks=true&resourceVersion=${encodeURIComponent(from)}`, "application/json", controller.signal, opts); + if (!watched.ok || !watched.body) + return await refused("watch", watched); + if (aborted()) + return void await watched.body.cancel().catch(() => { + }); + if (resumeFrom === void 0) { + consume({ type: "synced" }); + if (aborted()) + return void await watched.body.cancel().catch(() => { + }); + position.checkpoint = from; + } + hooks.synced(); + const reader = watched.body.getReader(); + const cancelReader = () => { + void reader.cancel().catch(() => { + }); + }; + controller.signal.addEventListener("abort", cancelReader, { once: true }); + if (aborted()) + cancelReader(); + const decoder = new WatchDecoder(); + try { + for (; ; ) { + const { done, value } = await reader.read(); + if (aborted()) + return; + let lines; + try { + if (done) + return decoder.end(); + lines = decoder.push(value); + } catch (error) { + if (error instanceof Truncated) { + return void hooks.error("INTERNAL", `native watch: ${error.message}`, false); + } + if (!(error instanceof Malformed)) + throw error; + return malformed("watch", error); + } + for (const line of lines) { + if (aborted()) + return; + let more; + try { + more = deliver(line, position.type, hooks, advance, position); + } catch (error) { + if (!(error instanceof Malformed)) + throw error; + return malformed("watch", error); + } + if (!more) + return; + } + } + } finally { + controller.signal.removeEventListener("abort", cancelReader); + await reader.cancel().catch(() => { + }); + } + } catch { + } finally { + controller.abort(); + signal.removeEventListener("abort", abort); + } +} +function deliver(line, type, hooks, advance, position) { + let frame; + try { + frame = JSON.parse(line); + } catch { + throw new Malformed("a watch frame is not JSON"); + } + if (!isRecord(frame) || typeof frame.type !== "string" || !isRecord(frame.object)) { + throw new Malformed("a watch frame has no type or object"); + } + const object = frame.object; + switch (frame.type) { + case "ADDED": + case "MODIFIED": { + const applied = resource(object, type, "watch"); + hooks.consume({ type: frame.type === "ADDED" ? "added" : "modified", object: applied }); + advance(applied.metadata.resourceVersion); + return true; + } + case "DELETED": { + const removed = resource(object, type, "watch"); + hooks.consume({ type: "deleted", identity: identity(removed) }); + advance(removed.metadata.resourceVersion); + return true; + } + case "BOOKMARK": + advance(isRecord(object.metadata) ? object.metadata.resourceVersion : void 0); + return true; + case "ERROR": { + const { code, terminal, message, retryAfterMs, keepsCheckpoint } = statusError(object); + if (!keepsCheckpoint) + position.checkpoint = void 0; + hooks.error(code, message, terminal, retryAfterMs); + return false; + } + default: + throw new Malformed(`unknown watch event type ${JSON.stringify(frame.type)}`); + } +} +function classify(status) { + return status === 410 ? { code: "RESYNC_REQUIRED", terminal: false } : refusal(status); +} +function statusError(status) { + const reason = typeof status.reason === "string" ? status.reason : void 0; + const expired = reason === "Expired" || reason === "Gone"; + const code = typeof status.code === "number" ? status.code : expired ? 410 : void 0; + const { code: errorCode, terminal } = code === void 0 ? { code: "INTERNAL", terminal: false } : classify(code); + const text = typeof status.message === "string" ? status.message : ""; + const details = status.details; + const seconds = isRecord(details) ? details.retryAfterSeconds : void 0; + return { + code: errorCode, + terminal, + message: text !== "" ? text : `native watch: error ${code ?? reason ?? "without a status"}`, + retryAfterMs: typeof seconds === "number" && seconds >= 0 ? seconds * 1e3 : void 0, + keepsCheckpoint: code !== void 0 && !expired && resumable(code) + }; +} +var Unpaginated = class extends Error { +}; +function readCollection(text) { + let body; + try { + body = JSON.parse(text); + } catch { + throw new Malformed("the collection is not JSON"); + } + if (!isRecord(body)) + throw new Malformed("the collection is not an object"); + const metadata = isRecord(body.metadata) ? body.metadata : {}; + if (typeof metadata.continue === "string" && metadata.continue !== "") { + throw new Unpaginated("native list: the response is one page of a paginated collection, which this connector does not support"); + } + const resourceVersion = metadata.resourceVersion; + if (typeof resourceVersion !== "string" || resourceVersion === "") { + throw new Malformed("the collection has no metadata.resourceVersion"); + } + const raw = body.items; + if (raw !== null && !Array.isArray(raw)) + throw new Malformed("the collection has no items array"); + const type = itemType(body); + const uids = /* @__PURE__ */ new Set(); + const items = (raw ?? []).map((item) => { + const object = resource(item, type, "list"); + if (uids.has(object.metadata.uid)) + throw new Malformed(`the collection lists UID ${object.metadata.uid} twice`); + uids.add(object.metadata.uid); + return object; + }); + return { items, resourceVersion, type }; +} +function itemType(collection) { + const { apiVersion, kind } = collection; + if (typeof apiVersion !== "string" || apiVersion === "" || apiVersion.startsWith("meta.k8s.io/")) + return void 0; + if (typeof kind !== "string" || !/^[A-Za-z0-9]+List$/.test(kind)) + return void 0; + return { apiVersion, kind: kind.slice(0, -"List".length) }; +} +function resource(value, type, phase) { + const where = phase === "list" ? "a collection item" : "a watch object"; + if (!isRecord(value) || !isRecord(value.metadata)) + throw new Malformed(`${where} has no metadata`); + const { uid, name, namespace } = value.metadata; + if (typeof uid !== "string" || uid === "") + throw new Malformed(`${where} has no metadata.uid`); + if (typeof name !== "string" || name === "") + throw new Malformed(`${where} ${uid} has no metadata.name`); + if (namespace !== void 0 && typeof namespace !== "string") { + throw new Malformed(`${where} ${uid} has an invalid metadata.namespace`); + } + const present = (field) => typeof field === "string" && field !== ""; + if (present(value.apiVersion) && present(value.kind)) + return value; + const apiVersion = present(value.apiVersion) ? value.apiVersion : type?.apiVersion; + const kind = present(value.kind) ? value.kind : type?.kind; + if (apiVersion === void 0 || kind === void 0) { + throw new Malformed(`${where} ${uid} has no apiVersion or kind, and the collection does not name its item type`); + } + return { ...value, apiVersion, kind }; +} +function identity(object) { + const { uid, name, namespace } = object.metadata; + return { + uid, + apiVersion: object.apiVersion, + kind: object.kind, + ...namespace === void 0 ? {} : { namespace }, + name + }; +} +var WatchDecoder = class { + #utf8 = new TextDecoder("utf-8", { fatal: true }); + #buffer = ""; + /** Feed a chunk; get back the complete, nonblank lines it finished. */ + push(chunk) { + this.#buffer += decode(this.#utf8, chunk); + const lines = []; + for (; ; ) { + const end = this.#buffer.indexOf("\n"); + if (end === -1) + break; + const line = this.#buffer.slice(0, end).trim(); + this.#buffer = this.#buffer.slice(end + 1); + if (line !== "") + lines.push(line); + } + return lines; + } + /** The stream ended. Anything still buffered — a character cut off included — is a truncated frame: + * every complete line has already been returned, so the cut can only be inside the last one. */ + end() { + let rest; + try { + rest = this.#utf8.decode(); + } catch { + throw new Truncated("the watch ended inside a UTF-8 character"); + } + if ((this.#buffer + rest).trim() !== "") + throw new Truncated("the watch ended inside a frame"); + } +}; +async function readText(body, signal) { + const reader = body.getReader(); + const cancel = () => { + void reader.cancel().catch(() => { + }); + }; + signal.addEventListener("abort", cancel, { once: true }); + if (signal.aborted) + cancel(); + const utf8 = new TextDecoder("utf-8", { fatal: true }); + let text = ""; + try { + for (; ; ) { + const { done, value } = await reader.read(); + if (signal.aborted) + return void 0; + if (done) + return text + decode(utf8); + text += decode(utf8, value); + } + } finally { + signal.removeEventListener("abort", cancel); + await reader.cancel().catch(() => { + }); + } +} +function decode(utf8, chunk) { + try { + return chunk === void 0 ? utf8.decode() : utf8.decode(chunk, { stream: true }); + } catch { + throw new Malformed("the response is not valid UTF-8"); + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + // dist/path.js function pathKey(path) { return JSON.stringify(path); @@ -539,8 +898,8 @@ function withOpenAPIKeyedLists(policy, schema) { } function mapKeysAt(root, path) { let schema = root; - for (const segment of path) { - schema = typeof segment === "number" ? schema?.items : schema?.properties?.[segment]; + for (const segment2 of path) { + schema = typeof segment2 === "number" ? schema?.items : schema?.properties?.[segment2]; if (!schema) return void 0; } @@ -628,12 +987,12 @@ function remapListConflicts(s, path, previousOrder, outputKeys) { const outputIndex = new Map(outputKeys.map((key, index) => [key, index])); const moved = []; for (const [encoded, conflict] of s.conflicts) { - const segment = conflict.path[path.length]; - if (!isPrefix(path, conflict.path) || typeof segment !== "number") + const segment2 = conflict.path[path.length]; + if (!isPrefix(path, conflict.path) || typeof segment2 !== "number") continue; - const key = previousOrder[segment]; + const key = previousOrder[segment2]; const nextIndex = key === void 0 ? void 0 : outputIndex.get(key); - if (nextIndex === void 0 || nextIndex === segment) + if (nextIndex === void 0 || nextIndex === segment2) continue; s.conflicts.delete(encoded); moved.push({ ...conflict, path: [...path, nextIndex, ...conflict.path.slice(path.length + 1)] }); @@ -670,10 +1029,10 @@ function indexAssociativeList(values, keys) { for (const value of values) { if (!isPlainObject(value)) return void 0; - const identity = keys.map((key) => value[key]); - if (identity.some((part) => part === void 0 || part === null || typeof part === "object")) + const identity2 = keys.map((key) => value[key]); + if (identity2.some((part) => part === void 0 || part === null || typeof part === "object")) return void 0; - const encoded = JSON.stringify(identity); + const encoded = JSON.stringify(identity2); if (indexed.values.has(encoded)) return void 0; indexed.order.push(encoded); @@ -718,6 +1077,30 @@ function clearConflict(s, path) { } // dist/store.js +function applyStreamEvent(store, event) { + switch (event.type) { + case "reset": + store.beginSnapshot(); + return { type: event.type, added: false, structural: false, flashed: [], conflicts: [] }; + case "added": + case "modified": { + const result = store.applyServerEvent(event.object, { redacted: event.redacted }); + return { type: event.type, uid: event.object.metadata.uid, ...result }; + } + case "deleted": { + const uid = event.identity.uid; + store.removeResource(uid); + return { type: event.type, uid, added: false, structural: true, flashed: [], conflicts: [] }; + } + case "synced": + store.endSnapshot(); + return { type: event.type, added: false, structural: false, flashed: [], conflicts: [] }; + } +} +var MACHINERY = [ + ["metadata", "managedFields"], + ["metadata", "annotations", "kubectl.kubernetes.io/last-applied-configuration"] +]; var LiveResourceStore = class { #policy; #revision = 0; @@ -733,15 +1116,22 @@ var LiveResourceStore = class { } // ------------------------------------------------------------------ the stream in -- /** `added` and `modified` — the only two upsert spellings, and they are treated identically - * (spec §4). Both mean "here is this object's complete current state". */ + * (spec §4). Both mean "here is this object's complete current state", and during a snapshot cycle + * that the object is still in scope. */ applyServerEvent(object, opts = {}) { + this.#seen?.add(object.metadata.uid); + return this.#upsert(object, opts); + } + /** Replace the server object and reconcile the draft. Membership is the caller's business: only the + * stream's own upserts count towards a snapshot, so a save or read response that lands mid-cycle + * can never keep alive an object the snapshot no longer contains. */ + #upsert(object, opts) { const id = object.metadata.uid; const incoming = clone(object); const redacted = (opts.redacted ?? []).map((entry) => ({ path: typeof entry.path === "string" ? parsePointer(entry.path) : [...entry.path], rev: entry.rev })); - this.#seen?.add(id); const existing = this.#resources.get(id); if (!existing) { this.#resources.set(id, { @@ -818,10 +1208,10 @@ var LiveResourceStore = class { adoptSaved(object) { const existing = this.#resources.get(object.metadata.uid); if (!existing) { - this.applyServerEvent(object); + this.#upsert(object, {}); return; } - this.applyServerEvent(object, { redacted: existing.redacted }); + this.#upsert(object, { redacted: existing.redacted }); } // ------------------------------------------------------------------------- edits -- setValue(id, path, value) { @@ -905,7 +1295,8 @@ var LiveResourceStore = class { const res = this.#must(id); if (this.isEditable(id, path)) return !deepEqual(get(res.server, path), get(res.draft, path)); - if (!this.#policy.containsEditable(res.server, path)) + const regions = this.#regionsFor(res.server, res.redacted.map((r) => r.path)); + if (!regions.container(path)) return false; return this.changes(id).some((c) => isPrefix(path, c.path)); } @@ -952,14 +1343,14 @@ var LiveResourceStore = class { /** Capture the patch, UID and merge-base version synchronously, before any await. A stale version * is safe: Kubernetes rejects it with 409. Never replace it with a newer GET's version. */ captureSave(id) { - const resource = this.#must(id); + const resource2 = this.#must(id); const patch = this.patch(id); if (!patch) return null; - const resourceVersion = resource.server.metadata.resourceVersion; + const resourceVersion = resource2.server.metadata.resourceVersion; if (!resourceVersion) throw new Error("krm-stream: conditional save requires resourceVersion"); - return { uid: resource.server.metadata.uid, resourceVersion, patch }; + return { uid: resource2.server.metadata.uid, resourceVersion, patch }; } /** Capture before starting a host GET. The returned function applies its projected response only * if no server event or earlier response has advanced this resource since capture. Local edits @@ -983,7 +1374,7 @@ var LiveResourceStore = class { return false; redacted = paths.map((path) => known.get(pathKey(path))); } - this.applyServerEvent(object, { redacted }); + this.#upsert(object, { redacted }); return true; }; } @@ -1010,12 +1401,17 @@ var LiveResourceStore = class { } return res; } + /** The policy, minus the protected paths: this object's redactions and the machinery. A path + * inside one is read-only. A path holding one cannot be replaced or removed whole, which would + * rewrite what it holds, so where the policy makes it editable it is merged key by key instead: a + * new Secret key beside withheld values, or one annotation beside the last-applied one. */ #regionsFor(object, redacted) { - const insideRedacted = (path) => redacted.some((r) => isPrefix(r, path)); - const containsRedacted = (path) => redacted.some((r) => isPrefix(path, r)); + const protectedPaths = [...MACHINERY, ...redacted]; + const inside = (path) => protectedPaths.some((p) => isPrefix(p, path)); + const holds = (path) => protectedPaths.some((p) => isPrefix(path, p)); return { - editable: (path) => !insideRedacted(path) && !containsRedacted(path) && this.#policy.isEditable(object, path), - container: (path) => !insideRedacted(path) && this.#policy.containsEditable(object, path), + editable: (path) => !inside(path) && !holds(path) && this.#policy.isEditable(object, path), + container: (path) => !inside(path) && (this.#policy.containsEditable(object, path) || holds(path) && this.#policy.isEditable(object, path)), listMapKeys: (path) => this.#policy.listMapKeys?.(object, path) }; } @@ -1106,10 +1502,42 @@ function resourceStreamURL(base, scope) { set("projection", scope.projection); return `${base}${base.includes("?") ? "&" : "?"}${q.toString()}`; } - -// dist/version.js -var VERSION = "0.7.0"; -var PROTOCOL_VERSION = 1; +function nativeCollectionURL(proxyBase, scope) { + const q = new URLSearchParams(); + if (scope.labelSelector) + q.append("labelSelector", scope.labelSelector); + if (scope.name) + q.append("fieldSelector", `metadata.name=${scope.name}`); + const query = q.toString(); + return collectionPath(proxyBase, scope) + (query ? `?${query}` : ""); +} +function nativeObjectURL(proxyBase, scope) { + return `${collectionPath(proxyBase, scope)}/${segment("name", scope.name)}`; +} +function collectionPath(proxyBase, scope) { + const path = [withoutTrailingSlashes(proxyBase)]; + if (scope.group) + path.push("apis", segment("group", scope.group)); + else + path.push("api"); + path.push(segment("version", scope.version)); + if (scope.namespace) + path.push("namespaces", segment("namespace", scope.namespace)); + path.push(segment("resource", scope.resource)); + return path.join("/"); +} +function segment(what, value) { + if (!value || value === "." || value === ".." || value.includes("/")) { + throw new Error(`krm-stream: invalid ${what} ${JSON.stringify(value ?? "")}`); + } + return encodeURIComponent(value); +} +function withoutTrailingSlashes(base) { + let end = base.length; + while (end > 0 && base[end - 1] === "/") + end--; + return base.slice(0, end); +} export { DEFAULT_EDITABLE_REGIONS, LiveResourceStore, @@ -1119,14 +1547,15 @@ export { VERSION, applyStreamEvent, clone, - connectManagedResourceStream, + connectNativeWatch, connectResourceStream, - connectWithEventSource, deepEqual, defaultPolicy, get, has, isPrefix, + nativeCollectionURL, + nativeObjectURL, parsePointer, pathKey, readOnlyPolicy, diff --git a/go.mod b/go.mod index 05ede11..55ad081 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/ConfigButler/krm-foyer go 1.27.1 require ( - github.com/ConfigButler/krm-stream/gateway v0.7.0 - github.com/ConfigButler/krm-stream/gateway/kube v0.7.0 + github.com/ConfigButler/krm-stream/gateway v0.10.0 + github.com/ConfigButler/krm-stream/gateway/kube v0.10.0 github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f github.com/chromedp/chromedp v0.16.0 github.com/coreos/go-oidc/v3 v3.21.0 diff --git a/go.sum b/go.sum index 12eabf0..6890e96 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -github.com/ConfigButler/krm-stream/gateway v0.7.0 h1:Pb1fWshxt44D+LMcD7sIrzo3lJQyKtYivIfRuXpBLxI= -github.com/ConfigButler/krm-stream/gateway v0.7.0/go.mod h1:sVRhasWxXotQQHsyY4mKvvT0npkupDjrvg/i3Elq5+E= -github.com/ConfigButler/krm-stream/gateway/kube v0.7.0 h1:8Zm8+il5xgFlRWLWehm1P7e4qSentcMzkRa5AcbqyS4= -github.com/ConfigButler/krm-stream/gateway/kube v0.7.0/go.mod h1:4NPwh1gb4ewfGunozln3G8W4y0//VPirwx+AdeNsmuo= +github.com/ConfigButler/krm-stream/gateway v0.10.0 h1:bL3ZsY3B9hR4qiMVAz+rv1fdHoJ2tZAXd66utnCtYgQ= +github.com/ConfigButler/krm-stream/gateway v0.10.0/go.mod h1:sVRhasWxXotQQHsyY4mKvvT0npkupDjrvg/i3Elq5+E= +github.com/ConfigButler/krm-stream/gateway/kube v0.10.0 h1:Kv7q1AOaQLJQaOQc+TFs1EufKu9YABU1jW/DXVh9Xu0= +github.com/ConfigButler/krm-stream/gateway/kube v0.10.0/go.mod h1:s65yd6idUaNknxk5oepnj0n6n8IIvk9Olr7Y/G7D5Q8= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/internal/stream/shared.go b/internal/stream/shared.go index 0b0d7b5..4a72cd6 100644 --- a/internal/stream/shared.go +++ b/internal/stream/shared.go @@ -167,7 +167,7 @@ func newShared(cfg SharedConfig, s *Streams) (*shared, error) { m := s.gate.Metrics() upstream := &sharedUpstream{kube: kube.NewBackend(data), metrics: m} return &shared{ - backend: gateway.NewSharedBackendWithOptions(upstream, gateway.SharedOptions{ + backend: gateway.NewSharedBackend(upstream, gateway.SharedOptions{ Observer: observer{metrics: m}, }), resources: slices.Clone(cfg.Resources), diff --git a/internal/stream/stream.go b/internal/stream/stream.go index 427908f..920ae88 100644 --- a/internal/stream/stream.go +++ b/internal/stream/stream.go @@ -129,22 +129,24 @@ func (s *Streams) ServeHTTP(w http.ResponseWriter, r *http.Request) { // The gate has found the user; the gateway only carries the credential back // to Clients. Principal: func(*http.Request) (gateway.Principal, error) { return a.Credential, nil }, - // Kubernetes decides: every watch is opened with the user's own token, and - // the API server refuses what RBAC does not allow. - Authorizer: gateway.AllowAll{}, - Clients: u.backend, // krm-foyer keeps no list of resources (docs/design.md, "Access"): what the // user may watch is what the API server lets them, since every watch is // theirs. The gateway still parses the scope itself and refuses one it will // not serve, an API-server address or a credential among them; only the // unnamed target, the one cluster, exists. - Scopes: gateway.ScopePolicy{Targets: []string{""}, AnyResource: true, AllowLabelSelector: true}, - Projections: gateway.ProjectionPolicyFunc(project), - Diagnostics: func(d gateway.Diagnostic) { s.diagnose(a, d) }, - // Every write to the browser is bounded, so one that stopped reading ends its - // stream rather than holding it, and a shared stream's rechecks, which wait - // for the write in progress. - WriteTimeout: s.writes, + Scopes: gateway.ScopePolicy{Targets: []string{""}, AnyResource: true, AllowLabelSelector: true}, + StreamConfig: gateway.StreamConfig{ + // Kubernetes decides: every watch is opened with the user's own token, and + // the API server refuses what RBAC does not allow. + Authorizer: gateway.AllowAll{}, + Clients: u.backend, + Projections: gateway.ProjectionPolicyFunc(project), + Diagnostics: func(d gateway.Diagnostic) { s.diagnose(a, d) }, + // Every write to the browser is bounded, so one that stopped reading ends its + // stream rather than holding it, and a shared stream's rechecks, which wait + // for the write in progress. + WriteTimeout: s.writes, + }, } // A scope the gateway refuses goes the user's way, and is refused there. if scope, err := gateway.ScopeFromQuery(r.URL.Query()); err == nil && s.shared.serves(scope) {