| index a971d9baf..35cdf81e8 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -15,14 +15,21 @@ import ( |
| "text/tabwriter" |
| |
| "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| const envUsage = `usage: carlos env <command> |
| |
| commands: |
| - set -app <a> KEY=value [KEY2=value2 ...] set one or more plain config vars |
| - unset -app <a> KEY [KEY2 ...] remove config vars |
| - list -app <a> list an app's config vars |
| + set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...] set one or more plain config vars |
| + unset -app <a> [-environment <name>] KEY [KEY2 ...] remove config vars |
| + list -app <a> [-environment <name>] list an app's config vars |
| + environments -app <a> list an app's named environment bundles |
| + |
| +-environment names a bundle at apps/<acct>/<app>/environments/<name>.json, |
| +layered on top of the default bundle (config.json) at materialization time — |
| +see LoadMerged. Omit -environment for the default bundle itself. The bundle |
| +is created implicitly on its first "set", same as config.json today. |
| ` |
| |
| // RunEnv is the `carlos env` verb. |
| @@ -38,12 +45,98 @@ func RunEnv(args []string) { |
| runEnvUnset(args[1:]) |
| case "list": |
| runEnvList(args[1:]) |
| + case "environments": |
| + runEnvEnvironments(args[1:]) |
| default: |
| fmt.Fprint(os.Stderr, envUsage) |
| os.Exit(1) |
| } |
| } |
| |
| +// bundleLabel names the bundle a command is reading or writing, for a |
| +// `list` header or a confirmation line: "default" reads better to a human |
| +// than "", and matches ValidEnvironmentName's own vocabulary for the base |
| +// bundle every route already gets when no -environment is bound. |
| +func bundleLabel(environment string) string { |
| + if environment == "" { |
| + return "default" |
| + } |
| + return environment |
| +} |
| + |
| +// checkEnvironmentFlag refuses a bad -environment value before it reaches |
| +// appconfig, with a reason a bare "invalid" wouldn't give. "" is not |
| +// reserved — it is the flag's own way of saying "no -environment", i.e. the |
| +// default bundle — but "default" IS reserved: it already names that same |
| +// bundle (config.json), so naming it as an -environment value would either |
| +// error confusingly deep in appconfig or, worse, look like a second bundle |
| +// that happens to share a name. Every env/secrets subcommand that accepts |
| +// -environment calls this right after fs.Parse. |
| +func checkEnvironmentFlag(environment string) { |
| + if environment == "" { |
| + return |
| + } |
| + if environment == "default" { |
| + log.Fatal(`-environment default is refused: "default" already names the base bundle ` + |
| + "(config.json) that every route gets when no -environment is bound — omit -environment " + |
| + "to set/unset/list it directly, don't name it") |
| + } |
| + if !appconfig.ValidEnvironmentName(environment) { |
| + log.Fatalf("-environment %q is not a valid environment name — want [a-z0-9-]{1,32}", environment) |
| + } |
| +} |
| + |
| +// loadBundle fetches the manifest a `list` command shows: the default |
| +// bundle when environment=="", or the named environment's own bundle |
| +// otherwise — never merged. LoadMerged is what a bound route actually gets |
| +// at materialization time; `list` shows exactly one bundle, matching what |
| +// `set`/`unset` write to for the same -environment value. |
| +func loadBundle(ctx context.Context, s release.Store, acct, app, environment string) (*appconfig.Manifest, error) { |
| + if environment == "" { |
| + return appconfig.Get(ctx, s, acct, app) |
| + } |
| + return appconfig.LoadEnvironment(ctx, s, acct, app, environment) |
| +} |
| + |
| +// saveEnvVars writes plain config vars to the default bundle when |
| +// environment=="", or to the named environment's own bundle otherwise — |
| +// the same CAS discipline either way (appconfig.SetEnv / SaveEnvironment). |
| +func saveEnvVars(ctx context.Context, s release.Store, acct, app, environment string, kv map[string]string) error { |
| + if environment == "" { |
| + return appconfig.SetEnv(ctx, s, acct, app, kv) |
| + } |
| + sets := make(map[string]appconfig.Var, len(kv)) |
| + for k, v := range kv { |
| + sets[k] = appconfig.Var{Kind: "env", Value: v} |
| + } |
| + return appconfig.SaveEnvironment(ctx, s, acct, app, environment, sets, nil) |
| +} |
| + |
| +// saveSecrets writes already-sealed secrets to the default bundle when |
| +// environment=="", or to the named environment's own bundle otherwise — |
| +// mirrors saveEnvVars; see its comment. |
| +func saveSecrets(ctx context.Context, s release.Store, acct, app, environment string, sealed map[string]string) error { |
| + if environment == "" { |
| + return appconfig.SetSecret(ctx, s, acct, app, sealed) |
| + } |
| + sets := make(map[string]appconfig.Var, len(sealed)) |
| + for k, v := range sealed { |
| + sets[k] = appconfig.Var{Kind: "secret", Envelope: v} |
| + } |
| + return appconfig.SaveEnvironment(ctx, s, acct, app, environment, sets, nil) |
| +} |
| + |
| +// unsetVars removes vars (env or secret) by key from the default bundle |
| +// when environment=="", or from the named environment's own bundle |
| +// otherwise — shared by `env unset` and `secrets unset`, same as |
| +// appconfig.Unset is shared by their non-environment paths today. |
| +func unsetVars(ctx context.Context, s release.Store, acct, app, environment string, keys []string) error { |
| + if environment == "" { |
| + return appconfig.Unset(ctx, s, acct, app, keys) |
| + } |
| + return appconfig.SaveEnvironment(ctx, s, acct, app, environment, nil, keys) |
| +} |
| + |
| // parseKV turns ["KEY=value", "KEY2=value2"] into a map, refusing anything |
| // without an "=" or with an empty key. Shared by `env set` and `secrets set`. |
| func parseKV(args []string) (map[string]string, error) { |
| @@ -67,47 +160,54 @@ func parseKV(args []string) (map[string]string, error) { |
| func runEnvSet(args []string) { |
| fs := flag.NewFlagSet("env set", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)") |
| acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| - log.Fatal("usage: carlos env set -app <a> KEY=value [KEY2=value2 ...]") |
| + log.Fatal("usage: carlos env set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]") |
| } |
| + checkEnvironmentFlag(*environment) |
| kv, err := parseKV(fs.Args()) |
| if err != nil { |
| log.Fatalf("env set: %v", err) |
| } |
| - if err := appconfig.SetEnv(context.Background(), openStore(), acctOf(), *app, kv); err != nil { |
| + if err := saveEnvVars(context.Background(), openStore(), acctOf(), *app, *environment, kv); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("set %d env var(s) for %s\n", len(kv), *app) |
| + fmt.Printf("set %d env var(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment)) |
| } |
| |
| func runEnvUnset(args []string) { |
| fs := flag.NewFlagSet("env unset", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)") |
| acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() == 0 { |
| - log.Fatal("usage: carlos env unset -app <a> KEY [KEY2 ...]") |
| + log.Fatal("usage: carlos env unset -app <a> [-environment <name>] KEY [KEY2 ...]") |
| } |
| - if err := appconfig.Unset(context.Background(), openStore(), acctOf(), *app, fs.Args()); err != nil { |
| + checkEnvironmentFlag(*environment) |
| + if err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args()); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("unset %d var(s) for %s\n", fs.NArg(), *app) |
| + fmt.Printf("unset %d var(s) for %s (%s)\n", fs.NArg(), *app, bundleLabel(*environment)) |
| } |
| |
| func runEnvList(args []string) { |
| fs := flag.NewFlagSet("env list", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)") |
| acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| - log.Fatal("usage: carlos env list -app <a>") |
| + log.Fatal("usage: carlos env list -app <a> [-environment <name>]") |
| } |
| - m, err := appconfig.Get(context.Background(), openStore(), acctOf(), *app) |
| + checkEnvironmentFlag(*environment) |
| + m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment) |
| if err != nil { |
| log.Fatal(err) |
| } |
| + fmt.Printf("%s (%s):\n", *app, bundleLabel(*environment)) |
| var keys []string |
| for k, v := range m.Vars { |
| if v.Kind == "env" { |
| @@ -123,13 +223,35 @@ func runEnvList(args []string) { |
| w.Flush() |
| } |
| |
| +func runEnvEnvironments(args []string) { |
| + fs := flag.NewFlagSet("env environments", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos env environments -app <a>") |
| + } |
| + names, err := appconfig.ListEnvironments(context.Background(), openStore(), acctOf(), *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + for _, n := range names { |
| + fmt.Println(n) |
| + } |
| +} |
| + |
| const secretsUsage = `usage: carlos secrets <command> |
| |
| commands: |
| - set -app <a> KEY=value [KEY2=value2 ...] seal and set one or more secrets |
| - unset -app <a> KEY [KEY2 ...] remove secrets |
| - list -app <a> list a secret's key names only — never values |
| - genkey mint a CARLOS_SECRETS_KEY/PUBKEY pair |
| + set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...] seal and set one or more secrets |
| + unset -app <a> [-environment <name>] KEY [KEY2 ...] remove secrets |
| + list -app <a> [-environment <name>] list a secret's key names only — never values |
| + genkey mint a CARLOS_SECRETS_KEY/PUBKEY pair |
| + |
| +-environment names a bundle at apps/<acct>/<app>/environments/<name>.json — |
| +see "carlos env" for the full explanation; the same bundle backs both env |
| +vars and secrets, sealed or not. "carlos env environments -app <a>" lists |
| +an app's bundle names. |
| ` |
| |
| // RunSecrets is the `carlos secrets` verb. |
| @@ -166,11 +288,13 @@ func secretsPubKey() *ecdh.PublicKey { |
| func runSecretsSet(args []string) { |
| fs := flag.NewFlagSet("secrets set", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)") |
| acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| - log.Fatal("usage: carlos secrets set -app <a> KEY=value [KEY2=value2 ...]") |
| + log.Fatal("usage: carlos secrets set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]") |
| } |
| + checkEnvironmentFlag(*environment) |
| kv, err := parseKV(fs.Args()) |
| if err != nil { |
| log.Fatalf("secrets set: %v", err) |
| @@ -192,38 +316,43 @@ func runSecretsSet(args []string) { |
| } |
| sealed[k] = env |
| } |
| - if err := appconfig.SetSecret(context.Background(), openStore(), acctOf(), *app, sealed); err != nil { |
| + if err := saveSecrets(context.Background(), openStore(), acctOf(), *app, *environment, sealed); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("set %d secret(s) for %s\n", len(kv), *app) |
| + fmt.Printf("set %d secret(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment)) |
| } |
| |
| func runSecretsUnset(args []string) { |
| fs := flag.NewFlagSet("secrets unset", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)") |
| acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() == 0 { |
| - log.Fatal("usage: carlos secrets unset -app <a> KEY [KEY2 ...]") |
| + log.Fatal("usage: carlos secrets unset -app <a> [-environment <name>] KEY [KEY2 ...]") |
| } |
| - if err := appconfig.Unset(context.Background(), openStore(), acctOf(), *app, fs.Args()); err != nil { |
| + checkEnvironmentFlag(*environment) |
| + if err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args()); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("unset %d secret(s) for %s\n", fs.NArg(), *app) |
| + fmt.Printf("unset %d secret(s) for %s (%s)\n", fs.NArg(), *app, bundleLabel(*environment)) |
| } |
| |
| func runSecretsList(args []string) { |
| fs := flag.NewFlagSet("secrets list", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)") |
| acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| - log.Fatal("usage: carlos secrets list -app <a>") |
| + log.Fatal("usage: carlos secrets list -app <a> [-environment <name>]") |
| } |
| - m, err := appconfig.Get(context.Background(), openStore(), acctOf(), *app) |
| + checkEnvironmentFlag(*environment) |
| + m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment) |
| if err != nil { |
| log.Fatal(err) |
| } |
| + fmt.Printf("%s (%s):\n", *app, bundleLabel(*environment)) |
| var keys []string |
| for k, v := range m.Vars { |
| if v.Kind == "secret" { |