# Control plane development

> Go 1.22+ build/test/vet in control-plane/, CGO sqlite note, compose --build loop, package map (docker, store, reaper, wake, api), and image build cache behavior.

- Repository: tastyeffectco/sandboxes
- GitHub: https://github.com/tastyeffectco/sandboxes
- Human docs: https://grok-wiki.com/public/docs/tastyeffectco-sandboxes-f551c1a2e9a0
- Complete Markdown: https://grok-wiki.com/public/docs/tastyeffectco-sandboxes-f551c1a2e9a0/llms-full.txt

## Source Files

- `CONTRIBUTING.md`
- `control-plane/README.md`
- `control-plane/go.mod`
- `control-plane/Dockerfile`
- `control-plane/internal/api/api.go`
- `image/build.sh`

---

---
title: "Control plane development"
description: "Go 1.22+ build/test/vet in control-plane/, CGO sqlite note, compose --build loop, package map (docker, store, reaper, wake, api), and image build cache behavior."
---

The `control-plane/` tree is a Go 1.22 module (`github.com/sandboxed/control-plane`) that builds `sandboxd` (CGO + SQLite, shells out to the host `docker` CLI) and `runtimed` (static, CGO-free, baked into the sandbox base image). Day-to-day work is `go build` / `go test` / `go vet` under `control-plane/`, or rebuilding the stack with `docker compose build` after `./install.sh` has produced the base image.

## Toolchain and layout

| Item | Value |
|------|--------|
| Go version | `1.22` (`control-plane/go.mod`) |
| Module path | `github.com/sandboxed/control-plane` |
| Daemon entrypoint | `control-plane/cmd/sandboxd` |
| In-sandbox supervisor | `control-plane/cmd/runtimed` |
| SQL migrations | `control-plane/migrations/*.sql` → `/usr/local/share/sandboxd/migrations/` in the image |
| Container image tag | `sandboxed-control-plane:1.0.0` (`docker-compose.yml`) |

:::files
sandboxed/
├── docker-compose.yml          # traefik + sandboxd (build context: control-plane/)
├── install.sh                  # base image + compose build + up
├── image/
│   ├── build.sh                # sandboxed-base image (repo root context)
│   └── Dockerfile              # stage 1: CGO_ENABLED=0 runtimed; stage 2: runtime
└── control-plane/
    ├── cmd/sandboxd/           # daemon: store, reconcile, reapers, API
    ├── cmd/runtimed/           # in-sandbox HTTP supervisor
    ├── internal/               # docker, store, api, wake, reaper, …
    ├── migrations/
    ├── Dockerfile              # CGO sandboxd + docker-ce-cli runtime
    └── go.mod
:::

## Local Go workflow

From the repository root:

```bash
cd control-plane
go build ./...
go test ./...
go vet ./...
```

<Note>
`go build ./...` compiles every package including `cmd/sandboxd` and `cmd/runtimed`. A successful local build of `sandboxd` requires **CGO** and a C toolchain (see below). `runtimed` builds without CGO.
</Note>

### CGO and SQLite

`sandboxd` depends on `github.com/mattn/go-sqlite3`, which requires CGO. The production image build sets this explicitly:

```dockerfile
RUN CGO_ENABLED=1 go build -trimpath -o /sandboxd ./cmd/sandboxd
```

`runtimed` is built with `CGO_ENABLED=0` inside `image/Dockerfile` so the sandbox base image carries a static binary with no extra native SQLite dependency.

<Warning>
On macOS or other non-Linux hosts, `go test ./...` may fail to link `go-sqlite3` without CGO and a working C compiler. The supported integration path is the Linux Docker stack (`install.sh` / `docker compose build`), which uses `golang:1.22-bookworm` for the CGO build stage.
</Warning>

At runtime, `store.Open` opens SQLite with WAL, busy timeout, and foreign keys (`file:…?_journal=WAL&_busy_timeout=5000&_fk=1` in `cmd/sandboxd/main.go`). Migrations are applied at startup from `SANDBOXD_MIGRATIONS` (default `/usr/local/share/sandboxd/migrations/`). When running a locally built `sandboxd` binary, the daemon falls back to `../../migrations` relative to the executable if the default path is missing.

## Full-stack dev loop

The installer and Compose file split **two** image builds:

