summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-08-05 23:14:29 -0500
committerClaude <noreply@anthropic.com>2026-08-05 23:14:29 -0500
commit9f7c9f2f31acd7bc544dd988af1c8c2fe63ec504 (patch)
tree6d7ab2a965c3e138a56c625197658ef2b324b045
Initial commit of skiff scaffolding
-rw-r--r--README.md168
-rw-r--r--USER_GUIDE.md406
-rw-r--r--cmd/skiff/main.go162
-rw-r--r--go.mod3
-rw-r--r--internal/appmeta/appmeta.go35
-rw-r--r--internal/deploy/deploy.go135
-rw-r--r--internal/jailmgr/jailmgr.go71
-rw-r--r--internal/layout/layout.go107
-rw-r--r--internal/nginx/nginx.go45
-rw-r--r--internal/release/release.go52
-rw-r--r--internal/sh/sh.go65
-rw-r--r--internal/tmpl/jail.conf.tmpl10
-rw-r--r--internal/tmpl/nginx-app.conf.tmpl15
-rw-r--r--internal/tmpl/post-receive.tmpl6
-rw-r--r--internal/tmpl/rc.d.tmpl17
-rw-r--r--internal/tmpl/tmpl.go59
-rw-r--r--internal/zfsutil/zfsutil.go55
17 files changed, 1411 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..632fbb0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,168 @@
+# Skiff
+
+A minimal PaaS for FreeBSD + Nginx apps, in any language with a Makefile.
+`git push` triggers a build, an optional app-defined release phase, a ZFS
+snapshot, a cutover, and a health check - with automatic rollback to the
+pre-deploy snapshot if the new release fails its health check. One binary,
+no daemon, no separate metadata database - jail.conf, ZFS datasets, and a
+couple of flat files per app are the only state.
+
+Skiff has no opinion on your language or runtime - the only thing it
+requires of an app is a `Makefile` that produces an executable named `app`,
+and that `app` accepts TCP traffic on an assigned port. See [What an app
+needs to provide](#what-an-app-needs-to-provide).
+
+## Requirements
+
+- FreeBSD 15, ZFS root
+- `nginx` and `git` - neither ships in base; install with
+ `pkg install nginx git-lite`
+- `jail_enable="YES"` in `/etc/rc.conf` (`sysrc jail_enable=YES`) - `service
+ jail start`/`restart` refuse to run without it
+- Whatever toolchain your apps' Makefiles invoke to build - `go`, `python`,
+ `rustc`, `node`, etc. - installed on the **host** (builds run outside the
+ jail; see [One-time host setup](#one-time-host-setup)). `make` itself is
+ already in FreeBSD base (bmake), so it's the one build tool Skiff never
+ asks you to install.
+
+## One-time host setup
+
+### ZFS layout
+
+```
+# zfs create zroot/skiff
+# zfs create -o mountpoint=/skiff/base zroot/skiff/base
+# bsdinstall jail /skiff/base
+# zfs snapshot zroot/skiff/base@ready
+# zfs create -o mountpoint=/skiff/apps zroot/skiff/apps
+```
+
+`ready` is the snapshot every app's dataset gets cloned from. Re-snapshot
+under a new tag after patching the base (e.g. `security-2026-08`) and pass
+`--base-snapshot` to `new-app` to pick it up.
+
+`zroot/skiff/apps` must exist as its own dataset, not a plain directory -
+`new-app` clones straight into it (`zroot/skiff/apps/<name>`), and `zfs
+clone` requires the parent dataset to already exist.
+
+### Repos directory
+
+```
+# mkdir -p /skiff/repos
+```
+
+### Nginx
+
+Add once to `/usr/local/etc/nginx/nginx.conf`, inside the `http` block:
+
+```
+include /usr/local/etc/nginx/skiff.d/*.conf;
+```
+
+```
+# mkdir -p /usr/local/etc/nginx/skiff.d
+```
+
+Skiff only ever writes files under `skiff.d/` and reloads - it never touches
+`nginx.conf` itself.
+
+### Install skiff
+
+```
+$ GOOS=freebsd GOARCH=amd64 go build -o skiff ./cmd/skiff
+# install -m 755 skiff /usr/local/sbin/skiff
+```
+
+## Deploying an app
+
+### First deploy
+
+```
+# skiff new-app --name myapp --ip 10.0.0.5 --port 8080 --server-name myapp.example.com
+```
+
+Clones the base dataset, writes `/etc/jail.conf.d/myapp.conf` and the jail's
+own rc.d service, creates the bare repo at `/skiff/repos/myapp.git` with its
+`post-receive` hook installed, and starts the (empty) jail. Prints the
+remote URL to add locally.
+
+### Every deploy after
+
+```
+$ git remote add skiff root@host:/skiff/repos/myapp.git
+$ git push skiff main
+```
+
+The push blocks until the deploy finishes - success or failure prints
+straight to your terminal, no dashboard to check.
+
+### Rollback
+
+```
+# skiff rollback --app myapp --list
+# skiff rollback --app myapp --to pre-a1b2c3d
+```
+
+Rolls the app's entire ZFS dataset back to that snapshot (code and any
+release-phase side effects together) and restarts the jail.
+
+This uses `zfs rollback -r`, which **destroys every snapshot newer than the
+target** - ZFS can't roll back past one otherwise. Rolling back to an old
+snapshot permanently discards the ability to return to anything deployed
+in between; there's no separate "undo the rollback."
+
+## What an app needs to provide
+
+A `Makefile` in the repo root, and nothing else Skiff cares about - no
+required language, framework, or file layout beyond this contract:
+
+- Running bare `make` (Skiff runs `make -B`, so it never trusts a stale
+ build artifact left in the checkout; every deploy is a fresh worktree
+ anyway) must leave an executable file named `app` in the repo root.
+ What produces it is entirely up to you - `go build -o app .`, a
+ `cp target/release/myapp app` after `cargo build --release`, a shell
+ script with a `#!/usr/bin/env python3` shebang that `chmod +x`'d itself
+ into place, whatever. Skiff execs this file directly (via `daemon(8)`),
+ and the kernel doesn't care whether it's a native binary or a
+ script - only that it's marked executable and has a valid interpreter
+ line if it's not.
+- That executable listens on the port given to `new-app` (bind the
+ wildcard address, not `127.0.0.1`), and answers the configured health
+ path with a non-3xx status once ready.
+- Optionally, a `release` target in the same Makefile - run once inside
+ the jail *before* cutover (against the new release's own checkout, not
+ whatever `current` still points at), gating the deploy on its exit
+ code. Skiff doesn't interpret this target; it's entirely the app's own
+ convention (`sqlite3` migrations, cache warming, whatever). Detected by
+ asking `make` itself via `make -n release`, not by parsing the
+ Makefile - it can be defined however you like (directly, via an
+ include, a pattern rule).
+
+```makefile
+.PHONY: all release
+all: app
+
+app: main.go go.mod
+ go build -o app .
+
+release:
+ sqlite3 /app/data.db < migrations/0004_add_x.sql
+```
+
+See [USER_GUIDE.md](USER_GUIDE.md) for a full walkthrough with a real
+example app.
+
+Runtime dependencies (a Python interpreter, the Node runtime, etc.) must
+be present in the jail's base image if your `app` isn't a self-contained
+static binary - that's a base-jail provisioning concern, not something
+the Makefile handles.
+
+## Known gaps
+
+- No IP allocation - `--ip` is operator-chosen. Fine for a handful of apps
+ on one host; would want a small pool allocator beyond that.
+- Old `releases/<sha>` directories are never pruned. The ZFS snapshot is the
+ real rollback mechanism; the release dirs are just there for the instant
+ symlink flip and are safe to clean up by hand.
+- Single host only. Scaling to a second host is a `zfs send`/`receive` of an
+ app's dataset plus the same jail.conf/nginx steps - not yet automated here.
diff --git a/USER_GUIDE.md b/USER_GUIDE.md
new file mode 100644
index 0000000..5ff93f1
--- /dev/null
+++ b/USER_GUIDE.md
@@ -0,0 +1,406 @@
+# Skiff User Guide
+
+This guide covers Skiff from two angles: **Developers**, who just want to
+`git push` their app, and **System Administrators**, who provision and run
+the FreeBSD host Skiff deploys onto. If you only need the quick reference,
+see [README.md](README.md); this document goes deeper and includes
+operational details discovered by actually standing up a host end-to-end.
+
+## Contents
+
+- [How Skiff works, in one picture](#how-skiff-works-in-one-picture)
+- [Part 1: For Developers](#part-1-for-developers)
+- [Part 2: For System Administrators](#part-2-for-system-administrators)
+- [Troubleshooting](#troubleshooting)
+- [Command reference](#command-reference)
+
+## How Skiff works, in one picture
+
+```
+you skiff host
+ | |
+ | git push skiff main |
+ |--------------------------->| post-receive hook -> `skiff deploy`
+ | | 1. git worktree add (checkout the sha)
+ | | 2. make -B (must produce ./app)
+ | | 3. zfs snapshot (pre-<sha>)
+ | | 4. optional Makefile `release` target
+ | | 5. flip `current` symlink, restart jail
+ | | 6. poll health path for up to 10s
+ | | healthy -> update nginx, done
+ | | unhealthy -> zfs rollback -r, restart
+ | <--------------------------|
+ | push succeeds/fails |
+```
+
+Every app lives in its own FreeBSD jail, cloned from a shared ZFS base
+image. There's no scheduler, no dashboard, no separate metadata database -
+`jail.conf.d/<app>.conf`, the ZFS dataset, and a `meta.json` file are the
+only state, and the entire control plane is the one `skiff` binary.
+
+---
+
+## Part 1: For Developers
+
+### What your app needs to provide
+
+Skiff has no opinion on your language - the entire build/run contract is a
+`Makefile` in your repo root:
+
+- Running bare `make` (Skiff runs `make -B`, so a stale artifact left
+ over from a previous local run is never trusted - not that one would
+ exist anyway, since every deploy is a brand-new checkout) must leave an
+ executable file named `app` in the repo root. Skiff execs this file
+ directly via `daemon(8)` once it's deployed - the kernel handles
+ interpreter shebang lines (`#!/usr/bin/env python3`, etc.) exactly like
+ a native binary, so *any* language works as long as something ends up
+ executable at that one fixed path. A Go example:
+
+ ```makefile
+ .PHONY: all
+ all: app
+
+ app: main.go go.mod
+ go build -o app .
+ ```
+
+ A Python example, to show it's not just "compiled languages with an
+ extra step":
+
+ ```makefile
+ .PHONY: all
+ all: app
+
+ app: main.py requirements.txt
+ python3 -m venv .venv
+ .venv/bin/pip install -q -r requirements.txt
+ printf '#!/bin/sh\nexec %s/.venv/bin/python3 %s/main.py\n' "$$(pwd)" "$$(pwd)" > app
+ chmod +x app
+ ```
+
+- That executable must listen on the port your sysadmin assigned when
+ they ran `new-app` (default `8080`), and answer the configured health
+ path (default `/healthz`) with a non-3xx status once it's ready to
+ serve traffic. Bind to `:<port>` (all interfaces), not
+ `127.0.0.1:<port>` - each jail has exactly one IP and binding to it
+ explicitly is unnecessary; the wildcard bind resolves correctly inside
+ the jail's own address space.
+
+- Optionally, a `release` target in the same Makefile, run once inside
+ the jail *before* cutover - against the **new** release's own checkout
+ (Skiff invokes it as `make -C <new-release-dir> release`, so relative
+ paths work), not whatever `current` still points at:
+
+ ```makefile
+ release:
+ sqlite3 /app/data.db < migrations/0004_add_x.sql
+ ```
+
+ Skiff never interprets this target - it's entirely your own
+ convention. If it exits non-zero, the deploy aborts *before* touching
+ the live release, so a bad migration never takes your app down. Skiff
+ detects whether you've declared one by asking `make` itself
+ (`make -n release`), not by reading your Makefile's text - it doesn't
+ care how the target is defined, only whether bmake thinks it exists.
+
+- Persistent state: `/app/data.db` and `/app/env` (jail-relative paths,
+ siblings of `/app/current`) are meant to be your durable state,
+ untouched by any single release's checkout/build. In practice today
+ this is a *convention*, not something Skiff actively manages for you -
+ there's no automatic env-var injection or database provisioning. If you
+ want a SQLite file that survives every deploy, just open it at
+ `/app/data.db` yourself and it'll sit outside the `releases/<sha>`
+ directories that get replaced on every push.
+
+### Deploying
+
+Once your sysadmin has run `new-app` for you, they'll give you a remote
+URL:
+
+```
+$ git remote add skiff root@host:/skiff/repos/myapp.git
+$ git push skiff main
+```
+
+**Only pushes to `main` trigger a deploy.** Push to any other branch and
+the hook no-ops (prints a message, exits 0) - handy for keeping a
+`skiff` remote around without accidentally deploying WIP branches.
+
+The push blocks until the deploy finishes - build, snapshot, cutover, and
+health check all stream straight back to your terminal:
+
+```
+remote: ==> checking out 1a36776...
+remote: ==> building
+remote: ==> snapshotting current state
+remote: ==> no release target declared, skipping
+remote: ==> cutting over
+remote: Stopping jails: myapp.
+remote: Starting jails: myapp.
+remote: ==> healthy, updating routing
+remote: ==> deployed myapp at 1a36776...
+```
+
+There's no dashboard - if it printed `healthy`, it's live; check the
+terminal output, not a web UI.
+
+### If the health check fails
+
+Skiff automatically rolls the app's entire ZFS dataset back to right
+before your deploy and restarts the jail on the old release:
+
+```
+remote: ==> health check failed (never became healthy at http://10.0.0.5:8080/healthz: ...), rolling back to pre-1a36776...
+remote: Stopping jails: myapp.
+remote: Starting jails: myapp.
+remote: error: deploy failed health check and was rolled back: ...
+```
+
+The old version is back up by the time you see this error - you did not
+just take the app down. Common causes, roughly in order of likelihood:
+
+1. Your app doesn't actually bind to the assigned port within 10 seconds
+ (that's the whole health-check budget - retried every 500ms).
+2. It's listening on `127.0.0.1` instead of the wildcard address, or on
+ the wrong port.
+3. The health path returns a 3xx/4xx/5xx instead of 2xx.
+4. It panics/crashes immediately on start - check with your sysadmin,
+ who can `jexec <app> /app/current/app` to see stderr directly, or
+ inspect syslog inside the jail.
+
+**One git subtlety worth knowing:** even when the deploy fails and rolls
+back, the git ref on the server *still advances* to your new commit -
+git itself doesn't reject the push, only the app-level deploy failed
+after the fact. So if you fix nothing and just run `git push` again,
+you'll see `Everything up-to-date` and nothing will happen, because
+there's no new commit to push. Either make a new commit (even
+`git commit --allow-empty -m retry` while you investigate) or ask your
+sysadmin to re-run the deploy directly on the host:
+`skiff deploy --app myapp --new <sha> --ref refs/heads/main`.
+
+### Rolling back a bad release yourself
+
+If you have shell access to the host (or your sysadmin does it for you):
+
+```
+# skiff rollback --app myapp --list
+# skiff rollback --app myapp --to pre-a1b2c3d
+```
+
+Read the sysadmin section's [rollback notes](#rollback) before you do
+this in a hurry - it's more destructive than "undo."
+
+---
+
+## Part 2: For System Administrators
+
+### Requirements
+
+- FreeBSD 15, ZFS root
+- `nginx` and `git` - **neither ships in FreeBSD base.** Install them
+ with:
+ ```
+ # pkg install nginx git-lite
+ ```
+- Whatever your apps' Makefiles actually invoke to build - `go`,
+ `python3`, `rustc`, `node`, etc. - installed on the **host** (builds
+ run outside the jail, via `make -B` in the checked-out worktree). Skiff
+ itself doesn't care which; `make` is already in FreeBSD base (bmake),
+ so it's never something you need to install separately.
+- `jail_enable="YES"` in `/etc/rc.conf`:
+ ```
+ # sysrc jail_enable=YES
+ ```
+ Without this, `service jail start`/`restart` refuse to run at all, and
+ every deploy will fail at the cutover step with a jail that never
+ actually starts.
+
+### One-time host setup
+
+#### 1. ZFS layout
+
+```
+# zfs create zroot/skiff
+# zfs create -o mountpoint=/skiff/base zroot/skiff/base
+# bsdinstall jail /skiff/base
+# zfs snapshot zroot/skiff/base@ready
+# zfs create -o mountpoint=/skiff/apps zroot/skiff/apps
+```
+
+`zroot/skiff/apps` must exist as its own dataset, **not a plain
+directory** - `new-app` clones straight into it
+(`zroot/skiff/apps/<name>`), and `zfs clone` requires its parent dataset
+to already exist. A plain `mkdir /skiff/apps` will let `new-app` fail
+with `cannot create '...': parent does not exist`.
+
+`ready` is the snapshot every app's dataset gets cloned from. Re-snapshot
+under a new tag after patching the base (e.g. `security-2026-08`) and
+pass `--base-snapshot` to `new-app` to pick it up.
+
+**If `bsdinstall jail` hangs at 100% CPU with no output:** it's spawned
+an interactive `dialog` (distribution-set vs. packages, mirror
+selection) and has no terminal to answer it - common when driving this
+over a non-interactive SSH session. Kill it (`pkill -9 bsddialog`) and
+re-run fully non-interactive instead:
+
+```
+# export nonInteractive=YES
+# export BSDINSTALL_DISTSITE=https://download.freebsd.org/releases/amd64/amd64/15.0-RELEASE
+# bsdinstall jail /skiff/base
+```
+
+(match the release number to your host's own `uname -r`; check
+`https://download.freebsd.org/releases/amd64/amd64/<version>/MANIFEST`
+exists before relying on it.) You can watch real progress with
+`zfs list zroot/skiff/base -o used` even while `dialog`'s own progress
+bar is garbled without a real tty.
+
+#### 2. Repos directory
+
+```
+# mkdir -p /skiff/repos
+```
+
+#### 3. Nginx
+
+Add once to `/usr/local/etc/nginx/nginx.conf`, inside the `http` block:
+
+```
+include /usr/local/etc/nginx/skiff.d/*.conf;
+```
+
+```
+# mkdir -p /usr/local/etc/nginx/skiff.d
+# sysrc nginx_enable=YES
+# service nginx start
+```
+
+Skiff only ever writes files under `skiff.d/` and reloads - it never
+touches `nginx.conf` itself.
+
+#### 4. Install skiff
+
+Cross-compile from any machine with Go, then copy the binary over:
+
+```
+$ GOOS=freebsd GOARCH=amd64 go build -o skiff ./cmd/skiff
+$ scp skiff root@host:/tmp/skiff
+# install -m 755 /tmp/skiff /usr/local/sbin/skiff
+```
+
+### Provisioning a new app
+
+```
+# skiff new-app --name myapp --ip 10.0.0.5 --port 8080 --server-name myapp.example.com
+```
+
+This clones the base dataset, writes `/etc/jail.conf.d/myapp.conf` and
+the jail's own `/usr/local/etc/rc.d/myapp` script (installed *inside*
+the jail's own filesystem, not the host's), creates the bare repo at
+`/skiff/repos/myapp.git` with its `post-receive` hook, and starts the
+(empty) jail.
+
+**Critical, undocumented-by-default step: alias the IP onto the host.**
+Skiff's jails are classic (non-VNET) jails - `--ip` is an address the
+jail binds to, but FreeBSD won't route to it unless that address already
+exists on a host network interface. `new-app` does **not** do this for
+you:
+
+```
+# ifconfig <your-interface> alias 10.0.0.5/24
+```
+
+Without this, the jail starts fine but is completely unreachable - every
+deploy for that app will fail its health check and roll back forever,
+even though the app itself is working correctly inside the jail. This
+was the single most confusing failure mode encountered testing Skiff:
+the deploy pipeline, build, and app were all fine, and the jail was
+simply not reachable. If you're provisioning many apps, plan a real IP
+range up front and script the alias step - see [Known
+gaps](#known-gaps) below.
+
+Make the alias persistent across reboots via `/etc/rc.conf`
+(`ifconfig_<if>_alias0="inet 10.0.0.5/24"`) rather than a one-off
+`ifconfig` command, or it won't survive a host restart.
+
+### Rollback
+
+```
+# skiff rollback --app myapp --list
+# skiff rollback --app myapp --to pre-a1b2c3d
+```
+
+Rolls the app's entire ZFS dataset back to that snapshot (code and any
+release-phase side effects together) and restarts the jail.
+
+This uses `zfs rollback -r`, which **destroys every snapshot newer than
+the target** - ZFS can't roll back past one otherwise. Rolling back to
+an old snapshot permanently discards the ability to return to anything
+deployed in between; there's no separate "undo the rollback." Treat
+`--to` as a one-way door, not a slider you can move back and forth.
+
+Because the bare git repo lives on a *separate* dataset from the app
+(`/skiff/repos` vs. `/skiff/apps/<name>`), a rollback can delete a
+release directory whose git worktree is still registered in the repo.
+Current `skiff` versions self-heal this automatically (checkout runs
+`git worktree prune` first); if you're running an older binary and see
+`missing but already registered worktree`, run
+`git -C /skiff/repos/<app>.git worktree prune` by hand and retry.
+
+### Inspecting a running app
+
+```
+# jls # list running jails
+# jexec myapp ps aux # processes inside the jail
+# jexec myapp sockstat -4 -l # what's actually listening
+# jexec myapp service myapp status # the app's own rc.d status
+# service jail restart myapp # what skiff itself calls on deploy
+```
+
+### Known gaps
+
+Carried over from the project's own README, plus what testing surfaced:
+
+- **No IP allocation.** `--ip` is operator-chosen and must be manually
+ aliased onto a host interface (see above) - fine for a handful of
+ apps on one host, but you'll want a small pool allocator and an
+ automated alias step beyond that.
+- **Old `releases/<sha>` directories are never pruned.** The ZFS
+ snapshot is the real rollback mechanism; release directories are just
+ there for the instant symlink flip and are safe to clean up by hand
+ once you're confident you won't roll back to them.
+- **Single host only.** Scaling to a second host is a `zfs
+ send`/`receive` of an app's dataset plus the same jail.conf/nginx
+ steps - not automated.
+- **`skiff rollback -r` is destructive** (see above) - there is
+ currently no confirmation prompt or dry-run.
+
+---
+
+## Troubleshooting
+
+| Symptom | Likely cause | Fix |
+|---|---|---|
+| `zfs clone ...: parent does not exist` on `new-app` | `zroot/skiff/apps` was created as a directory, not a dataset | `zfs create -o mountpoint=/skiff/apps zroot/skiff/apps` |
+| `Cannot 'start' jail. Set jail_enable to YES...` | `jail_enable` unset | `sysrc jail_enable=YES` |
+| Deploy always health-check-fails with `connection refused`, even though the app builds fine | Jail's IP was never aliased onto a host interface | `ifconfig <if> alias <ip>/<mask>` (see [Provisioning](#provisioning-a-new-app)) |
+| `git worktree add ...: missing but already registered worktree` on redeploy | Stale worktree entry after a rollback deleted a release dir (older skiff binaries only) | `git -C /skiff/repos/<app>.git worktree prune` |
+| `bsdinstall jail` spins at 100% CPU forever | Interactive `dialog` waiting on a terminal that isn't there | Kill it, re-run with `nonInteractive=YES` and `BSDINSTALL_DISTSITE` set (see setup section) |
+| `git push` says `Everything up-to-date` but you wanted to retry a failed deploy | The git ref already advanced despite the deploy failing - nothing new to push | New commit (`git commit --allow-empty`), or on the host: `skiff deploy --app <app> --new <sha> --ref refs/heads/main` |
+| `build: \`make\` did not produce ./app` | Your Makefile's default target ran (or was skipped as "up to date") without ultimately leaving a file named `app` in the repo root | Run `make -B` locally in a clean checkout and confirm `./app` exists after; check the target actually named `app` (or a dependency of the default target) writes there |
+| Health check fails and you're not sure why | - | `jexec <app> sockstat -4 -l` to check for a listener; `jexec <app> ps aux` to see if the process is even running; try `jexec <app> /app/current/app` directly to see startup errors that daemon(8) would otherwise swallow |
+
+---
+
+## Command reference
+
+```
+skiff new-app --name <app> --ip <ip> [--port 8080] [--health-path /healthz]
+ [--server-name <name>] [--base-snapshot ready]
+
+skiff deploy --app <app> --new <sha> [--ref refs/heads/main] [--old <sha>]
+ (normally invoked by the post-receive hook, not by hand)
+
+skiff rollback --app <app> --list
+skiff rollback --app <app> --to <snapshot-tag>
+```
diff --git a/cmd/skiff/main.go b/cmd/skiff/main.go
new file mode 100644
index 0000000..e709227
--- /dev/null
+++ b/cmd/skiff/main.go
@@ -0,0 +1,162 @@
+// Command skiff is the entire control plane: one binary, invoked either by
+// a repo's post-receive hook (deploy) or by hand (new-app, rollback).
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "skiff/internal/appmeta"
+ "skiff/internal/deploy"
+ "skiff/internal/jailmgr"
+ "skiff/internal/layout"
+ "skiff/internal/sh"
+ "skiff/internal/tmpl"
+ "skiff/internal/zfsutil"
+)
+
+func main() {
+ if len(os.Args) < 2 {
+ fmt.Fprintln(os.Stderr, "usage: skiff <deploy|new-app|rollback> [flags]")
+ os.Exit(1)
+ }
+
+ var err error
+ switch os.Args[1] {
+ case "deploy":
+ err = cmdDeploy(os.Args[2:])
+ case "new-app":
+ err = cmdNewApp(os.Args[2:])
+ case "rollback":
+ err = cmdRollback(os.Args[2:])
+ default:
+ fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1])
+ os.Exit(1)
+ }
+
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func cmdDeploy(args []string) error {
+ fs := flag.NewFlagSet("deploy", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ oldSHA := fs.String("old", "", "old commit sha (unused, kept for hook parity)")
+ newSHA := fs.String("new", "", "new commit sha to deploy")
+ ref := fs.String("ref", "", "ref being pushed, e.g. refs/heads/main")
+ fs.Parse(args)
+ _ = oldSHA
+
+ if *app == "" || *newSHA == "" {
+ return fmt.Errorf("--app and --new are required")
+ }
+ if *ref != "" && *ref != "refs/heads/main" {
+ fmt.Printf("==> %s is not refs/heads/main, skipping deploy\n", *ref)
+ return nil
+ }
+
+ return deploy.Run(context.Background(), os.Stdout, layout.Default(), *app, *newSHA)
+}
+
+func cmdNewApp(args []string) error {
+ fs := flag.NewFlagSet("new-app", flag.ExitOnError)
+ name := fs.String("name", "", "app name")
+ ip := fs.String("ip", "", "IPv4 address to assign the jail (operator-chosen)")
+ port := fs.Int("port", layout.DefaultHealthPort, "port the app listens on inside the jail")
+ healthPath := fs.String("health-path", layout.DefaultHealthPath, "health check path")
+ serverName := fs.String("server-name", "", "nginx server_name (defaults to <name>)")
+ baseSnapshot := fs.String("base-snapshot", "ready", "snapshot on the base dataset to clone from")
+ fs.Parse(args)
+
+ if *name == "" || *ip == "" {
+ return fmt.Errorf("--name and --ip are required")
+ }
+ if *serverName == "" {
+ *serverName = *name
+ }
+
+ ctx := context.Background()
+ out := os.Stdout
+ cfg := layout.Default()
+
+ fmt.Fprintf(out, "==> cloning %s@%s -> %s\n", cfg.BaseDataset, *baseSnapshot, cfg.AppDataset(*name))
+ if err := zfsutil.Clone(ctx, out, cfg.BaseDataset, *baseSnapshot, cfg.AppDataset(*name), cfg.JailRoot(*name)); err != nil {
+ return fmt.Errorf("clone base dataset: %w", err)
+ }
+
+ if err := os.MkdirAll(filepath.Join(cfg.AppDir(*name), "releases"), 0755); err != nil {
+ return fmt.Errorf("create app dir: %w", err)
+ }
+
+ if err := jailmgr.WriteConf(cfg, *name, *ip); err != nil {
+ return fmt.Errorf("write jail config: %w", err)
+ }
+
+ if err := appmeta.Write(cfg.MetaPath(*name), appmeta.Meta{
+ IP: *ip,
+ Port: *port,
+ HealthPath: *healthPath,
+ ServerName: *serverName,
+ }); err != nil {
+ return fmt.Errorf("write app metadata: %w", err)
+ }
+
+ fmt.Fprintf(out, "==> initializing bare repo %s\n", cfg.RepoPath(*name))
+ if err := sh.Run(ctx, out, "", "git", "init", "--bare", cfg.RepoPath(*name)); err != nil {
+ return fmt.Errorf("git init: %w", err)
+ }
+ hook, err := tmpl.PostReceive(tmpl.PostReceiveData{Name: *name})
+ if err != nil {
+ return err
+ }
+ hookPath := filepath.Join(cfg.RepoPath(*name), "hooks", "post-receive")
+ if err := os.WriteFile(hookPath, []byte(hook), 0755); err != nil {
+ return fmt.Errorf("install post-receive hook: %w", err)
+ }
+
+ fmt.Fprintln(out, "==> starting jail")
+ if err := jailmgr.Start(ctx, out, *name); err != nil {
+ return fmt.Errorf("start jail: %w", err)
+ }
+
+ fmt.Fprintf(out, "\n%s is ready. From the app's repo:\n git remote add skiff <this-host>:%s\n git push skiff main\n", *name, cfg.RepoPath(*name))
+ return nil
+}
+
+func cmdRollback(args []string) error {
+ fs := flag.NewFlagSet("rollback", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ to := fs.String("to", "", "snapshot tag to roll back to, e.g. pre-<sha>")
+ list := fs.Bool("list", false, "list available snapshots and exit")
+ fs.Parse(args)
+
+ if *app == "" {
+ return fmt.Errorf("--app is required")
+ }
+ ctx := context.Background()
+ cfg := layout.Default()
+ dataset := cfg.AppDataset(*app)
+
+ if *list {
+ snaps, err := zfsutil.Snapshots(ctx, dataset)
+ if err != nil {
+ return err
+ }
+ fmt.Println(strings.Join(snaps, "\n"))
+ return nil
+ }
+ if *to == "" {
+ return fmt.Errorf("--to is required (use --list to see snapshots)")
+ }
+
+ if err := zfsutil.Rollback(ctx, os.Stdout, dataset, *to); err != nil {
+ return fmt.Errorf("rollback: %w", err)
+ }
+ return jailmgr.Restart(ctx, os.Stdout, *app)
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..21c0977
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module skiff
+
+go 1.25.10
diff --git a/internal/appmeta/appmeta.go b/internal/appmeta/appmeta.go
new file mode 100644
index 0000000..61ceb18
--- /dev/null
+++ b/internal/appmeta/appmeta.go
@@ -0,0 +1,35 @@
+// Package appmeta stores the small amount of per-app networking config
+// Skiff itself needs (IP, port, health check) that isn't already implied by
+// jail.conf/rc.conf - a flat file living with the app's own data, not a
+// separate control-plane database.
+package appmeta
+
+import (
+ "encoding/json"
+ "os"
+)
+
+type Meta struct {
+ IP string `json:"ip"`
+ Port int `json:"port"`
+ HealthPath string `json:"health_path"`
+ ServerName string `json:"server_name"`
+}
+
+func Write(path string, m Meta) error {
+ b, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, b, 0644)
+}
+
+func Read(path string) (Meta, error) {
+ var m Meta
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return m, err
+ }
+ err = json.Unmarshal(b, &m)
+ return m, err
+}
diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go
new file mode 100644
index 0000000..a25701a
--- /dev/null
+++ b/internal/deploy/deploy.go
@@ -0,0 +1,135 @@
+// Package deploy orchestrates one push: checkout, build, snapshot, release
+// phase, cutover, health check, and - on a failed health check - a full
+// dataset rollback. It is the entire "control plane": one function, called
+// synchronously from the repo's post-receive hook.
+package deploy
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "skiff/internal/appmeta"
+ "skiff/internal/jailmgr"
+ "skiff/internal/layout"
+ "skiff/internal/nginx"
+ "skiff/internal/release"
+ "skiff/internal/sh"
+ "skiff/internal/zfsutil"
+)
+
+func Run(ctx context.Context, out io.Writer, cfg layout.Config, app, sha string) error {
+ dataset := cfg.AppDataset(app)
+ snapshotTag := "pre-" + sha
+ releaseDir := cfg.ReleaseDir(app, sha)
+
+ fmt.Fprintf(out, "==> checking out %s\n", sha)
+ // A previous rollback can revert the app's dataset (and so delete a
+ // release dir) without the bare repo - on a separate dataset - ever
+ // knowing; prune first or `worktree add` fails on the stale entry.
+ if err := sh.Run(ctx, out, "", "git", "-C", cfg.RepoPath(app), "worktree", "prune"); err != nil {
+ fmt.Fprintf(out, "==> warning: worktree prune failed: %v\n", err)
+ }
+ if err := sh.Run(ctx, out, "", "git", "-C", cfg.RepoPath(app), "worktree", "add", releaseDir, sha); err != nil {
+ return fmt.Errorf("checkout: %w", err)
+ }
+
+ fmt.Fprintln(out, "==> building")
+ if err := sh.Run(ctx, out, releaseDir, "make", "-B"); err != nil {
+ return fmt.Errorf("build: %w", err)
+ }
+ if _, err := os.Stat(filepath.Join(releaseDir, "app")); err != nil {
+ return fmt.Errorf("build: `make` did not produce ./app: %w", err)
+ }
+
+ fmt.Fprintln(out, "==> snapshotting current state")
+ if err := zfsutil.Snapshot(ctx, out, dataset, snapshotTag); err != nil {
+ return fmt.Errorf("snapshot: %w", err)
+ }
+
+ hasRelease, err := release.Declared(ctx, releaseDir)
+ if err != nil {
+ return fmt.Errorf("check for release target: %w", err)
+ }
+ if hasRelease {
+ if err := release.Run(ctx, out, app, cfg.ReleaseDirInJail(sha)); err != nil {
+ return fmt.Errorf("release phase failed, deploy aborted before cutover: %w", err)
+ }
+ } else {
+ fmt.Fprintln(out, "==> no release target declared, skipping")
+ }
+
+ fmt.Fprintln(out, "==> cutting over")
+ if err := swapCurrent(cfg.CurrentRelease(app), cfg.ReleaseDirInJail(sha)); err != nil {
+ return fmt.Errorf("cutover: %w", err)
+ }
+ if err := jailmgr.Restart(ctx, out, app); err != nil {
+ return fmt.Errorf("restart: %w", err)
+ }
+
+ meta, err := appmeta.Read(cfg.MetaPath(app))
+ if err != nil {
+ return fmt.Errorf("read app metadata: %w", err)
+ }
+
+ if err := waitHealthy(ctx, meta); err != nil {
+ fmt.Fprintf(out, "==> health check failed (%v), rolling back to pre-%s\n", err, sha)
+ if rbErr := zfsutil.Rollback(ctx, out, dataset, snapshotTag); rbErr != nil {
+ return fmt.Errorf("health check failed AND rollback failed: %v / %w", err, rbErr)
+ }
+ if rbErr := jailmgr.Restart(ctx, out, app); rbErr != nil {
+ return fmt.Errorf("health check failed, rolled back, but restart failed: %v / %w", err, rbErr)
+ }
+ return fmt.Errorf("deploy failed health check and was rolled back: %w", err)
+ }
+
+ fmt.Fprintln(out, "==> healthy, updating routing")
+ if err := nginx.EnsureRoute(ctx, out, cfg, app, meta.IP, meta.Port, meta.ServerName); err != nil {
+ return fmt.Errorf("nginx: %w", err)
+ }
+
+ fmt.Fprintf(out, "==> deployed %s at %s\n", app, sha)
+ return nil
+}
+
+// swapCurrent atomically repoints the `current` symlink at newTarget by
+// building the replacement next to it and renaming over it - never leaves
+// the symlink briefly missing.
+func swapCurrent(currentPath, newTarget string) error {
+ tmp := currentPath + ".next"
+ os.Remove(tmp)
+ if err := os.Symlink(newTarget, tmp); err != nil {
+ return err
+ }
+ return os.Rename(tmp, currentPath)
+}
+
+func waitHealthy(ctx context.Context, meta appmeta.Meta) error {
+ url := fmt.Sprintf("http://%s:%d%s", meta.IP, meta.Port, meta.HealthPath)
+ deadline := time.Now().Add(layout.HealthTimeoutSecs * time.Second)
+ client := &http.Client{Timeout: 2 * time.Second}
+
+ var lastErr error
+ for time.Now().Before(deadline) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return err
+ }
+ resp, err := client.Do(req)
+ if err == nil {
+ resp.Body.Close()
+ if resp.StatusCode < 300 {
+ return nil
+ }
+ lastErr = fmt.Errorf("status %d", resp.StatusCode)
+ } else {
+ lastErr = err
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+ return fmt.Errorf("never became healthy at %s: %w", url, lastErr)
+}
diff --git a/internal/jailmgr/jailmgr.go b/internal/jailmgr/jailmgr.go
new file mode 100644
index 0000000..54cf614
--- /dev/null
+++ b/internal/jailmgr/jailmgr.go
@@ -0,0 +1,71 @@
+// Package jailmgr talks to jail(8) directly via jail.conf.d and the base
+// system's `service jail` rc.d script - no bastille/iocage in the loop.
+package jailmgr
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "skiff/internal/layout"
+ "skiff/internal/sh"
+ "skiff/internal/tmpl"
+)
+
+// WriteConf renders jail.conf.d/<app>.conf and the jail's own rc.d service
+// script + rc.conf enable line. Called once, from `skiff new-app`.
+func WriteConf(cfg layout.Config, app, ip string) error {
+ conf, err := tmpl.JailConf(tmpl.JailConfData{
+ Name: app,
+ JailRoot: cfg.JailRoot(app),
+ IP: ip,
+ })
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(cfg.JailConfPath(app), []byte(conf), 0644); err != nil {
+ return fmt.Errorf("write jail.conf: %w", err)
+ }
+
+ rc, err := tmpl.RCScript(tmpl.RCData{Name: app})
+ if err != nil {
+ return err
+ }
+ rcPath := filepath.Join(cfg.JailRoot(app), "usr", "local", "etc", "rc.d", app)
+ if err := os.MkdirAll(filepath.Dir(rcPath), 0755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(rcPath, []byte(rc), 0755); err != nil {
+ return fmt.Errorf("write rc.d script: %w", err)
+ }
+
+ rcConfLine := fmt.Sprintf("\n%s_enable=\"YES\"\n", app)
+ f, err := os.OpenFile(filepath.Join(cfg.JailRoot(app), "etc", "rc.conf"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil {
+ return fmt.Errorf("open jail rc.conf: %w", err)
+ }
+ defer f.Close()
+ if _, err := f.WriteString(rcConfLine); err != nil {
+ return fmt.Errorf("enable service in jail rc.conf: %w", err)
+ }
+ return nil
+}
+
+func Start(ctx context.Context, out io.Writer, app string) error {
+ return sh.Run(ctx, out, "", "service", "jail", "start", app)
+}
+
+func Stop(ctx context.Context, out io.Writer, app string) error {
+ return sh.Run(ctx, out, "", "service", "jail", "stop", app)
+}
+
+// Restart restarts an already-running jail, or starts it if this is its
+// first deploy and it isn't up yet.
+func Restart(ctx context.Context, out io.Writer, app string) error {
+ if err := sh.Run(ctx, out, "", "service", "jail", "restart", app); err != nil {
+ return sh.Run(ctx, out, "", "service", "jail", "start", app)
+ }
+ return nil
+}
diff --git a/internal/layout/layout.go b/internal/layout/layout.go
new file mode 100644
index 0000000..89e6848
--- /dev/null
+++ b/internal/layout/layout.go
@@ -0,0 +1,107 @@
+// Package layout defines Skiff's directory and dataset conventions in one
+// place. Nothing else in the codebase should hardcode a path.
+package layout
+
+import (
+ "fmt"
+ "path/filepath"
+)
+
+const (
+ DefaultHealthPort = 8080
+ DefaultHealthPath = "/healthz"
+ HealthTimeoutSecs = 10
+)
+
+// Config is Skiff's host-wide configuration. Every field has a sane FreeBSD
+// default; override via flags on the skiff binary if a host differs.
+type Config struct {
+ ZFSPool string // e.g. "zroot"
+ BaseDataset string // template jail root, cloned per app
+ ReposDir string // bare git repos live here
+ JailsMount string // parent mountpoint for per-app datasets
+ JailConfDir string // /etc/jail.conf.d
+ NginxConfDir string // nginx include dir regenerated on every deploy
+}
+
+func Default() Config {
+ return Config{
+ ZFSPool: "zroot/skiff",
+ BaseDataset: "zroot/skiff/base",
+ ReposDir: "/skiff/repos",
+ JailsMount: "/skiff/apps",
+ JailConfDir: "/etc/jail.conf.d",
+ NginxConfDir: "/usr/local/etc/nginx/skiff.d",
+ }
+}
+
+func (c Config) AppDataset(app string) string {
+ return fmt.Sprintf("%s/apps/%s", c.ZFSPool, app)
+}
+
+func (c Config) JailRoot(app string) string {
+ return filepath.Join(c.JailsMount, app)
+}
+
+func (c Config) RepoPath(app string) string {
+ return filepath.Join(c.ReposDir, app+".git")
+}
+
+func (c Config) JailConfPath(app string) string {
+ return filepath.Join(c.JailConfDir, app+".conf")
+}
+
+func (c Config) NginxConfPath(app string) string {
+ return filepath.Join(c.NginxConfDir, app+".conf")
+}
+
+// Everything below is Capistrano-style: each deploy gets its own immutable
+// releases/<sha> checkout (built in place, inside the jail's own dataset),
+// `current` is a symlink to whichever release is live, and data.db/env are
+// persistent siblings that outlive any single release.
+func (c Config) AppDir(app string) string {
+ return filepath.Join(c.JailRoot(app), "app")
+}
+
+func (c Config) ReleaseDir(app, sha string) string {
+ return filepath.Join(c.AppDir(app), "releases", sha)
+}
+
+func (c Config) CurrentRelease(app string) string {
+ return filepath.Join(c.AppDir(app), "current")
+}
+
+// ReleaseDirInJail is releases/<sha> as seen from inside the jail's own
+// chroot, not the host. The `current` symlink must be written with this
+// form as its target: it's dereferenced by the jail's rc.d script (execing
+// CurrentBin), where a host-absolute path like ReleaseDir's resolves
+// relative to the jail root instead and never exists.
+func (c Config) ReleaseDirInJail(sha string) string {
+ return filepath.Join("/app", "releases", sha)
+}
+
+// CurrentBin is the path the jail's rc.d script execs - must match
+// internal/tmpl/rc.d.tmpl's command_args.
+func (c Config) CurrentBin(app string) string {
+ return filepath.Join(c.CurrentRelease(app), "app")
+}
+
+func (c Config) MakefilePath(app, sha string) string {
+ return filepath.Join(c.ReleaseDir(app, sha), "Makefile")
+}
+
+func (c Config) DataDB(app string) string {
+ return filepath.Join(c.AppDir(app), "data.db")
+}
+
+func (c Config) EnvFile(app string) string {
+ return filepath.Join(c.AppDir(app), "env")
+}
+
+func (c Config) MetaPath(app string) string {
+ return filepath.Join(c.AppDir(app), "meta.json")
+}
+
+func (c Config) SnapshotName(app, sha string) string {
+ return fmt.Sprintf("%s@pre-%s", c.AppDataset(app), sha)
+}
diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go
new file mode 100644
index 0000000..abdd4c9
--- /dev/null
+++ b/internal/nginx/nginx.go
@@ -0,0 +1,45 @@
+// Package nginx regenerates one vhost file per app and reloads. Skiff never
+// touches nginx.conf itself - that just needs one `include skiff.d/*.conf;`
+// line added once, by hand, when nginx is first set up.
+package nginx
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+
+ "skiff/internal/layout"
+ "skiff/internal/sh"
+ "skiff/internal/tmpl"
+)
+
+// EnsureRoute writes app's vhost file and reloads nginx, but only if the
+// rendered config actually changed - most deploys don't touch routing.
+func EnsureRoute(ctx context.Context, out io.Writer, cfg layout.Config, app, ip string, port int, serverName string) error {
+ rendered, err := tmpl.NginxApp(tmpl.NginxAppData{
+ Name: app,
+ IP: ip,
+ Port: port,
+ ServerName: serverName,
+ })
+ if err != nil {
+ return err
+ }
+
+ path := cfg.NginxConfPath(app)
+ existing, _ := os.ReadFile(path)
+ if string(existing) == rendered {
+ return nil
+ }
+
+ if err := os.WriteFile(path, []byte(rendered), 0644); err != nil {
+ return fmt.Errorf("write nginx conf for %s: %w", app, err)
+ }
+ fmt.Fprintf(out, "==> nginx route changed for %s, reloading\n", app)
+ return Reload(ctx, out)
+}
+
+func Reload(ctx context.Context, out io.Writer) error {
+ return sh.Run(ctx, out, "", "nginx", "-s", "reload")
+}
diff --git a/internal/release/release.go b/internal/release/release.go
new file mode 100644
index 0000000..fe48aa4
--- /dev/null
+++ b/internal/release/release.go
@@ -0,0 +1,52 @@
+// Package release runs an app's own release-phase Makefile target, if it
+// declares one - mirroring Heroku's Procfile `release:` line, but as a
+// `release` target in the same Makefile that builds the app. Skiff never
+// interprets what the target does (run migrations, warm a cache,
+// whatever); it only checks whether one is declared and gates the deploy
+// on its exit code.
+package release
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+
+ "skiff/internal/sh"
+)
+
+// notDeclared is bmake's exact wording (verified against FreeBSD 15's
+// /usr/bin/make) when asked to build a target with no matching rule -
+// explicit, implicit, or file-on-disk. Skiff is FreeBSD-only, so this
+// doesn't need to also cover GNU make's differently-worded message.
+const notDeclared = "don't know how to make release"
+
+// Declared reports whether dir's Makefile defines a `release` target, by
+// asking bmake itself via a dry run rather than parsing Makefile syntax -
+// robust regardless of how the target is actually defined (directly, via
+// an include, a conditional, a pattern rule). dir is the release's
+// host-side checkout; this runs on the host, before the jail is involved.
+func Declared(ctx context.Context, dir string) (bool, error) {
+ out, err := sh.CombinedOutput(ctx, dir, "make", "-n", "release")
+ if err == nil {
+ return true, nil
+ }
+ if strings.Contains(out, notDeclared) {
+ return false, nil
+ }
+ return false, fmt.Errorf("make -n release: %w\n%s", err, out)
+}
+
+// Run executes the `release` Makefile target as a one-off inside app's
+// already-running jail (the jail holds the process's filesystem and any
+// state the target needs to reach - e.g. the app's own data.db).
+// releaseDirInJail must be the new release's own checkout, jail-relative -
+// not `current`, which still points at the previous release until cutover
+// happens after this returns successfully. -B forces the recipe to run
+// even if a stray file/directory named `release` already exists in the
+// checkout. A non-nil error means the deploy must abort before
+// restart/cutover.
+func Run(ctx context.Context, out io.Writer, app, releaseDirInJail string) error {
+ fmt.Fprintf(out, "==> release: make release (in %s)\n", releaseDirInJail)
+ return sh.Run(ctx, out, "", "jexec", app, "make", "-B", "-C", releaseDirInJail, "release")
+}
diff --git a/internal/sh/sh.go b/internal/sh/sh.go
new file mode 100644
index 0000000..b5f6f49
--- /dev/null
+++ b/internal/sh/sh.go
@@ -0,0 +1,65 @@
+// Package sh runs external commands with output streamed live, since a
+// post-receive hook's stdout/stderr goes straight back to the pushing client.
+package sh
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+)
+
+func Run(ctx context.Context, out io.Writer, dir string, name string, args ...string) error {
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Dir = dir
+ cmd.Stdout = out
+ cmd.Stderr = out
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("%s %v: %w", name, args, err)
+ }
+ return nil
+}
+
+// Output runs a command and returns its trimmed stdout, for callers that need
+// a value back (e.g. `zfs list -H -o name`) rather than a stream.
+func Output(ctx context.Context, dir string, name string, args ...string) (string, error) {
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Dir = dir
+ b, err := cmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("%s %v: %w", name, args, err)
+ }
+ return string(b), nil
+}
+
+// CombinedOutput runs a command and returns its combined stdout+stderr,
+// even on failure - for callers that need to inspect the error text itself
+// (e.g. distinguishing bmake's "no such target" from a real failure)
+// rather than just pass/fail.
+func CombinedOutput(ctx context.Context, dir string, name string, args ...string) (string, error) {
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Dir = dir
+ b, err := cmd.CombinedOutput()
+ return string(b), err
+}
+
+// RunWithStdinFile runs a command with stdinPath fed to its stdin, e.g.
+// piping a migration file into `sqlite3 db`.
+func RunWithStdinFile(ctx context.Context, out io.Writer, dir, stdinPath, name string, args ...string) error {
+ f, err := os.Open(stdinPath)
+ if err != nil {
+ return fmt.Errorf("open %s: %w", stdinPath, err)
+ }
+ defer f.Close()
+
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Dir = dir
+ cmd.Stdin = f
+ cmd.Stdout = out
+ cmd.Stderr = out
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("%s %v < %s: %w", name, args, stdinPath, err)
+ }
+ return nil
+}
diff --git a/internal/tmpl/jail.conf.tmpl b/internal/tmpl/jail.conf.tmpl
new file mode 100644
index 0000000..446c8c8
--- /dev/null
+++ b/internal/tmpl/jail.conf.tmpl
@@ -0,0 +1,10 @@
+{{.Name}} {
+ path = "{{.JailRoot}}";
+ host.hostname = "{{.Name}}.skiff.local";
+ ip4.addr = "{{.IP}}";
+ exec.start = "/bin/sh /etc/rc";
+ exec.stop = "/bin/sh /etc/rc.shutdown";
+ exec.clean;
+ mount.devfs;
+ persist;
+}
diff --git a/internal/tmpl/nginx-app.conf.tmpl b/internal/tmpl/nginx-app.conf.tmpl
new file mode 100644
index 0000000..b55a332
--- /dev/null
+++ b/internal/tmpl/nginx-app.conf.tmpl
@@ -0,0 +1,15 @@
+upstream {{.Name}}_upstream {
+ server {{.IP}}:{{.Port}};
+}
+
+server {
+ listen 80;
+ server_name {{.ServerName}};
+
+ location / {
+ proxy_pass http://{{.Name}}_upstream;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/internal/tmpl/post-receive.tmpl b/internal/tmpl/post-receive.tmpl
new file mode 100644
index 0000000..b06d46e
--- /dev/null
+++ b/internal/tmpl/post-receive.tmpl
@@ -0,0 +1,6 @@
+#!/bin/sh
+# Installed by `skiff new-app {{.Name}}`. Do not edit by hand - re-run
+# new-app (or hand-edit the app name below) if this repo is ever renamed.
+while read old new ref; do
+ skiff deploy --app "{{.Name}}" --old "$old" --new "$new" --ref "$ref" || exit 1
+done
diff --git a/internal/tmpl/rc.d.tmpl b/internal/tmpl/rc.d.tmpl
new file mode 100644
index 0000000..db940fd
--- /dev/null
+++ b/internal/tmpl/rc.d.tmpl
@@ -0,0 +1,17 @@
+#!/bin/sh
+#
+# PROVIDE: {{.Name}}
+# REQUIRE: LOGIN
+# KEYWORD: shutdown
+
+. /etc/rc.subr
+
+name="{{.Name}}"
+rcvar="{{.Name}}_enable"
+
+pidfile="/var/run/${name}.pid"
+command="/usr/sbin/daemon"
+command_args="-P ${pidfile} -r -t {{.Name}} /app/current/app"
+
+load_rc_config $name
+run_rc_command "$1"
diff --git a/internal/tmpl/tmpl.go b/internal/tmpl/tmpl.go
new file mode 100644
index 0000000..68e917d
--- /dev/null
+++ b/internal/tmpl/tmpl.go
@@ -0,0 +1,59 @@
+// Package tmpl embeds every file Skiff writes out to the host - jail
+// config, rc.d script, nginx vhost, git hook - so the whole tool ships as
+// one binary with nothing extra to install alongside it.
+package tmpl
+
+import (
+ "bytes"
+ _ "embed"
+ "text/template"
+)
+
+//go:embed jail.conf.tmpl
+var jailConf string
+
+//go:embed rc.d.tmpl
+var rcScript string
+
+//go:embed nginx-app.conf.tmpl
+var nginxApp string
+
+//go:embed post-receive.tmpl
+var postReceive string
+
+func Render(text string, data any) (string, error) {
+ t, err := template.New("t").Parse(text)
+ if err != nil {
+ return "", err
+ }
+ var buf bytes.Buffer
+ if err := t.Execute(&buf, data); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
+
+type JailConfData struct {
+ Name string
+ JailRoot string
+ IP string
+}
+
+func JailConf(d JailConfData) (string, error) { return Render(jailConf, d) }
+
+type RCData struct{ Name string }
+
+func RCScript(d RCData) (string, error) { return Render(rcScript, d) }
+
+type NginxAppData struct {
+ Name string
+ IP string
+ Port int
+ ServerName string
+}
+
+func NginxApp(d NginxAppData) (string, error) { return Render(nginxApp, d) }
+
+type PostReceiveData struct{ Name string }
+
+func PostReceive(d PostReceiveData) (string, error) { return Render(postReceive, d) }
diff --git a/internal/zfsutil/zfsutil.go b/internal/zfsutil/zfsutil.go
new file mode 100644
index 0000000..3d3bd40
--- /dev/null
+++ b/internal/zfsutil/zfsutil.go
@@ -0,0 +1,55 @@
+// Package zfsutil wraps the zfs(8)/jail(8) CLI operations Skiff relies on as
+// its only state store - no separate app-metadata database.
+package zfsutil
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+
+ "skiff/internal/sh"
+)
+
+func Exists(ctx context.Context, dataset string) bool {
+ _, err := sh.Output(ctx, "", "zfs", "list", "-H", "-o", "name", dataset)
+ return err == nil
+}
+
+// Clone creates a new dataset from a (read-only) snapshot of base, mounted
+// at mountpoint. base must already have a snapshot named baseSnapshot.
+func Clone(ctx context.Context, out io.Writer, base, baseSnapshot, dataset, mountpoint string) error {
+ src := fmt.Sprintf("%s@%s", base, baseSnapshot)
+ return sh.Run(ctx, out, "", "zfs", "clone", "-o", "mountpoint="+mountpoint, src, dataset)
+}
+
+func Snapshot(ctx context.Context, out io.Writer, dataset, tag string) error {
+ return sh.Run(ctx, out, "", "zfs", "snapshot", fmt.Sprintf("%s@%s", dataset, tag))
+}
+
+// Rollback discards everything written to dataset since the snapshot named
+// tag, including any newer snapshots. This is Skiff's entire undo mechanism:
+// used both for the automatic post-health-check-failure rollback and for a
+// manual `skiff rollback` days later.
+func Rollback(ctx context.Context, out io.Writer, dataset, tag string) error {
+ return sh.Run(ctx, out, "", "zfs", "rollback", "-r", fmt.Sprintf("%s@%s", dataset, tag))
+}
+
+// Snapshots lists snapshot names (without the dataset@ prefix), newest last.
+func Snapshots(ctx context.Context, dataset string) ([]string, error) {
+ raw, err := sh.Output(ctx, "", "zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-s", "creation", "-r", dataset)
+ if err != nil {
+ return nil, err
+ }
+ var names []string
+ for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, "@", 2)
+ if len(parts) == 2 {
+ names = append(names, parts[1])
+ }
+ }
+ return names, nil
+}