// 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" "strings" "skiff/internal/appmeta" "skiff/internal/deploy" "skiff/internal/jailmgr" "skiff/internal/layout" "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:]) 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 } return deploy.Run(context.Background(), os.Stdout, layout.Default(), *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() 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(filepath.Join(cfg.AppDir(*name), "releases"), 0755); err != nil { return fmt.Errorf("create app dir: %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 } hookPath := filepath.Join(cfg.RepoPath(*name), "hooks", "post-receive") if err := os.WriteFile(hookPath, []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)") } if err := zfsutil.Rollback(ctx, os.Stdout, dataset, *to); err != nil { return fmt.Errorf("rollback: %w", err) } return jailmgr.Restart(ctx, os.Stdout, *app) }