```mermaid
flowchart LR
  subgraph host["Host"]
    install["install.sh"]
    compose["docker compose build && up -d"]
  end
  subgraph images["Images"]
    base["sandboxed-base:tag\nimage/Dockerfile\n(runtimed + toolchains)"]
    cp["sandboxed-control-plane:1.0.0\ncontrol-plane/Dockerfile\n(sandboxd CGO)"]
  end
  install --> base
  install --> compose
  compose --> cp
  base --> sandboxd_run["sandboxd docker run\nSANDBOXD_IMAGE"]
  cp --> sandboxd_svc["sandboxd service :9000"]
```

<Steps>
<Step title="Bootstrap env and data dir">
Run `./install.sh` once (idempotent). It copies `.env.example` → `.env` if needed, creates `SANDBOXED_DATA_DIR`, and detects `docker` / `docker compose` (with optional `sudo`).
</Step>
<Step title="Build the sandbox base image">
`image/build.sh` builds from the **repo root** (`-f image/Dockerfile`) and tags `SANDBOXED_IMAGE` (default `sandboxed-base:1.0.0`). This stage compiles `runtimed` and installs Node, pnpm, uv, bun, and agent CLIs — the slow step on first install.
</Step>
<Step title="Build and start the control plane">
`docker compose build` rebuilds `sandboxd` when `control-plane/` changes, then `docker compose up -d` starts Traefik and sandboxd. API: `SANDBOXED_API_BIND` (default `127.0.0.1:9090` → container `:9000`).
</Step>
<Step title="Iterate on Go changes">
After editing `control-plane/`, run `docker compose build sandboxd && docker compose up -d sandboxd` (or `docker compose up -d --build`) and tail logs with `docker compose logs -f sandboxd`.
</Step>
</Steps>

<RequestExample>
```bash
# Rebuild only the control-plane service after a code change
docker compose build sandboxd
docker compose up -d sandboxd
curl -s http://127.0.0.1:9090/healthz
curl -s http://127.0.0.1:9090/readyz
```
</RequestExample>

Per-sandbox containers are **not** Compose services; `sandboxd` launches them at runtime from `SANDBOXD_IMAGE` on `SANDBOXED_NETWORK`.

## Package map

Core packages named in the control-plane README, plus collaborators wired from `cmd/sandboxd/main.go` and `internal/api`:

| Package | Responsibility |
|---------|----------------|
| `cmd/sandboxd` | Env wiring, SQLite open, boot `reconcile.Once`, background reapers/tailers, HTTP server, signals |
| `cmd/runtimed` | In-sandbox supervisor: dev server, tasks, Unix-socket HTTP API |
| `internal/docker` | Typed wrapper over `docker` CLI (`os/exec`); no policy defaults inside the package |
| `internal/store` | SQLite source of truth; single-writer channel; numbered `migrations/*.sql` |
| `internal/loopback` | Per-sandbox workspace directories under `SANDBOXED_DATA_DIR/workspaces` |
| `internal/traefik` | Preview-route Docker label generation |
| `internal/reconcile` | Boot-time convergence: Docker state → SQLite (orphans logged, not deleted) |
| `internal/reaper` | Idle stop (`docker stop`) and host memory pressure stop |
| `internal/wake` | Preview catch-all and `POST /wake/{id}`; admission + warming HTML |
| `internal/api` | HTTP mux: legacy `/sandbox*`, `/v1/*`, health, metrics, forward-auth |
| `internal/auth` | Service-token and preview-token middleware (optional) |
| `internal/runtime` | Client bridge to `runtimed` over the in-container Unix socket |
| `internal/activity` | Traefik access-log tailer, optional connection poller, inflight exec tracking |
| `internal/snapshot` | Manual snapshot/template hooks (auto-snapshotter disabled in OSS compose) |
| `internal/metrics` | Prometheus registry used by `/metrics` |

```text
                    ┌──────────── cmd/sandboxd ────────────┐
                    │  store.Open → reconcile.Once         │
                    │  idle.Run │ pressure.Run │ tailer    │
                    └──────────────┬───────────────────────┘
                                   │
         ┌─────────────────────────┼─────────────────────────┐
         ▼                         ▼                         ▼
   internal/store           internal/docker          internal/api.Server
   (SQLite truth)           (CLI shell-out)          ├─ handlers (CRUD, exec)
         ▲                         ▲                  ├─ v1 (tasks, files, stop)
         │                         │                  └─ wake via hostDispatch
   internal/reaper            internal/loopback              │
   internal/wake              internal/traefik               ▼
   internal/reconcile         internal/runtime ──────► runtimed (in container)
```

