The Go devbox-agent Internals
The local hands of the control plane. Pages 02/03/05 own the Firecracker / networking / snapshot machinery; this page owns the Go binary that drives them: how it boots, how it gets work, how it dispatches that work, and every knob and endpoint it exposes.
Audience: a Go-literate newcomer. We cover how and why, and verify every claim against the source under
backbone/agent/.
1. What the agent is
devbox-agent is a single statically-linked Go binary that runs as a
systemd service on each Hetzner server (one per server: fc-01 prod,
the test box, etc.). It has exactly one job:
Be the local hands of the Frappe control plane. Frappe decides what should happen ("create a box named
mybox", "deleteoldbox"); the agent is the thing actually sitting on the server that makes it happen — booting Firecracker VMs, wiring Caddy routes, allocating IPs — and reports the result back.
Why pull, not push? Frappe never SSHes into servers to run commands.
Instead each server runs this agent, which polls Frappe for work and reports
back. That is simpler (no inbound SSH/credentials from the control plane), more
secure (the server initiates all connections outbound), and survives network
blips — when connectivity returns, the agent just resumes polling
(backbone/agent/architecture.md:7).
The binary is backend-agnostic. It defines an Executor interface and picks an
implementation at startup: firecracker (production) or docker (the
older/local-dev path). Everything above the Executor boundary — polling,
the task queue, the HTTP API — is identical regardless of backend
(backbone/agent/cmd/devbox-agent/main.go:59).
Stale-doc warning:
backbone/agent/architecture.mddescribes the original Docker + Traefik design and a "retry ≤ 3" task policy (verified: its flow diagram still reads "Execute (docker create/stop/rm)" and "Mark done or failed (retry ≤ 3)"). The live code has moved to Firecracker + Caddy and does not auto-retry failed tasks (see §6). Where they disagree, the code wins.
2. Package / component map
| Package | Responsibility |
|---|---|
cmd/devbox-agent |
Entry point. Loads config, opens DB, builds the chosen executor, builds the worker + HTTP server, starts both as goroutines, handles graceful shutdown (main.go:23). |
internal/config |
Parses config.yml into a Config struct, applies defaults, layers env-var overrides for secrets (config.go:63). |
internal/db |
All SQLite access. Owns the schema/migrations and every query. Tables: tasks, containers, ip_allocations, logs (db.go:40). |
internal/api |
Gin REST server on :8090. Lets an operator submit/inspect tasks and trigger golden-snapshot builds locally (api.go:43). |
internal/worker |
The orchestrator. Runs the poll loop, claims tasks, dispatches each action to executor calls, reports status/progress (worker.go:47). |
internal/executor |
The backend contract — a Go interface, no logic. Decouples the worker from Firecracker vs Docker (executor.go:11). |
internal/frappe |
The control-plane client. TaskSource interface with two impls: HTTPClient (real Frappe) and StubSource (standalone/no-op) (client.go:29). |
internal/firecracker |
Production Executor impl — boots microVMs, manages Caddy routes & snapshots. Described on pages 02/03/05; here we only show how the worker calls it. |
internal/docker |
Legacy/local Executor impl — runs Docker containers with Traefik labels (docker.go:32). |
3. Process startup (main.go)
Step by step (main.go:23–137):
- Config —
config.Load(*configPath)reads the YAML, applies defaults, and overlays env vars. Fatal on error (main.go:28). - Logging — a
slogJSON handler writes to stdout (journald captures it), level chosen fromcfg.Agent.LogLevel(main.go:34). - Database —
db.Openopens SQLite at/opt/devbox/agent/devbox-agent.db, enables WAL, runs migrations (main.go:51). Closed on shutdown viadefer. - Executor — switch on
cfg.Agent.Backend:firecracker→ microVM executor, anything else → Docker executor (main.go:59). The Firecracker constructor gets the DB handle too, because IP allocation lives in SQLite (firecracker.go:40). - Frappe source —
frappe.New(cfg.Frappe)returns anHTTPClientif a URL is set, else aStubSourceno-op (main.go:78). - Firecracker-only startup tasks — if the backend is Firecracker, the agent
fetches the Cloudflare API token from Frappe and configures wildcard TLS, then
calls
ReconcileRoutes()to re-register Caddy routes for VMs that were already running before this restart (main.go:81). This is what makes an agent restart non-disruptive to live boxes. - Worker + API are constructed; the SSH key path is injected into the worker
(used when building rootfs images) (
main.go:96). - Goroutines —
go work.Run(ctx)andgo httpServer.ListenAndServe()run concurrently (main.go:105,main.go:113). - Shutdown — block until
SIGINT/SIGTERM, thencancel()the context (the worker's loop seesctx.Done()and returns) andhttpServer.Shutdownwith a 5s grace window (main.go:122).KillMode=processin the unit file means systemd kills only the agent, not the child Firecracker VMs (devbox-agent.service:17).
4. The Executor contract
The worker never imports firecracker or docker types directly — it talks to
this interface, which is why the same orchestration code drives both backends
(executor.go:11):
type Executor interface {
Create(slug string, envVars map[string]string) (string, error)
Start(slug string) error
Stop(slug string) error
Remove(slug string) error
RemoveVolume(slug string) error
InspectState(slug string) (string, error)
InspectID(slug string) (string, error)
Logs(ctx, slug, follow) (io.ReadCloser, error)
Stats(slug string) (*ContainerStats, error)
VolumePath(slug string) string
WorkspaceTarStream(ctx, slug) (io.ReadCloser, error)
WaitForReady(ctx, slug, port, timeout, interval) error
PlainTextLogs() bool
Close() error
}
Some capabilities are optional and discovered at runtime via Go type assertions, so a backend that lacks them simply isn't offered the action:
TemplateAwareExecutor—CreateWithTemplate,BuildGoldenSnapshotForTemplate(worker.go:208).ResettableExecutor—Reset(slug, templateSlug, envVars)(worker.go:215).GoldenSnapshotBuilder— the four golden-snapshot methods the API checks for (api.go:205).
This is the seam between this page and pages 02/03/05: the worker calls
w.exec.Create(...); what that does — jailer, kernel, overlay, TAP device,
Caddy route — is the Firecracker package's story.
5. The Frappe client (internal/frappe)
TaskSource is the whole contract between agent and control plane
(client.go:29):
type TaskSource interface {
Poll(server string) ([]FrappeTask, error)
ReportStatus(jobName, status, errorMsg string) error
ReportProgress(jobName, step string) error
FetchServerConfig() (ServerConfig, error)
}
frappe.New returns HTTPClient when cfg.Frappe.URL is set, otherwise
StubSource (all methods return nil/empty — standalone mode, used for local
testing where tasks come only from the HTTP API) (client.go:37, stub.go).
The HTTPClient uses a 10s-timeout http.Client and authenticates every call
with a Frappe token header — note this is token <key>:<secret>, different
from the Bearer scheme the agent's own HTTP API uses (client.go:60).
| Method | Frappe endpoint | Direction |
|---|---|---|
Poll(server) |
GET /api/method/benchspace.api.devbox.pending_tasks?server=<name> |
pull pending tasks for this server; reads message[] (client.go:54) |
ReportStatus |
POST .../devbox.update_task {job,status,error} |
report terminal done/failed (client.go:85) |
ReportProgress |
POST .../devbox.update_progress {job,step} |
live human-readable step text ("Waiting for site to be ready") (client.go:122) |
FetchServerConfig |
GET .../devbox.get_server_config |
one-time fetch of the Cloudflare API token at startup (client.go:157) |
ReportStatus/ReportProgress short-circuit to nil when jobName == "" — i.e.
tasks submitted via the local HTTP API (which have no Frappe doc) skip reporting
(client.go:86).
6. The poll loop in detail (internal/worker)
The worker is a single goroutine running two tickers (worker.go:47):
- poll ticker — fires every
cfg.Agent.PollInterval(default 10s) →tick(). - reconcile ticker — fires every 30s →
reconcileVMs().
Before the loop starts, two things happen once:
- Crash recovery —
ResetRunningTasks()flips any task left inrunning(because the agent died mid-task) back topendingso it gets retried (worker.go:50,queries.go:84). - Initial reconcile —
reconcileVMs()runs immediately (worker.go:59).
One tick() (worker.go:128)
The four numbered phases in tick():
- Poll & ingest — call
frappe.Poll(serverName); for each returnedFrappeTask,InsertTaskinto the localtaskstable. Frappe is the source of truth for what to do; SQLite is the local durable queue (worker.go:130). - Claim one —
ClaimPendingTask()(see locking below). If nil, return — nothing to do (worker.go:141). - Execute —
w.execute(task)switches ontask.Actionand calls the matching handler, which drives the executor (worker.go:153,:180). - Report — on error:
FailTask+ReportStatus("failed", …)(error truncated to the last 2000 chars to keep payloads small); on success:CompleteTask+ReportStatus("done")(worker.go:156).
Actions → handlers
action |
Handler | What it does (executor calls) |
|---|---|---|
create |
handleCreate |
Create / CreateWithTemplate, then WaitForReady on port 8000 (site) and 8888 (code-server), 5-min timeout each (worker.go:219) |
start |
handleStart |
Start + mark running (worker.go:298) |
stop |
handleStop |
Stop + mark stopped (worker.go:310) |
delete |
handleDelete |
Stop (best-effort) → Remove → RemoveVolume → mark deleted (worker.go:323) |
reset |
handleReset |
ResettableExecutor.Reset — wipe to template state, keep IP/routes/slug, re-wait (worker.go:261) |
build_golden |
handleBuildGolden |
BuildGoldenSnapshotForTemplate (worker.go:344) |
build_image |
handleBuildImage |
docker build → export to ext4 → zstd compress → sha256sum (worker.go:365) |
Each handler calls w.progress(jobName, step) between phases, which forwards a
human-readable step string to Frappe's update_progress (worker.go:201).
Idempotency & locking
- Single claim, atomic.
ClaimPendingTaskrunsBEGIN → SELECT oldest pending (ORDER BY id ASC LIMIT 1) → UPDATE that row to running → COMMITin one transaction (queries.go:32). Combined withSetMaxOpenConns(1)on the DB (all writes serialized through one connection,db.go:18), no task can be claimed twice. - One task per tick. The worker processes exactly one task per 10s cycle,
synchronously — execution blocks the loop until done (
worker.go:140). Simple and predictable; the ceiling is throughput, not correctness. - No automatic retry.
FailTaskmarks the taskfailedimmediately and does not incrementretriesor requeue (queries.go:76). (This contradictsarchitecture.md's "retries < 3" claim — the retry logic described there does not exist in the current code.) The only path back topendingis crash recovery on restart (ResetRunningTasks) — confirmed:FailTaskis a one-shot terminal transition with no requeue. - IP allocation is idempotent.
AllocateIPreturns the slug's existing IP if it already has one, soresetkeeps the same external URLs (queries.go:157).
reconcileVMs() (the 30s loop)
Independent of the task queue. For every container the DB marks running, it
calls exec.InspectState(slug); if the VM is actually down, it auto-restarts
it via exec.Start(slug), and on restart failure records the real state so it
doesn't thrash every 30s (worker.go:84). This is the self-healing that keeps
boxes up across host hiccups.
Note:
startLogCollector/stopLogCollectorare intentional no-ops — Firecracker writes serial logs straight to flat files under/opt/devbox/logs/<slug>-serial.log, so nothing is collected into SQLite (worker.go:120).
7. The local HTTP API (internal/api)
A Gin server (release mode, Recovery + Logger middleware) on cfg.Agent.ListenAddr
(default :8090) (api.go:43). It exists for operator/local debugging —
in production Frappe submits work via polling, not this API, and the port is
firewalled off the public internet.
Auth: every route except /health is wrapped in bearerAuth(authToken),
which requires Authorization: Bearer <auth_token> to exactly match
cfg.Agent.AuthToken; otherwise 401 (api.go:32). If auth_token is empty,
the auth middleware is skipped entirely (api.go:54).
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
GET |
/health |
none | — | 200 {"status":"ok"} (api.go:75) |
POST |
/tasks |
Bearer | {slug, action, env_vars?, template_slug?} — action must be one of create start stop delete reset build_image build_golden |
201 {id, slug, action, status:"pending"}; inserts into tasks (api.go:79) |
GET |
/tasks |
Bearer | — | 200 []Task (last 100, newest first) (api.go:95) |
GET |
/containers |
Bearer | — | 200 []Container (api.go:107) |
GET |
/containers/:slug/logs |
Bearer | ?follow=1&after=<id> |
follow=0 → JSON []LogEntry after id; follow=1 → streamed text/plain, polling the DB every 500ms (api.go:119) |
GET |
/containers/:slug/stats |
Bearer | — | 200 ContainerStats (cpu %, mem used/limit, disk) via exec.Stats (api.go:174) |
GET |
/containers/:slug/workspace/download |
Bearer | — | 200 application/x-tar stream of the workspace via exec.WorkspaceTarStream (api.go:184) |
POST |
/golden-snapshot/build |
Bearer | — | 202 {status:"building"}; runs in background goroutine, 400 if backend can't build (api.go:212) |
GET |
/golden-snapshot/status |
Bearer | — | 200 {available: bool} (api.go:230) |
POST |
/golden-snapshot/build/:template |
Bearer | {app_specs?} |
202 {status:"building", template}; background (api.go:239) |
GET |
/golden-snapshot/status/:template |
Bearer | — | 200 {available, template} (api.go:262) |
The golden-snapshot routes use a runtime type assertion to
GoldenSnapshotBuilder; on the Docker backend (which doesn't implement it) the
build routes return 400 and the status routes return {available:false}
(api.go:213).
8. Config reference (config.yml)
Loaded by config.Load (config.go:63). The struct splits into four YAML
sections. Defaults below are the in-code defaults applied before YAML is
read; YAML values override defaults, and a few env vars override YAML (last
column).
agent: (config.go:45)
| Key | Meaning | Default | Env override |
|---|---|---|---|
poll_interval |
How often the worker polls Frappe (Go duration string). Parsed by PollDuration(); falls back to 10s if unparseable (config.go:129) |
"10s" |
— |
db_path |
SQLite file path | /opt/devbox/agent/devbox-agent.db |
— |
log_level |
debug / info / warn / error |
"info" |
— |
listen_addr |
HTTP API bind address | ":8090" |
— |
auth_token |
Bearer token for the HTTP API; empty disables auth | "" |
AGENT_AUTH_TOKEN |
backend |
"firecracker" or anything else → docker |
"" (→ docker) |
— |
frappe: (config.go:38)
| Key | Meaning | Default | Env override |
|---|---|---|---|
url |
Control-plane base URL. Empty = standalone (stub) mode | "" |
FRAPPE_URL |
api_key |
Frappe API key (token auth) | "" |
FRAPPE_API_KEY |
api_secret |
Frappe API secret | "" |
FRAPPE_API_SECRET |
server_name |
Frappe Server doctype name; sent as ?server= when polling |
"" |
FRAPPE_SERVER_NAME |
firecracker: (config.go:17) — consumed by the Firecracker executor (pages 02/03/05)
| Key | Meaning | Default |
|---|---|---|
domain |
Base domain for box URLs | buildwithhussain.site |
kernel_path |
vmlinux kernel | /opt/devbox/bin/vmlinux |
rootfs_path |
Base rootfs ext4 | /opt/devbox/images/frappe-v16.ext4 |
overlay_base |
Per-VM rootfs overlays dir | /opt/devbox/overlays |
volume_base |
Per-VM workspace volumes dir | /opt/devbox/volumes |
snapshot_base |
Snapshots dir | /opt/devbox/snapshots |
socket_base |
Firecracker API sockets dir | /opt/devbox/firecracker |
caddy_api |
Caddy admin API URL | http://localhost:2019 |
bridge_name |
Linux bridge for VM networking | br-devbox |
subnet_cidr |
VM IP pool | 10.0.0.0/16 |
memory |
Per-VM RAM | 2g |
swap_size_mb |
Per-VM swap | 8192 |
cpus |
vCPUs per VM | 2 |
jailer_path |
Firecracker jailer binary | /opt/devbox/bin/jailer |
firecracker_path |
Firecracker binary | /opt/devbox/bin/firecracker |
serial_log_dir |
Serial console logs | /opt/devbox/logs |
ssh_key_path |
Agent SSH private key (pub key at + ".pub") |
/opt/devbox/agent/fc_key |
golden_memory |
RAM for the golden-snapshot build VM | 4g |
docker: (config.go:54) — legacy/local backend only
| Key | Meaning | Default |
|---|---|---|
domain |
Base domain for Traefik routes | buildwithhussain.site |
image |
Container image | devbox-frappe-v16:latest |
network |
Docker network (Traefik) | traefik-public |
volume_base |
Bind-mount base | /opt/devbox/volumes |
memory |
Memory limit ("4g") |
4g |
cpus |
CPU limit ("2") |
2 |
config.example.ymlships only thefrappe,agent, anddockersections — the Firecracker block is supplied per-server in the realconfig.yml.
9. Concurrency model
A small, deliberately simple set of goroutines (main.go):
- main goroutine — builds everything, then blocks on the signal channel.
- worker goroutine (
go work.Run(ctx)) — the only thing that mutates VMs. It processes one task at a time, synchronously (execution blocks the poll loop), so there is no concurrency between tasks and no need for task-level locking beyond the atomic claim (worker.go:69). - HTTP server goroutine (
go httpServer.ListenAndServe()) — Gin spawns one goroutine per request. These goroutines only read DB rows and call read-style executor methods (Stats,WorkspaceTarStream), exceptPOST /taskswhich inserts apendingrow for the worker to pick up. - golden-snapshot builds spawn their own detached background goroutine so the
HTTP handler returns
202immediately (api.go:220).
Shared-state safety rests on SQLite, not Go mutexes: SetMaxOpenConns(1)
serializes every write through a single connection, WAL mode allows concurrent
reads, and a 5s busy_timeout waits out brief lock contention instead of
failing with SQLITE_BUSY (db.go:11–30). There are no explicit mutexes or
channels for task coordination in the agent layer — the single-connection DB +
single-worker design makes them unnecessary.
10. The data model (internal/db)
Pure-Go SQLite via modernc.org/sqlite (no CGO → static binary). Schema created
on Open via migrate() (db.go:40).
| Table | Columns | Role |
|---|---|---|
tasks |
id, slug, action, status, error_msg, frappe_task, env_vars, template_slug, retries, created_at, updated_at |
The durable work queue. status ∈ pending/running/done/failed. env_vars and template_slug added via in-code migrations (db.go:103, :124) |
containers |
slug (PK), status, docker_id |
Last-known state of each box (creating/running/stopped/deleted). Kept in sync via UpsertContainer (queries.go:147) |
ip_allocations |
ip (PK), slug (UNIQUE) |
Firecracker IP leases in 10.0.0.0/16; AllocateIP finds the next free .2–.254, idempotent per slug (queries.go:155) |
logs |
id, slug, line, stream, created_at (index on slug,id) |
Log-line store the /logs endpoint reads; currently unused by the Firecracker path (serial logs go to flat files) (db.go:60) |
Task and Container are the Go structs the API serializes to JSON
(models.go:5). Notable query helpers: ClaimPendingTask (atomic claim, §6),
CompleteTask/FailTask (terminal transitions), ResetRunningTasks (crash
recovery), UpsertContainer (INSERT…ON CONFLICT) (queries.go).
11. Deployment recap
Runs under systemd as the deploy user (devbox-agent.service):
ExecStart=/opt/devbox/agent/devbox-agent -config /opt/devbox/agent/config.ymlEnvironmentFile=-/opt/devbox/agent/agent.env— where the secret env overrides (AGENT_AUTH_TOKEN,FRAPPE_*) live.Restart=always,RestartSec=5— self-healing process supervision.KillMode=process— only the agent dies on stop/restart, never the child Firecracker VMs (devbox-agent.service:17).- Hardening:
ProtectSystem=strict,ReadWritePaths=/opt/devbox,NoNewPrivileges=true. - Logs to journald:
journalctl -u devbox-agent -f.
Build & ship with ./scripts/deploy-agent.sh test|prod (see root CLAUDE.md).
Cross-references
- What
exec.Create/Start/Resetactually do → pages 02/03 (Firecracker, rootfs/overlays) and 05 (snapshots). - The Caddy route wiring triggered by
ReconcileRoutes/ConfigureTLS→ networking page. - The Frappe side of
pending_tasks/update_task→ the control-plane page.