| 1 | // Package appconfig is the per-app config/secrets plane: a bucket-backed |
| 2 | // manifest of plain env vars and sealed secrets (internal/release parallel — |
| 3 | // see its doc comment — sharing only the underlying Store), a native |
| 4 | // seal/open primitive, and the host-side render/sync that materializes a |
| 5 | // manifest into the EnvironmentFile carlos-app@.service already reads. |
| 6 | // |
| 7 | // Bucket layout (deployment bucket, account-scoped like every other app key |
| 8 | // internal/release writes): |
| 9 | // |
| 10 | // apps/<acct>/<app>/config.json CAS-guarded config manifest |
| 11 | // apps/<acct>/<app>/environments/<name>.json named environment overlay |
| 12 | // apps/<acct>/<app>/instances-config/<host>.json one instance's overlay |
| 13 | // |
| 14 | // The three resolve default < environment < instance, per key, and only a |
| 15 | // caller that names a host gets the third (LoadMergedHost, instances.go). |
| 16 | package appconfig |
| 17 | |
| 18 | import ( |
| 19 | "regexp" |
| 20 | |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | // Manifest holds every config var and secret an app has set, together so a |
| 25 | // host materializes both with one bucket fetch. |
| 26 | type Manifest struct { |
| 27 | Schema int `json:"schema"` // 1 |
| 28 | App string `json:"app"` |
| 29 | Vars map[string]Var `json:"vars"` |
| 30 | // ExecDelivery is the app's opt-in for issue #18's third SyncExec |
| 31 | // eligibility case: a TCP-addr exec route (Backing == "", Socket == "", |
| 32 | // Addr or Addrs set) whose process carlos does not spawn or own — an |
| 33 | // app-supervised instance family (titogo's router fleet is the |
| 34 | // motivating case). "" (the zero value, omitted from JSON) means not |
| 35 | // opted in; ExecDeliveryFile ("file") means SyncExec writes this app's |
| 36 | // routes' merged config to <dataDir>/<host>.config.env exactly as it |
| 37 | // does for socket-exec routes — but NEVER bounces them, since carlos |
| 38 | // does not own the process (see syncexec.go). |
| 39 | // |
| 40 | // Additive to schema 1 (schema is not bumped for this field), so any |
| 41 | // other value is refused at SET time (SetExecDeliveryAt, |
| 42 | // ValidExecDelivery) and, since an older binary must still be able to |
| 43 | // parse a manifest a newer one wrote, ignored — loudly, once per pass — |
| 44 | // at SYNC time (see SyncExec's checkOptedIn) rather than causing a hard |
| 45 | // failure. An older binary reading a NAMED bundle this one wrote the |
| 46 | // field into ignores it entirely (it consults the default bundle |
| 47 | // alone), which is the pre-#195 behaviour, not a fault. |
| 48 | // |
| 49 | // BUNDLE-LEVEL, resolved as an overlay (issue #195, ruled 2026-08-16). |
| 50 | // SyncExec reads this field from the bundle a route's config actually |
| 51 | // comes from: a route bound to environment e takes e's value when e |
| 52 | // STATES one, and inherits the default bundle's when it does not. An |
| 53 | // unbound route reads the default bundle alone, exactly as before. |
| 54 | // LoadMerged still never carries this field into its merged result — |
| 55 | // the merged manifest is what gets rendered into a file, and a delivery |
| 56 | // flag has no business appearing there; checkOptedIn reads the two |
| 57 | // bundles' fields directly instead. |
| 58 | // |
| 59 | // The 2026-08-03 ruling this narrows said a named environment's |
| 60 | // manifest is never a valid place for the field, because "the reader is |
| 61 | // the app's own supervisor, one program per app". That premise is false |
| 62 | // in the deployment the feature was built for: the titogo app |
| 63 | // supervises its relay and its router fleet through different units, so |
| 64 | // the app-level flag thrown to deliver one sealed credential to the |
| 65 | // relay wrote a file for every router too. |
| 66 | // |
| 67 | // STILL NOT PER-ROUTE. A per-route flag would need a registry column |
| 68 | // and — since a Provisioned route's Channel and Environment belong to |
| 69 | // its instance record — a record field and reconcile convergence too: |
| 70 | // three doors onto one bit. The "config-that-lies" hazard the original |
| 71 | // ruling named is real at that granularity, where two routes resolving |
| 72 | // through the SAME bundle could disagree about whether it is delivered. |
| 73 | // The environment binding is one door onto a scope that already exists, |
| 74 | // and is the same axis the secrets themselves are scoped on. |
| 75 | ExecDelivery string `json:"exec_delivery,omitempty"` |
| 76 | |
| 77 | // Swap and SwapConcurrency are the app's swap policy (zero-downtime spec |
| 78 | // §4.1): how an awake instance moves to a new generation ("overlap", the |
| 79 | // default, or "hold"), and how many swaps of this app may run at once on |
| 80 | // one box after a cohort's canary (0 means the default, 1). App-level: |
| 81 | // boxes read them from the DEFAULT bundle only, never from a named |
| 82 | // environment. Additive to schema 1, like ExecDelivery: absent from every |
| 83 | // manifest written before them and omitted when zero, so a manifest with |
| 84 | // no policy marshals byte for byte as before. Written by plan 6's doors, |
| 85 | // which refuse values ValidSwap and ValidSwapConcurrency reject; a box |
| 86 | // that reads a value it does not know runs in hold (spec §4.2). |
| 87 | Swap string `json:"swap,omitempty"` |
| 88 | SwapConcurrency int `json:"swap_concurrency,omitempty"` |
| 89 | } |
| 90 | |
| 91 | // ExecDeliveryFile is the only non-empty value ValidExecDelivery accepts |
| 92 | // today for Manifest.ExecDelivery — see its doc comment. |
| 93 | const ExecDeliveryFile = "file" |
| 94 | |
| 95 | // ValidExecDelivery reports whether v is a value this binary understands |
| 96 | // for Manifest.ExecDelivery: "" (not opted in) or ExecDeliveryFile. Shared |
| 97 | // by the set-time refusal (SetExecDelivery, appconfig_cli.go's |
| 98 | // runEnvExecDelivery) and SyncExec's sync-time ignore-with-a-log, so the |
| 99 | // two can never define "valid" differently. |
| 100 | func ValidExecDelivery(v string) bool { |
| 101 | return v == "" || v == ExecDeliveryFile |
| 102 | } |
| 103 | |
| 104 | // The two swap policies of zero-downtime spec §4.1. |
| 105 | const ( |
| 106 | SwapOverlap = "overlap" |
| 107 | SwapHold = "hold" |
| 108 | ) |
| 109 | |
| 110 | // ValidSwap reports whether v is a swap policy this binary understands: unset, |
| 111 | // overlap or hold. |
| 112 | func ValidSwap(v string) bool { return v == "" || v == SwapOverlap || v == SwapHold } |
| 113 | |
| 114 | // ValidSwapConcurrency reports whether n is a storable swap_concurrency: 0 |
| 115 | // (unset, meaning 1) or any positive count. |
| 116 | func ValidSwapConcurrency(n int) bool { return n >= 0 } |
| 117 | |
| 118 | // Var is one config var or secret. Kind selects which of Value/Envelope is |
| 119 | // populated: "env" vars are plaintext (Value); "secret" vars are sealed |
| 120 | // client-side before upload (Envelope, base64 — see crypto.go's Seal). |
| 121 | type Var struct { |
| 122 | Kind string `json:"kind"` |
| 123 | Value string `json:"value,omitempty"` |
| 124 | Envelope string `json:"envelope,omitempty"` |
| 125 | } |
| 126 | |
| 127 | // Key is the bucket key of app's config manifest, account-scoped by |
| 128 | // deriving it from release.AppPrefix rather than restating its shape (app |
| 129 | // names are unique only within an account) — a manifest has to sit under |
| 130 | // the same prefix as everything else the app owns, permanently. |
| 131 | func Key(acct, app string) string { |
| 132 | return release.AppPrefix(acct, app) + "config.json" |
| 133 | } |
| 134 | |
| 135 | // varNameRe: a leading letter or underscore, then letters/digits/underscore |
| 136 | // — the shape every shell and systemd's EnvironmentFile parser expect a |
| 137 | // variable name to have. Case is not restricted (systemd doesn't care). |
| 138 | var varNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) |
| 139 | |
| 140 | // ValidVarName reports whether k is usable as an env/secret var name. |
| 141 | func ValidVarName(k string) bool { return varNameRe.MatchString(k) } |
| 142 | |