carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "crypto/ecdh"
6 "errors"
7 "log"
8 "sort"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "time"
13
14 "github.com/carlosframework/platform/internal/appconfig"
15 "github.com/carlosframework/platform/internal/registry"
16 "github.com/carlosframework/platform/internal/release"
17 "github.com/carlosframework/platform/internal/status"
18)
19
20// The config guard (amadan #213). A box takes an instance's lease, or mints
21// its record, only if it can provide that instance's config.
22//
23// The failure it ends: a box with no CARLOS_SECRETS_KEY (or the wrong one)
24// still admitted and woke socket-backed shared-pool instances in its region.
25// Its config sync loops are off without the key, and ExecBackend.instanceEnv
26// treats a missing <host>.config.env as optional, so the instance started
27// without its secrets or store config. kpmg was down 1h54m on 2026-10-01
28// because the UK box had no key. The console cannot prove what reconcile will
29// admit, so the invariant lives here, at admission.
30//
31// One guard per process, built from CARLOS_SECRETS_KEY and the deploy store,
32// and asked through Gate from three places, each BEFORE the side effect it
33// guards: activator.ExecBackend.Acquire (before lease.Acquire),
34// provision.Reconcile and provision.ReconcileSidecars (before a mint). The
35// decision itself is appconfig.CanProvideConfig; this type adds what that
36// pure function cannot: the box's key, the store, a short refusal cache, the
37// once-per-host log and the list the status snapshot publishes.
38
39const (
40 // configGuardRefuseTTL bounds how long a refusal is answered from memory.
41 // A request storm on a refused host would otherwise cost a set of bucket
42 // reads per request, and the reconcile pass asks about every unminted
43 // record every tick. It is also the retry interval after a store error:
44 // the box can tell again within this long of the store coming back. An
45 // admission is never cached: wakes are rare, and the answer must be
46 // current when it is taken.
47 configGuardRefuseTTL = 30 * time.Second
48 // configGuardListCap bounds the refusal list in the status snapshot; the
49 // total is published beside it so a capped list reads as long.
50 configGuardListCap = 100
51 // configGuardRefusalMaxAge is how long a host stays in the published list
52 // without being asked about again. A wake-time refusal is only re-asked
53 // when someone requests the host, so this is what makes the list mean
54 // "currently refused" rather than "ever refused since boot".
55 configGuardRefusalMaxAge = 24 * time.Hour
56)
57
58// errNoDeployStore is openDeployStoreEnv's answer for a box with no
59// deployment store configured at all: no bucket, no dir, no fleet identity.
60//
61// That is NOT proof that nothing is sealed. Config lives in the deployment
62// store, but CARLOS_HIBERNATE_BUCKET enables leases independently of it, so a
63// box can wake instances whose sealed config sits in a store it was never told
64// about. The guard therefore refuses (no_config_source) unless the operator
65// has said, with CARLOS_NO_SEALED_CONFIG=1 (or "true"), that this deployment
66// has no sealed config: the config-free development setup. The flag only
67// matters when there is no store; with one, config is read as usual.
68var errNoDeployStore = errors.New("neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set")
69
70type guardRefusal struct {
71 reason string
72 since time.Time
73 last time.Time
74 count int
75}
76
77type guardVerdict struct {
78 err error
79 until time.Time
80}
81
82type configGuard struct {
83 priv *ecdh.PrivateKey
84 keyState appconfig.KeyState
85 openStore func(context.Context) (release.Store, error)
86 now func() time.Time
87 // noSealedConfig is the operator's explicit "this deployment has no
88 // sealed config" (CARLOS_NO_SEALED_CONFIG), honoured only when there is
89 // no deployment store at all.
90 noSealedConfig bool
91
92 mu sync.Mutex
93 bypassed map[string]bool // hosts already logged as admitted by the bypass
94 store release.Store
95 verdicts map[string]guardVerdict // host -> cached refusal
96 refused map[string]*guardRefusal
97}
98
99// newConfigGuard reads CARLOS_SECRETS_KEY once, the way the config loops do.
100// A key that does not parse is logged here, once, at arming; per-host lines
101// say only that the host was refused.
102func newConfigGuard(getenv func(string) string, openStore func(context.Context) (release.Store, error)) *configGuard {
103 g := &configGuard{
104 openStore: openStore,
105 now: time.Now,
106 verdicts: map[string]guardVerdict{},
107 refused: map[string]*guardRefusal{},
108 }
109 g.noSealedConfig = noSealedConfigFlag(getenv)
110 raw := strings.TrimSpace(getenv("CARLOS_SECRETS_KEY"))
111 switch {
112 case raw == "":
113 g.keyState = appconfig.KeyAbsent
114 default:
115 priv, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY")
116 if err != nil {
117 log.Printf("agent: config guard: %v", err)
118 g.keyState = appconfig.KeyUnparseable
119 } else {
120 g.priv, g.keyState = priv, appconfig.KeyOK
121 }
122 }
123 return g
124}
125
126// noSealedConfigFlag is the operator's CARLOS_NO_SEALED_CONFIG, read the
127// one way the guard and the instance key's configuration reader share.
128func noSealedConfigFlag(getenv func(string) string) bool {
129 switch strings.ToLower(strings.TrimSpace(getenv("CARLOS_NO_SEALED_CONFIG"))) {
130 case "1", "true":
131 return true
132 }
133 return false
134}
135
136// needsBoxConfig reports whether a route is one this box would spawn with a
137// config.env of its own: a socket-backed instance or a sidecar worker that
138// belongs to an app. Static sites, addr-backed routes, Backing=unit routes
139// (whose config the root adopt pass delivers), platform routes and app-less
140// rows take no part: the guard must not touch them.
141//
142// The exclusion is Backing and nothing else, because Backing is what
143// SplitBackend dispatches on. A socket-backed hibernating instance may carry a
144// Unit name with an empty Backing (the record validator and `carlos route
145// -unit` allow it) and still reaches ExecBackend.Acquire.
146func needsBoxConfig(rt registry.Route) bool {
147 if rt.App == "" || rt.Platform || rt.Backing == registry.BackingUnit {
148 return false
149 }
150 switch {
151 case rt.IsSidecar():
152 return true
153 case rt.Kind == "instance":
154 return rt.Socket != ""
155 }
156 return false
157}
158
159// Gate is the seam both ExecBackend.ConfigGate and ReconcileConfig.ConfigGate
160// are wired to. nil means this box can provide rt's config (or none is
161// needed); an error means refuse, and is an *appconfig.ConfigRefusal.
162func (g *configGuard) Gate(ctx context.Context, rt registry.Route) error {
163 if !needsBoxConfig(rt) {
164 return nil
165 }
166 now := g.now()
167 if err := g.cached(rt.Host, now); err != nil {
168 g.note(rt.Host, err, now)
169 return err
170 }
171 err := g.decide(ctx, rt)
172 if err != nil {
173 g.mu.Lock()
174 g.verdicts[rt.Host] = guardVerdict{err: err, until: now.Add(configGuardRefuseTTL)}
175 g.mu.Unlock()
176 g.note(rt.Host, err, now)
177 return err
178 }
179 g.clear(rt.Host)
180 return nil
181}
182
183func (g *configGuard) cached(host string, now time.Time) error {
184 g.mu.Lock()
185 defer g.mu.Unlock()
186 v, ok := g.verdicts[host]
187 if !ok {
188 return nil
189 }
190 if !now.Before(v.until) {
191 delete(g.verdicts, host)
192 return nil
193 }
194 return v.err
195}
196
197func (g *configGuard) decide(ctx context.Context, rt registry.Route) error {
198 s, err := g.deployStore(ctx)
199 if errors.Is(err, errNoDeployStore) {
200 if g.noSealedConfig {
201 // The operator said this deployment has none. Said loudly, once
202 // per host: this is the one path that admits without looking.
203 g.noteBypass(rt.Host)
204 return nil
205 }
206 return &appconfig.ConfigRefusal{Reason: appconfig.ReasonNoSource, Err: err}
207 }
208 if err != nil {
209 return &appconfig.ConfigRefusal{Reason: appconfig.ReasonStoreError, Err: err}
210 }
211 return appconfig.CanProvideConfig(ctx, s, g.priv, g.keyState, release.RouteAccount(rt), rt.App, rt.Environment, rt.Host)
212}
213
214// configGuardBypassStatus is the private status word published while the
215// CARLOS_NO_SEALED_CONFIG bypass is in effect.
216const configGuardBypassStatus = "bypassed (CARLOS_NO_SEALED_CONFIG)"
217
218// noteBypass logs, once per host, that a placed instance was admitted without
219// its sealed config being checked.
220func (g *configGuard) noteBypass(host string) {
221 g.mu.Lock()
222 if g.bypassed == nil {
223 g.bypassed = map[string]bool{}
224 }
225 seen := g.bypassed[host]
226 g.bypassed[host] = true
227 g.mu.Unlock()
228 if !seen {
229 log.Printf("agent: config guard: BYPASS: admitting %s WITHOUT checking its sealed config, because CARLOS_NO_SEALED_CONFIG is set and this box has no deployment store. Never set it on a box that serves tenants", host)
230 }
231}
232
233// Bypassed reports whether the CARLOS_NO_SEALED_CONFIG bypass is in effect:
234// the flag is set AND the box has no deployment store configured. A flag set
235// beside a store changes nothing, so it does not count.
236func (g *configGuard) Bypassed() bool {
237 if !g.noSealedConfig {
238 return false
239 }
240 _, err := g.deployStore(context.Background())
241 return errors.Is(err, errNoDeployStore)
242}
243
244// Status is the private status-block word for the guard's mode: the bypass
245// words while it is in effect, "" otherwise.
246func (g *configGuard) Status() string {
247 if g.Bypassed() {
248 return configGuardBypassStatus
249 }
250 return ""
251}
252
253// Announce logs the guard's mode once at startup. The bypass is the only mode
254// worth shouting about, and it is shouted whether or not it is in effect, so a
255// flag copied into the wrong host.env is visible either way.
256func (g *configGuard) Announce() {
257 switch {
258 case g.Bypassed():
259 log.Printf("agent: config guard: BYPASSED. CARLOS_NO_SEALED_CONFIG is set and no deployment store is configured, so this box admits placed exec instances without checking their sealed config. Intended for config-free development only; it is listed in this box's status snapshot")
260 case g.noSealedConfig:
261 log.Printf("agent: config guard: CARLOS_NO_SEALED_CONFIG is set but a deployment store is configured, so it is ignored and sealed config is checked as usual")
262 }
263}
264
265// deployStore opens the store on first use and keeps it. A failure is not
266// kept, so the next ask (after the refusal TTL) tries again.
267func (g *configGuard) deployStore(ctx context.Context) (release.Store, error) {
268 g.mu.Lock()
269 s := g.store
270 g.mu.Unlock()
271 if s != nil {
272 return s, nil
273 }
274 s, err := g.openStore(ctx)
275 if err != nil {
276 return nil, err
277 }
278 g.mu.Lock()
279 g.store = s
280 g.mu.Unlock()
281 return s, nil
282}
283
284// note records a refusal for the snapshot and logs it: once per host, and
285// again only when the reason changes. The detail in the log is the operator's;
286// the snapshot carries the closed reason word alone.
287func (g *configGuard) note(host string, err error, now time.Time) {
288 reason := appconfig.ReasonStoreError
289 var r *appconfig.ConfigRefusal
290 if errors.As(err, &r) {
291 reason = r.Reason
292 }
293 g.mu.Lock()
294 e := g.refused[host]
295 fresh := e == nil || e.reason != reason
296 if e == nil {
297 e = &guardRefusal{since: now}
298 g.refused[host] = e
299 }
300 e.reason, e.last = reason, now
301 e.count++
302 g.mu.Unlock()
303 if fresh {
304 log.Printf("agent: config guard: refusing %s: %v. A box that cannot open an instance's sealed config does not take its lease or mint its record, so a keyed sibling can; it is listed in this box's status snapshot", host, err)
305 }
306}
307
308// clear forgets a host that now passes, saying so once if it was refused.
309func (g *configGuard) clear(host string) {
310 g.mu.Lock()
311 _, was := g.refused[host]
312 delete(g.refused, host)
313 delete(g.verdicts, host)
314 g.mu.Unlock()
315 if was {
316 log.Printf("agent: config guard: %s can be provided from this box again", host)
317 }
318}
319
320// Refusals is what the status snapshot publishes: the currently refused
321// hosts, newest first and capped, plus how many there are in all. A host not
322// asked about for configGuardRefusalMaxAge is dropped.
323func (g *configGuard) Refusals() ([]status.ConfigRefusal, int) {
324 now := g.now()
325 g.mu.Lock()
326 defer g.mu.Unlock()
327 var out []status.ConfigRefusal
328 for host, e := range g.refused {
329 if now.Sub(e.last) > configGuardRefusalMaxAge {
330 delete(g.refused, host)
331 continue
332 }
333 out = append(out, status.ConfigRefusal{Host: host, Reason: e.reason, Since: e.since, Last: e.last, Count: e.count})
334 }
335 total := len(out)
336 sort.Slice(out, func(i, j int) bool {
337 if !out[i].Last.Equal(out[j].Last) {
338 return out[i].Last.After(out[j].Last)
339 }
340 return out[i].Host < out[j].Host
341 })
342 if len(out) > configGuardListCap {
343 out = out[:configGuardListCap]
344 }
345 return out, total
346}
347
348// armedConfigGuard is the process's one guard, set by RunAgent before any
349// loop that asks it starts. Nil is "not armed": every consumer treats that as
350// today's behaviour, so tests and callers that never arm one are unaffected.
351var armedConfigGuard atomic.Pointer[configGuard]
352
353func armConfigGuard(g *configGuard) { armedConfigGuard.Store(g) }
354
355// configGate is the armed guard's Gate, or nil when none is armed.
356func configGate() func(context.Context, registry.Route) error {
357 if g := armedConfigGuard.Load(); g != nil {
358 return g.Gate
359 }
360 return nil
361}
362
363// configGuardProbe feeds status.Probes.ConfigGuard: "" unless the bypass is
364// in effect.
365func configGuardProbe() string {
366 if g := armedConfigGuard.Load(); g != nil {
367 return g.Status()
368 }
369 return ""
370}
371
372// configRefusalProbe feeds status.Probes.ConfigRefusals.
373func configRefusalProbe() ([]status.ConfigRefusal, int) {
374 if g := armedConfigGuard.Load(); g != nil {
375 return g.Refusals()
376 }
377 return nil, 0
378}
379