Skip to content

test(kube): compose the save path against a real API server - #58

Merged
sunib merged 2 commits into
feat/editor-recipesfrom
test/real-api-save-races
Oct 5, 2026
Merged

sunib merged 2 commits into
feat/editor-recipesfrom
test/real-api-save-races

Conversation

@sunib

@sunib sunib commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #57 (→ #55 → #54 → #53). This PR adds two commits; the PRs below it are unchanged. A third commit, the CI workflow, is not pushed yet: see Making it merge evidence.

Implements priority 3 of proposal 0006: real-API save composition and identity races.

Harness

The TestRealAPI cases (gateway/kube/composition_e2e_test.go, build tag e2e) compose:

  • a real API server;
  • the real gateway over the real kube backend;
  • a host save endpoint;
  • the real browser store and conditional editor, run under node by packages/krm-stream/e2e/store-driver.ts.

The Go side decides exactly when each stream event reaches the browser, so the races are staged on purpose rather than hoped for. No browser logic is reimplemented in Go.

Cases

0006 requirement Case What it asserts
Status churn, real status subresource TestRealAPIStatusChurnUnderSpecProjection A test-installed Widget CRD has a status subresource. Under krm-spec/v1: the status write advances RV (checked numerically); the gateway suppresses it (observation, and the stream stays quiet); the captured save gets a real 409; the guarded projected GET advances the base with no field conflicts (version-stale) and no status leak; a fresh intent saves; the controller's status survives; the echo settles the draft.
Contrast: full projection TestRealAPIStatusChangeIsDeliveredUnderFullProjection The same write is delivered, the base advances, and the save needs no refusal (PATCH 204 only).
Contrast: bookkeeping-only TestRealAPIBookkeepingOnlyWriteUnderFullProjection A new server-side-apply manager claiming an existing value changes only managedFields. It does advance RV, unlike a no-op reapply, which isn't a valid fixture. krm-full/v1 suppresses it, and the save goes 409 → version-stale → saved.
Overlapping recovery TestRealAPIOverlappingRecovery The save starts live and gets 409. The host holds the reconciliation GET until a closed upstream watch has begun a new snapshot (reset), and the read lands before synced. The guard refuses it (the base is still the delivered version), the draft is kept, and the editor reports recovering. After synced, Save does only a GET (version-stale), then one deliberate PATCH 204. There are no blind PATCH retries.
UID race TestRealAPIReplacementBetweenPreflightAndPatch A test-only transport barrier deletes and recreates the ConfigMap between the real conditionalsave handler's preflight GET and its PATCH. The response is a structured Kubernetes Status naming the replacement in details.uid. The replacement is unchanged, the editor reports unavailable, and the replacement opens with no old draft.
Never normalize 422 to conflicts TestRealAPIValidationErrorIsNotAConflict A real invalid-label 422 keeps its reason and metadata.labels causes, and the object is unchanged.

What the race revealed, and the fix (8d67d44)

Against a real API server, the replaced object's PATCH fails the captured resourceVersion check before the UID check. So it arrives as a plain 409 Conflict, indistinguishable from a stale write. Only when the RV matches does the uid precondition give 422 field is immutable, and that can't happen in a race. The example host also flattened every error to bare status text. Now:

  • Kubernetes errors are answered with the Status JSON, keeping code, reason and causes. A non-API failure is reported without its text.
  • After a 409, or a 422 caused by metadata.uid, one GET classifies the failure: a different UID becomes 409 "object was replaced" with details.uid; a vanished object becomes 404. Every other 422 passes through unchanged.
  • The example editor reports a PATCH 404 as unavailable instead of throwing a host error.

Fake-client unit tests cover each branch.

Making it merge evidence

  • task test-real-api runs the cases on the spike cluster. task test-cluster still runs everything tagged e2e.

  • Pending push: commit bc78e95 (ci: run the real-API save composition on pull requests and on demand) adds .github/workflows/real-api.yml and the doc lines that point at it. GitHub refused it from this machine because the token here lacks the workflow scope; it is on the local test/real-api-save-races branch, on top of what is pushed. The workflow:

    • runs on pull requests that touch the save path (gateway, client src, the driver, the conditional-save example, the workflow), and on demand;
    • starts k3d with the Taskfile's K3s image and embedded etcd;
    • records the commit, server version, command and each case in its summary, and uploads the JSON results.

    ci.yml and the release gate stay cluster-free. A workflow_dispatch-only workflow can't run before it exists on main, so the PR trigger is what lets it produce evidence for this PR. Until it is pushed, the evidence is the local run below.

