A local CLI to spin up Hetzner Cloud dev VMs from a single cloud-config
template, verify each one is dev-ready, and manage their lifecycle.
Each VM is reachable at https://<name>.<base-domain> via Caddy with a
Let's Encrypt cert.
Single operator, single workstation. State lives in ~/.dev-orchestrator/.
No daemon, no remote DB, no async — concurrent.futures.ThreadPoolExecutor
with hard caps (5 create, 8 test, 3 power/destroy).
▸ The workshop admin dashboard — live pool, claims, costs, and one-click re-test.
uv handles the Python version and virtualenv
automatically. Install uv once (curl -LsSf https://astral.sh/uv/install.sh | sh),
then:
git clone git@github.com:casushq/orchestrator.git
cd orchestrator
uv syncuv sync reads pyproject.toml, fetches Python 3.11 if needed, creates .venv,
and installs all dependencies plus the dev console script. Run it as
.venv/bin/dev … or activate first (source .venv/bin/activate).
To include the test dependencies:
uv sync --extra devRequires Python 3.11+.
git clone git@github.com:casushq/orchestrator.git
cd orchestrator
python3.11 -m venv .venv
.venv/bin/pip install -e .The console script dev is installed in .venv/bin/. Either activate the
venv (source .venv/bin/activate) or call it directly (.venv/bin/dev …).
On macOS with pyenv (python3.11: command not found even though
pyenv versions lists 3.11.x), the global pyenv version is older.
Either pin pyenv for this directory:
pyenv local 3.11.13 # writes .python-version
python -m venv .venv
.venv/bin/pip install -e .…or call the binary directly:
~/.pyenv/versions/3.11.13/bin/python -m venv .venv
.venv/bin/pip install -e .Before dev init, have these set up once on the Hetzner side:
- Hetzner Cloud project with an SSH key uploaded.
- A DNS zone in the Hetzner Console whose name is
BASE_DOMAINor any parent of it (e.g.casus.orgworks for a base ofdev.casus.org, and a delegateddev.casus.orgzone works too). DNS lives on the unified Cloud API now (api.hetzner.cloud); the legacydns.hetzner.comconsole + token are deprecated as of May 2026, so the orchestrator uses the sameHETZNER_TOKENfor both compute and DNS.
The orchestrator manages A records inside the matching zone — dev create
adds <name>.<base-domain> → VM IPv4, dev destroy removes it. Zone
discovery is automatic: it picks the longest-suffix-match against
BASE_DOMAIN from your project's zones.
dev initInteractively prompts for every value below, validates the Hetzner Cloud
token, the SSH key existence, and the Hetzner DNS token + zone, then
writes ~/.dev-orchestrator/.env with mode 0600 and creates an empty
state.db.
| Field | Notes |
|---|---|
HETZNER_TOKEN |
Hetzner Cloud API token. Used for both compute and DNS — DNS lives on the unified api.hetzner.cloud now. Validated against the API. |
BASE_DOMAIN |
Suffix for VM FQDNs (e.g. dev.casus.org). VMs reachable as <name>.<base-domain>. The orchestrator picks the matching DNS zone automatically. |
CADDY_EMAIL |
Email registered with Let's Encrypt for the wildcard cert account. Receives expiry notifications. |
SSH_KEY_NAME |
Name of an SSH key already uploaded to your Hetzner project. Existence is verified. |
SSH_PRIVATE_KEY_PATH |
Path on your workstation; used by dev ssh, dev logs, dev sync, and the SSH-based health checks. ~ is expanded. |
USERNAME |
Login user provisioned inside each VM. Default human. Used as the SSH login account, sudoers entry, and home-directory owner. |
USER_PASSWORD_HASH |
SHA-512 ($6$…) password hash for the USERNAME account inside each VM. dev init asks for the password as plaintext (entered twice, masked) and hashes it for you — this field stores the resulting hash, never the plaintext. |
SSH_PUBKEY |
Contents of the public key matching SSH_PRIVATE_KEY_PATH. The wizard auto-detects this if <key>.pub exists. |
OPENROUTER_API_KEY |
Baked into ~/.pi/agent/auth.json on each VM. |
GITHUB_TOKEN |
Optional; only needed if BOILERPLATE_REPO is private. |
OFF_S3_ENDPOINT |
Optional. Hetzner Object Storage endpoint, e.g. https://nbg1.your-objectstorage.com. When set, VMs download parquet files from S3 instead of HuggingFace. |
OFF_S3_BUCKET |
Optional. S3 bucket name, e.g. openfoodfacts-dataharvest-2026. Pushed to VMs as a public download URL (no credentials). |
OFF_S3_ACCESS_KEY |
Optional. Hetzner Object Storage access key ID. Required by scripts/setup_object_storage.py and scripts/upload_parquet.py on the operator machine. Not pushed to VMs. |
OFF_S3_SECRET_KEY |
Optional. Hetzner Object Storage secret key. Required by the upload scripts. Not pushed to VMs. |
BOILERPLATE_REPO |
Format: github.com/owner/repo — no https:// prefix, no .git suffix, no git@github.com: SSH form. The cloud-config builds the HTTPS URL from this. |
DEFAULT_SERVER_TYPE, DEFAULT_LOCATION, DEFAULT_IMAGE, DEFAULT_MODEL |
Defaults: cx23, nbg1, ubuntu-24.04, anthropic/claude-sonnet-4.6. DEFAULT_IMAGE accepts a system/app image name (e.g. ubuntu-24.04, docker-ce), a numeric image id, or the description of a snapshot/backup in your project (e.g. claude-pink). |
DEV_PORT |
Port Caddy reverse-proxies to (i.e. where the participant runs their dev server). Default 3000. |
Output:
~/.dev-orchestrator/.env(mode 0600)~/.dev-orchestrator/state.db(servers + test_runs schema)~/.dev-orchestrator/logs/(per-day append-only logs)~/.dev-orchestrator/certs/(wildcard cert + key + ACME account key, 0600)
At the end of dev init, the wizard offers to obtain the wildcard cert
inline (~30–60s). Decline and run dev cert init later if you prefer.
dev create hard-fails without the cert, so it must exist before you
provision any VMs.
Per-VM Let's Encrypt issuance hits the 5-certs-per-week-per-identifier
rate limit fast in workshop scenarios (a few destroy/recreate cycles for
the same hostname is enough). The orchestrator instead obtains a single
*.<base-domain> + <base-domain> cert via DNS-01 against your Hetzner
DNS zone, and pushes it into every VM's cloud-config. Caddy on each VM
serves the static cert — it never talks to ACME, so it can't be rate
limited regardless of how many times you provision the same name.
dev cert init # obtain a fresh cert (overwrites any existing)
dev cert init --staging # use Let's Encrypt staging (untrusted, no rate limit)
dev cert renew # renew if expiring within 30 days
dev cert renew --force # renew unconditionally
dev cert show # print expiry + SAN listThe cert lives at ~/.dev-orchestrator/certs/fullchain.pem (mode 0644)
and privkey.pem (mode 0600). The ACME account key is in the same
directory at account-key.pem. Renewal is manual — Let's Encrypt issues
90-day certs; renew at 60 (or dev create will warn at 30).
The DNS-01 challenge places a multi-value TXT record at
_acme-challenge.<base-domain> (one validation per name in the cert),
waits ~30s for propagation, answers the challenges, and tears the TXT
record down. Uses the same HETZNER_TOKEN as everything else, the
unified Hetzner Cloud DNS API.
One-time setup on a fresh laptop, then the workshop sequence top to bottom.
| Phase | Command | When | Notes |
|---|---|---|---|
| One-time | dev init |
Once per laptop | Writes ~/.dev-orchestrator/.env, validates Hetzner token, picks the DNS zone. |
| One-time | dev cert init |
After dev init |
Issues the wildcard cert via DNS-01. Repeat every 60 days with dev cert renew --force. |
| One-time | dev snapshot <prep-vm> |
Optional, before any workshop | Bakes the stable installs into a Hetzner snapshot. Cuts cloud-init from 15 minutes to ~4. |
| Pre-workshop | dev admin deploy --workshop <slug> |
Day before | Provisions the admin VM at the BASE_DOMAIN apex. Prompts for admin password, workshop password, and the plaintext SSH password shown to participants. The SSH password must match the VM password configured by dev init. |
| Pre-workshop | dev create --count N --workshop <slug> |
Day before | Provisions the pool, 10% over-warmed so broken VMs and re-claims don't deplete it. |
| Pre-workshop | dev test all --workshop <slug> |
Day before | Confirms every VM is 18/18 green. Anything below 18 gets dev destroy <name> plus a replacement. |
| Live | Admin dashboard | Workshop day | Monitor + one-click re-test. |
| Post-workshop | dev destroy all --workshop <slug> |
After | Deletes DNS records first, then VMs, then state rows. |
| Post-workshop | dev admin destroy |
After | Removes the admin VM and its DNS record. |
T minus 1 day.
dev cert renew --forceif the wildcard is within 30 days of expiry.dev cert showto verify.dev admin deploy --workshop <slug>. Prompts for three secrets; write them down.dev create --workshop <slug> --count <participants + 10%>. Wait for cloud-init (~15 min on stock, ~4 min from a snapshot).dev test all --workshop <slug>. Anything below 18/18:dev destroy <name>plus a replacement.- Open
https://<base>/admin, log in, set the workshop name and (optionally) logo from settings.
T minus 1 hour.
- Open
https://<base>/adminon a second monitor. Leave it open during the workshop. - Walk the room with the workshop password.
During the workshop.
- Dashboard refreshes every 10 s. Filter by name to find specific participants.
- If a participant reports a broken VM: click the row, hit Re-test. If it stays red, tell them to re-submit at
/name(they get a fresh VM from the over-warm). - If a participant is stuck on SSH: walk them through the workspace page step by step. Pi auto-attaches a tmux session, so reconnects pick up where they left off.
Post-workshop.
dev destroy all --workshop <slug>. Confirms with the count.dev admin destroy. Confirms with the VM name.- Hetzner project is back to baseline.
~/.dev-orchestrator/state.dbis empty for this workshop.
For workshops (35 participants self-claiming pre-warmed VMs from a browser), provision a small admin VM in the same Hetzner project:
dev admin deploy --workshop dataharvest-2026This creates <base-domain> (e.g. dev.casus.org),
fronted by Caddy with the same wildcard cert workshop VMs serve.
Prompts once for the admin UI password, the workshop password
(participants type this), and the plaintext SSH password shown on
the per-VM workspace page. Configuration is stored in
/etc/orchestrator-admin/env; orchestrator source is rsynced into
/opt/orchestrator-admin/; the FastAPI app runs under a systemd
unit orchestrator-admin.service.
Once running:
https://<base-domain>/is the participant claim flow (password, name, atomic claim, workspace page at/server/<vm-name>). The SSH password prompt is validated against the VM login hash fromdev initso the page cannot show a stale password.https://<base-domain>/adminis the dashboard (HTTP Basic): pool table with 10-second auto-refresh, name filter, Hetzner + OpenRouter cost summary (60-second cache).https://<base-domain>/admin/server/<name>is the per-VM page with a Re-test button (sync, 15-second budget, runs every check exceptcloud_init_done).https://<base-domain>/admin/settingsrotates the workshop password without redeploy.
Lifecycle: provision the day before, destroy after.
dev admin deploy --workshop X # day before
# ... workshop happens ...
dev admin destroy # afterdev admin update rsyncs your local source and restarts the unit
without recreating the VM. dev admin logs streams
journalctl -u orchestrator-admin. Re-running dev admin deploy --workshop X against an existing admin VM falls through to the
update path and prints a yellow warning if X differs from the
slug baked into the admin's env file (rotate slugs by destroying
- redeploying).
dev admin deploy is idempotent on a stale apex DNS record. If a
prior deploy was interrupted between VM create and state.add_server,
the apex A record can be left dangling in your zone — pointing at
a destroyed VM. The deploy detects this, deletes the stale record,
prints a yellow Replaced stale apex A record at ... warning, and
proceeds with the new IP. No manual hcloud dns rrset delete
needed.
The pool itself is still provisioned with the CLI, just with the workshop label set so the admin can find them:
dev create --workshop dataharvest-2026 --count 40 --name-prefix dh(Over-warm by ~10% so broken VMs + accidental re-claims don't deplete the pool.)
https://<BASE_DOMAIN>/admin after HTTP Basic login.
- Stats row. Three tiles, 60-second cache: Claims (
N / Mparticipants claimed), Hetzner (VM hours since creation in EUR), OpenRouter (lifetime usage on the shared key in USD). - Filter input. Live filters the pool table by VM name or claimed participant name (HTMX debounced 200 ms).
- Pool table. One row per VM, refreshing every 10 seconds: VM name (linked to detail), status badge, IPv4, claimed-by, last-test verdict. Click a VM name to open the detail page.
- Per-VM detail (
/admin/server/<vm>): Live/status.jsonfrom the VM plus a Re-test button that re-runs the admin-safe 17-check subset synchronously within a 15-second budget.
/admin/settings. Three sections; each saves independently without redeploying the admin server.
| Section | What it sets | Takes effect |
|---|---|---|
| Workshop name | Human-readable label shown on participant pages. Empty falls back to the slug. | Next request |
| Workshop logo | URL to a square logo image. Empty falls back to the bundled logo. Non-square images are center-cropped. | Next request |
| Workshop password | bcrypt-hashed password participants type at the front page. | Next request. Existing 4-hour sessions stay valid. |
GET /→ password page.POST /authwith workshop password → sets 4-hour cookie, redirects to/name.POST /claimwith typed name → atomically picks oldest unclaimed VM, labels it, redirects to/server/<vm-name>./server/<vm-name>→ workspace page: SSH command, plaintext SSH password, recovery URL, tmux quickstart.ssh human@<vm>.<base>→ tmux sessionmainauto-attached.
Every SSH login automatically attaches to a persistent tmux session named
main (created on first login, reattached on every subsequent one). This
keeps work intact across disconnections — participants never lose their
terminal state.
If a session is unresponsive but the terminal still accepts input:
type exit or Ctrl+D inside the tmux session to kill it. The login
shell is still alive (no exec replacement), so you land at a bare bash
prompt; log out and reconnect to get a fresh session.
If the terminal is completely frozen: run this from your local machine:
ssh <user>@<host> 'tmux kill-session -t main'Non-interactive SSH bypasses the auto-attach (no TTY), kills the stuck session, and exits. The next normal login starts a clean new one.
If your operator .env has ADMIN_URL=https://<base> set,
every dev invocation fires a fire-and-forget POST to
/admin/heartbeat (2-second timeout, daemon thread, silent on
failure). The admin appends one JSON line per call to
/var/log/orchestrator-admin/cli-usage.log. Sensitive flag values
(--password, --*-token, --secret, --api-key) are redacted
before send. The endpoint is unauthenticated in v1 by design.
To disable, omit ADMIN_URL from your .env.
dev create --count 3Walks you through a defaults table, lets you override any field, asks to
confirm, then for each server: submits a Hetzner create (concurrency 5),
waits until it reports running, allocates a DNS A record
<name>.<base-domain> → IPv4 in your Hetzner DNS zone. Cloud-init
(which installs pi, Claude, Caddy, the status page generator and clones
the boilerplate) continues asynchronously after the command returns —
dev test is how you confirm bootstrap is finished.
Non-interactive:
dev create --count 5 --no-prompt
dev create --count 1 --location fsn1 --server-type cax21 --no-promptTiming. The Hetzner create call returns in ~30 seconds (server
status running). DNS A record creation adds ~1 second. Cloud-init
plus bootstrap.sh then take roughly 10–15 minutes on a cax11:
~3 min for apt-get dist-upgrade, then serial installs of NodeSource
Node, GitHub CLI, pi, Claude, Caddy, plus the boilerplate clone and
the first cert issuance from Let's Encrypt. Run dev logs <name> --follow
to watch in real time.
dev create builds its candidate list from the live Hetzner catalog,
not from DEFAULT_SERVER_TYPE / DEFAULT_LOCATION. The selection
policy is intentionally hardcoded and not exposed in any settings
menu:
- Locations:
nbg1(Nuremberg),fsn1(Falkenstein),hel1(Helsinki). EU only, both DE and FI. Latency between EU datacenters is single-digit ms — irrelevant for a workshop VM. - Family:
architecture == "x86"andcpu_type == "shared", read from the structured API fields. Today this matchescx(Intel) andcpx(AMD); tomorrow it auto-includes any new shared-x86 family Hetzner adds. Excludes ARM (cax, trips the boilerplate's NodeSource fallback uncomfortably often) and dedicated x86 (ccx, ~3-5x the price for no workshop benefit). - Availability: every candidate is checked against the
per-location
available: trueflag the API attaches to each server type. Combos the API knows are unsupported never even make the list — no more "unsupported location for server type" from a stale default. - Disk floor: every candidate must have
disk >= max(requested-type.disk, image.disk_size). The operator's--server-typeworks as a floor, never a ceiling: pickcpx32and the ladder will not silently fall back to a 40 GB box for cost savings. When booting a snapshot, the source VM'sdisk_sizeis read and combined with that floor — snapshots from a 160 GB VM refuse to boot on smaller disks at the Hetzner API level (returns non-retryableservice_error), so the ladder filters those out before any attempt. - Order: cheapest gross-hourly EUR first, walking up the
ladder. Mixing across locations and across vendors (Intel vs
AMD) is fine —
cx22infsn1beatscx32innbg1if it's cheaper, andcpx22is just as good ascx22for the workshop's x86_64 workload. The fallback retries the next cheapest only on Hetznerresource_unavailable/placement_error(genuine capacity issues).
The operator's DEFAULT_SERVER_TYPE / DEFAULT_LOCATION and the
--server-type / --location CLI flags are honored as the first
attempt only, and only if that combo is in the valid (and
disk-qualifying) set. If the operator asks for something the API says
is invalid (e.g. cx23 in nbg1 when Hetzner has marked that combo
unavailable, or a 40 GB type for a 160 GB snapshot), the ladder skips
straight to the cheapest valid combo instead of failing.
To override the policy entirely (e.g. to test ARM, or to force a specific size for benchmarking):
dev create --server-type cax21 --location fsn1 --no-fallback--no-fallback disables the ladder; the create attempts exactly
the requested (server_type, location) and fails with whatever
Hetzner returns. The pure helper eu_x86_shared_price_ladder in
src/dev_orchestrator/hetzner.py is the single source of truth for
the policy.
dev test all # parallel up to 8 servers, sequential per server
dev test dev-2026-05-09-01Runs 17 checks per server (see Health checks
below). Most are derived from a single fetch of /status.json per VM,
so the suite is fast even on big batches. Results are persisted to
state.db and surface in dev list and dev show.
Workshop skills that need large datasets (e.g. the 7.5 GB Open Food Facts parquet) add 30–90 minutes to each VM's first-boot time. Instead of waiting on 35 VMs in parallel, stage the data on one prep VM and snapshot it:
dev create --count 1 --no-prompt --server-type cpx32 # 160 GB disk, fits OFF dataset
# ... wait for bootstrap + data download (dev test will show datasets_ready: fail → pass) ...
dev snapshot <prep-vm> # captures full disk incl. staged data
# prints: DEFAULT_IMAGE=<image-id>Set DEFAULT_IMAGE=<image-id> in ~/.dev-orchestrator/.env, then
dev create --count 35 clones from the snapshot. Each clone boots with
data already in place — cloud-init re-runs (new instance-id) but the
prep-service sentinel is already there so datasets_ready passes
immediately.
The orchestrator picks an appropriate server type for the clones
automatically: the ladder enforces server.disk >= snapshot.disk_size,
so a 160 GB snapshot will never be attempted on (and silently fail
against) a 40 GB box. You don't need to pin --server-type on the
clones unless you want more RAM than the cheapest qualifying type.
--image accepts three forms; use whichever is convenient:
- Numeric image id (e.g.
390582140) — fastest, no description lookup. - Snapshot description (e.g.
dh-prep-01-snapshot-20260526) — whatdev snapshotprints. Not the source VM's name. - System image name (e.g.
ubuntu-24.04) — for fresh boots.
Snapshot disk size matters: the snapshot is tied to its source VM's disk size, not the bytes actually used. A 160 GB prep VM snapshot forces clones onto 160 GB+ server types regardless of how empty the filesystem is. Pick the smallest viable prep VM size for the data you need to stage.
Destroy the prep VM after snapshotting:
dev destroy <prep-vm>dev sync all # git pull + scripts/bootstrap.sh on each serverLets you change skills/prompts in your boilerplate repo without
rebuilding VMs. If the repo doesn't ship a scripts/bootstrap.sh,
sync just runs the pull and reports ok.
dev destroy dev-2026-05-09-01 # confirm by typing the name back
dev destroy all # confirm by typing the count backDestroy deletes the DNS A record first, then the Hetzner VM, then the local state row. If the VM teardown fails the DNS record is still removed (so a future tenant doesn't inherit a stale record pointing at their IP).
dev list # everything you manage, with status + last test
dev show dev-...-01 # one server, full detail + per-check breakdown + fqdn
dev power off all # off-all needs typed-count confirmation
dev power on dev-...-01
dev ssh dev-...-01 # exec into ssh as human
dev logs dev-...-01 -f # follow /var/log/dev-bootstrap.logEvery VM publishes a status page at https://<name>.<base-domain>/status
(HTML) and /status.json (machine-readable). The Caddyfile serves both
from /var/www/status regardless of whether the user's dev server on
DEV_PORT is up — so you can always tell what's happening on the box.
A shell script at /usr/local/bin/dev-status-gen.sh regenerates both
files. It runs:
- Once at the end of
bootstrap.shso/statusexists immediately. - Every 60 seconds thereafter via the
dev-status.timersystemd unit (withOnBootSec=30s).
The JSON payload contains, with no secrets:
{
"generated_at": "2026-05-10T...Z",
"hostname": "dataharvest-01",
"fqdn": "dataharvest-01.dev.casus.org",
"uptime_seconds": 600,
"bootstrap_complete": true,
"services": { "caddy": "active", "fail2ban": "active", "ufw": "active" },
"tools": {
"pi": "0.74.0", "claude": "2.1.138", "node": "24.15.0",
"rg": "14.0.3", "fd": "9.0.0", "gh": "2.40.0"
},
"repo": { "present": true, "sha": "abc123def..." },
"skill": { "present": true, "sha": "def456abc..." },
"parquet": { "count": 54, "expected": 54, "empty": 0, "complete": true },
"dev_port": 3000,
"dev_port_listening": false,
"datasets": [{"name": "openfoodfacts", "state": "ready", "done": true}]
}Requests to https://<name>.<base-domain>/output/* serve static files
from ~/output directly. This is the default path for presentation
HTML: save ~/output/report.html, then open
https://<name>.<base-domain>/output/report.html.
Anything else hitting https://<name>.<base-domain>/<anything> is
reverse-proxied to localhost:DEV_PORT — that's where the participant
runs their app.
Each check is a function in checks.py that returns a CheckResult
(name, passed, duration_ms, message). They run sequentially against
one server, and dev test all parallelises across servers up to 8 at
a time. Most checks read from a single /status.json fetch per server,
so they're cheap and don't add SSH load.
| # | Name | Source | Asserts | Typical failure |
|---|---|---|---|---|
| 1 | ssh_reachable |
SSH | human can run a command |
sshd still restarting from apt-get dist-upgrade; key not authorised; fail2ban ban (rare on a fresh VM) |
| 2 | cloud_init_done |
SSH | cloud-init status --wait exits 0 within 180s |
package_upgrade still running; write_files errors (older template versions before defer:true) |
| 3 | dns_resolves |
DNS | <fqdn> resolves locally to the IPv4 in state |
DNS record never created; record stale after manual rename; resolver caching |
| 4 | https_reachable |
HTTPS | GET https://<fqdn>/status.json returns 200 |
Caddy not up; cert issuance still in progress; firewall blocking 443 |
| 5 | cert_valid |
TLS | TLS handshake succeeds against system trust store (static wildcard cert from orchestrator) | Cert files missing on disk; key permissions wrong; cert expired (dev cert renew) |
| 6 | status_fresh |
status.json | generated_at within the last 5 minutes |
systemd timer not enabled; generator script crashed |
| 7 | bootstrap_complete |
status.json | bootstrap_complete: true |
An installer step failed under set -euo pipefail; check dev logs <name> |
| 8 | caddy_running |
status.json | services.caddy == "active" |
Caddyfile syntax error; cert issuance loop |
| 9 | fail2ban_running |
status.json | services.fail2ban == "active" |
Package install failed during cloud-init |
| 10 | ufw_active |
status.json | services.ufw == "active" |
runcmd didn't run cleanly |
| 11 | pi_installed |
status.json | tools.pi non-empty |
NodeSource setup failed; pi installer aborted |
| 12 | claude_installed |
status.json | tools.claude non-empty |
claude.ai/install.sh network fetch failed |
| 13 | core_tools |
status.json | rg, fd, gh, node all reported |
One of those installers failed; fd symlink not created |
| 14 | repo_cloned |
status.json | repo.present == true |
BOILERPLATE_REPO format wrong; private repo without valid GITHUB_TOKEN |
| 15 | skill_loaded |
status.json | skills/openfoodfacts/SKILL.md exists |
boilerplate checkout is stale or the skill was not wired into the repo |
| 16 | datasets_ready |
status.json | every entry in datasets[] has state == "ready" |
background data-prep service still running; download failed; a parquet file is zero bytes — check systemctl status off-data-prep on the VM |
| 17 | pi_auth_present |
SSH | ~/.pi/agent/auth.json mode is 600 |
defer:true missing on the write_files entry (file never written) |
| 18 | pi_calls_openrouter |
SSH | pi -p 'reply with the single word ok' returns "ok" |
OPENROUTER_API_KEY invalid; chosen DEFAULT_MODEL doesn't exist on OpenRouter; model with heavy preamble obscures "ok" |
Result table is sorted alphabetically by (server, check). Persisted
to state.db.test_runs so dev list and dev show can show the
last verdict (pass (N/18)) without re-running.
A failing check exits non-zero so dev test is CI-friendly; a single
failure does not abort the rest of the suite.
The cloud-config template at templates/cloud-config.yaml.tmpl provisions:
humanuser with sudo, SSH-key auth, password auth for participants, andNOPASSWD:ALLsudoers entryufwfirewall (22, 80, 443 only),fail2ban,unattended-upgrades- Node.js (NodeSource LTS, with bundled
npm), GitHub CLI, ripgrep, fd, jq, tmux - pi coding agent (
~/.pi/agent/auth.jsonpoints at OpenRouter, the default model is${DEFAULT_MODEL}from your.env) - Claude Code CLI
- Caddy reverse-proxy on
https://<hostname>.<base-domain>, serving the static wildcard cert obtained by the orchestrator (no per-VM ACME) - Static publishing at
/output/*from~/output, alongside the normal/reverse proxy toDEV_PORT - Status page generator + 60s systemd timer
- Your boilerplate repo cloned into
~/dev/workshop-boilerplate - A participant front door at
~/workshopwith links to assignments, skill docs, data, and output
Bootstrap output is captured to /var/log/dev-bootstrap.log and the
marker Bootstrap complete. is what the bootstrap_complete check
looks for.
src/dev_orchestrator/
cli.py Typer entrypoints; wires modules together
config.py dataclass over ~/.dev-orchestrator/.env
state.py sqlite3 wrapper (servers + test_runs; lazy column upgrades)
template.py string.Template renderer (safe_substitute, preserves
bash $HOME / ${FOO:-} and apt's $${distro_id} escapes)
hetzner.py hcloud SDK wrapper (create/list/delete/power)
dns.py Hetzner DNS API wrapper (create_a_record/delete_record)
ssh.py paramiko connect (with retry) + run helpers
checks.py 18 health checks + parallel runner
ui.py Rich tables + questionary prompts
templates/
cloud-config.yaml.tmpl
With uv:
uv sync --extra dev
uv run pytestWith pip:
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytestUnit tests are hermetic (no network, no Hetzner, no SSH). The suite runs in ~30s on a laptop.
Two layers, by design:
-
Unit tests (
pytest) cover the parts that fit in a function: the cloud-config template renderer, the sqlite state DB (including the lazy column-upgrade for older state files), and the pure-logic check helpers (parse_cloud_init_status,parse_bootstrap_log, the status.json-derived checks, the registry tripwires). These run in milliseconds, gate every commit, and don't need credentials or network. -
Live integration (
dev testagainst a real VM) is how the SSH-dependent half of the codebase is exercised: paramiko connect, the SSH-based checks, the actual Caddy + cert flow, thedev create→ DNS → bootstrap →dev destroylifecycle. These are not pytest because (a) they cost real money each run and (b) they're inherently slow (~10–15 min for one VM to bootstrap). Run them after non-trivial changes tochecks.py,hetzner.py,dns.py,ssh.py,cli.py, or the cloud-config template.
The hcloud and Hetzner DNS SDKs are not mocked anywhere — calls
go to Hetzner directly. This was a conscious choice: a mocked Hetzner
that returns "server created, status=running" would have masked every
bug found during live verification (cloud-init's defer:true
write_files ordering, Ubuntu 24.04's needrestart whiptail prompt,
NodeSource vs Ubuntu npm conflict on ARM64, paramiko's stdin-channel
hang in pi). Trust the SDK; verify the integration on a real VM.
| Bug | Where | Fix |
|---|---|---|
write_files with owner: human:human failed because cloud-init runs write_files before users-groups |
templates/cloud-config.yaml.tmpl |
defer: true on every human-owned entry |
Ubuntu 24.04's needrestart showed an interactive whiptail kernel-upgrade prompt mid-apt install, killing bootstrap |
bootstrap.sh in template | export NEEDRESTART_MODE=a + DEBIAN_FRONTEND=noninteractive at the top |
NodeSource's setup script silently no-op'd on ARM64; falling back to Ubuntu's nodejs package broke because Ubuntu's npm depends on Ubuntu's node-* packages that conflict with NodeSource's nodejs when the repo IS configured |
bootstrap.sh in template | If nodesource.* apt source exists, install only nodejs (NodeSource bundles npm); otherwise install nodejs npm |
pi installer dropped the binary in ~/.npm-global/bin/pi, but bash -lc doesn't pick up env.sh because Ubuntu's ~/.bashrc returns early on non-interactive shells, so PATH didn't include ~/.npm-global/bin |
checks.py:_shell() and /etc/profile.d/dev-env.sh from cloud-init |
_shell() explicitly sources env.sh; /etc/profile.d/dev-env.sh lands during cloud-init's early write_files so PATH is correct from the very first SSH login |
pi -p '...' via paramiko hung 45s waiting for stdin (paramiko's exec_command leaves the channel open), but the same call via real ssh returned in 4s |
checks.py:_shell() |
Append </dev/null to every wrapped command |
-
tests/test_template.py— cloud-config rendering: everyEXPECTED_KEYSvalue substitutes; apt's$${distro_id}preserved; bash$HOME/${GITHUB_TOKEN:-}preserved; Caddyfile block renders with the actualDEV_PORT; missing-key error names every absent key; none of the_REMOVED_TERMSsubstrings appear in the rendered output (tripwire against accidental re-introduction of dependencies that were migrated out). -
tests/test_state.py— state DB: full CRUD, partial updates, both UNIQUE constraints, test-run record/fetch, recency order, cascade delete, fqdn + dns_record_id round-trip, lazy column upgrade for legacy DBs (a state.db created before the DNS migration is opened, the new columns get added, existing rows survive). -
tests/test_checks_unit.py— check registry + pure-logic helpers: registered set matches the current spec (tripwire); none of theREMOVED_TERMSappear in any check name (tripwire);parse_cloud_init_status,parse_bootstrap_log; every status.json-derived check (bootstrap, services, tools, repo, status_fresh) covered for both pass and fail paths;https_reachableanddns_resolvesexercised over their pure context (no sockets opened).
When adding a new entry to ALL_CHECKS in checks.py:
- Decide which source the check reads from: SSH (
ctx.client), status.json (ctx.status_json), or HTTPS/DNS (ctx.https_status,ctx.fqdn). Most new checks should be status-derived — add a field todev-status-gen.shand read it here. - Write the check function (returns
CheckResult). If the check has parsing logic, factor the parser into a top-level helper so it's unit-testable. - Add unit tests covering the pass case, the obvious fail case, and the "no status.json" / "no SSH" path.
- Add the new name to
test_registered_checks_match_current_spec's expected set — this assertion is your tripwire against accidental removal or rename. - Run
dev test <name>against a real VM to validate the SSH or network side.
When a tool is migrated out of the cloud-config or check suite (e.g.
swapping a tunneling provider), add its name to REMOVED_TERMS in
tests/test_checks_unit.py and _REMOVED_TERMS in
tests/test_template.py. The tripwires there will fail any future
change that re-introduces the term in a check name or in the rendered
cloud-config — the cheapest way to catch a half-revert.
Claimed VMs keep working: participants are connected via SSH direct to VM IPs, not through the admin server. New claims fail until the admin comes back. The CLI is the failover for everything except the claim flow.
dev list --workshop <slug> # see the pool
dev test all --workshop <slug> # health-check
dev ssh dh-04 # SSH directly
dev admin destroy # cleans DNS + VM (idempotent)
dev admin deploy --workshop <slug> # redeployCookie secret rotates on redeploy; participants log in fresh. VM names and claims survive because they live on Hetzner labels.
| Scenario | Recovery |
|---|---|
| Cookie and bookmark intact | Open /server/<vm> directly. Works. |
| Cookie expired, VM name remembered | Type https://<base>/server/<vm>. Server redirects to /?next=/server/<vm>. Log in. Cookie set. Land back on the workspace page. |
| Cookie intact, forgot VM name | Re-submit at /name. Get a new VM from the pool over-warm. Old claim orphans on the prior VM until teardown. |
| Forgot VM name, had real work on the old one | Operator filters the dashboard by participant's typed name, reads the old FQDN out-of-band, hands it to the participant. Participant SSHs directly. |
The VM is usually fine; it's a DNS resolution problem on your laptop. Verify:
dig +short @1.1.1.1 <base-domain>
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponderIf 1.1.1.1 has the record but your local resolver doesn't, you're on a VPN
with a stale negative cache. Bypass with --resolve:
curl --resolve <base-domain>:443:<ip> https://<base-domain>/healthzThe wildcard cert on disk is for a different BASE_DOMAIN. Re-issue and push:
dev cert renew --force
dev cert show # confirms SANs match BASE_DOMAIN
dev sync-certs all # pushes to every running VM including admindev test <name> # confirms the failure
dev destroy <name> # removes DNS + VM + state row
dev create --count 1 --workshop <slug> --name-prefix <prefix>Pool over-warm absorbs the gap until the replacement boots.
- Check the workshop password against
/admin/settings. If you rotated mid-workshop and forgot, set a new one and tell the room. - Check that
<base>/adminresponds. If it doesn't, the admin VM is down — see above. - Check Caddy logs on the admin VM:
dev admin logs.
By default, dev create will automatically fall back when Hetzner reports
the requested (server_type, location) is at capacity. The search order is
per-location size ladder: it walks up the size ladder in the requested
location first, then moves to the next location and walks the ladder again
there. Within a family the ladder is cx23 → cx33 → cx43 → cx53
(and similarly cax11 → cax21 → cax31 → cax41, cpx11 → cpx21 → …).
The ladder never crosses CPU vendor or architecture. A cax11 request
stays on ARM; a cx23 request stays on Intel — silent vendor swaps are
never performed.
Each VM in a batch falls back independently, so one batch can land on a mix
of sizes. The actually-provisioned (server_type, location) is recorded in
state.db and shown in dev show. Each fallback decision is logged to
~/.dev-orchestrator/logs/<date>.log, and a yellow notice prints to the
terminal when a VM ends up on a different size or location than requested.
The ladder also auto-skips any size Hetzner has marked deprecated past its
unavailable_after date — the orchestrator queries /v1/server_types once
per dev create and filters the live list rather than relying on hardcoded
defaults.
If you'd rather fail fast on capacity issues (and pick the alternative
yourself), pass --no-fallback:
dev create --count 5 --no-fallbackCaddy is alerting on the TLS handshake — almost always because the
static wildcard cert isn't on disk where it expects, or its key is
unreadable by the caddy user. Check on the VM:
sudo ls -la /etc/caddy/wildcard.{crt,key} (key should be 0640,
caddy group). If the files are missing, either the cloud-config render
got an empty cert (run dev cert show locally — if no cert, run
dev cert init) or the b64 decoding failed at boot
(sudo journalctl -u cloud-final --no-pager | grep -i wildcard).
Caddy is up and got a cert (otherwise this would be a TLS error), but
nothing is listening on DEV_PORT to reverse-proxy to. That's
expected for an idle VM — /status is served from disk regardless.
Per spec, https_reachable checks /status.json specifically (which
Caddy serves from disk), so a 502 here means Caddy itself is in a
bad state, not that the dev server is missing.
Either the DNS record was never created (check dev show <name> —
dns_record should not be -), or your local resolver is caching a
stale negative response. Try dscacheutil -flushcache on macOS or
resolvectl flush-caches on Linux.
The check expects ok (case-insensitive) in the response. Some models
preface with formatting; if you change DEFAULT_MODEL to a model with
heavy preamble behavior, the check will be flaky. The default
anthropic/claude-sonnet-4.6 is well-behaved for this prompt.
SSH in (dev ssh <name>) and check sudo cloud-init status --long.
If package_upgrade is still running, just wait. If it's stuck on a
needrestart prompt, the cloud-config sets NEEDRESTART_MODE=a to
avoid this — but only for bootstrap.sh, not for cloud-init's own
package upgrade. On rare images you may need to reboot the VM
(dev power off <name>; dev power on <name>) and re-run dev sync.
Set GITHUB_TOKEN during dev init. The cloud-config exports it into
the VM's env.sh and uses it for the https://${GITHUB_TOKEN}@… clone.
The token also lets you gh from inside the VM.
Shouldn't happen — destroy deletes DNS first, then VM, then state.
But if it did (e.g. you killed the process mid-destroy): the record
is in your Hetzner DNS zone, you can delete it from the dashboard, or
re-dev create with the same name (which will collide on DNS create
and surface the existing record_id).
| Command | Options / arguments | Purpose |
|---|---|---|
dev init |
First-run wizard. Writes .env, validates token, picks DNS zone. |
|
dev cert init |
--staging |
Obtain the wildcard cert via DNS-01. |
dev cert renew |
--force |
Renew if expiring within 30 days (--force renews unconditionally). |
dev cert show |
Print expiry date and SAN list. | |
dev create |
--count N --name-prefix PREFIX --server-type TYPE --location LOC --image IMAGE --model MODEL --repo REPO --workshop SLUG --no-prompt --no-fallback |
Provision N servers. Interactive defaults table unless --no-prompt or flags are set. |
dev list |
--workshop SLUG |
Pool overview: status badge, IPv4, last-test verdict. |
dev show NAME |
Full detail for one server: all fields + per-check breakdown. | |
dev test NAME|all |
--workshop SLUG |
Run the 18-check health suite. Exits non-zero on any failure. |
dev snapshot NAME |
--description DESC |
Snapshot a server into a reusable image. Prints the image id to paste into DEFAULT_IMAGE. |
dev sync NAME|all |
git pull + scripts/bootstrap.sh on each server (iterate on boilerplate without rebuilding). |
|
dev sync-certs NAME|all |
Push current wildcard cert to one (or all) VMs and reload Caddy. | |
dev power on NAME|all |
Power on server(s). | |
dev power off NAME|all |
Power off server(s). all requires typed-count confirmation. |
|
dev destroy NAME|all |
--workshop SLUG |
Delete DNS record, then VM, then state row. Requires typed confirmation. |
dev ssh NAME |
Exec into a server as the configured user. | |
dev logs NAME |
--follow / -f |
Stream /var/log/dev-bootstrap.log. |
dev admin deploy |
--workshop SLUG |
Provision the admin VM at the BASE_DOMAIN apex. |
dev admin update |
Rsync local source onto the admin VM and restart the unit. | |
dev admin logs |
--follow / -f |
Stream journalctl -u orchestrator-admin. |
dev admin destroy |
Tear down the admin VM and its DNS record. |
Per the original PRD: no YAML config (.env is enough), no plugin
system for checks, no abstraction over hcloud, no asyncio, no TUI,
no auto-update of pi/claude/Caddy versions.