diff options
Diffstat (limited to 'internal/deploy/deploy.go')
| -rw-r--r-- | internal/deploy/deploy.go | 125 |
1 files changed, 101 insertions, 24 deletions
diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index 5430b7f..751c976 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -22,8 +22,6 @@ import ( ) 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) @@ -33,10 +31,37 @@ func Run(ctx context.Context, out io.Writer, cfg layout.Config, app, sha string) 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 := os.Stat(releaseDir); err == nil { + // A retry of this exact sha whose previous attempt got far enough + // to cut over, then failed its health check: the dataset rollback + // reverts `current` but - by design - leaves this release dir (and + // its worktree registration) on disk, since it existed at + // snapshot time. Clear it so `add` below doesn't collide. + fmt.Fprintln(out, "==> removing leftover checkout from a previous attempt") + if err := sh.Run(ctx, out, "", "git", "-C", cfg.RepoPath(app), "worktree", "remove", "--force", releaseDir); err != nil { + fmt.Fprintf(out, "==> warning: worktree remove failed (%v), removing directory directly\n", err) + if err := os.RemoveAll(releaseDir); err != nil { + return fmt.Errorf("remove leftover checkout: %w", err) + } + } + } if err := sh.Run(ctx, out, "", "git", "-C", cfg.RepoPath(app), "worktree", "add", releaseDir, sha); err != nil { return fmt.Errorf("checkout: %w", err) } + cutover := false + defer func() { + if cutover { + return + } + // Nothing ever went live under this checkout - leaving it behind + // blocks a retry of the same sha with "already exists" on the next + // `worktree add`. + if err := sh.Run(ctx, out, "", "git", "-C", cfg.RepoPath(app), "worktree", "remove", "--force", releaseDir); err != nil { + fmt.Fprintf(out, "==> warning: failed to clean up worktree %s: %v\n", releaseDir, err) + } + }() + fmt.Fprintln(out, "==> building") if err := sh.Run(ctx, out, releaseDir, "make", "-B"); err != nil { return fmt.Errorf("build: %w", err) @@ -45,27 +70,85 @@ func Run(ctx context.Context, out io.Writer, cfg layout.Config, app, sha string) 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) + if err := GuardedApply(ctx, out, cfg, app, "pre-"+sha, "deploy", func() error { + 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) + } + cutover = true + return nil + }); err != nil { + return err + } + + fmt.Fprintln(out, "==> healthy, updating routing") + meta, err := appmeta.Read(cfg.MetaPath(app)) + if err != nil { + return fmt.Errorf("read app metadata: %w", err) + } + if err := nginx.EnsureRoute(ctx, out, cfg, app, meta.IP, meta.Port, meta.ServerName); err != nil { + return fmt.Errorf("nginx: %w", err) } - hasRelease, err := release.Declared(ctx, releaseDir) + fmt.Fprintf(out, "==> deployed %s at %s\n", app, sha) + return nil +} + +// GuardedApply snapshots app's dataset under tag, runs apply, restarts the +// jail, and waits for it to report healthy - rolling the dataset back to +// the snapshot (and restarting again) if it doesn't. Shared by code +// deploys and config changes: either one is exactly as much of an outage +// as the other if it goes wrong, so both are guarded the same way. label +// is used only in the final error message (e.g. "deploy", "config change"). +func GuardedApply(ctx context.Context, out io.Writer, cfg layout.Config, app, tag, label string, apply func() error) error { + dataset := cfg.AppDataset(app) + + existing, err := zfsutil.Snapshots(ctx, dataset) if err != nil { - return fmt.Errorf("check for release target: %w", err) + return fmt.Errorf("list snapshots: %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) + for _, s := range existing { + if s != tag { + continue + } + // A previous attempt under this same tag (e.g. a retried deploy of + // the same sha, rolled back to this exact snapshot) left it + // behind - retaking it now just recaptures the state a rollback + // already reverted the dataset to. + fmt.Fprintf(out, "==> replacing stale snapshot %s from a previous attempt\n", tag) + if err := zfsutil.DestroySnapshot(ctx, out, dataset, tag); err != nil { + return fmt.Errorf("clean up stale snapshot: %w", err) } - } else { - fmt.Fprintln(out, "==> no release target declared, skipping") + break } - fmt.Fprintln(out, "==> cutting over") - if err := swapCurrent(cfg.CurrentRelease(app), cfg.ReleaseDirInJail(sha)); err != nil { - return fmt.Errorf("cutover: %w", err) + fmt.Fprintln(out, "==> snapshotting current state") + if err := zfsutil.Snapshot(ctx, out, dataset, tag); err != nil { + return fmt.Errorf("snapshot: %w", err) } + + if err := apply(); err != nil { + // Nothing has been restarted yet, so this snapshot was never needed + // as a rollback target - left behind, it collides with the same + // tag on the next retry. + if destroyErr := zfsutil.DestroySnapshot(ctx, out, dataset, tag); destroyErr != nil { + fmt.Fprintf(out, "==> warning: failed to clean up snapshot %s: %v\n", tag, destroyErr) + } + return err + } + if err := jailmgr.Restart(ctx, out, app); err != nil { return fmt.Errorf("restart: %w", err) } @@ -76,22 +159,16 @@ func Run(ctx context.Context, out io.Writer, cfg layout.Config, app, sha string) } 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 { + fmt.Fprintf(out, "==> health check failed (%v), rolling back to %s\n", err, tag) + if rbErr := zfsutil.Rollback(ctx, out, dataset, tag); 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) + return fmt.Errorf("%s failed health check and was rolled back: %w", label, err) } - fmt.Fprintf(out, "==> deployed %s at %s\n", app, sha) return nil } |
