diff options
| author | Mike Wild <mike@mikeanthonywild.com> | 2026-08-25 23:04:10 -0500 |
|---|---|---|
| committer | Mike Wild <mike@mikeanthonywild.com> | 2026-08-25 23:04:10 -0500 |
| commit | 025a365a23f5ed2b3df4df71cbff9689575968d9 (patch) | |
| tree | 1a1f93de4163a3a452d0dd4ed4499fe2084d2db7 /internal/rebuild | |
| parent | 2a9ca0577ecdbee8b5a4bc8886c112dbc4260eab (diff) | |
Split per-app dataset into OS/data layers, add skiff rebuild
Each app now gets two ZFS datasets - a disposable OS layer
(zroot/skiff/appbase/<app>) cloned from base, and a persistent data
layer (zroot/skiff/appdata/<app>) holding releases/current/env/
meta.json/data.db. `skiff rebuild --app|--all` discards and reclones
the OS layer to pick up an OS/toolchain/config-template upgrade,
guarded by the same snapshot-and-rollback safety net as deploy and
config changes; the data layer is never touched.
Also fixes stale docs (builds already run via jexec inside the jail,
not on the host; --iface already auto-aliases the jail's IP, so the
manual ifconfig step and its troubleshooting entry are gone) found
while updating the same sections for this feature.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'internal/rebuild')
| -rw-r--r-- | internal/rebuild/rebuild.go | 126 |
1 files changed, 126 insertions, 0 deletions
diff --git a/internal/rebuild/rebuild.go b/internal/rebuild/rebuild.go new file mode 100644 index 0000000..f58c67f --- /dev/null +++ b/internal/rebuild/rebuild.go @@ -0,0 +1,126 @@ +// Package rebuild implements `skiff rebuild`: discard an app's OS layer +// (everything cloned from base) and reclone it fresh, leaving the data layer +// (a separate dataset) untouched. Guarded the same way as +// deploy.GuardedApply: the old OS dataset is kept until the new one is +// confirmed healthy, and swapped back automatically on failure. +package rebuild + +import ( + "context" + "fmt" + "io" + "os" + + "skiff/internal/appmeta" + "skiff/internal/health" + "skiff/internal/jailmgr" + "skiff/internal/layout" + "skiff/internal/zfsutil" +) + +func Run(ctx context.Context, out io.Writer, cfg layout.Config, app, baseSnapshot, iface string) error { + osDataset := cfg.AppDataset(app) + osDatasetPrev := osDataset + "-prev" + jailRoot := cfg.JailRoot(app) + jailRootPrev := jailRoot + "-prev" + dataDataset := cfg.AppDataDataset(app) + + // jail.conf.d/<app>.conf lives on the host, outside both per-app + // datasets, so swapping OS-layer datasets back on a failed rebuild + // doesn't revert it on its own - back it up so rollback can. + oldConf, err := os.ReadFile(cfg.JailConfPath(app)) + if err != nil { + return fmt.Errorf("read current jail.conf: %w", err) + } + + fmt.Fprintln(out, "==> stopping jail") + if err := jailmgr.Stop(ctx, out, app); err != nil { + return fmt.Errorf("stop jail: %w", err) + } + if err := zfsutil.Unmount(ctx, out, dataDataset); err != nil { + return fmt.Errorf("unmount data dataset: %w", err) + } + + fmt.Fprintf(out, "==> setting aside current OS layer as %s\n", osDatasetPrev) + if err := zfsutil.Rename(ctx, out, osDataset, osDatasetPrev); err != nil { + return fmt.Errorf("rename OS dataset: %w", err) + } + // Rename doesn't move the mountpoint - relocate it explicitly, or the + // clone below silently mount-stacks on top of it instead of at jailRoot. + if err := zfsutil.SetMountpoint(ctx, out, osDatasetPrev, jailRootPrev); err != nil { + return fmt.Errorf("relocate old OS dataset mountpoint: %w", err) + } + + fmt.Fprintf(out, "==> cloning fresh OS layer from %s@%s\n", cfg.BaseDataset, baseSnapshot) + if err := zfsutil.Clone(ctx, out, cfg.BaseDataset, baseSnapshot, osDataset, jailRoot); err != nil { + return fmt.Errorf("clone fresh OS dataset: %w", err) + } + if err := zfsutil.Mount(ctx, out, dataDataset); err != nil { + return fmt.Errorf("remount data dataset: %w", err) + } + + if err := provisionAndStart(ctx, out, cfg, app, iface); err != nil { + fmt.Fprintf(out, "==> %v, rolling back to previous OS layer\n", err) + return rollback(ctx, out, cfg, app, osDataset, osDatasetPrev, jailRoot, dataDataset, oldConf, err) + } + + fmt.Fprintf(out, "==> healthy, discarding previous OS layer %s\n", osDatasetPrev) + if err := zfsutil.Destroy(ctx, out, osDatasetPrev); err != nil { + return fmt.Errorf("rebuild succeeded but cleanup failed, destroy %s manually: %w", osDatasetPrev, err) + } + return nil +} + +func provisionAndStart(ctx context.Context, out io.Writer, cfg layout.Config, app, iface string) error { + meta, err := appmeta.Read(cfg.MetaPath(app)) + if err != nil { + return fmt.Errorf("read app metadata: %w", err) + } + if err := jailmgr.WriteConf(cfg, app, meta.IP, iface); err != nil { + return fmt.Errorf("write jail config: %w", err) + } + if err := jailmgr.EnableMail(cfg, app); err != nil { + return fmt.Errorf("configure mail: %w", err) + } + if err := jailmgr.Start(ctx, out, app); err != nil { + return fmt.Errorf("start jail: %w", err) + } + if err := health.WaitHealthy(ctx, meta); err != nil { + return fmt.Errorf("rebuilt jail failed health check: %w", err) + } + return nil +} + +// rollback restores the previous OS layer, mirroring GuardedApply's failure +// shape: the app ends up running on the dataset it was healthy on before +// the attempt, and cause is always reported even if a rollback step fails. +func rollback(ctx context.Context, out io.Writer, cfg layout.Config, app, osDataset, osDatasetPrev, jailRoot, dataDataset string, oldConf []byte, cause error) error { + fail := func(step string, err error) error { + return fmt.Errorf("rebuild failed AND rollback failed: %v / %s: %w", cause, step, err) + } + if err := jailmgr.Stop(ctx, out, app); err != nil { + return fail("stop jail", err) + } + if err := zfsutil.Unmount(ctx, out, dataDataset); err != nil { + return fail("unmount data dataset", err) + } + if err := zfsutil.Destroy(ctx, out, osDataset); err != nil { + return fail("destroy failed OS dataset", err) + } + if err := zfsutil.Rename(ctx, out, osDatasetPrev, osDataset); err != nil { + return fail("restore previous OS dataset", err) + } + if err := zfsutil.SetMountpoint(ctx, out, osDataset, jailRoot); err != nil { + return fail("restore OS dataset mountpoint", err) + } + if err := os.WriteFile(cfg.JailConfPath(app), oldConf, 0644); err != nil { + return fail("restore jail.conf", err) + } + if err := zfsutil.Mount(ctx, out, dataDataset); err != nil { + return fail("remount data dataset", err) + } + if err := jailmgr.Start(ctx, out, app); err != nil { + return fail("restart on previous OS layer", err) + } + return fmt.Errorf("rebuild failed and was rolled back to the previous OS layer: %w", cause) +} |