Evidence from this run

  • Commit: 2268916cf8e08650de1468d5832e61b125de2391 (the pushed head; the 3× repeat, task test-cluster and task verify ran on a commit with identical code that also contained the workflow)
  • Server: v1.36.4+k3s1 (k3d krm-stream-spike)
  • Command: go test -tags e2e -count=1 -run '^TestRealAPI' -v . in gateway/kube

Results:

  • All 6 cases passed, and passed again in a 3× repeat: 18/18.
  • task test-cluster passed 43 tests with 0 failures and 0 skips, including the aggregated-API fallback and the shared-host example.
  • task verify passes: client 144/144, browser 20/20.

The cases were also checked by breaking the code on purpose:

  • restoring the old handler fails the UID-race and validation cases (plain-text bodies);
  • a store guard that ignores the open snapshot fails the overlapping-recovery case (base=<new RV>).

Not run: the CI workflow, pending the push above. Nothing was skipped.

🤖 Generated with Claude Code

sunib and others added 2 commits October 4, 2026 21:08
…e conditional-save host

Against a real API server, a ConfigMap deleted and recreated between the
host's preflight GET and its PATCH fails the captured resourceVersion
check first, so it arrives as an ordinary 409 Conflict. Only a UID check
tells a replacement from a stale write. The handler also flattened every
Kubernetes error to bare status text, losing reasons and field causes.

- Errors are answered with the Kubernetes Status as JSON, keeping its
  code, reason and causes; a non-API failure is reported without its
  text.
- After a PATCH 409, or a 422 whose cause is metadata.uid, one GET
  classifies it: a different UID answers 409 "object was replaced" with
  Details.UID naming the replacement, a deleted object answers 404, and
  anything else is forwarded as Kubernetes reported it. Every other 422
  stays a validation error, never a conflict.
- The example editor reports a PATCH 404 as unavailable rather than
  throwing a host error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Implement priority 3 of proposal 0006. The TestRealAPI cases run a
real API server, the real gateway over the real backend, a host save
endpoint and the real browser store and conditional editor. The browser
runs under node (packages/krm-stream/e2e/store-driver.ts), and Go
decides when each stream event reaches it, so races are staged on
purpose.

- Status churn under krm-spec/v1, using a real status subresource from a
  test-installed Widget CRD: the write advances resourceVersion, the
  gateway suppresses it, the captured save is refused with 409, the
  guarded projected GET advances the base without field conflicts, and
  a fresh intent saves while the controller's status survives.
- The contrasts: the same write is delivered under krm-full/v1, and a
  bookkeeping-only write (a new server-side-apply manager, which does
  advance the version, unlike a no-op reapply) is suppressed there.
- Overlapping recovery: the reconciliation read is held until a closed
  upstream watch has begun a new snapshot. The guard refuses it, the
  base and draft are unchanged, the editor reports recovering, and
  progress resumes with a read and then one deliberate write.
- The UID race: a transport barrier deletes and recreates the ConfigMap
  between the host's preflight GET and its PATCH. The host names the
  replacement in a structured Status, the replacement is untouched, the
  editor reports unavailable, and the replacement opens without the old
  draft. A real 422 keeps its causes and is never a conflict.

task test-real-api runs them on the spike cluster; the full suite stays
task test-cluster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 6d23becd-3c04-410d-8408-2ca97ac3b5a6
📥 Commits

Reviewing files that changed from the base of the PR and between 8c48eb2 and 2268916.

📒 Files selected for processing (12)
  • CONTRIBUTING.md
  • Taskfile.yml
  • docs/proposals/0006-stream-and-save-implementation-plan.md
  • docs/saving.md
  • examples/conditional-save/README.md
  • examples/conditional-save/editor.ts
  • gateway/kube/composition_e2e_test.go
  • gateway/kube/e2e_test.go
  • gateway/kube/examples/conditionalsave/handler.go
  • gateway/kube/examples/conditionalsave/handler_test.go
  • packages/krm-stream/e2e/store-driver.ts
  • packages/krm-stream/test/saving.test.ts
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sunib
sunib added this pull request to stack #56 October 5, 2026 08:54
@sunib
sunib merged commit 854f2e2 into main Oct 5, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant