diff options
Diffstat (limited to 'cmd/skiff/main.go')
| -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 +} |
