diff options
Diffstat (limited to 'internal')
| -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 |
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 +} |
