Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
30 changes: 18 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 13 additions & 5 deletions docs/api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<n>`). 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
Expand All @@ -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.
16 changes: 10 additions & 6 deletions docs/fidelity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<n>`, 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

Expand Down
8 changes: 4 additions & 4 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
```

Expand Down
9 changes: 5 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 31 additions & 16 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand Down
Loading