// The `carlos env` and `carlos secrets` verbs: per-app config, backed by // internal/appconfig. Dispatch shape mirrors RunAccounts/RunAuth (accounts.go, // auth.go) — a flat switch on the first arg. package main import ( "context" "crypto/ecdh" "flag" "fmt" "io" "log" "os" "sort" "strconv" "strings" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // restartAnswer is what one config write can be said to have left behind // on the box, and it exists to keep THREE outcomes apart where a bool or an // empty slice would keep two. // // A nil *restartAnswer means "no claim was made" — an older console that // does not send the field, or a direct-mode run with no registry anywhere // near it. Nothing is printed: silence about a question nobody was in a // position to ask is honest, where a "could not check" line printed on // every laptop-side `env set` would be noise that teaches people to skip // the line that matters. // // known == false means the opposite: someone WAS in a position to look and // could not. That gets said out loud. Conflating it with "all clear" is // discussion #16's bug, and reproducing it inside the fix would be a poor // joke. type restartAnswer struct { known bool pending appconfig.RestartPending } // restartFromAPI converts the console's answer, preserving the nil case. func restartFromAPI(r *apiConfigRestart) *restartAnswer { if r == nil { return nil } out := &restartAnswer{known: r.Known} out.pending.Unresolved = r.Unresolved for _, h := range r.Hosts { out.pending.Hosts = append(out.pending.Hosts, appconfig.RestartHost{Host: h.Host, Unit: h.Unit}) } return out } // localRestartAnswer is the direct-mode (on-box, CARLOS_DEPLOYMENT_BUCKET) // half: the same question asked of this box's own registry file. // // A registry that is not THERE returns nil rather than known:false. Direct // mode is equally the mode an operator runs from a laptop against the // bucket, where no registry exists, none is expected, and there is no box // in the picture to have routes on. A registry that exists but will not // open or list is the other thing entirely — a box that should be able to // answer and cannot — and that one says so. // // Deliberately os.Stat before registry.Open: Open CREATES an empty database // at a path that has none (see SyncExec's own note on this), so opening // optimistically would answer "no routes, nothing to restart" with complete // confidence, having just minted the empty file it read that from. func localRestartAnswer(ctx context.Context, s release.Store, regPath, acct, app, env string, keys []string) *restartAnswer { path := regPath if _, err := os.Stat(path); err != nil { return nil } reg, err := registry.Open(path) if err != nil { return &restartAnswer{} } defer reg.Close() routes, err := reg.List() if err != nil { return &restartAnswer{} } return &restartAnswer{known: true, pending: appconfig.AwaitingRestart(ctx, s, routes, acct, app, env, keys)} } // printRestartNotice writes the line discussion #16 was filed for: the // config write landed, and here is what is still running without it. // // One renderer for all four writing verbs (env set/unset, secrets // set/unset) and both modes, because the thing being reported is a property // of the write rather than of the verb — and because four hand-written // variants of one sentence is how they end up disagreeing about which // routes count. func printRestartNotice(w io.Writer, app string, a *restartAnswer) { if a == nil { return } c := colorFor(w) if !a.known { fmt.Fprintf(w, "%s %s\n", c.warn("note:"), restartCopyUnknown) return } if a.pending.Empty() { return } for _, h := range a.pending.Hosts { line := fmt.Sprintf(restartCopyPlain, h.Host) if h.Unit != "" { line = fmt.Sprintf(restartCopyUnit, h.Host, app, h.Unit) } fmt.Fprintf(w, "%s %s\n", c.warn("note:"), line) } for _, h := range a.pending.Unresolved { fmt.Fprintf(w, "%s %s\n", c.warn("note:"), fmt.Sprintf(restartCopyUnresolved, h)) } // Once, under the list, rather than on every line: the caveat is about // the LIST — what this box could see — so repeating it per host would be // three copies of one sentence saying nothing more the third time. fmt.Fprintf(w, "%s\n", c.muted(restartCopyScope)) } // checkNoEmptyValues refuses a `secrets set` carrying any empty value, and // is a returned error rather than an inline log.Fatalf so that it can be // tested for what it says as well as for whether it fires. // // Keys are named, values never — parseKV's rule, for the same reason: a // mistyped `secrets set` argument IS a secret, and this text lands in stderr // and in shell scrollback. All of them at once, sorted, because a caller who // mistyped one field name has usually mistyped several and Go's map order // would otherwise pick a different one to complain about each run. func checkNoEmptyValues(kv map[string]string) error { var empty []string for k, v := range kv { if v == "" { empty = append(empty, k) } } if len(empty) == 0 { return nil } sort.Strings(empty) return fmt.Errorf(secretsCopyEmpty, strings.Join(empty, ", ")) } // The notice's words, in one block because they are one message in four // shapes and were reviewed as one. The verb sites format these; they do not // write their own. const ( // Carries its own "secrets set:" prefix, so its one call site prints it // bare rather than prefixing it again. secretsCopyEmpty = "secrets set: %s: empty value: nothing stored. To remove a secret use `carlos secrets unset`; for a deliberately empty value use `carlos env set`. (A command substitution that produced nothing is the usual cause.)" restartCopyPlain = "%s has the file but is still running the old values. carlos routes to this process without owning it. Restart to fix." restartCopyUnit = "%s has the file but is still running the old values. carlos restart --app %s cycles %s." restartCopyUnresolved = "could not tell whether %s picked this up. Its config bundle would not load." restartCopyUnknown = "could not check which routes are still running the old values. This console cannot see the route registry." restartCopyScope = "(this machine's routes only)" ) const envUsage = `usage: carlos env commands: set --app [--environment ] KEY=value [KEY2=value2 ...] set one or more plain config vars unset --app [--environment ] KEY [KEY2 ...] remove config vars list --app [--environment ] list an app's config vars environments --app list an app's named environment bundles exec-delivery --app [--environment ] file|off|inherit opt one bundle's TCP-addr exec routes into config delivery sync --app request immediate config convergence (issue #44) --environment names a bundle at apps///environments/.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. exec-delivery sets "exec_delivery" in ONE bundle. "file" makes SyncExec write /data/.config.env for the TCP-addr exec routes that resolve through that bundle (routes carlos proxies to but does not spawn — e.g. titogo's router fleet) exactly as it already does for socket-exec routes, but those routes are NEVER bounced: carlos does not own the process, so the app's own supervisor owns the restart. Without --environment it is the app's default bundle, and "off" clears it. With --environment it is that bundle alone, and "inherit" clears it — a bundle that says nothing takes the app default's answer, so clearing there is NOT the same as turning delivery off. To deliver to one service only: turn the app default off, then set "file" on the bundle its route is bound to (carlos route --host --environment binds it). Turning the app default off does NOT delete files already written for routes that lose the opt-in: they hold decrypted secrets, and carlos cannot know when the app's own supervisor has stopped reading them. It names each one in its log every pass until you delete it by hand. sync touches apps///config-gen.json — the agent's fast poll loop (host package) notices within ~3s and re-renders exec-backed instances; unit-backed routes converge in seconds on a box whose trigger unit (carlos-adopt.path) is armed, and on the next hourly root pass otherwise — the agent can only request that pass, not write /etc or restart units itself. It never restarts a box's shared edge service, only the one app's own delivery. Every command above also works through a logged-in console (carlos auth login) when CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are unset — [--console ] picks which logged-in console when more than one is configured; the store env vars win outright when set (direct-bucket mode, unchanged). ` // RunEnv is the `carlos env` verb. func RunEnv(args []string) { if len(args) == 0 { fmt.Fprint(os.Stderr, envUsage) os.Exit(1) } switch args[0] { case "set": runEnvSet(args[1:]) case "unset": runEnvUnset(args[1:]) case "list": runEnvList(args[1:]) case "environments": runEnvEnvironments(args[1:]) case "exec-delivery": runEnvExecDelivery(args[1:]) case "sync": runEnvSync(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. It returns // how many keys were ACTUALLY removed, which is what the caller prints: a // count of arguments reads as success for a typo'd key or a typo'd // --environment (2026-08-02 review, MINOR 9). Unsetting from a bundle that // does not exist is an error from appconfig, not a create — see applyAt. func unsetVars(ctx context.Context, s release.Store, acct, app, environment string, keys []string) (int, error) { if environment == "" { return appconfig.Unset(ctx, s, acct, app, keys) } return appconfig.UnsetEnvironment(ctx, s, acct, app, environment, 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) { if len(args) == 0 { return nil, fmt.Errorf("at least one KEY=value pair is required") } out := map[string]string{} for i, a := range args { k, v, ok := strings.Cut(a, "=") if !ok || k == "" { // Named by position, never echoed: a mistyped `secrets set` // argument IS a secret, and this message lands in stderr and // shell scrollback. return nil, fmt.Errorf("argument %d is not a KEY=value pair", i+1) } out[k] = v } return out, nil } func runEnvSet(args []string) { fs := flag.NewFlagSet("env set", flag.ExitOnError) messageFlags := addMutationMessageFlags(fs) app := appFlag(fs, "") environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)") acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos env set --app [--environment ] [--console ] KEY=value [KEY2=value2 ...]" checkEnvironmentFlag(*environment) kv, err := parseKV(fs.Args()) if err != nil { log.Fatalf("env set: %v", err) } sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess != nil { printUsing(os.Stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, usage) set := make(map[string]appconfig.Var, len(kv)) for k, v := range kv { set[k] = appconfig.Var{Kind: "env", Value: v} } message, merr := resolveMutationMessage(fs, messageFlags) if merr != nil { log.Fatalf("env set: %v", merr) } res, err := sess.configApply(context.Background(), acct, app2, apiConfigApply{Environment: *environment, Set: set, Message: message}) if err != nil { log.Fatal(err) } fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), app2, bundleLabel(*environment)) printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart)) return } if *app == "" { log.Fatal(usage) } store := openStore() if err := saveEnvVars(context.Background(), store, acctOf(), *app, *environment, kv); err != nil { log.Fatal(err) } fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment)) printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, mapKeys(kv))) } // mapKeys is the key set of a KEY=value map, for the writtenKeys argument // AwaitingRestart narrows on. Sorted, so a notice is deterministic across // runs where Go's map order is not. Generic: config.go lists destination // names with it too. func mapKeys[V any](kv map[string]V) []string { out := make([]string, 0, len(kv)) for k := range kv { out = append(out, k) } sort.Strings(out) return out } func runEnvUnset(args []string) { fs := flag.NewFlagSet("env unset", flag.ExitOnError) messageFlags := addMutationMessageFlags(fs) app := appFlag(fs, "") environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)") acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos env unset --app [--environment ] [--console ] KEY [KEY2 ...]" if fs.NArg() == 0 { log.Fatal(usage) } checkEnvironmentFlag(*environment) sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess != nil { printUsing(os.Stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, usage) message, merr := resolveMutationMessage(fs, messageFlags) if merr != nil { log.Fatalf("env unset: %v", merr) } res, err := sess.configApply(context.Background(), acct, app2, apiConfigApply{Environment: *environment, Unset: fs.Args(), Message: message}) if err != nil { log.Fatal(err) } fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), res.Removed, fs.NArg(), app2, bundleLabel(*environment)) printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart)) return } if *app == "" { log.Fatal(usage) } store := openStore() removed, err := unsetVars(context.Background(), store, acctOf(), *app, *environment, fs.Args()) if err != nil { log.Fatal(err) } fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment)) // Gated on removed: an unset that removed nothing moved no file, so // there is nothing for anyone to restart over — the same test the // console door applies before it computes its own notice. if removed > 0 { printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, fs.Args())) } } func runEnvList(args []string) { fs := flag.NewFlagSet("env list", flag.ExitOnError) app := appFlag(fs, "") environment := environmentFlag(fs, "named environment bundle to list (omit for the default bundle)") acctRaw, acctIDRaw, acctOf := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos env list --app [--environment ] [--console ]" checkEnvironmentFlag(*environment) sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess != nil { printUsing(os.Stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, usage) cfg, err := sess.configGet(context.Background(), acct, app2, *environment) if err != nil { log.Fatal(err) } printEnvList(app2, *environment, cfg.ExecDelivery, envVarsFromAPI(cfg.Vars)) return } if *app == "" { log.Fatal(usage) } m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment) if err != nil { log.Fatal(err) } envVars := map[string]string{} for k, v := range m.Vars { if v.Kind == "env" { envVars[k] = v.Value } } printEnvList(*app, *environment, m.ExecDelivery, envVars) } // envVarsFromAPI narrows an apiConfig's Vars to the kind=="env" rows, keyed // by name — configGet's response shape for the API branch of runEnvList, // matching the direct branch's own map[key]value shape so printEnvList // prints identically regardless of mode. func envVarsFromAPI(vars []apiConfigVar) map[string]string { out := map[string]string{} for _, v := range vars { if v.Kind == "env" { out[v.Key] = v.Value } } return out } // printEnvList renders `env list`'s output — header line, an exec-delivery // line when the listed bundle sets one (see appconfig.Manifest.ExecDelivery), // then a sorted KEY/VALUE tabwriter — // byte-identical between the direct and API branches of runEnvList, which // differ only in HOW they fetch execDelivery/vars. func printEnvList(app, environment, execDelivery string, vars map[string]string) { fmt.Printf("%s\n", colorFor(os.Stdout).head(fmt.Sprintf("%s (%s):", app, bundleLabel(environment)))) // exec_delivery is printed for whichever bundle is being listed — since // issue #195 it can be set in a named one, where it means "routes bound // to this environment get files". A bundle SILENT on it prints nothing // rather than the value it inherits: the listing shows what this bundle // holds, and rendering an inherited value would make the bundle look // like it states something it does not. if execDelivery != "" { fmt.Printf("exec-delivery: %s\n", execDelivery) } var keys []string for k := range vars { keys = append(keys, k) } sort.Strings(keys) w := newTable(os.Stdout) fmt.Fprintln(w, "KEY\tVALUE") for _, k := range keys { fmt.Fprintf(w, "%s\t%s\n", k, vars[k]) } w.Flush() } // runEnvExecDelivery is `carlos env exec-delivery` — issue #18's opt-in // verb, deliberately NOT `env set --app EXEC_DELIVERY=...`: the field // is not a config var (it's never rendered into an EnvironmentFile, never // listed alongside KEY/VALUE pairs), it's a manifest-level flag with its // own validation and its own bundle, so it gets its own verb surface // instead of overloading env set's KEY=value parsing with a special-cased // key name. // // --environment (issue #195) writes the flag into that bundle instead of // the app default. The VOCABULARY changes with the scope, and that is the // point: at app scope the second word is "off", because clearing the // default bundle's field really does stop delivery; in a named bundle it // is "inherit", because clearing it there hands the question back to the // app default. Spelling the second case "off" would read as "stop // delivering" while doing the opposite whenever the app default is on — // exactly the kind of quiet lie this feature exists to avoid — so "off" // is refused there, by name, rather than accepted as a synonym. func runEnvExecDelivery(args []string) { fs := flag.NewFlagSet("env exec-delivery", flag.ExitOnError) messageFlags := addMutationMessageFlags(fs) app := appFlag(fs, "") environment := environmentFlag(fs, "named environment bundle to set the flag in (omit for the app's default bundle)") acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos env exec-delivery --app [--console ] file|off\n" + " or: carlos env exec-delivery --app --environment [--console ] file|inherit" if fs.NArg() != 1 { log.Fatal(usage) } checkEnvironmentFlag(*environment) // One stored value either way — "" clears the field. What differs is // which SECOND WORD spells that clear, and which bundle it clears in. // cleared carries the operator's own word through to the wire and to // the confirmation line, so the two never paraphrase each other. var value, cleared string switch { case fs.Arg(0) == appconfig.ExecDeliveryFile: value = appconfig.ExecDeliveryFile case *environment == "" && fs.Arg(0) == "off": value, cleared = "", "off" case *environment != "" && fs.Arg(0) == "inherit": value, cleared = "", "inherit" case *environment != "" && fs.Arg(0) == "off": log.Fatalf(`carlos env exec-delivery: "off" is not a value for a named environment — say "inherit" instead. `+ `Clearing exec_delivery in a bundle does not turn delivery off, it hands the question back to the app's `+ `default bundle (config.json), so a route bound to %q keeps getting files whenever the app-level flag is `+ `on. To stop delivery for the whole app: carlos env exec-delivery --app %s off`, *environment, bundleAppName(*app)) default: want := `"off"` if *environment != "" { want = `"inherit"` } log.Fatalf("carlos env exec-delivery: %q is not a valid value — want %q or %s", fs.Arg(0), appconfig.ExecDeliveryFile, want) } sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } appName := *app // Turning delivery ON is the write that creates a route's file for the // first time, with the same never-bounced process on the other end of // it, so it carries the same notice as a var write. No keys are passed: // there is nothing to shadow here, and the file appears (or does not) // regardless of which vars the bundle happens to hold. var restart *restartAnswer if sess != nil { printUsing(os.Stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) appName = resolvedApp(sess, acct, *app, usage) // The wire vocabulary is a WORD, never "" — the console's PUT // decodes ExecDelivery as a pointer where nil means "no change", so // clearing must still be spelled. The word sent is the operator's // own: the console refuses "off" for a named bundle exactly as this // function does, so relaying it verbatim keeps the two refusals // identical rather than letting one paraphrase the other. wire := cleared if value != "" { wire = value } message, merr := resolveMutationMessage(fs, messageFlags) if merr != nil { log.Fatalf("env exec-delivery: %v", merr) } res, err := sess.configApply(context.Background(), acct, appName, apiConfigApply{Environment: *environment, ExecDelivery: &wire, Message: message}) if err != nil { log.Fatal(err) } restart = restartFromAPI(res.Restart) } else { if *app == "" { log.Fatal(usage) } store := openStore() if err := appconfig.SetExecDeliveryAt(context.Background(), store, acctOf(), *app, *environment, value); err != nil { log.Fatal(err) } restart = localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, nil) } word := value if value == "" { word = cleared } fmt.Printf("%s %s for %s (%s)\n", colorFor(os.Stdout).ok("exec-delivery"), word, appName, bundleLabel(*environment)) printRestartNotice(os.Stdout, appName, restart) } // bundleAppName renders --app for a refusal printed BEFORE the app has been // resolved (the refusals above run at flag-parse time, ahead of // resolvedApp): an omitted --app must not turn the suggested command into // one with an empty argument. func bundleAppName(app string) string { if app == "" { return "" } return app } func runEnvEnvironments(args []string) { fs := flag.NewFlagSet("env environments", flag.ExitOnError) app := appFlag(fs, "") acctRaw, acctIDRaw, acctOf := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos env environments --app [--console ]" sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess != nil { printUsing(os.Stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, usage) cfg, err := sess.configGet(context.Background(), acct, app2, "") if err != nil { log.Fatal(err) } for _, n := range cfg.Environments { fmt.Println(n) } return } if *app == "" { log.Fatal(usage) } names, err := appconfig.ListEnvironments(context.Background(), openStore(), acctOf(), *app) if err != nil { log.Fatal(err) } for _, n := range names { fmt.Println(n) } } // runEnvSync is `carlos env sync` (issue #44) — the escape hatch for a // config write that needs to reach a running instance faster than // configSyncLoop's hourly floor: it touches apps///config-gen.json // (appconfig.GenKey), which the agent's configGenLoop polls every ~3s and // treats a moved etag as "run SyncExec now" for the app's exec-backed // instances and "ask the root config pass to run now" (the marker file // carlos-adopt.path watches) for its unit-backed ones, so both shapes // converge in seconds on a box whose path unit is armed — and the unit half // falls back to the hourly pass on a box where it isn't, which is why the // printed line says so rather than promising seconds outright. It never // restarts a box's shared edge service and never targets a bundle directly // — the generation // object covers every bundle an app has (default plus every environment) in // one touch, matching TouchGeneration's own "app granularity, not // per-bundle" design (generation.go). func runEnvSync(args []string) { fs := flag.NewFlagSet("env sync", flag.ExitOnError) app := appFlag(fs, "") acctRaw, acctIDRaw, acctOf := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos env sync --app [--account ] [--account-id ] [--console ]" sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } appName := *app if sess != nil { printUsing(os.Stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) appName = resolvedApp(sess, acct, *app, usage) if err := sess.configSync(context.Background(), acct, appName); err != nil { log.Fatal(err) } } else { if *app == "" { log.Fatal(usage) } if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil { log.Fatal(err) } } // The files, not the processes. The old wording promised that // "exec-backed instances converge within seconds", which is true of the // file and false of the program reading it for exactly the route shape // discussion #16 was filed about — and this is the command people run // while waiting for a change to take effect, so overstating it sends // them off to debug the wrong thing. fmt.Printf("%s for %s the files land within seconds; programs carlos owns restart themselves, "+ "and anything else keeps its old values until you restart it\n", colorFor(os.Stdout).ok("config sync requested"), appName) } const secretsUsage = `usage: carlos secrets commands: set --app [--environment ] KEY=value [KEY2=value2 ...] seal and set one or more secrets unset --app [--environment ] KEY [KEY2 ...] remove secrets list --app [--environment ] list a secret's key names only — never values genkey mint a CARLOS_SECRETS_KEY/PUBKEY pair --environment names a bundle at apps///environments/.json — see "carlos env" for the full explanation; the same bundle backs both env vars and secrets, sealed or not. "carlos env environments --app " lists an app's bundle names. set/unset/list also work through a logged-in console (carlos auth login) when CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are unset — [--console ] picks which logged-in console when more than one is configured; the store env vars win outright when set (direct-bucket mode, unchanged). In that mode "secrets set" seals with CARLOS_SECRETS_PUBKEY if it's set in this terminal's own environment, otherwise the console's own served public key (Config.SecretsPub) if it has one — CARLOS_SECRETS_KEY (the PRIVATE half) never leaves the box holding it either way. genkey is always local, in both modes. ` // RunSecrets is the `carlos secrets` verb. func RunSecrets(args []string) { if len(args) == 0 { fmt.Fprint(os.Stderr, secretsUsage) os.Exit(1) } switch args[0] { case "set": runSecretsSet(args[1:]) case "unset": runSecretsUnset(args[1:]) case "list": runSecretsList(args[1:]) case "genkey": runSecretsGenkey(args[1:]) default: fmt.Fprint(os.Stderr, secretsUsage) os.Exit(1) } } // secretsPubKey reads CARLOS_SECRETS_PUBKEY — distinct from cli.go's // publicKey()/signingKey(), which are the unrelated release signing pair. func secretsPubKey() *ecdh.PublicKey { k, err := appconfig.ParsePublicKey(os.Getenv("CARLOS_SECRETS_PUBKEY"), "CARLOS_SECRETS_PUBKEY") if err != nil { log.Fatal(err) } return k } // secretsPubKeyAPI resolves the sealing key for API-mode `secrets set`: // CARLOS_SECRETS_PUBKEY in THIS terminal's own environment wins — matching // secretsPubKey's direct-mode rule exactly, so the same env var works // whichever mode a script happens to run in — otherwise the console's own // served public key (Config.SecretsPub, GET config's "secrets_pubkey" // field). That fallback is what makes `carlos auth login` alone sufficient // for `secrets set` (spec decision 2): public material by design, it only // encrypts. Neither present: a fatal naming both fixes — CARLOS_SECRETS_KEY // (the PRIVATE half) never leaves the box holding it, so there is no third // way to get a key here. // // It also returns the account sqid to bind the envelopes to, which only the // console can supply (the flag may be a name), so the GET is made even when // the key comes from the environment. "" means the console does not write // bound envelopes (switched off, or too old); the caller seals v1 then. func secretsPubKeyAPI(sess *apiSession, account apiAcct, app string) (*ecdh.PublicKey, string) { cfg, err := sess.configGet(context.Background(), account, app, "") if err != nil { log.Fatal(err) } if raw := os.Getenv("CARLOS_SECRETS_PUBKEY"); raw != "" { k, err := appconfig.ParsePublicKey(raw, "CARLOS_SECRETS_PUBKEY") if err != nil { log.Fatal(err) } return k, cfg.SecretsAccount } if cfg.SecretsPubkey == "" { log.Fatal("no CARLOS_SECRETS_PUBKEY in env and this console does not serve one — " + "set the env var, or set CARLOS_SECRETS_PUBKEY in the console's environment") } k, err := appconfig.ParsePublicKey(cfg.SecretsPubkey, "console secrets_pubkey") if err != nil { log.Fatal(err) } return k, cfg.SecretsAccount } // secretsEnvelopeV2Env reads CONSOLE_SECRETS_ENVELOPE_V2 for direct-bucket // `secrets set`, with the console's own rule: unset is false, and a value // that is not a boolean stops rather than guessing. func secretsEnvelopeV2Env() bool { v := strings.TrimSpace(os.Getenv("CONSOLE_SECRETS_ENVELOPE_V2")) if v == "" { return false } b, err := strconv.ParseBool(v) if err != nil { log.Fatalf("CONSOLE_SECRETS_ENVELOPE_V2 %q is not a boolean (use true or false)", v) } return b } // sealSecretAPI seals one value for API-mode `secrets set`: bound to // (account, app) when the console named the account — which it does only // while its deployment writes v2 (CONSOLE_SECRETS_ENVELOPE_V2) — and v1 // otherwise, which every box and every console door accepts. func sealSecretAPI(pub *ecdh.PublicKey, account, app string, v []byte) (string, error) { if account == "" { return appconfig.SealUnbound(pub, v) } return appconfig.SealBound(pub, appconfig.Binding{Account: account, App: app}, v) } func runSecretsSet(args []string) { fs := flag.NewFlagSet("secrets set", flag.ExitOnError) app := appFlag(fs, "") environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)") acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos secrets set --app [--environment ] [--console ] KEY=value [KEY2=value2 ...]" checkEnvironmentFlag(*environment) kv, err := parseKV(fs.Args()) if err != nil { log.Fatalf("secrets set: %v", err) } // Empty values first, and as a group: they are the likeliest thing to // be wrong about a `secrets set` invocation and the cheapest to catch, // and a caller who mistyped one field name has usually mistyped // several. Discussion #16 — values arrive as "$(op read ...)", `op // read` on a field that does not exist prints nothing and exits 0, so // the shell hands us an empty string, we seal it, store it, and report // success. The symptom lands hours later somewhere else entirely: with // both halves of a credential pair empty the app reads "not // configured" and silently offers no OAuth provider; with one empty it // refuses to boot. // // Keys only, never values — parseKV's rule above, for the same reason: // a mistyped `secrets set` argument IS a secret, and this text lands in // stderr and shell scrollback. // // The console door refuses these too (appconfig.CheckEnvelope, which // reads emptiness off the envelope's length). This check exists in // front of it because it is the ONLY one in direct mode, because it // fires before the network round-trip that fetches the sealing key, // and because it can name every offending key at once where the door, // which validates var by var, names the first. if err := checkNoEmptyValues(kv); err != nil { log.Fatal(err) } // Validate every value while the plaintext is still in hand, before // either branch below seals anything — after Seal the value is opaque // forever (`secrets list` never shows values), so a bad one would only // surface as a render failure in the hourly adopt log, stalling the // app's entire config with nothing for the operator to inspect. for k, v := range kv { if err := appconfig.ValidVarValue(v); err != nil { log.Fatalf("secrets set: %s: %v", k, err) } } sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess != nil { printUsing(os.Stdout, sess) // Resolved ONCE, used for both the seal key and the write: two // resolutions here could answer differently (the chooser asked // twice), sealing the value with one account's public key and // writing it into another's config — an undecryptable secret whose // only symptom is a render failure in the hourly adopt log. acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, usage) pub, sealAcct := secretsPubKeyAPI(sess, acct, app2) set := make(map[string]appconfig.Var, len(kv)) for k, v := range kv { env, err := sealSecretAPI(pub, sealAcct, app2, []byte(v)) if err != nil { log.Fatalf("seal %s: %v", k, err) } set[k] = appconfig.Var{Kind: "secret", Envelope: env} } res, err := sess.configApply(context.Background(), acct, app2, apiConfigApply{Environment: *environment, Set: set}) if err != nil { log.Fatal(err) } fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), app2, bundleLabel(*environment)) printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart)) return } if *app == "" { log.Fatal(usage) } pub := secretsPubKey() acct := acctOf() // Direct-bucket mode has no console to ask, so the deployment's switch // comes from the operator's environment under the console's own name. appconfig.SetBoundSealing(secretsEnvelopeV2Env()) sealed := make(map[string]string, len(kv)) for k, v := range kv { env, err := appconfig.Seal(pub, appconfig.Binding{Account: acct, App: *app}, []byte(v)) if err != nil { log.Fatalf("seal %s: %v", k, err) } sealed[k] = env } store := openStore() if err := saveSecrets(context.Background(), store, acct, *app, *environment, sealed); err != nil { log.Fatal(err) } fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment)) printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, mapKeys(kv))) } func runSecretsUnset(args []string) { fs := flag.NewFlagSet("secrets unset", flag.ExitOnError) app := appFlag(fs, "") environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)") acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos secrets unset --app [--environment ] [--console ] KEY [KEY2 ...]" if fs.NArg() == 0 { log.Fatal(usage) } checkEnvironmentFlag(*environment) sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess != nil { printUsing(os.Stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, usage) res, err := sess.configApply(context.Background(), acct, app2, apiConfigApply{Environment: *environment, Unset: fs.Args()}) if err != nil { log.Fatal(err) } fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), res.Removed, fs.NArg(), app2, bundleLabel(*environment)) printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart)) return } if *app == "" { log.Fatal(usage) } store := openStore() removed, err := unsetVars(context.Background(), store, acctOf(), *app, *environment, fs.Args()) if err != nil { log.Fatal(err) } fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment)) // Gated on removed, exactly as `env unset`'s direct branch is. if removed > 0 { printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, fs.Args())) } } // printSecretsList renders `secrets list`'s output — header line, then // sorted key names only, never values — byte-identical between the direct // and API branches of runSecretsList, which differ only in HOW they fetch // the key set. func printSecretsList(app, environment string, keys []string) { fmt.Printf("%s\n", colorFor(os.Stdout).head(fmt.Sprintf("%s (%s):", app, bundleLabel(environment)))) sort.Strings(keys) for _, k := range keys { fmt.Println(k) } } func runSecretsList(args []string) { fs := flag.NewFlagSet("secrets list", flag.ExitOnError) app := appFlag(fs, "") environment := environmentFlag(fs, "named environment bundle to list (omit for the default bundle)") acctRaw, acctIDRaw, acctOf := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos secrets list --app [--environment ] [--console ]" checkEnvironmentFlag(*environment) sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess != nil { printUsing(os.Stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, usage) cfg, err := sess.configGet(context.Background(), acct, app2, *environment) if err != nil { log.Fatal(err) } var keys []string for _, v := range cfg.Vars { if v.Kind == "secret" { keys = append(keys, v.Key) } } printSecretsList(app2, *environment, keys) return } if *app == "" { log.Fatal(usage) } m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment) if err != nil { log.Fatal(err) } var keys []string for k, v := range m.Vars { if v.Kind == "secret" { keys = append(keys, k) } } printSecretsList(*app, *environment, keys) } func runSecretsGenkey(args []string) { priv, pub, err := appconfig.GenerateKey() if err != nil { log.Fatal(err) } fmt.Printf("CARLOS_SECRETS_KEY=%s\nCARLOS_SECRETS_PUBKEY=%s\n", priv, pub) fmt.Fprintln(os.Stderr, "store the KEY in your secret manager and in every host's host.env; "+ "the PUBKEY can be held widely — it only encrypts") fmt.Fprintln(os.Stderr, "note: config sync reaches unit-backed routes (carlos-app@) via the root adopt pass, "+ "and exec-backed instance routes via the agent's in-process loop (materialized to /data/.config.env; "+ "the instance's own /data/.env always wins conflicts)") }