// 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" "sort" "strings" "time" "skiff/internal/appmeta" "skiff/internal/deploy" "skiff/internal/envfile" "skiff/internal/jailmgr" "skiff/internal/layout" "skiff/internal/lock" "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 "config": err = cmdConfig(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") oldSHA := fs.String("old", "", "old commit sha (unused, kept for hook parity)") newSHA := fs.String("new", "", "new commit sha to deploy") ref := fs.String("ref", "", "ref being pushed, e.g. refs/heads/main") fs.Parse(args) _ = oldSHA 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") serverName := fs.String("server-name", "", "nginx server_name (defaults to )") baseSnapshot := fs.String("base-snapshot", "ready", "snapshot on the base dataset to clone from") fs.Parse(args) if *name == "" || *ip == "" { return fmt.Errorf("--name and --ip are required") } if *serverName == "" { *serverName = *name } 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) } 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); err != nil { return fmt.Errorf("write jail config: %w", err) } if err := appmeta.Write(cfg.MetaPath(*name), appmeta.Meta{ IP: *ip, Port: *port, HealthPath: *healthPath, ServerName: *serverName, }); 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", *name, cfg.RepoPath(*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.AppDataset(*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 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 }