Container Dispatch
Container Dispatch
Section titled “Container Dispatch”Status: Authoritative — describes how the Worker dispatches merge queue reconciliation to the Go Container via an authenticated internal path.
Cross-references:
docs/architecture/container-reconcile.md— Go container endpoint contractdocs/architecture/shunt-container-execution.md— broader Worker → Container orchestrationdocs/SECURITY.md— encryption model and threat model
Architecture
Section titled “Architecture”cron / webhook trigger │ ▼index.mjs (Worker) ├── fetch encrypted DEK + token from Supabase ├── call LeaseDO /internal/reconcile (POST, auth header) │ ▼LeaseDO (Durable Object) ├── validate X-Container-Auth header (CONTAINER_AUTH_SECRET) ├── acquire lease (atomic CAS with TTL) ├── decrypt token via encrypt.mjs (envelope decryption) ├── dispatch to ShuntContainer /dispatch (POST) │ │ │ ▼ │ ShuntContainer (DO) │ ├── get container via this.env.shunt_container │ ├── getTcpPort(8787) → Fetcher │ ├── POST http://localhost:8787/internal/reconcile │ │ │ │ │ ▼ │ │ Go Container (WASI) │ │ ├── validate X-Container-Auth (CONTAINER_AUTH_SECRET) │ │ ├── validate & parse ReconcileRequest │ │ ├── run engine.Reconcile() │ │ └── return {"status":"ok"} or {"status":"error","errors":[]} │ │ │ └── parse response, return {ok, status, errors} │ ├── release lease (finally) └── return safe summary fields (status, errors — no credentials)Auth model
Section titled “Auth model”Service-to-service auth uses a shared secret from Cloudflare Secrets.
| Component | How auth is provided |
|---|---|
| Worker (index.mjs) | env.CONTAINER_AUTH_SECRET → X-Container-Auth header |
| LeaseDO | this.env.CONTAINER_AUTH_SECRET → compares to request header |
| Go Container | os.Getenv("CONTAINER_AUTH_SECRET") → compares to header |
The secret is the same value injected as a Cloudflare Secret at deploy time. It is never logged, persisted, or returned in any response.
Why not ephemeral capability tokens?
Section titled “Why not ephemeral capability tokens?”A short-lived per-invocation capability would require key distribution (each DO must share a signing key with the container). The shared secret is simpler and equally secure in the Cloudflare Workers runtime: the DO and container are isolated from external access, and the secret never appears in logs or API responses.
Token decryption (envelope)
Section titled “Token decryption (envelope)”Tokens are decrypted in-memory via encrypt.mjs for exactly one tick:
Master key (Cloudflare Secret, 32 bytes, base64) ↓ AES-256-GCMPer-tenant DEK (base64 in Supabase) ↓ AES-256-GCMForge token (plaintext, in memory only)The decryptEnvelope function:
- Base64-decodes master key, encrypted DEK, DEK nonce, encrypted token, token nonce
- Validates master key length (32 bytes for AES-256, 24 for AES-192)
- Decrypts DEK with master key
- Decrypts token with DEK
- Returns plaintext token
The plaintext token exists only in the calling function’s scope. It is:
- Never logged
- Never persisted
- Never returned in API responses
- Only used for the single container dispatch request body
On engine or network failure, the token is garbage-collected with the function scope.
Lease protection
Section titled “Lease protection”Exactly-once semantics are provided by the existing LeaseDO lease mechanism:
- Acquire — atomic CAS: succeeds if no lease is held or lease is expired
- Dispatch — execute the container call
- Release — clear the lease (runs in
finallyblock)
If acquire fails (lease held by another tick), the response is 409 Conflict
with the holder’s identity. The cron job will retry on the next tick.
Request contract (Worker → Go Container)
Section titled “Request contract (Worker → Go Container)”The Go Container POST /internal/reconcile endpoint expects:
{ "tenant_id": "string", // required "repo_slug": "owner/name", // required "base_branch": "string", // required "queue_name": "string", // required (always "merge-queue") "instance_url": "https://…", // required, https-only "token": "string" // required, ephemeral decrypted token}Response:
{ "status": "ok", "errors": []}Or on error:
{ "status": "error", "errors": ["reconciliation failed"]}Safety boundaries
Section titled “Safety boundaries”- Body size capped at 1 KiB
DisallowUnknownFieldsrejects unknown JSON keys- Engine errors are genericised — no raw messages in response
- Token never appears in logs, errors, or responses
Removed fields (from legacy reconcile)
Section titled “Removed fields (from legacy reconcile)”The following fields were removed from the dispatch request body because they
are not consumed by the Go Container’s ReconcileRequest struct or engine
config:
owner— derived fromrepo_slugin the Go container (splitRepoSlug)statusCtx— hardcoded to"merge-queue"in the Go container’s engine configmergeStyle— not used by theReconcilepath (the Go container runs a stateless reconcile, not a merge decision)maxBatch— the Go container engine uses its own defaultsrepoId— not a field onReconcileRequest; tenant+repo are identified bytenant_id+repo_slug
These fields remain in the legacy POST /reconcile path but are not sent to
the Go Container via POST /internal/reconcile.
Failure handling (fail closed)
Section titled “Failure handling (fail closed)”| Failure mode | Action |
|---|---|
| Container unreachable (network error) | Return {ok:false, errors:["container unreachable"]} |
| Container returns non-200 | Parse response, return safe {ok, status, errors} |
| Container returns non-JSON | Return {ok:false, errors:["container unreachable"]} |
| Token decryption fails | Return {error: "decryption failed"} (500) |
| Auth header missing or wrong | Return 401 {error: "unauthorized"} |
| Lease already held | Return 409 {error: "locked", held_by: "..."} |
| Engine error in Go container | Go container returns {"status":"error","errors":["reconciliation failed"]} |
In all failure cases, the lease is released (via finally) and no partial
state is claimed. The cron job will retry on the next tick.
Files changed
Section titled “Files changed”| File | Change |
|---|---|
shunt-container.mjs |
Added dispatch() method — invokes container via getTcpPort(8787) |
encrypt.mjs |
Envelope decryption helper (decryptToken) — from PR #25 |
encrypt.test.mjs |
Tests for encrypt.mjs — from PR #25 |
lease-do.mjs |
Added POST /internal/reconcile endpoint — auth, lease, decrypt, dispatch |
index.mjs |
Cron updated — calls /internal/reconcile with auth header |
cmd/gondolier/container/main.go |
Added X-Container-Auth header validation |
shunt-container.test.mjs |
Updated tests for new dispatch method |
dispatch.test.mjs |
Created — tests for dispatch auth, lease contention, fail-closed, token leakage |
docs/architecture/container-dispatch.md |
Created — this document |
Deployment requirements
Section titled “Deployment requirements”- Add Cloudflare Secrets (one-time):
wrangler secret put CONTAINER_AUTH_SECRET # 32+ random bytes, base64(Envelope encryption master key CREDENTIAL_MASTER_KEY was configured in PR #25.)
-
Go container — The container reads
CONTAINER_AUTH_SECRETfrom its environment at runtime (injected by Cloudflare Containers from the same Cloudflare Secrets namespace). -
Deploy:
wrangler deployRollback
Section titled “Rollback”This change is fully backward-compatible:
- The existing
POST /reconcileendpoint is unchanged - The new
POST /internal/reconcileis an additional endpoint - The cron job switches to
/internal/reconcilebut falls back gracefully on 4xx/5xx - The Go container auth validation is opt-in (skipped when
CONTAINER_AUTH_SECRETis empty) - Reverting to the previous commit restores the old cron behavior