snowglobe

Orchestration

Many machines from one site, without KVM, root or Docker: snowglobe daemon restores sites once as templates and forks machines from them in milliseconds, keeps snapshots of running ones, puts services behind ports that scale to zero, holds them all to a memory budget, and applies whole fleets from a file.

In a browser: snowglobe ui

snowglobe ui shows the daemon in your browser: the last hour on one time axis (memory against the budget, templates running, jobs and their latency, what happened), a lane per template with each machine's life as a bar, and under them whatever you select, with its terminal one click away. It also runs commands on fresh forks, builds sites from a directory, an image or a Dockerfile you write there, and manages templates, machines, services, snapshots and ports.

snowglobe ui                                     # opens http://127.0.0.1:7777/?token=…
snowglobe ui --start-daemon                      # starts a detached daemon first if none runs

It listens on 127.0.0.1 only. The link it prints carries a session token that becomes a cookie, and every request needs it, so other pages you visit can't reach the daemon through it. The terminal is ghostty-web, Ghostty's terminal compiled to WebAssembly, joined to the machine's console over a WebSocket.

Templates and machines: the daemon

snowglobe daemon keeps templates (a site restored once) and the machines forked from them. snowglobe up forks a machine from a template in tens of milliseconds. The machine shares the template's memory, lasts until it is stopped, keeps what it writes, and has its ports forwarded by the daemon (its site needs a network for that). sh, cp, exec, attach, ps and stop reach the daemon's machines as they reach instances.

snowglobe daemon --detach                        # in the background, its log in daemon.log beside the socket
snowglobe template add dist/api --name api       # the python example built with --network fetch
snowglobe up api --name web -p 8080:8000 --label app=web
snowglobe up api --name worker                   # another machine, its own state
snowglobe sh web 'python3 -m http.server 8000 &'
snowglobe ps
snowglobe stop worker
snowglobe template rm api                        # and its machines

The API is HTTP/JSON on a Unix socket in the user cache (curl --unix-socket works): /v1/templates, /v1/machines, /v1/machines/{name}/jobs and /v1/templates/{name}/jobs (a job on a fresh fork). --listen ADDR --token-file F also serves it over TCP, to requests bearing the token, and SNOWGLOBE_DAEMON=http://HOST:PORT with SNOWGLOBE_DAEMON_TOKEN points the CLI at it. A daemon started again takes back the templates and machines that are still running.

It also builds: POST /v1/builds takes a directory with a Dockerfile (by its full path), an image reference, or a Dockerfile's text, runs snowglobe build (Docker when it's there; an image builds natively without it), and makes the result a template. /v1/builds/{id}/log streams its output (?follow=1). A machine's terminal is a WebSocket at /v1/machines/{name}/attach?rows=R&cols=C. /v1/metrics keeps a sample every 5 s for the last hour (memory, machines, replicas, jobs and their latency), and /metrics serves the same in Prometheus's format.

Fire and forget: submit and runs

snowglobe submit runs a command once on a fresh fork of a template and returns at once with the run's id. The fork is thrown away when the command ends; its output and exit status are kept for snowglobe runs. --wait waits for the end and exits with the command's status. The API is POST /v1/runs and GET /v1/runs/{id}.

id=$(snowglobe submit py 'python3 report.py' --env DAY=monday --timeout 600)
snowglobe runs                                   # every run, newest first
snowglobe runs $id --wait                        # its output; exits with its status

Snapshots of running machines

snowglobe snapshot save saves a machine whole: its memory, its running processes and the files it wrote. Machines start from it again with up --snapshot, or it becomes a template of its own with template add --snapshot. Set a machine up once (dependencies installed, a server started, data loaded), then fork as many as you want, already set up.

