// Command skiff is the entire control plane: one binary, invoked either by // a repo's post-receive hook (deploy) or by hand (new-app, rollback). package main import ( "context" "flag" "fmt" "os" "path/filepath" "sort" "strings" "time" "skiff/internal/appmeta" "skiff/internal/deploy" "skiff/internal/domains" "skiff/internal/envfile" "skiff/internal/jailmgr" "skiff/internal/layout" "skiff/internal/lock" "skiff/internal/rebuild" "skiff/internal/sh" "skiff/internal/tmpl" "skiff/internal/zfsutil" ) func main() { if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "usage: skiff [flags]") os.Exit(1) } var err error switch os.Args[1] { case "deploy": err = cmdDeploy(os.Args[2:]) case "new-app": err = cmdNewApp(os.Args[2:]) case "rollback": err = cmdRollback(os.Args[2:]) case "rebuild": err = cmdRebuild(os.Args[2:]) case "config": err = cmdConfig(os.Args[2:]) case "domains": err = cmdDomains(os.Args[2:]) default: fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1]) os.Exit(1) } if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } func cmdDeploy(args []string) error { fs := flag.NewFlagSet("deploy", flag.ExitOnError) app := fs.String("app", "", "app name") newSHA := fs.String("new", "", "new commit sha to deploy") ref := fs.String("ref", "", "ref being pushed, e.g. refs/heads/main") fs.Parse(args) if *app == "" || *newSHA == "" { return fmt.Errorf("--app and --new are required") } if *ref != "" && *ref != "refs/heads/main" { fmt.Printf("==> %s is not refs/heads/main, skipping deploy\n", *ref) return nil } cfg := layout.Default() unlock, err := lock.Acquire(cfg.LockPath(*app)) if err != nil { return fmt.Errorf("acquire app lock: %w", err) } defer unlock.Release() return deploy.Run(context.Background(), os.Stdout, cfg, *app, *newSHA) } func cmdNewApp(args []string) error { fs := flag.NewFlagSet("new-app", flag.ExitOnError) name := fs.String("name", "", "app name") ip := fs.String("ip", "", "IPv4 address to assign the jail (operator-chosen)") port := fs.Int("port", layout.DefaultHealthPort, "port the app listens on inside the jail") healthPath := fs.String("health-path", layout.DefaultHealthPath, "health check path") baseSnapshot := fs.String("base-snapshot", "default", "snapshot on the base dataset to clone from") iface := fs.String("iface", layout.DefaultJailIface, "host interface the jail's IP is aliased onto") fs.Parse(args) if *name == "" || *ip == "" { return fmt.Errorf("--name and --ip are required") } ctx := context.Background() out := os.Stdout cfg := layout.Default() unlock, err := lock.Acquire(cfg.LockPath(*name)) if err != nil { return fmt.Errorf("acquire app lock: %w", err) } defer unlock.Release() fmt.Fprintf(out, "==> cloning %s@%s -> %s\n", cfg.BaseDataset, *baseSnapshot, cfg.AppDataset(*name)) if err := zfsutil.Clone(ctx, out, cfg.BaseDataset, *baseSnapshot, cfg.AppDataset(*name), cfg.JailRoot(*name)); err != nil { return fmt.Errorf("clone base dataset: %w", err) } fmt.Fprintf(out, "==> creating data dataset %s\n", cfg.AppDataDataset(*name)) if err := zfsutil.Create(ctx, out, cfg.AppDataDataset(*name), cfg.AppDir(*name)); err != nil { return fmt.Errorf("create data dataset: %w", err) } if err := os.MkdirAll(cfg.ReleasesDir(*name), 0755); err != nil { return fmt.Errorf("create app dir: %w", err) } if err := envfile.Save(cfg.EnvPath(*name), map[string]string{}); err != nil { return fmt.Errorf("initialize env file: %w", err) } if err := jailmgr.WriteConf(cfg, *name, *ip, *iface); err != nil { return fmt.Errorf("write jail config: %w", err) } if err := jailmgr.EnableMail(cfg, *name); err != nil { return fmt.Errorf("configure mail: %w", err) } if err := appmeta.Write(cfg.MetaPath(*name), appmeta.Meta{ IP: *ip, Port: *port, HealthPath: *healthPath, }); err != nil { return fmt.Errorf("write app metadata: %w", err) } fmt.Fprintf(out, "==> initializing bare repo %s\n", cfg.RepoPath(*name)) if err := sh.Run(ctx, out, "", "git", "init", "--bare", cfg.RepoPath(*name)); err != nil { return fmt.Errorf("git init: %w", err) } hook, err := tmpl.PostReceive(tmpl.PostReceiveData{Name: *name}) if err != nil { return err } if err := os.WriteFile(cfg.PostReceiveHookPath(*name), []byte(hook), 0755); err != nil { return fmt.Errorf("install post-receive hook: %w", err) } fmt.Fprintln(out, "==> starting jail") if err := jailmgr.Start(ctx, out, *name); err != nil { return fmt.Errorf("start jail: %w", err) } fmt.Fprintf(out, "\n%s is ready. From the app's repo:\n git remote add skiff :%s\n git push skiff main\n\nNo domains are routed yet - once DNS points at %s:\n skiff domains add --app %s \n", *name, cfg.RepoPath(*name), *ip, *name) return nil } func cmdRollback(args []string) error { fs := flag.NewFlagSet("rollback", flag.ExitOnError) app := fs.String("app", "", "app name") to := fs.String("to", "", "snapshot tag to roll back to, e.g. pre-") list := fs.Bool("list", false, "list available snapshots and exit") fs.Parse(args) if *app == "" { return fmt.Errorf("--app is required") } ctx := context.Background() cfg := layout.Default() dataset := cfg.AppDataDataset(*app) if *list { snaps, err := zfsutil.Snapshots(ctx, dataset) if err != nil { return err } fmt.Println(strings.Join(snaps, "\n")) return nil } if *to == "" { return fmt.Errorf("--to is required (use --list to see snapshots)") } unlock, err := lock.Acquire(cfg.LockPath(*app)) if err != nil { return fmt.Errorf("acquire app lock: %w", err) } defer unlock.Release() if err := zfsutil.Rollback(ctx, os.Stdout, dataset, *to); err != nil { return fmt.Errorf("rollback: %w", err) } return jailmgr.Restart(ctx, os.Stdout, *app) } func cmdRebuild(args []string) error { fs := flag.NewFlagSet("rebuild", flag.ExitOnError) app := fs.String("app", "", "app name") all := fs.Bool("all", false, "rebuild every app") baseSnapshot := fs.String("base-snapshot", "default", "snapshot on the base dataset to clone from") iface := fs.String("iface", layout.DefaultJailIface, "host interface the jail's IP is aliased onto") fs.Parse(args) if (*app == "") != *all { return fmt.Errorf("exactly one of --app or --all is required") } cfg := layout.Default() if *app != "" { return rebuildOne(cfg, *app, *baseSnapshot, *iface) } apps, err := listApps(cfg) if err != nil { return fmt.Errorf("list apps: %w", err) } var done []string for _, a := range apps { fmt.Printf("==> rebuilding %s\n", a) if err := rebuildOne(cfg, a, *baseSnapshot, *iface); err != nil { fmt.Printf("==> rebuilt %d/%d apps before %s failed\n", len(done), len(apps), a) return fmt.Errorf("rebuild %s: %w", a, err) } done = append(done, a) } fmt.Printf("==> rebuilt %d/%d apps\n", len(done), len(apps)) return nil } func rebuildOne(cfg layout.Config, app, baseSnapshot, iface string) error { unlock, err := lock.Acquire(cfg.LockPath(app)) if err != nil { return fmt.Errorf("acquire app lock: %w", err) } defer unlock.Release() return rebuild.Run(context.Background(), os.Stdout, cfg, app, baseSnapshot, iface) } // listApps has no other caller today - every other command already knows // its app name via --app. func listApps(cfg layout.Config) ([]string, error) { matches, err := filepath.Glob(filepath.Join(cfg.JailConfDir, "*.conf")) if err != nil { return nil, err } apps := make([]string, len(matches)) for i, m := range matches { apps[i] = strings.TrimSuffix(filepath.Base(m), ".conf") } sort.Strings(apps) return apps, nil } func cmdConfig(args []string) error { if len(args) < 1 { return fmt.Errorf("usage: skiff config --app [args...]") } switch args[0] { case "set": return cmdConfigSet(args[1:]) case "unset": return cmdConfigUnset(args[1:]) case "list": return cmdConfigList(args[1:]) default: return fmt.Errorf("unknown config subcommand %q", args[0]) } } func cmdConfigSet(args []string) error { fs := flag.NewFlagSet("config set", flag.ExitOnError) app := fs.String("app", "", "app name") // --app must precede the positional KEY=VALUE args: the flag package // stops parsing flags at the first non-flag argument. fs.Parse(args) pairs := fs.Args() if *app == "" { return fmt.Errorf("--app is required") } if len(pairs) == 0 { return fmt.Errorf("usage: skiff config set --app KEY=VALUE [KEY=VALUE...]") } return applyConfigChange(*app, func(kv map[string]string) error { for _, pair := range pairs { parts := strings.SplitN(pair, "=", 2) if len(parts) != 2 { return fmt.Errorf("invalid KEY=VALUE pair %q", pair) } key := parts[0] if !envfile.ValidKey(key) { return fmt.Errorf("invalid env var key %q", key) } kv[key] = parts[1] } return nil }) } func cmdConfigUnset(args []string) error { fs := flag.NewFlagSet("config unset", flag.ExitOnError) app := fs.String("app", "", "app name") // --app must precede the positional KEY args, as above. fs.Parse(args) keys := fs.Args() if *app == "" { return fmt.Errorf("--app is required") } if len(keys) == 0 { return fmt.Errorf("usage: skiff config unset --app KEY [KEY...]") } return applyConfigChange(*app, func(kv map[string]string) error { for _, key := range keys { delete(kv, key) } return nil }) } func cmdConfigList(args []string) error { fs := flag.NewFlagSet("config list", flag.ExitOnError) app := fs.String("app", "", "app name") fs.Parse(args) if *app == "" { return fmt.Errorf("--app is required") } cfg := layout.Default() if _, err := os.Stat(cfg.MetaPath(*app)); err != nil { return fmt.Errorf("app %q not found: %w", *app, err) } kv, err := envfile.Load(cfg.EnvPath(*app)) if err != nil { return fmt.Errorf("load config: %w", err) } keys := make([]string, 0, len(kv)) for key := range kv { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { fmt.Printf("%s=%s\n", key, kv[key]) } return nil } // applyConfigChange loads app's current env file, applies mutate to it, and // saves it under deploy.GuardedApply - the same snapshot -> restart -> // health-check -> rollback-on-failure sequence deploy.Run uses for code // deploys, since a bad config change is exactly as much of an outage as a // bad deploy. func applyConfigChange(app string, mutate func(map[string]string) error) error { cfg := layout.Default() if _, err := os.Stat(cfg.MetaPath(app)); err != nil { return fmt.Errorf("app %q not found: %w", app, err) } unlock, err := lock.Acquire(cfg.LockPath(app)) if err != nil { return fmt.Errorf("acquire app lock: %w", err) } defer unlock.Release() kv, err := envfile.Load(cfg.EnvPath(app)) if err != nil { return fmt.Errorf("load config: %w", err) } if err := mutate(kv); err != nil { return err } ctx := context.Background() out := os.Stdout tag := fmt.Sprintf("precfg-%d", time.Now().Unix()) if err := deploy.GuardedApply(ctx, out, cfg, app, tag, "config change", func() error { if err := envfile.Save(cfg.EnvPath(app), kv); err != nil { return fmt.Errorf("save config: %w", err) } return nil }); err != nil { return err } fmt.Fprintln(out, "==> config updated") return nil } func cmdDomains(args []string) error { if len(args) < 1 { return fmt.Errorf("usage: skiff domains --app [args...]") } switch args[0] { case "add": return cmdDomainsAdd(args[1:]) case "remove": return cmdDomainsRemove(args[1:]) case "list": return cmdDomainsList(args[1:]) default: return fmt.Errorf("unknown domains subcommand %q", args[0]) } } // Unlike cmdConfigSet, these skip deploy.GuardedApply - see internal/domains. func cmdDomainsAdd(args []string) error { fs := flag.NewFlagSet("domains add", flag.ExitOnError) app := fs.String("app", "", "app name") primary := fs.Bool("primary", false, "make this the app's primary domain - every other domain redirects to it") fs.Parse(args) rest := fs.Args() if *app == "" || len(rest) != 1 { return fmt.Errorf("usage: skiff domains add --app [--primary] ") } cfg := layout.Default() if _, err := os.Stat(cfg.MetaPath(*app)); err != nil { return fmt.Errorf("app %q not found: %w", *app, err) } unlock, err := lock.Acquire(cfg.LockPath(*app)) if err != nil { return fmt.Errorf("acquire app lock: %w", err) } defer unlock.Release() return domains.Add(context.Background(), os.Stdout, cfg, *app, rest[0], *primary) } func cmdDomainsRemove(args []string) error { fs := flag.NewFlagSet("domains remove", flag.ExitOnError) app := fs.String("app", "", "app name") fs.Parse(args) rest := fs.Args() if *app == "" || len(rest) != 1 { return fmt.Errorf("usage: skiff domains remove --app ") } cfg := layout.Default() if _, err := os.Stat(cfg.MetaPath(*app)); err != nil { return fmt.Errorf("app %q not found: %w", *app, err) } unlock, err := lock.Acquire(cfg.LockPath(*app)) if err != nil { return fmt.Errorf("acquire app lock: %w", err) } defer unlock.Release() return domains.Remove(context.Background(), os.Stdout, cfg, *app, rest[0]) } func cmdDomainsList(args []string) error { fs := flag.NewFlagSet("domains list", flag.ExitOnError) app := fs.String("app", "", "app name") fs.Parse(args) if *app == "" { return fmt.Errorf("--app is required") } cfg := layout.Default() meta, err := appmeta.Read(cfg.MetaPath(*app)) if err != nil { return fmt.Errorf("app %q not found: %w", *app, err) } if len(meta.Domains) == 0 { fmt.Println("no domains configured") return nil } tlsLive := make(map[string]bool, len(meta.TLSDomains)) for _, h := range meta.TLSDomains { tlsLive[h] = true } fmt.Printf("%-30s %-8s %s\n", "HOSTNAME", "PRIMARY", "TLS") for _, d := range meta.Domains { primaryCol, tlsCol := "no", "pending" if d.Primary { primaryCol = "yes" } if tlsLive[d.Hostname] { tlsCol = "issued" } fmt.Printf("%-30s %-8s %s\n", d.Hostname, primaryCol, tlsCol) } return nil }