Skip to content

Repository files navigation

spritzer

A standalone, stateful local emulator of the Fly.io Sprites API. Like LocalStack, but for Fly Sprites.

spritzer is the local target for chant's Fly lexicon — its Sprite activities (create, exec, checkpoint, restore) run against spritzer for offline, accountless checkpoint-as-compensation. See the chant docs and the Fly deploy tutorial it powers.

CI Go Reference License: MIT Release GHCR

Purpose

Testing a Sprites client, such as a workflow that checkpoints a sandbox before a risky step and restores it on failure, requires testing against state: a sprite whose filesystem a command mutates, a checkpoint that captures that filesystem, and a restore that rewinds to it. A schema mock holds no state, so it cannot model these behaviors. spritzer keeps sprites, their filesystems, and their checkpoints in memory and runs a small exec interpreter, so a client's checkpoint-as-compensation logic can be exercised end to end offline.

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 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 an ordered list of checkpoints.
  • 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.
  • Single static binary and distroless container image; no runtime dependencies.

Quick start

Run the container:

docker run --rm -p 4290:4290 ghcr.io/intentius/spritzer:latest

Or install with Go:

go install github.com/intentius/spritzer/cmd/spritzer@latest
spritzer            # listens on :4290 by default

Point a Sprites client at it with the same environment variable the real client uses:

export SPRITES_BASE_URL=http://localhost:4290

The listen address is configurable with -addr or SPRITZER_ADDR (default :4290).

Usage example

BASE=http://localhost:4290

# Create a sprite (its name is its id).
curl -s -X POST "$BASE/v1/sprites" -d '{"name":"demo"}'
# => {"id":"demo","url":"http://localhost:4290/s/demo"}

# 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"}'
# => {"type":"info","data":"Creating checkpoint..."}
#    {"type":"info","data":"  ID: v1"}
#    {"type":"complete","data":"Checkpoint v1 created successfully"}

# List the checkpoints (creation order) as a bare array.
curl -s "$BASE/v1/sprites/demo/checkpoints"
# => [{"id":"v1","comment":"pre-run","create_time":"2026-07-11T...Z","is_auto":false}]

# 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"
# => {"type":"info","data":"Restoring checkpoint v1..."}
#    {"type":"complete","data":"Checkpoint v1 restored successfully"}

exec is a control WebSocket at ws://<host>/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]<bytes>, stderr as [2]<bytes>, then [3]<exitCodeByte>. 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).

Container exec mode

The interpreter above is the default and does not change. Set SPRITZER_EXEC=container and each sprite becomes a container instead: exec runs the real command, services keep running after the exec that started them, and the sprite URL reaches whatever listens on the sprite's port 8080. This is the mode for hosting a real workload locally, such as an arugula studio box or a fountain turn whose agent actually runs.

Two runtimes are supported. Kubernetes runs each sprite as a pod sprite-<name> in spritzer's own namespace, using the in-cluster service account. Docker runs each sprite as a container spritzer-<name> through the Docker socket, which is the quick loop on a laptop.

# Docker: spritzer on the host, sprites as containers.
docker build -t spritzer:dev .
SPRITZER_EXEC=container SPRITZER_AGENT_IMAGE=spritzer:dev SPRITZER_URL_DOMAIN=localhost spritzer

curl -s -X POST localhost:4290/v1/sprites -d '{"name":"box"}'
# => {"id":"box","url":"http://box.localhost:4290"}
Variable Default Meaning
SPRITZER_EXEC interpreter container turns this mode on.
SPRITZER_RUNTIME kubernetes in a cluster, else docker Which runtime holds the sprites.
SPRITZER_SPRITE_IMAGE node:22-bookworm The image every sprite runs (Debian with node, git, python3 and curl).
SPRITZER_AGENT_IMAGE ghcr.io/intentius/spritzer:<version> spritzer's own image. Its Linux binary is copied into each sprite as the agent. A development build has no published image, so it must be set.
SPRITZER_NAMESPACE the service account's Kubernetes namespace for sprite pods.
SPRITZER_URL_DOMAIN unset Also serve each sprite at <name>.<domain>, and return that as its URL. localhost works in browsers without DNS.
SPRITZER_CREATE_TIMEOUT 5m How long a create may take, image pulls included.
DOCKER_HOST unix:///var/run/docker.sock Docker runtime only; unix sockets only.