snowglobe up api --name base
snowglobe sh base 'pip install httpx && python3 -m http.server 8000 &'
snowglobe snapshot save base --name ready        # tens of milliseconds
snowglobe template add --snapshot ready --name api-ready
snowglobe up api-ready -p 8000                    # installed, its server already listening
snowglobe snapshot ls
snowglobe snapshot rm ready

Services and scale to zero

A service puts a template's machines behind host ports that the daemon holds. Each connection (or snowglobe sh <service>) goes to one of its replicas, started on demand. With --min 0 it scales to zero: replicas with no connection or job for --idle stop, and the next request forks one again. Made of a snapshot whose server was already listening, a service at zero answers its first request in about 20 ms. --state keep saves the replica before it stops, and the next one carries on from that save (one replica); --state discard, the default, starts every replica from the template.

snowglobe service add web --template api-ready -p 8080:8000 --min 0 --max 3 --idle 5m --concurrency 10
snowglobe service add db --template pg --state keep --idle 10m
snowglobe service ls                              # replicas running now, of each
snowglobe service rm web

A replica takes --concurrency connections before another is started, up to --max; under --min, replicas that stop are replaced. The API is /v1/services and /v1/services/{name}/jobs.

Ports

snowglobe ports lists every host port the daemon holds and where it leads: the machine or service, the guest port and whether it is open now (a service's stays open at zero). snowglobe ports check says whether a host port is free, or which machine, service or other program holds it, and exits 1 when it is taken. The UI shows the same, and checks the ports you type as you type them. The API is /v1/ports and /v1/ports/check?port=N&ip=IP.

snowglobe ports
HOST             GUEST  KIND     NAME  TEMPLATE  STATE
127.0.0.1:18081  8000   machine  web   curl      open
127.0.0.1:18090  8000   service  api   curl      at zero
snowglobe ports check 8080                       # 127.0.0.1:8080 is free

Limits and lifecycle

snowglobe daemon --memory 8G caps what its template servers and machines take together. A machine that doesn't fit waits up to --wait (30 s) for room, then is refused with the reason. Linux measures each process's proportional share; macOS its resident memory, which counts pages shared with the template in full, so the cap is reached sooner. No cap unless asked. up --ttl 1h stops a machine after that long, whatever it is doing. template add --idle 30m stops a template's server after that long without a machine; the next up (or job) restores it first, in about a second.

snowglobe daemon --memory 8G --wait 30s &
snowglobe template add dist/python --name py --idle 30m
snowglobe up py --ttl 1h

Events and top

snowglobe top shows the daemon live: memory against its budget, templates (awake or asleep), services with their replicas and open connections, machines with what each takes and when it stops, and the latest events. snowglobe events streams those events (machines up and stopped, replicas started and stopped, templates asleep and awake, snapshots, admissions refused), the last 200 first; --json for programs. The API is /v1/events (server-sent events, ?after=SEQ to resume) and /v1/stats.

snowglobe top
snowglobe events --json | jq 'select(.kind == "admission.refused")'

Fleets: snowglobe apply

A snowglobe.toml says what the daemon should have, and snowglobe apply gets it there: templates (a built site, a URL, or a Dockerfile directory built first), snapshots (a machine of a template set up by commands, saved once a readiness command succeeds), templates of those snapshots, services and machines. snowglobe diff says what apply would do; snowglobe down removes all of it. An entry that changed is replaced, with what is made from it; one gone from the file is removed; names something else uses are refused. One file goes from source to running services (examples/fleet/snowglobe.toml):

[templates.py]
build = "../python"                # built first, for running natively
network = "fetch"

[snapshots.serving]
template = "py"
run = ["cd /srv && python3 -m http.server 8000 >/dev/null 2>&1 &"]
ready = "wget -q -O /dev/null http://127.0.0.1:8000/"

[templates.serving]
snapshot = "serving"

[services.web]
template = "serving"
ports = ["8080:8000"]
min = 0                            # scaled to zero, woken by a request
idle = "5m"

[machines.scratch]
template = "py"
ttl = "24h"