carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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).
16package appconfig
17
18import (
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.
26type 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.
93const 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.
100func ValidExecDelivery(v string) bool {
101 return v == "" || v == ExecDeliveryFile
102}
103
104// The two swap policies of zero-downtime spec §4.1.
105const (
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.
112func 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.
116func 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).
121type 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.
131func 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).
138var varNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
139
140// ValidVarName reports whether k is usable as an env/secret var name.
141func ValidVarName(k string) bool { return varNameRe.MatchString(k) }
142