From 9f7c9f2f31acd7bc544dd988af1c8c2fe63ec504 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:14:29 -0500 Subject: Initial commit of skiff scaffolding --- cmd/skiff/main.go | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 cmd/skiff/main.go (limited to 'cmd/skiff') diff --git a/cmd/skiff/main.go b/cmd/skiff/main.go new file mode 100644 index 0000000..e709227 --- /dev/null +++ b/cmd/skiff/main.go @@ -0,0 +1,162 @@ +// 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) +} -- cgit v1.3