From e9ed71ef97c7f10ad1980a6c88f01377f48e71c0 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:30:57 -0600 Subject: [PATCH] feat: faithful control-WS exec + real checkpoint API (v0.3.0) Aligns exec and checkpoints with the real Sprites API surface reverse-engineered from superfly/sprites-go (websocket.go, checkpoint.go). Exec moves from JSON POST to a control WebSocket at GET /v1/sprites/{id}/exec speaking the framed [streamID][payload] protocol (StreamStdin=0, StreamStdout=1, StreamStderr=2, StreamExit=3, StreamStdinEOF=4). The command is reconstructed from cmd/path query params and run through the existing scripted interpreter; the server writes stdout as [1], stderr as [2], then [3] and closes. The handshake advertises sprite-capabilities: control-ws. Checkpoint create moves to the singular POST /v1/sprites/{id}/checkpoint and streams NDJSON progress ending in {"event":"complete","id":"v"}. The list is now a bare JSON array of {id, comment, create_time, is_auto} and a new GET .../checkpoints/{id} returns one checkpoint. Restore streams NDJSON. Adds github.com/coder/websocket. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 46 ++++- README.md | 56 +++--- docs/api-coverage.md | 56 ++++-- docs/fidelity.md | 34 ++-- docs/getting-started.md | 19 +- go.mod | 2 + go.sum | 2 + internal/server/server.go | 104 ++++++---- internal/server/server_test.go | 333 ++++++++++++++++++++++++++------- internal/server/ws.go | 126 +++++++++++++ internal/sprite/sprite.go | 88 ++++++--- internal/sprite/sprite_test.go | 50 ++++- 12 files changed, 724 insertions(+), 192 deletions(-) create mode 100644 go.sum create mode 100644 internal/server/ws.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bd898eb..1061d5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,49 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.3.0] - 2026-07-11 + +Aligns exec and checkpoints with the real Sprites API surface reverse-engineered +from `superfly/sprites-go` (websocket.go, checkpoint.go). Exec is now a control +WebSocket speaking the framed stream protocol, and checkpoint create/restore +stream NDJSON progress. + +### Changed + +- `exec` is now a control WebSocket at `GET /v1/sprites/{name}/exec`, replacing + the JSON `POST` endpoint. The command is reconstructed from the query string + (`cmd` repeated per argv element, or a single `cmd` as the whole command line; + `path` is the argv[0] fallback). Every message is a binary frame + `[streamID][payload]`: StreamStdin=0, StreamStdout=1, StreamStderr=2, + StreamExit=3 (payload[0] is the exit code), StreamStdinEOF=4. The server writes + stdout as `[1]`, stderr as `[2]`, then `[3]` and + closes, matching the real Sprites SDK's non-PTY framing. The handshake response + advertises `sprite-capabilities: control-ws`. +- Checkpoint create moved to the singular `POST /v1/sprites/{name}/checkpoint` + and now streams line-delimited NDJSON progress events (`application/x-ndjson`): + an `info` event then a terminal `{"event":"complete","id":"v"}` carrying the + server-assigned version id. The old plural create and its `{id}` JSON body are + removed. +- `GET /v1/sprites/{name}/checkpoints` now returns a bare JSON array + `[{id, comment, create_time, is_auto}]` (creation order), not a + `{checkpoints: [...]}` wrapper. Each checkpoint gains a `create_time` timestamp + and an `is_auto` flag (false for manual checkpoints). +- Restore (`POST /v1/sprites/{name}/checkpoints/{id}/restore`) now streams NDJSON + progress events; an unknown sprite or checkpoint id is still a `404` before the + stream starts. +- `GET /v1/sprites/{name}`'s `checkpoints` projection carries the richer + `{id, comment, create_time, is_auto}` shape. +- `/_spritzer/health`'s implemented-path list reflects the new surface (WS exec, + singular checkpoint create, individual checkpoint GET, checkpoints list, and + restore). + +### Added + +- `GET /v1/sprites/{name}/checkpoints/{id}` returns a single checkpoint's + metadata (`{id, comment, create_time, is_auto}`); an unknown id is a `404`. +- `github.com/coder/websocket` as the WebSocket dependency for the control-exec + endpoint. + ## [0.2.0] - 2026-07-11 Corrects the checkpoint/restore surface to the confirmed real Sprites API. The @@ -64,6 +107,7 @@ real API assigns the id and the caller controls only a comment. - Distroless container image, GoReleaser configuration, mkdocs-material doc site, and CI. -[Unreleased]: https://github.com/intentius/spritzer/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/intentius/spritzer/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/intentius/spritzer/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/intentius/spritzer/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/intentius/spritzer/releases/tag/v0.1.0 diff --git a/README.md b/README.md index bc5a515..e83ad2e 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,17 @@ it, so the same integration suite passes against the spritzer container image. - Stateful in-memory store of sprites keyed by name, each with a filesystem (path → contents) and an ordered list of checkpoints. -- `exec` runs a small scripted interpreter (`echo > path`, `echo`, `cat`, `rm`, - `true`/`false`, `./risky.sh`, and an echo-back default) so a command can write, - overwrite, or fail a filesystem key and the result is observable. -- Checkpoint / restore: a checkpoint deep-copies the filesystem under a - server-assigned version id (`v1`, `v2`, …) with an optional caller comment; a - restore takes a checkpoint id in the path, replaces the filesystem with that - copy, and returns the sprite to `running`. This is the +- `exec` is a control WebSocket at `GET /v1/sprites/{id}/exec` speaking the real + Sprites SDK's framed protocol: each binary message is `[streamID][payload]` + (StreamStdin=0, StreamStdout=1, StreamStderr=2, StreamExit=3, StreamStdinEOF=4). + Behind the frames a small scripted interpreter (`echo > path`, `echo`, `cat`, + `rm`, `true`/`false`, `./risky.sh`, and an echo-back default) writes, + overwrites, or fails a filesystem key so the result is observable. +- Checkpoint / restore: create is `POST /v1/sprites/{id}/checkpoint` (singular), + streaming NDJSON progress and assigning a server version id (`v1`, `v2`, …) with + an optional caller comment; the list is a bare array with `create_time` and + `is_auto`; restore takes a checkpoint id in the path, streams NDJSON, replaces + the filesystem with that copy, and returns the sprite to `running`. This is the checkpoint-as-compensation primitive. - A destroyed or missing sprite returns `404` on any subsequent operation. - A `/_spritzer/health` endpoint reporting version and implemented paths. @@ -72,25 +76,30 @@ BASE=http://localhost:4290 curl -s -X POST "$BASE/v1/sprites" -d '{"name":"demo"}' # => {"id":"demo","url":"http://localhost:4290/s/demo"} -# Seed state, then checkpoint it. The server assigns the version id. -curl -s -X POST "$BASE/v1/sprites/demo/exec" -d '{"cmd":"echo good > /state"}' -curl -s -X POST "$BASE/v1/sprites/demo/checkpoints" -d '{"comment":"pre-run"}' -# => {"id":"v1"} +# Checkpoint the current state. The server assigns the version id and streams +# NDJSON progress; the id is on the terminal complete event. +curl -s -X POST "$BASE/v1/sprites/demo/checkpoint" -d '{"comment":"pre-run"}' +# => {"event":"info","message":"creating checkpoint"} +# {"event":"complete","message":"checkpoint created","id":"v1"} -# List the checkpoints (creation order). +# List the checkpoints (creation order) as a bare array. curl -s "$BASE/v1/sprites/demo/checkpoints" -# => {"checkpoints":[{"id":"v1","comment":"pre-run"}]} +# => [{"id":"v1","comment":"pre-run","create_time":"2026-07-11T...Z","is_auto":false}] -# Run a risky step that corrupts state and fails. -curl -s -X POST "$BASE/v1/sprites/demo/exec" -d '{"cmd":"./risky.sh"}' -# => {"stdout":"","stderr":"risky.sh: failed\n","exitCode":1} - -# Restore rewinds the filesystem to the checkpoint, addressed by id in the path. +# Restore rewinds the filesystem to the checkpoint, addressed by id in the path, +# streaming NDJSON progress. curl -s -X POST "$BASE/v1/sprites/demo/checkpoints/v1/restore" -curl -s "$BASE/v1/sprites/demo" -# => {"id":"demo","status":"running","url":"...","fs":{"/state":"good"},"checkpoints":[{"id":"v1","comment":"pre-run"}]} +# => {"event":"info","message":"restoring checkpoint v1"} +# {"event":"complete","message":"checkpoint restored","id":"v1"} ``` +`exec` is a control WebSocket at `ws:///v1/sprites/{id}/exec`. Pass the +command as `cmd` query params (`?cmd=echo&cmd=hi`, or a single `?cmd=echo hi`). +Every message is a binary frame `[streamID][payload]`: the server writes stdout +as `[1]`, stderr as `[2]`, then `[3]`. So +`echo hi` yields `[1]"hi\n"` then `[3]\x00` (exit 0), and `./risky.sh` yields +`[2]"risky.sh: failed\n"` then `[3]\x01` (exit 1). + ## Comparison | Capability | spritzer | Schema mock | Real Sprites | @@ -104,9 +113,10 @@ curl -s "$BASE/v1/sprites/demo" ## API coverage -Implemented: create, exec, checkpoint, list checkpoints, restore-by-id, destroy, -and an inspection `GET`, plus a `/_spritzer/health` report. The full table is in the -[API coverage docs](https://intentius.github.io/spritzer/api-coverage/). +Implemented: create, the exec control WebSocket, checkpoint (NDJSON), list +checkpoints (bare array), get one checkpoint, restore-by-id (NDJSON), destroy, +and an inspection `GET`, plus a `/_spritzer/health` report. The full table is in +the [API coverage docs](https://intentius.github.io/spritzer/api-coverage/). ## Development diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 6d5a29f..5a8c84d 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -10,20 +10,50 @@ clear JSON error. | Method | Path | Notes | | --- | --- | --- | | POST | `/v1/sprites` | Create a sprite; `name` is required and becomes the id. Returns `{id, url}`. | -| POST | `/v1/sprites/{id}/exec` | Run a command; returns `{stdout, stderr, exitCode}`. The REST exec response shape is provisional (`TODO(confirm)`); real exec is WebSocket-primary. | -| POST | `/v1/sprites/{id}/checkpoints` | Deep-copy the filesystem under a server-assigned version id (`v1`, `v2`, …). Body is `{comment?}`; returns `{id}`. | -| GET | `/v1/sprites/{id}/checkpoints` | List the checkpoints in creation order: `{checkpoints: [{id, comment}]}`. | -| POST | `/v1/sprites/{id}/checkpoints/{cid}/restore` | Replace the filesystem with checkpoint `{cid}` and return the sprite to `running`; `404` if the id is unknown. | +| GET (WS) | `/v1/sprites/{id}/exec` | Control WebSocket. Reconstructs the command from the query string and streams framed `[streamID][payload]` messages. See [the exec control WebSocket](#the-exec-control-websocket). | +| POST | `/v1/sprites/{id}/checkpoint` | Deep-copy the filesystem under a server-assigned version id (`v1`, `v2`, …). Body is `{comment?}`; streams NDJSON progress ending in `{"event":"complete","id":"v"}`. | +| GET | `/v1/sprites/{id}/checkpoints` | List the checkpoints in creation order as a bare JSON array `[{id, comment, create_time, is_auto}]`. | +| GET | `/v1/sprites/{id}/checkpoints/{cid}` | A single checkpoint's metadata: `{id, comment, create_time, is_auto}`; `404` if the id is unknown. | +| POST | `/v1/sprites/{id}/checkpoints/{cid}/restore` | Replace the filesystem with checkpoint `{cid}` and return the sprite to `running`; streams NDJSON progress. `404` if the id is unknown. | | DELETE | `/v1/sprites/{id}` | Destroy a sprite. Subsequent operations return `404`. | -| GET | `/v1/sprites/{id}` | Inspect a sprite: `{id, status, url, fs, checkpoints}` (checkpoints as `[{id, comment}]`). | +| GET | `/v1/sprites/{id}` | Inspect a sprite: `{id, status, url, fs, checkpoints}` (checkpoints as `[{id, comment, create_time, is_auto}]`). | | GET | `/_spritzer/health` | Version and coverage report (spritzer-only). | Checkpoints are addressed by a server-assigned version id, not a caller label. The caller supplies only an optional `comment`; the store assigns `v1`, `v2`, … -in creation order per sprite. A compensation workflow can therefore use the +in creation order per sprite, stamping a `create_time` and an `is_auto` flag +(false for manual checkpoints). A compensation workflow can therefore use the `comment` as a stable handle — list the checkpoints and restore the newest one whose comment matches — while restore itself always takes an explicit id in the -path. +path. Create and restore reply with streaming NDJSON progress +(`application/x-ndjson`): one or more `{"event":"info",...}` lines then a +terminal `{"event":"complete","id":"v"}`. + +## The exec control WebSocket + +`exec` is a control WebSocket at `GET /v1/sprites/{id}/exec`, matching the real +Sprites SDK (`superfly/sprites-go`, websocket.go). The command is reconstructed +from the query string: each repeated `cmd` param is one argv element (joined with +spaces), or a single `cmd` param is taken as the whole command line; `path` +(argv[0]) is the fallback when no `cmd` is present. `stdin=false` skips stdin +draining. + +Non-PTY framing: every WebSocket message is a binary frame whose first byte is a +stream id and whose remaining bytes are the payload. + +| Stream | Id | Direction | Payload | +| --- | --- | --- | --- | +| StreamStdin | 0 | client → server | stdin bytes | +| StreamStdout | 1 | server → client | stdout bytes | +| StreamStderr | 2 | server → client | stderr bytes | +| StreamExit | 3 | server → client | one byte: the exit code | +| StreamStdinEOF | 4 | client → server | end of stdin | + +The server runs the exec interpreter, writes stdout as `[1]`, stderr as +`[2]`, then a final `[3]` and closes the connection. The +handshake response advertises `sprite-capabilities: control-ws`. A client with no +stdin passes `stdin=false` (or sends a single `[4]` frame); the interpreter does +not read stdin, so any stdin frames are drained and discarded. ## The exec interpreter @@ -46,8 +76,10 @@ small set of forms: ## Wire fidelity -spritzer is wire-compatible with chant's in-process Sprites fake -(`sprites-fake.ts`). The JSON field names — `id`, `url`, the checkpoint `id`, -`stdout`/`stderr`/`exitCode`, and the `GET` shape's `fs` and `checkpoints` — and -the exec interpreter's behavior match it exactly, so chant's integration suite -passes against the spritzer container image unchanged. +spritzer mirrors the real Sprites API surface reverse-engineered from +`superfly/sprites-go`: the control-WebSocket exec framing (`[streamID][payload]` +with StreamStdin/Stdout/Stderr/Exit/StdinEOF), the singular NDJSON checkpoint +create, and the bare-array checkpoint list with `create_time` and `is_auto`. The +exec interpreter's behavior still matches chant's in-process Sprites fake +(`sprites-fake.ts`) so a client's checkpoint-as-compensation logic can be +exercised end to end offline. diff --git a/docs/fidelity.md b/docs/fidelity.md index 9c2bdd9..8da8196 100644 --- a/docs/fidelity.md +++ b/docs/fidelity.md @@ -22,22 +22,30 @@ spritzer creates sprites `running`, and a restore returns a sprite to `running`. ## The filesystem and exec -A sprite's filesystem is a `path -> contents` map. `exec` runs a small scripted -interpreter (not a real shell) so a test can write a key, then overwrite or fail -it, and prove that a later restore rewinds. See -[API coverage](api-coverage.md#the-exec-interpreter) for the recognized forms. -Segments split on `;` run in order and the last segment's exit code wins, -matching shell `;` semantics. +A sprite's filesystem is a `path -> contents` map. `exec` is a control +WebSocket at `GET /v1/sprites/{id}/exec` that speaks the real Sprites SDK's +framed protocol (`[streamID][payload]`; see +[the exec control WebSocket](api-coverage.md#the-exec-control-websocket)). Behind +the frames it runs a small scripted interpreter (not a real shell) so a test can +write a key, then overwrite or fail it, and prove that a later restore rewinds. +See [API coverage](api-coverage.md#the-exec-interpreter) for the recognized +forms. Segments split on `;` run in order and the last segment's exit code wins, +matching shell `;` semantics. The framing is faithful; the command execution +behind it is a deliberate limitation (a scripted interpreter, not a sandbox). ## Checkpoint and restore -A checkpoint deep-copies the filesystem under a server-assigned version id -(`v1`, `v2`, …, one past the current count); the caller supplies only an -optional comment. A restore addresses a checkpoint by its id in the path, -replaces the filesystem with that copy, and returns the sprite to `running`; -restoring an unknown id is a `404`. `GET .../checkpoints` lists the checkpoints -as `{id, comment}` in creation order, so a compensation workflow can use the -comment as a stable handle and restore the newest matching one. Because the +Create is `POST /v1/sprites/{id}/checkpoint` (singular) and streams NDJSON +progress, ending in `{"event":"complete","id":"v"}`. A checkpoint deep-copies +the filesystem under a server-assigned version id (`v1`, `v2`, …, one past the +current count), stamping a `create_time` and an `is_auto` flag (false for manual +checkpoints); the caller supplies only an optional comment. A restore addresses a +checkpoint by its id in the path, streams NDJSON progress, replaces the +filesystem with that copy, and returns the sprite to `running`; restoring an +unknown id is a `404` before the stream starts. `GET .../checkpoints` lists the +checkpoints as a bare array of `{id, comment, create_time, is_auto}` in creation +order, so a compensation workflow can use the comment as a stable handle and +restore the newest matching one. Because the checkpoint is a deep copy, mutating the filesystem after a checkpoint does not change what a later restore rewinds to — this is the checkpoint-as-compensation guarantee a guarded workflow relies on. diff --git a/docs/getting-started.md b/docs/getting-started.md index 75961c4..7ef1964 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -41,16 +41,25 @@ BASE=http://localhost:4290 # Create a sprite (its name is its id). curl -s -X POST "$BASE/v1/sprites" -d '{"name":"demo"}' -# Seed state, checkpoint it (the server assigns id v1), then corrupt it and fail. -curl -s -X POST "$BASE/v1/sprites/demo/exec" -d '{"cmd":"echo good > /state"}' -curl -s -X POST "$BASE/v1/sprites/demo/checkpoints" -d '{"comment":"pre-run"}' -curl -s -X POST "$BASE/v1/sprites/demo/exec" -d '{"cmd":"./risky.sh"}' +# Checkpoint the current state; the server assigns id v1 and streams NDJSON +# progress ending in {"event":"complete","id":"v1"}. +curl -s -X POST "$BASE/v1/sprites/demo/checkpoint" -d '{"comment":"pre-run"}' -# Restore rewinds the filesystem to the checkpoint, by id in the path. +# List the checkpoints as a bare array. +curl -s "$BASE/v1/sprites/demo/checkpoints" | jq + +# Restore rewinds the filesystem to the checkpoint, by id in the path (NDJSON). curl -s -X POST "$BASE/v1/sprites/demo/checkpoints/v1/restore" curl -s "$BASE/v1/sprites/demo" | jq '{id, status, fs, checkpoints}' ``` +`exec` is a control WebSocket, so it is not a plain `curl` call. Connect +`ws://localhost:4290/v1/sprites/demo/exec?cmd=` and read the binary +`[streamID][payload]` frames — the server writes stdout as `[1]`, stderr +as `[2]`, then a final `[3]`. See the +[API coverage](api-coverage.md#the-exec-control-websocket) for the frame +protocol. + ## Check what is implemented ```sh diff --git a/go.mod b/go.mod index ad0c448..7954f36 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/intentius/spritzer go 1.25 + +require github.com/coder/websocket v1.8.15 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4e0a48d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= diff --git a/internal/server/server.go b/internal/server/server.go index 1f203bf..21c6ae2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,9 +19,10 @@ import ( // implementedPaths backs the health/coverage endpoint. var implementedPaths = []string{ "POST /v1/sprites", - "POST /v1/sprites/{id}/exec", - "POST /v1/sprites/{id}/checkpoints", + "GET /v1/sprites/{id}/exec (control WebSocket)", + "POST /v1/sprites/{id}/checkpoint", "GET /v1/sprites/{id}/checkpoints", + "GET /v1/sprites/{id}/checkpoints/{cid}", "POST /v1/sprites/{id}/checkpoints/{cid}/restore", "DELETE /v1/sprites/{id}", "GET /v1/sprites/{id}", @@ -70,9 +71,10 @@ func (s *Server) routes() { mux := http.NewServeMux() mux.HandleFunc("POST /v1/sprites", s.createSprite) - mux.HandleFunc("POST /v1/sprites/{id}/exec", s.execSprite) - mux.HandleFunc("POST /v1/sprites/{id}/checkpoints", s.checkpointSprite) + mux.HandleFunc("GET /v1/sprites/{id}/exec", s.execSpriteWS) + mux.HandleFunc("POST /v1/sprites/{id}/checkpoint", s.checkpointSprite) mux.HandleFunc("GET /v1/sprites/{id}/checkpoints", s.listCheckpoints) + mux.HandleFunc("GET /v1/sprites/{id}/checkpoints/{cid}", s.getCheckpoint) mux.HandleFunc("POST /v1/sprites/{id}/checkpoints/{cid}/restore", s.restoreCheckpoint) mux.HandleFunc("DELETE /v1/sprites/{id}", s.destroySprite) mux.HandleFunc("GET /v1/sprites/{id}", s.getSprite) @@ -98,28 +100,19 @@ type createResponse struct { URL string `json:"url"` } -// execRequest is the body of POST /v1/sprites/{id}/exec. -type execRequest struct { - Cmd string `json:"cmd"` -} - -// checkpointRequest is the body of POST /v1/sprites/{id}/checkpoints. The -// caller supplies only an optional comment; the checkpoint id is -// server-assigned. +// checkpointRequest is the body of POST /v1/sprites/{id}/checkpoint. The caller +// supplies only an optional comment; the checkpoint id is server-assigned. type checkpointRequest struct { Comment string `json:"comment,omitempty"` } -// checkpointResponse is the POST /v1/sprites/{id}/checkpoints response, carrying -// the server-assigned version id (v1, v2, …). -type checkpointResponse struct { - ID string `json:"id"` -} - -// listCheckpointsResponse is the GET /v1/sprites/{id}/checkpoints response, the -// checkpoints in creation order (oldest first). -type listCheckpointsResponse struct { - Checkpoints []sprite.CheckpointInfo `json:"checkpoints"` +// progressEvent is one line of the NDJSON progress stream that the checkpoint +// create and restore endpoints emit, mirroring the real Sprites API's +// line-delimited progress body. The terminal event is {"event":"complete","id":"v"}. +type progressEvent struct { + Event string `json:"event"` + Message string `json:"message,omitempty"` + ID string `json:"id,omitempty"` } // ErrorResponse is the JSON body spritzer returns for any non-2xx status. It @@ -144,44 +137,55 @@ func (s *Server) createSprite(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, createResponse{ID: created.ID, URL: created.URL}) } -// execSprite runs a command in a sprite over the REST exec endpoint. The -// response shape is provisional; see the ExecResult TODO(confirm) note: real -// exec is WebSocket-primary and the REST response shape is not published. -func (s *Server) execSprite(w http.ResponseWriter, r *http.Request) { +// checkpointSprite creates a checkpoint and streams NDJSON progress events. The +// store assigns the version id (v1, v2, …); the response is an info event +// followed by a terminal complete event carrying that id. The sprite lookup +// error is resolved before the stream starts so an unknown sprite is a plain +// 404 rather than a half-written stream. +func (s *Server) checkpointSprite(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - var req execRequest + var req checkpointRequest if !s.decodeJSON(w, r, &req) { return } - result, err := s.store.Exec(id, req.Cmd) + cid, err := s.store.Checkpoint(id, req.Comment) if s.handleLookupError(w, id, err) { return } - writeJSON(w, http.StatusOK, result) + writeNDJSON(w, []progressEvent{ + {Event: "info", Message: "creating checkpoint"}, + {Event: "complete", Message: "checkpoint created", ID: cid}, + }) } -func (s *Server) checkpointSprite(w http.ResponseWriter, r *http.Request) { +func (s *Server) listCheckpoints(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - var req checkpointRequest - if !s.decodeJSON(w, r, &req) { - return - } - cid, err := s.store.Checkpoint(id, req.Comment) + cps, err := s.store.ListCheckpoints(id) if s.handleLookupError(w, id, err) { return } - writeJSON(w, http.StatusCreated, checkpointResponse{ID: cid}) + // The list is a bare JSON array, not wrapped in an object. + writeJSON(w, http.StatusOK, cps) } -func (s *Server) listCheckpoints(w http.ResponseWriter, r *http.Request) { +func (s *Server) getCheckpoint(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - cps, err := s.store.ListCheckpoints(id) + cid := r.PathValue("cid") + info, err := s.store.GetCheckpoint(id, cid) + if errors.Is(err, sprite.ErrCheckpointNotFound) { + s.writeError(w, http.StatusNotFound, "no checkpoint \""+cid+"\" for sprite "+id) + return + } if s.handleLookupError(w, id, err) { return } - writeJSON(w, http.StatusOK, listCheckpointsResponse{Checkpoints: cps}) + writeJSON(w, http.StatusOK, info) } +// restoreCheckpoint replaces the sprite's filesystem with the identified +// checkpoint's copy and streams NDJSON progress events. As with create, the +// error is resolved before streaming so an unknown sprite or checkpoint is a +// plain 404. func (s *Server) restoreCheckpoint(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") cid := r.PathValue("cid") @@ -193,7 +197,10 @@ func (s *Server) restoreCheckpoint(w http.ResponseWriter, r *http.Request) { if s.handleLookupError(w, id, err) { return } - writeJSON(w, http.StatusOK, struct{}{}) + writeNDJSON(w, []progressEvent{ + {Event: "info", Message: "restoring checkpoint " + cid}, + {Event: "complete", Message: "checkpoint restored", ID: cid}, + }) } func (s *Server) destroySprite(w http.ResponseWriter, r *http.Request) { @@ -269,3 +276,20 @@ func writeJSON(w http.ResponseWriter, status int, v any) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } + +// writeNDJSON streams a sequence of progress events as line-delimited JSON with +// Content-Type application/x-ndjson, flushing after each line so a client +// consuming the stream sees progress before completion. json.Encoder.Encode +// appends the newline that delimits each event. +func writeNDJSON(w http.ResponseWriter, events []progressEvent) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + enc := json.NewEncoder(w) + flusher, _ := w.(http.Flusher) + for i := range events { + _ = enc.Encode(events[i]) + if flusher != nil { + flusher.Flush() + } + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index ee7cb5e..dec3889 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -2,15 +2,20 @@ package server import ( "bytes" + "context" "encoding/json" "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" + "github.com/coder/websocket" + "github.com/intentius/spritzer/internal/clock" + "github.com/intentius/spritzer/internal/sprite" ) type harness struct { @@ -57,6 +62,93 @@ func (h *harness) mustJSON(raw []byte, dst any) { } } +// checkpointID posts to the singular /checkpoint endpoint, scans the NDJSON +// progress stream, and returns the id from the terminal complete event. +func (h *harness) checkpointID(name, comment string) string { + h.t.Helper() + body := map[string]any{} + if comment != "" { + body["comment"] = comment + } + code, raw := h.do(http.MethodPost, "/v1/sprites/"+name+"/checkpoint", body) + if code != http.StatusOK { + h.t.Fatalf("checkpoint = %d %s, want 200", code, raw) + } + return completeID(h.t, raw) +} + +// completeID scans an NDJSON progress body and returns the id carried by the +// terminal {"event":"complete"} line. +func completeID(t *testing.T, raw []byte) string { + t.Helper() + var id string + sawComplete := false + for _, line := range bytes.Split(bytes.TrimSpace(raw), []byte("\n")) { + if len(bytes.TrimSpace(line)) == 0 { + continue + } + var ev progressEvent + if err := json.Unmarshal(line, &ev); err != nil { + t.Fatalf("ndjson line %q: %v", line, err) + } + if ev.Event == "complete" { + id = ev.ID + sawComplete = true + } + } + if !sawComplete { + t.Fatalf("ndjson stream had no complete event: %s", raw) + } + return id +} + +// execWS connects the control WebSocket, sends the command as a single cmd +// query param, reads the framed stdout/stderr/exit, and returns them. It fails +// the test if no exit frame arrives. +func (h *harness) execWS(name, cmd string) (stdout, stderr string, exit int) { + h.t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + q := url.Values{"cmd": {cmd}, "stdin": {"false"}} + wsURL := "ws" + strings.TrimPrefix(h.ts.URL, "http") + "/v1/sprites/" + name + "/exec?" + q.Encode() + + c, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + h.t.Fatalf("ws dial %s: %v", name, err) + } + defer func() { _ = c.CloseNow() }() + + sawExit := false + for { + typ, data, err := c.Read(ctx) + if err != nil { + break + } + if typ != websocket.MessageBinary || len(data) == 0 { + continue + } + switch data[0] { + case streamStdout: + stdout += string(data[1:]) + case streamStderr: + stderr += string(data[1:]) + case streamExit: + if len(data) >= 2 { + exit = int(data[1]) + } + sawExit = true + } + if sawExit { + break + } + } + if !sawExit { + h.t.Fatalf("exec ws %q: no exit frame", cmd) + } + return stdout, stderr, exit +} + func TestHealth(t *testing.T) { h := newHarness(t) code, body := h.do(http.MethodGet, "/_spritzer/health", nil) @@ -75,6 +167,15 @@ func TestHealth(t *testing.T) { if len(payload.Implemented) == 0 { t.Fatalf("expected non-empty coverage list: %+v", payload) } + // The exec entry names the control WebSocket, and checkpoint create is the + // singular path. + joined := strings.Join(payload.Implemented, "\n") + if !strings.Contains(joined, "exec (control WebSocket)") { + t.Fatalf("coverage list missing WS exec entry: %v", payload.Implemented) + } + if !strings.Contains(joined, "POST /v1/sprites/{id}/checkpoint\n") && !strings.HasSuffix(joined, "POST /v1/sprites/{id}/checkpoint") { + t.Fatalf("coverage list missing singular checkpoint create: %v", payload.Implemented) + } } func TestCreateRequiresName(t *testing.T) { @@ -107,8 +208,9 @@ func TestCreateResponseShape(t *testing.T) { } } -// TestFullLoop exercises every endpoint: create, exec write, checkpoint, exec -// corrupt, GET (corrupt), restore, GET (rewound), destroy, then 404s. +// TestFullLoop exercises the whole surface: create, WS exec write, checkpoint +// (NDJSON), WS exec corrupt, GET (corrupt), restore (NDJSON), GET (rewound), +// destroy, then 404s. func TestFullLoop(t *testing.T) { h := newHarness(t) @@ -117,53 +219,45 @@ func TestFullLoop(t *testing.T) { t.Fatalf("create = %d %s", code, body) } - // exec: write a key - code, body := h.do(http.MethodPost, "/v1/sprites/s1/exec", map[string]any{"cmd": "echo good > /state"}) - if code != http.StatusOK { - t.Fatalf("exec write = %d %s", code, body) - } - var ex struct { - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - ExitCode int `json:"exitCode"` - } - h.mustJSON(body, &ex) - if ex.ExitCode != 0 { - t.Fatalf("exec write exitCode = %d, want 0", ex.ExitCode) + // exec over the control WebSocket: write a key, exit frame 0. + if stdout, stderr, exit := h.execWS("s1", "echo good > /state"); exit != 0 || stdout != "" || stderr != "" { + t.Fatalf("exec write = out=%q err=%q exit=%d, want exit 0", stdout, stderr, exit) } - // checkpoint: the server assigns the version id v1; the caller supplies a - // comment. - code, body = h.do(http.MethodPost, "/v1/sprites/s1/checkpoints", map[string]any{"comment": "pre-run"}) - if code != http.StatusCreated { - t.Fatalf("checkpoint = %d %s", code, body) - } - var cp checkpointResponse - h.mustJSON(body, &cp) - if cp.ID != "v1" { - t.Fatalf("checkpoint id = %q, want v1", cp.ID) + // checkpoint: the server assigns v1; the caller supplies a comment. + if id := h.checkpointID("s1", "pre-run"); id != "v1" { + t.Fatalf("checkpoint id = %q, want v1", id) } - // list checkpoints reports v1 with its comment - code, body = h.do(http.MethodGet, "/v1/sprites/s1/checkpoints", nil) + // list checkpoints is a bare array with is_auto false and a create_time. + code, body := h.do(http.MethodGet, "/v1/sprites/s1/checkpoints", nil) if code != http.StatusOK { t.Fatalf("list checkpoints = %d %s", code, body) } - var list listCheckpointsResponse + var list []sprite.CheckpointInfo h.mustJSON(body, &list) - if len(list.Checkpoints) != 1 || list.Checkpoints[0].ID != "v1" || list.Checkpoints[0].Comment != "pre-run" { - t.Fatalf("list = %+v, want [{v1 pre-run}]", list.Checkpoints) + if len(list) != 1 || list[0].ID != "v1" || list[0].Comment != "pre-run" || list[0].IsAuto { + t.Fatalf("list = %+v, want [{v1 pre-run is_auto:false}]", list) + } + if !strings.HasPrefix(strings.TrimSpace(string(body)), "[") { + t.Fatalf("list body = %s, want a bare JSON array", body) } - // exec: corrupt via risky.sh (exit 1) — the server still returns 200 with - // the exec result; a non-zero exit is the client's concern. - code, body = h.do(http.MethodPost, "/v1/sprites/s1/exec", map[string]any{"cmd": "echo bad > /state; ./risky.sh"}) + // get a single checkpoint + code, body = h.do(http.MethodGet, "/v1/sprites/s1/checkpoints/v1", nil) if code != http.StatusOK { - t.Fatalf("exec corrupt = %d %s", code, body) + t.Fatalf("get checkpoint = %d %s", code, body) } - h.mustJSON(body, &ex) - if ex.ExitCode != 1 || ex.Stderr != "risky.sh: failed\n" { - t.Fatalf("exec corrupt = %+v, want exit 1 + stderr", ex) + var one sprite.CheckpointInfo + h.mustJSON(body, &one) + if one.ID != "v1" || one.Comment != "pre-run" || one.IsAuto { + t.Fatalf("get checkpoint = %+v, want {v1 pre-run is_auto:false}", one) + } + + // exec: corrupt via risky.sh (stderr + exit frame 1). + stdout, stderr, exit := h.execWS("s1", "echo bad > /state; ./risky.sh") + if exit != 1 || stderr != "risky.sh: failed\n" || stdout != "" { + t.Fatalf("exec corrupt = out=%q err=%q exit=%d, want exit 1 + stderr", stdout, stderr, exit) } // GET shows corruption @@ -172,14 +266,11 @@ func TestFullLoop(t *testing.T) { t.Fatalf("get corrupt = %d %s", code, body) } var view struct { - ID string `json:"id"` - Status string `json:"status"` - URL string `json:"url"` - FS map[string]string `json:"fs"` - Checkpoints []struct { - ID string `json:"id"` - Comment string `json:"comment"` - } `json:"checkpoints"` + ID string `json:"id"` + Status string `json:"status"` + URL string `json:"url"` + FS map[string]string `json:"fs"` + Checkpoints []sprite.CheckpointInfo `json:"checkpoints"` } h.mustJSON(body, &view) if view.FS["/state"] != "bad" || view.FS["/work/output"] != "partial-corrupt" { @@ -189,13 +280,16 @@ func TestFullLoop(t *testing.T) { t.Fatalf("checkpoints = %+v, want [{v1 pre-run}]", view.Checkpoints) } - // restore by id in the path - if code, body := h.do(http.MethodPost, "/v1/sprites/s1/checkpoints/v1/restore", nil); code != http.StatusOK { + // restore by id in the path: consume the NDJSON stream and confirm it names v1. + code, body = h.do(http.MethodPost, "/v1/sprites/s1/checkpoints/v1/restore", nil) + if code != http.StatusOK { t.Fatalf("restore = %d %s", code, body) } + if id := completeID(t, body); id != "v1" { + t.Fatalf("restore complete id = %q, want v1", id) + } - // GET shows rewound. Use a fresh struct: unmarshaling into the populated - // `view` above would merge maps and not clear /work/output. + // GET shows rewound. Use a fresh struct so maps do not merge. var rewound struct { Status string `json:"status"` FS map[string]string `json:"fs"` @@ -218,34 +312,126 @@ func TestFullLoop(t *testing.T) { if code, _ := h.do(http.MethodGet, "/v1/sprites/s1", nil); code != http.StatusNotFound { t.Fatalf("get after destroy = %d, want 404", code) } - if code, _ := h.do(http.MethodPost, "/v1/sprites/s1/exec", map[string]any{"cmd": "true"}); code != http.StatusNotFound { - t.Fatalf("exec after destroy = %d, want 404", code) + if code, _ := h.do(http.MethodPost, "/v1/sprites/s1/checkpoint", map[string]any{}); code != http.StatusNotFound { + t.Fatalf("checkpoint after destroy = %d, want 404", code) } if code, _ := h.do(http.MethodDelete, "/v1/sprites/s1", nil); code != http.StatusNotFound { t.Fatalf("destroy after destroy = %d, want 404", code) } } +// TestExecWSFrames asserts the framing directly: echo writes fs and exits 0; +// risky.sh yields a stderr frame and exit 1. +func TestExecWSFrames(t *testing.T) { + h := newHarness(t) + if code, body := h.do(http.MethodPost, "/v1/sprites", map[string]any{"name": "ws"}); code != http.StatusCreated { + t.Fatalf("create = %d %s", code, body) + } + + // echo hi (no redirect) -> stdout "hi\n", exit 0. + if stdout, stderr, exit := h.execWS("ws", "echo hi"); stdout != "hi\n" || stderr != "" || exit != 0 { + t.Fatalf("echo hi = out=%q err=%q exit=%d, want out=\"hi\\n\" exit 0", stdout, stderr, exit) + } + + // echo hi > /state writes the fs and exits 0. + if _, _, exit := h.execWS("ws", "echo hi > /state"); exit != 0 { + t.Fatalf("echo redirect exit = %d, want 0", exit) + } + _, body := h.do(http.MethodGet, "/v1/sprites/ws", nil) + var view struct { + FS map[string]string `json:"fs"` + } + h.mustJSON(body, &view) + if view.FS["/state"] != "hi" { + t.Fatalf("fs after ws write = %v, want {/state: hi}", view.FS) + } + + // risky.sh -> stderr frame + exit 1. + if stdout, stderr, exit := h.execWS("ws", "./risky.sh"); exit != 1 || stderr != "risky.sh: failed\n" || stdout != "" { + t.Fatalf("risky = out=%q err=%q exit=%d, want stderr + exit 1", stdout, stderr, exit) + } +} + +// TestExecWSArgvParams confirms repeated cmd params are joined into the command +// line (argv form), equivalent to a single cmd param. +func TestExecWSArgvParams(t *testing.T) { + h := newHarness(t) + if code, body := h.do(http.MethodPost, "/v1/sprites", map[string]any{"name": "argv"}); code != http.StatusCreated { + t.Fatalf("create = %d %s", code, body) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // Send `echo` `hi` as separate argv elements plus path=echo. + q := url.Values{"cmd": {"echo", "hi"}, "path": {"echo"}, "stdin": {"false"}} + wsURL := "ws" + strings.TrimPrefix(h.ts.URL, "http") + "/v1/sprites/argv/exec?" + q.Encode() + c, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = c.CloseNow() }() + var stdout string + var exit int + sawExit := false + for !sawExit { + typ, data, err := c.Read(ctx) + if err != nil { + t.Fatalf("read: %v", err) + } + if typ != websocket.MessageBinary || len(data) == 0 { + continue + } + switch data[0] { + case streamStdout: + stdout += string(data[1:]) + case streamExit: + if len(data) >= 2 { + exit = int(data[1]) + } + sawExit = true + } + } + if stdout != "hi\n" || exit != 0 { + t.Fatalf("argv exec = out=%q exit=%d, want out=\"hi\\n\" exit 0", stdout, exit) + } +} + +// TestExecWSMissingSprite confirms the WS closes with a policy-violation status +// for an unknown sprite. +func TestExecWSMissingSprite(t *testing.T) { + h := newHarness(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + q := url.Values{"cmd": {"true"}, "stdin": {"false"}} + wsURL := "ws" + strings.TrimPrefix(h.ts.URL, "http") + "/v1/sprites/ghost/exec?" + q.Encode() + c, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = c.CloseNow() }() + // A read should fail with a close status carrying the policy violation. + _, _, readErr := c.Read(ctx) + if readErr == nil { + t.Fatalf("expected close for missing sprite, got a frame") + } + if status := websocket.CloseStatus(readErr); status != websocket.StatusPolicyViolation { + t.Fatalf("close status = %v, want policy violation", status) + } +} + // TestCheckpointVersionIDOverHTTP confirms the server assigns v1 for the first -// checkpoint even when the body carries no comment. +// checkpoint even when the body carries no comment, via the NDJSON stream. func TestCheckpointVersionIDOverHTTP(t *testing.T) { h := newHarness(t) if code, body := h.do(http.MethodPost, "/v1/sprites", map[string]any{"name": "s"}); code != http.StatusCreated { t.Fatalf("create = %d %s", code, body) } - code, body := h.do(http.MethodPost, "/v1/sprites/s/checkpoints", map[string]any{}) - if code != http.StatusCreated { - t.Fatalf("checkpoint = %d %s", code, body) - } - var cp checkpointResponse - h.mustJSON(body, &cp) - if cp.ID != "v1" { - t.Fatalf("checkpoint id = %q, want v1", cp.ID) + if id := h.checkpointID("s", ""); id != "v1" { + t.Fatalf("checkpoint id = %q, want v1", id) } } // TestListCheckpointsEmpty confirms a sprite with no checkpoints lists as an -// empty array, not null. +// empty bare array, not null and not a wrapper object. func TestListCheckpointsEmpty(t *testing.T) { h := newHarness(t) if code, body := h.do(http.MethodPost, "/v1/sprites", map[string]any{"name": "s"}); code != http.StatusCreated { @@ -255,12 +441,13 @@ func TestListCheckpointsEmpty(t *testing.T) { if code != http.StatusOK { t.Fatalf("list = %d %s", code, body) } - if s := string(body); !strings.Contains(s, `"checkpoints":[]`) { - t.Fatalf("empty list body = %s, want checkpoints:[]", s) + if s := strings.TrimSpace(string(body)); s != "[]" { + t.Fatalf("empty list body = %s, want []", s) } } -// TestRestoreUnknownCheckpoint404 confirms an unknown id in the path is a 404. +// TestRestoreUnknownCheckpoint404 confirms an unknown id in the path is a 404 +// (JSON error, not an NDJSON stream). func TestRestoreUnknownCheckpoint404(t *testing.T) { h := newHarness(t) if code, body := h.do(http.MethodPost, "/v1/sprites", map[string]any{"name": "s"}); code != http.StatusCreated { @@ -277,7 +464,19 @@ func TestRestoreUnknownCheckpoint404(t *testing.T) { } } -// TestOpsOnMissingSprite confirms a missing sprite is 404 on exec/get/destroy. +// TestGetUnknownCheckpoint404 confirms GET of an unknown checkpoint id is a 404. +func TestGetUnknownCheckpoint404(t *testing.T) { + h := newHarness(t) + if code, body := h.do(http.MethodPost, "/v1/sprites", map[string]any{"name": "s"}); code != http.StatusCreated { + t.Fatalf("create = %d %s", code, body) + } + code, body := h.do(http.MethodGet, "/v1/sprites/s/checkpoints/v99", nil) + if code != http.StatusNotFound { + t.Fatalf("get unknown checkpoint = %d %s, want 404", code, body) + } +} + +// TestOpsOnMissingSprite confirms a missing sprite is 404 on the HTTP ops. func TestOpsOnMissingSprite(t *testing.T) { h := newHarness(t) for _, tc := range []struct { @@ -285,9 +484,9 @@ func TestOpsOnMissingSprite(t *testing.T) { body any }{ {http.MethodGet, "/v1/sprites/ghost", nil}, - {http.MethodPost, "/v1/sprites/ghost/exec", map[string]any{"cmd": "true"}}, - {http.MethodPost, "/v1/sprites/ghost/checkpoints", map[string]any{}}, + {http.MethodPost, "/v1/sprites/ghost/checkpoint", map[string]any{}}, {http.MethodGet, "/v1/sprites/ghost/checkpoints", nil}, + {http.MethodGet, "/v1/sprites/ghost/checkpoints/v1", nil}, {http.MethodPost, "/v1/sprites/ghost/checkpoints/v1/restore", nil}, {http.MethodDelete, "/v1/sprites/ghost", nil}, } { diff --git a/internal/server/ws.go b/internal/server/ws.go new file mode 100644 index 0000000..8a9a85e --- /dev/null +++ b/internal/server/ws.go @@ -0,0 +1,126 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/coder/websocket" + + "github.com/intentius/spritzer/internal/sprite" +) + +// The control-WebSocket stream ids. Every non-PTY exec frame is a binary +// message whose first byte is the stream id and whose remaining bytes are the +// payload, matching superfly/sprites-go's websocket.go framing. +const ( + streamStdin byte = 0 // client → server: stdin bytes + streamStdout byte = 1 // server → client: stdout bytes + streamStderr byte = 2 // server → client: stderr bytes + streamExit byte = 3 // server → client: payload[0] is the exit code + streamStdinEOF byte = 4 // client → server: no more stdin +) + +// execSpriteWS upgrades GET /v1/sprites/{id}/exec to a control WebSocket and +// runs the exec interpreter over the framed protocol. The argv is reconstructed +// from the query string: each repeated cmd param is one argv element joined with +// spaces, or a single cmd param is taken as the whole command line; path is the +// argv[0] fallback when no cmd param is present. Non-PTY only: the server writes +// stdout as [streamStdout], stderr as [streamStderr], then a final +// [streamExit] and closes. +func (s *Server) execSpriteWS(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + cmd := reconstructCmd(r) + + // Advertise the control-WebSocket capability on the 101 response so a client + // can confirm the framed protocol before it starts writing frames. + w.Header().Set("sprite-capabilities", "control-ws") + + c, err := websocket.Accept(w, r, nil) + if err != nil { + s.log.Warn("exec websocket accept failed", "id", id, "err", err) + return + } + // CloseNow is idempotent with a graceful Close; deferring it guards the error + // paths without double-closing the happy path in any harmful way. + defer func() { _ = c.CloseNow() }() + + ctx := r.Context() + + // Drain any client stdin frames in the background. The interpreter does not + // read stdin, so the bytes are discarded, but reading keeps the connection + // responsive and honors a client that streams stdin then a StreamStdinEOF + // frame. Draining stops on EOF, read error, or connection close. + if r.URL.Query().Get("stdin") != "false" { + go drainStdin(ctx, c) + } + + result, err := s.store.Exec(id, cmd) + if err != nil { + if errors.Is(err, sprite.ErrNotFound) { + _ = c.Close(websocket.StatusPolicyViolation, "no sprite "+id) + return + } + _ = c.Close(websocket.StatusInternalError, err.Error()) + return + } + + if result.Stdout != "" { + if err := writeFrame(ctx, c, streamStdout, []byte(result.Stdout)); err != nil { + return + } + } + if result.Stderr != "" { + if err := writeFrame(ctx, c, streamStderr, []byte(result.Stderr)); err != nil { + return + } + } + if err := writeFrame(ctx, c, streamExit, []byte{byte(result.ExitCode)}); err != nil { + return + } + + _ = c.Close(websocket.StatusNormalClosure, "") +} + +// reconstructCmd rebuilds the command line from the exec query params. Repeated +// cmd params are argv elements joined with spaces; a single cmd param is the +// whole command line (joining a one-element slice is a no-op). When no cmd param +// is present, path (argv[0]) is the fallback. +func reconstructCmd(r *http.Request) string { + q := r.URL.Query() + if cmds := q["cmd"]; len(cmds) > 0 { + return strings.Join(cmds, " ") + } + return q.Get("path") +} + +// drainStdin reads and discards client stdin frames until a StreamStdinEOF +// frame, a read error, or context cancellation. It exists so a client that +// speaks the full framing (stdin bytes then EOF) does not stall; the scripted +// interpreter has no stdin to consume. +func drainStdin(ctx context.Context, c *websocket.Conn) { + for { + _, data, err := c.Read(ctx) + if err != nil { + return + } + if len(data) == 0 { + continue + } + switch data[0] { + case streamStdinEOF: + return + case streamStdin: + // stdin bytes are discarded; the interpreter has no stdin. + } + } +} + +// writeFrame writes one [streamID] binary message. +func writeFrame(ctx context.Context, c *websocket.Conn, streamID byte, payload []byte) error { + frame := make([]byte, 0, len(payload)+1) + frame = append(frame, streamID) + frame = append(frame, payload...) + return c.Write(ctx, websocket.MessageBinary, frame) +} diff --git a/internal/sprite/sprite.go b/internal/sprite/sprite.go index e514099..5ca82c7 100644 --- a/internal/sprite/sprite.go +++ b/internal/sprite/sprite.go @@ -43,21 +43,26 @@ var ( ) // Checkpoint is a captured filesystem snapshot: a server-assigned version id -// (v1, v2, …), the caller-supplied comment, and a full copy of the fs at -// checkpoint time. +// (v1, v2, …), the caller-supplied comment, the creation timestamp, whether it +// was created automatically, and a full copy of the fs at checkpoint time. type Checkpoint struct { - ID string - Comment string - FS map[string]string + ID string + Comment string + CreateTime string + IsAuto bool + FS map[string]string } -// CheckpointInfo is the id+comment projection of a checkpoint, without its fs -// copy. It is what the list endpoint and the GET view expose so a client can -// pick a checkpoint by id (or, for compensation, by the newest matching -// comment). +// CheckpointInfo is the metadata projection of a checkpoint, without its fs +// copy. It is what the list endpoint, the individual GET, and the sprite view +// expose so a client can pick a checkpoint by id (or, for compensation, by the +// newest matching comment). The JSON tags match the real Sprites checkpoint +// shape: id, comment, create_time, is_auto. type CheckpointInfo struct { - ID string `json:"id"` - Comment string `json:"comment"` + ID string `json:"id"` + Comment string `json:"comment"` + CreateTime string `json:"create_time"` + IsAuto bool `json:"is_auto"` } // Sprite is a single sprite: its lifecycle status, its addressable URL, its @@ -75,18 +80,14 @@ type Sprite struct { CreatedAt string } -// ExecResult is the outcome of running a command in a sprite. The JSON tags -// match the Sprites REST exec response (note the camelCase exitCode). -// -// TODO(confirm REST exec response against real Sprites): real exec is -// WebSocket-primary (WSS /v1/sprites/{name}/exec?cmd=). The REST POST is the -// documented alternative, but its exact response shape is not published; this -// {stdout,stderr,exitCode} shape is provisional and kept for the emulator + the -// Op loop. +// ExecResult is the outcome of running a command in a sprite. The server's +// control-WebSocket exec handler carries these three fields to the client as +// framed messages: stdout as StreamStdout, stderr as StreamStderr, and the exit +// code as the StreamExit frame's single payload byte. type ExecResult struct { - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - ExitCode int `json:"exitCode"` + Stdout string + Stderr string + ExitCode int } // View is the read-only projection returned by GET /v1/sprites/{id}: the @@ -160,13 +161,33 @@ func (s *Store) Checkpoint(id, comment string) (string, error) { } cid := fmt.Sprintf("v%d", len(sp.Checkpoints)+1) sp.Checkpoints = append(sp.Checkpoints, Checkpoint{ - ID: cid, - Comment: comment, - FS: cloneFS(sp.FS), + ID: cid, + Comment: comment, + CreateTime: s.clk.Now().UTC().Format("2006-01-02T15:04:05.999999999Z07:00"), + IsAuto: false, + FS: cloneFS(sp.FS), }) return cid, nil } +// GetCheckpoint returns a single checkpoint's metadata projection. It returns +// ErrNotFound for a missing or destroyed sprite, and ErrCheckpointNotFound for +// an unknown id. +func (s *Store) GetCheckpoint(id, checkpointID string) (CheckpointInfo, error) { + s.mu.Lock() + defer s.mu.Unlock() + sp, err := s.live(id) + if err != nil { + return CheckpointInfo{}, err + } + for i := range sp.Checkpoints { + if sp.Checkpoints[i].ID == checkpointID { + return checkpointInfo(sp.Checkpoints[i]), nil + } + } + return CheckpointInfo{}, ErrCheckpointNotFound +} + // ListCheckpoints returns the sprite's checkpoints as {id, comment} projections // in creation order (oldest first), so a client can pick the newest // deterministically. It returns ErrNotFound for a missing or destroyed sprite. @@ -232,16 +253,26 @@ func (s *Store) Get(id string) (View, error) { }, nil } -// checkpointInfos projects a checkpoint list to its id+comment view, always +// checkpointInfos projects a checkpoint list to its metadata view, always // returning a non-nil slice so it marshals as [] rather than null. func checkpointInfos(cps []Checkpoint) []CheckpointInfo { out := make([]CheckpointInfo, 0, len(cps)) for _, cp := range cps { - out = append(out, CheckpointInfo{ID: cp.ID, Comment: cp.Comment}) + out = append(out, checkpointInfo(cp)) } return out } +// checkpointInfo projects a single checkpoint to its metadata view. +func checkpointInfo(cp Checkpoint) CheckpointInfo { + return CheckpointInfo{ + ID: cp.ID, + Comment: cp.Comment, + CreateTime: cp.CreateTime, + IsAuto: cp.IsAuto, + } +} + // live finds a sprite without locking (callers hold s.mu). A destroyed sprite is // reported as ErrNotFound so every op past destroy behaves as if it is gone. func (s *Store) live(id string) (*Sprite, error) { @@ -260,7 +291,8 @@ func cloneSprite(sp *Sprite) *Sprite { if sp.Checkpoints != nil { cps := make([]Checkpoint, len(sp.Checkpoints)) for i, cp := range sp.Checkpoints { - cps[i] = Checkpoint{ID: cp.ID, Comment: cp.Comment, FS: cloneFS(cp.FS)} + cps[i] = cp + cps[i].FS = cloneFS(cp.FS) } c.Checkpoints = cps } diff --git a/internal/sprite/sprite_test.go b/internal/sprite/sprite_test.go index bbcd4da..77d5081 100644 --- a/internal/sprite/sprite_test.go +++ b/internal/sprite/sprite_test.go @@ -191,9 +191,53 @@ func TestCheckpointVersionIDs(t *testing.T) { if err != nil { t.Fatalf("list = %v", err) } - want := []CheckpointInfo{{ID: "v1", Comment: "pre-run"}, {ID: "v2", Comment: ""}} - if !reflect.DeepEqual(cps, want) { - t.Fatalf("list = %v, want %v", cps, want) + // create_time is stamped from the clock and not compared here; the id, + // comment, and is_auto (default false) are the contract fields. + if len(cps) != 2 { + t.Fatalf("list len = %d, want 2", len(cps)) + } + if cps[0].ID != "v1" || cps[0].Comment != "pre-run" || cps[0].IsAuto { + t.Fatalf("cps[0] = %+v, want {v1 pre-run is_auto:false}", cps[0]) + } + if cps[1].ID != "v2" || cps[1].Comment != "" || cps[1].IsAuto { + t.Fatalf("cps[1] = %+v, want {v2 \"\" is_auto:false}", cps[1]) + } +} + +// TestCheckpointCreateTimeStamped confirms a checkpoint records a create_time +// from the injected clock, and GetCheckpoint returns the same metadata. +func TestCheckpointCreateTimeStamped(t *testing.T) { + at := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + st := New(clock.NewFake(at)) + st.Create("s", "http://h/s/s", nil) + if _, err := st.Checkpoint("s", "pre-run"); err != nil { + t.Fatalf("checkpoint = %v", err) + } + info, err := st.GetCheckpoint("s", "v1") + if err != nil { + t.Fatalf("get checkpoint = %v", err) + } + want := CheckpointInfo{ + ID: "v1", + Comment: "pre-run", + CreateTime: at.Format("2006-01-02T15:04:05.999999999Z07:00"), + IsAuto: false, + } + if !reflect.DeepEqual(info, want) { + t.Fatalf("get checkpoint = %+v, want %+v", info, want) + } +} + +// TestGetUnknownCheckpoint confirms GetCheckpoint reports ErrCheckpointNotFound +// for an unknown id and ErrNotFound for a missing sprite. +func TestGetUnknownCheckpoint(t *testing.T) { + st := New(nil) + st.Create("s", "http://h/s/s", nil) + if _, err := st.GetCheckpoint("s", "v99"); !errors.Is(err, ErrCheckpointNotFound) { + t.Fatalf("get unknown = %v, want ErrCheckpointNotFound", err) + } + if _, err := st.GetCheckpoint("ghost", "v1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("get on missing sprite = %v, want ErrNotFound", err) } }