summaryrefslogtreecommitdiff
path: root/internal/deploy/deploy.go
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/deploy/deploy.go
Initial commit of skiff scaffolding
Diffstat (limited to 'internal/deploy/deploy.go')
-rw-r--r--internal/deploy/deploy.go135
1 files changed, 135 insertions, 0 deletions
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)
+}