What each part of the API does in this mode:

  • Create starts the container and returns once the agent inside answers. Names must be DNS labels. Destroy removes the container or pod and returns once it is gone. GET /v1/sprites lists the sprites the runtime holds, and a restarted spritzer adopts them.
  • Exec runs the command over the same framed WebSocket, streaming stdout and stderr as they are produced and passing stdin through. Repeated cmd params are an argv; a single cmd holding a command line runs under /bin/sh -c. env (repeatable KEY=value) and dir are honoured. A client that disconnects before the command exits has it killed. There is no TTY yet.
  • Services follow wisp's guest API, which follows Sprites: PUT /v1/sprites/{id}/services/{name} with {"cmd","args","env","dir","needs","http_port"} creates and starts one and streams NDJSON events for ?duration (default 5s); start, stop, restart, logs, signal and DELETE are there too. Inside the sprite, sprite-env services create web --cmd python3 --args -m,http.server,8080 --http-port 8080 talks to the same agent on /.sprite/api.sock, and PUT /v1/tasks there is accepted for keep-awake loops. Definitions live in /.sprite/services, logs in /.sprite/logs/services/<name>.log. Services restart when they crash and all start again when the container restarts.
  • The sprite URL, /s/<name>/... and <name>.<SPRITZER_URL_DOMAIN>, proxies HTTP and WebSocket to the service with an http_port, else port 8080. The path form strips /s/<name> and sends X-Forwarded-Prefix. Nothing listening is a 503 with Retry-After.
  • The filesystem API reads and writes the container's real files. A write applies the requested mode (e.g. ?mode=0755), defaulting to 0644 when none is given, as the Sprites API and wisp do.
  • Checkpoints and restore answer 501 in this mode (#23). Network policy is stored and returned, not enforced: a sprite's egress is not restricted. POST /v1/sprites/{id}/policy/network answers 204 as Sprites does, and GET returns the stored rules. Tasks on the public API are stored, not enforced.

On Kubernetes, spritzer's service account needs pods create, get, list, watch and delete, and pods/exec create and get, in its namespace. deploy/k8s/container-mode.yaml is a complete example. Nothing inside a sprite has to be reachable from spritzer: the agent binary is copied in by an init container (a named volume on Docker), and spritzer reaches services and the URL port by exec'ing spritzer x-relay in the sprite, so the same code works on Docker Desktop and in a cluster.

just e2e-docker and just e2e-real (a throwaway k3d cluster) run the acceptance test in e2e/, which CI also runs. See the container mode docs for the design.

Comparison

Capability spritzer Schema mock Real Sprites
Sprite filesystem that exec mutates Yes No Yes
Checkpoint / restore (rewind on failure) Yes No Yes
Destroyed-sprite 404 semantics Yes No Yes
Runs fully offline Yes Yes No
Cost Free Free Billed
Real code execution, images, services With SPRITZER_EXEC=container No Yes

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.

Development

The primary task runner is just; a Makefile mirrors the same targets.

just build      # compile
just test       # go test ./...
just race       # go test -race ./...
just lint       # golangci-lint
just cover      # coverage profile
just docs-serve # preview the doc site

Contributing

Contributions are welcome. See CONTRIBUTING.md and the Code of Conduct.

License

Licensed under the MIT License. Copyright (c) 2026 Intentius.

About

A standalone, stateful local emulator of the Fly.io Sprites API. Like LocalStack, but for Fly Sprites.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages