diff options
| author | Claude <noreply@anthropic.com> | 2026-08-06 23:22:03 -0500 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-08-06 23:22:03 -0500 |
| commit | f34ba20fc566ab861179a8ac2fd55eaabbc6a327 (patch) | |
| tree | f3b421a59cff06d1fa83ae3f75065971dcc750df /cmd/skiff | |
| parent | fc0c6951d7ac36dd8f30ace929991197dc99ae54 (diff) | |
Add skiff config set/unset/list, backed by /app/env in the app's dataset
Config vars are plain env vars, injected into both the running app (via
rc.d) and the release phase, and versioned on the same ZFS snapshot
timeline as deploys - a bad config change gets the same automatic
snapshot/restart/health-check/rollback guarantee a bad deploy already
gets. Extracts deploy.go's health check into internal/health so both
paths share it.
Verified end-to-end against a FreeBSD VM: config set/unset, automatic
rollback on a health-check failure, and explicit `skiff rollback` all
correctly carry the env file along with the rest of the dataset.
Diffstat (limited to 'cmd/skiff')
| -rw-r--r-- | cmd/skiff/main.go | 164 |
1 files changed, 163 insertions, 1 deletions
diff --git a/cmd/skiff/main.go b/cmd/skiff/main.go index 33aaf5e..2fe5907 100644 --- a/cmd/skiff/main.go +++ b/cmd/skiff/main.go @@ -7,10 +7,14 @@ import ( "flag" "fmt" "os" + "sort" "strings" + "time" "skiff/internal/appmeta" "skiff/internal/deploy" + "skiff/internal/envfile" + "skiff/internal/health" "skiff/internal/jailmgr" "skiff/internal/layout" "skiff/internal/sh" @@ -20,7 +24,7 @@ import ( func main() { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: skiff <deploy|new-app|rollback> [flags]") + fmt.Fprintln(os.Stderr, "usage: skiff <deploy|new-app|rollback|config> [flags]") os.Exit(1) } @@ -32,6 +36,8 @@ func main() { 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) @@ -92,6 +98,9 @@ func cmdNewApp(args []string) error { 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) @@ -158,3 +167,156 @@ func cmdRollback(args []string) error { } return jailmgr.Restart(ctx, os.Stdout, *app) } + +func cmdConfig(args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: skiff config <set|unset|list> --app <name> [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 <name> 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 <name> 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 +// carries the same snapshot -> restart -> health-check -> rollback-on- +// failure sequence deploy.Run uses for code deploys - a bad config change +// is exactly as much of an outage as a bad deploy, guarded the same way. +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) + } + + 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 + dataset := cfg.AppDataset(app) + tag := fmt.Sprintf("precfg-%d", time.Now().Unix()) + + fmt.Fprintln(out, "==> snapshotting current state") + if err := zfsutil.Snapshot(ctx, out, dataset, tag); err != nil { + return fmt.Errorf("snapshot: %w", err) + } + + if err := envfile.Save(cfg.EnvPath(app), kv); err != nil { + return fmt.Errorf("save config: %w", err) + } + + if err := jailmgr.Restart(ctx, out, app); err != nil { + return fmt.Errorf("restart: %w", err) + } + + meta, err := appmeta.Read(cfg.MetaPath(app)) + if err != nil { + return fmt.Errorf("read app metadata: %w", err) + } + + if err := health.WaitHealthy(ctx, meta); err != nil { + fmt.Fprintf(out, "==> health check failed (%v), rolling back to %s\n", err, tag) + if rbErr := zfsutil.Rollback(ctx, out, dataset, tag); rbErr != nil { + return fmt.Errorf("health check failed AND rollback failed: %v / %w", err, rbErr) + } + if rbErr := jailmgr.Restart(ctx, out, app); rbErr != nil { + return fmt.Errorf("health check failed, rolled back, but restart failed: %v / %w", err, rbErr) + } + return fmt.Errorf("config change failed health check and was rolled back: %w", err) + } + + fmt.Fprintln(out, "==> config updated") + return nil +} |
