summaryrefslogtreecommitdiff
path: root/internal/deploy/deploy.go
blob: 5430b7f2dc2851348dcff1b38e48be3248f78a2d (plain)
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)
}