// Package appconfig is the per-app config/secrets plane: a bucket-backed // manifest of plain env vars and sealed secrets (internal/release parallel — // see its doc comment — sharing only the underlying Store), a native // seal/open primitive, and the host-side render/sync that materializes a // manifest into the EnvironmentFile carlos-app@.service already reads. // // Bucket layout (deployment bucket, account-scoped like every other app key // internal/release writes): // // apps///config.json CAS-guarded config manifest // apps///environments/.json named environment overlay // apps///instances-config/.json one instance's overlay // // The three resolve default < environment < instance, per key, and only a // caller that names a host gets the third (LoadMergedHost, instances.go). package appconfig import ( "regexp" "github.com/carlosframework/platform/internal/release" ) // Manifest holds every config var and secret an app has set, together so a // host materializes both with one bucket fetch. type Manifest struct { Schema int `json:"schema"` // 1 App string `json:"app"` Vars map[string]Var `json:"vars"` // ExecDelivery is the app's opt-in for issue #18's third SyncExec // eligibility case: a TCP-addr exec route (Backing == "", Socket == "", // Addr or Addrs set) whose process carlos does not spawn or own — an // app-supervised instance family (titogo's router fleet is the // motivating case). "" (the zero value, omitted from JSON) means not // opted in; ExecDeliveryFile ("file") means SyncExec writes this app's // routes' merged config to /.config.env exactly as it // does for socket-exec routes — but NEVER bounces them, since carlos // does not own the process (see syncexec.go). // // Additive to schema 1 (schema is not bumped for this field), so any // other value is refused at SET time (SetExecDeliveryAt, // ValidExecDelivery) and, since an older binary must still be able to // parse a manifest a newer one wrote, ignored — loudly, once per pass — // at SYNC time (see SyncExec's checkOptedIn) rather than causing a hard // failure. An older binary reading a NAMED bundle this one wrote the // field into ignores it entirely (it consults the default bundle // alone), which is the pre-#195 behaviour, not a fault. // // BUNDLE-LEVEL, resolved as an overlay (issue #195, ruled 2026-08-16). // SyncExec reads this field from the bundle a route's config actually // comes from: a route bound to environment e takes e's value when e // STATES one, and inherits the default bundle's when it does not. An // unbound route reads the default bundle alone, exactly as before. // LoadMerged still never carries this field into its merged result — // the merged manifest is what gets rendered into a file, and a delivery // flag has no business appearing there; checkOptedIn reads the two // bundles' fields directly instead. // // The 2026-08-03 ruling this narrows said a named environment's // manifest is never a valid place for the field, because "the reader is // the app's own supervisor, one program per app". That premise is false // in the deployment the feature was built for: the titogo app // supervises its relay and its router fleet through different units, so // the app-level flag thrown to deliver one sealed credential to the // relay wrote a file for every router too. // // STILL NOT PER-ROUTE. A per-route flag would need a registry column // and — since a Provisioned route's Channel and Environment belong to // its instance record — a record field and reconcile convergence too: // three doors onto one bit. The "config-that-lies" hazard the original // ruling named is real at that granularity, where two routes resolving // through the SAME bundle could disagree about whether it is delivered. // The environment binding is one door onto a scope that already exists, // and is the same axis the secrets themselves are scoped on. ExecDelivery string `json:"exec_delivery,omitempty"` // Swap and SwapConcurrency are the app's swap policy (zero-downtime spec // §4.1): how an awake instance moves to a new generation ("overlap", the // default, or "hold"), and how many swaps of this app may run at once on // one box after a cohort's canary (0 means the default, 1). App-level: // boxes read them from the DEFAULT bundle only, never from a named // environment. Additive to schema 1, like ExecDelivery: absent from every // manifest written before them and omitted when zero, so a manifest with // no policy marshals byte for byte as before. Written by plan 6's doors, // which refuse values ValidSwap and ValidSwapConcurrency reject; a box // that reads a value it does not know runs in hold (spec §4.2). Swap string `json:"swap,omitempty"` SwapConcurrency int `json:"swap_concurrency,omitempty"` } // ExecDeliveryFile is the only non-empty value ValidExecDelivery accepts // today for Manifest.ExecDelivery — see its doc comment. const ExecDeliveryFile = "file" // ValidExecDelivery reports whether v is a value this binary understands // for Manifest.ExecDelivery: "" (not opted in) or ExecDeliveryFile. Shared // by the set-time refusal (SetExecDelivery, appconfig_cli.go's // runEnvExecDelivery) and SyncExec's sync-time ignore-with-a-log, so the // two can never define "valid" differently. func ValidExecDelivery(v string) bool { return v == "" || v == ExecDeliveryFile } // The two swap policies of zero-downtime spec §4.1. const ( SwapOverlap = "overlap" SwapHold = "hold" ) // ValidSwap reports whether v is a swap policy this binary understands: unset, // overlap or hold. func ValidSwap(v string) bool { return v == "" || v == SwapOverlap || v == SwapHold } // ValidSwapConcurrency reports whether n is a storable swap_concurrency: 0 // (unset, meaning 1) or any positive count. func ValidSwapConcurrency(n int) bool { return n >= 0 } // Var is one config var or secret. Kind selects which of Value/Envelope is // populated: "env" vars are plaintext (Value); "secret" vars are sealed // client-side before upload (Envelope, base64 — see crypto.go's Seal). type Var struct { Kind string `json:"kind"` Value string `json:"value,omitempty"` Envelope string `json:"envelope,omitempty"` } // Key is the bucket key of app's config manifest, account-scoped by // deriving it from release.AppPrefix rather than restating its shape (app // names are unique only within an account) — a manifest has to sit under // the same prefix as everything else the app owns, permanently. func Key(acct, app string) string { return release.AppPrefix(acct, app) + "config.json" } // varNameRe: a leading letter or underscore, then letters/digits/underscore // — the shape every shell and systemd's EnvironmentFile parser expect a // variable name to have. Case is not restricted (systemd doesn't care). var varNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) // ValidVarName reports whether k is usable as an env/secret var name. func ValidVarName(k string) bool { return varNameRe.MatchString(k) }