Implement get_deployment_payload and delete_deployment_payload operations#1898
Implement get_deployment_payload and delete_deployment_payload operations#1898kriszyp wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for retrieving and deleting deployment payloads. It adds two new operations: get_deployment_payload (which streams the stored tarball as raw bytes) and delete_deployment_payload (which deletes the payload while retaining metadata and event logs for auditing). It also updates the server handlers to bypass recompression for pre-compressed streams and forward stream errors to prevent worker crashes. The review feedback suggests using pipeline from node:stream instead of .pipe() when chaining streams in serverHandlers.js to ensure proper stream destruction and prevent resource leaks if a client disconnects.
|
Reviewed; no blockers found. |
recordCommitLatency() only uses commitResolution.then() for timing and never touches the resolved value, but its parameter was typed as Promise<void>. TS's control-flow narrowing widens an assignment of Promise<void> to a Promise<number | void> variable back to Promise<number | void> (the union member it's assignable to, not the literal assigned type), so passing that variable at the call site failed to typecheck. Widen the parameter to Promise<unknown>, which matches what the function actually needs, and drop the now-unnecessary `as Promise<void>` cast on the commit() call. Fixes the "Next.js adapter integration" CI failures on PR #1898 (all Node versions), which build harper fresh via `npm run build` and hit this compile error. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Reply to the Good catch, and confirmed the mechanism exactly: Fixed in 9ba73f1 with your first option: Thread marked resolved. — Claude (Opus 4.8) |
|
Reply to the claude-bot finding on Investigated by instrumenting the live code path rather than trusting either the finding or my own earlier smoke test at face value, since they seemed to disagree. Booted a scratch instance from this branch, deployed a payload, and logged
No code change from this thread. — Claude (Opus 4.8) |
…ions Both operations have been documented (and enum-declared) since 5.1 but never registered, so they returned "Operation not found" (#1893). - get_deployment_payload streams the stored tarball as raw bytes (never base64: V8's ~512MiB string cap makes large payloads hard-fail) via the existing Readable+headers pipe path, marked preCompressed since the payload is already a gzipped tar. - delete_deployment_payload nulls payload_blob on terminal-status rows, retaining row metadata/event_log as audit; the replicated null unlinks the blob file locally and on every peer (the #1495 reclaim mechanism). Idempotent; 409 on non-terminal rows whose blob may still be the replication source. - serverHandlers gzip pipe now forwards source-stream errors; an async blob read error previously fired 'error' with no listener and killed the whole worker via uncaughtException (also latent for get_backup). - MCP operations adapter fails cleanly (isError) on streaming results instead of JSON.stringify-ing stream internals, and destroys the stream; delete_deployment_payload added to DESTRUCTIVE_OPERATIONS. Fixes #1893 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client disconnect destroys only the stream Fastify was handed (the gzip transform); without the reverse hook the source file/blob read stays open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Kris caught in review: destroying an already-returned stream isn't sufficient cleanup for get_backup's LMDB path — it opens an fd and read transaction, then returns Readable.from(asyncGenerator). Per spec, calling .return() (which .destroy() triggers) on a generator that was never iterated resolves it to done and skips its body, including the readTxn.done() cleanup at the tail — so the fd/transaction leaked on every rejected MCP call. STREAMING_OPERATIONS (get_backup, get_deployment_payload) now short-circuits before chooseOperation/processLocalTransaction ever run, so those resources are never opened. The prior data instanceof Readable check remains as a defense-in-depth backstop for an operation outside that set unexpectedly returning a stream, with its own message so the two paths are distinguishable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9ba73f1 to
0afdc1f
Compare
cb1kenobi (review on #1898, responding to the PR's own flagged open question): get_deployment_payload streams the full tarball, which can embed secrets — unlike get_deployment/list_deployments, which only ever return stripped metadata. Registering it with get_deployment's permission shape lets an SU delegate it to a non-SU role via the operations allowlist (gate-2), handing that role full source+secret read. That's exactly the property that makes the secrets-store ops self-enforce super_user in the handler so they can't be gate-2 delegated. get_deployment_payload now does the same (mirroring secretOperations' requireSuperUser). delete_deployment_payload is intentionally left as-is — cb1kenobi's recommendation was scoped to the payload-read op; the #1893 non-SU cleanup-automation use case only needs delete, which doesn't expose source/secrets. Verified live: a non-SU role granted get_deployment_payload via add_role({operations:[...]}) now gets 403; the same role's delete_deployment_payload call still succeeds; an SU caller still gets a byte-identical download. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Reply to cb1kenobi's finding on Agreed, and this was exactly the open question I flagged in the PR description ("Happy to tighten if reviewers disagree"). Fixed in fba0fcb: Verified live against a real non-SU role granted both ops via Thanks for catching this — nice catch on the asymmetry with |
Both operations have been documented (operations-api reference + 5.1 release notes) and enum-declared since 5.1, but were never registered — calling them returned
Operation 'delete_deployment_payload' not found(#1893, reported from a Fabric quota-cleanup workflow).What this adds
get_deployment_payload(components/deploymentOperations.ts) — streams the stored deployment tarball back as raw bytes withcontent-type: application/octet-stream+ acontent-dispositionfilename, via the existingReadable+headers pipe path inserverHandlers.js(theget_backupmechanism). Deliberately never base64: payloads can be hundreds of MB, and a base64 round-trip materializes multi-hundred-MB V8 strings (hardERR_STRING_TOO_LONGnear ~400MB). The stream is markedpreCompressedso the already-gzipped tar isn't re-gzipped.delete_deployment_payload— nullspayload_blobon the row and re-puts it; the RecordEncoder retained-blob check unlinks the local blob file and the replicated null makes every peer drop its copy too (the same cluster-wide reclaim mechanism as the automatic post-deploy retention drop from feat(deploy): auto-drop large deployment payload blobs after a successful deploy #1496). Row metadata +event_logare retained as the audit trail, with apayload_droppedevent recordingdeleted_by. Guards: 409 on non-terminal deployments (their blob may still be the replication channel peers install from); idempotent success (freed_bytes: 0) when the payload is already gone; 404 for unknown ids.get_deployment's super_user permission shape.Hardening found by review (in this PR)
serverHandlers.jsgzip pipe now forwards source-stream errors..pipe()doesn't propagate them, so an async read error on a returned stream (e.g.get_backup's file stream) fired'error'with no listener →uncaughtException→ whole worker exits. Latent before this PR; blob streams made it much more reachable. Now the error destroys the gzip stream and Fastify aborts just that response.isErrorfor streaming (Readable) results instead ofJSON.stringify-ing stream internals, and destroys the stream so file-backed sources release fds.delete_deployment_payloadadded toDESTRUCTIVE_OPERATIONS. (Neither payload op is in the MCP default-allow surface; this covers explicitmcp.operations.allowopt-ins, andget_backupas well.)Considered decisions (flagging for review)
get_deployment's permission shape, meaning an SU can grant them to a role via itsoperationsallowlist (gate-2). Considered self-enforcing super_user like the secrets ops (tarballs can embed secrets), but delegation requires a deliberate per-op SU grant, and a non-SU cleanup-automation principal is exactly the delete_deployment_payload operation not implemented #1893 use case. Happy to tighten if reviewers disagree.Verification
get_deployment_payloadbyte-identical round-trip (sha256 match) with and withoutAccept-Encoding: gzip;delete_deployment_payload→freed_bytescorrect, blob file physically unlinked,payload_blob_present:false,payload_droppedaudit event, idempotent second call, 404s on reclaimed/unknown.Cross-model coverage: codex ✓ / gemini ✓ / Harper-domain pass ✓ (thorough), + final-artifact pass after the review fixes. All blockers/concerns from review addressed above.
Docs: companion PR documentation#600 adds the delete response shape, terminal-status requirement, and raw-bytes (not base64) clarification.
Fixes #1893
🤖 Generated with Claude Code (Opus 4.8)