summaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/skiff/main.go164
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
+}