diff --git a/CHANGELOG.md b/CHANGELOG.md index da313f0..bd898eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.2.0] - 2026-07-11 + +Corrects the checkpoint/restore surface to the confirmed real Sprites API. The +provisional v0.1.0 shape treated the caller's label as the checkpoint id; the +real API assigns the id and the caller controls only a comment. + +### Changed + +- Checkpoints are now addressed by a server-assigned version id (`v1`, `v2`, …), + assigned sequentially per sprite in creation order, not by a caller label. The + create body is `{comment?}` (an optional string) and the response is `{id}` + (the server id), replacing the previous `{label}` body and `{checkpointId}` + response. +- Restore moved to `POST /v1/sprites/{name}/checkpoints/{id}/restore`, taking the + checkpoint id in the path with an empty body; an unknown id is a `404`. The old + `POST /v1/sprites/{name}/restore` route (with a `{checkpoint}` body) is removed. +- `GET /v1/sprites/{name}` now exposes `checkpoints` as `[{id, comment}]` instead + of a sorted list of labels. +- `/_spritzer/health`'s implemented-path list reflects the new surface (drops the + top-level `.../restore`, adds `.../checkpoints/{id}/restore` and + `GET .../checkpoints`). + +### Added + +- `GET /v1/sprites/{name}/checkpoints` lists a sprite's checkpoints as + `{checkpoints: [{id, comment}]}` in creation order, so a compensation workflow + can pick the newest checkpoint whose comment matches a stable handle. + +### Note + +- The REST `exec` response shape (`{stdout, stderr, exitCode}`) is kept unchanged + but is provisional: real exec is WebSocket-primary and the REST response shape + is not published (`TODO(confirm)`). + ## [0.1.0] - 2026-07-11 ### Added @@ -30,5 +64,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Distroless container image, GoReleaser configuration, mkdocs-material doc site, and CI. -[Unreleased]: https://github.com/intentius/spritzer/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/intentius/spritzer/compare/v0.2.0...HEAD +[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 bae6681..bc5a515 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,15 @@ it, so the same integration suite passes against the spritzer container image. ## Features - Stateful in-memory store of sprites keyed by name, each with a filesystem - (path → contents) and a set of labeled checkpoints. + (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 label; a - restore replaces the filesystem with that copy and returns the sprite to - `running`. This is the checkpoint-as-compensation primitive. +- 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 + checkpoint-as-compensation primitive. - A destroyed or missing sprite returns `404` on any subsequent operation. - A `/_spritzer/health` endpoint reporting version and implemented paths. - Single static binary and distroless container image; no runtime dependencies. @@ -70,19 +72,23 @@ 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. +# 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 '{"label":"pre"}' -# => {"checkpointId":"pre"} +curl -s -X POST "$BASE/v1/sprites/demo/checkpoints" -d '{"comment":"pre-run"}' +# => {"id":"v1"} + +# List the checkpoints (creation order). +curl -s "$BASE/v1/sprites/demo/checkpoints" +# => {"checkpoints":[{"id":"v1","comment":"pre-run"}]} # 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. -curl -s -X POST "$BASE/v1/sprites/demo/restore" -d '{"checkpoint":"pre"}' +# Restore rewinds the filesystem to the checkpoint, addressed by id in the path. +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":["pre"]} +# => {"id":"demo","status":"running","url":"...","fs":{"/state":"good"},"checkpoints":[{"id":"v1","comment":"pre-run"}]} ``` ## Comparison @@ -98,8 +104,8 @@ curl -s "$BASE/v1/sprites/demo" ## API coverage -Implemented: create, exec, checkpoint, restore, destroy, and an inspection -`GET`, plus a `/_spritzer/health` report. The full table is in the +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/). ## Development diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 02c7860..6d5a29f 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -10,13 +10,21 @@ 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}`. | -| POST | `/v1/sprites/{id}/checkpoints` | Deep-copy the filesystem under a label (default `cp-`). Returns `{checkpointId}`. | -| POST | `/v1/sprites/{id}/restore` | Replace the filesystem with a labeled checkpoint; `404` if the label is unknown. | +| 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. | | DELETE | `/v1/sprites/{id}` | Destroy a sprite. Subsequent operations return `404`. | -| GET | `/v1/sprites/{id}` | Inspect a sprite: `{id, status, url, fs, checkpoints}`. | +| GET | `/v1/sprites/{id}` | Inspect a sprite: `{id, status, url, fs, checkpoints}` (checkpoints as `[{id, comment}]`). | | 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 +`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. + ## The exec interpreter `exec` is not a real shell. A command is split on `;` into segments that run in @@ -39,7 +47,7 @@ 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`, `checkpointId`, +(`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. diff --git a/docs/fidelity.md b/docs/fidelity.md index b3bc99a..9c2bdd9 100644 --- a/docs/fidelity.md +++ b/docs/fidelity.md @@ -31,12 +31,16 @@ matching shell `;` semantics. ## Checkpoint and restore -A checkpoint deep-copies the filesystem under a label (an omitted label defaults -to `cp-`, one past the current count). A restore replaces the filesystem with -that copy and returns the sprite to `running`; restoring an unknown label is a -`404`. 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. +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 +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. ## What spritzer does not do diff --git a/docs/getting-started.md b/docs/getting-started.md index 44dcbb2..75961c4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -41,13 +41,13 @@ 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, then corrupt it and fail. +# 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 '{"label":"pre"}' +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"}' -# Restore rewinds the filesystem to the checkpoint. -curl -s -X POST "$BASE/v1/sprites/demo/restore" -d '{"checkpoint":"pre"}' +# Restore rewinds the filesystem to the checkpoint, by id in the path. +curl -s -X POST "$BASE/v1/sprites/demo/checkpoints/v1/restore" curl -s "$BASE/v1/sprites/demo" | jq '{id, status, fs, checkpoints}' ``` diff --git a/docs/index.md b/docs/index.md index c693c0d..13bf681 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,10 +14,11 @@ client talks to spritzer instead of the real service. Testing a Sprites client means testing against state. A command run with `exec` mutates a sprite's filesystem. A checkpoint captures that filesystem under a -label. A restore rewinds to it. This is the checkpoint-as-compensation pattern: -a workflow checkpoints before a risky step and, on failure, restores the label -instead of unwinding with an inverse action. A schema mock has no memory, so it -cannot model any of that. spritzer does. +server-assigned version id (`v1`, `v2`, …). A restore rewinds to it. This is the +checkpoint-as-compensation pattern: a workflow checkpoints before a risky step +and, on failure, restores that checkpoint instead of unwinding with an inverse +action. A schema mock has no memory, so it cannot model any of that. spritzer +does. spritzer is wire-compatible with the in-process Sprites fake in the `chant` lexicon (`sprites-fake.ts`): the endpoint shapes and the exec interpreter match diff --git a/internal/server/server.go b/internal/server/server.go index 5ec89f4..1f203bf 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -21,7 +21,8 @@ var implementedPaths = []string{ "POST /v1/sprites", "POST /v1/sprites/{id}/exec", "POST /v1/sprites/{id}/checkpoints", - "POST /v1/sprites/{id}/restore", + "GET /v1/sprites/{id}/checkpoints", + "POST /v1/sprites/{id}/checkpoints/{cid}/restore", "DELETE /v1/sprites/{id}", "GET /v1/sprites/{id}", "GET /_spritzer/health", @@ -71,7 +72,8 @@ func (s *Server) routes() { 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("POST /v1/sprites/{id}/restore", s.restoreSprite) + mux.HandleFunc("GET /v1/sprites/{id}/checkpoints", s.listCheckpoints) + 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) @@ -101,19 +103,23 @@ type execRequest struct { Cmd string `json:"cmd"` } -// checkpointRequest is the body of POST /v1/sprites/{id}/checkpoints. +// checkpointRequest is the body of POST /v1/sprites/{id}/checkpoints. The +// caller supplies only an optional comment; the checkpoint id is +// server-assigned. type checkpointRequest struct { - Label string `json:"label,omitempty"` + Comment string `json:"comment,omitempty"` } -// checkpointResponse is the POST /v1/sprites/{id}/checkpoints response. +// checkpointResponse is the POST /v1/sprites/{id}/checkpoints response, carrying +// the server-assigned version id (v1, v2, …). type checkpointResponse struct { - CheckpointID string `json:"checkpointId"` + ID string `json:"id"` } -// restoreRequest is the body of POST /v1/sprites/{id}/restore. -type restoreRequest struct { - Checkpoint string `json:"checkpoint"` +// listCheckpointsResponse is the GET /v1/sprites/{id}/checkpoints response, the +// checkpoints in creation order (oldest first). +type listCheckpointsResponse struct { + Checkpoints []sprite.CheckpointInfo `json:"checkpoints"` } // ErrorResponse is the JSON body spritzer returns for any non-2xx status. It @@ -138,6 +144,9 @@ 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) { id := r.PathValue("id") var req execRequest @@ -157,22 +166,28 @@ func (s *Server) checkpointSprite(w http.ResponseWriter, r *http.Request) { if !s.decodeJSON(w, r, &req) { return } - label, err := s.store.Checkpoint(id, req.Label) + cid, err := s.store.Checkpoint(id, req.Comment) if s.handleLookupError(w, id, err) { return } - writeJSON(w, http.StatusCreated, checkpointResponse{CheckpointID: label}) + writeJSON(w, http.StatusCreated, checkpointResponse{ID: cid}) } -func (s *Server) restoreSprite(w http.ResponseWriter, r *http.Request) { +func (s *Server) listCheckpoints(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - var req restoreRequest - if !s.decodeJSON(w, r, &req) { + cps, err := s.store.ListCheckpoints(id) + if s.handleLookupError(w, id, err) { return } - err := s.store.Restore(id, req.Checkpoint) + writeJSON(w, http.StatusOK, listCheckpointsResponse{Checkpoints: cps}) +} + +func (s *Server) restoreCheckpoint(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + cid := r.PathValue("cid") + err := s.store.Restore(id, cid) if errors.Is(err, sprite.ErrCheckpointNotFound) { - s.writeError(w, http.StatusNotFound, "no checkpoint \""+req.Checkpoint+"\" for sprite "+id) + s.writeError(w, http.StatusNotFound, "no checkpoint \""+cid+"\" for sprite "+id) return } if s.handleLookupError(w, id, err) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index fe645b2..ee7cb5e 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -132,15 +132,27 @@ func TestFullLoop(t *testing.T) { t.Fatalf("exec write exitCode = %d, want 0", ex.ExitCode) } - // checkpoint - code, body = h.do(http.MethodPost, "/v1/sprites/s1/checkpoints", map[string]any{"label": "pre"}) + // 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.CheckpointID != "pre" { - t.Fatalf("checkpointId = %q, want pre", cp.CheckpointID) + if cp.ID != "v1" { + t.Fatalf("checkpoint id = %q, want v1", cp.ID) + } + + // list checkpoints reports v1 with its comment + 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 + 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) } // exec: corrupt via risky.sh (exit 1) — the server still returns 200 with @@ -164,18 +176,21 @@ func TestFullLoop(t *testing.T) { Status string `json:"status"` URL string `json:"url"` FS map[string]string `json:"fs"` - Checkpoints []string `json:"checkpoints"` + Checkpoints []struct { + ID string `json:"id"` + Comment string `json:"comment"` + } `json:"checkpoints"` } h.mustJSON(body, &view) if view.FS["/state"] != "bad" || view.FS["/work/output"] != "partial-corrupt" { t.Fatalf("fs before restore = %v, want corrupt", view.FS) } - if len(view.Checkpoints) != 1 || view.Checkpoints[0] != "pre" { - t.Fatalf("checkpoints = %v, want [pre]", view.Checkpoints) + if len(view.Checkpoints) != 1 || view.Checkpoints[0].ID != "v1" || view.Checkpoints[0].Comment != "pre-run" { + t.Fatalf("checkpoints = %+v, want [{v1 pre-run}]", view.Checkpoints) } - // restore - if code, body := h.do(http.MethodPost, "/v1/sprites/s1/restore", map[string]any{"checkpoint": "pre"}); code != http.StatusOK { + // restore by id in the path + if code, body := h.do(http.MethodPost, "/v1/sprites/s1/checkpoints/v1/restore", nil); code != http.StatusOK { t.Fatalf("restore = %d %s", code, body) } @@ -211,8 +226,9 @@ func TestFullLoop(t *testing.T) { } } -// TestDefaultCheckpointLabelOverHTTP confirms an omitted label yields cp-. -func TestDefaultCheckpointLabelOverHTTP(t *testing.T) { +// TestCheckpointVersionIDOverHTTP confirms the server assigns v1 for the first +// checkpoint even when the body carries no comment. +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) @@ -223,18 +239,34 @@ func TestDefaultCheckpointLabelOverHTTP(t *testing.T) { } var cp checkpointResponse h.mustJSON(body, &cp) - if cp.CheckpointID != "cp-1" { - t.Fatalf("default checkpointId = %q, want cp-1", cp.CheckpointID) + if cp.ID != "v1" { + t.Fatalf("checkpoint id = %q, want v1", cp.ID) + } +} + +// TestListCheckpointsEmpty confirms a sprite with no checkpoints lists as an +// empty array, not null. +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 { + t.Fatalf("create = %d %s", code, body) + } + code, body := h.do(http.MethodGet, "/v1/sprites/s/checkpoints", nil) + 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) } } -// TestRestoreUnknownCheckpoint404 confirms an unknown label is a 404. +// TestRestoreUnknownCheckpoint404 confirms an unknown id in the path is a 404. 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 { t.Fatalf("create = %d %s", code, body) } - code, body := h.do(http.MethodPost, "/v1/sprites/s/restore", map[string]any{"checkpoint": "ghost"}) + code, body := h.do(http.MethodPost, "/v1/sprites/s/checkpoints/v99/restore", nil) if code != http.StatusNotFound { t.Fatalf("restore unknown = %d %s, want 404", code, body) } @@ -255,6 +287,8 @@ func TestOpsOnMissingSprite(t *testing.T) { {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.MethodGet, "/v1/sprites/ghost/checkpoints", nil}, + {http.MethodPost, "/v1/sprites/ghost/checkpoints/v1/restore", nil}, {http.MethodDelete, "/v1/sprites/ghost", nil}, } { if code, body := h.do(tc.method, tc.path, tc.body); code != http.StatusNotFound { diff --git a/internal/sprite/sprite.go b/internal/sprite/sprite.go index bc2ab7c..e514099 100644 --- a/internal/sprite/sprite.go +++ b/internal/sprite/sprite.go @@ -4,15 +4,18 @@ // // A sprite's filesystem is modeled as a path -> contents map. exec runs a small // scripted interpreter (see exec.go) that can write or modify an fs key, so a -// checkpoint (a deep copy of the fs under a label) and a later restore (replace -// the fs with that copy) are observable. This mirrors the behavior of chant's -// in-process Sprites fake rather than real code execution. +// checkpoint (a deep copy of the fs under a server-assigned version id) and a +// later restore (replace the fs with that copy) are observable. This mirrors the +// behavior of chant's in-process Sprites fake rather than real code execution. +// +// Checkpoints are addressed by a server-assigned version id (v1, v2, …), not by +// a caller label. The caller supplies only an optional comment; the store +// assigns the id sequentially per sprite in creation order. package sprite import ( "errors" "fmt" - "sort" "sync" "github.com/intentius/spritzer/internal/clock" @@ -35,19 +38,37 @@ var ( // ErrNotFound is returned for a sprite that was never created or has been // destroyed. A destroyed sprite is treated as absent, matching the fake. ErrNotFound = errors.New("sprite not found") - // ErrCheckpointNotFound is returned by Restore for an unknown label. + // ErrCheckpointNotFound is returned by Restore for an unknown checkpoint id. ErrCheckpointNotFound = errors.New("checkpoint not found") ) +// 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. +type Checkpoint struct { + ID string + Comment string + 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). +type CheckpointInfo struct { + ID string `json:"id"` + Comment string `json:"comment"` +} + // Sprite is a single sprite: its lifecycle status, its addressable URL, its -// filesystem, and its checkpoints (each a full copy of the fs at checkpoint -// time, keyed by label). +// filesystem, and its checkpoints (an ordered list, each a full copy of the fs +// at checkpoint time under a sequential v id). type Sprite struct { ID string Status Status URL string FS map[string]string - Checkpoints map[string]map[string]string + Checkpoints []Checkpoint Policy any // CreatedAt is stamped from the injected clock at creation. It is internal // bookkeeping and is not part of the wire contract. @@ -55,7 +76,13 @@ type Sprite struct { } // ExecResult is the outcome of running a command in a sprite. The JSON tags -// match the Sprites exec response (note the camelCase exitCode). +// 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. type ExecResult struct { Stdout string `json:"stdout"` Stderr string `json:"stderr"` @@ -63,13 +90,14 @@ type ExecResult struct { } // View is the read-only projection returned by GET /v1/sprites/{id}: the -// checkpoints are exposed as a sorted list of labels, not their fs copies. +// checkpoints are exposed as an ordered list of {id, comment} projections, not +// their fs copies. type View struct { ID string `json:"id"` Status Status `json:"status"` URL string `json:"url"` FS map[string]string `json:"fs"` - Checkpoints []string `json:"checkpoints"` + Checkpoints []CheckpointInfo `json:"checkpoints"` } // Store holds sprites keyed by id. @@ -98,7 +126,7 @@ func (s *Store) Create(id, url string, policy any) Sprite { Status: StatusRunning, URL: url, FS: map[string]string{}, - Checkpoints: map[string]map[string]string{}, + Checkpoints: nil, Policy: policy, CreatedAt: s.clk.Now().UTC().Format("2006-01-02T15:04:05.999999999Z07:00"), } @@ -118,41 +146,59 @@ func (s *Store) Exec(id, cmd string) (ExecResult, error) { return execInto(sp, cmd), nil } -// Checkpoint deep-copies the sprite's current filesystem under a label and -// returns the checkpoint id. An empty label defaults to "cp-", where n is one -// past the current checkpoint count. It returns ErrNotFound for a missing or -// destroyed sprite. -func (s *Store) Checkpoint(id, label string) (string, error) { +// Checkpoint deep-copies the sprite's current filesystem under a fresh, +// server-assigned version id and returns that id. Ids are assigned sequentially +// per sprite: "v1", "v2", …, one past the current checkpoint count. The caller +// controls only the comment, which may be empty. It returns ErrNotFound for a +// missing or destroyed sprite. +func (s *Store) Checkpoint(id, comment string) (string, error) { s.mu.Lock() defer s.mu.Unlock() sp, err := s.live(id) if err != nil { return "", err } - if label == "" { - label = fmt.Sprintf("cp-%d", len(sp.Checkpoints)+1) + cid := fmt.Sprintf("v%d", len(sp.Checkpoints)+1) + sp.Checkpoints = append(sp.Checkpoints, Checkpoint{ + ID: cid, + Comment: comment, + FS: cloneFS(sp.FS), + }) + return cid, nil +} + +// 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. +func (s *Store) ListCheckpoints(id string) ([]CheckpointInfo, error) { + s.mu.Lock() + defer s.mu.Unlock() + sp, err := s.live(id) + if err != nil { + return nil, err } - sp.Checkpoints[label] = cloneFS(sp.FS) - return label, nil + return checkpointInfos(sp.Checkpoints), nil } -// Restore replaces the sprite's filesystem with the labeled checkpoint's copy -// and sets its status back to running. It returns ErrNotFound for a missing or -// destroyed sprite, and ErrCheckpointNotFound for an unknown label. -func (s *Store) Restore(id, checkpoint string) error { +// Restore replaces the sprite's filesystem with the identified checkpoint's copy +// and sets its status back to running. The checkpoint is addressed by its +// server-assigned version id (v1, v2, …). It returns ErrNotFound for a missing +// or destroyed sprite, and ErrCheckpointNotFound for an unknown id. +func (s *Store) Restore(id, checkpointID string) error { s.mu.Lock() defer s.mu.Unlock() sp, err := s.live(id) if err != nil { return err } - snap, ok := sp.Checkpoints[checkpoint] - if !ok { - return ErrCheckpointNotFound + for i := range sp.Checkpoints { + if sp.Checkpoints[i].ID == checkpointID { + sp.FS = cloneFS(sp.Checkpoints[i].FS) + sp.Status = StatusRunning + return nil + } } - sp.FS = cloneFS(snap) - sp.Status = StatusRunning - return nil + return ErrCheckpointNotFound } // Destroy marks the sprite destroyed. It returns ErrNotFound if the sprite is @@ -177,20 +223,25 @@ func (s *Store) Get(id string) (View, error) { if err != nil { return View{}, err } - labels := make([]string, 0, len(sp.Checkpoints)) - for l := range sp.Checkpoints { - labels = append(labels, l) - } - sort.Strings(labels) return View{ ID: sp.ID, Status: sp.Status, URL: sp.URL, FS: cloneFS(sp.FS), - Checkpoints: labels, + Checkpoints: checkpointInfos(sp.Checkpoints), }, nil } +// checkpointInfos projects a checkpoint list to its id+comment 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}) + } + return out +} + // 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) { @@ -207,9 +258,9 @@ func cloneSprite(sp *Sprite) *Sprite { c := *sp c.FS = cloneFS(sp.FS) if sp.Checkpoints != nil { - cps := make(map[string]map[string]string, len(sp.Checkpoints)) - for label, fs := range sp.Checkpoints { - cps[label] = cloneFS(fs) + 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)} } c.Checkpoints = cps } diff --git a/internal/sprite/sprite_test.go b/internal/sprite/sprite_test.go index 97449f2..bbcd4da 100644 --- a/internal/sprite/sprite_test.go +++ b/internal/sprite/sprite_test.go @@ -135,8 +135,8 @@ func TestExecInterpreter(t *testing.T) { } // TestCheckpointRestoreRewind is the headline proof: create -> write a key -> -// checkpoint -> run the risky step (corrupts fs, exits 1) -> restore -> the key -// is rewound and status is running again. +// checkpoint (server assigns v1) -> run the risky step (corrupts fs, exits 1) -> +// restore v1 -> the key is rewound and status is running again. func TestCheckpointRestoreRewind(t *testing.T) { st := New(clock.NewFake(time.Time{})) st.Create("guard-1", "http://localhost/s/guard-1", nil) @@ -145,10 +145,10 @@ func TestCheckpointRestoreRewind(t *testing.T) { if r, err := st.Exec("guard-1", "echo good > /state"); err != nil || r.ExitCode != 0 { t.Fatalf("seed exec = %+v, %v", r, err) } - // Checkpoint the good state. - label, err := st.Checkpoint("guard-1", "pre-run") - if err != nil || label != "pre-run" { - t.Fatalf("checkpoint = %q, %v", label, err) + // Checkpoint the good state with a comment; the server assigns id v1. + cid, err := st.Checkpoint("guard-1", "pre-run") + if err != nil || cid != "v1" { + t.Fatalf("checkpoint = %q, %v, want v1", cid, err) } // Run the risky step: overwrites /state then fails. r, err := st.Exec("guard-1", "echo bad > /state; ./risky.sh") @@ -163,8 +163,8 @@ func TestCheckpointRestoreRewind(t *testing.T) { if view.FS["/state"] != "bad" || view.FS["/work/output"] != "partial-corrupt" { t.Fatalf("fs before restore = %v, want corrupt", view.FS) } - // Restore rewinds the fs to the checkpoint (only /state=good) and status runs. - if err := st.Restore("guard-1", "pre-run"); err != nil { + // Restore v1 rewinds the fs to the checkpoint (only /state=good) and runs. + if err := st.Restore("guard-1", cid); err != nil { t.Fatalf("restore = %v", err) } view, _ = st.Get("guard-1") @@ -176,14 +176,46 @@ func TestCheckpointRestoreRewind(t *testing.T) { } } -// TestDefaultCheckpointLabel confirms an empty label defaults to cp-. -func TestDefaultCheckpointLabel(t *testing.T) { +// TestCheckpointVersionIDs confirms ids are server-assigned sequentially (v1, +// v2, …) regardless of the caller's comment, and the list reports them in +// creation order with their comments. +func TestCheckpointVersionIDs(t *testing.T) { st := New(nil) st.Create("s", "http://h/s/s", nil) - first, _ := st.Checkpoint("s", "") - second, _ := st.Checkpoint("s", "") - if first != "cp-1" || second != "cp-2" { - t.Fatalf("default labels = %q, %q, want cp-1, cp-2", first, second) + first, _ := st.Checkpoint("s", "pre-run") + second, _ := st.Checkpoint("s", "") // empty comment still gets a version id + if first != "v1" || second != "v2" { + t.Fatalf("checkpoint ids = %q, %q, want v1, v2", first, second) + } + cps, err := st.ListCheckpoints("s") + 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) + } +} + +// TestRestoreByIDRewinds confirms restoring an explicit earlier id (v1) rewinds +// the fs to that checkpoint even after a later checkpoint (v2) captured newer +// state. +func TestRestoreByIDRewinds(t *testing.T) { + st := New(nil) + st.Create("s", "http://h/s/s", nil) + _, _ = st.Exec("s", "echo one > /f") + v1, _ := st.Checkpoint("s", "first") + _, _ = st.Exec("s", "echo two > /f") + v2, _ := st.Checkpoint("s", "second") + if v1 != "v1" || v2 != "v2" { + t.Fatalf("ids = %q, %q, want v1, v2", v1, v2) + } + if err := st.Restore("s", "v1"); err != nil { + t.Fatalf("restore v1 = %v", err) + } + view, _ := st.Get("s") + if view.FS["/f"] != "one" { + t.Fatalf("restored /f = %q, want one (restore-by-id must target v1)", view.FS["/f"]) } } @@ -212,12 +244,12 @@ func TestDestroyedSpriteIsNotFound(t *testing.T) { } } -// TestRestoreUnknownCheckpoint confirms an unknown label reports +// TestRestoreUnknownCheckpoint confirms an unknown id reports // ErrCheckpointNotFound. func TestRestoreUnknownCheckpoint(t *testing.T) { st := New(nil) st.Create("s", "http://h/s/s", nil) - if err := st.Restore("s", "nope"); !errors.Is(err, ErrCheckpointNotFound) { + if err := st.Restore("s", "v99"); !errors.Is(err, ErrCheckpointNotFound) { t.Fatalf("restore unknown = %v, want ErrCheckpointNotFound", err) } } @@ -228,9 +260,9 @@ func TestCheckpointIsDeepCopied(t *testing.T) { st := New(nil) st.Create("s", "http://h/s/s", nil) _, _ = st.Exec("s", "echo one > /f") - _, _ = st.Checkpoint("s", "cp") + cid, _ := st.Checkpoint("s", "cp") _, _ = st.Exec("s", "echo two > /f") // mutate after checkpoint - if err := st.Restore("s", "cp"); err != nil { + if err := st.Restore("s", cid); err != nil { t.Fatalf("restore = %v", err) } view, _ := st.Get("s")