to select ↑↓ to navigate
Doppiobox

Doppiobox

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

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", "delete oldbox"); 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.md describes 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

diagram

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)

diagram

Step by step (main.go:23137):

  1. Configconfig.Load(*configPath) reads the YAML, applies defaults, and overlays env vars. Fatal on error (main.go:28).
  2. Logging — a slog JSON handler writes to stdout (journald captures it), level chosen from cfg.Agent.LogLevel (main.go:34).
  3. Databasedb.Open opens SQLite at /opt/devbox/agent/devbox-agent.db, enables WAL, runs migrations (main.go:51). Closed on shutdown via defer.
  4. 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).
  5. Frappe sourcefrappe.New(cfg.Frappe) returns an HTTPClient if a URL is set, else a StubSource no-op (main.go:78).
  6. 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.
  7. Worker + API are constructed; the SSH key path is injected into the worker (used when building rootfs images) (main.go:96).
  8. Goroutinesgo work.Run(ctx) and go httpServer.ListenAndServe() run concurrently (main.go:105, main.go:113).
  9. Shutdown — block until SIGINT/SIGTERM, then cancel() the context (the worker's loop sees ctx.Done() and returns) and httpServer.Shutdown with a 5s grace window (main.go:122). KillMode=process in 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:

  • TemplateAwareExecutorCreateWithTemplate, BuildGoldenSnapshotForTemplate (worker.go:208).
  • ResettableExecutorReset(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 30sreconcileVMs().

Before the loop starts, two things happen once:

  1. Crash recoveryResetRunningTasks() flips any task left in running (because the agent died mid-task) back to pending so it gets retried (worker.go:50, queries.go:84).
  2. Initial reconcilereconcileVMs() runs immediately (worker.go:59).

One tick() (worker.go:128)

diagram

The four numbered phases in tick():

  1. Poll & ingest — call frappe.Poll(serverName); for each returned FrappeTask, InsertTask into the local tasks table. Frappe is the source of truth for what to do; SQLite is the local durable queue (worker.go:130).
  2. Claim oneClaimPendingTask() (see locking below). If nil, return — nothing to do (worker.go:141).
  3. Executew.execute(task) switches on task.Action and calls the matching handler, which drives the executor (worker.go:153, :180).
  4. 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) → RemoveRemoveVolume → 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. ClaimPendingTask runs BEGIN → SELECT oldest pending (ORDER BY id ASC LIMIT 1) → UPDATE that row to running → COMMIT in one transaction (queries.go:32). Combined with SetMaxOpenConns(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. FailTask marks the task failed immediately and does not increment retries or requeue (queries.go:76). (This contradicts architecture.md's "retries < 3" claim — the retry logic described there does not exist in the current code.) The only path back to pending is crash recovery on restart (ResetRunningTasks) — confirmed: FailTask is a one-shot terminal transition with no requeue.
  • IP allocation is idempotent. AllocateIP returns the slug's existing IP if it already has one, so reset keeps 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 / stopLogCollector are 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.yml ships only the frappe, agent, and docker sections — the Firecracker block is supplied per-server in the real config.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), except POST /tasks which inserts a pending row for the worker to pick up.
  • golden-snapshot builds spawn their own detached background goroutine so the HTTP handler returns 202 immediately (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:1130). 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.yml
  • EnvironmentFile=-/opt/devbox/agent/agent.env — where the secret env overrides (AGENT_AUTH_TOKEN, FRAPPE_*) live.
  • Restart=always, RestartSec=5 — self-healing process supervision.
  • KillMode=processonly 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/Reset actually 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.
Last updated 1 month ago
Was this helpful?
Thanks!