1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
|
// 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"
"os"
"path/filepath"
"skiff/internal/appmeta"
"skiff/internal/health"
"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 := health.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)
}
|