diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c703391..cbd8c46 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -99,13 +99,21 @@ ENV DEBIAN_FRONTEND=noninteractive SHELL ["/bin/bash", "-o", "pipefail", "-c"] ENV DLV_VERSION=v1.27.0 \ - GOPLS_VERSION=v0.22.0 \ - STATICCHECK_VERSION=v0.7.0 + GOPLS_VERSION=v0.23.0 \ + STATICCHECK_VERSION=v0.7.0 \ + CODEX_VERSION=0.144.3 RUN apt-get update \ && apt-get -y install --no-install-recommends bash-completion \ && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* +# Codex CLI. npm's prefix here is /usr, which lives in the container's writable layer — so this +# has to be baked into the image, not installed at post-create, or a rebuild silently loses it. +# The login it writes lands in ~/.codex/auth.json, which devcontainer.json mounts as a volume. +RUN npm install -g "@openai/codex@${CODEX_VERSION}" \ + && npm cache clean --force \ + && codex --version + # hadolint ignore=SC2016 RUN printf '%s\n' \ '' \ diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..d2f65b2 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2.5.9": { + "version": "2.5.9", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a" + }, + "ghcr.io/devcontainers/features/docker-outside-of-docker:1.10.0": { + "version": "1.10.0", + "resolved": "ghcr.io/devcontainers/features/docker-outside-of-docker@sha256:c2c2cf829505ead8e4892c88c31b6594ae94a2bbb209e16e1fac456c1a3a624e", + "integrity": "sha256:c2c2cf829505ead8e4892c88c31b6594ae94a2bbb209e16e1fac456c1a3a624e" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "1.3.8", + "resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2", + "integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d2e9137..429b7aa 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -40,6 +40,7 @@ "extensions": [ "golang.go", "anthropic.claude-code", + "openai.chatgpt", "redhat.vscode-yaml" ] } @@ -59,12 +60,16 @@ // cannot write to it at all. post-create.sh chowns /home/vscode for exactly this reason. // Without it: `claude` cannot log in, `gh auth login` cannot store a token, and the symptom // gives you no clue why. + // + // Codex is kinder than Claude here: it keeps BOTH its config and its login (auth.json) inside + // ~/.codex, so the one volume is the whole story — no symlink into persisted-home needed. "mounts": [ "source=krm-stream-gomodcache,target=/go/pkg/mod,type=volume", "source=krm-stream-gobuildcache,target=/home/vscode/.cache/go-build,type=volume", "source=krm-stream-dotkube,target=/home/vscode/.kube,type=volume", "source=krm-stream-ghconfig,target=/home/vscode/.config/gh,type=volume", "source=krm-stream-claudeconfig,target=/home/vscode/.claude,type=volume", + "source=krm-stream-codexconfig,target=/home/vscode/.codex,type=volume", "source=krm-stream-persisted-home,target=/home/vscode/persisted-home,type=volume" ], "containerEnv": { diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index a58a3a0..3cd4029 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -27,16 +27,17 @@ bash "${workspace_dir}/.devcontainer/sync-signing-key.sh" \ || log "warning: SSH commit signing not configured (no agent / no key?) — commits will be unsigned" # ------------------------------------------------------- home ownership (READ ME) -- -# Every named volume mounted under /home/vscode (~/.claude, ~/.config/gh, ~/.kube, the Go -# caches, persisted-home) is created by Docker as ROOT-owned, because the image has no such +# Every named volume mounted under /home/vscode (~/.claude, ~/.codex, ~/.config/gh, ~/.kube, the +# Go caches, persisted-home) is created by Docker as ROOT-owned, because the image has no such # directory to inherit ownership from. Without this chown, the `vscode` user cannot write to -# any of them — and the symptom is baffling: `claude` cannot log in, `gh auth login` cannot -# store a token, and kubectl cannot write a kubeconfig. Same fix as gitops-api's. +# any of them — and the symptom is baffling: `claude` and `codex` cannot log in, `gh auth login` +# cannot store a token, and kubectl cannot write a kubeconfig. Same fix as gitops-api's. log "ensuring cache dirs exist, then fixing ownership under /home/vscode" sudo mkdir -p \ /home/vscode/.cache/go-build \ /home/vscode/.cache/golangci-lint \ /home/vscode/.claude \ + /home/vscode/.codex \ /home/vscode/.config/gh \ /home/vscode/.kube \ /home/vscode/persisted-home diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..17d0c4f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,61 @@ +# Grouped, so a week of upstream patch releases is one PR to review and one release-note line — not +# eleven. Every ecosystem in the repo is listed: an ecosystem that is not listed here is one where +# nobody is watching. +# +# Conventional commits, because release-please reads them: `chore(deps):` keeps routine bumps out of +# the changelog, which is what a consumer of this library wants — they do not care that our linter +# moved. The k8s.io/* floor is DELIBERATELY excluded below; see gateway/kube/go.mod for why. +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: ["*"] + commit-message: + prefix: "chore(deps)" + + # The core module has no dependencies at all — by design, and CI fails if that changes — so there + # is nothing here to update. Only the adapter has any. + - package-ecosystem: gomod + directory: /gateway/kube + schedule: + interval: weekly + groups: + gomod: + patterns: ["*"] + ignore: + # The k8s.io/* requirement is a SUPPORTED-VERSION FLOOR, not a version to keep current: Go's + # minimal version selection means an adopter already on a newer client-go keeps it, while + # chasing patch releases here would force every adopter up to whatever we last built against. + # An adapter has no business dictating which Kubernetes libraries its host runs. Raising it is + # a deliberate act (it raises the supported minor), so it is not Dependabot's to make. + - dependency-name: k8s.io/* + commit-message: + prefix: "chore(deps)" + + # devDependencies only — this package ships ZERO runtime dependencies, and the three dev ones are + # the compiler, its node types, and biome. + - package-ecosystem: npm + directory: /packages/krm-stream + schedule: + interval: weekly + groups: + npm: + patterns: ["*"] + commit-message: + prefix: "chore(deps)" + + # Playwright lives here rather than in the library, which is exactly why it needs its own entry. + - package-ecosystem: npm + directory: /examples/vanilla-browser + schedule: + interval: weekly + groups: + npm: + patterns: ["*"] + commit-message: + prefix: "chore(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43867be..d66950b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,26 @@ +# The validation pipeline: everything needed to TRUST a commit, and nothing that publishes. +# +# It runs directly on pull requests, and release.yml calls it as a reusable workflow on every push to +# main. That is the whole trust model in one rule: the release tail runs only after the exact same +# jobs a PR gets, in the same run — so a write token never meets code this pipeline has not already +# validated. (GitHub enforces the fork half of it too: a fork PR always gets a read-only token.) name: ci on: - push: - branches: [main] pull_request: + workflow_call: + workflow_dispatch: permissions: contents: read +# A new push to a PR cancels the previous run. When release.yml calls this workflow there is no PR +# number, and github.run_id keeps the group unique — so a release run can never be cancelled by, or +# cancel, anything else. +concurrency: + group: ci-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: # The point of the monorepo: one contract, and a change to it must be seen by BOTH # implementations in the same commit. Both jobs read conformance/gen/, which is built @@ -17,7 +30,9 @@ jobs: name: fixtures are the contract runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: install yq run: sudo curl -fsSL -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.48.1/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq - name: regenerate @@ -30,37 +45,284 @@ jobs: runs-on: ubuntu-latest needs: fixtures steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "1.26" - cache-dependency-path: gateway/go.sum + # There is no gateway/go.sum, and there never will be: the core module has ZERO + # dependencies, and the `consumable` job below fails the build if it ever grows one. So + # there is nothing to restore and nothing to save — asking for a cache here only produced + # a "Restore cache failed: some specified paths were not resolved" warning on every run. + cache: false - run: go vet ./... working-directory: gateway - - run: go test ./... + # -race: the gateway fans out one upstream watch to N subscribers (SharedBackend). A data race + # there shows a browser another tenant's object; it is not a flaky-test problem. + - run: go test -race ./... working-directory: gateway - - uses: golangci/golangci-lint-action@v9 + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: v2.12.2 working-directory: gateway + # The Kubernetes adapter is a SEPARATE MODULE — which is the point (`go get .../gateway` stays + # dependency-free, and client-go is opt-in) but has a sharp edge: the gateway job's `go test ./...` + # does not reach across a module boundary, so without this job the adapter would ship untested. + # + # The e2e build tag is VETTED here and RUN nowhere in per-PR CI: the real-cluster suite needs + # Docker-in-Docker and minutes, while the fixtures gate every PR in milliseconds (`task + # test-cluster` runs it, and proposal 0002 says why it lives outside this gate). Vetting it still + # matters — a test suite that no longer compiles is a suite nobody notices has stopped running. + kube: + name: kube adapter (go) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: "1.26" + # This module DOES have dependencies (it is the only one that imports client-go), so this + # is the one Go job where a cache is real. + cache-dependency-path: gateway/kube/go.sum + - run: go vet -tags e2e ./... + working-directory: gateway/kube + - run: go test ./... + working-directory: gateway/kube + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.12.2 + working-directory: gateway/kube + args: --build-tags e2e + + # Can a STRANGER actually use this? Nothing else in this repo can answer that. + # + # gateway/kube once carried `replace ... => ../`, and a replace applies ONLY to the main module. So + # every test in this repo passed while `go get github.com/ConfigButler/krm-stream/gateway/kube` was + # broken for literally everyone else ("unknown revision gateway/v0.0.0"). The bug was invisible from + # the inside BY CONSTRUCTION — which is why this job builds from a clean module in a temp directory, + # outside the workspace, resolving over the network exactly as an adopter would. + # + # It also guards the other half of the promise: the CORE module must stay dependency-free. `go get + # .../gateway` pulling in client-go would make the protocol cost what the adapter costs. + # + # There is a sibling of this job in release.yml (`consumable-release`) that runs the same check + # against the PUBLISHED TAG rather than a commit sha. That one is the check that actually matters + # to an adopter; this one is the one that catches the mistake before it is tagged. + consumable: + name: a stranger can `go get` this + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: "1.26" + # No cache, and not because a file is missing: this job's entire job is to resolve the + # modules FROM THE NETWORK, the way an adopter does. A warm module cache here would be a + # way for the bug to hide. (It also has no go.mod of its own to key a cache on — the repo + # root holds a go.work — which is what the "dependencies file is not found" warning was.) + cache: false + + - name: the core module has NO dependencies at all + run: | + set -euo pipefail + if grep -qE '^[[:space:]]*require' gateway/go.mod; then + echo '::error file=gateway/go.mod::the core module grew a dependency. go get of .../krm-stream/gateway is advertised as zero-dependency: it is the protocol, and an adopter must not inherit client-go transitively just to read a conforming event.' + grep -nE '^[[:space:]]*require' -A5 gateway/go.mod + exit 1 + fi + echo "gateway/go.mod has no require block. The protocol still costs nothing." + + # The original bug, caught directly and cheaply, on every PR. The `go get` below would also + # catch it — but only while the version gateway/kube requires is one that already exists as a + # tag, and on a release commit it is not (see the guard below). This grep has no such blind + # spot, so the exact mistake that shipped an unconsumable module can never be re-introduced + # silently. + - name: no module may carry a `replace` + run: | + set -euo pipefail + if grep -nE '^[[:space:]]*replace([[:space:]]|\()' gateway/go.mod gateway/kube/go.mod; then + echo '::error::a `replace` directive applies ONLY to the main module. It works in this checkout and is IGNORED by everyone who runs `go get`, so it makes the module unconsumable while every test here still passes. go.work is how this repo points the modules at each other; see the comment at the top of it.' + exit 1 + fi + echo "no replace directives. go.work is doing the job, and consumers are not lied to." + + # No checkout is used below this line, on purpose: the module must resolve from the network. + - name: go get the ADAPTER from a clean module, outside this workspace + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + env: + # direct, not the proxy: a commit pushed seconds ago may not be in proxy.golang.org yet, and + # a cache miss here would look like a pass. + GOPROXY: direct + GOFLAGS: -mod=mod + run: | + set -euo pipefail + + # gateway/kube requires the core module by version. On an ordinary commit that version is + # one that has already been released, so the resolution below is exactly what an adopter + # gets. On a RELEASE commit it is the version being released — whose tag does not exist + # until the release PR merges — and `go get` would fail here for a reason that is not a + # bug. So: if the required core version has no tag yet, skip. release.yml re-runs this same + # check against the real tag once it exists, which is the stronger check anyway. + core="$(sed -n 's|^[[:space:]]*github.com/ConfigButler/krm-stream/gateway v\([^ ]*\).*|\1|p' gateway/kube/go.mod | head -1)" + case "$core" in + 0.0.0-*) ;; # a pseudo-version names a commit, and always resolves + "") + echo "::error::gateway/kube/go.mod does not require the core module at all" && exit 1 ;; + *) + if ! git ls-remote --exit-code --tags origin "refs/tags/gateway/v${core}" >/dev/null 2>&1; then + echo "::notice::gateway/kube requires core v${core}, which is not tagged yet — this is a release commit. The published tag is verified by release.yml (consumable-release)." + exit 0 + fi ;; + esac + + sha="${{ github.event.pull_request.head.sha || github.sha }}" + tmp="$(mktemp -d)" + cd "$tmp" + go mod init example.com/stranger + cat > main.go <<'GO' + package main + + import ( + "k8s.io/client-go/dynamic" + + "github.com/ConfigButler/krm-stream/gateway" + "github.com/ConfigButler/krm-stream/gateway/kube" + ) + + // The one line an adopter writes: the adapter must satisfy the core's seam. + var _ gateway.Backend = kube.NewBackend(dynamic.Interface(nil)) + + func main() {} + GO + go get "github.com/ConfigButler/krm-stream/gateway/kube@${sha}" + go build ./... + echo "a stranger can go get and build the Kubernetes adapter." + client: name: client (typescript) runs-on: ubuntu-latest needs: fixtures steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "22" cache: npm cache-dependency-path: packages/krm-stream/package-lock.json - - run: npm install --no-audit --no-fund + # npm ci, not npm install: the lockfile is the input, and a build that is allowed to quietly + # resolve a different tree than the one committed is not the build we tested. + - run: npm ci --no-audit --no-fund working-directory: packages/krm-stream - # Node 22 runs .ts directly (type stripping) — the suite needs no test deps. + # Node 22 runs .ts directly (type stripping) — the suite needs no test runner dependency. - run: node --test working-directory: packages/krm-stream - - run: npx tsc --noEmit + # ...but stripping is not checking. This is the ONLY thing that typechecks test/, and test/ is + # where the conformance suite lives, so it is not optional. + - run: npx tsc --noEmit -p tsconfig.test.json working-directory: packages/krm-stream + - run: npx biome ci + working-directory: packages/krm-stream + # The build, last: it is what ships, and it must stay plain ESM a browser can import with no + # bundler and no runtime dependency. - run: npx tsc working-directory: packages/krm-stream + + # The unscoped package is a tiny forwarder. Its version and dependency on the real scoped + # package must match the release that is being packed, so derive both from the scoped + # package.json immediately before packing. This edits only the CI workspace; release-please + # owns the committed version bumps. + - name: synchronize the unscoped forwarder + run: | + set -euo pipefail + node <<'NODE' + const fs = require("node:fs"); + const real = JSON.parse(fs.readFileSync("packages/krm-stream/package.json", "utf8")); + const compatPath = "packages/krm-stream-compat/package.json"; + const compat = JSON.parse(fs.readFileSync(compatPath, "utf8")); + compat.version = real.version; + compat.dependencies["@configbutler/krm-stream"] = real.version; + fs.writeFileSync(compatPath, `${JSON.stringify(compat, null, 2)}\n`); + NODE + + # Pack the tarballs HERE, in the job that just tested them, and hand them to the release + # workflow as artifacts. release.yml publishes these exact bytes rather than rebuilding them: + # the things on npm are then the things this pipeline proved, and not a second build that + # merely ought to match. + # + # The versions inside them are already correct on a release commit — release-please bumps + # package.json in the release PR, so by the time this runs on main the tree IS the release. + - name: pack the tarballs that will be published + # mkdir first: --pack-destination does not create the directory, it fails with ENOENT. + run: | + set -euo pipefail + mkdir -p /tmp/npm + npm pack --pack-destination /tmp/npm ./packages/krm-stream + npm pack --pack-destination /tmp/npm ./packages/krm-stream-compat + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-packages + path: /tmp/npm/*.tgz + if-no-files-found: error + retention-days: 7 + + # The seam. Both suites above pass against bytes that never left their own process — which cannot + # prove that the server flushes, that frames survive arbitrary chunk boundaries, or that a terminal + # error really closes the connection. This job runs the REAL Go gateway over a REAL socket and reads + # it with the REAL TypeScript client. Still no cluster: the upstream is the scripted corpus. + wire: + name: end to end (go gateway -> ts client, over HTTP) + runs-on: ubuntu-latest + needs: fixtures + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: "1.26" + cache: false # the core module has no dependencies; see the `gateway` job + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + - uses: arduino/setup-task@c0bc642852239c2689f73f4ea6459c29405f3c52 # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - run: task e2e-wire + + # The top of the ladder that still needs no cluster. This is the ONLY job that proves the library's + # central promise — that the published ESM imports in a browser with no bundler — because Node + # importing it proves nothing: Node is not a browser. It is also the only place a `flash` is a fact + # rather than an array, and the only place native EventSource runs at all. + browser: + name: end to end (a real browser) + runs-on: ubuntu-latest + needs: fixtures + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: "1.26" + cache: false # the core module has no dependencies; see the `gateway` job + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + - uses: arduino/setup-task@c0bc642852239c2689f73f4ea6459c29405f3c52 # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - run: task e2e-browser + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: failure() + with: + name: playwright-trace + path: examples/vanilla-browser/test-results/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a2d9a3c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,180 @@ +# The trusted pipeline for pushes to main: run the FULL validation workflow (ci.yml — the exact same +# jobs a PR gets), and only once every one of them is green let release-please version and tag, and +# then publish. Nothing here runs for pull requests, so a write token never meets unreviewed code. +# +# What "release" means is different for the two halves of this repo, and both are handled below: +# +# npm the client is a package, and it is PUSHED to registry.npmjs.org. +# Go the gateway and its Kubernetes adapter are not pushed anywhere. A Go module is +# published by the mere existence of a git tag of the form `/vX.Y.Z`, which +# the GitHub release created here IS. So `consumable-release` is not a formality: the tag +# is the artifact, and that job is the only thing that ever proves the artifact works. +# +# Versioning is conventional-commits, in lockstep: one number for the npm client, the gateway and the +# adapter, because this repo's whole claim is that they are one contract and move in one commit. A +# client that speaks protocol N and a gateway that speaks protocol N have no business carrying version +# numbers that need a compatibility table to compare. +name: release + +on: + push: + branches: [main] + +permissions: + contents: read + +# Never cancel a release mid-publish; a follow-up push queues behind it. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + ci: + name: ci + uses: ./.github/workflows/ci.yml + permissions: + contents: read + + release-please: + name: release-please + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: ci + permissions: + contents: write # create the tags and releases + pull-requests: write # open/refresh the release PR + outputs: + released: ${{ steps.release.outputs.releases_created }} + version: ${{ steps.release.outputs['packages/krm-stream--version'] }} + kube_tag: ${{ steps.release.outputs['gateway/kube--tag_name'] }} + gateway_tag: ${{ steps.release.outputs['gateway--tag_name'] }} + steps: + - id: release + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Publish the tarballs that ci.yml PACKED AND TESTED in this same run — not a second build of the + # same source. `npm pack` ran in the `client` job after the type check, the lint and the browser + # e2e's build; those exact bytes are what land on the registry. + # + # No NPM_TOKEN. This publishes over OIDC ("trusted publishing"): npm trades this workflow's + # short-lived GitHub identity token for a publish credential, which means the repo holds no + # long-lived registry secret that could leak, and npm attaches SLSA provenance to the release + # automatically — the published package can be traced back to this commit and this workflow. + # + # It requires a one-time setup on npmjs.com (Settings -> Trusted publisher for BOTH + # `@configbutler/krm-stream` and `krm-stream`: repo ConfigButler/krm-stream, workflow + # `release.yml`). See docs/releasing.md. + npm: + name: publish to npm + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [ci, release-please] + if: needs.release-please.outputs.released == 'true' + permissions: + contents: read + id-token: write # the OIDC token npm exchanges for a publish credential + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: npm-packages + path: /tmp/npm + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + # Writes the .npmrc that points publish at the public registry. With trusted publishing + # there is no token to put in it. + registry-url: https://registry.npmjs.org + + # Trusted publishing needs npm >= 11.5.1; Node 22 ships an older npm. + - run: npm install -g npm@latest + + - name: the tarballs must be the version that was just released + env: + VERSION: ${{ needs.release-please.outputs.version }} + run: | + set -euo pipefail + scoped="/tmp/npm/configbutler-krm-stream-${VERSION}.tgz" + compat="/tmp/npm/krm-stream-${VERSION}.tgz" + if [ ! -f "$scoped" ] || [ ! -f "$compat" ]; then + echo "::error::expected ${scoped} and ${compat}, but ci.yml packed: $(ls /tmp/npm)" + echo "the tarball and the tag disagree about what this release is — refusing to publish." + exit 1 + fi + echo "scoped_tarball=${scoped}" >> "$GITHUB_ENV" + echo "compat_tarball=${compat}" >> "$GITHUB_ENV" + + - name: publish the scoped package first + run: npm publish "$scoped_tarball" --provenance --access public + + - name: publish the unscoped forwarder + run: npm publish "$compat_tarball" --provenance --access public + + # A Go module is published by its TAG, and until something outside this repository resolves that + # tag, "published" is a hope. This is the same check as ci.yml's `consumable`, aimed at the thing an + # adopter actually types — `go get ...@v0.3.1`, not `@` — and it is the only run of it that can + # exist, because the tag does not exist until the job above creates it. + # + # It is also the check that would have caught the `replace` bug at the only moment that mattered. + consumable-release: + name: a stranger can `go get` the release + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: release-please + if: needs.release-please.outputs.released == 'true' + permissions: + contents: read + steps: + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: "1.26" + cache: false # it must resolve from the network. That is the entire point. + + # No checkout anywhere in this job, on purpose. + - name: go get the released ADAPTER from a clean module + env: + # direct: the tag was created seconds ago and proxy.golang.org may not have indexed it yet, + # so a proxy miss here would look like a failure that is not one. + GOPROXY: direct + GOFLAGS: -mod=mod + KUBE_TAG: ${{ needs.release-please.outputs.kube_tag }} + run: | + set -euo pipefail + tmp="$(mktemp -d)" + cd "$tmp" + go mod init example.com/stranger + cat > main.go <<'GO' + package main + + import ( + "k8s.io/client-go/dynamic" + + "github.com/ConfigButler/krm-stream/gateway" + "github.com/ConfigButler/krm-stream/gateway/kube" + ) + + // The one line an adopter writes: the adapter must satisfy the core's seam. + var _ gateway.Backend = kube.NewBackend(dynamic.Interface(nil)) + + func main() {} + GO + go get "github.com/ConfigButler/krm-stream/gateway/kube@${KUBE_TAG#gateway/kube/}" + go build ./... + echo "a stranger can go get and build ${KUBE_TAG}." + + # Ask the module proxy for the new versions, which is what makes them appear on pkg.go.dev. It + # would get there eventually on its own, via the first person to ask — but "eventually, if a + # stranger happens to try" is a poor way to publish documentation. + - name: warm proxy.golang.org and pkg.go.dev + env: + GOPROXY: https://proxy.golang.org + GATEWAY_TAG: ${{ needs.release-please.outputs.gateway_tag }} + KUBE_TAG: ${{ needs.release-please.outputs.kube_tag }} + run: | + set -euo pipefail + go list -m "github.com/ConfigButler/krm-stream/gateway@${GATEWAY_TAG#gateway/}" || true + go list -m "github.com/ConfigButler/krm-stream/gateway/kube@${KUBE_TAG#gateway/kube/}" || true diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..be4619b --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,45 @@ +# OpenSSF Scorecard: automated supply-chain hygiene checks (token permissions, pinned dependencies, +# branch protection, dangerous workflow patterns, ...). Results land in the repo's code-scanning tab +# and on the public report at +# https://scorecard.dev/viewer/?uri=github.com/ConfigButler/krm-stream +# +# This is not decoration. A library whose whole pitch is "import this into your browser and trust what +# it tells you about your cluster" is asking for a lot of trust, and it should be able to show its +# working. +name: scorecard + +on: + branch_protection_rule: + schedule: + - cron: "30 3 * * 1" + push: + branches: [main] + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: scorecard analysis + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + security-events: write # upload SARIF to code scanning + id-token: write # publish results to the public Scorecard API + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: run scorecard analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: upload SARIF to code scanning + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 62e1ff1..7b25c60 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,14 @@ packages/*/dist/ # Editor scratch *.local.* + +# Playwright (examples/vanilla-browser) — the browser rung of the e2e ladder +test-results/ +playwright-report/ + +# A sibling repo checked out for reference (gitops-reverser: its e2e stands up the wardle +# sample-apiserver, which is where the aggregated-API fixtures came from). It is NOT ours. +external/ + +# The workspace is committed (it is how gateway/kube builds locally); its sum file is not. +go.work.sum diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..b938019 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,6 @@ +{ + "packages/krm-stream": "0.0.0", + "packages/krm-stream-compat": "0.0.0", + "gateway": "0.0.0", + "gateway/kube": "0.0.0" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d99396..1983489 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,75 +1,55 @@ # Contributing -## Get running +## Prerequisites -Open the repo in the devcontainer (*Reopen in Container*). Everything is already installed — Go 1.26, -Node 22, Task, jq/yq, k3d, kubectl. +The devcontainer provides Go 1.26, Node 22, Task, `kubectl`, and `k3d`. ```bash -task # what there is -task test # go test + node --test, both against the shared conformance fixtures -task lint # go vet + golangci-lint + tsc --noEmit -task fixtures # rebuild conformance/gen/*.json from the YAML sources +task fixtures-check # regenerate and verify shared fixture output +task test # gateway, Kubernetes adapter, and client suites +task lint # Go vet, golangci-lint, TypeScript, and Biome +task build-client # produce the dependency-free ESM bundle ``` -**Before every commit:** `task test` and `task lint` must pass, and `conformance/gen/` must not be -stale (`task fixtures-check`). CI enforces all three. +Run `task fixtures-check`, `task test`, and `task lint` before opening a pull request. -## The one rule +## Design rules -``` -krm-stream ──depends on──▶ nothing of ConfigButler's. Ever. -``` - -This library knows **KRM**. It does not know GitOps, Flux, Dex, kcp, tenants, or ConfigButler. If the -gateway ever seems to need "which namespace is this caller's?", that is a missing *interface*, not a -missing import — inject it (`Authorizer`, `ClientFor(target, principal)`) and let the host answer. - -The moment that rule bends, this stops being a library anyone else can adopt, and becomes a piece of -someone's product with a misleading name. - -## The order of work +- Keep the core gateway free of `client-go`; Kubernetes integration belongs in `gateway/kube`. +- Keep the browser client framework-free and free of runtime dependencies. +- Keep credentials, application identity, authorization policy, and writes in the host application. +- Treat `spec/v1.md` and `conformance/` as the shared contract between gateway and client. -The contract comes first, and it is already written. Both implementations are built *against* it. +## Changing behavior -1. **[`spec/v1.md`](spec/v1.md)** — the wire. Normative; changes here are protocol changes. -2. **[`conformance/`](conformance/)** — the spec, executable. KRM object bodies in YAML, scenarios that - say what the gateway must emit and what the client must then hold. **Both suites load the same - files** — that is the whole reason this is one repo. -3. **[`packages/krm-stream/`](packages/krm-stream/)** — the client (`LiveResourceStore`). Pure logic, - no Kubernetes, fastest feedback. Implement the algorithm in - [`docs/client-state-model.md`](docs/client-state-model.md) until the fixtures are green. -4. **[`gateway/`](gateway/)** — the Go producer. Test against a fake watch; then prove it against a - real API server (`task spike-up`). +1. Update [spec/v1.md](spec/v1.md) when the wire contract changes. +2. Add or update a fixture in [`conformance/`](conformance/) for behavior both sides share. +3. Add focused package tests for behavior a fixture cannot express. +4. Run the validation commands above. -**Test-first is not a style preference here.** The merge logic this library replaces shipped three -bugs in three days while living inline in an HTML file where no test could reach it. Each of those -bugs is now a named fixture (`edit-vs-unrelated-change`, `conflict-and-converge`, `dotted-label-keys`). -They exist so the rewrite cannot reintroduce them. +Protocol compatibility is explicit. Additive optional fields are allowed when consumers can ignore them; +incompatible event semantics require a new protocol version rather than a silent reinterpretation. -## Changing the protocol +## Fixtures -A protocol change is a change to `spec/v1.md` **and** to the fixtures **and** to both implementations, -in one commit. CI is set up so that a change to `conformance/` re-runs both suites; if only one side -compiles, you have broken the contract and the build will say so. +Fixtures use source YAML in `conformance/bodies/` and `conformance/fixtures/`. Run `task fixtures` to +regenerate `conformance/gen/`; generated files are committed. Each fixture's `why` field should name +the rule it protects. -Adding an *optional* event type or field is not a breaking change — consumers must ignore what they -don't know (spec §0). Anything else gets a new path segment (`/v2`), not a silent redefinition. +## Test levels -## Adding a fixture +| Command | Purpose | +|---|---| +| `task test` | Deterministic gateway, adapter, client, and shared-fixture coverage. | +| `task e2e-wire` | Real Go SSE bytes consumed by the TypeScript client over HTTP. | +| `task e2e-browser` | Native `EventSource` and unbundled ESM in Chromium. | +| `task cluster-facts` | Record observed Kubernetes behavior for the supported cluster version. | +| `task test-cluster` | Exercise the Kubernetes backend against a real API server. | -1. Add or reuse a body in `conformance/bodies/` — it is a plain Kubernetes object, written the way you - would `kubectl apply` it. -2. Add `conformance/fixtures/.yaml`. In `why:`, name the rule it defends. If it defends no rule, - ask whether it earns its keep. -3. `task fixtures && task test`. Commit the YAML **and** the generated JSON. +The cluster tasks need Docker and take longer; the fixture suites are the per-pull-request baseline. ## Style -- **Go:** `gofmt`, `go vet`, `golangci-lint` clean. The wire types stay dependency-free — no client-go - in `event.go`. -- **TypeScript:** `strict`. **No runtime dependencies, ever** — the published bundle is plain ESM a - browser can `import` with no bundler. `typescript` is the only devDependency; the tests run on - Node's built-in runner, which reads `.ts` directly. -- Comments explain *why*, and especially *what breaks if you do the obvious thing instead*. The - fixtures are the best example of the house style: every one of them says what it defends. +- Format Go with `gofmt`; keep `go vet` and `golangci-lint` clean. +- Keep TypeScript strict and pass `biome check`. +- Prefer narrow changes and comments that explain constraints or non-obvious choices. diff --git a/README.md b/README.md index f72879d..b421feb 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,101 @@ # krm-stream -**A live, honest window onto Kubernetes resources, in the browser.** +`krm-stream` turns Kubernetes resource watches into a small, browser-safe SSE protocol. It ships a +Go gateway, a headless TypeScript store, and a shared conformance corpus. -**A Go library** that turns a Kubernetes watch into a browser-friendly stream of complete **KRM** -(Kubernetes Resource Model) objects — absorbing every watch mechanic (`resourceVersion` arithmetic, -`410 Gone`, relists, bookmarks, partial objects, reconnects) behind a small, stable wire contract — -**and a small JavaScript client that ships with it**, so the browser end is solved too rather than left -as an exercise. +The library owns the read stream. Your application owns identity, authorization policy, Kubernetes +credentials, and writes. -Go is the product. The npm package is the helper you would otherwise have had to write. +## Start here + +Mount a scoped stream endpoint in an existing Go application: ```go -import "github.com/ConfigButler/krm-stream/gateway" - -// Your app answers the two questions the library must never assume: -// who is this caller, and what may they see? -h := gateway.Handler(gateway.Options{ - Authorizer: myAuthz, // may this principal watch this scope? - ClientFor: myClientForIdentity, // a client acting AS them — their RBAC, their attribution -}) -mux.Handle("/resource-stream/v1", h) +mux.Handle("/resource-stream/v1", gateway.Handler(gateway.Options{ + Principal: func(r *http.Request) (gateway.Principal, error) { return userFromSession(r) }, + Authorizer: authorizeScope, + Clients: func(_ context.Context, _ string, p gateway.Principal) (gateway.Backend, error) { + return kube.NewBackend(dynamicClientFor(p.(*User))), nil + }, + Scopes: gateway.ScopePolicy{ + Targets: []string{"production"}, + Resources: []gateway.GroupResource{ + {Resource: "configmaps", Scope: gateway.ResourceScopeNamespaced}, + }, + }, + Projection: gateway.ProjectionFull, +})) ``` -...and the browser end, for free: +Consume the stream without choosing a UI framework: -```js -import { LiveResourceStore, connectResourceStream } from "krm-stream"; +```ts +import { LiveResourceStore, connectWithEventSource, resourceStreamURL } from "@configbutler/krm-stream"; const store = new LiveResourceStore(); -connectResourceStream("/resource-stream/v1?resource=configmaps&namespace=app", store); - -store.subscribe(() => render(store)); // live status, live conflicts -store.setValue(uid, ["data", "log-level"], "debug"); -await save(store.patch(uid)); // a merge patch of only what you changed +connectWithEventSource( + resourceStreamURL("/resource-stream/v1", { + target: "production", + version: "v1", + resource: "configmaps", + namespace: "app", + }), + store, +); ``` -Watch `status` reconcile in real time. Edit `spec` with a real three-way merge. Real RBAC, real -attribution — the browser sees exactly what the API server sees, and never more. - ---- - -## Why this exists +`LiveResourceStore` keeps server truth and a local draft separate, reconciles live updates with a +three-way merge, records conflicts, and builds RFC 7386 merge patches. The host applies any patch +through its own save endpoint. -Every "Kubernetes in a browser" UI reinvents the same three things, and gets at least one of them -wrong: +## Packages -1. **The watch → browser bridge.** `resourceVersion` arithmetic, `410 Gone`, relists, bookmarks, - partial objects, reconnects. Get it wrong and you show ghosts: objects deleted an hour ago that - are still on the screen. -2. **The merge.** The server writes `status` continuously while a human is typing into `spec`. A UI - that naively overwrites the form on every watch event destroys the edit; one that naively ignores - the event shows stale truth. The correct answer is a **three-way merge** — previous server state, - your draft, new server state — and almost nobody does it. -3. **The honesty.** Most consoles flatten Kubernetes into an abstracted "document" and lose the thing - that made it worth showing. A CRD you have never heard of must round-trip **verbatim**. +| Package | Purpose | +|---|---| +| `github.com/ConfigButler/krm-stream/gateway` | Dependency-free Go stream gateway and SSE handler. | +| `github.com/ConfigButler/krm-stream/gateway/kube` | Optional `client-go` backend and SSAR authorizer. | +| `@configbutler/krm-stream` | Official dependency-free ESM client store and transports. | +| `krm-stream` | Compatibility forwarder to the official scoped npm package. | +| [`spec/v1.md`](spec/v1.md) | Normative protocol contract. | +| [`conformance/`](conformance/) | Shared fixtures exercised by the Go gateway and TypeScript client. | -`krm-stream` does those three things, once, with a written contract and a conformance suite that both -sides run. +## Boundaries -## The three artifacts +- No browser token handling or raw API-server URLs. +- No authorization system: the host provides `Principal`, `Authorizer`, and `ClientFor`. +- No write endpoint: hosts validate and apply their own patches. +- No framework dependency in the browser client. +- No `client-go` dependency in the core gateway. -| | what | where | -|---|---|---| -| **Gateway** (Go) — *the library* | `go get github.com/ConfigButler/krm-stream/gateway`. Produces the stream from a Kubernetes watch; absorbs every watch mechanic; applies saves as a guarded patch (it will refuse one that touches a redacted path). Your app injects the two things it must never assume: **who the caller is**, and **what they may see** | [`gateway/`](gateway/) | -| **Protocol** — *the contract* | the wire: `reset` · `added` · `modified` · `deleted` · `synced` · `error`, over SSE. Language-neutral on purpose: a Rust or Python gateway is a legitimate thing to write | [`spec/v1.md`](spec/v1.md) | -| **Client** (TS/JS) — *the helper* | `npm i krm-stream`. `LiveResourceStore`: three-way merge, derived dirtiness, conflicts, merge-patch builder. Optional — any conforming consumer works — but you would only reimplement it | [`packages/krm-stream/`](packages/krm-stream/) | +The important safety rule is simple: a projected or redacted field must never be written back by a +browser. Use [`gateway.ValidateMergePatch`](gateway/patch.go) in the host save handler. -They are joined by one thing, and it is the reason they live in one repo: +## Guides -> **[`conformance/`](conformance/) — shared fixtures.** KRM object bodies in YAML, plus scenarios that -> say what the gateway must *emit* and what the client must then *hold*. Both suites load them. A -> protocol change that breaks one side fails both, in the same commit, before it can ship. +- [Adopting krm-stream](docs/adopting.md): same-origin cookie, bearer-token, and shared-watch setups. +- [Authentication and authorization](docs/auth.md): identity and RBAC boundaries. +- [Saving edits safely](docs/saving.md): patch validation and host write responsibilities. +- [Operating krm-stream](docs/operations.md): metrics, alerts, and runtime controls. +- [Client state model](docs/client-state-model.md): drafts, conflicts, redactions, and keyed lists. +- [Releasing](docs/releasing.md): release workflow and publication prerequisites. -## Status +## Requirements and maturity -**Early.** The specs are written and the conformance fixtures are the contract. The gateway and the -client are being implemented against them, test-first. Nothing is published yet. +The project is pre-1.0. Protocol and API changes may still be made before the first release. -The design record lives in [`docs/`](docs/): -[client-state-model](docs/client-state-model.md) (the merge algorithm the client implements), -[extraction-plan](docs/extraction-plan.md) (where this came from, and the order of the work), and -[naming](docs/naming.md) (why `krm-stream` — and why not `krm-live`). -[`CONTRIBUTING.md`](CONTRIBUTING.md) is how to run it. +- Go 1.26 for the gateway. +- Node 22 for client development and tests. +- Kubernetes 1.35+ for strict resource-version ordering. `OrderingLenient` supports known + non-conformant or aggregated APIs at the cost of per-object monotonic ordering. -## Quick start +## Development ```bash -task # list everything -task test # go test + node --test, both against the shared fixtures +task fixtures-check +task test task lint -task fixtures # regenerate conformance/gen/*.json from the YAML sources +task build-client ``` -Open the repo in the devcontainer (VS Code: *Reopen in Container*) and everything above is already -installed: Go 1.26, Node 22, Task, k3d, kubectl. - -## Provenance - -Extracted from [ConfigButler/gitops-api](https://github.com/ConfigButler/gitops-api), where the -pattern was proven live: a browser editing Kubernetes objects in a kcp workspace, as the signed-in -human, with every change landing in Git attributed to them. The engine's three-way merge is a -corrected descendant of that console's — the bugs it shipped are now regression tests here. - -## License - -Apache-2.0 +See [CONTRIBUTING.md](CONTRIBUTING.md) for the fixture and test workflow. Licensed under +[Apache-2.0](LICENSE). diff --git a/Taskfile.yml b/Taskfile.yml index 91b4a81..3b1793a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,15 +1,21 @@ version: "3" -# Everything in this repo is driven by `task` (same convention as gitops-api). +# Everything in this repository is driven by `task`. # `task` with no argument lists what there is. run: once vars: CLIENT_DIR: packages/krm-stream + REPLAY_ADDR: 127.0.0.1:8099 # A throwaway cluster for proving the gateway against a real API server. # (The unit suites need no cluster at all — see `task test`.) SPIKE_CLUSTER: krm-stream-spike + # Kubernetes 1.35+ is a REQUIREMENT of this library, not a preference: from 1.35, resourceVersion + # orderability is a Certified Kubernetes conformance requirement, and the gateway relies on it to + # promise consumers per-object monotonicity. So the cluster we verify against must be one. + K3S_IMAGE: rancher/k3s:v1.36.2-k3s1 + KUBECONFIG_PATH: '{{.HOME}}/.kube/config' tasks: default: @@ -20,15 +26,31 @@ tasks: # ---------------------------------------------------------------- fixtures -- fixtures: - desc: "Regenerate conformance/gen/*.json from the YAML sources (both suites read the JSON)." + desc: "Regenerate conformance/gen/ — the JSON both suites read, and the SSE both suites agree on." sources: - conformance/bodies/*.yaml - conformance/fixtures/*.yaml + - conformance/scopes.yaml - conformance/generate.sh + - gateway/*.go + # The generator's own command line lives in this file, so it is a source too: changing WHICH + # golden tests run, and then being told "up to date", is a trap I walked into writing this line. + - Taskfile.yml generates: - conformance/gen/*.json + - conformance/gen/sse/*.sse cmds: - bash conformance/generate.sh + # The SSE goldens: the bytes the gateway REALLY writes, through its real sink. They are what + # closes the seam — the TypeScript suite parses these exact files back and replays them into a + # store, so "the client can read what the gateway wrote" is a test and not a hope. + # + # They are generated by the gateway rather than by generate.sh precisely BECAUSE the gateway is + # the thing under test: a transcript hand-written next to the implementation would agree with it + # by construction and prove nothing. + # …and gen/protocol.json, written by the Go const that DEFINES the protocol version, so the + # TypeScript suite can assert it rather than keeping its own hand-copied number in step. + - cd gateway && go test -count=1 -run 'TestSSEGoldens|TestProtocolVersionIsPublished' -update ./... fixtures-check: desc: "Fail if conformance/gen/ is stale — the YAML is the source of truth, the JSON is built." @@ -39,12 +61,24 @@ tasks: # -------------------------------------------------------------------- test -- test: desc: "Run both suites against the shared conformance fixtures." - deps: [test-gateway, test-client] + deps: [test-gateway, test-kube, test-client] test-gateway: desc: "Go: the gateway + its half of the conformance suite." deps: [fixtures] dir: gateway + cmds: + # -race, and it stays: SharedBackend fans one upstream watch out to N subscribers, so the + # gateway now has a pump goroutine, a per-scope cache and N bounded queues. A data race in + # THAT is a browser being shown another tenant's object, not a flaky test. + - go test -race ./... + + # A SEPARATE MODULE, so `go test ./...` in gateway/ does not reach it — which means that until it + # was listed here, nothing tested it at all. The Kubernetes adapter is opt-in for adopters; it is + # not optional for us. + test-kube: + desc: "Go: the Kubernetes adapter (no cluster — see test-cluster for the real thing)." + dir: gateway/kube cmds: - go test ./... @@ -55,10 +89,74 @@ tasks: cmds: - node --test + # --------------------------------------------------------------------- e2e -- + replay: + desc: "Serve the conformance corpus over real SSE — a cluster you can point a browser at." + dir: gateway + cmds: + - go run ./cmd/replay --addr {{.REPLAY_ADDR}} {{.CLI_ARGS}} + + e2e-wire: + desc: "End to end, no cluster: the real Go gateway over a real socket, into the real TS store." + deps: [fixtures] + cmds: + # Two processes, two languages, one contract. Everything else in the suite feeds the store bytes + # that never left the process — which cannot prove that the server flushes, that the frames + # survive arbitrary chunk boundaries, or that a terminal error really closes the connection. + - | + set -e + log="$(mktemp -t krm-replay-XXXX.log)" + + # BUILD, then run the binary — do not `go run` it. `go run` execs the real program as a + # CHILD, so killing the pid we know about leaves the server holding the port, and the next + # run of this task fails with a confusing "address already in use". + # + # And redirect its output to a file rather than inheriting ours: a background process holding + # our stdout open makes `task` (and any pipe we are in) wait for it forever, which looks + # exactly like a hung test and is not one. + bin="$(mktemp -t krm-replay-XXXX)" + (cd gateway && go build -o "$bin" ./cmd/replay) + "$bin" --addr {{.REPLAY_ADDR}} --corpus conformance >"$log" 2>&1 & + pid=$! + trap 'kill $pid 2>/dev/null || true; rm -f "$bin"' EXIT + + for _ in $(seq 50); do + curl -sf http://{{.REPLAY_ADDR}}/healthz >/dev/null && break || sleep 0.2 + done + curl -sf http://{{.REPLAY_ADDR}}/healthz >/dev/null || { echo "replay server never came up:"; cat "$log"; exit 1; } + + cd {{.CLIENT_DIR}} && REPLAY_URL=http://{{.REPLAY_ADDR}} node e2e/wire.ts + + e2e-browser: + desc: "End to end in a REAL browser: native EventSource, unbundled ESM, no cluster." + # build-client, because playwright's webServer rebuilds the library from packages/krm-stream — + # and it cannot do that with no node_modules there. On a developer's machine that directory is + # already populated by `task test`, which is exactly why this gap survived until the first CI run + # on a clean checkout found it. + deps: [fixtures, build-client] + dir: examples/vanilla-browser + cmds: + # The only place the library's central promise is actually tested: that the published ESM + # imports in a browser with no bundler. Node importing it proves nothing — Node is not a browser. + # Playwright lives in the EXAMPLE's package, so the library keeps its three devDependencies. + - npm install --no-audit --no-fund + - npx --no-install playwright install chromium --with-deps + - npx --no-install playwright test {{.CLI_ARGS}} + + demo: + desc: "Serve the browser demo — watch a status reconcile while you edit spec. No cluster." + deps: [build-client] + cmds: + - | + echo "→ http://127.0.0.1:8100/?fixture=status-follow-live&pace=800ms" + echo " (also: conflict-and-converge, secret-redaction, edit-vs-unrelated-change — /fixtures lists them)" + cd gateway && go run ./cmd/replay --addr 127.0.0.1:8100 --corpus ../conformance \ + --static ../examples/vanilla-browser --dist ../packages/krm-stream/dist + # -------------------------------------------------------------------- lint -- lint: desc: "Vet + lint both languages." - deps: [lint-gateway, lint-client] + deps: [lint-gateway, lint-kube, lint-client] lint-gateway: dir: gateway @@ -66,10 +164,30 @@ tasks: - go vet ./... - golangci-lint run ./... + lint-kube: + dir: gateway/kube + cmds: + # --build-tags e2e, or the real-cluster suite is the one Go file in the repo nobody vets. + - go vet -tags e2e ./... + - golangci-lint run --build-tags e2e ./... + lint-client: dir: "{{.CLIENT_DIR}}" cmds: + # Two tsconfigs, on purpose. The first is the BUILD (src only — its rootDir is what ships); + # the second is the only thing that ever typechecks test/, because `node --test` STRIPS types + # rather than checking them. Without it the conformance suite — the file whose whole job is to + # be the contract check — would be the one piece of unverified TypeScript in the repo. - npx --no-install tsc --noEmit + - npx --no-install tsc --noEmit -p tsconfig.test.json + # biome = the TypeScript half of gofmt + go vet + golangci-lint. `task fmt-client` fixes. + - npx --no-install biome check + + fmt-client: + desc: "Format and auto-fix the TypeScript (the gofmt of this side)." + dir: "{{.CLIENT_DIR}}" + cmds: + - npx --no-install biome check --write # ------------------------------------------------------------------- build -- build-client: @@ -79,18 +197,81 @@ tasks: generates: ["dist/**/*.js"] cmds: - npm install --no-audit --no-fund - - npx tsc + # --no-install: bare `npx tsc` with no node_modules silently downloads a REGISTRY package named + # `tsc` (which is not TypeScript) and runs it. Never let a build reach the network by accident. + - npx --no-install tsc - # -------------------------------------------------------------------- spike -- - spike-up: - desc: "Throwaway k3d cluster for proving the gateway against a real API server." + # ---------------------------------------------------------------- cluster -- + # The one rung a fake watch cannot reach. Everything the gateway BELIEVES about Kubernetes — that a + # streaming list ends with an `initial-events-end` bookmark, that a 410 arrives as a watch error, + # that resourceVersions are orderable decimals — is unverified until something asks a real API + # server. See docs/proposals/0002-real-cluster.md. + cluster-up: + desc: "A real Kubernetes (k3d, {{.K3S_IMAGE}}) — with real etcd, because that is what we are verifying." status: - k3d cluster list {{.SPIKE_CLUSTER}} >/dev/null 2>&1 cmds: - - k3d cluster create {{.SPIKE_CLUSTER}} --wait + # --cluster-init gives k3s its EMBEDDED ETCD. Without it k3s stores in kine (SQLite), and then + # `resourceVersion` and compaction/410 semantics would be kine's rather than etcd's — which are + # exactly the two things this rung exists to check. Verifying them against a substitute storage + # layer would be worse than not verifying them at all. + - k3d cluster create {{.SPIKE_CLUSTER}} --image {{.K3S_IMAGE}} --k3s-arg "--cluster-init@server:0" --wait + - task: cluster-kubeconfig - kubectl --context k3d-{{.SPIKE_CLUSTER}} get --raw /healthz + - kubectl --context k3d-{{.SPIKE_CLUSTER}} version -o json | jq -r '.serverVersion.gitVersion' + + cluster-kubeconfig: + desc: "Point the kubeconfig at the cluster in a way that works from INSIDE the devcontainer." + cmds: + # Docker-outside-Docker. k3d publishes the API server on the HOST's docker daemon, and writes a + # kubeconfig saying `https://0.0.0.0:` — which is the host's address, and means nothing in + # here. From this container the host is `host.docker.internal`. + # + # But the API server's certificate has no SAN for that name (it has `localhost`, `127.0.0.1`, + # `0.0.0.0` and the k3d container IPs), so simply rewriting the host fails TLS verification. + # `--tls-server-name` is the honest fix: connect to the host, verify the name the cert actually + # carries. The alternative — turning off certificate verification — would be trading a real check + # for a convenient one, in a repo whose entire point is not doing that. + - | + set -e + port="$(docker port k3d-{{.SPIKE_CLUSTER}}-serverlb 6443/tcp | head -1 | sed 's/.*://')" + kubectl config set-cluster k3d-{{.SPIKE_CLUSTER}} \ + --server "https://host.docker.internal:${port}" --tls-server-name localhost >/dev/null + echo "kubeconfig → https://host.docker.internal:${port} (tls-server-name: localhost)" - spike-down: + cluster-down: desc: "Delete the throwaway cluster." cmds: - k3d cluster delete {{.SPIKE_CLUSTER}} + + cluster-aggregated-api: + desc: "Install Kubernetes' own sample-apiserver (wardle Flunders) as a REAL aggregated API." + deps: [cluster-up] + cmds: + # The one upstream whose behaviour Kubernetes' conformance requirements do NOT cover — so the only + # place OrderingLenient could be needed, and (as it turns out) the only place the gateway's + # streaming-list assumption actually breaks. See docs/facts/observed-*.md, F6. + - kubectl apply -f test/cluster/sample-apiserver/sample-apiserver.yaml + - kubectl -n wardle rollout status deploy/wardle-server --timeout=180s + - kubectl wait --for=condition=Available apiservice/v1alpha1.wardle.example.com --timeout=120s + + cluster-facts: + desc: "Ask a REAL API server the questions the docs do not answer. Writes docs/facts/observed-*.md." + deps: [cluster-up, cluster-aggregated-api] + dir: gateway/kube + cmds: + - go run ./cmd/facts --kubeconfig {{.KUBECONFIG_PATH}} --out ../../docs/facts + + # The backend, against the real thing. The unit suite stubs the API server — and a stub is a thing we + # wrote, so it agrees with us. This drives the REAL stream loop over the REAL backend against a REAL + # API server, on BOTH paths: the streaming list against kube-apiserver, and list-then-watch against + # the aggregated API that refuses it (F6). It does not run in per-PR CI: it needs Docker-in-Docker + # and it takes minutes, while the fixtures gate every PR in milliseconds. + test-cluster: + desc: "The kube backend against a real API server on both watch paths." + deps: [cluster-up, cluster-aggregated-api] + dir: gateway/kube + env: + KUBECONFIG: '{{.KUBECONFIG_PATH}}' + cmds: + - go test -tags e2e -v -count=1 -timeout 10m ./... diff --git a/conformance/README.md b/conformance/README.md index 1d3f911..bc36010 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -52,9 +52,9 @@ title: A server change to a key you are NOT editing must not disturb the key you why: R-THREEWAY — the base is the PREVIOUS server object, not the draft. suites: [gateway, client] scope: { target: demo, version: v1, resource: configmaps, namespace: app } -projection: krm-editor/v1 +projection: krm-full/v1 -watch: # ops: list | added | modified | deleted | relist | disconnect +watch: # see "The watch ops" below - { op: list, bodies: [cm-app.v1] } - { op: modified, body: cm-app.v2 } @@ -82,6 +82,29 @@ three-way merge, and a fixture format that couldn't express it would be useless. opaque string; making it legible costs nothing and makes a delete-and-recreate fixture (§`uid` changes, name does not) obvious at a glance. +## The watch ops + +`watch:` models conditions handled across the gateway pipeline — API-server watch behavior, browser +disconnects, and client-go cache tombstones. Where an operation maps to Kubernetes API behavior, the reference is +[docs/facts/kubernetes-api-concepts.md](../docs/facts/kubernetes-api-concepts.md), which is a reading +of the [API concepts page](https://kubernetes.io/docs/reference/using-api/api-concepts/) rather than a +reading of anyone's memory. That distinction has already cost us two bugs. + +| op | means | the gateway must | +|---|---|---| +| `list` | the objects currently in scope, then the bookmark that ends the initial events | open a cycle: `reset`, one `added` each, `synced` | +| `added` / `modified` | an upsert | forward it (subject to monotonicity) | +| `deleted` | the object left scope | emit `deleted` with its identity | +| `relist` | **upstream** continuity lost (410 Gone / cache reset) — the SSE connection is fine | announce `RESYNC_REQUIRED`, then a fresh cycle **on the same connection** | +| `disconnect` | the **browser's** connection dropped | nothing — the next connection is a new stream | +| `bookmark` | a routine `BOOKMARK`, which may arrive on a watch that requested bookmarks. Its object can include type metadata alongside `metadata.resourceVersion` | **absorb it.** Never forward it; never mistake it for `synced` | +| `partial` | a metadata-only object (`PartialObjectMetadata`) delivered as an upsert. **It has a uid**; it has no `spec` and no `status` | **refuse it** and resnapshot. Forwarding it blanks the consumer's object | +| `tombstone` | a `DELETED` whose object lost its identity (client-go's `DeletedFinalStateUnknown`) | **not guess.** Begin a new cycle and let `synced` prune | + +The final three operations cover gateway safety rules that ordinary list and watch traffic cannot +express. They ensure routine bookmarks, metadata-only objects, and ambiguous tombstones remain +covered by the shared corpus. + **Assertions.** `dirty` and `conflicts` are compared as exact sets (order-insensitive). `patch` is compared exactly — it is what gets sent to the API server, so "close enough" is not a thing. `draftSubset` is a deep-subset check: it lets a fixture assert the two fields it cares about without @@ -100,12 +123,36 @@ Every fixture names the rule it defends, in `why:`. The ones that catch real bug | `delete-recreate-uid` | identity is `uid`, never `name`; no state bleeds across a recreate | | `resync-midstream` | upstream continuity can be lost *without* the SSE connection dropping → a fresh cycle mid-stream | | `nested-field-removed` | `added`/`modified` **replace**; a deep-merge would resurrect a field the server deleted (a ghost) | -| `status-only-churn` | `status` is read-only: it follows the server live, and never becomes dirty, never conflicts, never enters a patch | +| `status-follow-live` | `status` is read-only under the full projection: it follows the server live, and never becomes dirty, never conflicts, never enters a patch | +| `status-only-churn` | a spec-only projection suppresses controller status churn entirely, without disturbing an in-flight spec edit | | `edit-vs-unrelated-change` | **R-THREEWAY** — the base is the previous *server* object | | `conflict-and-converge` | a conflict clears when the server's value arrives at what you typed | | `dotted-label-keys` | **R-ID** — `app.kubernetes.io/name` is ONE path segment. Dot-joining it is silently wrong | -| `array-atomic-on-change` | arrays merge atomically when lengths change (engine spec §4.1); a positional merge mis-aligns | +| `array-atomic-on-change` | unannotated arrays merge atomically; a positional merge mis-aligns | | `secret-redaction` | a redacted value is never displayed, never dirty, and can never be written back over the real one | +| `bookmark-absorbed` | a routine `BOOKMARK` is absorbed. Forward its object and you replace a live resource with a husk that has only a `resourceVersion` | +| `partial-object-refused` | a `PartialObjectMetadata` **has a uid** — so "has a uid" was never a sufficient test for "is a complete object", and ours was wrong | +| `tombstone-without-uid` | identity is never *reconstructed*. A guessed uid deletes the wrong object out of somebody's browser | +| `resourceversion-bignum` | a `resourceVersion` is an **arbitrary-bitsize** decimal (Kubernetes' own example is 40 digits). `strconv.ParseInt` overflows it, and the symptom is silently dropped live updates | +| `resourceversion-unorderable` | an **aggregated API** may serve a non-decimal `resourceVersion`. Strict ordering refuses it, loudly, naming the escape hatch — it never degrades in silence | + +### Why the resourceVersion fixtures use a `Flunder` + +Those two use **`wardle.example.com/v1alpha1 Flunder`** — Kubernetes' own +[sample-apiserver](https://github.com/kubernetes/sample-apiserver) — and not a ConfigMap, and the +reason is the whole point of grounding fixtures in real behaviour: + +- **A conformant cluster cannot produce either value on a built-in.** Since **1.35**, orderability is a + Certified Kubernetes requirement for base objects *and* custom resources. An unorderable + `resourceVersion` on a ConfigMap is a scenario that **cannot happen**, and a fixture defending + against it would be defending against nothing. +- **kube-apiserver's `resourceVersion` is an etcd revision** — an int64, 19 digits. You will never meet + a 40-digit one there either. A different backing store is where such a value actually comes from. +- **An aggregated / extension API server is the one server the docs still carve out**, because it is a + third-party implementation the conformance test does not cover. So it is the right home for both. + +A fixture that teaches a real rule with an impossible example is worse than no fixture: it makes the +reader trust a mental model that will mislead them the next time. ## Adding a fixture diff --git a/conformance/bodies/deploy-web.v1-spec.yaml b/conformance/bodies/deploy-web.v1-spec.yaml new file mode 100644 index 0000000..994e625 --- /dev/null +++ b/conformance/bodies/deploy-web.v1-spec.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + uid: deploy-web-0001 + name: web + namespace: app + resourceVersion: "3001" + generation: 4 + labels: + app.kubernetes.io/name: web +spec: + replicas: 2 + paused: false + selector: + matchLabels: + app: web + template: + metadata: + labels: + app: web + spec: + containers: + - name: web + image: ghcr.io/configbutler/web:v1.2.3 + ports: + - containerPort: 8080 diff --git a/conformance/bodies/deploy-web.v4.yaml b/conformance/bodies/deploy-web.v4.yaml index 6c2b96b..16db53e 100644 --- a/conformance/bodies/deploy-web.v4.yaml +++ b/conformance/bodies/deploy-web.v4.yaml @@ -1,11 +1,8 @@ # The server APPENDED a container (a sidecar was injected). The array's length changed. # -# This is the array problem, and it is why the engine treats an array atomically as soon as it is -# dirty or its length moves (engine spec §4.1): a positional merge would try to reconcile the user's -# edited element 0 against a shifted list and produce nonsense. Atomic is coarse but correct — and -# it agrees with RFC 7386 merge patch, which replaces arrays wholesale anyway. -# -# The keyed merge (x-kubernetes-list-map-keys: containers by `name`) is the known follow-up. +# Without an OpenAPI associative-list schema, arrays are intentionally atomic. A positional merge +# could apply a local edit to the wrong element after this server-side append. RFC 7386 also replaces +# arrays as a whole. apiVersion: apps/v1 kind: Deployment metadata: diff --git a/conformance/bodies/flunder.v1-bignum.yaml b/conformance/bodies/flunder.v1-bignum.yaml new file mode 100644 index 0000000..7f478c0 --- /dev/null +++ b/conformance/bodies/flunder.v1-bignum.yaml @@ -0,0 +1,26 @@ +# A Flunder: Kubernetes' OWN canonical aggregated-API example (k8s.io/sample-apiserver), and the +# reason this body is not a ConfigMap. +# +# `resourceVersion` here is 40 digits. That is the Kubernetes docs' own worked example — and it +# CANNOT have come from kube-apiserver, whose resourceVersion is an etcd revision and therefore fits +# in an int64 (19 digits). A value like this comes from a server with a different backing store: an +# aggregated API, exactly like this one. +# +# Which is the whole point. The docs say "resource versions are compared as ARBITRARY BITSIZE decimal +# integers... the bitsize must not be assumed to be some fixed amount", and a corpus that only ever +# said so with a ConfigMap would be teaching a lie about where you actually meet such a value. +# +# It is still perfectly ORDERABLE — it is a decimal — so strict ordering handles it, and must. +apiVersion: wardle.example.com/v1alpha1 +kind: Flunder +metadata: + uid: flunder-0001 + name: fl-1 + namespace: app + resourceVersion: "2345678901234567890123456789012345678901" + labels: + app.kubernetes.io/name: shop +spec: + referenceType: Flunder + reference: some-flunder +status: {} diff --git a/conformance/bodies/flunder.v2-bignum-newer.yaml b/conformance/bodies/flunder.v2-bignum-newer.yaml new file mode 100644 index 0000000..cd7aad5 --- /dev/null +++ b/conformance/bodies/flunder.v2-bignum-newer.yaml @@ -0,0 +1,19 @@ +# One digit longer than v1-bignum, and therefore GREATER: "if they are not of equal length, the longer +# one is greater" — the comparison Kubernetes prescribes. +# +# A plain lexicographic compare (the obvious "fix" once you learn int64 overflows) would call this +# OLDER than the 40-digit one, drop it as a stale replay, and the user would simply never see the +# change. That is the second bug hiding behind the first. +apiVersion: wardle.example.com/v1alpha1 +kind: Flunder +metadata: + uid: flunder-0001 + name: fl-1 + namespace: app + resourceVersion: "12345678901234567890123456789012345678901" + labels: + app.kubernetes.io/name: shop +spec: + referenceType: Flunder + reference: another-flunder +status: {} diff --git a/conformance/bodies/flunder.v3-opaque.yaml b/conformance/bodies/flunder.v3-opaque.yaml new file mode 100644 index 0000000..b5c9f2e --- /dev/null +++ b/conformance/bodies/flunder.v3-opaque.yaml @@ -0,0 +1,27 @@ +# A resourceVersion that is not a decimal at all, on the one kind of server that may legitimately +# serve one: an AGGREGATED API. +# +# Since Kubernetes 1.35, orderability is a Certified Kubernetes conformance requirement — for built-in +# types AND custom resources. So a ConfigMap, a Deployment or a CRD simply CANNOT deliver this, and a +# fixture that used one would be defending against a case a conformant cluster cannot produce. +# +# An aggregated/extension API server is a third-party implementation that the conformance test does +# not cover, and it is exactly the case the docs still carve out: "if either of two resource version +# strings does not parse as a decimal number, the two strings can be checked for equality but you +# CANNOT rely on comparisons for ordering." +# +# So this is the honest home of Gateway.Ordering: strict refuses it (loudly, naming the escape hatch), +# lenient streams it and simply stops ordering. +apiVersion: wardle.example.com/v1alpha1 +kind: Flunder +metadata: + uid: flunder-0001 + name: fl-1 + namespace: app + resourceVersion: "opaque-alpha" + labels: + app.kubernetes.io/name: shop +spec: + referenceType: Flunder + reference: some-flunder +status: {} diff --git a/conformance/bodies/secret-token.v1-wire.yaml b/conformance/bodies/secret-token.v1-wire.yaml new file mode 100644 index 0000000..3aeed7b --- /dev/null +++ b/conformance/bodies/secret-token.v1-wire.yaml @@ -0,0 +1,13 @@ +# The same Secret under `krm-full/v1`: values are omitted from the object. The accompanying event's +# `redacted` entries declare which paths exist upstream, allowing the client to render them as +# read-only without introducing a placeholder value. +apiVersion: v1 +kind: Secret +metadata: + uid: secret-token-0001 + name: git-creds + namespace: app + resourceVersion: "4001" + labels: + app.kubernetes.io/name: shop +type: Opaque diff --git a/conformance/bodies/secret-token.v1.yaml b/conformance/bodies/secret-token.v1.yaml index e7f1488..69f7861 100644 --- a/conformance/bodies/secret-token.v1.yaml +++ b/conformance/bodies/secret-token.v1.yaml @@ -1,9 +1,11 @@ -# A Secret under the `krm-editor/v1` projection: the KEYS are disclosed, the VALUES are not. +# A real Secret, as a real API server serves it: base64 values, and they are SECRET. # -# `redactedPaths` is on the wire (RFC 6901 JSON Pointers) so the consumer never has to GUESS whether -# a value is real. The placeholder is displayed as a mask, is never dirtiable, and — the rule that -# makes this safe — can never be written back over the real value: the gateway rejects any patch -# touching a redacted path. +# This is the gateway's INPUT — what arrives on the watch. What goes on the WIRE is +# secret-token.v1-wire.yaml, and the difference between the two files IS the projection. +# +# They used to be the same file, which only worked because masking an already-masked value produces +# the same masked value. That accident hid the fact that the corpus never actually watched a Secret +# with a real value in it. Now it does, and `krm-full/v1` has to genuinely remove something. apiVersion: v1 kind: Secret metadata: @@ -15,5 +17,5 @@ metadata: app.kubernetes.io/name: shop type: Opaque data: - token: "**REDACTED**" - username: "**REDACTED**" + token: aHVudGVyMg== # "hunter2" — and it must never reach a browser, in any form + username: Ym9i # "bob" diff --git a/conformance/fixtures/array-atomic-on-change.yaml b/conformance/fixtures/array-atomic-on-change.yaml index 6fdbc60..17c2da9 100644 --- a/conformance/fixtures/array-atomic-on-change.yaml +++ b/conformance/fixtures/array-atomic-on-change.yaml @@ -1,7 +1,7 @@ id: array-atomic-on-change title: When an array's length changes under an edit, the whole array conflicts — atomically. why: > - Engine spec §4.1, the known-coarse-but-correct rule. The user edited containers[0].image; the + Default array behavior. The user edited containers[0].image; the server injected a sidecar, so containers now has 2 elements. A positional merge would reconcile ours[0] against theirs[0] and ours[1] (absent) against theirs[1] — and with a PREPENDED element it would silently merge the user's edit into the wrong container. @@ -11,8 +11,7 @@ why: > conflicts with an unrelated append — but never wrong. It also agrees with RFC 7386, which replaces arrays wholesale anyway, so the patch format needs no special case. - The right long-term answer is a keyed merge (x-kubernetes-list-map-keys: containers by `name`), - driven by the OpenAPI schema. That is the project's main open design question, not a TODO. + Hosts that supply an OpenAPI associative-list schema can opt into keyed-list merging instead. suites: [client] events: diff --git a/conformance/fixtures/bookmark-absorbed.yaml b/conformance/fixtures/bookmark-absorbed.yaml new file mode 100644 index 0000000..728af50 --- /dev/null +++ b/conformance/fixtures/bookmark-absorbed.yaml @@ -0,0 +1,38 @@ +id: bookmark-absorbed +title: A routine watch BOOKMARK is absorbed — never forwarded, never mistaken for `synced`. +why: > + Kubernetes, verbatim: "The document representing the BOOKMARK event is of the type requested by the + request, but only includes a .metadata.resourceVersion field." So an object with no uid, no name, no + spec and no status is not an exotic case someone contrived — it is on EVERY conforming watch stream + that asked for bookmarks, and the gateway must ask (allowWatchBookmarks=true is how the snapshot + boundary arrives at all). + + Two ways to get this wrong, and this fixture fails on both: + + - forward the bookmark's object as `modified`. The consumer REPLACES on modified — that is the + protocol's single most important rule — so it would replace a live ConfigMap with a husk that + has only a resourceVersion. The screen goes blank. + - treat every bookmark as the snapshot boundary and emit `synced`. Pruning is gated on `synced`, + so a mid-cycle `synced` prunes objects the snapshot had not reached yet: half the user's + resources vanish, and reappear on the next relist. + + Only the bookmark that TERMINATES the initial events is `synced` (spec §5). Every other + one is absorbed for its resourceVersion and never spoken of again (spec §2). + + See docs/facts/kubernetes-api-concepts.md §1.1 — this rule is a quote from the API docs, not an + opinion about them. +suites: [gateway] +scope: { target: demo, version: v1, resource: configmaps, namespace: app } +projection: krm-full/v1 + +watch: + - { op: list, bodies: [cm-app.v1] } + - { op: bookmark, resourceVersion: "1500" } # routine: absorbed, produces NOTHING + - { op: modified, body: cm-app.v2 } + - { op: bookmark, resourceVersion: "1600" } # ...and again + +events: + - { type: reset } + - { type: added, body: cm-app.v1 } + - { type: synced } + - { type: modified, body: cm-app.v2 } diff --git a/conformance/fixtures/conflict-and-converge.yaml b/conformance/fixtures/conflict-and-converge.yaml index d0c165e..eba321d 100644 --- a/conformance/fixtures/conflict-and-converge.yaml +++ b/conformance/fixtures/conflict-and-converge.yaml @@ -13,7 +13,7 @@ why: > Dirtiness is DERIVED — deepEqual(draft@path, base@path) — never remembered. suites: [gateway, client] scope: { target: demo, version: v1, resource: configmaps, namespace: app } -projection: krm-editor/v1 +projection: krm-full/v1 watch: - { op: list, bodies: [cm-app.v1] } diff --git a/conformance/fixtures/delete-recreate-uid.yaml b/conformance/fixtures/delete-recreate-uid.yaml index 1025072..2f4d608 100644 --- a/conformance/fixtures/delete-recreate-uid.yaml +++ b/conformance/fixtures/delete-recreate-uid.yaml @@ -7,7 +7,7 @@ why: > they were editing no longer exists. suites: [gateway, client] scope: { target: demo, version: v1, resource: configmaps, namespace: app } -projection: krm-editor/v1 +projection: krm-full/v1 watch: - { op: list, bodies: [cm-app.v1] } diff --git a/conformance/fixtures/edit-vs-unrelated-change.yaml b/conformance/fixtures/edit-vs-unrelated-change.yaml index c2adbba..3baa021 100644 --- a/conformance/fixtures/edit-vs-unrelated-change.yaml +++ b/conformance/fixtures/edit-vs-unrelated-change.yaml @@ -10,7 +10,7 @@ why: > and the draft is left ALONE — no follow, no flash, no conflict. suites: [gateway, client] scope: { target: demo, version: v1, resource: configmaps, namespace: app } -projection: krm-editor/v1 +projection: krm-full/v1 watch: - { op: list, bodies: [cm-app.v1] } diff --git a/conformance/fixtures/key-removed-upstream.yaml b/conformance/fixtures/key-removed-upstream.yaml index 6731389..50d1b81 100644 --- a/conformance/fixtures/key-removed-upstream.yaml +++ b/conformance/fixtures/key-removed-upstream.yaml @@ -15,7 +15,7 @@ why: > into — and then a save resurrects them in the cluster. suites: [gateway, client] scope: { target: demo, version: v1, resource: configmaps, namespace: app } -projection: krm-editor/v1 +projection: krm-full/v1 watch: - { op: list, bodies: [cm-app.v1] } diff --git a/conformance/fixtures/named-object-absent.yaml b/conformance/fixtures/named-object-absent.yaml index bbf4288..65f61f7 100644 --- a/conformance/fixtures/named-object-absent.yaml +++ b/conformance/fixtures/named-object-absent.yaml @@ -7,7 +7,7 @@ why: > consumer state machine, both scope types. suites: [gateway, client] scope: { target: demo, version: v1, resource: configmaps, namespace: app, name: app-config } -projection: krm-editor/v1 +projection: krm-full/v1 watch: - { op: list, bodies: [] } diff --git a/conformance/fixtures/nested-field-removed.yaml b/conformance/fixtures/nested-field-removed.yaml index a8eb60a..5b8d59a 100644 --- a/conformance/fixtures/nested-field-removed.yaml +++ b/conformance/fixtures/nested-field-removed.yaml @@ -13,7 +13,7 @@ why: > layered ABOVE that — never in place of it. suites: [gateway, client] scope: { target: demo, group: apps, version: v1, resource: deployments, namespace: app } -projection: krm-editor/v1 +projection: krm-full/v1 watch: - { op: list, bodies: [deploy-web.v1] } diff --git a/conformance/fixtures/partial-object-refused.yaml b/conformance/fixtures/partial-object-refused.yaml new file mode 100644 index 0000000..ec3232b --- /dev/null +++ b/conformance/fixtures/partial-object-refused.yaml @@ -0,0 +1,35 @@ +id: partial-object-refused +title: A metadata-only object is never forwarded as an upsert — the gateway resnapshots instead. +why: > + Kubernetes serves PartialObjectMetadata on request (Accept: application/json;as=PartialObjectMetadata): + "the returned objects only contain the `metadata` field. The `spec` and `status` fields are omitted." + + This fixture exists because it caught a real mistake in our own gateway. The guard was "an object + with no uid is partial" — but a PartialObjectMetadata HAS a uid. It has a whole metadata block. What + it does not have is a spec or a status. So the guard looked at the wrong field, the object sailed + through, and the consumer — whose model is REPLACE, never merge — swapped a live Deployment for a + husk: the status view goes blank and the editor silently loses the user's spec. + + The honest check is the KIND (PartialObjectMetadata / PartialObjectMetadataList, group meta.k8s.io), + not the presence of a uid. A partial object delivered as an update would blank a consumer's state — + spec §2 says so, and it is right; we were simply not enforcing what we had written down. + + The recovery is a new snapshot cycle: it is the one response that is ALWAYS correct, because it + re-establishes the truth rather than guessing at it. +suites: [gateway] +scope: { target: demo, group: apps, version: v1, resource: deployments, namespace: app } +projection: krm-full/v1 + +watch: + - { op: list, bodies: [deploy-web.v1] } + - { op: partial, body: deploy-web.v1 } # metadata only: uid present, spec and status GONE + - { op: list, bodies: [deploy-web.v2] } # the resnapshot the gateway must perform + +events: + - { type: reset } + - { type: added, body: deploy-web.v1 } + - { type: synced } + - { type: error, code: RESYNC_REQUIRED, terminal: false } + - { type: reset } + - { type: added, body: deploy-web.v2 } + - { type: synced } diff --git a/conformance/fixtures/reconnect-prune.yaml b/conformance/fixtures/reconnect-prune.yaml index e50c8e4..3539aa7 100644 --- a/conformance/fixtures/reconnect-prune.yaml +++ b/conformance/fixtures/reconnect-prune.yaml @@ -6,7 +6,7 @@ why: > on each added, prune what is still unseen on synced. suites: [gateway, client] scope: { target: demo, version: v1, resource: configmaps, namespace: app } -projection: krm-editor/v1 +projection: krm-full/v1 watch: - { op: list, bodies: [cm-app.v1, cm-flags.v1] } diff --git a/conformance/fixtures/resourceversion-bignum.yaml b/conformance/fixtures/resourceversion-bignum.yaml new file mode 100644 index 0000000..55855f4 --- /dev/null +++ b/conformance/fixtures/resourceversion-bignum.yaml @@ -0,0 +1,43 @@ +id: resourceversion-bignum +title: A 40-digit resourceVersion orders correctly — and a stale replay is still dropped. +why: > + Kubernetes: "Resource versions are compared as arbitrary bitsize decimal integers... The bitsize + must not be assumed to be some fixed amount." Its own worked example is 40 digits long. + + strconv.ParseInt tops out at 19. The gateway used to compare resource versions with it, so against a + server like this one the parse simply failed — and the per-object monotonicity check (spec §6) gave + up. The user-visible symptom is silently dropped live updates, which in a status view is + indistinguishable from "Kubernetes is being slow", and is therefore the kind of bug that survives for + years. + + Note the KIND. This is a Flunder — Kubernetes' own sample aggregated API — and not a ConfigMap, on + purpose: kube-apiserver's resourceVersion is an etcd revision, so it fits in an int64 and you will + never meet a 40-digit one there. A server with a different backing store is where such a value + actually comes from, and a fixture that pretended otherwise would be teaching the rule with an + example that cannot happen. + + The comparison Kubernetes prescribes: "If they are not of equal length, the longer one is greater... + If they are of equal length, the lexicographically greater one is greater." That rules out a plain + lexicographic compare, which would call the 41-digit version OLDER than the 40-digit one. + + So, both halves at once: + - the 41-digit v2 is NEWER than the 40-digit v1 and must be delivered; + - replaying v1 afterwards is STALE and must be dropped (§6: never emit, within a cycle, a state + older than one already emitted for that uid). + + These are decimals, so they ARE orderable: the default (OrderingStrict) handles them, and must. +suites: [gateway] +scope: { target: demo, group: wardle.example.com, version: v1alpha1, resource: flunders, namespace: app } +projection: krm-full/v1 + +watch: + - { op: list, bodies: [flunder.v1-bignum] } + - { op: modified, body: flunder.v2-bignum-newer } # 41 digits: LONGER, therefore newer + - { op: modified, body: flunder.v1-bignum } # a replay of the older state: must be DROPPED + +events: + - { type: reset } + - { type: added, body: flunder.v1-bignum } + - { type: synced } + - { type: modified, body: flunder.v2-bignum-newer } + # ...and nothing more: the stale replay never reaches the wire. diff --git a/conformance/fixtures/resourceversion-unorderable.yaml b/conformance/fixtures/resourceversion-unorderable.yaml new file mode 100644 index 0000000..6cdf44f --- /dev/null +++ b/conformance/fixtures/resourceversion-unorderable.yaml @@ -0,0 +1,37 @@ +id: resourceversion-unorderable +title: An aggregated API's unorderable resourceVersion is refused, loudly — not tolerated in silence. +why: > + Kubernetes 1.35 made orderability a CONFORMANCE requirement, for built-in types and custom resources + alike. This library REQUIRES 1.35+ and relies on it: a ConfigMap, a Deployment or a CRD cannot serve + an unorderable resourceVersion, so a fixture using one would be defending against a case a conformant + cluster cannot produce. + + The one server that still may is an AGGREGATED / extension API server — a third-party implementation + the conformance test does not cover. Hence a Flunder (wardle.example.com), Kubernetes' own sample + aggregated API. The docs' carve-out is written for exactly this: "if either of two resource version + strings does not parse as a decimal number, the two strings can be checked for equality but you + cannot rely on comparisons for ordering." + + The protocol promises consumers per-object monotonicity (§6) — within a cycle you are never handed a + state older than one you already hold — and the gateway keeps that promise BY ordering resource + versions. If it cannot order them, it cannot keep it. A gateway that quietly carries on anyway leaves + a consumer believing a guarantee it is no longer getting, which is strictly worse than one that has + been told. + + So the default (Gateway.Ordering = OrderingStrict) refuses: a terminal INTERNAL error that NAMES the + escape hatch, so the operator of an aggregated API is never merely stuck. OrderingLenient streams it + anyway and simply stops ordering — dropping nothing it cannot compare, because a duplicate is + harmless (apply is idempotent) while a wrongly-dropped update is data loss. + + Note WHERE the refusal lands: after `reset`, because the gateway only learns what this upstream's + resource versions look like when the first object arrives. It cannot know from the scope alone. +suites: [gateway] +scope: { target: demo, group: wardle.example.com, version: v1alpha1, resource: flunders, namespace: app } +projection: krm-full/v1 + +watch: + - { op: list, bodies: [flunder.v3-opaque] } + +events: + - { type: reset } + - { type: error, code: INTERNAL, terminal: true } diff --git a/conformance/fixtures/resync-midstream.yaml b/conformance/fixtures/resync-midstream.yaml index 1a91e7a..b783424 100644 --- a/conformance/fixtures/resync-midstream.yaml +++ b/conformance/fixtures/resync-midstream.yaml @@ -8,7 +8,7 @@ why: > It SHOULD announce the break first with a non-terminal RESYNC_REQUIRED. suites: [gateway, client] scope: { target: demo, version: v1, resource: configmaps, namespace: app } -projection: krm-editor/v1 +projection: krm-full/v1 watch: - { op: list, bodies: [cm-app.v1, cm-flags.v1] } diff --git a/conformance/fixtures/secret-redaction.yaml b/conformance/fixtures/secret-redaction.yaml index ee41794..014bd34 100644 --- a/conformance/fixtures/secret-redaction.yaml +++ b/conformance/fixtures/secret-redaction.yaml @@ -1,30 +1,26 @@ id: secret-redaction -title: A redacted value is shown as a mask, is never dirtiable, and can never be written back. +title: A redacted value is not on the wire at all — the key is disclosed, the value is withheld. why: > - The rule that makes a Secret safe to display at all: a value you never SAW cannot round-trip over - the real one. Three obligations, and all three must hold or the feature is a vulnerability: - - 1. the gateway declares WHICH paths are redacted (RFC 6901 pointers), on the wire, per object — - so a consumer never has to infer redaction from a value that merely LOOKS like a placeholder; - 2. the client marks those paths read-only: no draft, no dirty, no conflict, never in a patch; - 3. the gateway REJECTS any patch that touches a redacted path — belt and braces, because the - client is not the security boundary. A hostile client is just a client. - - Editing an UNREDACTED field of the same object must still work — that is the whole point of - keys-only disclosure. You can see that `token` exists, you can rename the object's labels, and you - can never learn or clobber the token itself. + Keys-only disclosure. The gateway omits Secret values and declares their RFC 6901 paths in + `redacted`; the client keeps those paths read-only and never includes them in a patch. The host save + handler independently validates a patch against the active projection. Unredacted fields of the + same object remain editable. suites: [gateway, client] scope: { target: demo, version: v1, resource: secrets, namespace: app } -projection: krm-editor/v1 +projection: krm-full/v1 watch: + # What the API server sends: a REAL Secret, with real base64 values in it. - { op: list, bodies: [secret-token.v1] } events: - type: reset - type: added - body: secret-token.v1 - redactedPaths: ["/data/token", "/data/username"] + # What the browser gets: the same object with `data` EMPTIED. The difference between these two + # files is the projection, and it is now a real difference rather than a mask being re-masked. + body: secret-token.v1-wire + # The keys live here, and nowhere else. This is what a UI renders `token ••••••` from. + redacted: [{ path: "/data/token", rev: 1 }, { path: "/data/username", rev: 1 }] - type: synced client: @@ -34,16 +30,9 @@ client: dirty: [[metadata, labels, "app.kubernetes.io/name"]] # the unredacted edit works normally conflicts: [] readOnlyPaths: - - [data, token] # masked -> read-only -> not dirtiable + - [data, token] # named only by redacted -> read-only -> not dirtiable - [data, username] - patch: # the patch touches NOTHING under data/ + patch: # the patch touches NOTHING under data/ — there is nothing there to touch metadata: labels: app.kubernetes.io/name: checkout - -# The gateway's half: a patch that DOES touch a redacted path must be refused before it reaches the -# API server, whatever the client believes. -gatewayRejects: - - patch: { data: { token: "hunter2" } } - code: FORBIDDEN - because: "/data/token is redacted under krm-editor/v1" diff --git a/conformance/fixtures/snapshot-then-deltas.yaml b/conformance/fixtures/snapshot-then-deltas.yaml index abd4ded..5b3d491 100644 --- a/conformance/fixtures/snapshot-then-deltas.yaml +++ b/conformance/fixtures/snapshot-then-deltas.yaml @@ -6,7 +6,7 @@ why: > resourceVersion, so no event between the two is lost. suites: [gateway, client] scope: { target: demo, version: v1, resource: configmaps, namespace: app } -projection: krm-editor/v1 +projection: krm-full/v1 watch: - { op: list, bodies: [cm-app.v1, cm-flags.v1] } diff --git a/conformance/fixtures/status-follow-live.yaml b/conformance/fixtures/status-follow-live.yaml new file mode 100644 index 0000000..bced809 --- /dev/null +++ b/conformance/fixtures/status-follow-live.yaml @@ -0,0 +1,32 @@ +id: status-follow-live +title: Status follows the server live while an edit survives. +why: > + `krm-full/v1` keeps `status`, read-only and live. A controller-only update must flash and follow + the server while an in-flight edit to `spec.replicas` survives untouched and remains the only + patchable change. + + This is the browser demo: watch Kubernetes reconcile while you type. +suites: [gateway, client] +scope: { target: demo, group: apps, version: v1, resource: deployments, namespace: app } +projection: krm-full/v1 + +watch: + - { op: list, bodies: [deploy-web.v1] } + - { op: modified, body: deploy-web.v2 } # status only: readyReplicas 1 -> 2, Available False -> True + +events: + - { type: reset } + - { type: added, body: deploy-web.v1 } + - { type: synced } + - { type: modified, body: deploy-web.v2 } + +client: + edits: + - { after: 2, op: set, uid: deploy-web-0001, path: [spec, replicas], value: 4 } + expect: + dirty: [[spec, replicas]] + conflicts: [] + draftSubset: + spec: { replicas: 4 } + status: { readyReplicas: 2 } + patch: { spec: { replicas: 4 } } diff --git a/conformance/fixtures/status-only-churn.yaml b/conformance/fixtures/status-only-churn.yaml index 8081ea5..f63f791 100644 --- a/conformance/fixtures/status-only-churn.yaml +++ b/conformance/fixtures/status-only-churn.yaml @@ -1,15 +1,13 @@ id: status-only-churn -title: status follows the server live, and is never dirty, never conflicted, never in a patch. +title: A status-only update produces no event when the selected projection ignores status. why: > - This is the product thesis in one fixture. `status` is controller-owned: it must update live (that - is the whole live-status-watch use case — read-only is NOT ignored, it flashes and re-renders), - and it must be structurally incapable of becoming an edit. Meanwhile the user is editing - `spec.replicas`, and a status heartbeat must not touch it. + `krm-spec/v1` ignores `status`, so a controller-only update must emit no event at all. Meanwhile the + user is editing `spec.replicas`; the suppressed status heartbeat must not touch that draft. On a real workload this — not editing — is the dominant traffic on the stream. suites: [gateway, client] scope: { target: demo, group: apps, version: v1, resource: deployments, namespace: app } -projection: krm-editor/v1 +projection: krm-spec/v1 watch: - { op: list, bodies: [deploy-web.v1] } @@ -17,9 +15,8 @@ watch: events: - { type: reset } - - { type: added, body: deploy-web.v1 } + - { type: added, body: deploy-web.v1-spec } - { type: synced } - - { type: modified, body: deploy-web.v2 } client: edits: @@ -28,9 +25,5 @@ client: dirty: [[spec, replicas]] # the ONLY dirty path — status can never be one conflicts: [] draftSubset: - spec: { replicas: 4 } # the edit survived the status churn - status: # ...and status followed the server, live - readyReplicas: 2 - observedGeneration: 4 + spec: { replicas: 4 } # the edit survived suppressed status churn patch: { spec: { replicas: 4 } } # status is NOT in the patch. It cannot be. - flashed: [[status, readyReplicas], [status, conditions]] diff --git a/conformance/fixtures/tombstone-without-uid.yaml b/conformance/fixtures/tombstone-without-uid.yaml new file mode 100644 index 0000000..acecd30 --- /dev/null +++ b/conformance/fixtures/tombstone-without-uid.yaml @@ -0,0 +1,32 @@ +id: tombstone-without-uid +title: An ambiguous tombstone is never emitted as `deleted` — a new cycle prunes instead. +why: > + spec §4.2, and it is stated in strong terms for a reason: if the gateway cannot recover a trustworthy + uid it MUST NOT emit an ambiguous `deleted`. It begins a new snapshot cycle and lets reset…synced do + the pruning. + + The source of a degenerate tombstone is client-go's cache.DeletedFinalStateUnknown: the informer + missed the delete and noticed during a relist, so it hands you a wrapper whose object may be nothing + more than a key. A gateway that "helpfully" reconstructs a uid from the name deletes THE WRONG + OBJECT out of somebody's browser — and a delete-and-recreate under the same name (see + delete-recreate-uid) is exactly the case where the name is a lie. + + Paying for a relist is the cheap option here. Guessing is not. +suites: [gateway] +scope: { target: demo, version: v1, resource: configmaps, namespace: app } +projection: krm-full/v1 + +watch: + - { op: list, bodies: [cm-app.v1, cm-flags.v1] } + - { op: tombstone, body: cm-flags.v1 } # DELETED, but the object lost its identity + - { op: list, bodies: [cm-app.v1] } # the resnapshot — and cm-flags is genuinely gone + +events: + - { type: reset } + - { type: added, body: cm-app.v1 } + - { type: added, body: cm-flags.v1 } + - { type: synced } + - { type: error, code: RESYNC_REQUIRED, terminal: false } + - { type: reset } + - { type: added, body: cm-app.v1 } + - { type: synced } # cm-flags-0001 is pruned HERE, correctly, and by identity diff --git a/conformance/gen/bodies.json b/conformance/gen/bodies.json index bef0174..22897c6 100644 --- a/conformance/gen/bodies.json +++ b/conformance/gen/bodies.json @@ -124,6 +124,49 @@ "checkout-v2": "false" } }, + "deploy-web.v1-spec": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "uid": "deploy-web-0001", + "name": "web", + "namespace": "app", + "resourceVersion": "3001", + "generation": 4, + "labels": { + "app.kubernetes.io/name": "web" + } + }, + "spec": { + "replicas": 2, + "paused": false, + "selector": { + "matchLabels": { + "app": "web" + } + }, + "template": { + "metadata": { + "labels": { + "app": "web" + } + }, + "spec": { + "containers": [ + { + "name": "web", + "image": "ghcr.io/configbutler/web:v1.2.3", + "ports": [ + { + "containerPort": 8080 + } + ] + } + ] + } + } + } + }, "deploy-web.v1": { "apiVersion": "apps/v1", "kind": "Deployment", @@ -347,6 +390,74 @@ ] } }, + "flunder.v1-bignum": { + "apiVersion": "wardle.example.com/v1alpha1", + "kind": "Flunder", + "metadata": { + "uid": "flunder-0001", + "name": "fl-1", + "namespace": "app", + "resourceVersion": "2345678901234567890123456789012345678901", + "labels": { + "app.kubernetes.io/name": "shop" + } + }, + "spec": { + "referenceType": "Flunder", + "reference": "some-flunder" + }, + "status": {} + }, + "flunder.v2-bignum-newer": { + "apiVersion": "wardle.example.com/v1alpha1", + "kind": "Flunder", + "metadata": { + "uid": "flunder-0001", + "name": "fl-1", + "namespace": "app", + "resourceVersion": "12345678901234567890123456789012345678901", + "labels": { + "app.kubernetes.io/name": "shop" + } + }, + "spec": { + "referenceType": "Flunder", + "reference": "another-flunder" + }, + "status": {} + }, + "flunder.v3-opaque": { + "apiVersion": "wardle.example.com/v1alpha1", + "kind": "Flunder", + "metadata": { + "uid": "flunder-0001", + "name": "fl-1", + "namespace": "app", + "resourceVersion": "opaque-alpha", + "labels": { + "app.kubernetes.io/name": "shop" + } + }, + "spec": { + "referenceType": "Flunder", + "reference": "some-flunder" + }, + "status": {} + }, + "secret-token.v1-wire": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "uid": "secret-token-0001", + "name": "git-creds", + "namespace": "app", + "resourceVersion": "4001", + "labels": { + "app.kubernetes.io/name": "shop" + } + }, + "type": "Opaque" + }, "secret-token.v1": { "apiVersion": "v1", "kind": "Secret", @@ -361,8 +472,8 @@ }, "type": "Opaque", "data": { - "token": "**REDACTED**", - "username": "**REDACTED**" + "token": "aHVudGVyMg==", + "username": "Ym9i" } } } diff --git a/conformance/gen/fixtures.json b/conformance/gen/fixtures.json index 84a6b0a..a504893 100644 --- a/conformance/gen/fixtures.json +++ b/conformance/gen/fixtures.json @@ -2,7 +2,7 @@ { "id": "array-atomic-on-change", "title": "When an array's length changes under an edit, the whole array conflicts — atomically.", - "why": "Engine spec §4.1, the known-coarse-but-correct rule. The user edited containers[0].image; the server injected a sidecar, so containers now has 2 elements. A positional merge would reconcile ours[0] against theirs[0] and ours[1] (absent) against theirs[1] — and with a PREPENDED element it would silently merge the user's edit into the wrong container.\nSo: any array that is dirty or whose length moved is treated as ONE value. The user's array wins until they resolve it, and the whole array is the conflict. Coarse — their one-field edit now conflicts with an unrelated append — but never wrong. It also agrees with RFC 7386, which replaces arrays wholesale anyway, so the patch format needs no special case.\nThe right long-term answer is a keyed merge (x-kubernetes-list-map-keys: containers by `name`), driven by the OpenAPI schema. That is the project's main open design question, not a TODO.\n", + "why": "Default array behavior. The user edited containers[0].image; the server injected a sidecar, so containers now has 2 elements. A positional merge would reconcile ours[0] against theirs[0] and ours[1] (absent) against theirs[1] — and with a PREPENDED element it would silently merge the user's edit into the wrong container.\nSo: any array that is dirty or whose length moved is treated as ONE value. The user's array wins until they resolve it, and the whole array is the conflict. Coarse — their one-field edit now conflicts with an unrelated append — but never wrong. It also agrees with RFC 7386, which replaces arrays wholesale anyway, so the patch format needs no special case.\nHosts that supply an OpenAPI associative-list schema can opt into keyed-list merging instead.\n", "suites": [ "client" ], @@ -80,6 +80,57 @@ } } }, + { + "id": "bookmark-absorbed", + "title": "A routine watch BOOKMARK is absorbed — never forwarded, never mistaken for `synced`.", + "why": "Kubernetes, verbatim: \"The document representing the BOOKMARK event is of the type requested by the request, but only includes a .metadata.resourceVersion field.\" So an object with no uid, no name, no spec and no status is not an exotic case someone contrived — it is on EVERY conforming watch stream that asked for bookmarks, and the gateway must ask (allowWatchBookmarks=true is how the snapshot boundary arrives at all).\nTwo ways to get this wrong, and this fixture fails on both:\n\n - forward the bookmark's object as `modified`. The consumer REPLACES on modified — that is the\n protocol's single most important rule — so it would replace a live ConfigMap with a husk that\n has only a resourceVersion. The screen goes blank.\n - treat every bookmark as the snapshot boundary and emit `synced`. Pruning is gated on `synced`,\n so a mid-cycle `synced` prunes objects the snapshot had not reached yet: half the user's\n resources vanish, and reappear on the next relist.\n\nOnly the bookmark that TERMINATES the initial events is `synced` (spec §5). Every other one is absorbed for its resourceVersion and never spoken of again (spec §2).\nSee docs/facts/kubernetes-api-concepts.md §1.1 — this rule is a quote from the API docs, not an opinion about them.\n", + "suites": [ + "gateway" + ], + "scope": { + "target": "demo", + "version": "v1", + "resource": "configmaps", + "namespace": "app" + }, + "projection": "krm-full/v1", + "watch": [ + { + "op": "list", + "bodies": [ + "cm-app.v1" + ] + }, + { + "op": "bookmark", + "resourceVersion": "1500" + }, + { + "op": "modified", + "body": "cm-app.v2" + }, + { + "op": "bookmark", + "resourceVersion": "1600" + } + ], + "events": [ + { + "type": "reset" + }, + { + "type": "added", + "body": "cm-app.v1" + }, + { + "type": "synced" + }, + { + "type": "modified", + "body": "cm-app.v2" + } + ] + }, { "id": "conflict-and-converge", "title": "A real conflict is raised, kept, and then clears itself when the server converges.", @@ -94,7 +145,7 @@ "resource": "configmaps", "namespace": "app" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -195,7 +246,7 @@ "resource": "configmaps", "namespace": "app" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -345,7 +396,7 @@ "resource": "configmaps", "namespace": "app" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -423,7 +474,7 @@ "resource": "configmaps", "namespace": "app" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -512,7 +563,7 @@ "namespace": "app", "name": "app-config" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -551,7 +602,7 @@ "resource": "deployments", "namespace": "app" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -637,6 +688,67 @@ } } }, + { + "id": "partial-object-refused", + "title": "A metadata-only object is never forwarded as an upsert — the gateway resnapshots instead.", + "why": "Kubernetes serves PartialObjectMetadata on request (Accept: application/json;as=PartialObjectMetadata): \"the returned objects only contain the `metadata` field. The `spec` and `status` fields are omitted.\"\nThis fixture exists because it caught a real mistake in our own gateway. The guard was \"an object with no uid is partial\" — but a PartialObjectMetadata HAS a uid. It has a whole metadata block. What it does not have is a spec or a status. So the guard looked at the wrong field, the object sailed through, and the consumer — whose model is REPLACE, never merge — swapped a live Deployment for a husk: the status view goes blank and the editor silently loses the user's spec.\nThe honest check is the KIND (PartialObjectMetadata / PartialObjectMetadataList, group meta.k8s.io), not the presence of a uid. A partial object delivered as an update would blank a consumer's state — spec §2 says so, and it is right; we were simply not enforcing what we had written down.\nThe recovery is a new snapshot cycle: it is the one response that is ALWAYS correct, because it re-establishes the truth rather than guessing at it.\n", + "suites": [ + "gateway" + ], + "scope": { + "target": "demo", + "group": "apps", + "version": "v1", + "resource": "deployments", + "namespace": "app" + }, + "projection": "krm-full/v1", + "watch": [ + { + "op": "list", + "bodies": [ + "deploy-web.v1" + ] + }, + { + "op": "partial", + "body": "deploy-web.v1" + }, + { + "op": "list", + "bodies": [ + "deploy-web.v2" + ] + } + ], + "events": [ + { + "type": "reset" + }, + { + "type": "added", + "body": "deploy-web.v1" + }, + { + "type": "synced" + }, + { + "type": "error", + "code": "RESYNC_REQUIRED", + "terminal": false + }, + { + "type": "reset" + }, + { + "type": "added", + "body": "deploy-web.v2" + }, + { + "type": "synced" + } + ] + }, { "id": "reconnect-prune", "title": "An object deleted while the consumer was disconnected is pruned on the next synced.", @@ -651,7 +763,7 @@ "resource": "configmaps", "namespace": "app" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -707,6 +819,88 @@ } } }, + { + "id": "resourceversion-bignum", + "title": "A 40-digit resourceVersion orders correctly — and a stale replay is still dropped.", + "why": "Kubernetes: \"Resource versions are compared as arbitrary bitsize decimal integers... The bitsize must not be assumed to be some fixed amount.\" Its own worked example is 40 digits long.\nstrconv.ParseInt tops out at 19. The gateway used to compare resource versions with it, so against a server like this one the parse simply failed — and the per-object monotonicity check (spec §6) gave up. The user-visible symptom is silently dropped live updates, which in a status view is indistinguishable from \"Kubernetes is being slow\", and is therefore the kind of bug that survives for years.\nNote the KIND. This is a Flunder — Kubernetes' own sample aggregated API — and not a ConfigMap, on purpose: kube-apiserver's resourceVersion is an etcd revision, so it fits in an int64 and you will never meet a 40-digit one there. A server with a different backing store is where such a value actually comes from, and a fixture that pretended otherwise would be teaching the rule with an example that cannot happen.\nThe comparison Kubernetes prescribes: \"If they are not of equal length, the longer one is greater... If they are of equal length, the lexicographically greater one is greater.\" That rules out a plain lexicographic compare, which would call the 41-digit version OLDER than the 40-digit one.\nSo, both halves at once:\n - the 41-digit v2 is NEWER than the 40-digit v1 and must be delivered;\n - replaying v1 afterwards is STALE and must be dropped (§6: never emit, within a cycle, a state\n older than one already emitted for that uid).\n\nThese are decimals, so they ARE orderable: the default (OrderingStrict) handles them, and must.\n", + "suites": [ + "gateway" + ], + "scope": { + "target": "demo", + "group": "wardle.example.com", + "version": "v1alpha1", + "resource": "flunders", + "namespace": "app" + }, + "projection": "krm-full/v1", + "watch": [ + { + "op": "list", + "bodies": [ + "flunder.v1-bignum" + ] + }, + { + "op": "modified", + "body": "flunder.v2-bignum-newer" + }, + { + "op": "modified", + "body": "flunder.v1-bignum" + } + ], + "events": [ + { + "type": "reset" + }, + { + "type": "added", + "body": "flunder.v1-bignum" + }, + { + "type": "synced" + }, + { + "type": "modified", + "body": "flunder.v2-bignum-newer" + } + ] + }, + { + "id": "resourceversion-unorderable", + "title": "An aggregated API's unorderable resourceVersion is refused, loudly — not tolerated in silence.", + "why": "Kubernetes 1.35 made orderability a CONFORMANCE requirement, for built-in types and custom resources alike. This library REQUIRES 1.35+ and relies on it: a ConfigMap, a Deployment or a CRD cannot serve an unorderable resourceVersion, so a fixture using one would be defending against a case a conformant cluster cannot produce.\nThe one server that still may is an AGGREGATED / extension API server — a third-party implementation the conformance test does not cover. Hence a Flunder (wardle.example.com), Kubernetes' own sample aggregated API. The docs' carve-out is written for exactly this: \"if either of two resource version strings does not parse as a decimal number, the two strings can be checked for equality but you cannot rely on comparisons for ordering.\"\nThe protocol promises consumers per-object monotonicity (§6) — within a cycle you are never handed a state older than one you already hold — and the gateway keeps that promise BY ordering resource versions. If it cannot order them, it cannot keep it. A gateway that quietly carries on anyway leaves a consumer believing a guarantee it is no longer getting, which is strictly worse than one that has been told.\nSo the default (Gateway.Ordering = OrderingStrict) refuses: a terminal INTERNAL error that NAMES the escape hatch, so the operator of an aggregated API is never merely stuck. OrderingLenient streams it anyway and simply stops ordering — dropping nothing it cannot compare, because a duplicate is harmless (apply is idempotent) while a wrongly-dropped update is data loss.\nNote WHERE the refusal lands: after `reset`, because the gateway only learns what this upstream's resource versions look like when the first object arrives. It cannot know from the scope alone.\n", + "suites": [ + "gateway" + ], + "scope": { + "target": "demo", + "group": "wardle.example.com", + "version": "v1alpha1", + "resource": "flunders", + "namespace": "app" + }, + "projection": "krm-full/v1", + "watch": [ + { + "op": "list", + "bodies": [ + "flunder.v3-opaque" + ] + } + ], + "events": [ + { + "type": "reset" + }, + { + "type": "error", + "code": "INTERNAL", + "terminal": true + } + ] + }, { "id": "resync-midstream", "title": "Upstream continuity can be lost WITHOUT the SSE connection dropping — a fresh cycle follows.", @@ -721,7 +915,7 @@ "resource": "configmaps", "namespace": "app" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -789,8 +983,8 @@ }, { "id": "secret-redaction", - "title": "A redacted value is shown as a mask, is never dirtiable, and can never be written back.", - "why": "The rule that makes a Secret safe to display at all: a value you never SAW cannot round-trip over the real one. Three obligations, and all three must hold or the feature is a vulnerability:\n\n 1. the gateway declares WHICH paths are redacted (RFC 6901 pointers), on the wire, per object —\n so a consumer never has to infer redaction from a value that merely LOOKS like a placeholder;\n 2. the client marks those paths read-only: no draft, no dirty, no conflict, never in a patch;\n 3. the gateway REJECTS any patch that touches a redacted path — belt and braces, because the\n client is not the security boundary. A hostile client is just a client.\n\nEditing an UNREDACTED field of the same object must still work — that is the whole point of keys-only disclosure. You can see that `token` exists, you can rename the object's labels, and you can never learn or clobber the token itself.\n", + "title": "A redacted value is not on the wire at all — the key is disclosed, the value is withheld.", + "why": "Keys-only disclosure. The gateway omits Secret values and declares their RFC 6901 paths in `redacted`; the client keeps those paths read-only and never includes them in a patch. The host save handler independently validates a patch against the active projection. Unredacted fields of the same object remain editable.\n", "suites": [ "gateway", "client" @@ -801,7 +995,7 @@ "resource": "secrets", "namespace": "app" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -816,10 +1010,16 @@ }, { "type": "added", - "body": "secret-token.v1", - "redactedPaths": [ - "/data/token", - "/data/username" + "body": "secret-token.v1-wire", + "redacted": [ + { + "path": "/data/token", + "rev": 1 + }, + { + "path": "/data/username", + "rev": 1 + } ] }, { @@ -867,18 +1067,7 @@ } } } - }, - "gatewayRejects": [ - { - "patch": { - "data": { - "token": "hunter2" - } - }, - "code": "FORBIDDEN", - "because": "/data/token is redacted under krm-editor/v1" - } - ] + } }, { "id": "snapshot-then-deltas", @@ -894,7 +1083,7 @@ "resource": "configmaps", "namespace": "app" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -948,9 +1137,9 @@ } }, { - "id": "status-only-churn", - "title": "status follows the server live, and is never dirty, never conflicted, never in a patch.", - "why": "This is the product thesis in one fixture. `status` is controller-owned: it must update live (that is the whole live-status-watch use case — read-only is NOT ignored, it flashes and re-renders), and it must be structurally incapable of becoming an edit. Meanwhile the user is editing `spec.replicas`, and a status heartbeat must not touch it.\nOn a real workload this — not editing — is the dominant traffic on the stream.\n", + "id": "status-follow-live", + "title": "Status follows the server live while an edit survives.", + "why": "`krm-full/v1` keeps `status`, read-only and live. A controller-only update must flash and follow the server while an in-flight edit to `spec.replicas` survives untouched and remains the only patchable change.\nThis is the browser demo: watch Kubernetes reconcile while you type.\n", "suites": [ "gateway", "client" @@ -962,7 +1151,7 @@ "resource": "deployments", "namespace": "app" }, - "projection": "krm-editor/v1", + "projection": "krm-full/v1", "watch": [ { "op": "list", @@ -1017,26 +1206,154 @@ "replicas": 4 }, "status": { - "readyReplicas": 2, - "observedGeneration": 4 + "readyReplicas": 2 } }, "patch": { "spec": { "replicas": 4 } - }, - "flashed": [ - [ - "status", - "readyReplicas" + } + } + } + }, + { + "id": "status-only-churn", + "title": "A status-only update produces no event when the selected projection ignores status.", + "why": "`krm-spec/v1` ignores `status`, so a controller-only update must emit no event at all. Meanwhile the user is editing `spec.replicas`; the suppressed status heartbeat must not touch that draft.\nOn a real workload this — not editing — is the dominant traffic on the stream.\n", + "suites": [ + "gateway", + "client" + ], + "scope": { + "target": "demo", + "group": "apps", + "version": "v1", + "resource": "deployments", + "namespace": "app" + }, + "projection": "krm-spec/v1", + "watch": [ + { + "op": "list", + "bodies": [ + "deploy-web.v1" + ] + }, + { + "op": "modified", + "body": "deploy-web.v2" + } + ], + "events": [ + { + "type": "reset" + }, + { + "type": "added", + "body": "deploy-web.v1-spec" + }, + { + "type": "synced" + } + ], + "client": { + "edits": [ + { + "after": 2, + "op": "set", + "uid": "deploy-web-0001", + "path": [ + "spec", + "replicas" ], + "value": 4 + } + ], + "expect": { + "dirty": [ [ - "status", - "conditions" + "spec", + "replicas" ] - ] + ], + "conflicts": [], + "draftSubset": { + "spec": { + "replicas": 4 + } + }, + "patch": { + "spec": { + "replicas": 4 + } + } } } + }, + { + "id": "tombstone-without-uid", + "title": "An ambiguous tombstone is never emitted as `deleted` — a new cycle prunes instead.", + "why": "spec §4.2, and it is stated in strong terms for a reason: if the gateway cannot recover a trustworthy uid it MUST NOT emit an ambiguous `deleted`. It begins a new snapshot cycle and lets reset…synced do the pruning.\nThe source of a degenerate tombstone is client-go's cache.DeletedFinalStateUnknown: the informer missed the delete and noticed during a relist, so it hands you a wrapper whose object may be nothing more than a key. A gateway that \"helpfully\" reconstructs a uid from the name deletes THE WRONG OBJECT out of somebody's browser — and a delete-and-recreate under the same name (see delete-recreate-uid) is exactly the case where the name is a lie.\nPaying for a relist is the cheap option here. Guessing is not.\n", + "suites": [ + "gateway" + ], + "scope": { + "target": "demo", + "version": "v1", + "resource": "configmaps", + "namespace": "app" + }, + "projection": "krm-full/v1", + "watch": [ + { + "op": "list", + "bodies": [ + "cm-app.v1", + "cm-flags.v1" + ] + }, + { + "op": "tombstone", + "body": "cm-flags.v1" + }, + { + "op": "list", + "bodies": [ + "cm-app.v1" + ] + } + ], + "events": [ + { + "type": "reset" + }, + { + "type": "added", + "body": "cm-app.v1" + }, + { + "type": "added", + "body": "cm-flags.v1" + }, + { + "type": "synced" + }, + { + "type": "error", + "code": "RESYNC_REQUIRED", + "terminal": false + }, + { + "type": "reset" + }, + { + "type": "added", + "body": "cm-app.v1" + }, + { + "type": "synced" + } + ] } ] diff --git a/conformance/gen/protocol.json b/conformance/gen/protocol.json new file mode 100644 index 0000000..91bb30e --- /dev/null +++ b/conformance/gen/protocol.json @@ -0,0 +1,3 @@ +{ + "protocolVersion": 1 +} diff --git a/conformance/gen/scopes.json b/conformance/gen/scopes.json new file mode 100644 index 0000000..75d5edf --- /dev/null +++ b/conformance/gen/scopes.json @@ -0,0 +1,120 @@ +[ + { + "id": "cluster-scoped", + "because": "no namespace => cluster-scoped, or every namespace. The Authorizer decides if that is OK", + "query": "version=v1&resource=namespaces", + "canonical": "version=v1&resource=namespaces", + "scope": { + "version": "v1", + "resource": "namespaces" + } + }, + { + "id": "collection-core", + "because": "the common case: a core-group collection in one namespace", + "query": "resource=configmaps&version=v1&namespace=app", + "canonical": "version=v1&resource=configmaps&namespace=app", + "scope": { + "version": "v1", + "resource": "configmaps", + "namespace": "app" + } + }, + { + "id": "explicit-target", + "because": "which cluster/workspace — a LOGICAL name the host resolves, never an address", + "query": "target=prod&group=wardle.example.com&version=v1alpha1&resource=flunders&namespace=app", + "canonical": "target=prod&group=wardle.example.com&version=v1alpha1&resource=flunders&namespace=app", + "scope": { + "target": "prod", + "group": "wardle.example.com", + "version": "v1alpha1", + "resource": "flunders", + "namespace": "app" + } + }, + { + "id": "grouped-resource", + "because": "a non-core group", + "query": "group=apps&version=v1&resource=deployments&namespace=app", + "canonical": "group=apps&version=v1&resource=deployments&namespace=app", + "scope": { + "group": "apps", + "version": "v1", + "resource": "deployments", + "namespace": "app" + } + }, + { + "id": "label-selector", + "because": "a selector is part of the scope: it changes which objects the snapshot contains", + "query": "version=v1&resource=pods&namespace=app&labelSelector=tier%3Dweb", + "canonical": "version=v1&resource=pods&namespace=app&labelSelector=tier%3Dweb", + "scope": { + "version": "v1", + "resource": "pods", + "namespace": "app", + "labelSelector": "tier=web" + } + }, + { + "id": "named-object", + "because": "a single object. `name` is a scope, NOT a different endpoint — same framing (spec §2)", + "query": "version=v1&resource=configmaps&namespace=app&name=app-config", + "canonical": "version=v1&resource=configmaps&namespace=app&name=app-config", + "scope": { + "version": "v1", + "resource": "configmaps", + "namespace": "app", + "name": "app-config" + } + }, + { + "id": "reject-api-server-address", + "because": "spec §8: a raw API URL is NEVER accepted. Not merely unused — REFUSED, so that an SSRF attempt is an error and not a silently ignored parameter", + "query": "version=v1&resource=secrets&server=https://10.0.0.1:6443", + "code": "SCOPE_INVALID" + }, + { + "id": "reject-group-with-slash", + "because": "a group is a DNS subdomain; a slash means someone is building a path, not naming an API", + "query": "group=apps/v1&version=v1&resource=deployments", + "code": "SCOPE_INVALID" + }, + { + "id": "reject-kubeconfig", + "because": "same rule, the other spelling. The browser never supplies credentials or an endpoint", + "query": "version=v1&resource=secrets&kubeconfig=/etc/admin.conf", + "code": "SCOPE_INVALID" + }, + { + "id": "reject-name-with-slash", + "because": "a name is a DNS subdomain: no slashes. Same reason", + "query": "version=v1&resource=configmaps&namespace=app&name=app/../../etc", + "code": "SCOPE_INVALID" + }, + { + "id": "reject-namespace-path-traversal", + "because": "a namespace is a DNS label. `../` is someone probing whether we paste this into a URL", + "query": "version=v1&resource=secrets&namespace=../../kube-system", + "code": "SCOPE_INVALID" + }, + { + "id": "reject-no-resource", + "because": "there is no default resource, and guessing one would stream the wrong objects", + "query": "version=v1&namespace=app", + "code": "SCOPE_INVALID" + }, + { + "id": "reject-no-version", + "because": "there is no default version. `v1` vs `v1beta1` are different objects with different shapes", + "query": "resource=configmaps&namespace=app", + "code": "SCOPE_INVALID" + }, + { + "id": "reject-resource-is-a-kind", + "because": "`resource` is the plural lowercase API name (`configmaps`), not the Kind (`ConfigMap`) — the most common mistake there is, and it must be loud rather than 404 later", + "query": "version=v1&resource=ConfigMap&namespace=app", + "code": "SCOPE_INVALID" + } +] diff --git a/conformance/gen/sse/bookmark-absorbed.sse b/conformance/gen/sse/bookmark-absorbed.sse new file mode 100644 index 0000000..7aebecc --- /dev/null +++ b/conformance/gen/sse/bookmark-absorbed.sse @@ -0,0 +1,10 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + +data: {"seq":4,"type":"modified","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"5"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1002","uid":"cm-app-0001"}},"redacted":[]} + diff --git a/conformance/gen/sse/conflict-and-converge.sse b/conformance/gen/sse/conflict-and-converge.sse new file mode 100644 index 0000000..b0f212c --- /dev/null +++ b/conformance/gen/sse/conflict-and-converge.sse @@ -0,0 +1,12 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + +data: {"seq":4,"type":"modified","object":{"apiVersion":"v1","data":{"log-level":"warn","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1003","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":5,"type":"modified","object":{"apiVersion":"v1","data":{"log-level":"debug","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1004","uid":"cm-app-0001"}},"redacted":[]} + diff --git a/conformance/gen/sse/delete-recreate-uid.sse b/conformance/gen/sse/delete-recreate-uid.sse new file mode 100644 index 0000000..06e83f1 --- /dev/null +++ b/conformance/gen/sse/delete-recreate-uid.sse @@ -0,0 +1,12 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + +data: {"seq":4,"type":"deleted","identity":{"uid":"cm-app-0001","apiVersion":"v1","kind":"ConfigMap","namespace":"app","name":"app-config"}} + +data: {"seq":5,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"trace"},"kind":"ConfigMap","metadata":{"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"2001","uid":"cm-app-0002"}},"redacted":[]} + diff --git a/conformance/gen/sse/edit-vs-unrelated-change.sse b/conformance/gen/sse/edit-vs-unrelated-change.sse new file mode 100644 index 0000000..7aebecc --- /dev/null +++ b/conformance/gen/sse/edit-vs-unrelated-change.sse @@ -0,0 +1,10 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + +data: {"seq":4,"type":"modified","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"5"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1002","uid":"cm-app-0001"}},"redacted":[]} + diff --git a/conformance/gen/sse/key-removed-upstream.sse b/conformance/gen/sse/key-removed-upstream.sse new file mode 100644 index 0000000..d2ee769 --- /dev/null +++ b/conformance/gen/sse/key-removed-upstream.sse @@ -0,0 +1,10 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + +data: {"seq":4,"type":"modified","object":{"apiVersion":"v1","data":{"log-level":"info"},"kind":"ConfigMap","metadata":{"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1005","uid":"cm-app-0001"}},"redacted":[]} + diff --git a/conformance/gen/sse/named-object-absent.sse b/conformance/gen/sse/named-object-absent.sse new file mode 100644 index 0000000..88070be --- /dev/null +++ b/conformance/gen/sse/named-object-absent.sse @@ -0,0 +1,6 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app","name":"app-config"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"synced"} + diff --git a/conformance/gen/sse/nested-field-removed.sse b/conformance/gen/sse/nested-field-removed.sse new file mode 100644 index 0000000..94167ac --- /dev/null +++ b/conformance/gen/sse/nested-field-removed.sse @@ -0,0 +1,10 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","group":"apps","version":"v1","resource":"deployments","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"generation":4,"labels":{"app.kubernetes.io/name":"web"},"name":"web","namespace":"app","resourceVersion":"3001","uid":"deploy-web-0001"},"spec":{"paused":false,"replicas":2,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"image":"ghcr.io/configbutler/web:v1.2.3","name":"web","ports":[{"containerPort":8080}]}]}}},"status":{"conditions":[{"reason":"MinimumReplicasUnavailable","status":"False","type":"Available"}],"observedGeneration":4,"readyReplicas":1,"replicas":2}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + +data: {"seq":4,"type":"modified","object":{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"generation":5,"labels":{"app.kubernetes.io/name":"web"},"name":"web","namespace":"app","resourceVersion":"3003","uid":"deploy-web-0001"},"spec":{"replicas":2,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"image":"ghcr.io/configbutler/web:v1.2.3","name":"web","ports":[{"containerPort":8080}]}]}}},"status":{"conditions":[{"reason":"MinimumReplicasAvailable","status":"True","type":"Available"}],"observedGeneration":5,"readyReplicas":2,"replicas":2}},"redacted":[]} + diff --git a/conformance/gen/sse/partial-object-refused.sse b/conformance/gen/sse/partial-object-refused.sse new file mode 100644 index 0000000..9d3a0d7 --- /dev/null +++ b/conformance/gen/sse/partial-object-refused.sse @@ -0,0 +1,16 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","group":"apps","version":"v1","resource":"deployments","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"generation":4,"labels":{"app.kubernetes.io/name":"web"},"name":"web","namespace":"app","resourceVersion":"3001","uid":"deploy-web-0001"},"spec":{"paused":false,"replicas":2,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"image":"ghcr.io/configbutler/web:v1.2.3","name":"web","ports":[{"containerPort":8080}]}]}}},"status":{"conditions":[{"reason":"MinimumReplicasUnavailable","status":"False","type":"Available"}],"observedGeneration":4,"readyReplicas":1,"replicas":2}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + +data: {"seq":4,"type":"error","code":"RESYNC_REQUIRED","message":"upstream continuity lost; a new snapshot cycle follows","terminal":false} + +data: {"seq":5,"type":"reset","target":"demo","scope":{"target":"demo","group":"apps","version":"v1","resource":"deployments","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":6,"type":"added","object":{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"generation":4,"labels":{"app.kubernetes.io/name":"web"},"name":"web","namespace":"app","resourceVersion":"3002","uid":"deploy-web-0001"},"spec":{"paused":false,"replicas":2,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"image":"ghcr.io/configbutler/web:v1.2.3","name":"web","ports":[{"containerPort":8080}]}]}}},"status":{"conditions":[{"reason":"MinimumReplicasAvailable","status":"True","type":"Available"}],"observedGeneration":4,"readyReplicas":2,"replicas":2}},"redacted":[]} + +data: {"seq":7,"type":"synced"} + diff --git a/conformance/gen/sse/reconnect-prune.sse b/conformance/gen/sse/reconnect-prune.sse new file mode 100644 index 0000000..8e0c71e --- /dev/null +++ b/conformance/gen/sse/reconnect-prune.sse @@ -0,0 +1,18 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"added","object":{"apiVersion":"v1","data":{"checkout-v2":"false"},"kind":"ConfigMap","metadata":{"name":"feature-flags","namespace":"app","resourceVersion":"1010","uid":"cm-flags-0001"}},"redacted":[]} + +data: {"seq":4,"type":"synced"} + +: connection 2 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + diff --git a/conformance/gen/sse/resourceversion-bignum.sse b/conformance/gen/sse/resourceversion-bignum.sse new file mode 100644 index 0000000..d967d21 --- /dev/null +++ b/conformance/gen/sse/resourceversion-bignum.sse @@ -0,0 +1,10 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","group":"wardle.example.com","version":"v1alpha1","resource":"flunders","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"wardle.example.com/v1alpha1","kind":"Flunder","metadata":{"labels":{"app.kubernetes.io/name":"shop"},"name":"fl-1","namespace":"app","resourceVersion":"2345678901234567890123456789012345678901","uid":"flunder-0001"},"spec":{"reference":"some-flunder","referenceType":"Flunder"},"status":{}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + +data: {"seq":4,"type":"modified","object":{"apiVersion":"wardle.example.com/v1alpha1","kind":"Flunder","metadata":{"labels":{"app.kubernetes.io/name":"shop"},"name":"fl-1","namespace":"app","resourceVersion":"12345678901234567890123456789012345678901","uid":"flunder-0001"},"spec":{"reference":"another-flunder","referenceType":"Flunder"},"status":{}},"redacted":[]} + diff --git a/conformance/gen/sse/resourceversion-unorderable.sse b/conformance/gen/sse/resourceversion-unorderable.sse new file mode 100644 index 0000000..e9ec7f6 --- /dev/null +++ b/conformance/gen/sse/resourceversion-unorderable.sse @@ -0,0 +1,6 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","group":"wardle.example.com","version":"v1alpha1","resource":"flunders","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"error","code":"INTERNAL","message":"upstream served a resourceVersion that cannot be ordered (opaque-alpha). Kubernetes 1.35+ requires every resourceVersion to be an orderable decimal, so this upstream is either older than 1.35 or an aggregated API server that does not conform. Set Gateway.Ordering = OrderingLenient to stream it anyway, without per-object monotonicity.","terminal":true} + diff --git a/conformance/gen/sse/resync-midstream.sse b/conformance/gen/sse/resync-midstream.sse new file mode 100644 index 0000000..46cecc4 --- /dev/null +++ b/conformance/gen/sse/resync-midstream.sse @@ -0,0 +1,20 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"added","object":{"apiVersion":"v1","data":{"checkout-v2":"false"},"kind":"ConfigMap","metadata":{"name":"feature-flags","namespace":"app","resourceVersion":"1010","uid":"cm-flags-0001"}},"redacted":[]} + +data: {"seq":4,"type":"synced"} + +data: {"seq":5,"type":"modified","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"5"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1002","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":6,"type":"error","code":"RESYNC_REQUIRED","message":"upstream continuity lost; a new snapshot cycle follows","terminal":false} + +data: {"seq":7,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":8,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"5"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1002","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":9,"type":"synced"} + diff --git a/conformance/gen/sse/secret-redaction.sse b/conformance/gen/sse/secret-redaction.sse new file mode 100644 index 0000000..65bdea5 --- /dev/null +++ b/conformance/gen/sse/secret-redaction.sse @@ -0,0 +1,8 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"secrets","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","kind":"Secret","metadata":{"labels":{"app.kubernetes.io/name":"shop"},"name":"git-creds","namespace":"app","resourceVersion":"4001","uid":"secret-token-0001"},"type":"Opaque"},"redacted":[{"path":"/data/token","rev":1},{"path":"/data/username","rev":1}]} + +data: {"seq":3,"type":"synced"} + diff --git a/conformance/gen/sse/snapshot-then-deltas.sse b/conformance/gen/sse/snapshot-then-deltas.sse new file mode 100644 index 0000000..11c9dc4 --- /dev/null +++ b/conformance/gen/sse/snapshot-then-deltas.sse @@ -0,0 +1,12 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"added","object":{"apiVersion":"v1","data":{"checkout-v2":"false"},"kind":"ConfigMap","metadata":{"name":"feature-flags","namespace":"app","resourceVersion":"1010","uid":"cm-flags-0001"}},"redacted":[]} + +data: {"seq":4,"type":"synced"} + +data: {"seq":5,"type":"modified","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"5"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1002","uid":"cm-app-0001"}},"redacted":[]} + diff --git a/conformance/gen/sse/status-follow-live.sse b/conformance/gen/sse/status-follow-live.sse new file mode 100644 index 0000000..a2fd103 --- /dev/null +++ b/conformance/gen/sse/status-follow-live.sse @@ -0,0 +1,10 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","group":"apps","version":"v1","resource":"deployments","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"generation":4,"labels":{"app.kubernetes.io/name":"web"},"name":"web","namespace":"app","resourceVersion":"3001","uid":"deploy-web-0001"},"spec":{"paused":false,"replicas":2,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"image":"ghcr.io/configbutler/web:v1.2.3","name":"web","ports":[{"containerPort":8080}]}]}}},"status":{"conditions":[{"reason":"MinimumReplicasUnavailable","status":"False","type":"Available"}],"observedGeneration":4,"readyReplicas":1,"replicas":2}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + +data: {"seq":4,"type":"modified","object":{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"generation":4,"labels":{"app.kubernetes.io/name":"web"},"name":"web","namespace":"app","resourceVersion":"3002","uid":"deploy-web-0001"},"spec":{"paused":false,"replicas":2,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"image":"ghcr.io/configbutler/web:v1.2.3","name":"web","ports":[{"containerPort":8080}]}]}}},"status":{"conditions":[{"reason":"MinimumReplicasAvailable","status":"True","type":"Available"}],"observedGeneration":4,"readyReplicas":2,"replicas":2}},"redacted":[]} + diff --git a/conformance/gen/sse/status-only-churn.sse b/conformance/gen/sse/status-only-churn.sse new file mode 100644 index 0000000..7007f5e --- /dev/null +++ b/conformance/gen/sse/status-only-churn.sse @@ -0,0 +1,8 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","group":"apps","version":"v1","resource":"deployments","namespace":"app"},"projection":"krm-spec/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"generation":4,"labels":{"app.kubernetes.io/name":"web"},"name":"web","namespace":"app","resourceVersion":"3001","uid":"deploy-web-0001"},"spec":{"paused":false,"replicas":2,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"image":"ghcr.io/configbutler/web:v1.2.3","name":"web","ports":[{"containerPort":8080}]}]}}}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + diff --git a/conformance/gen/sse/tombstone-without-uid.sse b/conformance/gen/sse/tombstone-without-uid.sse new file mode 100644 index 0000000..08572a9 --- /dev/null +++ b/conformance/gen/sse/tombstone-without-uid.sse @@ -0,0 +1,18 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"added","object":{"apiVersion":"v1","data":{"checkout-v2":"false"},"kind":"ConfigMap","metadata":{"name":"feature-flags","namespace":"app","resourceVersion":"1010","uid":"cm-flags-0001"}},"redacted":[]} + +data: {"seq":4,"type":"synced"} + +data: {"seq":5,"type":"error","code":"RESYNC_REQUIRED","message":"upstream continuity lost; a new snapshot cycle follows","terminal":false} + +data: {"seq":6,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":7,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":8,"type":"synced"} + diff --git a/conformance/generate.sh b/conformance/generate.sh index 1f69826..779289f 100755 --- a/conformance/generate.sh +++ b/conformance/generate.sh @@ -32,4 +32,8 @@ for f in "${here}"/fixtures/*.yaml; do to_json "${f}" done | jq -s 'sort_by(.id)' > "${here}/gen/fixtures.json" -echo "conformance: $(jq 'length' "${here}/gen/bodies.json") bodies, $(jq 'length' "${here}/gen/fixtures.json") fixtures -> gen/" +# scopes.json — the REQUEST half: how a scope is encoded in a URL. Read by BOTH suites, which is the +# whole point: the client builds `canonical`, the gateway parses it back, and neither can drift. +to_json "${here}/scopes.yaml" | jq 'sort_by(.id)' > "${here}/gen/scopes.json" + +echo "conformance: $(jq 'length' "${here}/gen/bodies.json") bodies, $(jq 'length' "${here}/gen/fixtures.json") fixtures, $(jq 'length' "${here}/gen/scopes.json") scopes -> gen/" diff --git a/conformance/scopes.yaml b/conformance/scopes.yaml new file mode 100644 index 0000000..5f3a243 --- /dev/null +++ b/conformance/scopes.yaml @@ -0,0 +1,98 @@ +# The REQUEST half of the wire, which v1 shipped without — and it is the half every adopter touches +# first. +# +# The spec named the scope's FIELDS (§8) and never its ENCODING. So the gateway invented +# `?group=&version=&resource=…` in its replay server, the client invented a URL string in its README, +# and nothing anywhere compared the two. Two ends of one repository, free to disagree, with a green +# suite. That is the exact failure this corpus exists to make impossible for the response half — so +# the request half gets the same treatment. +# +# Read this file as a CONTRACT BETWEEN THE TWO SUITES: +# +# client (TS) — resourceStreamURL(base, scope) MUST produce `canonical` +# gateway (Go) — ScopeFromQuery(query) MUST produce `scope` … and MUST also accept +# `canonical`, which is how the client's output is fed straight into the server's +# parser. Neither side can drift without the other's suite going red. +# +# `query` is what a caller may send (fields in any order, extras tolerated). `canonical` is what the +# client emits: one fixed field order, so a URL is stable, cacheable and diffable. + +- id: collection-core + because: "the common case: a core-group collection in one namespace" + query: "resource=configmaps&version=v1&namespace=app" + canonical: "version=v1&resource=configmaps&namespace=app" + scope: { version: v1, resource: configmaps, namespace: app } + +- id: named-object + because: "a single object. `name` is a scope, NOT a different endpoint — same framing (spec §2)" + query: "version=v1&resource=configmaps&namespace=app&name=app-config" + canonical: "version=v1&resource=configmaps&namespace=app&name=app-config" + scope: { version: v1, resource: configmaps, namespace: app, name: app-config } + +- id: grouped-resource + because: "a non-core group" + query: "group=apps&version=v1&resource=deployments&namespace=app" + canonical: "group=apps&version=v1&resource=deployments&namespace=app" + scope: { group: apps, version: v1, resource: deployments, namespace: app } + +- id: cluster-scoped + because: "no namespace => cluster-scoped, or every namespace. The Authorizer decides if that is OK" + query: "version=v1&resource=namespaces" + canonical: "version=v1&resource=namespaces" + scope: { version: v1, resource: namespaces } + +- id: label-selector + because: "a selector is part of the scope: it changes which objects the snapshot contains" + query: "version=v1&resource=pods&namespace=app&labelSelector=tier%3Dweb" + canonical: "version=v1&resource=pods&namespace=app&labelSelector=tier%3Dweb" + scope: { version: v1, resource: pods, namespace: app, labelSelector: tier=web } + +- id: explicit-target + because: "which cluster/workspace — a LOGICAL name the host resolves, never an address" + query: "target=prod&group=wardle.example.com&version=v1alpha1&resource=flunders&namespace=app" + canonical: "target=prod&group=wardle.example.com&version=v1alpha1&resource=flunders&namespace=app" + scope: { target: prod, group: wardle.example.com, version: v1alpha1, resource: flunders, namespace: app } + +# ---------------------------------------------------------------------- rejections -- +# Every one of these is SCOPE_INVALID and TERMINAL. Terminal matters: a browser's EventSource +# reconnects by itself, so a non-terminal rejection would have it hammer a malformed scope forever. + +- id: reject-no-resource + because: "there is no default resource, and guessing one would stream the wrong objects" + query: "version=v1&namespace=app" + code: SCOPE_INVALID + +- id: reject-no-version + because: "there is no default version. `v1` vs `v1beta1` are different objects with different shapes" + query: "resource=configmaps&namespace=app" + code: SCOPE_INVALID + +- id: reject-resource-is-a-kind + because: "`resource` is the plural lowercase API name (`configmaps`), not the Kind (`ConfigMap`) — the most common mistake there is, and it must be loud rather than 404 later" + query: "version=v1&resource=ConfigMap&namespace=app" + code: SCOPE_INVALID + +- id: reject-api-server-address + because: "spec §8: a raw API URL is NEVER accepted. Not merely unused — REFUSED, so that an SSRF attempt is an error and not a silently ignored parameter" + query: "version=v1&resource=secrets&server=https://10.0.0.1:6443" + code: SCOPE_INVALID + +- id: reject-kubeconfig + because: "same rule, the other spelling. The browser never supplies credentials or an endpoint" + query: "version=v1&resource=secrets&kubeconfig=/etc/admin.conf" + code: SCOPE_INVALID + +- id: reject-namespace-path-traversal + because: "a namespace is a DNS label. `../` is someone probing whether we paste this into a URL" + query: "version=v1&resource=secrets&namespace=../../kube-system" + code: SCOPE_INVALID + +- id: reject-name-with-slash + because: "a name is a DNS subdomain: no slashes. Same reason" + query: "version=v1&resource=configmaps&namespace=app&name=app/../../etc" + code: SCOPE_INVALID + +- id: reject-group-with-slash + because: "a group is a DNS subdomain; a slash means someone is building a path, not naming an API" + query: "group=apps/v1&version=v1&resource=deployments" + code: SCOPE_INVALID diff --git a/docs/adopting.md b/docs/adopting.md new file mode 100644 index 0000000..407ca05 --- /dev/null +++ b/docs/adopting.md @@ -0,0 +1,125 @@ +# Adopting krm-stream + +This is the shortest safe route from an existing Go application to a browser KRM stream. The library +owns the read path. Your application owns identity, authorization policy, Kubernetes credentials, and +writes. + +## 1. Declare scopes precisely + +An empty namespace has two Kubernetes meanings: a cluster-scoped resource, or every namespace for a +namespaced resource. `ScopePolicy` makes the host choose explicitly. + +| Resource kind | `GroupResource` | Caller namespace | Result | +|---|---|---|---| +| one namespace of ConfigMaps | `Scope: ResourceScopeNamespaced` | `app` | watches only `app` | +| all namespaces of ConfigMaps | `Scope: ResourceScopeNamespaced, AllowAllNamespaces: true` | empty | watches all namespaces | +| Namespaces | `Scope: ResourceScopeCluster` | empty | watches cluster-scoped objects | +| Namespaces with `namespace=app` | `Scope: ResourceScopeCluster` | `app` | refused | + +Keep all-namespaces access rare. It changes the size, disclosure risk, and operating cost of a stream. +Use an `Authorizer` to pin a user to a namespace or target before any watch opens. + +## 2. Mount the same-origin cookie endpoint + +```go +import ( + "net/http" + + "github.com/ConfigButler/krm-stream/gateway" + "github.com/ConfigButler/krm-stream/gateway/kube" +) + +func mount(mux *http.ServeMux, dynamicClientFor func(*User) dynamic.Interface) { + mux.Handle("/resource-stream/v1", gateway.Handler(gateway.Options{ + // Your session cookie -> your application user. krm-stream never sees a token. + Principal: func(r *http.Request) (gateway.Principal, error) { + return userFromSession(r) + }, + // Deny before opening a watch; also rechecked on every recovery cycle. + Authorizer: gateway.AuthorizerFunc(func(ctx context.Context, p gateway.Principal, s gateway.Scope) error { + user := p.(*User) + if s.Target != user.Target || s.Namespace != user.Namespace { + return gateway.Forbidden("scope is not available to this user") + } + return nil + }), + // Build a dynamic client acting as this user. Kubernetes RBAC remains the boundary. + Clients: func(_ context.Context, _ string, p gateway.Principal) (gateway.Backend, error) { + return kube.NewBackend(dynamicClientFor(p.(*User))), nil + }, + Scopes: gateway.ScopePolicy{ + Targets: []string{"production"}, + Resources: []gateway.GroupResource{ + {Group: "", Resource: "configmaps", Scope: gateway.ResourceScopeNamespaced}, + {Group: "apps", Resource: "deployments", Scope: gateway.ResourceScopeNamespaced}, + }, + }, + Projection: gateway.ProjectionFull, + })) +} +``` + +The browser uses `connectWithEventSource` for this route. Its same-origin session cookie is the only +credential EventSource can carry. + +## 3. Browser client + +```ts +import { + defaultPolicy, + LiveResourceStore, + connectWithEventSource, + resourceStreamURL, + withOpenAPIKeyedLists, +} from "@configbutler/krm-stream"; + +const store = new LiveResourceStore(); +const url = resourceStreamURL("/resource-stream/v1", { + target: "production", + version: "v1", + resource: "configmaps", + namespace: "app", + projection: "krm-full/v1", +}); + +connectWithEventSource(url, store, { + onGap: () => location.reload(), // reconnect for a new snapshot in an SPA-specific way +}); +store.subscribe(() => render(store)); +``` + +For a bearer-token client, use `connectResourceStream(url, store, { headers: { Authorization: ... } })`. +That is useful for a non-browser client or an intentionally token-bearing browser application; the +same-origin cookie route is the safer browser default. + +For Deployment or CRD editing, a host may opt into OpenAPI-declared associative-list merging without +exposing schemas to the browser: + +```ts +const store = new LiveResourceStore(withOpenAPIKeyedLists(defaultPolicy, deploymentSchema)); +``` + +`deploymentSchema` is the structural schema for that exact GroupVersionKind. Only lists marked +`x-kubernetes-list-type: map` with `x-kubernetes-list-map-keys` are merged by key; every other list +stays safely atomic. + +## 4. Share watches only with Kubernetes-backed authorization + +`SharedBackend` saves upstream watches but runs as one service identity. Pair it with +`kube.SSARAuthorizer` so Kubernetes still decides whether each caller may list and watch the scope. + +```go +shared := gateway.NewSharedBackendWithOptions(serviceAccountBackend, gateway.SharedOptions{ + QueueDepth: 512, + Observer: metrics, +}) + +options.Authorizer = kube.SSARAuthorizer(clientset, subjectFromUser) +options.Clients = func(context.Context, string, gateway.Principal) (gateway.Backend, error) { return shared, nil } +``` + +Read [auth.md](auth.md) before using this configuration. The shared-cache authorizer is a security +boundary; a per-user backend keeps Kubernetes RBAC as the direct boundary by construction. + +Next: [saving.md](saving.md) for the host-owned write path and [operations.md](operations.md) for +stream monitoring and limits. diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..2a706e5 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,164 @@ +# Authentication & authorization + +**krm-stream never holds a credential, and it is not an authorization boundary. Kubernetes is.** + +That is the whole stance. Everything below is a consequence of it, plus the one physical constraint +that decides how a browser can authenticate at all. + +--- + +## The constraint that decides everything + +**A browser's `EventSource` cannot send an `Authorization` header.** It is not an oversight we can +work around; it is what the API is. So a browser holding an OIDC access token *cannot put it on a +native SSE request*. + +Everything else follows from that one sentence. + +## Recommended browser route: OIDC via Dex, with a same-origin cookie + +For browser applications, use the same-origin route that native `EventSource` permits: + +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant S as Your Go server
(krm-stream gateway) + participant D as Dex (OIDC) + participant K as Kubernetes API + + B->>S: 1. GET / (no session) + S->>D: 2. OIDC redirect + D-->>B: 3. user logs in + B->>S: 4. callback with code + S->>D: 5. exchange code → id/access token + Note over S: your server CUSTODIES the token.
krm-stream never sees, stores or logs it. + S-->>B: 6. Set-Cookie: session (HttpOnly, SameSite) + + B->>S: 7. EventSource("/resource-stream/v1?…")
carries the cookie, and nothing else + S->>S: 8. Principal(r) — cookie → session → this user + their token + S->>K: 9. ClientFor(ctx, target, principal) — a client bearing THEIR token + K-->>S: 10. watch … or 403, if their RBAC says no + S-->>B: 11. reset · added · synced · … +``` + +The browser authenticates to **your** server. Your server custodies the token. The SSE request +carries **nothing but a same-origin `HttpOnly` cookie** — no token in JavaScript, no token in a URL, +nothing an XSS can read. + +Then step 9 is the one that matters: **the upstream watch is opened as the user.** If they may not +watch Secrets in that namespace, the API server refuses. No bug in this library can change that. + +> A fetch-based reader (`connectResourceStream`) can send an `Authorization` header for a deliberate +> token-bearing client. The cookie route above is the safer default for browser applications. + +## The three seams, and what each is for + +```go +gateway.Handler(gateway.Options{ + // WHO is calling? Your cookie → your session → your user. Opaque to us. + Principal: func(r *http.Request) (gateway.Principal, error) { return sessionUser(r) }, + + // MAY they? Checked BEFORE any watch opens, and again on every snapshot cycle. + Authorizer: myAuthz, + + // Reach the cluster AS them — their token, their RBAC, their audit trail. + Clients: func(_ context.Context, target string, p gateway.Principal) (gateway.Backend, error) { + return kube.NewBackend(dynamicClientBearing(p.(*User).Token)), nil + }, + Scopes: myScopePolicy, +}) +``` + +| seam | what it is | what it is **not** | +|---|---|---| +| `Principal` | whatever your session says the caller is. The library treats it as opaque (`any`), and never inspects, persists or logs it | not a credential *we* manage | +| `Authorizer` | **fail-fast, defence in depth.** Denies *before* the watch opens, so the existence of an object is never leaked to someone who may not see it | **not the boundary** — see below | +| `ClientFor` | the boundary. It hands back a client acting **as the caller**, so Kubernetes' own RBAC enforces | not a place to put a privileged god-client (unless you have read the sharing section) | + +The gateway holds **no privileged client of its own**. It therefore cannot bypass RBAC even if it had +a bug that wanted to — authorization is not something this library *does*, it is something it +structurally *cannot avoid delegating*. + +### Bearer token or impersonation? + +`ClientFor` supports both, and it is a real choice: + +- **The user's bearer token** — the blast radius is exactly that user's. Preferred. +- **Impersonation** (a service account sending `Impersonate-User`) — keeps Kubernetes as the boundary + just as well, but requires your server to hold impersonate rights, which is a large privilege whose + compromise is total. + +## Long streams, short tokens + +An SSE stream lives as long as an open dashboard tab — **hours**. An OIDC access token lives 5–60 +minutes. So the credential you captured when the stream opened is *not* one you may keep using. + +The gateway therefore **re-authorizes on every snapshot cycle**, and re-invokes `ClientFor` there +too: + +- **Revocation is noticed.** Take a user's access away and their open stream ends with a **terminal + `FORBIDDEN`**. Terminal matters: `EventSource` reconnects on its own, so a non-terminal refusal + would leave a revoked user hammering a forbidden scope forever. +- **`ClientFor` is your refresh point.** It is called again each cycle, so you can hand back a client + bearing a *fresh* token. + +**The gap:** a perfectly quiet stream may not cycle for a long time, so revocation is +noticed at the *next cycle*, not instantly. The credential half of that is solved properly on your +side of the seam — give the client a **refreshing token source** (Dex issues a refresh token; the +standard `oauth2.TokenSource` wraps it), and it never hands us a dead token in the first place. + +## Two things that are easy to confuse + +**A projection is not authorization.** Redaction is a *tighter disclosure layer on top of* RBAC: a +user who is fully entitled to read a Secret still does not get its value in a browser. It must +**never** be relied on to hide something the caller could not have read anyway — that is Kubernetes' +job. Confusing the two is how you end up with a "secure" viewer whose only protection is a mask. + +**Sharing a watch moves the boundary — so give it back.** `SharedBackend` opens one upstream watch per +scope, so it opens it **once**, so it opens it as **one identity** — your service account. At that +moment your `Authorizer` stops being defence in depth and becomes *the only thing* between a caller +and the objects. That is why it is opt-in, and why it is not the default. + +If you turn it on, use **`kube.SSARAuthorizer`**, and Kubernetes is the boundary again: + +```go +shared := gateway.NewSharedBackend(serviceAccountBackend) // one watch, one identity… +opts.Authorizer = kube.SSARAuthorizer(clientset, subjectOf) // …but RBAC still decides +opts.Clients = func(context.Context, string, gateway.Principal) (gateway.Backend, error) { return shared, nil } +``` + +Before a subscriber is served from the shared cache, it asks the API server — with a +`SubjectAccessReview` — *"may this user `list` and `watch` this resource, in this namespace?"* — and +lets it answer. `subjectOf` is yours: it maps your opaque `Principal` onto the Kubernetes user and +groups that RBAC binds against (the OIDC `username` and `groups` claims). + +Three things it does that are easy to get accidentally permissive, all tested: + +- it asks about **both `list` and `watch`**. A snapshot cycle is a list *then* a watch — literally so + on the list-then-watch path — so a caller who may watch but not list could otherwise be handed, in + the snapshot, exactly the objects RBAC refused to let them enumerate; +- a review it could not **complete** is not an allow. If the API server cannot say whether you may + look, the answer is no; +- an explicit `Denied` wins over an `Allowed`. + +It needs your server's service account to hold `create` on `subjectaccessreviews` (the standard +`system:auth-delegator` role). It does **not** need impersonate rights: it asks a question *about* a +user, it does not act *as* one. And because the gateway re-authorizes every snapshot cycle, this is +also how a revocation reaches a stream that is already open. + +## What this library never does + +- It never **mints, refreshes, stores, inspects or logs** a credential. +- It never accepts an API-server address, endpoint or credential **from the caller** — such a query + parameter is *refused*, not ignored (spec §8.1). +- It never **writes**. Saves go through the Kubernetes API from your own handler ([spec §3](../spec/v1.md)) — + and *your* save endpoint carries the duty to refuse a patch touching a redacted path, because a + mask written back would overwrite the real Secret. + +## Save boundary + +Projection is not authorization, and it is not a write policy. The host owns its save endpoint and +must validate the active projection before sending a Kubernetes patch. Use +`gateway.ValidateMergePatch` to reject redacted and projection-removed paths, then apply a narrow +merge patch as the authenticated user. See [saving.md](saving.md). diff --git a/docs/client-state-model.md b/docs/client-state-model.md index 390e657..622ff08 100644 --- a/docs/client-state-model.md +++ b/docs/client-state-model.md @@ -1,410 +1,94 @@ -# Live KRM reconcile engine — requirements & design spec +# Client state model -> **Status:** specification, pre-implementation. Written to seed a **separate repository**; it lives -> here because gitops-api's console (`cmd/server/console_body.html`) is the code that motivated it and -> the first consumer. Working name: **`krm-live`** (final name TBD — Open questions). -> -> **One line:** a headless, framework-agnostic engine for **live-editing and live-watching Kubernetes -> objects in the browser** — a thin, honest layer over the **Kubernetes Resource Model (KRM)**. It -> does a **deep three-way merge** of your edits to `spec`/`metadata` against a live watch stream, -> tracks dirty state and conflicts, builds a merge patch — and streams read-only `status` so you can -> watch an object actually reconcile. -> -> **This is not domain-agnostic, on purpose.** The value is *being* a KRM UI layer. `spec`, `status`, -> `metadata`, "three-way merge" are surfaced, not hidden behind generic "sections" — that vocabulary -> is the product and the reason people find it. -> -> **Part of a family:** [resource-stream-protocol](../spec/v1.md) is the wire contract -> this engine consumes; [resource-stream-gateway](../gateway/README.md) is the server that -> produces it. This engine knows nothing about the transport — it consumes parsed KRM objects. +`LiveResourceStore` is a headless TypeScript store for a KRM resource stream. It separates the +authoritative object received from Kubernetes from the user's local draft, so a live update never +silently overwrites an edit. -## The two use cases (why the read/write split is the design) +## State per resource -1. **Live status watch (read).** Open an object; watch its `status` — conditions, replicas, - phase — change in real time as controllers reconcile it. `status` is controller-owned and - **read-only**: there is no draft, no merge, no conflict; the engine just follows the server and - **highlights what moved**. This is the headline demo — *see Kubernetes reconcile*. -2. **Live spec edit (write).** Edit desired state (`spec`, labels, annotations) while the object - changes underneath you; the engine **deep three-way merges** so an unrelated change never clobbers - your edit and a real one flags a **conflict**; save emits a merge patch. - -Both are the same object, the same stream, the same engine — differing only in **which regions are -editable**. A pure status viewer is "everything read-only"; the console is "`spec`/`metadata` -editable, `status` read-only." One engine, configured. - ---- - -## 1. Why this exists - -A browser edits an object while a **watch stream** delivers server changes to it live. Naive -last-write-wins clobbers the user's typing; naive two-way compare (draft vs incoming) false-conflicts -every edited field on any event. The correct model is a **three-way merge**, VCS-style: - -| merge term | KRM | -|---|---| -| **base** | the previous server version we last reconciled from | -| **ours** | the user's working draft of the editable regions | -| **theirs** | the incoming server version from the watch | - -Only a field the server *actually* changed (base ≠ theirs) is reconciled; an untouched field is left -alone. And unlike a flat form, KRM `spec`/`status` are **arbitrarily nested JSON**, so the merge must -recurse through objects and arrays — not just compare string maps. - -This engine is small but subtle. Its first inline incarnation shipped **three real bugs** in three -days, each now a hard requirement: - -1. A fragile in-band string separator for composite keys was corrupted (null byte vs space), so - conflict lookups silently never matched. → **R-ID**: field identity is collision-proof (and - paths are segment arrays, not dot-strings — the bug voter still has). -2. A cached `dirty` set drifted out of sync after a watch event. → **R-DERIVED**: dirtiness is always - derived from a live comparison, never stored. -3. Reconcile compared only draft vs incoming — no **base** — so it could not tell "server changed - this" from "user edited this," and false-conflicted on every event. → **R-THREEWAY**: the base is - mandatory. - ---- - -## 2. The KRM object model - -Every object is a **KRMObject** — the shape the [protocol](../spec/v1.md) delivers and -the save path accepts back. It is real KRM, named as such: - -```jsonc -{ - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": { - "uid": "9f1c…", // identity; the engine keys resources on it (per stream target) - "name": "web", - "namespace": "shop", - "resourceVersion": "12345", - "labels": { "app.kubernetes.io/name": "web" }, // keys contain dots AND slashes — see R-ID - "annotations": { "note": "keep" } - // creationTimestamp, generation, ownerReferences, … may be present; read-only - }, - "spec": { /* free-form JSON; shape depends on kind+version; desired state; EDITABLE */ }, - "status": { /* free-form JSON; shape depends on kind+version; actual state; READ-ONLY */ } - // plus the ugly exceptions (§2.2), and any custom root field a CRD defines -} -``` - -It is a **complete projected object**, not a schema: a `ConfigMap` has no `spec` and no `status`, a CRD -may define custom root fields, and the gateway may have removed or redacted paths (protocol §3). The -engine must therefore never assume a member exists — and must never re-add one it never saw (§2.3). - -- **`id` = `metadata.uid`.** Stable, unique; the engine keys resources on it. `name` can change - meaning across recreations; `uid` cannot. A uid is unique **within one stream target**, so a host - combining several streams keys on `(target, uid)` or keeps one engine per stream - ([protocol §1](resource-stream-protocol.md#1-identity-target--uid)). -- **`spec`** — desired state, free-form nested JSON, **editable**. -- **`status`** — actual/observed state, free-form nested JSON, **controller-owned → read-only**. This - is what the live-status-watch use case renders. (Editing `status` needs the `/status` subresource - and is out of the default editable set; a specialized tool can opt in — §3.) -- **`metadata.labels` / `metadata.annotations`** — editable string maps. Everything else in - `metadata` (name, uid, resourceVersion, timestamps, ownerRefs, managedFields) is read-only. - -### 2.2 The ugly exceptions — named, not hidden - -A few kinds carry editable payload at the **top level**, outside `spec`: - -- **`ConfigMap.data`** (`map`) and **`ConfigMap.binaryData`** (`map`). -- **`Secret.data`** (`map`), **`Secret.stringData`** (write-only convenience), and - `type`. - -The model treats these as declared **top-level editable maps** for those kinds, not as a special -case bolted onto `spec`. The engine must not assume every editable region lives under `spec`. - -**Secrets need a policy (flag, not solved here).** `Secret.data` values are sensitive. The *gateway* -decides whether to stream values at all (mask / omit / require elevated auth — see gateway spec); the -engine simply renders whatever regions it is given and marks binary payloads (`binaryData`, -non-UTF-8) as "N bytes, not text-editable" rather than corrupting them through a text input. - -### 2.3 Two layers: replace the server object, three-way merge the draft - -The protocol says the stream reducer **replaces** the authoritative server object for a uid — never -deep-merges it — because a generic deep merge of complete objects resurrects fields the server just -removed ([protocol §4.1](resource-stream-protocol.md#41-added--modified--replacement-not-merge)). The -engine is where the *other* half lives. Both layers are inside `applyServerEvent`, and conflating them -is a bug: - -| layer | operation | +| Value | Meaning | |---|---| -| **server truth** (`server(id)`, the merge `base`) | **replaced wholesale** by each incoming object. A key absent from the incoming object is *gone*, not retained. | -| **draft** (`draft(id)`) | **three-way reconciled** (§4) from the previous base, the user's edits, and the new object. | - -So `I-BASESHIFT` is a *replacement*, not a merge — and `prune()` in §4 exists precisely so a key the -server dropped does not survive in the draft either (unless the user is actively editing it, which is a -conflict, not a ghost). +| `server(id)` | The latest complete projected object. Every stream update replaces it. | +| `draft(id)` | The object rendered and edited by the UI. Editable regions are reconciled with server changes. | +| `conflicts(id)` | Server values that changed concurrently with a different local edit. | +| `redactions(id)` | Paths known to exist upstream but intentionally withheld by the selected projection. | -**Redacted paths never enter a patch.** Every `added`/`modified` carries `redactedPaths` (RFC 6901 -pointers — protocol §3). The engine marks those paths **read-only and non-dirtiable**: the mask is -displayed but can never become a draft value, and `patch(id)` MUST NOT contain a redacted path. A -gateway that receives one rejects the whole save, so this is a correctness requirement, not a courtesy. -Likewise, a path the projection *removed* is simply not in the object — the engine must not interpret -its absence as a deletion to be patched (`patch(id)` is built from the user's edits, never from a diff -against a full object). +The resource key is `metadata.uid`, not name. A delete followed by a recreate with the same name is a +new resource with a new draft. ---- +## Stream lifecycle -## 3. Editable vs read-only regions +Use `applyStreamEvent` or the transport helpers instead of translating protocol events in UI code. -The object is partitioned by an **editability policy** (defaults below, overridable per kind): +- `reset` starts a snapshot and marks existing resources unseen. +- `added` and `modified` both replace the server object and reconcile the draft. +- `deleted` removes the resource and its draft. +- `synced` prunes resources that were not seen during the completed snapshot. +- `error` is terminal only when the event says it is terminal. `RESYNC_REQUIRED` starts a new + snapshot on the same connection. -| region | default | in the merge? | -|---|---|---| -| `metadata.labels`, `metadata.annotations` | **editable** | full three-way | -| `spec` (whole subtree) | **editable** | full three-way | -| `data` / `stringData` (CM/Secret) | **editable** | full three-way | -| `status` (whole subtree) | **read-only** | live-follow: take server, flash change, never dirty/conflict | -| rest of `metadata`, `apiVersion`, `kind`, `binaryData` | **read-only** | live-follow (display only) | +An incomplete snapshot never prunes state. That prevents a transient disconnect from making a UI lose +objects it has not yet reloaded. -**Read-only ≠ ignored.** A read-only region still updates live and highlights what changed — that is -the entire status-watch use case. It simply never grows a draft, dirty flag, or conflict, and never -appears in a patch. The policy can also mark *parts* of `spec` read-only (e.g. immutable fields like -`spec.clusterIP`) so the UI shows them without offering to edit. +## Editing and conflicts ---- +The default editable regions are `spec`, `metadata.labels`, `metadata.annotations`, `data`, and +`stringData`. `status`, immutable metadata, and redacted paths are read-only. -## 4. The deep three-way reconcile +When a new server object arrives, the store compares three values at each editable path: -`applyServerEvent(incoming)` for one resource. Let `base` be the resource's current server snapshot -(unknown resource ⇒ seed base = draft = incoming, emit `added`). Otherwise reconcile each **editable -region** with the recursive merge below, **follow the server** for each read-only region (deep-replace -+ flash changed paths), then advance `base = incoming`. - -The recursive merge (this is voter's `reconcileValue`, corrected for KRM), at a `path` (a segment -array — §5), given `base`, `ours` (draft), `theirs` (incoming): - -``` -reconcile(path, base, ours, theirs): - # arrays — see the associative-list caveat (§4.1) - if isArray(base) or isArray(theirs) or isArray(ours): - if isDirty(path) or len(base) != len(theirs) or len(ours) != len(base): - return preserveOrConflict(path, ours, base, theirs) # treat the whole array atomically - return theirs.map((t,i) => reconcile(path+[i], base[i], ours[i], t)) - - # objects — recurse over the union of keys - if isObject(base) or isObject(theirs) or isObject(ours): - result = {} - for key in union(keys(base), keys(theirs), keys(ours)): # key may contain '.', '/', anything - result[key] = reconcile(path+[key], base[key], ours[key], theirs[key]) - return prune(result) # drop keys that ended up undefined +| Base | Draft | Incoming server | Result | +|---|---|---|---| +| unchanged | any | changed | follow the server | +| changed | local edit | unchanged | keep the draft | +| changed | same value | same value | converge and clear conflict | +| changed | different value | different value | keep the draft and record a conflict | - # scalars (string/number/bool/null/undefined) - if deepEqual(base, theirs): return clone(ours) # server didn't move it → keep the draft - if isDirty(path): # ours diverged from base (an edit) - if deepEqual(ours, theirs): { clearConflict(path); return clone(ours) } # converged - setConflict(path, theirs); return clone(ours) # real conflict; keep the edit - clearConflict(path); flash(path); return clone(theirs) # untouched → follow the server +`isDirty` and `changes` are derived from `draft` versus `server`; neither is a cache that can drift +after a stream update. `revert` or `takeTheirs` restores the current server value. -preserveOrConflict(path, ours, base, theirs): - if deepEqual(ours, theirs): { clearConflict(path); return clone(ours) } - setConflict(path, theirs); return clone(ours) # user's array wins until resolved -``` +## Patches and redactions -- **`isDirty(path)` is DERIVED**: `!deepEqual(get(draft, path), get(base, path))`. Never a cached set. -- **`base` advances every event** (I-BASESHIFT), so the next merge is against *this* version. -- **`deepEqual`** must be **key-order-independent** (recursive structural compare) — *not* - `JSON.stringify` equality, which voter uses and which false-negatives on reordered keys. -- **Read-only regions skip all of this**: set draft = server, flash any changed path, done. +`patch(id)` returns an RFC 7386 JSON merge patch over editable changes, or `null` when there are no +changes. It never diffs the full projected object, so a field absent because of a projection cannot +accidentally become a deletion. -### 4.1 The array problem (a real KRM wrinkle — flagged, not hand-waved) +Redacted paths are not placeholders and do not appear in the object. Render a withheld value using +`redactions(id)`, and do not offer an editor for it. The host save endpoint must also call +[`gateway.ValidateMergePatch`](../gateway/patch.go) before writing to Kubernetes. -Index-based array merge is fragile: an element inserted at the front shifts every index, so a -positional merge mis-aligns. The algorithm above degrades safely — **any dirty or length change makes -the whole array atomic** (voter's fallback) — which is correct but coarse (your one-element edit -conflicts with the server's unrelated append). The *right* answer for Kubernetes is a **keyed merge** -for associative lists: k8s marks them with `x-kubernetes-list-type: map` + -`x-kubernetes-list-map-keys` (e.g. containers by `name`, ports by `containerPort`), which is how -server-side apply merges lists. **Recommendation:** ship atomic-on-change first; add keyed-list merge -driven by the OpenAPI/CRD schema as a follow-up. This is the main open design question (§10). Note -that RFC 7386 merge patch also replaces arrays wholesale, so atomic arrays and the patch format agree -out of the box. +## Arrays and associative lists ---- +Arrays are atomic by default. A concurrent array change conflicts with a local array edit, which is +safe for ordinary JSON arrays where position is not stable. -## 5. Field identity — segment-array paths, never dot-strings (R-ID) +Kubernetes associative lists can merge by identity when the host provides the structural OpenAPI +schema for the exact GroupVersionKind: -A field is `(resourceId, path)` where **`path` is an array of segments**: object keys (arbitrary -strings) and array indices (integers). Examples: +```ts +import { defaultPolicy, LiveResourceStore, withOpenAPIKeyedLists } from "@configbutler/krm-stream"; +const store = new LiveResourceStore(withOpenAPIKeyedLists(defaultPolicy, deploymentSchema)); ``` -["spec", "replicas"] -["spec", "template", "spec", "containers", 0, "image"] -["metadata", "labels", "app.kubernetes.io/name"] # ONE key segment, containing dots and a slash -``` - -Dot-joining (`spec.replicas`, `metadata.labels.app.kubernetes.io/name`) is **forbidden**: label and -annotation keys contain `.` and `/`, so a dotted path is ambiguous and silently wrong — this is the -bug voter's `path.split('.')` has. Where a scalar key is needed (the `conflict`/`flash` maps), encode -the path *structurally* — `JSON.stringify(pathArray)` — never by concatenation with a magic -separator (the null-byte lesson). Prefer nested `Map`s keyed by resource → segment where practical. - ---- - -## 6. Public API (headless, KRM-aware) -JSDoc-typed pseudocode; the new repo finalizes it. The engine holds state and exposes KRM-shaped -queries + path-addressed edits, and emits a coarse "changed" signal so the host re-renders. +`withOpenAPIKeyedLists` recognizes `x-kubernetes-list-type: map` with +`x-kubernetes-list-map-keys`. It preserves an edit to one keyed item across server reorders and +unrelated keyed-item updates. Missing keys, duplicate keys, malformed lists, and unannotated arrays +retain the atomic behavior. Merge patches still send the final array as one RFC 7386 value. -```js -/** @typedef {{ apiVersion:string, kind:string, - * metadata:{ uid:string, name:string, namespace?:string, resourceVersion?:string, - * labels?:Record, annotations?:Record }, - * spec?:any, status?:any, data?:Record, stringData?:Record, - * binaryData?:Record }} KRMObject */ +## UI integration -/** @typedef {(string|number)[]} Path */ -/** @typedef {{ path:Path, kind:'add'|'update'|'delete', old:any, new:any }} Change */ -/** @typedef {{ path:Path, theirs:any }} Conflict */ +The store has no rendering dependency. Subscribe once, then query `draft`, `status`, `changes`, +`conflicts`, and `redactions` during rendering: -createEngine(policy?: EditabilityPolicy, opts?: { deepEqual?, isBinary? }): Engine +```ts +const unsubscribe = store.subscribe(() => render(store)); +store.setValue(uid, ["spec", "replicas"], 3); -interface Engine { - // stream in — the four protocol events, and nothing else - applyServerEvent(obj: KRMObject, opts?: { redactedPaths?: Path[] }) // ← added / modified (REPLACES base, §2.3) - : { added?:boolean, structural:boolean, flashed:Path[], conflicts:Path[] } - removeResource(id): void // ← deleted (identity.uid; a tombstone carries no object) - beginSnapshot(): void; endSnapshot(): void // ← reset / synced. MAY repeat many times on one - // connection; prune ONLY in endSnapshot (protocol §5) - adoptSaved(obj: KRMObject): void - - // edits (editable regions only; a write to a read-only path is rejected) - setValue(id, path: Path, value): void - removeKey(id, path: Path): void // for map entries / spec object keys - addKey(id, path: Path, key: string, value?): void - renameKey(id, path: Path, oldKey, newKey): void // label/annotation/data key rename - revert(id, path: Path): void; takeTheirs = revert - - // queries (all derived) - ids(): string[] - server(id): KRMObject // truth (incl. live status) - draft(id): KRMObject // editable regions merged over server - status(id): any // read-only convenience for the watch UI - changes(id): Change[] - isDirty(id, path: Path): boolean - conflicts(id): Conflict[] - isEditable(id, path: Path): boolean // from the policy - patch(id): object | null // JSON merge patch of editable changes - subscribe(cb): () => void -} +const patch = store.patch(uid); +if (patch) await save(patch); ``` -**Patch** (`patch(id)`): a JSON merge patch (RFC 7386) over the *editable* regions only — nested -`spec` subtrees merge recursively, deleted keys are `null`, changed arrays are sent whole (§4.1). -`status` and read-only metadata never appear. For k8s, the host `PATCH`es with -`application/merge-patch+json`; a future strategic-merge / SSA mode is an option for better list -handling. - ---- - -## 7. Invariants (property-test targets) - -- **I-REPLACE** — the server object (`server(id)`, the merge base) is **replaced** by each incoming - object, never merged into: a key the server removed must not survive in `server(id)` (§2.3). -- **I-REDACT** — a path in `redactedPaths` is read-only, never dirty, never conflicted, and never - appears in `patch(id)`; the placeholder can never become a draft value (§2.3). -- **I-DERIVED** — `isDirty(f)` ⟺ `!deepEqual(get(server,f), get(draft,f))`; no cached dirty state. -- **I-THREEWAY** — reconcile uses base = previous server; removing the base term must fail a test. -- **I-DEEP** — the merge recurses through nested objects and arrays; a change deep in `spec` reconciles - field-precisely, not by replacing the whole `spec`. -- **I-READONLY** — a read-only region (`status`, immutable metadata) always equals the server, always - flashes on change, and *never* becomes dirty, conflicted, or part of a patch. Edits to it are refused. -- **I-NONINTERFERE** — a server change to path X never disturbs or conflicts an edit at a disjoint path Y. -- **I-NOFALSE** — if an event does not change path X, an edit at X stays dirty but not conflicted. -- **I-CONVERGE** — if the draft equals the incoming server value at a path, it ends clean and unconflicted. -- **I-IDEMPOTENT** — `apply(v); apply(v)` ≡ `apply(v)` (harmless watch redelivery / own-save echo). -- **I-BASESHIFT** — `apply(v1); apply(v2)` reconciles v2 against v1. -- **I-KEYSAFE** — paths whose segments contain `.`, `/`, array-index-looking strings, or the map's - encoding separator behave correctly (segment arrays, structural key encoding). -- **I-ORDER-EQ** — `deepEqual` is key-order-independent (a reordered-but-equal object is not a change). -- **I-PATCH-ROUNDTRIP** — applying `patch(id)` (RFC 7386) to the server object yields the draft's - editable regions; unchanged fields absent; deletions `null`; arrays whole. -- **I-RESYNC** — `beginSnapshot()` → replay `applyServerEvent` → `endSnapshot()` prunes resources - absent from the snapshot and three-way-reconciles survivors (preserving in-flight drafts). This is - the consumer side of the protocol's - [`reset`…`synced`](resource-stream-protocol.md#5-snapshot-cycles--reset--synced). Two corollaries the - protocol makes explicit and the engine must honour: a snapshot cycle **may occur many times on one - connection** (an upstream relist, not just a reconnect), and a cycle that never reaches - `endSnapshot()` **prunes nothing** — an abandoned `beginSnapshot()` must leave prior state intact. - ---- - -## 8. Behavioral requirements — the test matrix - -`{…}` is JSON; `L/A` = labels/annotations. Assert resulting draft, dirty, conflict, flash. - -| # | Given (base, draft) | When (incoming / action) | Then | -|---|---|---|---| -| 1 | status `{replicas:1}` (read-only) | server → status `{replicas:3}` | draft.status `{replicas:3}`, **flash**, not dirty, not conflicted (I-READONLY) | -| 2 | spec `{replicas:1,image:"a"}`, user edits `replicas→5` | server → spec `{replicas:1,image:"b"}` (image changed) | draft `{replicas:5,image:"b"}`; replicas dirty, no conflict; image flashed (I-NONINTERFERE) | -| 3 | spec `{replicas:1}`, edited `replicas→5` | server → spec `{replicas:1}` (unchanged) | replicas dirty, **no conflict** (I-NOFALSE) | -| 4 | spec `{replicas:1}`, edited `replicas→5` | server → spec `{replicas:9}` | replicas **conflict** theirs=9, draft keeps 5 | -| 5 | spec `{replicas:1}`, edited `replicas→9` | server → spec `{replicas:9}` | not dirty, no conflict (I-CONVERGE) | -| 6 | nested spec `{a:{b:{c:1}}}`, edited `a.b.c→2` | server → `{a:{b:{c:1,d:9}}}` (adds sibling d) | draft `{a:{b:{c:2,d:9}}}`; c dirty, no conflict; d flashed (I-DEEP, I-NONINTERFERE) | -| 7 | spec array `ports:[80]`, untouched | server → `ports:[80,443]` (append) | draft `ports:[80,443]`, flashed | -| 8 | spec array `ports:[80]`, user edited `ports[0]→8080` | server → `ports:[80,443]` (len change) | whole-array **conflict** theirs=`[80,443]`, draft keeps `[8080]` (§4.1 atomic) | -| 9 | L `{app.kubernetes.io/name:"web"}` edited `→"api"` | server unchanged | dirty on the dotted key; patch `{metadata:{labels:{"app.kubernetes.io/name":"api"}}}` (I-KEYSAFE) | -| 10 | any editable | user edits a `status` path | rejected (I-READONLY) | -| 11 | `apply(v)` then `apply(v)` | — | second is a no-op (I-IDEMPOTENT) | -| 12 | base spec `{r:1}` | `apply({r:2})` then `apply({r:3})` | reconciles 3 vs 2 (I-BASESHIFT) | -| 13 | data `{A:"1"}`, rename `A→z` | — | draft `{z:"1"}`, no dup, patch `{data:{A:null,z:"1"}}` (I-KEYSAFE, rename) | -| 14 | object `{a:1,b:2}` in draft | server sends `{b:2,a:1}` (reordered) | not a change; no flash (I-ORDER-EQ) | -| 15 | ConfigMap, no spec/status, has `data` | edit a data key | works; patch under `data`, not `spec` (exceptions) | -| 16 | binaryData / non-UTF-8 value | render + reconcile | shown as "N bytes", not text-edited/corrupted | -| 17 | two objects; snapshot replays one | begin/apply/end | the other is pruned; survivor three-way reconciled (I-RESYNC) | -| 18 | save built for edited spec | server echoes the saved spec | no phantom conflict (I-IDEMPOTENT) | -| 19 | server spec `{a:1,b:2}`, user untouched | server → spec `{a:1}` (b **removed**) | `server(id).spec` = `{a:1}`; **no ghost `b`** in server or draft (I-REPLACE) | -| 20 | Secret, `redactedPaths:["/data/password"]`, user edits a label | build `patch(id)` | patch contains the label only; `/data/password` absent; the mask never became a value (I-REDACT) | -| 21 | user tries to edit a redacted path | `setValue` on `/data/password` | rejected, like any read-only path (I-REDACT, I-READONLY) | -| 22 | `beginSnapshot()`, one `applyServerEvent`, **connection drops** (no `endSnapshot`) | — | **nothing pruned**; pre-snapshot state and in-flight drafts intact (I-RESYNC) | - -Add property tests: random nested base/draft/incoming triples asserting I-DEEP, I-CONVERGE, -I-IDEMPOTENT, I-NONINTERFERE, I-ORDER-EQ, I-PATCH-ROUNDTRIP. - ---- - -## 9. Implementation hints - -- **Headless & framework-agnostic** (UI-wise): no DOM/fetch/SSE. Feed KRM objects in, read state + - `patch` out, re-render on `subscribe`. Unit-testable in Node with no cluster and no jsdom. -- **KRM-explicit** (domain-wise): the API speaks `spec`/`status`/`metadata`/`data`, and the - editability policy is KRM-shaped. Don't abstract it into anonymous "sections." -- **Types without a build step:** plain ESM + JSDoc + `// @ts-check` (type-checked by `tsc --checkJs` - or the editor; the file is what ships to the browser). Real `.ts` is fine *in the new repo* — it - owns its build; the point of extracting is that the toolchain stops being gitops-api's problem. -- **Zero-dependency tests:** Node 18+ `node:test` + `node:assert`; `node --test`. (Verified: Node 22.) -- **Paths are segment arrays; keys encode structurally** (§5). Store per-resource state as nested - `Map`s; if a flat key is needed, `JSON.stringify(path)`. -- **`deepEqual` recursive + order-independent**, not `JSON.stringify` (fixes voter's I-ORDER-EQ gap). -- **`flash` is an output**, not stored state — return moved paths from `applyServerEvent`. -- **Editability policy is injected** — default (labels/annotations/spec/data editable; status + - immutable metadata read-only) plus per-kind/per-path overrides, ideally schema-driven later. - ---- - -## 10. How gitops-api consumes it, and the two surfaces - -- **Status watch** — a read-only page: open a stream for an object, engine with an all-read-only - policy, render `spec` + `status` and flash changes. No save path. The clearest demo of "Kubernetes - reconciling," and today under-served (voter edits spec but doesn't foreground status). -- **Spec/console edit** — today's inline logic in `cmd/server/console_body.html` - (`applyServer`/`changesFor`/`patchFor`) becomes a thin host over the engine: SSE → - `applyServerEvent`; input → `setValue`/`renameKey`/…; save → `POST` `engine.patch(id)`; `subscribe` - → re-render. The server's `resourceView` ([console.go](../../cmd/server/console.go)) grows from - ConfigMap-only to the full KRM shape (metadata + spec + status + data), matching the protocol. - ---- - -## 11. Open questions for the new repo - -- **Array/list merge** (§4.1): atomic-on-change first; schema-driven keyed-list merge - (`x-kubernetes-list-map-keys`) as the real fix. Biggest open design item. -- **Editability policy source:** static defaults vs OpenAPI/CRD-schema-driven (immutable fields, - associative lists, `x-kubernetes-` hints). Schema-driven unlocks keyed lists *and* read-only marking. -- **Patch format:** RFC 7386 merge patch (simple, arrays-whole) vs strategic-merge / server-side - apply (better lists, field ownership) — decide per target API capability. -- **Status editing:** default off; expose a `/status`-subresource path for admin tools? -- **Secret value handling** on the engine side once the gateway's policy is set (mask vs omit vs - elevated) — keep values opaque; never log. -- **Name**, and three-packages-one-repo vs three-repos. +Use `adoptSaved` with the object returned by a successful host save to clear local dirtiness before +the watch echo arrives. See [`packages/krm-stream/`](../packages/krm-stream/) for the public API and +[`conformance/`](../conformance/) for executable behavior examples. diff --git a/docs/extraction-plan.md b/docs/extraction-plan.md deleted file mode 100644 index 68673e0..0000000 --- a/docs/extraction-plan.md +++ /dev/null @@ -1,257 +0,0 @@ -# `krm-stream` — extraction plan - -> **Where this came from.** This repo was extracted from -> [ConfigButler/gitops-api](https://github.com/ConfigButler/gitops-api), whose browser console is both -> the code that motivated it and its first consumer. This is that extraction's plan and decision -> record — it lives here, next to what it decided, rather than in the repo it left. -> -> **Status:** Phases 0–1 **done** (2026-07-11) — this repo exists, and CI is green. What remains is the -> part that touches gitops-api: cutting its console over to these libraries. -> -> Name and packaging: [naming decision](naming.md) (settled — `krm-stream`, unscoped on npm). Specs -> this plan executes: [protocol](../spec/v1.md) · [client state model](client-state-model.md) · -> [gateway](../gateway/README.md). - ---- - -## 1. Why a monorepo (and not three repos) - -The three artifacts are joined by **one contract**. Split them across repos and the producer (Go -gateway) and the consumer (TS client) drift the first time someone edits the protocol — which is the -exact failure the protocol spec exists to prevent. - -One repo buys the thing that makes the contract enforceable: - -> **`conformance/` — shared fixtures.** KRM object bodies in YAML, plus scenarios that say what the -> gateway must *emit* and what the client must then *hold*. **Both suites load the same generated -> JSON.** A protocol change that breaks one side fails both, in the same commit, before it can ship. - -That is the whole justification. Everything else (one clone, one PR, one CI) is convenience. - -**It earned its keep on the first run.** The corpus failed immediately — and the bug was in the *Go -wire types*, not in the test: `redactedPaths` carried `omitempty`, which drops the empty array. That -is precisely the case the spec makes mandatory (a consumer must never *infer* redaction from a value -that merely looks like a placeholder). A fixture caught a spec violation before a single line of -gateway existed. That is the entire thesis, demonstrated on day one. - -**A correction to the original plan.** It assumed one fixture set that both suites could "load and -assert". That is impossible: a gateway is fed *watch ops* and produces *events*; a client is fed -*events* plus *edits* and produces a *draft*. So a fixture has three sections — `watch` (gateway -input), `events` (**the shared surface — the only genuinely shared part**), and `client` (edits + -expected outcome) — and each suite reads the parts that apply to it. They meet in the middle, at -`events`. Without that split, "both sides run the same fixtures" would have been theatre. - -**We do not lose independent releases.** The client publishes to npm as `krm-stream`; the gateway is a -nested Go module with its own tags (`gateway/vX.Y.Z`). Consumers depend on one or the other, never -"the monorepo." - -**Why now, and not "just keep it in gitops-api":** the reconcile engine shipped three bugs in three -days while living inline in an HTML file, unreachable by any test. The point of extraction is not -tidiness — it is that this logic must be *proven*, and it could not be proven where it lived. - ---- - -## 2. What moves, what stays - -The dividing line is one question: **does it know anything about gitops-api, ConfigButler, Dex, kcp, or -tenants?** If yes it stays. If it only knows *KRM*, it moves. - -| Today (gitops-api) | Fate | Why | -|---|---|---| -| the three design docs | **moved** → `spec/v1.md`, `docs/client-state-model.md`, `gateway/README.md` | canonical contract; must live with the fixtures that enforce it | -| the reconcile/merge JS **inside** `cmd/server/console_body.html` (`applyServer`, `changesFor`, `patchFor`, dirty/conflict) | **moves** (as a **rewrite**) → `packages/krm-stream/` | pure KRM logic. Rewritten test-first against the corpus — copying it would import its bug history. Its three bugs are now fixtures: R-THREEWAY, R-DERIVED, R-ID | -| `cmd/server/console.go` — watch→SSE loop, `configMapView` reduction, merge-patch apply | **moves** (as a **generalisation**) → `gateway/` | the mechanics are sound; generalise ConfigMap→dynamic client, and `configMapView`→a complete projected KRM object (spec + status verbatim) | -| **the rest of** `console_body.html` — CSS, DOM rendering, the save pane, the SSE wiring | **stays** — but see the ⚠ in §5 | this is the *host*: a UI, not an engine | -| `cmd/server/console.go` — session gate, `consoleContext`, `kcpTenantForOwner`, `firstSelectionNamespace`, the kcp front-proxy hop, `userWorkspaceClientset` | **stays** | Dex, kcp, Tenant/RepoSelection — gitops-api's private business. These become the *injected* bits the gateway library asks for | -| `cmd/server/wizard.go`, `main.go`, the operator, `internal/*` | **stays** | untouched; nothing to do with KRM streaming | - -**The dependency direction is one-way and must stay that way:** - -``` -gitops-api ──depends on──▶ krm-stream (client + gateway) -krm-stream ──depends on──▶ nothing of gitops-api's. Ever. -``` - -If the gateway ever needs to know "which namespace is this caller's," that is a missing interface — -inject it (`Authorizer`, `ClientFor(target, principal)`), don't import gitops-api. The rule is written -into the new repo's CONTRIBUTING.md, where it will actually be read. - ---- - -## 3. What was built (Phase 1, done) - -``` -krm-stream/ - README.md LICENSE CONTRIBUTING.md Taskfile.yml - .devcontainer/ Go 1.26, Node 22, Task, yq/jq, k3d, kubectl - .github/workflows/ci.yml fixtures-are-fresh · go test+lint · node --test + tsc - spec/ - v1.md the protocol (moved) - events.schema.json the same, machine-readable - conformance/ - bodies/ 12 KRM objects in YAML — ConfigMap, Deployment (with live status), Secret - fixtures/ 14 scenarios, each naming the rule it defends - gen/ generated JSON — BOTH suites read this - generate.sh - gateway/ Go → github.com/ConfigButler/krm-stream/gateway - event.go conformance.go *_test.go .golangci.yml go.mod - packages/krm-stream/ TS → npm `krm-stream` (unscoped) - src/types.ts test/ package.json tsconfig.json - examples/vanilla-browser/ the status-watch demo (deferred; see §5) - docs/ - client-state-model.md the merge algorithm (moved) - extraction-plan.md this document - naming.md why `krm-stream`, and why not `krm-live` -``` - -**Tooling: nothing new to install.** Node 22 runs `.ts` under `node --test` natively (type stripping), -so the client suite has **zero test dependencies**; `typescript` is its only devDependency. Verified by -running it, not assumed. - ---- - -## 4. Three hard constraints - -Non-negotiable, because of how gitops-api consumes the result: - -1. **The client emits plain, dependency-free ESM.** gitops-api has *no* bundler and is not getting - one. `tsc` emits an ES2022 module a browser can `import` via ` -``` +The page demonstrates live status updates, three-way conflict handling, draft edits, and redacted +Secret values. Useful fixtures include: -**Not built yet.** It lands once the client and the gateway are green against the conformance suite — -it is the proof, not the prototype. See [`CONTRIBUTING.md`](../../CONTRIBUTING.md) for the order of -work. +| Fixture | Demonstrates | +|---|---| +| `status-only-churn` | Status updates while an editable draft remains intact. | +| `conflict-and-converge` | A real conflict followed by server convergence. | +| `edit-vs-unrelated-change` | An unrelated server update preserves the local edit. | +| `secret-redaction` | Redacted values remain unavailable and read-only. | +| `named-object-absent` | An empty snapshot is a valid resource state. | -Why status-only, and why first: it is the one use case that needs *no* merge, *no* conflict model and -*no* write path — so it is the smallest honest demonstration that the stream is real. If watching a -controller reconcile in a browser is not compelling on its own, nothing built on top of it will be. +Add `pace=0ms` for the fastest replay or use a positive value to inspect each event. diff --git a/examples/vanilla-browser/index.html b/examples/vanilla-browser/index.html new file mode 100644 index 0000000..804486c --- /dev/null +++ b/examples/vanilla-browser/index.html @@ -0,0 +1,231 @@ + + + + + + krm-stream — live KRM + + + +

krm-stream

+

+ A live, honest window onto Kubernetes resources. connecting… +

+ +
+

status read-only · follows the server · flashes

+
+
+ +
+

spec / data editable · three-way merged

+
+
+ +
+

patch — RFC 7386, editable changes only

+
null
+
+ + + + diff --git a/examples/vanilla-browser/package-lock.json b/examples/vanilla-browser/package-lock.json new file mode 100644 index 0000000..6351650 --- /dev/null +++ b/examples/vanilla-browser/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "krm-stream-example-vanilla-browser", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "krm-stream-example-vanilla-browser", + "devDependencies": { + "@playwright/test": "^1.50.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/examples/vanilla-browser/package.json b/examples/vanilla-browser/package.json new file mode 100644 index 0000000..4451089 --- /dev/null +++ b/examples/vanilla-browser/package.json @@ -0,0 +1,12 @@ +{ + "name": "krm-stream-example-vanilla-browser", + "private": true, + "type": "module", + "//": "Playwright lives HERE, not in packages/krm-stream, so the library keeps its three devDependencies and its promise: the published bundle has zero dependencies and needs no bundler. This example is the only place that promise is actually TESTED — a real browser, a real EventSource, a real unbundled ESM import.", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.50.0" + } +} diff --git a/examples/vanilla-browser/playwright.config.ts b/examples/vanilla-browser/playwright.config.ts new file mode 100644 index 0000000..0739493 --- /dev/null +++ b/examples/vanilla-browser/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from "@playwright/test"; + +// The browser rung of the e2e ladder — and it needs no cluster. +// +// One process serves everything, from ONE origin: the replay gateway serves the scripted corpus as +// real SSE, the built ESM at /krm-stream/, and this example at /. Same-origin is not a convenience — +// it is the deployment the protocol is specified around (spec §7: native EventSource cannot send an +// Authorization header, so the v1 baseline is same-origin session cookies). A demo served from a +// second port would need CORS, and would then be proving something we do not ship. + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: !!process.env["CI"], + retries: process.env["CI"] ? 1 : 0, + reporter: process.env["CI"] ? "list" : [["list"]], + use: { + baseURL: "http://127.0.0.1:8100", + trace: "retain-on-failure", + }, + projects: [{ name: "chromium", use: { browserName: "chromium" } }], + + // Build the library first — `dist/` is what the browser imports, unbundled, and a stale dist is a + // test of last week's library. + // + // `npx --no-install`, and it is not a style preference. Bare `npx tsc` in a directory with no + // node_modules does not fail — it goes to the REGISTRY, downloads whatever is published under the + // name `tsc` (a squatter package, not TypeScript), and runs it. That is what this job did on its + // first-ever CI run, and it is a supply-chain hole that happened to announce itself by failing. + // --no-install makes a missing dependency loud instead of resolving it from the internet. Run + // `task e2e-browser`, which installs the library's devDependencies first. + webServer: { + command: + "cd ../../packages/krm-stream && npx --no-install tsc && " + + "cd ../../gateway && go run ./cmd/replay " + + "--addr 127.0.0.1:8100 --corpus ../conformance " + + "--static ../examples/vanilla-browser --dist ../packages/krm-stream/dist", + url: "http://127.0.0.1:8100/healthz", + reuseExistingServer: !process.env["CI"], + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/examples/vanilla-browser/tests/live-krm.spec.ts b/examples/vanilla-browser/tests/live-krm.spec.ts new file mode 100644 index 0000000..7df7ab1 --- /dev/null +++ b/examples/vanilla-browser/tests/live-krm.spec.ts @@ -0,0 +1,128 @@ +// The top of the ladder that needs no cluster: a REAL browser, a REAL EventSource, a REAL unbundled +// ESM import, against the REAL gateway. +// +// Everything below this rung feeds the store bytes. That proves the protocol and the merge, and it +// cannot prove any of the following — each of which breaks the product, and each of which is +// invisible to `node --test`: +// +// - the published ESM actually imports in a browser with NO BUNDLER (dist/index.js imports +// ./store.js imports ./merge.js…). Node importing it proves nothing: Node is not a browser. +// - native EventSource works at all — it is the same-origin cookie path, the v1 baseline (spec §7), +// and the only transport a plain