summaryrefslogtreecommitdiff
path: root/internal
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 /internal
Initial commit of skiff scaffolding
Diffstat (limited to 'internal')
-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
13 files changed, 672 insertions, 0 deletions
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
+}