summaryrefslogtreecommitdiff
path: root/cmd/skiff/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/skiff/main.go')
-rw-r--r--cmd/skiff/main.go162
1 files changed, 162 insertions, 0 deletions
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 <deploy|new-app|rollback> [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 <name>)")
+ 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 <this-host>:%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-<sha>")
+ 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)
+}