`internal/api.Server.Handler()` registers legacy routes (`POST /sandbox`, `GET /sandboxes`, exec, keepalive, purge, snapshots) and v1 routes (`POST /v1/sandboxes`, tasks, files, export). `cmd/sandboxd` wraps the API mux with `auth.Wrap`, then `hostDispatch` so Traefik preview Host headers hit `wake.Handler` before the API.

## Image build cache behavior

Docker layer caching dominates iteration time. Neither Dockerfile uses BuildKit cache mounts; caching follows normal layer invalidation rules.

### Control plane (`control-plane/Dockerfile`)

| Layer order | Cache hit when |
|-------------|----------------|
| `COPY go.mod go.sum` + `go mod download` | Module versions unchanged |
| `COPY . .` + `go build sandboxd` | Any source file under `control-plane/` changes |

Rebuilding `sandboxd` after a one-line Go change reuses the module-download layer but recompiles the binary layer. The runtime stage (Debian slim + `docker-ce-cli` + copied `sandboxd` + `migrations/`) rebuilds when the builder output or migration files change.

### Sandbox base (`image/Dockerfile`)

| Stage | Cache hit when |
|-------|----------------|
| `runtimed-builder`: `go mod download` | `control-plane/go.mod` / `go.sum` unchanged |
| `runtimed-builder`: `go build runtimed` | Any `control-plane/` source change |
| Runtime: `apt-get` / Node / pnpm / uv / bun / npm globals | Earlier Dockerfile instructions unchanged |
| `COPY image/skel`, `COPY runtimed` | Skeleton or `runtimed` binary changed |

`image/build.sh` uses the **repository root** as context so the builder can `COPY control-plane/`. The first `install.sh` run pays the full apt and toolchain cost; subsequent runs skip unchanged layers (CONTRIBUTING: “cached after the first run”). Changing only `control-plane/` code does **not** invalidate the base image unless you rebuild it explicitly — but you **must** rebuild `sandboxd` via Compose for API changes, and rebuild the base image if `cmd/runtimed` changed.

<Tip>
Tag bumps: `image/build.sh [version]` and `SANDBOXED_IMAGE` in `.env` control the base tag. Compose pins `sandboxed-control-plane:1.0.0` independently; bump the image name in `docker-compose.yml` when you need a clean control-plane tag.
</Tip>

## Runtime wiring (for debugging)

On startup, `sandboxd`:

1. Opens SQLite and applies migrations.
2. Runs `reconcile.Once` before accepting HTTP traffic.
3. Starts idle and pressure reapers, access-log tailer, and optional connection poller.
4. Listens on `SANDBOXD_ADDR` (default `0.0.0.0:9000` in the container).

Egress nftables, nginx registry-proxy watcher, and the hourly auto-snapshotter are **disabled** in the OSS compose build (`egressMgr == nil` in `main.go`); the reconciler and manual snapshot APIs still run with directory-backed workspaces.

<Check>
Verify a dev iteration: `curl -s http://127.0.0.1:9090/healthz` → `ok`, `readyz` → `ready`, and `docker compose logs sandboxd` shows `startup: reconcile complete` and `startup: listening`.
</Check>

## Design constraints (when changing code)

- Shell out to `docker` CLI in `internal/docker` unless there is a measured reason to adopt the SDK.
- Treat SQLite as the only durable lifecycle truth; reconciler converges Docker → DB.
- Keep new host dependencies out of the default compose path; optional features should be env-gated and default-off.
- Add or extend `_test.go` where behavior is non-trivial (`internal/traefik`, `internal/auth`, `internal/api`, `cmd/runtimed` already have tests).

## Related pages

<CardGroup>
<Card title="Contributing" href="/contributing">
Project layout, design constraints, and issue report fields.
</Card>
<Card title="Installation" href="/installation">
`./install.sh`, `.env`, base image build, compose up, health checks.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Compose-backed env keys for preview, data dir, idle/reaper, and auth.
</Card>
<Card title="Observability" href="/observability">
`healthz` / `readyz`, Prometheus `/metrics`, compose logs.
</Card>
<Card title="Sandbox lifecycle" href="/sandbox-lifecycle">
SQLite status machine and reconcile-on-boot semantics.
</Card>
<Card title="runtimed reference" href="/runtimed-reference">
In-sandbox supervisor API bridged by `internal/runtime`.
</Card>
</CardGroup>
