diff options
| -rw-r--r-- | README.md | 168 | ||||
| -rw-r--r-- | USER_GUIDE.md | 406 | ||||
| -rw-r--r-- | cmd/skiff/main.go | 162 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | internal/appmeta/appmeta.go | 35 | ||||
| -rw-r--r-- | internal/deploy/deploy.go | 135 | ||||
| -rw-r--r-- | internal/jailmgr/jailmgr.go | 71 | ||||
| -rw-r--r-- | internal/layout/layout.go | 107 | ||||
| -rw-r--r-- | internal/nginx/nginx.go | 45 | ||||
| -rw-r--r-- | internal/release/release.go | 52 | ||||
| -rw-r--r-- | internal/sh/sh.go | 65 | ||||
| -rw-r--r-- | internal/tmpl/jail.conf.tmpl | 10 | ||||
| -rw-r--r-- | internal/tmpl/nginx-app.conf.tmpl | 15 | ||||
| -rw-r--r-- | internal/tmpl/post-receive.tmpl | 6 | ||||
| -rw-r--r-- | internal/tmpl/rc.d.tmpl | 17 | ||||
| -rw-r--r-- | internal/tmpl/tmpl.go | 59 | ||||
| -rw-r--r-- | internal/zfsutil/zfsutil.go | 55 |
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) +} @@ -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 +} |
