carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The `carlos env` and `carlos secrets` verbs: per-app config, backed by
2// internal/appconfig. Dispatch shape mirrors RunAccounts/RunAuth (accounts.go,
3// auth.go) — a flat switch on the first arg.
4package main
5
6import (
7 "context"
8 "crypto/ecdh"
9 "flag"
10 "fmt"
11 "io"
12 "log"
13 "os"
14 "sort"
15 "strconv"
16 "strings"
17
18 "github.com/carlosframework/platform/internal/appconfig"
19 "github.com/carlosframework/platform/internal/registry"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23// restartAnswer is what one config write can be said to have left behind
24// on the box, and it exists to keep THREE outcomes apart where a bool or an
25// empty slice would keep two.
26//
27// A nil *restartAnswer means "no claim was made" — an older console that
28// does not send the field, or a direct-mode run with no registry anywhere
29// near it. Nothing is printed: silence about a question nobody was in a
30// position to ask is honest, where a "could not check" line printed on
31// every laptop-side `env set` would be noise that teaches people to skip
32// the line that matters.
33//
34// known == false means the opposite: someone WAS in a position to look and
35// could not. That gets said out loud. Conflating it with "all clear" is
36// discussion #16's bug, and reproducing it inside the fix would be a poor
37// joke.
38type restartAnswer struct {
39 known bool
40 pending appconfig.RestartPending
41}
42
43// restartFromAPI converts the console's answer, preserving the nil case.
44func restartFromAPI(r *apiConfigRestart) *restartAnswer {
45 if r == nil {
46 return nil
47 }
48 out := &restartAnswer{known: r.Known}
49 out.pending.Unresolved = r.Unresolved
50 for _, h := range r.Hosts {
51 out.pending.Hosts = append(out.pending.Hosts, appconfig.RestartHost{Host: h.Host, Unit: h.Unit})
52 }
53 return out
54}
55
56// localRestartAnswer is the direct-mode (on-box, CARLOS_DEPLOYMENT_BUCKET)
57// half: the same question asked of this box's own registry file.
58//
59// A registry that is not THERE returns nil rather than known:false. Direct
60// mode is equally the mode an operator runs from a laptop against the
61// bucket, where no registry exists, none is expected, and there is no box
62// in the picture to have routes on. A registry that exists but will not
63// open or list is the other thing entirely — a box that should be able to
64// answer and cannot — and that one says so.
65//
66// Deliberately os.Stat before registry.Open: Open CREATES an empty database
67// at a path that has none (see SyncExec's own note on this), so opening
68// optimistically would answer "no routes, nothing to restart" with complete
69// confidence, having just minted the empty file it read that from.
70func localRestartAnswer(ctx context.Context, s release.Store, regPath, acct, app, env string, keys []string) *restartAnswer {
71 path := regPath
72 if _, err := os.Stat(path); err != nil {
73 return nil
74 }
75 reg, err := registry.Open(path)
76 if err != nil {
77 return &restartAnswer{}
78 }
79 defer reg.Close()
80 routes, err := reg.List()
81 if err != nil {
82 return &restartAnswer{}
83 }
84 return &restartAnswer{known: true, pending: appconfig.AwaitingRestart(ctx, s, routes, acct, app, env, keys)}
85}
86
87// printRestartNotice writes the line discussion #16 was filed for: the
88// config write landed, and here is what is still running without it.
89//
90// One renderer for all four writing verbs (env set/unset, secrets
91// set/unset) and both modes, because the thing being reported is a property
92// of the write rather than of the verb — and because four hand-written
93// variants of one sentence is how they end up disagreeing about which
94// routes count.
95func printRestartNotice(w io.Writer, app string, a *restartAnswer) {
96 if a == nil {
97 return
98 }
99 c := colorFor(w)
100 if !a.known {
101 fmt.Fprintf(w, "%s %s\n", c.warn("note:"), restartCopyUnknown)
102 return
103 }
104 if a.pending.Empty() {
105 return
106 }
107 for _, h := range a.pending.Hosts {
108 line := fmt.Sprintf(restartCopyPlain, h.Host)
109 if h.Unit != "" {
110 line = fmt.Sprintf(restartCopyUnit, h.Host, app, h.Unit)
111 }
112 fmt.Fprintf(w, "%s %s\n", c.warn("note:"), line)
113 }
114 for _, h := range a.pending.Unresolved {
115 fmt.Fprintf(w, "%s %s\n", c.warn("note:"), fmt.Sprintf(restartCopyUnresolved, h))
116 }
117 // Once, under the list, rather than on every line: the caveat is about
118 // the LIST — what this box could see — so repeating it per host would be
119 // three copies of one sentence saying nothing more the third time.
120 fmt.Fprintf(w, "%s\n", c.muted(restartCopyScope))
121}
122
123// checkNoEmptyValues refuses a `secrets set` carrying any empty value, and
124// is a returned error rather than an inline log.Fatalf so that it can be
125// tested for what it says as well as for whether it fires.
126//
127// Keys are named, values never — parseKV's rule, for the same reason: a
128// mistyped `secrets set` argument IS a secret, and this text lands in stderr
129// and in shell scrollback. All of them at once, sorted, because a caller who
130// mistyped one field name has usually mistyped several and Go's map order
131// would otherwise pick a different one to complain about each run.
132func checkNoEmptyValues(kv map[string]string) error {
133 var empty []string
134 for k, v := range kv {
135 if v == "" {
136 empty = append(empty, k)
137 }
138 }
139 if len(empty) == 0 {
140 return nil
141 }
142 sort.Strings(empty)
143 return fmt.Errorf(secretsCopyEmpty, strings.Join(empty, ", "))
144}
145
146// The notice's words, in one block because they are one message in four
147// shapes and were reviewed as one. The verb sites format these; they do not
148// write their own.
149const (
150 // Carries its own "secrets set:" prefix, so its one call site prints it
151 // bare rather than prefixing it again.
152 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.)"
153 restartCopyPlain = "%s has the file but is still running the old values. carlos routes to this process without owning it. Restart to fix."
154 restartCopyUnit = "%s has the file but is still running the old values. carlos restart --app %s cycles %s."
155 restartCopyUnresolved = "could not tell whether %s picked this up. Its config bundle would not load."
156 restartCopyUnknown = "could not check which routes are still running the old values. This console cannot see the route registry."
157 restartCopyScope = "(this machine's routes only)"
158)
159
160const envUsage = `usage: carlos env <command>
161
162commands:
163 set --app <a> [--environment <name>] KEY=value [KEY2=value2 ...] set one or more plain config vars
164 unset --app <a> [--environment <name>] KEY [KEY2 ...] remove config vars
165 list --app <a> [--environment <name>] list an app's config vars
166 environments --app <a> list an app's named environment bundles
167 exec-delivery --app <a> [--environment <name>] file|off|inherit opt one bundle's TCP-addr exec routes into config delivery
168 sync --app <a> request immediate config convergence (issue #44)
169
170--environment names a bundle at apps/<acct>/<app>/environments/<name>.json,
171layered on top of the default bundle (config.json) at materialization time —
172see LoadMerged. Omit --environment for the default bundle itself. The bundle
173is created implicitly on its first "set", same as config.json today.
174
175exec-delivery sets "exec_delivery" in ONE bundle. "file" makes SyncExec
176write /data/<host>.config.env for the TCP-addr exec routes that resolve
177through that bundle (routes carlos proxies to but does not spawn — e.g.
178titogo's router fleet) exactly as it already does for socket-exec routes,
179but those routes are NEVER bounced: carlos does not own the process, so the
180app's own supervisor owns the restart.
181
182Without --environment it is the app's default bundle, and "off" clears it.
183With --environment it is that bundle alone, and "inherit" clears it — a
184bundle that says nothing takes the app default's answer, so clearing there
185is NOT the same as turning delivery off. To deliver to one service only:
186turn the app default off, then set "file" on the bundle its route is bound
187to (carlos route --host <h> --environment <name> binds it).
188
189Turning the app default off does NOT delete files already written for
190routes that lose the opt-in: they hold decrypted secrets, and carlos cannot
191know when the app's own supervisor has stopped reading them. It names each
192one in its log every pass until you delete it by hand.
193
194sync touches apps/<acct>/<app>/config-gen.json — the agent's fast poll loop
195(host package) notices within ~3s and re-renders exec-backed instances;
196unit-backed routes converge in seconds on a box whose trigger unit
197(carlos-adopt.path) is armed, and on the next hourly root pass otherwise —
198the agent can only request that pass, not write /etc or restart units
199itself. It never restarts a box's shared edge service, only the one app's
200own delivery.
201
202Every command above also works through a logged-in console
203(carlos auth login) when CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are
204unset — [--console <origin>] picks which logged-in console when more than one
205is configured; the store env vars win outright when set (direct-bucket
206mode, unchanged).
207`
208
209// RunEnv is the `carlos env` verb.
210func RunEnv(args []string) {
211 if len(args) == 0 {
212 fmt.Fprint(os.Stderr, envUsage)
213 os.Exit(1)
214 }
215 switch args[0] {
216 case "set":
217 runEnvSet(args[1:])
218 case "unset":
219 runEnvUnset(args[1:])
220 case "list":
221 runEnvList(args[1:])
222 case "environments":
223 runEnvEnvironments(args[1:])
224 case "exec-delivery":
225 runEnvExecDelivery(args[1:])
226 case "sync":
227 runEnvSync(args[1:])
228 default:
229 fmt.Fprint(os.Stderr, envUsage)
230 os.Exit(1)
231 }
232}
233
234// bundleLabel names the bundle a command is reading or writing, for a
235// `list` header or a confirmation line: "default" reads better to a human
236// than "", and matches ValidEnvironmentName's own vocabulary for the base
237// bundle every route already gets when no --environment is bound.
238func bundleLabel(environment string) string {
239 if environment == "" {
240 return "default"
241 }
242 return environment
243}
244
245// checkEnvironmentFlag refuses a bad --environment value before it reaches
246// appconfig, with a reason a bare "invalid" wouldn't give. "" is not
247// reserved — it is the flag's own way of saying "no --environment", i.e. the
248// default bundle — but "default" IS reserved: it already names that same
249// bundle (config.json), so naming it as an --environment value would either
250// error confusingly deep in appconfig or, worse, look like a second bundle
251// that happens to share a name. Every env/secrets subcommand that accepts
252// --environment calls this right after fs.Parse.
253func checkEnvironmentFlag(environment string) {
254 if environment == "" {
255 return
256 }
257 if environment == "default" {
258 log.Fatal(`--environment default is refused: "default" already names the base bundle ` +
259 "(config.json) that every route gets when no --environment is bound — omit --environment " +
260 "to set/unset/list it directly, don't name it")
261 }
262 if !appconfig.ValidEnvironmentName(environment) {
263 log.Fatalf("--environment %q is not a valid environment name — want [a-z0-9-]{1,32}", environment)
264 }
265}
266
267// loadBundle fetches the manifest a `list` command shows: the default
268// bundle when environment=="", or the named environment's own bundle
269// otherwise — never merged. LoadMerged is what a bound route actually gets
270// at materialization time; `list` shows exactly one bundle, matching what
271// `set`/`unset` write to for the same --environment value.
272func loadBundle(ctx context.Context, s release.Store, acct, app, environment string) (*appconfig.Manifest, error) {
273 if environment == "" {
274 return appconfig.Get(ctx, s, acct, app)
275 }
276 return appconfig.LoadEnvironment(ctx, s, acct, app, environment)
277}
278
279// saveEnvVars writes plain config vars to the default bundle when
280// environment=="", or to the named environment's own bundle otherwise —
281// the same CAS discipline either way (appconfig.SetEnv / SaveEnvironment).
282func saveEnvVars(ctx context.Context, s release.Store, acct, app, environment string, kv map[string]string) error {
283 if environment == "" {
284 return appconfig.SetEnv(ctx, s, acct, app, kv)
285 }
286 sets := make(map[string]appconfig.Var, len(kv))
287 for k, v := range kv {
288 sets[k] = appconfig.Var{Kind: "env", Value: v}
289 }
290 return appconfig.SaveEnvironment(ctx, s, acct, app, environment, sets, nil)
291}
292
293// saveSecrets writes already-sealed secrets to the default bundle when
294// environment=="", or to the named environment's own bundle otherwise —
295// mirrors saveEnvVars; see its comment.
296func saveSecrets(ctx context.Context, s release.Store, acct, app, environment string, sealed map[string]string) error {
297 if environment == "" {
298 return appconfig.SetSecret(ctx, s, acct, app, sealed)
299 }
300 sets := make(map[string]appconfig.Var, len(sealed))
301 for k, v := range sealed {
302 sets[k] = appconfig.Var{Kind: "secret", Envelope: v}
303 }
304 return appconfig.SaveEnvironment(ctx, s, acct, app, environment, sets, nil)
305}
306
307// unsetVars removes vars (env or secret) by key from the default bundle
308// when environment=="", or from the named environment's own bundle
309// otherwise — shared by `env unset` and `secrets unset`, same as
310// appconfig.Unset is shared by their non-environment paths today. It returns
311// how many keys were ACTUALLY removed, which is what the caller prints: a
312// count of arguments reads as success for a typo'd key or a typo'd
313// --environment (2026-08-02 review, MINOR 9). Unsetting from a bundle that
314// does not exist is an error from appconfig, not a create — see applyAt.
315func unsetVars(ctx context.Context, s release.Store, acct, app, environment string, keys []string) (int, error) {
316 if environment == "" {
317 return appconfig.Unset(ctx, s, acct, app, keys)
318 }
319 return appconfig.UnsetEnvironment(ctx, s, acct, app, environment, keys)
320}
321
322// parseKV turns ["KEY=value", "KEY2=value2"] into a map, refusing anything
323// without an "=" or with an empty key. Shared by `env set` and `secrets set`.
324func parseKV(args []string) (map[string]string, error) {
325 if len(args) == 0 {
326 return nil, fmt.Errorf("at least one KEY=value pair is required")
327 }
328 out := map[string]string{}
329 for i, a := range args {
330 k, v, ok := strings.Cut(a, "=")
331 if !ok || k == "" {
332 // Named by position, never echoed: a mistyped `secrets set`
333 // argument IS a secret, and this message lands in stderr and
334 // shell scrollback.
335 return nil, fmt.Errorf("argument %d is not a KEY=value pair", i+1)
336 }
337 out[k] = v
338 }
339 return out, nil
340}
341
342func runEnvSet(args []string) {
343 fs := flag.NewFlagSet("env set", flag.ExitOnError)
344 messageFlags := addMutationMessageFlags(fs)
345 app := appFlag(fs, "")
346 environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)")
347 acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs)
348 consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)")
349 fs.Usage = flagUsage(fs, "")
350 fs.Parse(args)
351 usage := "usage: carlos env set --app <a> [--environment <name>] [--console <origin>] KEY=value [KEY2=value2 ...]"
352 checkEnvironmentFlag(*environment)
353 kv, err := parseKV(fs.Args())
354 if err != nil {
355 log.Fatalf("env set: %v", err)
356 }
357
358 sess, err := resolveMode(*consoleFlag)
359 if err != nil {
360 log.Fatal(err)
361 }
362 if sess != nil {
363 printUsing(os.Stdout, sess)
364 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
365 app2 := resolvedApp(sess, acct, *app, usage)
366 set := make(map[string]appconfig.Var, len(kv))
367 for k, v := range kv {
368 set[k] = appconfig.Var{Kind: "env", Value: v}
369 }
370 message, merr := resolveMutationMessage(fs, messageFlags)
371 if merr != nil {
372 log.Fatalf("env set: %v", merr)
373 }
374 res, err := sess.configApply(context.Background(), acct, app2,
375 apiConfigApply{Environment: *environment, Set: set, Message: message})
376 if err != nil {
377 log.Fatal(err)
378 }
379 fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), app2, bundleLabel(*environment))
380 printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart))
381 return
382 }
383
384 if *app == "" {
385 log.Fatal(usage)
386 }
387 store := openStore()
388 if err := saveEnvVars(context.Background(), store, acctOf(), *app, *environment, kv); err != nil {
389 log.Fatal(err)
390 }
391 fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment))
392 printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, mapKeys(kv)))
393}
394
395// mapKeys is the key set of a KEY=value map, for the writtenKeys argument
396// AwaitingRestart narrows on. Sorted, so a notice is deterministic across
397// runs where Go's map order is not. Generic: config.go lists destination
398// names with it too.
399func mapKeys[V any](kv map[string]V) []string {
400 out := make([]string, 0, len(kv))
401 for k := range kv {
402 out = append(out, k)
403 }
404 sort.Strings(out)
405 return out
406}
407
408func runEnvUnset(args []string) {
409 fs := flag.NewFlagSet("env unset", flag.ExitOnError)
410 messageFlags := addMutationMessageFlags(fs)
411 app := appFlag(fs, "")
412 environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)")
413 acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs)
414 consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)")
415 fs.Usage = flagUsage(fs, "")
416 fs.Parse(args)
417 usage := "usage: carlos env unset --app <a> [--environment <name>] [--console <origin>] KEY [KEY2 ...]"
418 if fs.NArg() == 0 {
419 log.Fatal(usage)
420 }
421 checkEnvironmentFlag(*environment)
422
423 sess, err := resolveMode(*consoleFlag)
424 if err != nil {
425 log.Fatal(err)
426 }
427 if sess != nil {
428 printUsing(os.Stdout, sess)
429 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
430 app2 := resolvedApp(sess, acct, *app, usage)
431 message, merr := resolveMutationMessage(fs, messageFlags)
432 if merr != nil {
433 log.Fatalf("env unset: %v", merr)
434 }
435 res, err := sess.configApply(context.Background(), acct, app2,
436 apiConfigApply{Environment: *environment, Unset: fs.Args(), Message: message})
437 if err != nil {
438 log.Fatal(err)
439 }
440 fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), res.Removed, fs.NArg(), app2, bundleLabel(*environment))
441 printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart))
442 return
443 }
444
445 if *app == "" {
446 log.Fatal(usage)
447 }
448 store := openStore()
449 removed, err := unsetVars(context.Background(), store, acctOf(), *app, *environment, fs.Args())
450 if err != nil {
451 log.Fatal(err)
452 }
453 fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment))
454 // Gated on removed: an unset that removed nothing moved no file, so
455 // there is nothing for anyone to restart over — the same test the
456 // console door applies before it computes its own notice.
457 if removed > 0 {
458 printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, fs.Args()))
459 }
460}
461
462func runEnvList(args []string) {
463 fs := flag.NewFlagSet("env list", flag.ExitOnError)
464 app := appFlag(fs, "")
465 environment := environmentFlag(fs, "named environment bundle to list (omit for the default bundle)")
466 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
467 consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)")
468 fs.Usage = flagUsage(fs, "")
469 fs.Parse(args)
470 usage := "usage: carlos env list --app <a> [--environment <name>] [--console <origin>]"
471 checkEnvironmentFlag(*environment)
472
473 sess, err := resolveMode(*consoleFlag)
474 if err != nil {
475 log.Fatal(err)
476 }
477 if sess != nil {
478 printUsing(os.Stdout, sess)
479 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
480 app2 := resolvedApp(sess, acct, *app, usage)
481 cfg, err := sess.configGet(context.Background(), acct, app2, *environment)
482 if err != nil {
483 log.Fatal(err)
484 }
485 printEnvList(app2, *environment, cfg.ExecDelivery, envVarsFromAPI(cfg.Vars))
486 return
487 }
488
489 if *app == "" {
490 log.Fatal(usage)
491 }
492 m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment)
493 if err != nil {
494 log.Fatal(err)
495 }
496 envVars := map[string]string{}
497 for k, v := range m.Vars {
498 if v.Kind == "env" {
499 envVars[k] = v.Value
500 }
501 }
502 printEnvList(*app, *environment, m.ExecDelivery, envVars)
503}
504
505// envVarsFromAPI narrows an apiConfig's Vars to the kind=="env" rows, keyed
506// by name — configGet's response shape for the API branch of runEnvList,
507// matching the direct branch's own map[key]value shape so printEnvList
508// prints identically regardless of mode.
509func envVarsFromAPI(vars []apiConfigVar) map[string]string {
510 out := map[string]string{}
511 for _, v := range vars {
512 if v.Kind == "env" {
513 out[v.Key] = v.Value
514 }
515 }
516 return out
517}
518
519// printEnvList renders `env list`'s output — header line, an exec-delivery
520// line when the listed bundle sets one (see appconfig.Manifest.ExecDelivery),
521// then a sorted KEY/VALUE tabwriter —
522// byte-identical between the direct and API branches of runEnvList, which
523// differ only in HOW they fetch execDelivery/vars.
524func printEnvList(app, environment, execDelivery string, vars map[string]string) {
525 fmt.Printf("%s\n", colorFor(os.Stdout).head(fmt.Sprintf("%s (%s):", app, bundleLabel(environment))))
526 // exec_delivery is printed for whichever bundle is being listed — since
527 // issue #195 it can be set in a named one, where it means "routes bound
528 // to this environment get files". A bundle SILENT on it prints nothing
529 // rather than the value it inherits: the listing shows what this bundle
530 // holds, and rendering an inherited value would make the bundle look
531 // like it states something it does not.
532 if execDelivery != "" {
533 fmt.Printf("exec-delivery: %s\n", execDelivery)
534 }
535 var keys []string
536 for k := range vars {
537 keys = append(keys, k)
538 }
539 sort.Strings(keys)
540 w := newTable(os.Stdout)
541 fmt.Fprintln(w, "KEY\tVALUE")
542 for _, k := range keys {
543 fmt.Fprintf(w, "%s\t%s\n", k, vars[k])
544 }
545 w.Flush()
546}
547
548// runEnvExecDelivery is `carlos env exec-delivery` — issue #18's opt-in
549// verb, deliberately NOT `env set --app <a> EXEC_DELIVERY=...`: the field
550// is not a config var (it's never rendered into an EnvironmentFile, never
551// listed alongside KEY/VALUE pairs), it's a manifest-level flag with its
552// own validation and its own bundle, so it gets its own verb surface
553// instead of overloading env set's KEY=value parsing with a special-cased
554// key name.
555//
556// --environment (issue #195) writes the flag into that bundle instead of
557// the app default. The VOCABULARY changes with the scope, and that is the
558// point: at app scope the second word is "off", because clearing the
559// default bundle's field really does stop delivery; in a named bundle it
560// is "inherit", because clearing it there hands the question back to the
561// app default. Spelling the second case "off" would read as "stop
562// delivering" while doing the opposite whenever the app default is on —
563// exactly the kind of quiet lie this feature exists to avoid — so "off"
564// is refused there, by name, rather than accepted as a synonym.
565func runEnvExecDelivery(args []string) {
566 fs := flag.NewFlagSet("env exec-delivery", flag.ExitOnError)
567 messageFlags := addMutationMessageFlags(fs)
568 app := appFlag(fs, "")
569 environment := environmentFlag(fs,
570 "named environment bundle to set the flag in (omit for the app's default bundle)")
571 acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs)
572 consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)")
573 fs.Usage = flagUsage(fs, "")
574 fs.Parse(args)
575 usage := "usage: carlos env exec-delivery --app <a> [--console <origin>] file|off\n" +
576 " or: carlos env exec-delivery --app <a> --environment <name> [--console <origin>] file|inherit"
577 if fs.NArg() != 1 {
578 log.Fatal(usage)
579 }
580 checkEnvironmentFlag(*environment)
581 // One stored value either way — "" clears the field. What differs is
582 // which SECOND WORD spells that clear, and which bundle it clears in.
583 // cleared carries the operator's own word through to the wire and to
584 // the confirmation line, so the two never paraphrase each other.
585 var value, cleared string
586 switch {
587 case fs.Arg(0) == appconfig.ExecDeliveryFile:
588 value = appconfig.ExecDeliveryFile
589 case *environment == "" && fs.Arg(0) == "off":
590 value, cleared = "", "off"
591 case *environment != "" && fs.Arg(0) == "inherit":
592 value, cleared = "", "inherit"
593 case *environment != "" && fs.Arg(0) == "off":
594 log.Fatalf(`carlos env exec-delivery: "off" is not a value for a named environment — say "inherit" instead. `+
595 `Clearing exec_delivery in a bundle does not turn delivery off, it hands the question back to the app's `+
596 `default bundle (config.json), so a route bound to %q keeps getting files whenever the app-level flag is `+
597 `on. To stop delivery for the whole app: carlos env exec-delivery --app %s off`, *environment, bundleAppName(*app))
598 default:
599 want := `"off"`
600 if *environment != "" {
601 want = `"inherit"`
602 }
603 log.Fatalf("carlos env exec-delivery: %q is not a valid value — want %q or %s", fs.Arg(0), appconfig.ExecDeliveryFile, want)
604 }
605
606 sess, err := resolveMode(*consoleFlag)
607 if err != nil {
608 log.Fatal(err)
609 }
610 appName := *app
611 // Turning delivery ON is the write that creates a route's file for the
612 // first time, with the same never-bounced process on the other end of
613 // it, so it carries the same notice as a var write. No keys are passed:
614 // there is nothing to shadow here, and the file appears (or does not)
615 // regardless of which vars the bundle happens to hold.
616 var restart *restartAnswer
617 if sess != nil {
618 printUsing(os.Stdout, sess)
619 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
620 appName = resolvedApp(sess, acct, *app, usage)
621 // The wire vocabulary is a WORD, never "" — the console's PUT
622 // decodes ExecDelivery as a pointer where nil means "no change", so
623 // clearing must still be spelled. The word sent is the operator's
624 // own: the console refuses "off" for a named bundle exactly as this
625 // function does, so relaying it verbatim keeps the two refusals
626 // identical rather than letting one paraphrase the other.
627 wire := cleared
628 if value != "" {
629 wire = value
630 }
631 message, merr := resolveMutationMessage(fs, messageFlags)
632 if merr != nil {
633 log.Fatalf("env exec-delivery: %v", merr)
634 }
635 res, err := sess.configApply(context.Background(), acct, appName,
636 apiConfigApply{Environment: *environment, ExecDelivery: &wire, Message: message})
637 if err != nil {
638 log.Fatal(err)
639 }
640 restart = restartFromAPI(res.Restart)
641 } else {
642 if *app == "" {
643 log.Fatal(usage)
644 }
645 store := openStore()
646 if err := appconfig.SetExecDeliveryAt(context.Background(), store, acctOf(), *app, *environment, value); err != nil {
647 log.Fatal(err)
648 }
649 restart = localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, nil)
650 }
651 word := value
652 if value == "" {
653 word = cleared
654 }
655 fmt.Printf("%s %s for %s (%s)\n", colorFor(os.Stdout).ok("exec-delivery"), word, appName, bundleLabel(*environment))
656 printRestartNotice(os.Stdout, appName, restart)
657}
658
659// bundleAppName renders --app for a refusal printed BEFORE the app has been
660// resolved (the refusals above run at flag-parse time, ahead of
661// resolvedApp): an omitted --app must not turn the suggested command into
662// one with an empty argument.
663func bundleAppName(app string) string {
664 if app == "" {
665 return "<app>"
666 }
667 return app
668}
669
670func runEnvEnvironments(args []string) {
671 fs := flag.NewFlagSet("env environments", flag.ExitOnError)
672 app := appFlag(fs, "")
673 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
674 consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)")
675 fs.Usage = flagUsage(fs, "")
676 fs.Parse(args)
677 usage := "usage: carlos env environments --app <a> [--console <origin>]"
678
679 sess, err := resolveMode(*consoleFlag)
680 if err != nil {
681 log.Fatal(err)
682 }
683 if sess != nil {
684 printUsing(os.Stdout, sess)
685 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
686 app2 := resolvedApp(sess, acct, *app, usage)
687 cfg, err := sess.configGet(context.Background(), acct, app2, "")
688 if err != nil {
689 log.Fatal(err)
690 }
691 for _, n := range cfg.Environments {
692 fmt.Println(n)
693 }
694 return
695 }
696
697 if *app == "" {
698 log.Fatal(usage)
699 }
700 names, err := appconfig.ListEnvironments(context.Background(), openStore(), acctOf(), *app)
701 if err != nil {
702 log.Fatal(err)
703 }
704 for _, n := range names {
705 fmt.Println(n)
706 }
707}
708
709// runEnvSync is `carlos env sync` (issue #44) — the escape hatch for a
710// config write that needs to reach a running instance faster than
711// configSyncLoop's hourly floor: it touches apps/<acct>/<app>/config-gen.json
712// (appconfig.GenKey), which the agent's configGenLoop polls every ~3s and
713// treats a moved etag as "run SyncExec now" for the app's exec-backed
714// instances and "ask the root config pass to run now" (the marker file
715// carlos-adopt.path watches) for its unit-backed ones, so both shapes
716// converge in seconds on a box whose path unit is armed — and the unit half
717// falls back to the hourly pass on a box where it isn't, which is why the
718// printed line says so rather than promising seconds outright. It never
719// restarts a box's shared edge service and never targets a bundle directly
720// — the generation
721// object covers every bundle an app has (default plus every environment) in
722// one touch, matching TouchGeneration's own "app granularity, not
723// per-bundle" design (generation.go).
724func runEnvSync(args []string) {
725 fs := flag.NewFlagSet("env sync", flag.ExitOnError)
726 app := appFlag(fs, "")
727 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
728 consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)")
729 fs.Usage = flagUsage(fs, "")
730 fs.Parse(args)
731 usage := "usage: carlos env sync --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
732
733 sess, err := resolveMode(*consoleFlag)
734 if err != nil {
735 log.Fatal(err)
736 }
737 appName := *app
738 if sess != nil {
739 printUsing(os.Stdout, sess)
740 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
741 appName = resolvedApp(sess, acct, *app, usage)
742 if err := sess.configSync(context.Background(), acct, appName); err != nil {
743 log.Fatal(err)
744 }
745 } else {
746 if *app == "" {
747 log.Fatal(usage)
748 }
749 if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil {
750 log.Fatal(err)
751 }
752 }
753 // The files, not the processes. The old wording promised that
754 // "exec-backed instances converge within seconds", which is true of the
755 // file and false of the program reading it for exactly the route shape
756 // discussion #16 was filed about — and this is the command people run
757 // while waiting for a change to take effect, so overstating it sends
758 // them off to debug the wrong thing.
759 fmt.Printf("%s for %s the files land within seconds; programs carlos owns restart themselves, "+
760 "and anything else keeps its old values until you restart it\n",
761 colorFor(os.Stdout).ok("config sync requested"), appName)
762}
763
764const secretsUsage = `usage: carlos secrets <command>
765
766commands:
767 set --app <a> [--environment <name>] KEY=value [KEY2=value2 ...] seal and set one or more secrets
768 unset --app <a> [--environment <name>] KEY [KEY2 ...] remove secrets
769 list --app <a> [--environment <name>] list a secret's key names only — never values
770 genkey mint a CARLOS_SECRETS_KEY/PUBKEY pair
771
772--environment names a bundle at apps/<acct>/<app>/environments/<name>.json —
773see "carlos env" for the full explanation; the same bundle backs both env
774vars and secrets, sealed or not. "carlos env environments --app <a>" lists
775an app's bundle names.
776
777set/unset/list also work through a logged-in console (carlos auth login)
778when CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are unset —
779[--console <origin>] picks which logged-in console when more than one is
780configured; the store env vars win outright when set (direct-bucket mode,
781unchanged). In that mode "secrets set" seals with CARLOS_SECRETS_PUBKEY if
782it's set in this terminal's own environment, otherwise the console's own
783served public key (Config.SecretsPub) if it has one — CARLOS_SECRETS_KEY
784(the PRIVATE half) never leaves the box holding it either way. genkey is
785always local, in both modes.
786`
787
788// RunSecrets is the `carlos secrets` verb.
789func RunSecrets(args []string) {
790 if len(args) == 0 {
791 fmt.Fprint(os.Stderr, secretsUsage)
792 os.Exit(1)
793 }
794 switch args[0] {
795 case "set":
796 runSecretsSet(args[1:])
797 case "unset":
798 runSecretsUnset(args[1:])
799 case "list":
800 runSecretsList(args[1:])
801 case "genkey":
802 runSecretsGenkey(args[1:])
803 default:
804 fmt.Fprint(os.Stderr, secretsUsage)
805 os.Exit(1)
806 }
807}
808
809// secretsPubKey reads CARLOS_SECRETS_PUBKEY — distinct from cli.go's
810// publicKey()/signingKey(), which are the unrelated release signing pair.
811func secretsPubKey() *ecdh.PublicKey {
812 k, err := appconfig.ParsePublicKey(os.Getenv("CARLOS_SECRETS_PUBKEY"), "CARLOS_SECRETS_PUBKEY")
813 if err != nil {
814 log.Fatal(err)
815 }
816 return k
817}
818
819// secretsPubKeyAPI resolves the sealing key for API-mode `secrets set`:
820// CARLOS_SECRETS_PUBKEY in THIS terminal's own environment wins — matching
821// secretsPubKey's direct-mode rule exactly, so the same env var works
822// whichever mode a script happens to run in — otherwise the console's own
823// served public key (Config.SecretsPub, GET config's "secrets_pubkey"
824// field). That fallback is what makes `carlos auth login` alone sufficient
825// for `secrets set` (spec decision 2): public material by design, it only
826// encrypts. Neither present: a fatal naming both fixes — CARLOS_SECRETS_KEY
827// (the PRIVATE half) never leaves the box holding it, so there is no third
828// way to get a key here.
829//
830// It also returns the account sqid to bind the envelopes to, which only the
831// console can supply (the flag may be a name), so the GET is made even when
832// the key comes from the environment. "" means the console does not write
833// bound envelopes (switched off, or too old); the caller seals v1 then.
834func secretsPubKeyAPI(sess *apiSession, account apiAcct, app string) (*ecdh.PublicKey, string) {
835 cfg, err := sess.configGet(context.Background(), account, app, "")
836 if err != nil {
837 log.Fatal(err)
838 }
839 if raw := os.Getenv("CARLOS_SECRETS_PUBKEY"); raw != "" {
840 k, err := appconfig.ParsePublicKey(raw, "CARLOS_SECRETS_PUBKEY")
841 if err != nil {
842 log.Fatal(err)
843 }
844 return k, cfg.SecretsAccount
845 }
846 if cfg.SecretsPubkey == "" {
847 log.Fatal("no CARLOS_SECRETS_PUBKEY in env and this console does not serve one — " +
848 "set the env var, or set CARLOS_SECRETS_PUBKEY in the console's environment")
849 }
850 k, err := appconfig.ParsePublicKey(cfg.SecretsPubkey, "console secrets_pubkey")
851 if err != nil {
852 log.Fatal(err)
853 }
854 return k, cfg.SecretsAccount
855}
856
857// secretsEnvelopeV2Env reads CONSOLE_SECRETS_ENVELOPE_V2 for direct-bucket
858// `secrets set`, with the console's own rule: unset is false, and a value
859// that is not a boolean stops rather than guessing.
860func secretsEnvelopeV2Env() bool {
861 v := strings.TrimSpace(os.Getenv("CONSOLE_SECRETS_ENVELOPE_V2"))
862 if v == "" {
863 return false
864 }
865 b, err := strconv.ParseBool(v)
866 if err != nil {
867 log.Fatalf("CONSOLE_SECRETS_ENVELOPE_V2 %q is not a boolean (use true or false)", v)
868 }
869 return b
870}
871
872// sealSecretAPI seals one value for API-mode `secrets set`: bound to
873// (account, app) when the console named the account — which it does only
874// while its deployment writes v2 (CONSOLE_SECRETS_ENVELOPE_V2) — and v1
875// otherwise, which every box and every console door accepts.
876func sealSecretAPI(pub *ecdh.PublicKey, account, app string, v []byte) (string, error) {
877 if account == "" {
878 return appconfig.SealUnbound(pub, v)
879 }
880 return appconfig.SealBound(pub, appconfig.Binding{Account: account, App: app}, v)
881}
882
883func runSecretsSet(args []string) {
884 fs := flag.NewFlagSet("secrets set", flag.ExitOnError)
885 app := appFlag(fs, "")
886 environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)")
887 acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs)
888 consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)")
889 fs.Usage = flagUsage(fs, "")
890 fs.Parse(args)
891 usage := "usage: carlos secrets set --app <a> [--environment <name>] [--console <origin>] KEY=value [KEY2=value2 ...]"
892 checkEnvironmentFlag(*environment)
893 kv, err := parseKV(fs.Args())
894 if err != nil {
895 log.Fatalf("secrets set: %v", err)
896 }
897 // Empty values first, and as a group: they are the likeliest thing to
898 // be wrong about a `secrets set` invocation and the cheapest to catch,
899 // and a caller who mistyped one field name has usually mistyped
900 // several. Discussion #16 — values arrive as "$(op read ...)", `op
901 // read` on a field that does not exist prints nothing and exits 0, so
902 // the shell hands us an empty string, we seal it, store it, and report
903 // success. The symptom lands hours later somewhere else entirely: with
904 // both halves of a credential pair empty the app reads "not
905 // configured" and silently offers no OAuth provider; with one empty it
906 // refuses to boot.
907 //
908 // Keys only, never values — parseKV's rule above, for the same reason:
909 // a mistyped `secrets set` argument IS a secret, and this text lands in
910 // stderr and shell scrollback.
911 //
912 // The console door refuses these too (appconfig.CheckEnvelope, which
913 // reads emptiness off the envelope's length). This check exists in
914 // front of it because it is the ONLY one in direct mode, because it
915 // fires before the network round-trip that fetches the sealing key,
916 // and because it can name every offending key at once where the door,
917 // which validates var by var, names the first.
918 if err := checkNoEmptyValues(kv); err != nil {
919 log.Fatal(err)
920 }
921 // Validate every value while the plaintext is still in hand, before
922 // either branch below seals anything — after Seal the value is opaque
923 // forever (`secrets list` never shows values), so a bad one would only
924 // surface as a render failure in the hourly adopt log, stalling the
925 // app's entire config with nothing for the operator to inspect.
926 for k, v := range kv {
927 if err := appconfig.ValidVarValue(v); err != nil {
928 log.Fatalf("secrets set: %s: %v", k, err)
929 }
930 }
931
932 sess, err := resolveMode(*consoleFlag)
933 if err != nil {
934 log.Fatal(err)
935 }
936 if sess != nil {
937 printUsing(os.Stdout, sess)
938 // Resolved ONCE, used for both the seal key and the write: two
939 // resolutions here could answer differently (the chooser asked
940 // twice), sealing the value with one account's public key and
941 // writing it into another's config — an undecryptable secret whose
942 // only symptom is a render failure in the hourly adopt log.
943 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
944 app2 := resolvedApp(sess, acct, *app, usage)
945 pub, sealAcct := secretsPubKeyAPI(sess, acct, app2)
946 set := make(map[string]appconfig.Var, len(kv))
947 for k, v := range kv {
948 env, err := sealSecretAPI(pub, sealAcct, app2, []byte(v))
949 if err != nil {
950 log.Fatalf("seal %s: %v", k, err)
951 }
952 set[k] = appconfig.Var{Kind: "secret", Envelope: env}
953 }
954 res, err := sess.configApply(context.Background(), acct, app2,
955 apiConfigApply{Environment: *environment, Set: set})
956 if err != nil {
957 log.Fatal(err)
958 }
959 fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), app2, bundleLabel(*environment))
960 printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart))
961 return
962 }
963
964 if *app == "" {
965 log.Fatal(usage)
966 }
967 pub := secretsPubKey()
968 acct := acctOf()
969 // Direct-bucket mode has no console to ask, so the deployment's switch
970 // comes from the operator's environment under the console's own name.
971 appconfig.SetBoundSealing(secretsEnvelopeV2Env())
972 sealed := make(map[string]string, len(kv))
973 for k, v := range kv {
974 env, err := appconfig.Seal(pub, appconfig.Binding{Account: acct, App: *app}, []byte(v))
975 if err != nil {
976 log.Fatalf("seal %s: %v", k, err)
977 }
978 sealed[k] = env
979 }
980 store := openStore()
981 if err := saveSecrets(context.Background(), store, acct, *app, *environment, sealed); err != nil {
982 log.Fatal(err)
983 }
984 fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment))
985 printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, mapKeys(kv)))
986}
987
988func runSecretsUnset(args []string) {
989 fs := flag.NewFlagSet("secrets unset", flag.ExitOnError)
990 app := appFlag(fs, "")
991 environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)")
992 acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs)
993 consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)")
994 fs.Usage = flagUsage(fs, "")
995 fs.Parse(args)
996 usage := "usage: carlos secrets unset --app <a> [--environment <name>] [--console <origin>] KEY [KEY2 ...]"
997 if fs.NArg() == 0 {
998 log.Fatal(usage)
999 }
1000 checkEnvironmentFlag(*environment)
1001
1002 sess, err := resolveMode(*consoleFlag)
1003 if err != nil {
1004 log.Fatal(err)
1005 }
1006 if sess != nil {
1007 printUsing(os.Stdout, sess)
1008 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1009 app2 := resolvedApp(sess, acct, *app, usage)
1010 res, err := sess.configApply(context.Background(), acct, app2,
1011 apiConfigApply{Environment: *environment, Unset: fs.Args()})
1012 if err != nil {
1013 log.Fatal(err)
1014 }
1015 fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), res.Removed, fs.NArg(), app2, bundleLabel(*environment))
1016 printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart))
1017 return
1018 }
1019
1020 if *app == "" {
1021 log.Fatal(usage)
1022 }
1023 store := openStore()
1024 removed, err := unsetVars(context.Background(), store, acctOf(), *app, *environment, fs.Args())
1025 if err != nil {
1026 log.Fatal(err)
1027 }
1028 fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment))
1029 // Gated on removed, exactly as `env unset`'s direct branch is.
1030 if removed > 0 {
1031 printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, fs.Args()))
1032 }
1033}
1034
1035// printSecretsList renders `secrets list`'s output — header line, then
1036// sorted key names only, never values — byte-identical between the direct
1037// and API branches of runSecretsList, which differ only in HOW they fetch
1038// the key set.
1039func printSecretsList(app, environment string, keys []string) {
1040 fmt.Printf("%s\n", colorFor(os.Stdout).head(fmt.Sprintf("%s (%s):", app, bundleLabel(environment))))
1041 sort.Strings(keys)
1042 for _, k := range keys {
1043 fmt.Println(k)
1044 }
1045}
1046
1047func runSecretsList(args []string) {
1048 fs := flag.NewFlagSet("secrets list", flag.ExitOnError)
1049 app := appFlag(fs, "")
1050 environment := environmentFlag(fs, "named environment bundle to list (omit for the default bundle)")
1051 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
1052 consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)")
1053 fs.Usage = flagUsage(fs, "")
1054 fs.Parse(args)
1055 usage := "usage: carlos secrets list --app <a> [--environment <name>] [--console <origin>]"
1056 checkEnvironmentFlag(*environment)
1057
1058 sess, err := resolveMode(*consoleFlag)
1059 if err != nil {
1060 log.Fatal(err)
1061 }
1062 if sess != nil {
1063 printUsing(os.Stdout, sess)
1064 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1065 app2 := resolvedApp(sess, acct, *app, usage)
1066 cfg, err := sess.configGet(context.Background(), acct, app2, *environment)
1067 if err != nil {
1068 log.Fatal(err)
1069 }
1070 var keys []string
1071 for _, v := range cfg.Vars {
1072 if v.Kind == "secret" {
1073 keys = append(keys, v.Key)
1074 }
1075 }
1076 printSecretsList(app2, *environment, keys)
1077 return
1078 }
1079
1080 if *app == "" {
1081 log.Fatal(usage)
1082 }
1083 m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment)
1084 if err != nil {
1085 log.Fatal(err)
1086 }
1087 var keys []string
1088 for k, v := range m.Vars {
1089 if v.Kind == "secret" {
1090 keys = append(keys, k)
1091 }
1092 }
1093 printSecretsList(*app, *environment, keys)
1094}
1095
1096func runSecretsGenkey(args []string) {
1097 priv, pub, err := appconfig.GenerateKey()
1098 if err != nil {
1099 log.Fatal(err)
1100 }
1101 fmt.Printf("CARLOS_SECRETS_KEY=%s\nCARLOS_SECRETS_PUBKEY=%s\n", priv, pub)
1102 fmt.Fprintln(os.Stderr, "store the KEY in your secret manager and in every host's host.env; "+
1103 "the PUBKEY can be held widely — it only encrypts")
1104 fmt.Fprintln(os.Stderr, "note: config sync reaches unit-backed routes (carlos-app@) via the root adopt pass, "+
1105 "and exec-backed instance routes via the agent's in-process loop (materialized to /data/<host>.config.env; "+
1106 "the instance's own /data/<host>.env always wins conflicts)")
1107}
1108