carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Ported from lchat activator.go (the s3Backend half) and the exec/env/health
2// helpers it leaned on from lchat control.go and versions.go.
3package activator
4
5import (
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "io/fs"
12 "log"
13 "maps"
14 "net"
15 "net/http"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "sort"
20 "strings"
21 "sync"
22 "syscall"
23 "time"
24
25 "github.com/carlosframework/platform/internal/applog"
26 "github.com/carlosframework/platform/internal/cgroup"
27 "github.com/carlosframework/platform/internal/edge"
28 "github.com/carlosframework/platform/internal/instancectl"
29 "github.com/carlosframework/platform/internal/instancekey"
30 "github.com/carlosframework/platform/internal/instanceupdates"
31 "github.com/carlosframework/platform/internal/lease"
32 "github.com/carlosframework/platform/internal/moves"
33 "github.com/carlosframework/platform/internal/registry"
34 "github.com/carlosframework/platform/internal/release"
35 "github.com/carlosframework/platform/internal/safefile"
36 "github.com/carlosframework/platform/internal/schedule"
37 "github.com/carlosframework/platform/internal/seeds"
38 "github.com/carlosframework/platform/internal/unixdial"
39)
40
41// spawnHealthTimeout bounds how long a spawned instance may take to bind its
42// socket and answer. A var so tests can shrink the failure path.
43var spawnHealthTimeout = 30 * time.Second
44
45// ErrSeedUnproven is seedUnit's answer when a seed's cgroup never reports
46// empty (design §6.2): only a SIGKILL survivor reaches this, and while its
47// cgroup stays populated it may still be writing the database, so the
48// caller must treat this the way stop() treats ErrStillRunning and leave
49// the lease for its own TTL to expire rather than release it over a write
50// that might still be happening.
51var ErrSeedUnproven = errors.New("activator: seed unproven; processes remain")
52
53// seedUnprovenSuffix names a flat seed's unproven-cleanup obligation, kept
54// beside the database (<db>.seed-unproven): a seed failed, its processes
55// were not proven gone, and so its database was kept. While the file is
56// there no wake treats the database as warm (Restore). It does not end in
57// ".db", so the box-wide litestream glob never picks it up.
58const seedUnprovenSuffix = ".seed-unproven"
59
60// writeSeedObligationFile is the obligation's durable write: a var so a
61// test can make it fail (a full disk).
62var writeSeedObligationFile = safefile.WriteFile
63
64// quarantineSuffix names where a restore that failed its integrity check is
65// parked. It deliberately does NOT end in ".db": the box-wide litestream
66// generator globs <data>/*.db, and a quarantined corpse must never be picked
67// up for replication.
68const quarantineSuffix = ".corrupt"
69
70// ExecBackend is the production Backend: leases + restore via the lease
71// package's litestream primitives, processes via exec of the same versioned
72// binary a systemd unit would run — `<OptDir>/live/<host>` with the
73// `<DataDir>/<host>.env` EnvironmentFile.
74type ExecBackend struct {
75 Cfg Config
76 // OptDir is the on-box install root: the version store and the
77 // per-instance "live" symlinks live under it (/opt/carlos in production;
78 // a struct field, never hardcoded, so tests point it at a temp dir).
79 OptDir string
80 // DataDir holds each instance's DB and env files (<host>.db, <host>.env,
81 // <host>-sidecar.env) — /data in production.
82 DataDir string
83 // Materialize, when non-nil, stages a binary and points the host's live
84 // symlink at it via an external mechanism instead of the backend doing
85 // filesystem work itself — Eleven needed this because its router ran
86 // sandboxed (ProtectSystem=strict) and couldn't write to /opt itself.
87 // With it nil, a missing live symlink is an error: Eleven fell back to
88 // staging its OWN binary (instance and control binary were one program),
89 // but a carlos app is a separate binary from the release store — there is
90 // nothing sensible to invent here, and the adopt pass owns the symlinks.
91 Materialize func(ctx context.Context, host string) error
92 // Mirror, when non-nil, arms Restore's staging fallback (issue #331):
93 // a host with no replica under the ordinary instances/ prefix may have
94 // one under moves/<host>/ — a residence move's staged copy, pushed by
95 // the SOURCE box. Restore then copies it into place (own bucket, own
96 // IAM), restores as normal, and deletes the staging copy once CheckDB
97 // vouches for what it restored. Nil = the fallback does not exist,
98 // byte-identical to the pre-move behaviour.
99 Mirror lease.MirrorStore
100 // Phase1, set by the agent from moves.Enabled, retires the staging
101 // fallback above: Restore never promotes a moves/<host>/ tree, because
102 // under Phase 1 the staged copy is the target's to read through the
103 // landing, and a restore that promoted it would be a second, older
104 // source of truth (moves design §5.3, M15). Off, the legacy promotion
105 // is exactly as it was until Plan 6 Task 8 removes it.
106 Phase1 bool
107 // Region is the box's CARLOS placement code (CARLOS_REGION), handed to
108 // every exec child as its own CARLOS_REGION (issue #329, titogo's
109 // residence design: the app reads where it lives — e.g. to clear a move
110 // freeze after landing). Agent-owned in BOTH directions, exactly like
111 // the control-channel vars below: set from what the agent knows, and
112 // STRIPPED when the agent knows nothing — a tenant's config env must not
113 // be able to tell an app it runs somewhere it does not.
114 Region string
115 // ReplicaHomeKeys answers, for a replica row's host, the SHA-256 of its
116 // home's effective CARLOS_INSTANCE_KEY (instance-key plan rulings 26 and
117 // 30). O8 will supply it from the home's published env item; until then
118 // the agent reads the box's list (/etc/carlos/replica-home-keys), which
119 // only the pre-roll check writes, again at every call. Nil, or false for
120 // a host, means the home key cannot be verified, and a replica launch
121 // that would be handed a key is refused (replicaKeyGate).
122 ReplicaHomeKeys func(host string) (sha256 string, ok bool)
123 // ConfiguredKey reads rt's configuration from the bucket now and
124 // returns the SHA-256 of the CARLOS_INSTANCE_KEY it renders, "" for
125 // none (appconfig.ConfiguredKeyDigest). Two readers ask it. The mint
126 // asks before it mints, so a key the configuration sets is never
127 // shadowed by one minted while that configuration was still on its
128 // way to the box (ensureInstanceKey; ruling 5): a key the bucket sets
129 // and the box has not delivered defers the launch, and nil is a box
130 // with no configuration source, where nothing is on its way. The
131 // replica gate asks at every launch whose key is configured, and
132 // refuses a replica whose configured key is not that digest: a
133 // configuration change is in flight (ruling 27); there nil refuses
134 // every configured key.
135 ConfiguredKey func(ctx context.Context, rt registry.Route) (sha256 string, err error)
136 // Cache, when non-nil, adds the purge verb to each spawned instance's
137 // control socket so it can invalidate its own cached responses right
138 // after a write. Nil disables that ONE verb, not the socket: the control
139 // channel is bound for every exec child either way (§7.2), because the
140 // scheduled-work verbs need it on boxes with no edge cache too.
141 Cache *edge.Cache
142 // Cgroups, when non-nil, places every spawned child — instance, sidecar,
143 // seed — in a per-host cgroup under the agent's delegated subtree so the
144 // metrics sampler can account it (exec-child accounting spec). Nil — no
145 // delegation: old unit file, tests, darwin — spawns exactly as before.
146 Cgroups *cgroup.Manager
147 // Tier, when non-nil, picks the memory cap for a route's carlos-exec@
148 // cgroup (memory-pressure spec §2.3). The box wiring reads the staged
149 // manifest of the live version; tests leave it nil and get the default
150 // tier. Instance, seed and sidecar share the cgroup, so they share the
151 // tier — a seed's first-boot cost is that instance's work.
152 Tier func(registry.Route) cgroup.Limit
153 // GenContainment selects the generation layout (zero-downtime spec
154 // §7.3, §7.6): each instance spawn gets its own child cgroup g<n> (or
155 // the carlos-instance-gen@<id>:<n> unit for a nested host) under a
156 // parent that holds no processes. False, the zero value, is today's
157 // layout byte for byte: one cgroup per host and carlos-instance@. Set
158 // only by plan 5's CARLOS_SWAP wiring.
159 GenContainment bool
160 // TierOf is one named version's tier, read from that version's staged
161 // manifest (zero-downtime spec §7.3). A generation's limits are its own
162 // artifact's, never whatever the live link names at the moment. Nil
163 // falls back to Tier (tests, and every flag-off spawn).
164 TierOf func(rt registry.Route, version string) cgroup.Limit
165 // Logs, when non-nil, gets each instance and seed child's stdout/stderr
166 // teed in line-wise for `carlos logs` (spec §5) — journald keeps every
167 // byte it got before. Nil (tests, a box without the shipping loop)
168 // spawns exactly as before: fd passthrough, no pipes.
169 Logs EventSink
170 // Timers, when non-nil, mounts the scheduled-work verbs on each spawned
171 // instance's control socket so an app can set and clear its own one-shot
172 // timers (spec 2026-08-23-scheduled-work-design.md §7.2). The box wiring
173 // passes the registry. Nil — tests, and any box whose agent predates the
174 // feature — binds the control socket without those verbs, so an app that
175 // asks gets 404 rather than a socket that isn't there.
176 Timers schedule.TimerStore
177 // UpdatesReader answers the control socket's read-only GET /updates
178 // (instance update settings spec §5.1); nil mounts nothing.
179 UpdatesReader func(host string) (instanceupdates.Report, error)
180 // LayoutFacts supplies the box facts the classification of §8.1 needs
181 // beyond the row: journals and peers. Zero value: never fenced, never
182 // conflict. Named apart from the Layout method (the Classifier
183 // interface's Layout(rt) (Layout, string)) so a struct field and a
184 // method can coexist on the same type.
185 LayoutFacts LayoutInputs
186 // Control is the root instance-control channel a nested wake talks to
187 // (design §5.3). Nil refuses every nested wake with "instance control
188 // channel not configured" rather than dialing nothing. A flat or
189 // foreign host never needs it, and a nil Control does not touch them.
190 Control Control
191 // UnitState answers `systemctl show` for the observer (design §6.5).
192 // Nil: systemctlUnitState, the real systemctl binary.
193 UnitState UnitState
194 // CgroupEvents reads a unit's cgroup.events for the observer and for a
195 // seed's cgroup-empty wait. Nil: cgroupEventsUnder("/sys/fs/cgroup"),
196 // or CgroupRoot when set.
197 CgroupEvents CgroupEvents
198 // CgroupRoot overrides where cgroup v2 is mounted, for the default
199 // CgroupEvents reader. Nil (empty): /sys/fs/cgroup.
200 CgroupRoot string
201 // NestedOK gates a nested wake on the box facts a unit test can't see:
202 // the isolation mounts, the control service's own /ready. Nil: no
203 // gate (every unit test and any box that predates the check). Wired in
204 // production by Task 6.
205 NestedOK func() error
206 // Seeds reads a seed's snapshot from this region's data bucket
207 // (seeded-instances spec §3.4). Nil: no seed can be placed here, and a
208 // seeded row's wake fails rather than starting empty.
209 Seeds SeedObjects
210 // ClaimArmed is CARLOS_HOST_CLAIM on this box. The seed fallback is the
211 // plain path only; with claims armed it refuses (spec §3.4, #250).
212 ClaimArmed bool
213 // OnSeedPlaced records a seed placed (Task 4): the record's CAS to
214 // placed, then the snapshot's delete. Nil: not recorded here.
215 OnSeedPlaced func(ctx context.Context, account, app, host, id string) error
216 // ConfigGate answers whether this box can provide rt's config (amadan
217 // #213): nil means yes, an error means refuse. Acquire asks it BEFORE
218 // lease.Acquire, so a box that would start the instance without its
219 // secrets never holds the lease, and a keyed sibling takes it instead.
220 // The gate owns its own policy, logging and operator-visible record of
221 // the refusal; this backend only honours the answer. Nil: no gate (every
222 // unit test, and a box with no deploy store to read config from).
223 ConfigGate func(ctx context.Context, rt registry.Route) error
224 // LiveExecGate answers whether rt may start build, the build a start
225 // from the live link is about to run: nil means yes, an error refuses
226 // the start and comes back as it is. Only a box that declined
227 // CARLOS_SWAP=overlap and honours failure records or hold-ran markers
228 // from an earlier armed run sets it (internal/host, through
229 // Service.SetLiveExecGate, before Start), so the plan-time refusal is
230 // repeated at the real launch with the build actually being started
231 // (Astra R1 #6, the R2/R3 declined residuals). It is asked by a flat
232 // instance's or sidecar's live-link spawn (which then execs the staged
233 // path it resolved, not the link), a nested instance's or sidecar's
234 // live-link start and a seed from the live link (each pins the link
235 // first, declinedLivePin, and runs the file it pinned), and a worker's
236 // spawn from the live link. Nil, on every other box: each of
237 // those spawns is exactly as it was. It reads only memory, the row it
238 // is handed and the live link, and writes nothing: the activator asks
239 // it under its own lock too (convergeWorkerLocked).
240 LiveExecGate func(rt registry.Route, build string) error
241 // ObservePoll is how often the exit observer (unitproc.go) and a
242 // seed's cgroup-empty wait re-check unit state. 0: one second. A field
243 // so tests can shrink the wait without a package-global var shared
244 // across every test in the suite.
245 ObservePoll time.Duration
246 // RunDirOverride is test-only: when set, layoutInputs() classifies
247 // against it instead of provision.SocketDir and pathsFor resolves a
248 // nested run dir and log socket beneath it, so a test can exercise the
249 // nested layout without writing to /run/carlos.
250 RunDirOverride string
251 // ScheduleFence runs a schedule write made on the control socket of
252 // host's generation gen (the number the listener was bound for, 0 for
253 // a spawn that is not a generation's), unless the activator has
254 // switched away from that generation, and reports whether it ran
255 // (zero-downtime spec §7.10, final review F8). Wired to
256 // Service.ScheduleWrite by the agent. Nil (tests, a box before this
257 // wiring) refuses nothing.
258 ScheduleFence func(host string, gen uint64, write func()) bool
259 // layoutFn, when set, replaces Classify in StartCmd's layout check; a
260 // unit test's way to stand a temp directory in for a nested host.
261 layoutFn func(rt registry.Route) (Layout, string)
262 // Strict, when non-nil and true for a host, makes every spawn of that
263 // host strict (moves design §5.3): the child starts only inside a group
264 // prepareStrict prepared, which is drained (KillAndEmpty) and closed
265 // when the child exits, and a spawn that cannot be made strict is
266 // refused with ErrStrictRequired. The host package sets it from the hold
267 // view: a host whose record has a move in flight, with the switch on
268 // (Task 14). Nil, or false for a host, is today's spawn exactly.
269 Strict func(host string) bool
270 // prepareStrict is the strict path's seam. Nil: Cgroups.PrepareStrict,
271 // and a nil Cgroups under Strict is ErrStrictRequired. Tests replace
272 // this seam, never the Cgroups field.
273 prepareStrict func(host string, limit cgroup.Limit) (strictProc, error)
274 // killGen is the spawn path's own generation kill (startChildAt's
275 // failed health wait). Nil: KillGen. Tests replace this seam, never the
276 // Cgroups field.
277 killGen func(ctx context.Context, rt registry.Route, n uint64) error
278 // cgroupKill is seedDescendantsGone's kill and proof: child "" is the
279 // host cgroup (KillHost), any other name one of its children. Nil: the
280 // cgroup manager's own. Tests replace this seam, never the Cgroups
281 // field.
282 cgroupKill func(ctx context.Context, rt registry.Route, child string) error
283 // openHostCleanup is EmptyHost's open of the host cgroup. Nil:
284 // Cgroups.OpenCleanup. Tests replace this seam, never the Cgroups
285 // field.
286 openHostCleanup func(host string) (*cgroup.Cleanup, error)
287 // strictPreflight is NextSpawnStrict's seam for PrepareStrict's runtime
288 // preflight. Nil: Cgroups.StrictPreflight, the very function
289 // PrepareStrict runs, so S2 and the spawn cannot disagree.
290 strictPreflight func() error
291 // strictGates holds one hostGate per host that has spawned with Strict
292 // set. strictMu guards only the map's lookup and insert, never a start
293 // or any filesystem work, so a stalled start blocks only its own host.
294 strictMu sync.Mutex
295 strictGates map[string]*hostGate
296 // strictHook, when set, is called at named points of the strict
297 // cleanup path. Test-only: it makes interleavings deterministic.
298 strictHook func(point string)
299 // launchHook, when set, is called by a staged spawn once its
300 // preparation is done, immediately before its exec gate (runExecGate):
301 // flat, inside the cgroup manager's before-start hook, so once per
302 // start attempt (a PID-move retry asks again); nested, before root's
303 // start call. Test-only: it lands a lease loss, a fence or a
304 // supersession after the preparation and before the launch (final
305 // review F1).
306 launchHook func(host string)
307}
308
309// hostGate serialises everything that touches one host's group once Strict
310// is wired (moves design §5.3): the ownership check and an ordinary start,
311// a strict spawn taking ownership, the exit path's directory removal, a
312// retry's removal, and S2's read. owner, and owner.pending, are read and
313// written only with mu held. While owner is set, from a strict spawn until
314// its cleanup succeeds (retries included), no other spawn of the host may
315// use the group and nothing but the owner may remove it.
316type hostGate struct {
317 mu sync.Mutex
318 owner *strictOwner
319}
320
321// gate returns host's gate, creating it.
322func (b *ExecBackend) gate(host string) *hostGate {
323 b.strictMu.Lock()
324 defer b.strictMu.Unlock()
325 if b.strictGates == nil {
326 b.strictGates = map[string]*hostGate{}
327 }
328 g := b.strictGates[host]
329 if g == nil {
330 g = &hostGate{}
331 b.strictGates[host] = g
332 }
333 return g
334}
335
336// lookupGate returns host's gate, or nil if it never had one.
337func (b *ExecBackend) lookupGate(host string) *hostGate {
338 b.strictMu.Lock()
339 defer b.strictMu.Unlock()
340 return b.strictGates[host]
341}
342
343func (b *ExecBackend) hook(point string) {
344 if b.strictHook != nil {
345 b.strictHook(point)
346 }
347}
348
349// ErrStrictGroupInUse is a spawn refused because a strict child of the same
350// host is alive in the host's group: an ordinary child placed beside it
351// would be killed by its exit drain.
352var ErrStrictGroupInUse = errors.New("activator: a strict child of this host is still running in its group")
353
354// ErrStrictCleanupPending is a spawn refused because the host's last strict
355// group has not been cleaned up: its retained handle still owns the group,
356// and a retry's drain would kill anything placed in it.
357var ErrStrictCleanupPending = errors.New("activator: this host's last strict group is still being cleaned up")
358
359// ErrStrictRequired is a spawn refused because its host is in a move and
360// the child could not be started under strict containment (moves design
361// §5.3): no delegated cgroup subtree, a populated or unverifiable group, or
362// a strict start that failed. Nothing was spawned.
363var ErrStrictRequired = errors.New("activator: a host in a move spawns only under strict containment")
364
365// strictProc is what a strict spawn holds for the life of its child.
366// *cgroup.Prepared satisfies it on every build (strict_linux.go,
367// strict_other.go).
368type strictProc interface {
369 Start(*exec.Cmd) error
370 KillAndEmpty(context.Context) error
371 Close() error
372}
373
374var _ strictProc = (*cgroup.Prepared)(nil)
375
376// strictDrainTimeout bounds each KillAndEmpty a strict group's cleanup runs.
377var strictDrainTimeout = 30 * time.Second
378
379// strictCleanupBackoff is the bounded retry schedule for a strict group
380// whose cleanup failed on exit: one more attempt after each wait. When it
381// runs out the handle is still kept, and the host's strict spawns stay
382// refused until the agent restarts: a group that never drained may still
383// hold a process nothing accounts for.
384var strictCleanupBackoff = []time.Duration{time.Second, 5 * time.Second, 15 * time.Second, 30 * time.Second, time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute}
385
386// execLogLineMax caps one teed log line (spec §5): longer is truncated with
387// a marker, never split into phantom lines.
388const execLogLineMax = 8 << 10
389
390// execDrainGrace bounds how long a dead child's held-open output pipes can
391// delay its Wait. Teeing turns the child's stdout/stderr into PIPES (with a
392// plain *os.File the fd passed straight through), and os/exec's Wait then
393// waits for the read side to drain — which a grandchild holding the write
394// end keeps open forever. Same discipline, same reason as seedDrainGrace;
395// without it, an exited instance would look alive to the activator for as
396// long as some stray grandchild lived, and Stop would hang on p.done.
397var execDrainGrace = 5 * time.Second
398
399// teeStd wires cmd's stdout/stderr: straight to the agent's own streams
400// (journald under systemd) exactly as before, PLUS — only when b.Logs is
401// set — a line-splitting tee into the log sink tagged as rt's app output.
402func (b *ExecBackend) teeStd(cmd *exec.Cmd, rt registry.Route) {
403 if b.Logs == nil {
404 cmd.Stdout = os.Stdout
405 cmd.Stderr = os.Stderr
406 return
407 }
408 acct, app := release.RouteAccount(rt), rt.App
409 emit := func(line string) { b.Logs.App(acct, app, rt.Host, line) }
410 cmd.Stdout = io.MultiWriter(os.Stdout, applog.NewLineWriter(execLogLineMax, emit))
411 cmd.Stderr = io.MultiWriter(os.Stderr, applog.NewLineWriter(execLogLineMax, emit))
412 cmd.WaitDelay = execDrainGrace
413}
414
415var _ Backend = (*ExecBackend)(nil)
416
417// tier is Tier with the nil default applied.
418func (b *ExecBackend) tier(rt registry.Route) cgroup.Limit {
419 if b.Tier == nil {
420 return cgroup.TierDefault
421 }
422 return b.Tier(rt)
423}
424
425// spawnInCgroup is the ONE seam all three child shapes — seed, instance,
426// sidecar — start through: the route's cgroup, the route's tier. A spawn
427// site that bypassed it would compile and silently ship uncapped children;
428// keeping the tier plumbing here makes that a diff a reviewer can see and
429// TestSpawnInCgroupAppliesTheTier can pin.
430//
431// Under a move (Strict) it is also the one place a spawn is made strict, so
432// no child shape can start a host's process outside the strict group. The
433// returned settle runs after the child has been reaped: for a strict spawn
434// it drains and closes the group, for a plain one it does nothing.
435//
436// A strict handle owns the host's group from the spawn until its cleanup
437// succeeds (hostGate.owner). While it does, every other spawn of the host
438// is refused, an ordinary one included: the check and an ordinary spawn's
439// start happen under the host's gate, so no strict group can be prepared
440// for the host in between. With Strict nil (a box with no moves wiring)
441// none of this runs and no lock is taken: the spawn is main's exactly.
442func (b *ExecBackend) spawnInCgroup(rt registry.Route, build func() *exec.Cmd) (*exec.Cmd, func(), error) {
443 return b.spawnInCgroupGated(rt, cgroup.Limit{}, build, nil)
444}
445
446// spawnInCgroupGated is spawnInCgroup with a before-start hook (final review
447// F1), asked immediately before each start attempt: the cgroup manager's
448// (StartGated), or, strict, after the strict group is prepared. A refusal
449// starts nothing and is cleaned up as a failed start is. A nil before is
450// spawnInCgroup exactly.
451//
452// tier is the artifact's own (a recorded or staged build's, ruling
453// R-TIER); zero is b.tier(rt), the live link's, which is every caller but
454// a staged spawn.
455func (b *ExecBackend) spawnInCgroupGated(rt registry.Route, tier cgroup.Limit, build func() *exec.Cmd, before func() error) (*exec.Cmd, func(), error) {
456 if b.Cfg.HostWrites.Enabled {
457 return nil, nil, ErrClaimIsolationUnavailable
458 }
459 if tier == (cgroup.Limit{}) {
460 tier = b.tier(rt)
461 }
462 if b.Strict == nil {
463 cmd, err := b.Cgroups.StartGated(rt.Host, tier, build, before)
464 return cmd, func() {}, err
465 }
466 strict := b.Strict(rt.Host)
467 g := b.gate(rt.Host)
468 g.mu.Lock()
469 if o := g.owner; o != nil {
470 pending := o.pending
471 g.mu.Unlock()
472 cause := ErrStrictGroupInUse
473 if pending {
474 cause = ErrStrictCleanupPending
475 }
476 if strict {
477 return nil, nil, fmt.Errorf("%s: %w: %w", rt.Host, ErrStrictRequired, cause)
478 }
479 return nil, nil, fmt.Errorf("%s: %w", rt.Host, cause)
480 }
481 if !strict {
482 cmd, err := b.Cgroups.StartGated(rt.Host, tier, build, before)
483 g.mu.Unlock()
484 return cmd, func() {}, err
485 }
486 o := &strictOwner{}
487 g.owner = o
488 g.mu.Unlock()
489 sp, err := b.prepareStrictProc(rt.Host, tier)
490 if err != nil {
491 b.disown(rt.Host, o, false)
492 return nil, nil, fmt.Errorf("%s: %w: %v", rt.Host, ErrStrictRequired, err)
493 }
494 o.sp = sp
495 cmd := build()
496 if before != nil {
497 if err := before(); err != nil {
498 // Nothing started: the strict group goes as after a failed start.
499 b.settleStrict(rt.Host, o)
500 return nil, nil, err
501 }
502 }
503 if err := sp.Start(cmd); err != nil {
504 b.settleStrict(rt.Host, o)
505 return nil, nil, fmt.Errorf("%s: %w: %v", rt.Host, ErrStrictRequired, err)
506 }
507 return cmd, func() { b.settleStrict(rt.Host, o) }, nil
508}
509
510// prepareStrictProc is prepareStrict with its default applied.
511func (b *ExecBackend) prepareStrictProc(host string, limit cgroup.Limit) (strictProc, error) {
512 if b.prepareStrict != nil {
513 return b.prepareStrict(host, limit)
514 }
515 if b.Cgroups == nil {
516 return nil, errors.New("no delegated cgroup subtree")
517 }
518 p, err := b.Cgroups.PrepareStrict(host, limit)
519 if err != nil {
520 return nil, err
521 }
522 return p, nil
523}
524
525// strictOwner is the strict handle that owns a host's group. pending
526// (guarded by the host gate's mu) is set once its first cleanup attempt
527// failed and retries own the group. drained records a KillAndEmpty that
528// already succeeded: the handle refuses a second one, so a retry after a
529// failed Close only closes. sp and drained are touched only by the one
530// goroutine running the owner's cleanup at a time.
531type strictOwner struct {
532 sp strictProc
533 pending bool
534 drained bool
535}
536
537// attempt is one cleanup attempt: drain (unless already drained), then
538// close. It runs outside the host gate (a drain may take strictDrainTimeout);
539// the owner it belongs to keeps every other user of the group out meanwhile.
540func (o *strictOwner) attempt(host string) error {
541 if !o.drained {
542 ctx, cancel := context.WithTimeout(context.Background(), strictDrainTimeout)
543 err := o.sp.KillAndEmpty(ctx)
544 cancel()
545 if err != nil {
546 log.Printf("activator: %s: strict group did not drain: %v", host, err)
547 return err
548 }
549 o.drained = true
550 }
551 if err := o.sp.Close(); err != nil {
552 log.Printf("activator: %s: strict group not closed: %v", host, err)
553 return err
554 }
555 return nil
556}
557
558// settleStrict drains a strict child's group and closes the handle. On
559// success the owner lets go and the exit path's cleanupHost removes the
560// group as before. On a failure the owner stays, marked pending, retried on
561// strictCleanupBackoff: until a retry succeeds every spawn of the host is
562// refused and only the owner may remove the group directory, because the
563// handle's identity is that directory and removing it would strand the
564// handle for ever.
565func (b *ExecBackend) settleStrict(host string, o *strictOwner) {
566 if o.attempt(host) == nil {
567 b.disown(host, o, false)
568 return
569 }
570 b.hook("cleanup-failed")
571 g := b.gate(host)
572 g.mu.Lock()
573 o.pending = true
574 g.mu.Unlock()
575 go b.retryStrictCleanup(host, o, append([]time.Duration(nil), strictCleanupBackoff...))
576}
577
578// disown ends o's ownership of host's group. removeGroup removes the group
579// directory first, under the host gate, so no spawn can recreate it in
580// between: the retry owner's last act.
581func (b *ExecBackend) disown(host string, o *strictOwner, removeGroup bool) {
582 g := b.gate(host)
583 g.mu.Lock()
584 defer g.mu.Unlock()
585 if g.owner != o {
586 return
587 }
588 if removeGroup {
589 b.Cgroups.CleanupHost(host)
590 }
591 g.owner = nil
592}
593
594// cleanupHost is the exit path's CleanupHost. With Strict nil it is main's
595// call, with no lock. Otherwise it runs under the host gate and removes
596// nothing while any strict owner, live or pending, holds the group: the
597// owner removes it once its handle is closed.
598func (b *ExecBackend) cleanupHost(host string) {
599 if b.Strict == nil {
600 b.Cgroups.CleanupHost(host)
601 return
602 }
603 g := b.gate(host)
604 g.mu.Lock()
605 defer g.mu.Unlock()
606 if g.owner != nil {
607 return
608 }
609 b.hook("exit-cleanup-checked")
610 b.Cgroups.CleanupHost(host)
611}
612
613// retryStrictCleanup retries o's cleanup once after each wait in schedule,
614// a copy taken when the cleanup was queued, and removes the group directory
615// when one succeeds.
616func (b *ExecBackend) retryStrictCleanup(host string, o *strictOwner, schedule []time.Duration) {
617 for i, wait := range schedule {
618 time.Sleep(wait)
619 if o.attempt(host) == nil {
620 b.disown(host, o, true)
621 log.Printf("activator: %s: strict group cleaned up on retry %d", host, i+1)
622 return
623 }
624 }
625 log.Printf("activator: %s: strict group still not cleaned up after %d retries; spawns of this host stay refused until the agent restarts", host, len(schedule))
626}
627
628// strictCleanupPending reports, under the host gate, whether host's group
629// is owned by a strict handle whose cleanup is being retried.
630func (b *ExecBackend) strictCleanupPending(host string) bool {
631 g := b.lookupGate(host)
632 if g == nil {
633 return false
634 }
635 g.mu.Lock()
636 defer g.mu.Unlock()
637 return g.owner != nil && g.owner.pending
638}
639
640// NextSpawnStrict is the source's S2 input (moves design §7.3, §5.3): will
641// host's next spawn be strict? OK only when Strict names the host, the box
642// has a delegated cgroup subtree, PrepareStrict's own runtime preflight
643// passes (the agent placed in that subtree or its supervise child, on a
644// real cgroup2 mount, on a build that supports it), and no earlier strict
645// group of the host awaits cleanup. The per-host group state PrepareStrict
646// also judges (a populated group) is the idle stop's to clear.
647func (b *ExecBackend) NextSpawnStrict(host string) moves.Check {
648 switch {
649 case b.Strict == nil || !b.Strict(host):
650 return moves.Check{Reason: "S2: the next spawn of this host is not strict"}
651 case b.Cgroups.Root() == "":
652 return moves.Check{Reason: "S2: this box has no delegated cgroup subtree, so it cannot spawn strictly"}
653 case b.strictCleanupPending(host):
654 return moves.Check{Reason: "S2: this host's last strict group has not been cleaned up yet"}
655 }
656 preflight := b.strictPreflight
657 if preflight == nil {
658 preflight = b.Cgroups.StrictPreflight
659 }
660 if err := preflight(); err != nil {
661 if errors.Is(err, cgroup.ErrStrictRefused) {
662 return moves.Check{Reason: "S2: the agent is not placed in its delegated cgroup subtree, so it cannot spawn strictly"}
663 }
664 return moves.Check{Reason: "S2: this box cannot spawn strictly"}
665 }
666 return moves.Check{OK: true}
667}
668
669func (b *ExecBackend) liveDir() string { return filepath.Join(b.OptDir, "live") }
670func (b *ExecBackend) liveLink(host string) string { return filepath.Join(b.liveDir(), host) }
671
672// LiveVersion reports the version host's live symlink currently points at, or
673// "" (no error) if the host hasn't been put under version management yet. It
674// doubles as the Service's convergence-sweep seam — New picks it up via an
675// optional interface.
676//
677// Delegated to release.LiveVersion rather than reading the parent directory,
678// because the version store gained an account level: a migrated tenant's link
679// is <opt>/versions/<ver>/<acct>/<app>, and "the parent dir" there is the
680// account SQID. ConvergeDecision compares this against the route's version,
681// so returning a sqid would convince it that every migrated tenant was
682// converged forever — a tenant that silently stops taking deploys. One
683// definition of "which segment is the version", in one place.
684func (b *ExecBackend) LiveVersion(host string) (string, error) {
685 return release.LiveVersion(b.OptDir, host)
686}
687
688// Acquire claims the per-host single-writer lease. A contested lease is
689// re-surfaced as errLeaseHeld (never a litestream type — the lease package's
690// *HeldError is this package's only view of it) so the edge can 503 with a
691// Retry-After that tracks the holder's expiry.
692func (b *ExecBackend) Acquire(ctx context.Context, rt registry.Route) (Lease, error) {
693 host := rt.Host
694 // Admission before the lease: the instance's config has to be
695 // providable from THIS box, or it must not be the one that starts it.
696 // Here, not in doWake, because this is the only place an instance or
697 // sidecar lease is taken; every wake source reaches it.
698 if err := b.AdmitConfig(ctx, rt); err != nil {
699 return nil, err
700 }
701 held, err := lease.Acquire(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), b.Cfg.Region)
702 if err != nil {
703 var he *lease.HeldError
704 if errors.As(err, &he) {
705 retry := time.Until(he.ExpiresAt)
706 if retry < time.Second {
707 retry = time.Second
708 }
709 return nil, &errLeaseHeld{holder: he.Owner, retryAfter: retry}
710 }
711 return nil, err
712 }
713 return &execLease{held: held, defaultDB: b.pathsFor(rt).dbPath}, nil
714}
715
716// AdmitConfig asks the config gate whether this box can provide rt's config:
717// nil means yes (or no gate), an *errConfigRefused means no. Acquire calls it
718// before the lease; the activator calls it again before an in-place worker
719// respawn, which keeps its lease and so never passes through Acquire.
720func (b *ExecBackend) AdmitConfig(ctx context.Context, rt registry.Route) error {
721 if b.ConfigGate == nil {
722 return nil
723 }
724 if err := b.ConfigGate(ctx, rt); err != nil {
725 return &errConfigRefused{err: err}
726 }
727 return nil
728}
729
730// execLease adapts *lease.Held to the Lease seam.
731type execLease struct {
732 held *lease.Held
733 defaultDB string // used when the route carries no DBPath
734}
735
736func (l *execLease) Replicate(ctx context.Context, dbPath string, syncInterval time.Duration) (Rep, error) {
737 if dbPath == "" {
738 dbPath = l.defaultDB
739 }
740 rep, err := l.held.Replicate(ctx, dbPath, syncInterval)
741 if err != nil {
742 return nil, err
743 }
744 return rep, nil
745}
746
747// The Rep execLease.Replicate hands back is lease.Held's *lease.Replicator,
748// and SeedPlaced records a seed placed only through its SyncAndWait. Pinned
749// here so a change to either fails the build, not every seeded wake.
750var (
751 _ func(*lease.Held, context.Context, string, time.Duration) (*lease.Replicator, error) = (*lease.Held).Replicate
752 _ interface{ SyncAndWait(context.Context) error } = (*lease.Replicator)(nil)
753)
754
755func (l *execLease) Release(ctx context.Context) error { return l.held.Release(ctx) }
756
757// RetryRelease is releaseRetrier: the held lease's conditional retry of a
758// release that failed (#292).
759func (l *execLease) RetryRelease(ctx context.Context) error { return l.held.RetryRelease(ctx) }
760
761// Err is the held lease's liveness (leaseHealth): a failed renewal, a
762// spent handle or a poisoned one.
763func (l *execLease) Err() error { return l.held.Err() }
764
765// Restore puts the row's database in place for a wake. A row whose seed is
766// placeable (ready, swapped or placed) or committed goes through the seed
767// fallback (seedplace.go), which never reports fresh; every other row
768// restores plainly. A seed column that does not parse fails the wake:
769// reading it as "no seed" would boot a seeded instance empty.
770func (b *ExecBackend) Restore(ctx context.Context, rt registry.Route) (bool, error) {
771 src, err := seeds.ParseRowSeed(rt.SeedSource)
772 if err != nil {
773 return false, err
774 }
775 if seedRestores(src) {
776 return b.seededRestore(ctx, rt, src)
777 }
778 return b.restorePlain(ctx, rt)
779}
780
781// restorePlain is Restore for a row with no seed to place: the warm cache,
782// then the staging fallback, then the host's own replica, and fresh only
783// when there is none of the three.
784func (b *ExecBackend) restorePlain(ctx context.Context, rt registry.Route) (bool, error) {
785 host, dbPath := rt.Host, b.pathsFor(rt).dbPath
786 // A failed seed whose processes were not proven gone left its database
787 // and an obligation (Task 8): it is answered before the database can
788 // count as a warm cache, and before the marker check below can purge it.
789 if err := b.settleSeedObligation(ctx, rt, dbPath); err != nil {
790 return false, err
791 }
792 if err := reconcileSeedMarker(rt, dbPath); err != nil {
793 return false, err
794 }
795 if _, err := os.Stat(dbPath); err == nil {
796 return false, nil // warm cache — see doWake
797 } else if !os.IsNotExist(err) {
798 return false, err
799 }
800 // The DB is gone, so any leftover litestream sidecar state (meta dirs,
801 // WAL/SHM) describes a database that no longer exists — litestream's own
802 // recovery guidance is to delete it. Clear it so the restored copy starts
803 // a clean lineage. Two meta dirs: litestream's default suffix ("-litestream",
804 // its MetaDirSuffix — spelled out because this package never imports
805 // litestream) as any box-wide backup replicator would use, and the lease
806 // package's activator-scoped ".activator-<db>-litestream".
807 PurgeLocalDBArtifacts(dbPath, false)
808
809 // The staging fallback (issue #331): when a move staged a replica
810 // here, promote it (server-side, own bucket) and restore from the
811 // promoted copy. Staging wins when present (promoteStagedReplica,
812 // review round 1): a staged copy exists only while an inbound move is
813 // landing, so it is newer than any lineage the own prefix kept from an
814 // earlier stay on this box (the legacy cut does not delete the own
815 // prefix it leaves behind), and the promotion's mirror replaces it.
816 var promoted map[string]lease.MirrorObject
817 if b.Mirror != nil && !b.Phase1 {
818 var err error
819 if promoted, err = b.promoteStagedReplica(ctx, host); err != nil {
820 // HARD failure, same rule as restoreReplica's own error paths
821 // (review round 1): "continuing without it" here would fall
822 // through to a FRESH boot + seed, and once that empty database
823 // replicates, the staged data is shadowed forever. A failed
824 // wake retries; a seeded ghost does not.
825 return false, fmt.Errorf("staging fallback for %s: %w", host, err)
826 }
827 }
828
829 restored, err := restoreReplica(ctx, dbPath, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), b.Cfg.Region)
830 if err != nil {
831 // "We couldn't tell" is never reported as "there was nothing there":
832 // fresh stays false on every error path, so a bucket outage can't
833 // route a live instance into the seed.
834 return false, err
835 }
836 if restored {
837 // Prove the restored file before anything serves from it. A replica
838 // can come back damaged — a torn page, a truncated object, a lineage
839 // that never had a snapshot — and the alternatives to failing here are
840 // both worse than a down route: serving a corrupt database, or (if
841 // this returned fresh) running the route's seed over recoverable data.
842 if err := lease.CheckDB(dbPath); err != nil {
843 q := dbPath + quarantineSuffix
844 _ = os.Remove(q) // keep exactly one: the newest failure is the evidence
845 if mvErr := os.Rename(dbPath, q); mvErr != nil {
846 // Quarantine failed, so the live path may still hold the bad
847 // file — remove it rather than leave a warm cache that the
848 // next wake would boot with no check at all.
849 _ = os.Remove(dbPath)
850 log.Printf("activator: %s restored CORRUPT (%v) and could not be quarantined (%v) — removed", host, err, mvErr)
851 } else {
852 log.Printf("activator: %s restored CORRUPT from s3://%s/%s: %v — quarantined at %s, instance NOT started",
853 host, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), err, q)
854 }
855 for _, p := range []string{dbPath + "-wal", dbPath + "-shm"} {
856 _ = os.Remove(p)
857 }
858 // The next wake re-attempts the restore rather than refusing on
859 // sight of the quarantined file: a replaced or repaired replica
860 // must recover with no operator action. The price is a repeated
861 // restore for as long as the replica stays broken, which is the
862 // right way round — a permanently corrupt replica is an alarm
863 // someone is answering, not a steady state to optimise for.
864 return false, fmt.Errorf("restored replica for %s is corrupt: %w", host, err)
865 }
866 log.Printf("activator: restored %s from s3://%s/%s", host, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host))
867 // A clean restore of a copy promoted THIS call is the moment the
868 // staging has served its purpose; a corrupt one (above)
869 // deliberately LEFT it in place as evidence, and staging that was
870 // never consulted (an abort's leftovers under a live own lineage)
871 // is not this restore's to touch.
872 if len(promoted) > 0 {
873 b.dropStagedReplica(ctx, promoted)
874 }
875 } else {
876 // Never replicated: a brand-new instance boots fresh and creates its
877 // own DB — the register-only create path (provisioning as a metadata
878 // write) landing on its first wake. This — DB absent AND no replica —
879 // is the ONLY fresh boot, and the one moment the route's seed runs.
880 log.Printf("activator: no replica for %s in s3://%s/%s — booting fresh", host, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host))
881 }
882 return !restored, nil
883}
884
885// restoreReplica is lease.Restore behind a package var so the fresh-detection
886// matrix (replica present / absent / unreachable) is testable without S3.
887var restoreReplica = lease.Restore
888
889// Seed runs the route's first-boot initialization, once, before the instance
890// has ever been spawned (doWake calls it only when Restore reported fresh).
891//
892// THE CONTRACT, FOR APP AUTHORS: the platform invokes your binary with your
893// manifest's `seed` argv followed by `--db <path>` — you create your schema
894// and starter content in that database and exit 0. (`titogo seed` is the shape
895// this was named for.) It runs under the same merged env layers the instance
896// itself gets, so the credentials and config in your env file are there; it
897// does NOT get the purge-channel vars, which describe a serving process's
898// cache and mean nothing to a one-shot init — and it does not get them even
899// if your own env file sets them, those two names being the agent's to
900// author. A non-zero exit fails the wake:
901// a half-seeded instance must not serve.
902// Seed dispatches by layout, the same way Start does: a nested row seeds
903// through its own launch file and unit, a fenced or conflicting row
904// refuses, and a flat or foreign row seeds through today's exec child.
905func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error {
906 p := b.pathsFor(rt)
907 switch p.layout {
908 case LayoutNested:
909 return b.seedUnit(ctx, rt, nil, cgroup.Limit{})
910 case LayoutFenced, LayoutConflict:
911 return fmt.Errorf("%s: refusing to seed: %s", rt.Host, p.reason)
912 }
913 return b.seedChild(ctx, rt, b.liveLink(rt.Host), cgroup.Limit{})
914}
915
916// SeedExe is Seed on the identity a wake recorded (settings spec §4.2):
917// id.StagedPath is checked against id.Digest first, and a copy that
918// changed seeds nothing (never the live link instead). A flat or foreign
919// row execs that file by path. A nested row asks root to run the seed unit
920// on it, which root validates and binds in place of the live link. The
921// seed runs at id's own tier (GenTier, ruling R-TIER), never the tier of
922// whatever the live link names by then.
923func (b *ExecBackend) SeedExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) error {
924 if err := release.VerifyStaged(id); err != nil {
925 return fmt.Errorf("%s: not seeded with %s: %w", rt.Host, id.Version, err)
926 }
927 tier := b.GenTier(rt, id.Version)
928 p := b.pathsFor(rt)
929 switch p.layout {
930 case LayoutNested:
931 return b.seedUnit(ctx, rt, stagedExeOf(id), tier)
932 case LayoutFenced, LayoutConflict:
933 return fmt.Errorf("%s: refusing to seed: %s", rt.Host, p.reason)
934 }
935 return b.seedChild(ctx, rt, id.StagedPath, tier)
936}
937
938// seedArgv parses rt.Seed's JSON argv array and appends the --db flag, the
939// composition both spawn paths need identically (design §6.2). An empty
940// declared seed (an empty array, len 0) returns a nil argv, which both
941// callers treat as "nothing to run" (doWake only calls Seed for a
942// non-empty rt.Seed; this stays defensive rather than assuming that).
943func seedArgv(rt registry.Route, dbPath string) ([]string, error) {
944 var seedArgs []string
945 if err := json.Unmarshal([]byte(rt.Seed), &seedArgs); err != nil {
946 // Never a silent skip: a mangled seed column must fail the wake
947 // loudly, not boot an empty instance that looks healthy.
948 return nil, fmt.Errorf("parse seed argv for %s (%q): %w", rt.Host, rt.Seed, err)
949 }
950 if len(seedArgs) == 0 {
951 return nil, nil
952 }
953 return append(seedArgs, "--db", dbPath), nil
954}
955
956// removeSeedDB deletes a seed's own half-built database and its WAL/SHM
957// siblings, proven out of reach of its processes: the cleanup both spawn
958// paths and the next wake's discharge run when, and only when, the
959// database is the seed's own work (seedCreatedTheDB). Shared so the three
960// cannot drift on which files "the database" means. A file already gone
961// is fine; any other failure is returned, and the directory is synced so
962// the removals are durable before the caller removes the obligation.
963func removeSeedDB(dbPath string) error {
964 for _, p := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
965 if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) {
966 return fmt.Errorf("remove the seed's %s: %w", p, err)
967 }
968 }
969 if err := syncDir(filepath.Dir(dbPath)); err != nil {
970 return fmt.Errorf("sync %s after removing the seed's database: %w", filepath.Dir(dbPath), err)
971 }
972 return nil
973}
974
975// seedChild is Seed's flat body. exe is the file the seed execs: the live
976// link (Seed), which is materialized first if it is missing, or a staged
977// path SeedExe verified, which is exec'd by path whatever the link names.
978// With LiveExecGate set the live link is pinned instead (declinedLivePin)
979// and the file it named is exec'd. tier is that file's own; zero is the
980// live tier (b.tier).
981func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route, exe string, tier cgroup.Limit) error {
982 host := rt.Host
983 paths := b.pathsFor(rt)
984 argv, err := seedArgv(rt, paths.dbPath)
985 if err != nil {
986 return err
987 }
988 if argv == nil {
989 return nil // defensive: doWake only calls us for a non-empty seed
990 }
991 if exe == b.liveLink(host) {
992 if b.LiveExecGate != nil {
993 // A declined box seeds the build its gate passed, by path, at
994 // that build's tier (declinedLivePin), never the link. Before the
995 // database stat below: a refusal runs nothing and so can never
996 // count as a failed seed whose database is removed.
997 id, err := b.declinedLivePin(ctx, rt)
998 if err != nil {
999 return err
1000 }
1001 exe, tier = id.StagedPath, b.GenTier(rt, id.Version)
1002 } else if err := b.ensureLiveLink(ctx, host); err != nil {
1003 return err
1004 }
1005 }
1006 // Mint before the env is read, exactly as Start does — a seed is the FIRST
1007 // thing a fresh instance runs, so this is usually where the token comes
1008 // into existence, and instanceEnv's contract is that a seed sees the same
1009 // environment the instance will.
1010 if err := EnsureAdminToken(paths.envFile); err != nil {
1011 return fmt.Errorf("admin token for %s: %w", host, err)
1012 }
1013 // The instance key, before the env is read for the same reason: a seed
1014 // is usually the first process of a fresh instance (L21).
1015 if _, err := b.ensureInstanceKeyFor(ctx, rt, paths); err != nil {
1016 return fmt.Errorf("instance key for %s: %w", host, err)
1017 }
1018 fileEnv, keyLayer, err := b.instanceEnvLayer(rt)
1019 if err != nil {
1020 return err
1021 }
1022 // A seed has no control socket, so a replica's seed is gated as a
1023 // launch without one (ruling 26), on the layer this same read found.
1024 if err := b.replicaKeyGate(rt, fileEnv, keyLayer, false); err != nil {
1025 return err
1026 }
1027 dbPath := paths.dbPath
1028
1029 // Whether the database existed BEFORE the seed ran decides whether a
1030 // failed seed may clean up after itself (below). On the wake path it is
1031 // always absent — Restore reported fresh.
1032 _, statErr := os.Stat(dbPath)
1033 seedOwnsDB := seedCreatedTheDB(statErr)
1034
1035 ctx, cancel := context.WithTimeout(ctx, seedTimeout)
1036 defer cancel()
1037 var stderr capBuffer
1038 log.Printf("activator: seeding %s: %s %s", host, exe, strings.Join(argv, " "))
1039 // The seed shares the instance's carlos-exec@<host> cgroup: first-boot
1040 // cost is that instance's work. No CleanupHost here — the instance spawn
1041 // reuses the dir moments later, and its exit path (or the next agent
1042 // start's sweep) removes it.
1043 child, _, perr := b.placeFor(spawnSeed, 0)
1044 if perr != nil {
1045 return perr
1046 }
1047 // The obligation goes on disk, durably, before the seed's first
1048 // instruction (Task 8 fix round 1): from here until its processes are
1049 // proven gone, nothing may treat the database as warm, whatever happens
1050 // to the seed, its disk or this agent. A write that fails refuses the
1051 // seed with nothing run.
1052 if err := recordSeedObligation(host, dbPath, seedObligation{ownsDB: seedOwnsDB, layout: LayoutFlat}); err != nil {
1053 return err
1054 }
1055 cmd, settle, err := b.spawnInCgroupAtTier(rt, child, false, tier, func() *exec.Cmd {
1056 cmd := exec.CommandContext(ctx, exe, argv...)
1057 // The control-channel vars are agent-owned in BOTH directions here too
1058 // (Start's discipline, see withoutVars), and here they are always
1059 // STRIPPED: the seed gets no control listener of its own, so fileEnv is
1060 // tenant-supplied and without this a tenant's own env file could hand
1061 // its seed a CARLOS_CONTROL_SOCKET the agent never opened and a
1062 // CARLOS_PURGE for a channel that does not exist — and the seed has no
1063 // serving cache to purge, nor any business setting timers, in any case.
1064 // This is what makes the contract above true rather than merely
1065 // intended.
1066 cmd.Env = childEnv(seedVars(fileEnv, host))
1067 // Like the instance child, the seed logs through the agent's own streams
1068 // (journald under systemd) — and its stderr is ALSO teed into a bounded
1069 // buffer, because the wake error this returns is the whole diagnosis an
1070 // operator gets for a host that refuses to come up. With Logs set the
1071 // seed's output ships to `carlos logs` too: it is app output, and on a
1072 // failed first wake it is the diagnosis the MEMBER gets.
1073 cmd.Stdout = os.Stdout
1074 cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
1075 if b.Logs != nil {
1076 acct, app := release.RouteAccount(rt), rt.App
1077 emit := func(line string) { b.Logs.App(acct, app, host, line) }
1078 cmd.Stdout = io.MultiWriter(os.Stdout, applog.NewLineWriter(execLogLineMax, emit))
1079 cmd.Stderr = io.MultiWriter(os.Stderr, &stderr, applog.NewLineWriter(execLogLineMax, emit))
1080 }
1081 // WaitDelay is what makes seedTimeout real. Because Stderr is a plain
1082 // io.Writer (the tee), os/exec pipes it and Wait waits for that pipe to
1083 // close — and a seed that spawned its own grandchild holds the write end
1084 // open long after the timeout killed the seed itself. Without this, a
1085 // wedged seed would hold the lease until the wake's own 5m budget expired.
1086 cmd.WaitDelay = seedDrainGrace
1087 return cmd
1088 }, pendingRecordHook(ctx)) // the wake's record, at the exec (amadan #272)
1089 spawned := err == nil
1090 if spawned {
1091 err = cmd.Wait()
1092 settle()
1093 }
1094 // WaitDelay reports on the SUCCESS path too: a seed that exits 0 after
1095 // backgrounding something (a helper, a stray `&`) leaves the inherited
1096 // stderr pipe open, and Wait then returns ErrWaitDelay INSTEAD of nil
1097 // even though the process exited 0. Treating that as a failure would
1098 // fail the wake AND delete the database the seed had just correctly
1099 // built — leaving the host fresh again next wake, i.e. permanently
1100 // un-wakeable. The exit status is the verdict; the leaked pipe is a
1101 // log line.
1102 exitedZero := spawned && (err == nil || (errors.Is(err, exec.ErrWaitDelay) && cmd.ProcessState != nil && cmd.ProcessState.Success()))
1103 var failure error
1104 if !exitedZero {
1105 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
1106 err = fmt.Errorf("timed out after %s and was killed: %w", seedTimeout, err)
1107 }
1108 failure = fmt.Errorf("seeding %s: %w", host, err)
1109 if tail := strings.TrimSpace(stderr.String()); tail != "" {
1110 failure = fmt.Errorf("seeding %s: %w: %s", host, err, tail)
1111 }
1112 }
1113 // What the seed left is proven gone before its database is touched
1114 // (Task 8). CommandContext and Wait speak for the leader only, and a
1115 // process it forked keeps writing the database, a removed one included.
1116 // A failed seed's cgroup is killed and proven empty: flag on its role
1117 // child, flag off carlos-exec@<host>, which holds nothing else yet (a
1118 // fresh host, before the spawn, under the wake's lease). A successful
1119 // seed's role child is too (flag on): a helper that outlived the seed
1120 // would otherwise run on beside the instance, out of reach of any
1121 // generation kill. Flag off, a successful seed's helper stays in the
1122 // host cgroup, where the terminal stop's proof (stopHostProven) finds
1123 // it. A spawn that failed ran nothing.
1124 if spawned && (failure != nil || child != "") {
1125 pctx, pcancel := context.WithTimeout(context.WithoutCancel(ctx), actStopTimeout)
1126 perr := b.seedDescendantsGone(pctx, rt, child)
1127 pcancel()
1128 if perr != nil {
1129 // The database is kept, as seedUnitFailed keeps it: removing it
1130 // under a live writer is the hazard itself. The obligation
1131 // written ahead stays, so every later wake refuses it as warm
1132 // until the proof holds (Restore), and the caller leaves the
1133 // lease to its TTL (ErrSeedUnproven).
1134 if failure == nil {
1135 failure = fmt.Errorf("seed for %s: %w: processes remain after a successful seed: %v", host, ErrSeedUnproven, perr)
1136 } else {
1137 failure = fmt.Errorf("%w: %w (the seed's processes remain after stopping it; its database was left in place: %v)", failure, ErrSeedUnproven, perr)
1138 }
1139 log.Printf("activator: %s: the seed's processes are not proven gone; its database is kept and %s refuses it as warm until they are", host, dbPath+seedUnprovenSuffix)
1140 return failure
1141 }
1142 b.Cgroups.RemoveChild(host, child) // the seed's own child only; no-op for "" (flag off)
1143 }
1144 // From here the seed's processes are proven gone (or a spawn that
1145 // failed ran nothing, or a flag-off success's helper is the terminal
1146 // stop's to prove), so the obligation is discharged. A failure takes
1147 // the half-built database with it first. Failing the wake is only half
1148 // the promise: the file a crashed seed left behind would look like a
1149 // warm cache to the NEXT wake (DB present, so not fresh, so no seed) and
1150 // the instance would boot on it, which is the half-seeded instance this
1151 // whole path exists to prevent. Only a database that wasn't there when
1152 // we started is ours to remove, and under the lease, with the seed's
1153 // processes proven gone above, nothing else can have written it. A
1154 // discharge that fails keeps the obligation and fails the wake while
1155 // nothing serves; the next wake proves and removes again. A successful
1156 // flag-off seed's failed discharge first proves its processes gone, since
1157 // no instance will (ErrSeedUnproven otherwise).
1158 if derr := dischargeSeedObligation(dbPath, failure != nil && seedOwnsDB); derr != nil {
1159 unprovedSuccess := spawned && failure == nil && child == "" // the proof above was skipped
1160 if failure == nil {
1161 failure = fmt.Errorf("seed for %s: %w", host, derr)
1162 } else {
1163 failure = fmt.Errorf("%w; and %w", failure, derr)
1164 }
1165 if unprovedSuccess {
1166 // A successful flag-off seed has no proof of its own: its helper
1167 // is left for the instance's terminal stop to prove. With the
1168 // discharge failed the wake fails, so no instance will run to
1169 // prove it, and releasing the lease over it would be the hazard
1170 // itself (fix wave 1, item 2): the proof is tried now. Unproven,
1171 // the seed is ErrSeedUnproven and the lease is left to its TTL.
1172 pctx, pcancel := context.WithTimeout(context.WithoutCancel(ctx), actStopTimeout)
1173 perr := b.seedDescendantsGone(pctx, rt, child)
1174 pcancel()
1175 if perr != nil {
1176 log.Printf("activator: %s: the seed's obligation could not be discharged and its processes are not proven gone; %s stays", host, dbPath+seedUnprovenSuffix)
1177 return fmt.Errorf("%w: %w: the seed's processes are not proven gone: %v", failure, ErrSeedUnproven, perr)
1178 }
1179 }
1180 return failure
1181 }
1182 if failure == nil {
1183 if err != nil {
1184 log.Printf("activator: seeded %s (it exited 0 but left a child holding its output pipe open for more than %s)", host, seedDrainGrace)
1185 } else {
1186 log.Printf("activator: seeded %s", host)
1187 }
1188 return nil
1189 }
1190 return failure
1191}
1192
1193// seedDescendantsGone kills what a flat seed left and proves it gone: flag
1194// on, its own role child (child); flag off, carlos-exec@<host> (""), which
1195// holds nothing else yet: a fresh host, before the spawn, under the wake's
1196// lease. An absent cgroup is empty. A box with no delegated cgroup
1197// (cgroup.ErrNoDelegation) ran the seed unaccounted, with nothing that
1198// could reach a descendant, so the leader's exit is the only proof there
1199// is (ledger 127): it logs and answers nil. So is a kernel with no
1200// cgroup.kill (cgroup.ErrNoKill). Any other failure is returned, and the
1201// caller keeps the database (ErrSeedUnproven).
1202func (b *ExecBackend) seedDescendantsGone(ctx context.Context, rt registry.Route, child string) error {
1203 var err error
1204 switch {
1205 case b.cgroupKill != nil:
1206 err = b.cgroupKill(ctx, rt, child)
1207 case child == "":
1208 err = b.KillHost(ctx, rt)
1209 default:
1210 err = b.killChild(ctx, rt.Host, child)
1211 }
1212 if errors.Is(err, cgroup.ErrNoDelegation) {
1213 log.Printf("activator: %s: the seed ran with no delegated cgroup; its leader's exit is the only proof there is", rt.Host)
1214 return nil
1215 }
1216 if errors.Is(err, cgroup.ErrNoKill) {
1217 // A kernel before 5.14 can kill nothing in a cgroup (EmptyHost's
1218 // rule): read as unproven, an obligation could never be discharged
1219 // on such a box and the row would stay down for good.
1220 log.Printf("activator: %s: this kernel has no cgroup.kill; the seed's leader's exit is the only proof there is", rt.Host)
1221 return nil
1222 }
1223 return err
1224}
1225
1226// seedObligation is what <db>.seed-unproven records, written ahead of a
1227// seed (Task 8 fix round 1): before the seed's first instruction runs, so
1228// a seed that dies of a full disk, or an agent that dies mid-seed, cannot
1229// leave a partial database with nothing to refuse it. It is removed only
1230// once the seed's processes are proven gone (dischargeSeedObligation).
1231//
1232// ownsDB is seedCreatedTheDB at the seed's start: only then is the
1233// database the obligation's to remove. layout is where the seed ran:
1234// LayoutNested is root's seed unit in the host slice, not
1235// carlos-exec@<host>, so its proof is that unit's (nestedSeedGone);
1236// LayoutFlat is an exec child; "" is an obligation that does not say.
1237type seedObligation struct {
1238 ownsDB bool
1239 layout Layout
1240}
1241
1242// The obligation's body: three lines, the first a version. Anything a
1243// reader does not recognise reads as the cautious answer: a database that
1244// was not the seed's (kept), and the layout the row reads now.
1245const (
1246 seedObligationHeader = "carlos seed obligation v1"
1247 seedObligationOwned = "db=owned-by-seed"
1248 seedObligationKept = "db=there-before-seed"
1249 seedObligationFlat = "seed=flat"
1250 seedObligationNested = "seed=nested"
1251)
1252
1253func (o seedObligation) body() []byte {
1254 db, seed := seedObligationKept, seedObligationFlat
1255 if o.ownsDB {
1256 db = seedObligationOwned
1257 }
1258 if o.layout == LayoutNested {
1259 seed = seedObligationNested
1260 }
1261 return []byte(seedObligationHeader + "\n" + db + "\n" + seed + "\n")
1262}
1263
1264// recordSeedObligation writes the obligation ahead of a seed, durably
1265// (safefile: file fsync, rename, dir fsync). One that is already there is
1266// an earlier seed not yet answered (Restore answers it before any seed of
1267// a wake): it is never overwritten, since that could lose what it says the
1268// earlier seed owned, and the seed is refused with ErrSeedUnproven. A write
1269// that fails refuses the seed as an ordinary error: nothing ran.
1270func recordSeedObligation(host, dbPath string, o seedObligation) error {
1271 ob := dbPath + seedUnprovenSuffix
1272 if _, err := os.Lstat(ob); err == nil {
1273 return fmt.Errorf("%s: refusing to seed: %w: an earlier seed's obligation %s is not answered", host, ErrSeedUnproven, ob)
1274 } else if !errors.Is(err, fs.ErrNotExist) {
1275 return fmt.Errorf("%s: refusing to seed: read the seed obligation %s: %w", host, ob, err)
1276 }
1277 if err := writeSeedObligationFile(ob, o.body(), 0o600); err != nil {
1278 return fmt.Errorf("%s: refusing to seed: record its obligation %s: %w", host, ob, err)
1279 }
1280 return nil
1281}
1282
1283// readSeedObligation reads dbPath's obligation. present is false only on a
1284// definite "not there"; any other failure to look is an error, never "no
1285// obligation".
1286//
1287// The name sits beside the database, on ground the instance itself can
1288// write, so it is opened as the env files are (openOwnEnvNoFollow): a
1289// symlink or a FIFO planted there is refused in the open itself, never
1290// followed or waited on. Such an entry was not written by a seed, so it
1291// reads as an obligation that does not say (the zero value): it is proven
1292// and removed like any other, and the database beside it is kept, which
1293// neither hangs the wake nor holds the row down.
1294func readSeedObligation(dbPath string) (o seedObligation, present bool, err error) {
1295 f, err := openOwnEnvNoFollow(dbPath+seedUnprovenSuffix, os.O_RDONLY, 0)
1296 if errors.Is(err, fs.ErrNotExist) {
1297 return seedObligation{}, false, nil
1298 }
1299 if errors.Is(err, errOwnEnvSymlink) || errors.Is(err, errOwnEnvNotRegular) {
1300 return seedObligation{}, true, nil
1301 }
1302 if err != nil {
1303 return seedObligation{}, true, err
1304 }
1305 raw, err := io.ReadAll(io.LimitReader(f, 4<<10))
1306 f.Close()
1307 if err != nil {
1308 return seedObligation{}, true, err
1309 }
1310 lines := strings.Split(string(raw), "\n")
1311 if len(lines) >= 3 && lines[0] == seedObligationHeader {
1312 o.ownsDB = lines[1] == seedObligationOwned
1313 switch lines[2] {
1314 case seedObligationNested:
1315 o.layout = LayoutNested
1316 case seedObligationFlat:
1317 o.layout = LayoutFlat
1318 }
1319 }
1320 return o, true, nil
1321}
1322
1323// dischargeSeedObligation runs once a seed's processes are proven gone:
1324// the database files go first when removeDB (removeSeedDB, every error
1325// checked and the directory synced), and only then the obligation, synced
1326// too, so a crash between the two leaves the obligation, never a database
1327// without it. Any failure keeps the obligation, and the next wake proves
1328// and removes again.
1329func dischargeSeedObligation(dbPath string, removeDB bool) error {
1330 if removeDB {
1331 if err := removeSeedDB(dbPath); err != nil {
1332 return err
1333 }
1334 }
1335 ob := dbPath + seedUnprovenSuffix
1336 if err := os.Remove(ob); err != nil && !errors.Is(err, fs.ErrNotExist) {
1337 return fmt.Errorf("remove the seed obligation %s: %w", ob, err)
1338 }
1339 if err := syncDir(filepath.Dir(dbPath)); err != nil {
1340 return fmt.Errorf("sync %s after removing the seed obligation: %w", filepath.Dir(dbPath), err)
1341 }
1342 return nil
1343}
1344
1345// settleSeedObligation answers an earlier seed's obligation before anything
1346// treats dbPath as warm. With none there it does nothing. Otherwise the
1347// seed's processes are proven gone first (seedObligationProof). A proof
1348// that fails refuses the wake with ErrSeedUnproven and removes nothing.
1349// One that holds discharges the obligation: a database the seed owned goes
1350// with it, so the wake goes on as a first boot and the seed runs again; a
1351// database that was there before the seed stays, and is warm. The
1352// obligation is a file, so it survives an agent restart.
1353func (b *ExecBackend) settleSeedObligation(ctx context.Context, rt registry.Route, dbPath string) error {
1354 host := rt.Host
1355 o, present, err := readSeedObligation(dbPath)
1356 if !present {
1357 return nil
1358 }
1359 if err != nil {
1360 // "Couldn't tell" is never "no obligation", and never "proven
1361 // gone" either (fix wave 1, item 2): ErrSeedUnproven leaves the
1362 // lease to its TTL. A nested wake runs no host proof before this,
1363 // so nothing else says the seed's processes are gone.
1364 return fmt.Errorf("%s: %w: read the seed obligation %s: %w", host, ErrSeedUnproven, dbPath+seedUnprovenSuffix, err)
1365 }
1366 if err := b.seedObligationProof(ctx, rt, o); err != nil {
1367 return fmt.Errorf("%s: %w: an earlier seed's processes are not proven gone, so its database is not used: %v", host, ErrSeedUnproven, err)
1368 }
1369 if err := dischargeSeedObligation(dbPath, o.ownsDB); err != nil {
1370 return fmt.Errorf("%s: after an earlier seed proven gone: %w", host, err)
1371 }
1372 if o.ownsDB {
1373 log.Printf("activator: %s: the earlier seed's processes are proven gone; its database is removed and the seed runs again", host)
1374 } else {
1375 log.Printf("activator: %s: the earlier seed's processes are proven gone; the database that was there before it is kept", host)
1376 }
1377 return nil
1378}
1379
1380// seedObligationProof proves an obligation's seed gone, by where it ran. A
1381// nested seed ran as root's seed unit in the host slice: that unit is
1382// stopped and its cgroup proven empty (nestedSeedGone). A flat seed ran in
1383// carlos-exec@<host>, flag off in it and flag on in its role child, so the
1384// host cgroup is proven, whatever CARLOS_SWAP says now: it holds the role
1385// child too, so it covers the seed whichever way it ran. An obligation that
1386// does not say (an unknown body) is proven by the layout the row reads now.
1387//
1388// Killing there is safe: this runs under the wake's lease, before anything
1389// of this wake has run, and no other entry of the host is live (an unproven
1390// failed wake or stray sidecar refuses the wake before the lease).
1391func (b *ExecBackend) seedObligationProof(ctx context.Context, rt registry.Route, o seedObligation) error {
1392 layout := o.layout
1393 if layout == "" {
1394 layout = b.pathsFor(rt).layout
1395 }
1396 if layout == LayoutNested {
1397 return b.nestedSeedGone(ctx, rt)
1398 }
1399 pctx, cancel := context.WithTimeout(ctx, actStopTimeout)
1400 defer cancel()
1401 return b.seedDescendantsGone(pctx, rt, "")
1402}
1403
1404// nestedSeedGone stops root's seed unit for rt and proves its cgroup empty
1405// (waitSeedCgroupEmpty), each bounded and detached from ctx's own
1406// cancellation, which may be why the seed failed. A failed stop is logged:
1407// the empty cgroup is the proof.
1408func (b *ExecBackend) nestedSeedGone(ctx context.Context, rt registry.Route) error {
1409 if b.Control == nil {
1410 return fmt.Errorf("%s: instance control channel not configured", rt.Host)
1411 }
1412 sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), instancectl.DeadlineControl)
1413 if err := b.Control.Stop(sctx, rt.Host, instancectl.RoleSeed); err != nil {
1414 log.Printf("activator: %s: stop seed unit: %v", rt.Host, err)
1415 }
1416 cancel()
1417 wctx, wcancel := context.WithTimeout(context.WithoutCancel(ctx), actStopTimeout)
1418 defer wcancel()
1419 return b.waitSeedCgroupEmpty(wctx, rt)
1420}
1421
1422// seedCreatedTheDB decides, from the stat taken just BEFORE a seed ran,
1423// whether the database at that path can only be the seed's own work — the
1424// single condition under which a failed seed's cleanup may delete it.
1425//
1426// Fail-safe by construction: ONLY a definite "not there" says yes. A stat that
1427// merely failed — a permission error, an I/O error, a path that stopped being
1428// traversable — means "there might be data here", and a guard on deleting live
1429// data must never arm itself because we couldn't look.
1430func seedCreatedTheDB(statErr error) bool { return os.IsNotExist(statErr) }
1431
1432// seedTimeout bounds a first-boot seed run — a wedged seed must fail the wake
1433// (which releases the lease) rather than sit on it. seedDrainGrace is how long
1434// Wait then waits for the killed seed's output pipes to close before giving up
1435// on them (cmd.WaitDelay). Both vars so tests can shrink them.
1436var (
1437 seedTimeout = 2 * time.Minute
1438 seedDrainGrace = 10 * time.Second
1439)
1440
1441// capBuffer keeps at most capBufferMax bytes of what's written to it and
1442// discards the rest, so quoting a child's stderr in an error can never turn a
1443// chatty seed into an unbounded string. Locked because it is read after
1444// the seed's Wait returns while os/exec's own copy goroutine may still be
1445// writing: on a WaitDelay expiry (the wedged-seed path) Wait returns without
1446// waiting for that copy to finish.
1447type capBuffer struct {
1448 mu sync.Mutex
1449 b []byte
1450}
1451
1452const capBufferMax = 4 << 10
1453
1454func (c *capBuffer) Write(p []byte) (int, error) {
1455 c.mu.Lock()
1456 defer c.mu.Unlock()
1457 if room := capBufferMax - len(c.b); room > 0 {
1458 if len(p) < room {
1459 room = len(p)
1460 }
1461 c.b = append(c.b, p[:room]...)
1462 }
1463 return len(p), nil
1464}
1465
1466func (c *capBuffer) String() string {
1467 c.mu.Lock()
1468 defer c.mu.Unlock()
1469 return string(c.b)
1470}
1471
1472// seedUnit is Seed's nested body: the same contract as seedChild, namely
1473// the merged env, the CARLOS_ADMIN_TOKEN mint, and the cleanup on a failure
1474// this call itself caused, run through the root instance-control channel
1475// instead of a direct exec (design §6.2). Argv and env come from the same
1476// seedArgv and withoutVars calls seedChild uses, so the two cannot drift.
1477//
1478// A nil exe seeds on the live link (Seed). A staged exe is the build the
1479// wake recorded (SeedExe): root validates it and binds the seed unit's app
1480// mount to it, so the live link need not exist and a repoint of it
1481// changes nothing. A control channel that cannot name it refuses the seed.
1482// With LiveExecGate set a nil exe pins the live link (declinedLivePin) and
1483// seeds that file the same way. tier is that build's own (ruling R-TIER);
1484// zero is the live tier.
1485func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route, exe *instancectl.StagedExe, tier cgroup.Limit) error {
1486 if b.Cfg.HostWrites.Enabled {
1487 return ErrClaimIsolationUnavailable
1488 }
1489 host := rt.Host
1490 if err := b.nestedGate(rt); err != nil {
1491 return err
1492 }
1493 if exe == nil && b.LiveExecGate != nil {
1494 // A declined box names the build its gate passed to root, at that
1495 // build's tier (declinedLivePin), as SeedExe does. Before anything
1496 // is prepared and before the database stat below, so a refusal asks
1497 // root for nothing and removes nothing.
1498 id, err := b.declinedLivePin(ctx, rt)
1499 if err != nil {
1500 return err
1501 }
1502 exe, tier = stagedExeOf(id), b.GenTier(rt, id.Version)
1503 }
1504 if tier == (cgroup.Limit{}) {
1505 tier = b.tier(rt)
1506 }
1507 var xc StagedExeControl
1508 if exe == nil {
1509 if err := b.ensureLiveLink(ctx, host); err != nil {
1510 return err
1511 }
1512 } else {
1513 var err error
1514 if xc, err = b.stagedExeControl(); err != nil {
1515 return fmt.Errorf("%s: not seeded with %s: %w", host, exe.Version, err)
1516 }
1517 }
1518 p, _, logs, fileEnv, err := b.prepare(rt, instancectl.RoleSeed)
1519 if err != nil {
1520 return err
1521 }
1522 defer logs.Close()
1523 dbPath := p.dbPath
1524 seedArgs, err := seedArgv(rt, dbPath)
1525 if err != nil {
1526 return err
1527 }
1528 if seedArgs == nil {
1529 return nil // defensive: doWake only calls us for a non-empty seed
1530 }
1531 argv := append([]string{b.liveLink(host)}, seedArgs...)
1532 env := childEnv(seedVars(fileEnv, host))
1533 if err := b.writeLaunch(p, instancectl.RoleSeed, host, argv, env); err != nil {
1534 return err
1535 }
1536 // Whether the database existed BEFORE the seed ran decides whether a
1537 // failed seed may clean up after itself below, seedChild's rule,
1538 // unchanged for the unit path.
1539 _, statErr := os.Stat(dbPath)
1540 seedOwnsDB := seedCreatedTheDB(statErr)
1541 // The obligation is written ahead, as seedChild's is, before root is
1542 // asked to run anything; it names the seed unit as the proof to ask.
1543 if err := recordSeedObligation(host, dbPath, seedObligation{ownsDB: seedOwnsDB, layout: LayoutNested}); err != nil {
1544 return err
1545 }
1546 // Then the wake's pending record (amadan #272), the last step before
1547 // root is asked, as the flat seed's comes at its exec after its
1548 // obligation (Astra review of zdd-owed-items, P2): an obligation that
1549 // cannot be written records nothing. A record that fails, or a recheck
1550 // that refuses after it, runs no seed, so the obligation just written
1551 // is discharged and the database, which no seed touched, is kept.
1552 if _, err := runPendingRecord(ctx); err != nil {
1553 cause := fmt.Errorf("seed for %s: %w", host, err)
1554 if derr := dischargeSeedObligation(dbPath, false); derr != nil {
1555 return fmt.Errorf("%w; and %w", cause, derr)
1556 }
1557 return cause
1558 }
1559
1560 log.Printf("activator: seeding %s: %s", host, strings.Join(argv, " "))
1561 // The client already bounds a seed call at instancectl.DeadlineSeed.
1562 // Wrapping ctx any tighter here (seedTimeout, at 2m, is shorter) would
1563 // guarantee the failure path on every seed that legitimately runs
1564 // longer than that. Wrapping at the SAME deadline is redundant with the
1565 // client's own timeout but harmless, and keeps this call visibly
1566 // bounded without second-guessing how long a seed may take.
1567 sctx, cancel := context.WithTimeout(ctx, instancectl.DeadlineSeed)
1568 var res instancectl.SeedResponse
1569 if exe != nil {
1570 res, err = xc.SeedExe(sctx, host, tier.MemoryMax, *exe)
1571 } else {
1572 res, err = b.Control.Seed(sctx, host, tier.MemoryMax)
1573 }
1574 cancel()
1575 if errors.Is(err, instancectl.ErrStagedExe) {
1576 // Root refused the recorded build before it laid out or started
1577 // anything (403), so no seed ran: nothing to stop, and the
1578 // database is not the seed's work. Its obligation is discharged.
1579 cause := fmt.Errorf("seed for %s: %w", host, err)
1580 if derr := dischargeSeedObligation(dbPath, false); derr != nil {
1581 return fmt.Errorf("%w; and %w", cause, derr)
1582 }
1583 return cause
1584 }
1585 if err != nil {
1586 return b.seedUnitFailed(ctx, rt, dbPath, seedOwnsDB, fmt.Errorf("seed for %s: %w", host, err))
1587 }
1588 if res.Result != "success" {
1589 return b.seedUnitFailed(ctx, rt, dbPath, seedOwnsDB, fmt.Errorf("seed for %s failed: exit %s (%s)", host, res.Status, res.Result))
1590 }
1591 // A successful seed is not done until its cgroup is empty (design
1592 // §6.2): the control service already waited before answering, but the
1593 // activator's own proof is what lets the wake go on to Start with no
1594 // seed process still holding the database. Unproven, the obligation
1595 // written ahead stays.
1596 wctx, wcancel := context.WithTimeout(context.WithoutCancel(ctx), actStopTimeout)
1597 emptyErr := b.waitSeedCgroupEmpty(wctx, rt)
1598 wcancel()
1599 if emptyErr != nil {
1600 return fmt.Errorf("seed for %s: %w: processes remain after a successful seed: %v", host, ErrSeedUnproven, emptyErr)
1601 }
1602 if derr := dischargeSeedObligation(dbPath, false); derr != nil {
1603 return fmt.Errorf("seed for %s: %w", host, derr)
1604 }
1605 log.Printf("activator: seeded %s", host)
1606 return nil
1607}
1608
1609// seedUnitFailed is seedUnit's shared failure tail: on EITHER failure
1610// shape, whether Control.Seed itself errored or it answered with a
1611// non-success result, the seed unit is stopped and its cgroup is proven
1612// empty (nestedSeedGone) BEFORE any database cleanup runs, never after.
1613// Running the cleanup on the bare result instead could race a seed process
1614// SIGKILL hadn't actually reaped yet, exactly the class of bug Stop's own
1615// ErrStillRunning discipline exists to rule out elsewhere.
1616// The database goes only when seedOwnsDB AND the proof held, and then the
1617// obligation (dischargeSeedObligation); when the proof failed, nothing is
1618// removed, the obligation stays and the returned error says so, because a
1619// database is not safe to delete out from under a process that might still
1620// be writing it.
1621func (b *ExecBackend) seedUnitFailed(ctx context.Context, rt registry.Route, dbPath string, seedOwnsDB bool, cause error) error {
1622 if emptyErr := b.nestedSeedGone(ctx, rt); emptyErr != nil {
1623 return fmt.Errorf("%w: %w (the seed's processes remain after stopping it; its database was left in place: %v)", cause, ErrSeedUnproven, emptyErr)
1624 }
1625 if derr := dischargeSeedObligation(dbPath, seedOwnsDB); derr != nil {
1626 return fmt.Errorf("%w; and %w", cause, derr)
1627 }
1628 return cause
1629}
1630
1631// waitSeedCgroupEmpty polls the seed unit's cgroup.events until it reports
1632// empty or roleEmptyBound elapses, honouring ctx and b.ObservePoll like the
1633// exit observer (unitproc.go). A timeout or a read error is returned to the
1634// caller as a log line, not a failure: it is the deciding result's
1635// confidence that is at stake here, not the wake itself.
1636func (b *ExecBackend) waitSeedCgroupEmpty(ctx context.Context, rt registry.Route) error {
1637 return b.waitRoleCgroupEmpty(ctx, rt, instancectl.RoleSeed)
1638}
1639
1640// roleEmptyBound is how long waitRoleCgroupEmpty waits for a role's cgroup
1641// to empty: actStopTimeout. A var so tests can shrink it.
1642var roleEmptyBound = actStopTimeout
1643
1644// waitRoleCgroupEmpty polls role's unit's cgroup.events until it reports
1645// empty or roleEmptyBound elapses, honouring ctx and b.ObservePoll.
1646func (b *ExecBackend) waitRoleCgroupEmpty(ctx context.Context, rt registry.Route, role instancectl.Role) error {
1647 events := b.CgroupEvents
1648 if events == nil {
1649 events = cgroupEventsUnder(b.cgroupRoot())
1650 }
1651 poll := b.ObservePoll
1652 if poll <= 0 {
1653 poll = time.Second
1654 }
1655 slice := instancectl.SliceName(rt.Host)
1656 unit := instancectl.UnitName(role, rt.Host)
1657 bound := roleEmptyBound
1658 deadline := time.Now().Add(bound)
1659 for {
1660 exists, populated, err := events(slice, unit)
1661 if err != nil {
1662 return err
1663 }
1664 if !exists || !populated {
1665 return nil
1666 }
1667 if time.Now().After(deadline) {
1668 return fmt.Errorf("processes remained after %s", bound)
1669 }
1670 select {
1671 case <-ctx.Done():
1672 return ctx.Err()
1673 case <-time.After(poll):
1674 }
1675 }
1676}
1677
1678// NextDue asks the (still-running) instance for its earliest pending scheduled
1679// delivery over its socket. The endpoint is admin-gated like every instance
1680// data endpoint, so present the instance's own admin token — read from the
1681// same env file the spawn path hands the child (the activator holds it either
1682// way).
1683func (b *ExecBackend) NextDue(ctx context.Context, rt registry.Route) (int64, error) {
1684 return b.NextDueVia(ctx, rt, rt.Socket)
1685}
1686
1687// NextDueVia is NextDue dialling socket instead of rt.Socket: the activator
1688// passes the instance's current generation socket (AppSocket, zero-downtime
1689// spec §7.1), because with generations on nothing listens on the canonical
1690// path. With them off the activator passes rt.Socket, which is NextDue.
1691func (b *ExecBackend) NextDueVia(ctx context.Context, rt registry.Route, socket string) (int64, error) {
1692 paths := b.pathsFor(rt)
1693 // A nested host's env file lives on tenant-writable ground (design
1694 // §3.2), the same as instanceEnv's read of it: go through instancefs
1695 // rather than a plain read, so a name planted there cannot be anything
1696 // but a regular file, on pain of an error instead of a hang.
1697 read := readEnvFile
1698 if paths.layout == LayoutNested {
1699 read = func(path string) (map[string]string, error) {
1700 return readEnvFileBeneath(filepath.Dir(path), filepath.Base(path))
1701 }
1702 }
1703 fileEnv, err := read(paths.envFile)
1704 if err != nil {
1705 return 0, fmt.Errorf("read env file: %w", err)
1706 }
1707 token := fileEnv["CARLOS_ADMIN_TOKEN"]
1708 if token == "" {
1709 return 0, errors.New("no CARLOS_ADMIN_TOKEN in env file")
1710 }
1711 req, err := http.NewRequestWithContext(ctx, "GET", "http://carlos/api/next-due", nil)
1712 if err != nil {
1713 return 0, err
1714 }
1715 req.Header.Set("Authorization", "Bearer "+token)
1716 resp, err := socketClient(socket).Do(req)
1717 if err != nil {
1718 return 0, err
1719 }
1720 defer resp.Body.Close()
1721 if resp.StatusCode != http.StatusOK {
1722 return 0, fmt.Errorf("GET /api/next-due: HTTP %d", resp.StatusCode)
1723 }
1724 var body struct {
1725 Due int64 `json:"due"`
1726 }
1727 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<12)).Decode(&body); err != nil {
1728 return 0, fmt.Errorf("decode /api/next-due: %w", err)
1729 }
1730 return body.Due, nil
1731}
1732
1733// ensureLiveLink verifies host's live symlink (<OptDir>/live/<host> → a staged
1734// version) exists before the exec, routing a missing one through the
1735// Materialize hook when configured. A materialize failure (e.g. the staging
1736// mechanism unreachable) must propagate — the exact live symptom found
1737// 2026-07-13 in Eleven (a 503 "instance failed to wake"), never silently
1738// swallowed into "looks fine, wakes anyway."
1739func (b *ExecBackend) ensureLiveLink(ctx context.Context, host string) error {
1740 bin := b.liveLink(host)
1741 if _, err := os.Stat(bin); err == nil {
1742 return nil
1743 }
1744 if b.Materialize != nil {
1745 if err := b.Materialize(ctx, host); err != nil {
1746 return fmt.Errorf("materialize live symlink for %s: %w", host, err)
1747 }
1748 return nil
1749 }
1750 return fmt.Errorf("no live binary for %s (%s missing and no Materialize hook)", host, bin)
1751}
1752
1753// LiveIdentity is the one file a cold wake will run (settings spec §4.2):
1754// the live link, materialized first if it is missing (ensureLiveLink, so
1755// the Materialize hook and its root-stage relay run exactly as a wake's
1756// spawn would run them), then resolved ONCE into the file at the end of
1757// its chain and that file's digest, as StartWorkerStaged does. Version is
1758// "" for a link outside the version store; the caller decides whether that
1759// may run (Service.recordBuild refuses it while a build state is kept).
1760func (b *ExecBackend) LiveIdentity(ctx context.Context, rt registry.Route) (release.StagedIdentity, error) {
1761 if err := b.ensureLiveLink(ctx, rt.Host); err != nil {
1762 return release.StagedIdentity{}, err
1763 }
1764 id, err := release.ResolveLive(b.OptDir, rt.Host)
1765 if err != nil {
1766 return release.StagedIdentity{}, fmt.Errorf("%s: %w", rt.Host, err)
1767 }
1768 return id, nil
1769}
1770
1771// declinedLivePin is a live-link start's identity on a box with
1772// LiveExecGate set (a declined box that honours failure records or hold-ran
1773// markers): live/<host> materialized and resolved ONCE, hashed
1774// (LiveIdentity), and the gate asked about that version. The caller runs
1775// exactly that file: a flat start execs it by path, and a nested one names
1776// it to root (StartExe, StartGenExe, StartSidecarExe, SeedExe), which
1777// validates it and binds it in place of the link. So the build the gate
1778// passed is the build that runs, and root repointing the link after this
1779// read changes nothing.
1780//
1781// A link outside the version store names no build the gate could judge or
1782// root could validate (instancectl.validateStagedExe), and is refused in
1783// recordBuild's words, as the activator already refuses every exec row's
1784// unversioned capture while a build state is kept (ruling
1785// R-EXEC-NOVERSION). Nothing has been prepared, launched or asked of root
1786// when it returns an error.
1787func (b *ExecBackend) declinedLivePin(ctx context.Context, rt registry.Route) (release.StagedIdentity, error) {
1788 id, err := b.LiveIdentity(ctx, rt)
1789 if err != nil {
1790 return release.StagedIdentity{}, err
1791 }
1792 if id.Version == "" {
1793 log.Printf("activator: %s not started: the executable its live link names (%s) is outside the version store, so this box cannot check it against its holds; move the instance onto a promoted build", rt.Host, id.StagedPath)
1794 return release.StagedIdentity{}, fmt.Errorf("%s: %w: the executable is outside the version store", rt.Host, errBuildStateUnwritten)
1795 }
1796 if err := b.LiveExecGate(rt, id.Version); err != nil {
1797 return release.StagedIdentity{}, err
1798 }
1799 return id, nil
1800}
1801
1802// BuildIdentity is buildIdentifier's answer: build's staged executable of
1803// rt's app on this box, at the path root's validation expects
1804// (release.VersionPath under the row's account; instancectl's
1805// validateStagedExe), hashed now. It reads no link. An invalid version, an
1806// app name that is not one path segment, or a copy that is not a regular
1807// file it can read names nothing. Whether build may run is the caller's
1808// question (the live exec gate), not this one's.
1809func (b *ExecBackend) BuildIdentity(rt registry.Route, build string) (release.StagedIdentity, error) {
1810 if !release.ValidVersion(build) {
1811 return release.StagedIdentity{}, fmt.Errorf("%s: %q is not a build version", rt.Host, build)
1812 }
1813 if rt.App == "" || rt.App == "." || rt.App == ".." || filepath.Base(rt.App) != rt.App {
1814 return release.StagedIdentity{}, fmt.Errorf("%s: app %q names no staged copy", rt.Host, rt.App)
1815 }
1816 path := release.VersionPath(b.OptDir, release.RouteAccount(rt), build, rt.App)
1817 fi, err := os.Lstat(path)
1818 if err != nil {
1819 return release.StagedIdentity{}, fmt.Errorf("%s: build %s: %w", rt.Host, build, err)
1820 }
1821 if !fi.Mode().IsRegular() {
1822 return release.StagedIdentity{}, fmt.Errorf("%s: build %s: %s is not a regular file", rt.Host, build, path)
1823 }
1824 sum, err := release.FileSHA256(path)
1825 if err != nil {
1826 return release.StagedIdentity{}, fmt.Errorf("%s: build %s: %w", rt.Host, build, err)
1827 }
1828 return release.StagedIdentity{Version: build, StagedPath: path, Digest: sum}, nil
1829}
1830
1831// Start dispatches a wake by layout (design §8.1): a nested row starts as a
1832// unit through the root instance-control channel, a fenced or conflicting
1833// row refuses outright, and a flat or foreign row runs today's exec child.
1834func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, string, error) {
1835 p := b.pathsFor(rt)
1836 switch p.layout {
1837 case LayoutNested:
1838 return b.startUnitAt(ctx, rt, rt.Socket, 0, nil, cgroup.Limit{})
1839 case LayoutFenced, LayoutConflict:
1840 return nil, "", fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason)
1841 }
1842 return b.startChildAt(ctx, rt, rt.Socket, 0, "", cgroup.Limit{})
1843}
1844
1845// StartGen is Start for generation n (zero-downtime spec §7.1). perGen false
1846// is Start exactly: the canonical socket, the canonical control socket, the
1847// same argv. perGen true binds the generation's own socket: GenSocketPath
1848// for a flat row, g<n>/app.sock in the instance run dir for a nested one
1849// (rev 19, rule 1). Only the directory differs from the flag-off launch:
1850// the file name, the rest of the argv and the agent-owned environment are
1851// the same (rule 2). A foreign row keeps its canonical socket either way:
1852// it is outside the two layouts the spec gives generation paths to, and
1853// keeping it is the conservative reading (plan 1, "Spec questions"). So
1854// does a flat row whose generation path would not fit a unix socket path
1855// (genLayout, rule 4).
1856//
1857// The stale-file rule (spec §7.1): before the app binds, the generation's
1858// directory is made and the file at THIS generation's own path is removed
1859// (left there by an earlier agent process, whose counter started at 1 like
1860// this one's). A sibling generation's path is never touched.
1861func (b *ExecBackend) StartGen(ctx context.Context, rt registry.Route, n uint64, perGen bool) (Proc, Gen, error) {
1862 return b.startGenFrom(ctx, rt, n, perGen, nil, cgroup.Limit{})
1863}
1864
1865// StartGenExe is StartGen on the identity a wake recorded (settings spec
1866// §4.2): the same generation, socket and tier, but the process runs
1867// id.StagedPath, checked against id.Digest first. A copy that changed is a
1868// failed start, and nothing runs (never the live link instead). A flat or
1869// foreign row execs the file by path. A nested row asks root to bind it in
1870// place of the live link: the original run unit while GenContainment is
1871// off (generation 0), generation n's unit while it is on. The handle names
1872// id's build, path, digest, demand and tier, so a sidecar can bind the same
1873// file.
1874//
1875// The tier is id's own (GenTier, as StartStaged takes it; ruling R-TIER),
1876// never b.tier(rt): that reads whatever the live link names when it is
1877// asked, and root may have repointed the link since the record, which
1878// would run A's bytes under B's limits.
1879func (b *ExecBackend) StartGenExe(ctx context.Context, rt registry.Route, n uint64, perGen bool, id release.StagedIdentity) (Proc, Gen, error) {
1880 if err := release.VerifyStaged(id); err != nil {
1881 return nil, Gen{}, fmt.Errorf("%s: not started on %s: %w", rt.Host, id.Version, err)
1882 }
1883 tier := b.GenTier(rt, id.Version)
1884 proc, g, err := b.startGenFrom(ctx, rt, n, perGen, &id, tier)
1885 if err != nil {
1886 return proc, g, err
1887 }
1888 g.Build, g.StagedPath, g.Digest, g.Demand, g.Tier = id.Version, id.StagedPath, id.Digest, id.Demand, tier
1889 return proc, g, nil
1890}
1891
1892// startGenFrom is StartGen's body. A nil id is the live link (StartGen); a
1893// non-nil one is the verified file StartGenExe names. tier is that file's
1894// own; zero is the live tier (b.tier).
1895func (b *ExecBackend) startGenFrom(ctx context.Context, rt registry.Route, n uint64, perGen bool, id *release.StagedIdentity, tier cgroup.Limit) (Proc, Gen, error) {
1896 var (
1897 flatExe string
1898 unitExe *instancectl.StagedExe
1899 )
1900 if id != nil {
1901 flatExe, unitExe = id.StagedPath, stagedExeOf(*id)
1902 }
1903 p := b.pathsFor(rt)
1904 sock := rt.Socket
1905 if perGen {
1906 switch genLayout(p.layout, rt.Host) {
1907 case LayoutFlat:
1908 sock = b.flatGenSocket(rt.Host, n)
1909 case LayoutNested:
1910 sock = nestedGenSocketIn(p.runDir, n)
1911 case LayoutForeign:
1912 if p.layout == LayoutFlat {
1913 log.Printf("activator: %s: a generation socket path would exceed %d bytes; generation %d keeps the canonical socket", rt.Host, maxUnixPath, n)
1914 }
1915 }
1916 }
1917 var (
1918 proc Proc
1919 ver string
1920 err error
1921 )
1922 switch p.layout {
1923 case LayoutNested:
1924 // prepareAt makes sock's generation directory and removes the
1925 // stale file at sock for the nested layout.
1926 proc, ver, err = b.startUnitAt(ctx, rt, sock, n, unitExe, tier)
1927 case LayoutFenced, LayoutConflict:
1928 return nil, Gen{}, fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason)
1929 default:
1930 if sock == rt.Socket {
1931 // A link left at the canonical path by an agent that ran with
1932 // generations on would make the app's bind fail. That holds for
1933 // every spawn on the canonical socket, not only a flag-off one:
1934 // a foreign row, or a flat row too long for a generation path
1935 // (rev 19 rule 4) whose box ran the old naming, binds it with
1936 // perGen true too (Astra, review of 19c07b2b), as StartStaged's
1937 // foreign arm already handles.
1938 if err := clearLeftoverCanonicalLink(rt.Socket); err != nil {
1939 return nil, Gen{}, fmt.Errorf("%s: leftover canonical link: %w", rt.Host, err)
1940 }
1941 }
1942 if sock != rt.Socket {
1943 if err := clearOwnGenSocket(sock); err != nil {
1944 return nil, Gen{}, fmt.Errorf("%s: generation socket: %w", rt.Host, err)
1945 }
1946 }
1947 proc, ver, err = b.startChildAt(ctx, rt, sock, n, flatExe, tier)
1948 }
1949 g := Gen{N: n, Socket: sock, Control: sock + ".control"}
1950 if err != nil {
1951 // The handle goes back with the error (P2-13): a spawn that failed
1952 // may have left generation n running, and its number is what the
1953 // caller proves it stopped by (KillGen). Refusals above spawned
1954 // nothing and answer no handle.
1955 return proc, g, err
1956 }
1957 g.Build = ver
1958 return proc, g, nil
1959}
1960
1961// flatGenSocket is GenSocketPath, under RunDirOverride/g in a test.
1962func (b *ExecBackend) flatGenSocket(host string, n uint64) string {
1963 if b.RunDirOverride != "" {
1964 return genSocketPathIn(filepath.Join(b.RunDirOverride, "g"), host, n)
1965 }
1966 return GenSocketPath(host, n)
1967}
1968
1969// clearOwnGenSocket makes the flat generation's own directory,
1970// /run/carlos/g/<n>, and removes a stale file at sock, and only at sock (rev
1971// 19, rule 3). The directory is 0750 and inherits /run/carlos's setgid
1972// carlos-sockets group, as the nested run dirs do.
1973func clearOwnGenSocket(sock string) error {
1974 if err := os.MkdirAll(filepath.Dir(sock), 0o750|os.ModeSetgid); err != nil {
1975 return err
1976 }
1977 if err := os.Remove(sock); err != nil && !errors.Is(err, os.ErrNotExist) {
1978 return err
1979 }
1980 return nil
1981}
1982
1983// startChildAt is Start's exec-child body (flat and foreign layouts):
1984// exec of the versioned binary directly, exactly as before nested layouts
1985// existed. sock is the app socket this spawn binds: rt.Socket, or a
1986// generation socket (StartGen). n is the generation this spawn is (StartGen's
1987// n; 0 from Start). It only places the spawn's cgroup, and only under
1988// GenContainment (placeFor).
1989//
1990// exe is "" for the live link, or the staged file a wake recorded
1991// (StartGenExe). spawnChildFrom leaves a staged spawn's cache purge to the
1992// swap's switch, but a wake's staged spawn is the host's only process, so
1993// its purge happens here, at the same moment, before the health wait.
1994// tier is exe's own; zero is the live tier (b.tier).
1995func (b *ExecBackend) startChildAt(ctx context.Context, rt registry.Route, sock string, n uint64, exe string, tier cgroup.Limit) (Proc, string, error) {
1996 p, spawnVer, err := b.spawnChildFrom(ctx, rt, sock, n, exe, tier)
1997 if err != nil {
1998 return p, "", err
1999 }
2000 if exe != "" && b.Cache != nil {
2001 if purged := b.Cache.PurgeHost(rt.Host); purged > 0 {
2002 log.Printf("activator: purged %d cached response(s) for %s on spawn", purged, rt.Host)
2003 }
2004 }
2005 if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil {
2006 stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout)
2007 defer cancel()
2008 _ = p.Stop(stopCtx)
2009 if b.GenContainment && n != 0 {
2010 // The spawn was placed in g<n>, and Stop signals only the
2011 // leader: a process it forked can live on there (spec §7.3).
2012 // The generation's own kill ends it and proves the child
2013 // empty. Unproven, the process goes back with an error that
2014 // says so, and the caller keeps the lease until the sweep
2015 // proves it (P2-13, as startUnitAt does).
2016 if kerr := b.killGenAfterFailedStart(ctx, rt, n); kerr != nil {
2017 log.Printf("activator: %s: kill generation %d after a failed health wait: %v", rt.Host, n, kerr)
2018 return p, "", fmt.Errorf("%w; %w: kill generation %d after a failed health wait: %v", err, errGenUnproven, n, kerr)
2019 }
2020 }
2021 // The handle goes back with the health error, as startUnitAt's does
2022 // (P2-13). The leader's exit is not the end of what it started: with
2023 // no g<n>, a process it forked lives on in carlos-exec@<host>, and
2024 // the caller's failed-wake cleanup proves that cgroup empty before
2025 // the lease goes (Task 15 fix round 1, ruling a; Astra IMPL-3 F6).
2026 // The leader is already stopped, so the cleanup's Stop is a no-op.
2027 return p, "", err
2028 }
2029 b.normalizeSocketMode(rt, sock, p)
2030 return p, spawnVer, nil
2031}
2032
2033// killGenAfterFailedStart is KillGen for a spawn path's own cleanup, on a
2034// budget the caller's context cannot cancel: a wake cancelled by a fence or
2035// Shutdown still proves what it started. nil is that proof.
2036func (b *ExecBackend) killGenAfterFailedStart(ctx context.Context, rt registry.Route, n uint64) error {
2037 kctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), instancectl.DeadlineControl)
2038 defer cancel()
2039 if b.killGen != nil {
2040 return b.killGen(kctx, rt, n)
2041 }
2042 return b.KillGen(kctx, rt, n)
2043}
2044
2045// spawnChildFrom is the flat spawn with the executable and the tier named,
2046// and nothing after the exec: it returns the moment the process runs. Empty
2047// exe is the live link, which is every wake (startChildAt adds today's
2048// health wait). A staged path is a swap's successor or a pinned wake
2049// (zero-downtime spec §7.5 step 3): the bytes the attempt chose, whatever
2050// root does to the live link meanwhile, with the caller owning the health
2051// deadline and the termination. tier is that artifact's own tier; zero is
2052// the live tier (b.tier). n is the generation number plan 2's containment
2053// places the child by.
2054func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, sock string, n uint64, exe string, tier cgroup.Limit) (Proc, string, error) {
2055 host := rt.Host
2056 paths := b.pathsFor(rt)
2057 dbPath := paths.dbPath
2058 child, ownTier, err := b.placeFor(spawnInstance, n)
2059 if err != nil {
2060 return nil, "", err
2061 }
2062
2063 // Exec exactly what a systemd unit would: the per-instance live symlink
2064 // (<OptDir>/live/<host> → a staged version), same --socket/--db flags,
2065 // same EnvironmentFile.
2066 bin := exe
2067 if bin == "" {
2068 bin = b.liveLink(host)
2069 if err := b.ensureLiveLink(ctx, host); err != nil {
2070 return nil, "", err
2071 }
2072 }
2073
2074 // Mint the instance's admin token BEFORE the env is read for the spawn, so
2075 // the child is handed the same secret the agent will present back to it
2076 // (NextDue, the scheduled-work tick) and the same one its control socket
2077 // checks bearers against. A failure here fails the wake on purpose: an
2078 // instance with no token can neither answer /api/next-due nor accept a
2079 // tick, and a wake that quietly skipped it would report a healthy instance
2080 // that silently never runs its scheduled work — the #110 kind of lie.
2081 if err := EnsureAdminToken(paths.envFile); err != nil {
2082 return nil, "", fmt.Errorf("admin token for %s: %w", host, err)
2083 }
2084 // The instance key is minted here for the same reason and fails the
2085 // wake the same way: an app that takes its key from the platform
2086 // refuses to start without one (L21). It returns only once the key is
2087 // durable, so no child starts with a key a power cut could take back.
2088 if _, err := b.ensureInstanceKeyFor(ctx, rt, paths); err != nil {
2089 return nil, "", fmt.Errorf("instance key for %s: %w", host, err)
2090 }
2091
2092 // One read gives the env and the layer its CARLOS_INSTANCE_KEY came
2093 // from, for the replica gate below (ruling 29).
2094 fileEnv, keyLayer, err := b.instanceEnvLayer(rt)
2095 if err != nil {
2096 return nil, "", err
2097 }
2098
2099 // The control socket reaches the child through its ENVIRONMENT, never as a
2100 // CLI flag: every app binary built before this feature existed parses its
2101 // flags with flag.ExitOnError, which exits 2 on an unrecognized flag — so
2102 // passing --control-socket would refuse to start every app that hasn't
2103 // also shipped support for it, a fleet-wide outage on agent upgrade. An
2104 // environment variable a binary doesn't know about is simply ignored.
2105 controlSocket := sock + ".control"
2106 // Bind the control channel BEFORE the spawn, so CARLOS_CONTROL_SOCKET can
2107 // follow the OUTCOME rather than the intention. It used to be set from
2108 // "b.Cache != nil" and the listener started afterwards; now that the
2109 // channel exists for every exec child, the only thing that can make the
2110 // variable a lie is a bind that failed (windows has no secureUnixListen at
2111 // all, and a stale path can refuse), and binding first is what lets the
2112 // else-branch below strip the name in exactly that case.
2113 //
2114 // A bind failure is still NOT fatal to the wake — same reasoning as before,
2115 // the instance serves requests either way; it just goes without its control
2116 // verbs until the next restart, a stuck-stale cache self-heals via the
2117 // safety-net TTL, and a missed tick is retried by the runner.
2118 envPath := paths.envFile
2119 // Read on EVERY call, never captured: the env file can be rewritten under a
2120 // running instance (`carlos env`, a re-mint on a later spawn), and a token
2121 // cached at bind time would keep authorising the old secret.
2122 tokenReader := func() (string, error) {
2123 env, err := readEnvFile(envPath)
2124 if err != nil {
2125 return "", err
2126 }
2127 return env["CARLOS_ADMIN_TOKEN"], nil
2128 }
2129 var mount func(*http.ServeMux)
2130 if b.Timers != nil || b.UpdatesReader != nil {
2131 mount = func(mux *http.ServeMux) {
2132 if b.Timers != nil {
2133 schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, n))
2134 }
2135 if b.UpdatesReader != nil {
2136 instanceupdates.Mount(mux, rt.Host, tokenReader, b.UpdatesReader)
2137 }
2138 }
2139 }
2140 ctl, err := edge.ListenControl(controlSocket, rt.Host, b.Cache, mount)
2141 if err != nil {
2142 log.Printf("activator: control listener for %s: %v (continuing without it)", rt.Host, err)
2143 ctl = nil
2144 }
2145 // A replica is started only with its home's key, verified, or with
2146 // none and a control socket its helper can refuse on (ruling 26).
2147 if err := b.replicaKeyGate(rt, fileEnv, keyLayer, ctl != nil); err != nil {
2148 if ctl != nil {
2149 ctl.Close()
2150 }
2151 return nil, "", err
2152 }
2153 // Both control-channel vars are AGENT-OWNED in BOTH directions: set when the
2154 // channel exists, and REMOVED when it does not. The removal half is not
2155 // decoration. fileEnv is tenant-supplied — <host>.config.env comes from the
2156 // bucket via `carlos env`, <host>.env is the instance's own file — so
2157 // without the else branches a tenant could put CARLOS_PURGE=header (or worse,
2158 // CARLOS_CONTROL_SOCKET=/some/path) in its own config and have it survive
2159 // into the child on a box with NO cache. The app would then be told a purge
2160 // channel exists when none does: an app that trusts the var skips its own
2161 // invalidation and serves stale content forever, and a socket path a tenant
2162 // chose is a name the agent never vouched for.
2163 //
2164 // "A variable naming a channel must not be set where that channel does
2165 // nothing" still holds — the two names just answer to different channels
2166 // now. CARLOS_CONTROL_SOCKET names the SOCKET, which exists for every exec
2167 // child once the bind above succeeded (the purge verb is only one of the
2168 // things served on it; the scheduled-work verbs are served there on a box
2169 // with no cache at all). CARLOS_PURGE names the PURGE channel, which still
2170 // exists only where there is a cache to purge. Both are set from what was
2171 // actually created, and stripped otherwise; neither is set from a tenant's
2172 // say-so.
2173 //
2174 // CARLOS_PURGE is ADVISORY: it tells the app that this box's edge reads the
2175 // Carlos-Purge response header (internal/edge/purge_header.go), so the app
2176 // can log or branch on it — but the channel works whether or not the app
2177 // ever reads the variable, because a header the edge understands is inert
2178 // when the app never emits it and honoured when it does. That is what makes
2179 // the rollout ordering harmless. It does not replace the socket channel;
2180 // both are live for exec-backed instances, and converging them is deferred
2181 // (spec §10.3).
2182 childVars := b.agentOwnedVars(rt, controlSocket, fileEnv, ctl != nil)
2183 // Record the build we're about to exec — read the symlink immediately before
2184 // the start so it names the version the kernel will resolve. Reading it here,
2185 // rather than back in wake() after the health wait, closes a real window: a
2186 // concurrent ship's repoint during the up-to-30s wait would otherwise make
2187 // us record the NEW target while this child runs the OLD binary, and the
2188 // sweep would then think it's already converged and never cycle it. The
2189 // residual sub-exec race (a repoint between this read and the start) is in
2190 // the safe direction — we'd record the older version, so the sweep cycles
2191 // once too often rather than never. Empty on a read error: the sweep won't
2192 // cycle.
2193 //
2194 // A staged spawn names its build from the attempt; the caller fills
2195 // Gen.Build. Reading the live link here would name whatever root
2196 // installed, not what this process runs.
2197 //
2198 // With LiveExecGate set (a declined box that honours failure records or
2199 // hold-ran markers, Task 9), the live link is resolved ONCE here, path
2200 // only, with no hash (release.ResolveLivePath, fix round 1 ruling 4b),
2201 // the gate is asked about that build at the
2202 // launch, and the staged file the link named is what is exec'd, not the
2203 // link. So the build the gate passed is the build that runs: a repoint
2204 // between the gate and the exec changes nothing, where exec'ing the link
2205 // would run whatever it named by then (plan review #6). A link that
2206 // cannot be resolved fails the wake, as a missing one does. With the gate
2207 // nil this is today's read and today's exec of the link.
2208 spawnVer := ""
2209 var liveGate func() error
2210 if exe == "" && b.LiveExecGate != nil {
2211 id, err := release.ResolveLivePath(b.OptDir, host)
2212 if err != nil {
2213 if ctl != nil {
2214 ctl.Close()
2215 }
2216 return nil, "", fmt.Errorf("%s: %w", host, err)
2217 }
2218 bin, spawnVer = id.StagedPath, id.Version
2219 liveGate = func() error { return b.LiveExecGate(rt, id.Version) }
2220 } else if exe == "" {
2221 spawnVer, _ = b.LiveVersion(host)
2222 }
2223 // Spawned through the cgroup manager (nil-tolerant: no delegation means
2224 // a plain start, exactly as before) so the child lands in
2225 // carlos-exec@<host> and the sampler can account it. build may run
2226 // twice — a flat start's PID-move fallback after a failed placement —
2227 // so everything about the Cmd is configured inside it.
2228 //
2229 // The caller's exec gate (final review F1) is the manager's before-start
2230 // hook (launchGate): asked after every step above (the token, the config
2231 // read, the control socket's bind) AND the cgroup preparation (the
2232 // parent, the child, its limits, and the shim's placement), immediately
2233 // before each exec, the flat PID-move fallback's included. The lease, a
2234 // fence or the attempt can change during any of it. A refusal runs
2235 // nothing of the command; the manager drops
2236 // the child cgroup and the error path below unbinds the listener, as for
2237 // a failed spawn. With no gate on ctx (every live-link spawn) there is no
2238 // hook and the spawn is exactly as it was, unless LiveExecGate is set:
2239 // then its check of the resolved build (liveGate) is the hook, asked at
2240 // the same point.
2241 cmd, settle, err := b.spawnInCgroupAtTier(rt, child, ownTier, tier, func() *exec.Cmd {
2242 cmd := exec.Command(bin, "--socket", sock, "--db", dbPath)
2243 cmd.Env = childEnv(childVars)
2244 // The child logs through the agent's own stderr/stdout (journald
2245 // under systemd); there's no per-instance unit to own a log stream
2246 // anymore. With Logs set the same bytes are ALSO teed line-wise
2247 // into the log sink (`carlos logs`); teeStd owns the WaitDelay
2248 // that piping entails.
2249 b.teeStd(cmd, rt)
2250 return cmd
2251 }, b.launchGateWith(ctx, host, liveGate))
2252 if err != nil {
2253 // The listener was bound before the spawn; nothing will ever answer on
2254 // it now, so unbind it rather than leaving a live socket for a process
2255 // that does not exist.
2256 if ctl != nil {
2257 ctl.Close()
2258 }
2259 return nil, "", err
2260 }
2261
2262 p := &childProc{cmd: cmd, done: make(chan struct{}), ctl: ctl}
2263 // A staged successor spawns while the incumbent still serves, and its
2264 // purge happens at the switch (GenSwitched), not here.
2265 if b.Cache != nil && exe == "" {
2266 // A fresh process for this host is exactly the moment its cached
2267 // content becomes suspect: a deploy/redeploy means new code and
2268 // potentially new pages, and nothing else would invalidate them until
2269 // their TTL ran out. Drop the whole host's entries before the new
2270 // process serves its first request.
2271 if n := b.Cache.PurgeHost(rt.Host); n > 0 {
2272 log.Printf("activator: purged %d cached response(s) for %s on spawn", n, rt.Host)
2273 }
2274 }
2275 go func() {
2276 p.err = cmd.Wait()
2277 // The member-facing record of the exit: a crash shows an "exited"
2278 // line with no "stopping" line before it — that pairing, not the
2279 // exit code alone, is what tells a member crash from lifecycle.
2280 if p.err != nil {
2281 emitEvent(b.Logs, rt, "warn", "instance exited: "+RedactPaths(p.err.Error()))
2282 } else {
2283 emitEvent(b.Logs, rt, "info", "instance exited (exit 0)")
2284 }
2285 // A strict child's group is drained and closed before anything
2286 // reads this exit as a stop (p.done closes below).
2287 settle()
2288 b.Cgroups.RemoveChild(host, child) // no-op for "" (flag off)
2289 // Last exiter wins: rmdir refuses while the sidecar (same host
2290 // cgroup) is still alive, and that failure is tolerated.
2291 b.cleanupHost(host)
2292 close(p.done)
2293 }()
2294 return p, spawnVer, nil
2295}
2296
2297// agentOwnedNames are the variables agentOwnedVars decides: it sets or
2298// removes each of them on every launch, whatever the instance's files say.
2299// moves.AgentOwnedVars is the same set (TestAgentOwnedVarsAreMovesBoxOwned
2300// pins both against agentOwnedVars itself).
2301var agentOwnedNames = []string{"CARLOS_CONTROL_SOCKET", "CARLOS_PURGE", "CARLOS_REGION", "CARLOS_HOST"}
2302
2303// agentOwnedVars composes the child's agent-owned environment variables,
2304// namely the control socket, the purge channel, and the region, on top of
2305// fileEnv, exactly as startChild did inline before the unit spawn path
2306// needed the same composition. Extracted so the two paths cannot drift
2307// (design §7.2): haveControl is whether this call's control listener
2308// actually bound. childVars must follow the OUTCOME of the bind, not the
2309// intention, per the long comment this replaced, still true here.
2310func (b *ExecBackend) agentOwnedVars(rt registry.Route, control string, fileEnv map[string]string, haveControl bool) map[string]string {
2311 childVars := fileEnv
2312 if haveControl {
2313 childVars = withVar(childVars, "CARLOS_CONTROL_SOCKET", control)
2314 } else {
2315 childVars = withoutVars(childVars, "CARLOS_CONTROL_SOCKET")
2316 }
2317 if b.Cache != nil {
2318 childVars = withVar(childVars, "CARLOS_PURGE", "header")
2319 } else {
2320 childVars = withoutVars(childVars, "CARLOS_PURGE")
2321 }
2322 if b.Region != "" {
2323 childVars = withVar(childVars, "CARLOS_REGION", b.Region)
2324 } else {
2325 childVars = withoutVars(childVars, "CARLOS_REGION")
2326 }
2327 // The hostname, always, from the row (discussion #256 item 2). An app
2328 // used to learn it from its socket's file name, /run/carlos/<host>.sock,
2329 // and the nested layout's app.sock names nothing; three instances
2330 // crashed at start over that on the first roll. Agent-owned like the
2331 // three above: a tenant's env file does not get to tell an app it is
2332 // some other host.
2333 return withVar(childVars, "CARLOS_HOST", rt.Host)
2334}
2335
2336// seedVars is the seed's environment over fileEnv: the control-channel
2337// variables stripped (a seed has no listener for either; see seedChild's
2338// comment), and CARLOS_HOST set, since a seed initialising a host's
2339// database is as entitled to know which host as the instance is. One
2340// composer for both seed paths, as agentOwnedVars is for both run paths.
2341func seedVars(fileEnv map[string]string, host string) map[string]string {
2342 return withVar(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE"), "CARLOS_HOST", host)
2343}
2344
2345// normalizeSocketMode is the PLATFORM's 0660 on a spawned instance's socket
2346// (issue #103), not the app's. Extracted from startChild so the unit path
2347// (whose own bind is the trampoline's, not this process's exec) applies the
2348// identical rule rather than a second copy that can drift. So a deployment's
2349// enrolled cross-user consumers (the carlos-sockets group, group-inherited
2350// from the setgid /run/carlos, see host.tmpfilesConf) can dial, no matter
2351// what the child's umask or its own post-bind chmod produced. titogo
2352// tightened its socket to 0600 and stranded the sibling router that proxies
2353// to it. Called after the health wait, so the app's own bind/chmod has
2354// already happened and this wins.
2355//
2356// The two guards make "only a socket THIS backend's child bound" true
2357// rather than merely intended. waitHealthy tolerates a squatter (its own
2358// contract: it only dials), so a child that lost the bind race with a
2359// stray supervisor (EADDRINUSE, exits) can still see the health wait
2360// succeed off the SQUATTER's answers; a dead child means the socket is
2361// not ours to widen (that route is the #17 external-socket advisory's
2362// territory). The Lstat keeps the chmod from ever following a
2363// non-socket: a symlink planted at the route's path must not become a
2364// mode change somewhere else. On a box with no consumers group the
2365// socket's group is just `carlos`, so the wider mode grants nobody
2366// anything. All three branches log and never fail the wake: a box with
2367// no consumers must not lose a healthy wake to a normalization it
2368// doesn't need.
2369func (b *ExecBackend) normalizeSocketMode(rt registry.Route, sock string, proc Proc) {
2370 host := rt.Host
2371 if proc.Exited() {
2372 log.Printf("activator: %s: child exited during the health wait (external process serving its socket?) — not normalizing the socket mode", host)
2373 } else if fi, statErr := os.Lstat(sock); statErr != nil || fi.Mode()&os.ModeSocket == 0 {
2374 log.Printf("activator: %s: %s is not a socket we can normalize (stat: %v)", host, sock, statErr)
2375 } else if err := os.Chmod(sock, 0o660); err != nil {
2376 log.Printf("activator: normalize socket mode for %s: %v (enrolled consumers would get permission denied)", host, err)
2377 }
2378}
2379
2380// spawnUnitFrom is the nested spawn with the executable and the tier named,
2381// and nothing after root has started the unit: it returns the moment the
2382// unit runs, as spawnChildFrom does for a flat host. A nil exe is the live
2383// link, which is every wake (startUnitAt adds today's health wait). A staged
2384// exe is a swap's successor or a pinned wake (zero-downtime spec §7.5 step
2385// 3): root validates the path and mounts it in place of the live link, so
2386// whatever happens to the live link meanwhile changes nothing, and the
2387// caller owns the health deadline and the termination. tier is that
2388// artifact's own tier; zero is the live tier (b.tier). With LiveExecGate
2389// set a nil exe pins the live link (declinedLivePin) and starts that file as
2390// a staged one, at its own tier.
2391//
2392// n is the generation this spawn is (StartGen's n; 0 from Start). Under
2393// GenContainment it selects the carlos-instance-gen@<id>:<n> unit and its
2394// generation-keyed files (unitGen); otherwise it is ignored. A failure
2395// whose kill could not prove the unit empty wraps errGenUnproven.
2396func (b *ExecBackend) spawnUnitFrom(ctx context.Context, rt registry.Route, sock string, n uint64, exe *instancectl.StagedExe, tier cgroup.Limit) (Proc, string, error) {
2397 // The launch's own split, for the journal (wake latency, pass 2).
2398 var steps wakeSteps
2399 steps.begin(time.Now())
2400 if err := b.nestedGate(rt); err != nil {
2401 return nil, "", err
2402 }
2403 gen, err := b.unitGen(n)
2404 if err != nil {
2405 return nil, "", err
2406 }
2407 pinned := ""
2408 if exe == nil && b.LiveExecGate != nil {
2409 // A declined box that honours failure records or hold-ran markers
2410 // (Task 9) pins the live link once and names that file to root at
2411 // its own tier (declinedLivePin), so root binds the build the gate
2412 // passed and a repoint after this read changes nothing. Before
2413 // anything is prepared: a refusal binds no listener and writes no
2414 // launch file.
2415 id, err := b.declinedLivePin(ctx, rt)
2416 if err != nil {
2417 return nil, "", err
2418 }
2419 exe, tier, pinned = stagedExeOf(id), b.GenTier(rt, id.Version), id.Version
2420 } else if exe == nil {
2421 // A staged exe is mounted in place of the live link, which then
2422 // need not exist (as spawnChildFrom does for a flat host).
2423 if err := b.ensureLiveLink(ctx, rt.Host); err != nil {
2424 return nil, "", err
2425 }
2426 }
2427 steps.mark(time.Now(), "build")
2428 var prep wakeSteps
2429 p, ctl, logs, fileEnv, keyLayer, err := b.prepareKeyedAt(rt, instancectl.RoleRun, sock, gen, &prep)
2430 if err != nil {
2431 return nil, "", err
2432 }
2433 fail := func(err error) (Proc, string, error) {
2434 if ctl != nil {
2435 ctl.Close()
2436 }
2437 logs.Close()
2438 return nil, "", err
2439 }
2440 childVars := b.agentOwnedVars(rt, sock+".control", fileEnv, ctl != nil)
2441 argv := []string{b.liveLink(rt.Host), "--socket", sock, "--db", p.dbPath}
2442 steps.mark(time.Now(), "prepare ("+prep.String()+")")
2443 if err := b.writeLaunchFor(p, instancectl.RoleRun, gen, rt.Host, argv, childEnv(childVars)); err != nil {
2444 return fail(err)
2445 }
2446 steps.mark(time.Now(), "launch file")
2447 // Read the symlink immediately before the start, same fail-safe
2448 // direction as startChild: a concurrent repoint at worst records the
2449 // older version (one extra convergence cycle), never the newer. A
2450 // pinned start names the build it pinned, which is what root binds.
2451 spawnVer := pinned
2452 if spawnVer == "" {
2453 spawnVer, _ = b.LiveVersion(rt.Host)
2454 }
2455 // The caller's exec gate (final review F1), right before root is asked
2456 // to start the unit: the config read, the listeners and the launch file
2457 // above all take time. A refusal starts nothing; the generation's own
2458 // launch file (it carries the instance's environment) goes, and the
2459 // listeners close, as after root's own refusal (ErrStagedExe below).
2460 // With no gate on ctx (every live-link spawn) this does nothing.
2461 if b.launchHook != nil {
2462 b.launchHook(rt.Host)
2463 }
2464 err = runExecGate(ctx)
2465 if err != nil {
2466 if gen != 0 {
2467 _ = os.Remove(filepath.Join(p.dataDir, instancectl.GenLaunchFileName(gen)))
2468 }
2469 return fail(err)
2470 }
2471 steps.mark(time.Now(), "gate")
2472 inv, err := b.startRole(ctx, rt, instancectl.RoleRun, gen, exe, tier)
2473 steps.mark(time.Now(), "root start")
2474 // Past this call root may have started the unit: every failure below
2475 // but ErrStagedExe (root's refusal before it laid out or started
2476 // anything) and errNoStagedExeControl (root was never asked: the
2477 // channel cannot name the file) is marked startUncertain, since the
2478 // build may have run before it failed or exited (final review wave 2,
2479 // N4).
2480 if errors.Is(err, errNoStagedExeControl) {
2481 // Nothing was sent to root. Proven not run, so never a failure
2482 // record. A generation's own launch file goes as after a refusal.
2483 if gen != 0 {
2484 _ = os.Remove(filepath.Join(p.dataDir, instancectl.GenLaunchFileName(gen)))
2485 }
2486 return fail(fmt.Errorf("%s: start unit: %w", rt.Host, err))
2487 }
2488 if errors.Is(err, instancectl.ErrStagedExe) {
2489 // Root refused the staged build before it laid out or started
2490 // anything, so there is nothing to kill and nothing unproven. The
2491 // generation's own launch file, which this start wrote and which
2492 // carries the instance's environment, goes too (final review T9).
2493 // The legacy launch.run.json (gen 0) is shared with the original
2494 // template and is left as it is.
2495 if gen != 0 {
2496 _ = os.Remove(filepath.Join(p.dataDir, instancectl.GenLaunchFileName(gen)))
2497 }
2498 return fail(fmt.Errorf("%s: start unit: %w", rt.Host, err))
2499 }
2500 if gen != 0 && (err != nil || inv == "") {
2501 if err == nil {
2502 // The unit ran and exited before root observed it: the build
2503 // crashed at its start (errStartedThenExited, ruling 206).
2504 err = exitedUnobserved{unit: unitName(instancectl.RoleRun, rt.Host, gen)}
2505 }
2506 if kerr := b.killFailedGen(ctx, rt, gen); kerr != nil {
2507 // Nothing is released: the generation may still be running.
2508 return nil, "", markStartUncertain(fmt.Errorf("%s: start unit: %w; %w: %v", rt.Host, err, errGenUnproven, kerr))
2509 }
2510 _ = os.Remove(filepath.Join(p.dataDir, instancectl.GenLaunchFileName(gen)))
2511 return fail(markStartUncertain(fmt.Errorf("%s: start unit: %w", rt.Host, err)))
2512 }
2513 if err != nil {
2514 // The original run unit (generation 0). Root was asked to start
2515 // it, and a start job systemd queued (behind an edge restart, say)
2516 // outlives this call's cancellation, so the wake's cleanup stops
2517 // the unit and proves it gone before the lease goes (#292).
2518 return fail(markLegacyStartUncertain(markStartUncertain(fmt.Errorf("%s: start unit: %w", rt.Host, err))))
2519 }
2520 if inv == "" {
2521 // The server read the invocation after the start and found none:
2522 // the unit already exited (plan 2 final review). Nothing is
2523 // running, so there is nothing to stop; the wake failed.
2524 return fail(markStartUncertain(fmt.Errorf("%s: %w", rt.Host, exitedUnobserved{unit: unitName(instancectl.RoleRun, rt.Host, gen)})))
2525 }
2526 started := tier
2527 if started == (cgroup.Limit{}) {
2528 started = b.tier(rt)
2529 }
2530 log.Printf("activator: %s: started %s memory.max=%d (%s)", rt.Host, unitName(instancectl.RoleRun, rt.Host, gen), started.MemoryMax, steps.String())
2531 up := b.newUnitProcFor(rt, instancectl.RoleRun, gen, p, inv, ctl, logs)
2532 // The file environment this generation launched with, kept on its
2533 // handle: a run against it composes its environment from this, never
2534 // from the files config sync has delivered since (merge ruling M3).
2535 // It carries the generation's CARLOS_INSTANCE_KEY, minted or
2536 // configured before this launch, and the layer that key came from is
2537 // kept with it, decided by the same read (instance-key ruling 29), so
2538 // a run's command passes the replica gate on exactly what it is given.
2539 up.launchEnv, up.launchKeyLayer = maps.Clone(fileEnv), keyLayer
2540 if b.Cache != nil {
2541 // A fresh process for this host is exactly the moment its cached
2542 // content becomes suspect, same rule as startChild.
2543 if n := b.Cache.PurgeHost(rt.Host); n > 0 {
2544 log.Printf("activator: purged %d cached response(s) for %s on spawn", n, rt.Host)
2545 }
2546 }
2547 return up, spawnVer, nil
2548}
2549
2550// startUnitAt is Start's nested body (design §6): the same admin token, env
2551// composition and health gate as startChild, but the process itself is
2552// started by the root instance-control channel from a launch file rather
2553// than exec'd directly. The agent has no privilege to enter the tenant's
2554// namespace, only the control service does. It is spawnUnitFrom, then the
2555// health wait. exe is nil for the live link, or the staged file a wake
2556// recorded (StartGenExe), which root binds in place of the link. tier is
2557// exe's own; zero is the live tier (b.tier).
2558func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock string, n uint64, exe *instancectl.StagedExe, tier cgroup.Limit) (Proc, string, error) {
2559 began := time.Now()
2560 p, spawnVer, err := b.spawnUnitFrom(ctx, rt, sock, n, exe, tier)
2561 if err != nil {
2562 return p, "", err
2563 }
2564 up := p.(*unitProc)
2565 launched := time.Now()
2566 if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil {
2567 // Set before the Stop call below, closing the same race Stop's own
2568 // internal set leaves open by a few instructions: observe runs
2569 // concurrently from the moment newUnitProc started it, and setting
2570 // this here rather than waiting for it to happen inside up.Stop
2571 // means the observer can never see a foreign invocation appear and
2572 // fire its own redundant stop for a unit we already know we are
2573 // about to stop.
2574 up.stopRequested.Store(true)
2575 stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout)
2576 defer cancel()
2577 if serr := up.Stop(stopCtx); serr != nil {
2578 // The health error, not this one, is the wake's failure. The
2579 // observer stands down on its own once the stop actually
2580 // lands (stopRequested above), so an ErrStillRunning here
2581 // just means it had not landed yet within actStopTimeout, not
2582 // that the wake should be retried differently.
2583 log.Printf("activator: %s: stop after failed health wait: %v", rt.Host, serr)
2584 // The unit may still run, so its process goes back with the
2585 // health error and the caller's failed-wake cleanup proves it
2586 // stopped before the lease goes (P2-13). Under GenContainment
2587 // the error also says so (errGenUnproven), so that cleanup
2588 // asks for the proof with generations off as well.
2589 if up.gen != 0 {
2590 return up, "", fmt.Errorf("%w; %w: stop after failed health wait: %v", err, errGenUnproven, serr)
2591 }
2592 return up, "", err
2593 }
2594 return nil, "", err
2595 }
2596 // The spawn's own split, for the journal: the unit's launch (instance
2597 // control's half included) and then the app's boot to its socket.
2598 log.Printf("activator: %s: %s launched in %s, healthy %s later", rt.Host, unitName(instancectl.RoleRun, rt.Host, n),
2599 launched.Sub(began).Round(time.Millisecond), time.Since(launched).Round(time.Millisecond))
2600 b.normalizeSocketMode(rt, sock, up)
2601 return up, spawnVer, nil
2602}
2603
2604// LegacyRunGone stops rt's original run unit (carlos-instance@, generation
2605// 0) and proves it gone (#292, Astra P1): the failed-wake cleanup's proof
2606// before an aborted start's lease goes back. The stop goes through
2607// instance-control's /stop, which runs `systemctl stop`. systemd keeps one
2608// job per unit and a stop job replaces a queued start job, so a successful
2609// stop also means that no start job is still queued, even while the edge
2610// restart that queued it is in progress. Then the unit must read inactive
2611// (or failed) with its cgroup empty or gone, the facts the exit observer
2612// trusts (observe, design §6.5). Bounded by ctx, which the caller gives a
2613// budget of its own; anything short of the proof is an error, and the
2614// caller keeps the lease.
2615func (b *ExecBackend) LegacyRunGone(ctx context.Context, rt registry.Route) error {
2616 if b.Control == nil {
2617 return fmt.Errorf("%s: instance control channel not configured", rt.Host)
2618 }
2619 unit := unitName(instancectl.RoleRun, rt.Host, 0)
2620 if err := b.Control.Stop(ctx, rt.Host, instancectl.RoleRun); err != nil {
2621 return fmt.Errorf("stop %s: %w", unit, err)
2622 }
2623 state := b.UnitState
2624 if state == nil {
2625 state = systemctlUnitState
2626 }
2627 events := b.CgroupEvents
2628 if events == nil {
2629 events = cgroupEventsUnder(b.cgroupRoot())
2630 }
2631 poll := b.ObservePoll
2632 if poll <= 0 {
2633 poll = time.Second
2634 }
2635 slice := instancectl.SliceName(rt.Host)
2636 var last error
2637 for {
2638 active, _, err := state(ctx, unit)
2639 switch {
2640 case err != nil:
2641 last = fmt.Errorf("unit state: %w", err)
2642 case active != "inactive" && active != "failed":
2643 last = fmt.Errorf("unit is %s", active)
2644 default:
2645 exists, populated, eerr := events(slice, unit)
2646 switch {
2647 case eerr != nil:
2648 last = fmt.Errorf("cgroup.events: %w", eerr)
2649 case exists && populated:
2650 last = errors.New("processes remain in its cgroup")
2651 default:
2652 return nil
2653 }
2654 }
2655 select {
2656 case <-ctx.Done():
2657 return fmt.Errorf("%s not proven stopped: %v", unit, last)
2658 case <-time.After(poll):
2659 }
2660 }
2661}
2662
2663// killFailedGen ends a generation whose start failed or answered no
2664// invocation. The start may have succeeded with its answer lost, so the
2665// generation is killed and proven empty (StopGen with kill) on a budget the
2666// caller's context cannot cancel. nil is that proof. With no generation
2667// channel StartGen was never sent, so nothing was started.
2668func (b *ExecBackend) killFailedGen(ctx context.Context, rt registry.Route, gen uint64) error {
2669 gc, err := b.genControl()
2670 if err != nil {
2671 return nil
2672 }
2673 kctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), instancectl.DeadlineControl)
2674 defer cancel()
2675 if err := gc.StopGen(kctx, rt.Host, gen, true); err != nil {
2676 log.Printf("activator: %s: kill %s after a failed start: %v", rt.Host, unitName(instancectl.RoleRun, rt.Host, gen), err)
2677 return err
2678 }
2679 return nil
2680}
2681
2682// nestedGate is the pre-flight check every nested spawn path runs first: no
2683// root instance-control channel configured, or the box itself is not fit
2684// to run an isolated instance (NestedOK's own mounts and /ready check),
2685// refuses before anything else runs. Shared so seedUnit and
2686// startSidecarUnit gate on NestedOK exactly like startUnit does.
2687func (b *ExecBackend) nestedGate(rt registry.Route) error {
2688 if b.Control == nil {
2689 return fmt.Errorf("%s: instance control channel not configured", rt.Host)
2690 }
2691 if b.NestedOK != nil {
2692 if err := b.NestedOK(); err != nil {
2693 return fmt.Errorf("%s: refusing to wake an isolated instance: %w", rt.Host, err)
2694 }
2695 }
2696 return nil
2697}
2698
2699// instanceEnv composes the host's two EnvironmentFile layers, both optional
2700// like a unit's "-" prefix: the sync-owned bucket config (<host>.config.env)
2701// underneath, the instance's own file (<host>.env) on top — it wins conflicts,
2702// see mergeEnv. Shared by Start and Seed precisely so a seed sees the SAME
2703// environment the instance will: a seed that read different credentials, or a
2704// different database URL, than the process it initializes would be a trap.
2705// (Start layers its agent-owned control-channel vars on top of this; those
2706// belong to a serving process, not to a one-shot init. Both callers mint the
2707// instance's CARLOS_ADMIN_TOKEN into <host>.env first, so "the same
2708// environment" includes the same secret.)
2709func (b *ExecBackend) instanceEnv(rt registry.Route) (map[string]string, error) {
2710 cfgEnv, ownEnv, err := b.instanceEnvParts(rt)
2711 if err != nil {
2712 return nil, err
2713 }
2714 return mergeEnv(cfgEnv, ownEnv), nil
2715}
2716
2717// instanceEnvParts is instanceEnv's two reads, before they are merged:
2718// the sync-owned config layer and the instance's own file. Extracted so
2719// LaunchEnvironment can see which names the own file shadows; instanceEnv
2720// is exactly mergeEnv over these two, as it was.
2721func (b *ExecBackend) instanceEnvParts(rt registry.Route) (cfgEnv, ownEnv map[string]string, err error) {
2722 paths := b.pathsFor(rt)
2723 // A nested host's files live under a tenant-writable directory (design
2724 // §3.2): read them without following a symlink. A flat host's files sit
2725 // outside any tenant's reach, so the plain read stays exactly as it was.
2726 read := readEnvFile
2727 if paths.layout == LayoutNested {
2728 read = func(path string) (map[string]string, error) {
2729 return readEnvFileBeneath(filepath.Dir(path), filepath.Base(path))
2730 }
2731 }
2732 cfgEnv, err = read(paths.configEnv)
2733 if err != nil && !os.IsNotExist(err) {
2734 return nil, nil, fmt.Errorf("read config env file: %w", err)
2735 }
2736 ownEnv, err = read(paths.envFile)
2737 if err != nil && !os.IsNotExist(err) {
2738 return nil, nil, fmt.Errorf("read env file: %w", err)
2739 }
2740 return cfgEnv, ownEnv, nil
2741}
2742
2743// LaunchEnvironment is the composed environment the next Start of rt
2744// would spawn with: childEnv over agentOwnedVars over instanceEnv, the
2745// exact composition Start uses (agentOwnedVars(rt, fileEnv, haveControl)),
2746// decoded back to a map. agentOwnedVars REMOVES tenant-provided
2747// CARLOS_CONTROL_SOCKET, CARLOS_PURGE and CARLOS_REGION when the agent
2748// does not provide them, and always sets CARLOS_HOST, so a report over
2749// mergeEnv alone would misstate the environment. haveControl is true: startChild calls
2750// edge.ListenControl for EVERY exec child, and b.Control is the nested
2751// root-control client, which says nothing about the flat channel, so
2752// before the spawn the honest composition is "the agent will bind it"; a
2753// bind that fails at spawn is logged there and the probe's spawn-time
2754// check compares the actual outcome. CARLOS_PURGE follows b.Cache != nil
2755// exactly as agentOwnedVars does.
2756//
2757// shadowed is the sorted names present in both the rendered configuration
2758// file and the instance's own <host>.env: the override mergeEnv applies
2759// (moves spec §7.3 T3 reports it), collected here because this is the only
2760// layer that sees both files. The one exception is mergeEnv's: an empty
2761// CARLOS_INSTANCE_KEY in the own file overrides nothing, so it is not
2762// listed. The move's launch report (§7.3, codex R2-8)
2763// is computed over exactly this env, so a target compares what would run,
2764// not a reconstruction of it. The values are raw: the caller HMACs them
2765// and must never log or return them.
2766func (b *ExecBackend) LaunchEnvironment(rt registry.Route) (map[string]string, []string, error) {
2767 env, _, shadowed, err := b.LaunchLayers(rt)
2768 return env, shadowed, err
2769}
2770
2771// LaunchLayers is LaunchEnvironment with the names whose running value the
2772// instance's own layers supplied: every name of the rendered configuration
2773// file and the instance's own <host>.env, as mergeEnv merged them, except
2774// the names agentOwnedVars decides (agentOwnedNames), whose running value
2775// is the agent's whatever the files say. A name in the composed env that is
2776// not in instance came from the agent: its own variables or its process
2777// environment (childEnv). The move's env pin needs the difference
2778// (moves.ReportLaunch): a box variable such as HOSTNAME is the box's when
2779// inherited and the instance's when its files set it.
2780func (b *ExecBackend) LaunchLayers(rt registry.Route) (env map[string]string, instance map[string]bool, shadowed []string, err error) {
2781 cfgEnv, ownEnv, err := b.instanceEnvParts(rt)
2782 if err != nil {
2783 return nil, nil, nil, err
2784 }
2785 for k := range ownEnv {
2786 if _, rendered := cfgEnv[k]; rendered {
2787 if k == instancekey.Var && ownEnv[k] == "" {
2788 continue // an empty own key shadows nothing (mergeEnv)
2789 }
2790 shadowed = append(shadowed, k)
2791 }
2792 }
2793 sort.Strings(shadowed)
2794 fileEnv := mergeEnv(cfgEnv, ownEnv)
2795 instance = make(map[string]bool, len(fileEnv))
2796 for k := range fileEnv {
2797 instance[k] = true
2798 }
2799 for _, k := range agentOwnedNames {
2800 delete(instance, k)
2801 }
2802 out := map[string]string{}
2803 for _, kv := range childEnv(b.agentOwnedVars(rt, rt.Socket+".control", fileEnv, true)) {
2804 if i := strings.IndexByte(kv, '='); i > 0 {
2805 out[kv[:i]] = kv[i+1:]
2806 }
2807 }
2808 return out, instance, shadowed, nil
2809}
2810
2811// childEnv is the child's complete environment: the agent's own, minus
2812// every CARLOS_* variable, with the instance's file values layered on top.
2813// One entry per key, last value wins: os/exec deduplicates cmd.Env that
2814// way, but on the unit path the trampoline hands this list to execve as
2815// is, duplicates and all, and a Go child then keeps the FIRST occurrence
2816// (design §6.2, codex R1-16). Composing it deduplicated here keeps the
2817// two spawn paths identical.
2818func childEnv(fileEnv map[string]string) []string {
2819 order := make([]string, 0, 32)
2820 values := make(map[string]string, 32)
2821 set := func(k, v string) {
2822 if _, seen := values[k]; !seen {
2823 order = append(order, k)
2824 }
2825 values[k] = v
2826 }
2827 for _, kv := range os.Environ() {
2828 if strings.HasPrefix(kv, "CARLOS_") {
2829 continue
2830 }
2831 k, v, ok := strings.Cut(kv, "=")
2832 if !ok {
2833 continue
2834 }
2835 set(k, v)
2836 }
2837 keys := make([]string, 0, len(fileEnv))
2838 for k := range fileEnv {
2839 keys = append(keys, k)
2840 }
2841 sort.Strings(keys)
2842 for _, k := range keys {
2843 set(k, fileEnv[k])
2844 }
2845 env := make([]string, 0, len(order))
2846 for _, k := range order {
2847 env = append(env, k+"="+values[k])
2848 }
2849 return env
2850}
2851
2852// withVar returns a copy of env with key=value added — a copy, so the
2853// caller's map (an EnvironmentFile's parsed contents) is never mutated.
2854func withVar(env map[string]string, key, value string) map[string]string {
2855 out := make(map[string]string, len(env)+1)
2856 for k, v := range env {
2857 out[k] = v
2858 }
2859 out[key] = value
2860 return out
2861}
2862
2863// withoutVars returns a copy of env with keys removed — the mirror of withVar,
2864// and the half that makes an agent-owned variable actually agent-owned. Absent
2865// it, "the agent decides whether this var is set" is only true when the agent
2866// sets it; a tenant supplying the same name in its own env file wins by
2867// default. Same copy discipline as withVar: the caller's map is never mutated.
2868//
2869// This package keeps its own agent-owned list inline (the two names above,
2870// at Start's control-channel branches) rather than sharing one with
2871// internal/appconfig's agentOwned (agentvars.go), which the unit-tenant sync
2872// path uses for the same purpose — the two lists must not drift apart.
2873func withoutVars(env map[string]string, keys ...string) map[string]string {
2874 out := make(map[string]string, len(env))
2875 for k, v := range env {
2876 out[k] = v
2877 }
2878 for _, k := range keys {
2879 delete(out, k)
2880 }
2881 return out
2882}
2883
2884// mergeEnv composes the child's env-file layers: base (the sync-owned
2885// <host>.config.env, bucket-derived) under overlay (the instance's own
2886// <host>.env). The overlay wins every conflict but one — CARLOS_ADMIN_TOKEN
2887// lives only in the instance file and must never be clobberable from
2888// the bucket, and a box-local override deliberately beats central
2889// config. The exception: an empty CARLOS_INSTANCE_KEY in the overlay
2890// supplies no key, so a non-empty one in base stands (instancekey.Effective).
2891// Returns a fresh map; inputs are never mutated.
2892func mergeEnv(base, overlay map[string]string) map[string]string {
2893 out := make(map[string]string, len(base)+len(overlay))
2894 for k, v := range base {
2895 out[k] = v
2896 }
2897 for k, v := range overlay {
2898 out[k] = v
2899 }
2900 // An empty CARLOS_INSTANCE_KEY in the instance's own file supplies no
2901 // key, so it never shadows a configured one: the child is never handed
2902 // an empty key while a non-empty one exists (instance-key plan ruling
2903 // 13). instancekey.Effective is the rule the key's mint decides by.
2904 if key, _ := instancekey.Effective(base, overlay); key != "" {
2905 out[instancekey.Var] = key
2906 }
2907 return out
2908}
2909
2910// HasSidecar: a host has a sidecar iff its sidecar EnvironmentFile exists.
2911// Derived from the filesystem on purpose, the way systemd units would do it:
2912// no registry column to drift from what's actually staged.
2913//
2914// This is the LEGACY file-staged co-spawn — Eleven runs on it today, so it
2915// is not going anywhere yet. The NATIVE path is a route with
2916// registry.Route.IsSidecar true: its own registry row, spawned by
2917// StartWorker instead of StartSidecar, with no parent instance and no file
2918// on disk. A host may carry one mechanism or the other but never both — the
2919// provisioning reconciler's mint-time check (sidecar_reconcile.go) refuses
2920// to mint a native row for a host this method already reports true for.
2921// Retiring this file path is a follow-up gated on Eleven's own migration to
2922// native sidecar records.
2923func (b *ExecBackend) HasSidecar(rt registry.Route) bool {
2924 _, err := os.Stat(b.pathsFor(rt).sidecarEnv)
2925 return err == nil
2926}
2927
2928// StartSidecar dispatches by layout, the same way Start does.
2929func (b *ExecBackend) StartSidecar(ctx context.Context, rt registry.Route) (Proc, error) {
2930 p := b.pathsFor(rt)
2931 switch p.layout {
2932 case LayoutNested:
2933 return b.startSidecarUnit(ctx, rt)
2934 case LayoutFenced, LayoutConflict:
2935 return nil, fmt.Errorf("%s: refusing to start sidecar: %s", rt.Host, p.reason)
2936 }
2937 return b.startSidecarChild(ctx, rt)
2938}
2939
2940// startSidecarChild execs the host's live symlink with `sidecar run` under
2941// the sidecar's EnvironmentFile, CARLOS_*-clean like the instance spawn. No
2942// health wait — the sidecar is a pure outbound client with no socket to
2943// probe; its own connect/backoff loop absorbs a slow instance. The live
2944// symlink exists by now: the instance spawn checked it. The ctx is unused (an
2945// exec doesn't block); it's here for the backend seam's symmetry.
2946//
2947// This arm serves the LEGACY co-spawn only (see HasSidecar) — StartWorker is
2948// the native spawn path for a standalone registry.Route.IsSidecar row.
2949//
2950// With LiveExecGate set (a declined box that honours failure records or
2951// hold-ran markers; Task 9 fix round 1, ruling 1), the link is resolved once,
2952// without hashing (release.ResolveLivePath), the gate is asked about that
2953// build, and the file it resolved is exec'd, not the link. The legs leave a
2954// held instance running on its old build while root repoints the link, and
2955// every respawn of its sidecar (the wake tail, late attachment, the sweep's
2956// supervision) comes through here: exec'ing the link would run the held
2957// build beside it. A refusal starts nothing, and the supervisor's backoff
2958// keeps the sidecar down. With the gate nil it is today's exec of the link.
2959func (b *ExecBackend) startSidecarChild(ctx context.Context, rt registry.Route) (Proc, error) {
2960 if b.LiveExecGate == nil {
2961 return b.startSidecarChildFrom(ctx, rt, b.liveLink(rt.Host))
2962 }
2963 id, err := release.ResolveLivePath(b.OptDir, rt.Host)
2964 if err != nil {
2965 return nil, fmt.Errorf("%s: sidecar: %w", rt.Host, err)
2966 }
2967 if err := b.LiveExecGate(rt, id.Version); err != nil {
2968 return nil, err
2969 }
2970 return b.startSidecarChildFrom(ctx, rt, id.StagedPath)
2971}
2972
2973// startSidecarChildFrom is startSidecarChild with the binary named. That is
2974// the live link for today's StartSidecar, and a generation's captured
2975// staged path for StartSidecarGen (zero-downtime spec §7.5). Placement is
2976// plan 2's: under GenContainment the sidecar lands in role-sidecar, with no
2977// limit of its own, under the generation parent's backstop (§7.3, plan 2
2978// D4). The parent holds no process and keeps memory.oom.group=0.
2979func (b *ExecBackend) startSidecarChildFrom(_ context.Context, rt registry.Route, bin string) (Proc, error) {
2980 fileEnv, err := readEnvFile(b.pathsFor(rt).sidecarEnv)
2981 if err != nil {
2982 return nil, fmt.Errorf("read sidecar env: %w", err)
2983 }
2984 // The sidecar runs the app binary with no control socket, so on a
2985 // replica host it is gated as the worker is (ruling 26): its home's key,
2986 // verified, or nothing runs. A key in its own file is checked too.
2987 if err := b.replicaKeyGate(rt, fileEnv, sidecarKeyLayer(fileEnv), false); err != nil {
2988 return nil, err
2989 }
2990 child, _, err := b.placeFor(spawnSidecar, 0)
2991 if err != nil {
2992 return nil, err
2993 }
2994 // Same host cgroup as the instance: a sidecar's load is that instance's
2995 // work, and attributing it elsewhere would be a lie in the other
2996 // direction.
2997 cmd, settle, err := b.spawnInCgroupAt(rt, child, false, func() *exec.Cmd {
2998 cmd := exec.Command(bin, "sidecar", "run")
2999 cmd.Env = childEnv(fileEnv)
3000 // Like the instance child: logs through the agent's own streams
3001 // (journald under systemd), teed into the log sink when armed — a
3002 // sidecar's output is the app's output.
3003 b.teeStd(cmd, rt)
3004 return cmd
3005 })
3006 if err != nil {
3007 return nil, err
3008 }
3009 p := &childProc{cmd: cmd, done: make(chan struct{})}
3010 go func() {
3011 p.err = cmd.Wait()
3012 settle()
3013 b.Cgroups.RemoveChild(rt.Host, child) // no-op for "" (flag off)
3014 b.cleanupHost(rt.Host)
3015 close(p.done)
3016 }()
3017 return p, nil
3018}
3019
3020// StartWorker is StartWorkerStaged onto whatever live/<host> names now. Its
3021// string result is that build's version, as it always was.
3022func (b *ExecBackend) StartWorker(ctx context.Context, rt registry.Route, extra map[string]string) (Proc, string, error) {
3023 p, id, err := b.StartWorkerStaged(ctx, rt, extra, nil)
3024 return p, id.Version, err
3025}
3026
3027// StartWorkerStaged execs a worker with the route's declared argv (rt.Args, a
3028// JSON array: a sidecar's `["sidecar","run"]` today) under the instance's own
3029// env layers, CARLOS_*-clean like every other child this backend spawns, and
3030// with NO health wait: a worker binds nothing, so there is nothing
3031// waitHealthyCtx could dial. extra (the per-wake sidecar token) is applied
3032// last and wins over the tenant's own <host>.env.
3033//
3034// What it execs is a staged build BY PATH, never live/<host> (zero-downtime
3035// deploys spec §7.9):
3036// - onto == nil (a wake, or a converge onto a new build): the live link is
3037// ensured, read ONCE, and the file it names is hashed and exec'd, once
3038// LiveExecGate (when set) has passed its build;
3039// - onto != nil (a crash or max_run recycle): onto.StagedPath is checked
3040// against onto.Digest (release.VerifyStaged) and exec'd. A copy that has
3041// changed is refused with release.ErrStagedCopyChanged and nothing runs.
3042//
3043// The identity returned is the build exec'd, the agent's serving identity for
3044// this child. What the child later reports over the agent socket is display
3045// metadata only.
3046//
3047// Between resolving that identity and the exec it runs ctx's before-exec hook
3048// (runBeforeExec), through which the activator publishes the build first; a
3049// hook error means nothing runs (ruling R19 item 3). ctx's exec gate
3050// (withExecGate), the caller's ownership check, is the cgroup manager's
3051// before-start hook (launchGate), as a flat instance spawn's is: asked after
3052// the cgroup's preparation and before each start attempt, and its refusal
3053// starts nothing and comes back as it is (Astra R3 §5 item 4).
3054//
3055// A worker on a replica row passes replicaKeyGate as a seed does, with no
3056// control socket, before that hook: its home's key, verified, or nothing
3057// runs (instance-key plan ruling 26). A worker never mints a key.
3058func (b *ExecBackend) StartWorkerStaged(ctx context.Context, rt registry.Route, extra map[string]string, onto *release.StagedIdentity) (Proc, release.StagedIdentity, error) {
3059 var argv []string
3060 if err := json.Unmarshal([]byte(rt.Args), &argv); err != nil || len(argv) == 0 {
3061 return nil, release.StagedIdentity{}, fmt.Errorf("worker %s: no runnable argv in args %q", rt.Host, rt.Args)
3062 }
3063 var id release.StagedIdentity
3064 if onto == nil {
3065 if err := b.ensureLiveLink(ctx, rt.Host); err != nil {
3066 return nil, release.StagedIdentity{}, err
3067 }
3068 got, err := release.ResolveLive(b.OptDir, rt.Host)
3069 if err != nil {
3070 return nil, release.StagedIdentity{}, fmt.Errorf("worker %s: %w", rt.Host, err)
3071 }
3072 id = got
3073 // A declined box that honours failure records or hold-ran markers
3074 // (Task 9) asks about the build just resolved, which is the build
3075 // exec'd (by its staged path), before it is published or anything is
3076 // prepared. A refusal runs nothing and comes back as it is. A recycle
3077 // onto the captured build (onto set) is no move onto the live link,
3078 // and is not asked.
3079 if b.LiveExecGate != nil {
3080 if err := b.LiveExecGate(rt, id.Version); err != nil {
3081 return nil, release.StagedIdentity{}, err
3082 }
3083 }
3084 } else {
3085 if err := release.VerifyStaged(*onto); err != nil {
3086 return nil, release.StagedIdentity{}, fmt.Errorf("worker %s: not recycled onto %s: %w", rt.Host, onto.Version, err)
3087 }
3088 id = *onto
3089 }
3090
3091 // One read gives the env and the layer its CARLOS_INSTANCE_KEY came
3092 // from, for the replica gate below (ruling 29). A worker never mints
3093 // (ruling 12): the key is whatever the env files already deliver.
3094 fileEnv, keyLayer, err := b.instanceEnvLayer(rt)
3095 if err != nil {
3096 return nil, release.StagedIdentity{}, err
3097 }
3098 for k, v := range extra {
3099 fileEnv = withVar(fileEnv, k, v)
3100 }
3101 // A worker has no control socket, so a replica row's worker is gated as
3102 // its seed is (ruling 26): its home's key, verified, or refused. extra is
3103 // agent-issued and has no business carrying a key; one that did would
3104 // not be the key the layer describes, so it is refused as of unknown
3105 // origin.
3106 if _, ok := extra[instancekey.Var]; ok {
3107 keyLayer = instancekey.LayerNone
3108 }
3109 if err := b.replicaKeyGate(rt, fileEnv, keyLayer, false); err != nil {
3110 return nil, release.StagedIdentity{}, err
3111 }
3112
3113 // The identity is resolved and nothing has run: the activator publishes
3114 // the build now, before the exec, and a failure means no child (ruling
3115 // R19 item 3).
3116 if err := runBeforeExec(ctx, id); err != nil {
3117 return nil, release.StagedIdentity{}, fmt.Errorf("worker %s: %w", rt.Host, err)
3118 }
3119
3120 // Through spawnInCgroup, not Cgroups.Start directly: that seam is what
3121 // applies the route's resource tier, and a worker is a long-lived child
3122 // of a tenant's app like any other. Bypassing it compiles and silently
3123 // ships an uncapped process — the exact failure spawnInCgroup's own
3124 // comment names.
3125 //
3126 // Gated (spawnInCgroupGated): ownership can be lost while the cgroup is
3127 // prepared and the process placed, or between a failed placement and
3128 // its PID-move fallback, so the caller's gate is asked at each exec, not
3129 // once before all of it.
3130 // With no gate on ctx (an unplanned wake's spawn passes none) launchGate
3131 // is nil and this is spawnInCgroup exactly. The zero tier is
3132 // spawnInCgroup's too: the route's live tier (b.tier), as a worker has
3133 // always had.
3134 cmd, settle, err := b.spawnInCgroupGated(rt, cgroup.Limit{}, func() *exec.Cmd {
3135 cmd := exec.Command(id.StagedPath, argv...)
3136 cmd.Env = childEnv(fileEnv)
3137 b.teeStd(cmd, rt)
3138 return cmd
3139 }, b.launchGate(ctx, rt.Host))
3140 if err != nil {
3141 return nil, release.StagedIdentity{}, err
3142 }
3143 p := &childProc{cmd: cmd, done: make(chan struct{})}
3144 go func() {
3145 p.err = cmd.Wait()
3146 // Mirrors Start's exit record (#5 review, closed here): a worker's
3147 // crash is a sidecar's health word ("Crashed") the moment it lands,
3148 // so the member needs the SAME explanation an instance crash gets,
3149 // not silence. This fires once per actual exit, and the caller's
3150 // own doubling-backoff restart (2s up to a minute, activator.go)
3151 // already bounds how often an exit can happen at all — a
3152 // crash-looping worker cannot make this any noisier than the
3153 // restart log line it already earns internally.
3154 if p.err != nil {
3155 emitEvent(b.Logs, rt, "warn", "worker exited: "+RedactPaths(p.err.Error()))
3156 } else {
3157 emitEvent(b.Logs, rt, "info", "worker exited (exit 0)")
3158 }
3159 settle()
3160 b.cleanupHost(rt.Host)
3161 close(p.done)
3162 }()
3163 return p, id, nil
3164}
3165
3166// startSidecarUnit is StartSidecar's nested body: a sidecar has no health
3167// wait either way, and no control listener of its own. Sidecar env is a
3168// single layer, read straight from its own file, never merged with the
3169// instance's two-layer config.
3170func (b *ExecBackend) startSidecarUnit(ctx context.Context, rt registry.Route) (Proc, error) {
3171 return b.startSidecarUnitFrom(ctx, rt, nil, cgroup.Limit{})
3172}
3173
3174// startSidecarUnitFrom is startSidecarUnit with the executable and the tier
3175// named. A nil exe is the live link (today's start), pinned instead on a box
3176// with LiveExecGate set (declinedLivePin). A staged exe is the serving
3177// generation's captured build (zero-downtime spec §7.5), which root
3178// validates before it lays out the mount, and tier that generation's own.
3179func (b *ExecBackend) startSidecarUnitFrom(ctx context.Context, rt registry.Route, exe *instancectl.StagedExe, tier cgroup.Limit) (Proc, error) {
3180 if b.Cfg.HostWrites.Enabled {
3181 return nil, ErrClaimIsolationUnavailable
3182 }
3183 if err := b.nestedGate(rt); err != nil {
3184 return nil, err
3185 }
3186 if exe == nil && b.LiveExecGate != nil {
3187 // A declined box that honours failure records or hold-ran markers
3188 // (Task 9 fix round 1, ruling 1) pins the live link once and names
3189 // that file to root at its own tier (declinedLivePin), as the nested
3190 // instance start does (spawnUnitFrom): root binds the build the gate
3191 // passed, never the link. Before the launch file is written, so a
3192 // refusal leaves nothing behind.
3193 id, err := b.declinedLivePin(ctx, rt)
3194 if err != nil {
3195 return nil, err
3196 }
3197 exe, tier = stagedExeOf(id), b.GenTier(rt, id.Version)
3198 }
3199 p, _, logs, fileEnv, err := b.prepare(rt, instancectl.RoleSidecar)
3200 if err != nil {
3201 return nil, err
3202 }
3203 argv := []string{b.liveLink(rt.Host), "sidecar", "run"}
3204 if err := b.writeLaunch(p, instancectl.RoleSidecar, rt.Host, argv, childEnv(fileEnv)); err != nil {
3205 logs.Close()
3206 return nil, err
3207 }
3208 var inv string
3209 if exe != nil {
3210 // The serving generation's captured staged build, which root
3211 // validates before it lays out the mount, at that generation's own
3212 // tier, never the live link's.
3213 gc, gerr := b.genControl()
3214 if gerr != nil {
3215 logs.Close()
3216 return nil, gerr
3217 }
3218 if tier == (cgroup.Limit{}) {
3219 tier = b.tier(rt)
3220 }
3221 inv, err = gc.StartSidecarExe(ctx, rt.Host, tier.MemoryMax, *exe)
3222 } else {
3223 inv, err = b.Control.Start(ctx, rt.Host, instancectl.RoleSidecar, b.tier(rt).MemoryMax)
3224 }
3225 if err != nil {
3226 logs.Close()
3227 return nil, fmt.Errorf("%s: start sidecar unit: %w", rt.Host, err)
3228 }
3229 if inv == "" {
3230 logs.Close()
3231 return nil, fmt.Errorf("%s: %s exited before it was observed", rt.Host, instancectl.UnitName(instancectl.RoleSidecar, rt.Host))
3232 }
3233 return b.newUnitProc(rt, instancectl.RoleSidecar, p, inv, nil, logs), nil
3234}
3235
3236// socketClient returns an HTTP client that dials an instance's unix socket
3237// (request URLs use the placeholder host http://carlos).
3238func socketClient(socket string) *http.Client {
3239 return &http.Client{
3240 Timeout: 3 * time.Second,
3241 Transport: &http.Transport{
3242 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
3243 return unixdial.Checked(ctx, &net.Dialer{}, socket)
3244 },
3245 },
3246 }
3247}
3248
3249// waitHealthy blocks until the instance answers an HTTP request on its socket,
3250// or the timeout elapses. It retries, so it's robust to the restart window
3251// (socket briefly absent, or a stale socket left by an abruptly-killed old
3252// process) — and a broken binary that never becomes healthy fails the wake.
3253func waitHealthy(socket string, timeout time.Duration) error {
3254 deadline := time.Now().Add(timeout)
3255 client := socketClient(socket)
3256 var last error
3257 var poll time.Duration
3258 for time.Now().Before(deadline) {
3259 resp, err := client.Get("http://carlos/")
3260 if err == nil {
3261 resp.Body.Close()
3262 if resp.StatusCode < 500 {
3263 return nil
3264 }
3265 last = fmt.Errorf("unhealthy: HTTP %d", resp.StatusCode)
3266 } else {
3267 last = err
3268 }
3269 poll = nextHealthPoll(poll)
3270 time.Sleep(poll)
3271 }
3272 return fmt.Errorf("instance not healthy within %s: %v", timeout, last)
3273}
3274
3275// The wait between health probes. A request is held open for the whole of
3276// a wake, and a fixed 150 ms gap cost it 75 ms on average after the app was
3277// already listening. A refused dial costs microseconds, so the wait starts
3278// short and doubles to a ceiling that keeps a slow boot from being probed
3279// in a busy loop.
3280const (
3281 healthPollMin = 5 * time.Millisecond
3282 healthPollMax = 50 * time.Millisecond
3283)
3284
3285// nextHealthPoll is the wait after one of d; zero gives the first.
3286func nextHealthPoll(d time.Duration) time.Duration {
3287 return min(max(2*d, healthPollMin), healthPollMax)
3288}
3289
3290// waitHealthyCtx is waitHealthy bounded by ctx as well as its own timeout, so
3291// an aborted wake doesn't keep probing a socket nobody wants anymore.
3292func waitHealthyCtx(ctx context.Context, socket string, timeout time.Duration) error {
3293 done := make(chan error, 1)
3294 go func() { done <- waitHealthy(socket, timeout) }()
3295 select {
3296 case err := <-done:
3297 return err
3298 case <-ctx.Done():
3299 return ctx.Err()
3300 }
3301}
3302
3303// readEnvFile parses a KEY=value EnvironmentFile (blank lines and #comments
3304// ignored).
3305func readEnvFile(path string) (map[string]string, error) {
3306 raw, err := os.ReadFile(path)
3307 if err != nil {
3308 return nil, err
3309 }
3310 return parseEnvBytes(raw), nil
3311}
3312
3313// childProc is a spawned child: SIGTERM starts the instance's own graceful
3314// drain; SIGKILL only if it overstays.
3315type childProc struct {
3316 cmd *exec.Cmd
3317 done chan struct{}
3318 err error
3319 ctl *edge.ControlListener // nil only if the pre-spawn bind failed
3320}
3321
3322func (p *childProc) Exited() bool {
3323 select {
3324 case <-p.done:
3325 return true
3326 default:
3327 return false
3328 }
3329}
3330
3331// Crashed answers the activator's procCrasher seam: a DEFINITELY non-zero
3332// exit status. Read from ProcessState rather than from p.err because a clean
3333// exit whose pipes outlive it comes back as exec.ErrWaitDelay (cmd.WaitDelay
3334// is set on every child here) — an error that says nothing about the exit
3335// status, and reading it as a crash would restart a worker that finished.
3336// Anything this cannot establish reads as NOT a crash, which is procCrashed's
3337// own default and for its reasons.
3338func (p *childProc) Crashed() bool {
3339 if !p.Exited() {
3340 return false
3341 }
3342 st := p.cmd.ProcessState // safe: written before done closed, read after
3343 return st != nil && !st.Success()
3344}
3345
3346func (p *childProc) Stop(ctx context.Context) error {
3347 // The control listener OUTLIVES the drain, and the ordering is the whole
3348 // point. Closing it first — which is what this did — unbound and unlinked
3349 // the socket before the child had even seen SIGTERM, so an app that
3350 // registers or re-asserts its once-timers on the way down (spec §7.2's
3351 // lifetime decision — the socket brackets the PROCESS, bound before the
3352 // spawn and closed after the exit — serving §7.1's re-assert story) got
3353 // connection-refused on the channel the platform had just told it about,
3354 // during precisely the window it was told to use. Signal first, close
3355 // after the process is gone.
3356 //
3357 // Deferred, so it is unconditional on every path out of here — the
3358 // already-exited return, the lost-race return, the kill-timeout return,
3359 // and even a Signal error we hand back to the caller. A listener left
3360 // bound to a socket no process is behind is the stale-socket wake trap
3361 // (#129) waiting to happen, and this is the only close a childProc ever
3362 // gets — Start's own is on the path where the spawn failed and no
3363 // childProc was ever built.
3364 defer func() {
3365 if p.ctl != nil {
3366 p.ctl.Close()
3367 }
3368 }()
3369 if p.Exited() {
3370 return nil
3371 }
3372 if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
3373 if errors.Is(err, os.ErrProcessDone) { // lost the race with its own exit
3374 <-p.done
3375 return nil
3376 }
3377 return err
3378 }
3379 select {
3380 case <-p.done:
3381 return nil
3382 case <-ctx.Done():
3383 _ = p.cmd.Process.Kill()
3384 <-p.done
3385 return fmt.Errorf("instance did not exit on SIGTERM; killed")
3386 }
3387}
3388
3389// movesPrefix is where a residence move stages a replica in the TARGET's
3390// data bucket (instance-move spec): moves/<host>/ beside the ordinary
3391// instances/ prefix. A free function so the host's move worker and this
3392// package spell it identically.
3393func MovesPrefix(host string) string { return "moves/" + host + "/" }
3394
3395// MoveReceiptsKey is where a residence move's mirror keeps its copy
3396// receipts (#175, lease.Receipts) in the SOURCE box's own data bucket. The
3397// source cannot read the target's moves/ prefix (its grant there is
3398// push-only), so the receipts cannot live beside the staged copy. This key
3399// is under neither MovesPrefix nor the hibernate prefix, so no restore,
3400// promotion or staging cleanup ever sees it.
3401func MoveReceiptsKey(host string) string { return "move-receipts/" + host + ".json" }
3402
3403// PurgeLocalDBArtifacts removes the local litestream footprint next to a
3404// DB — WAL, SHM and both meta dirs — and, when full is true, the DB file
3405// itself. Restore's pre-restore cleanup uses the partial form (the DB's
3406// absence is what got us here); a residence move's cut uses the full form:
3407// the activator's warm-cache trust is exactly right for crashes and exactly
3408// wrong after a cut, where a stale local file would resurrect a moved-away
3409// instance's history. The full form removes the database's <db>.seed
3410// marker too (final review carry item): a marker that outlived its
3411// database would name a seed for whatever database is put there later.
3412func PurgeLocalDBArtifacts(dbPath string, full bool) {
3413 for _, p := range []string{dbPath + "-wal", dbPath + "-shm"} {
3414 _ = os.Remove(p)
3415 }
3416 _ = os.RemoveAll(dbPath + "-litestream")
3417 _ = os.RemoveAll(filepath.Join(filepath.Dir(dbPath), ".activator-"+filepath.Base(dbPath)+"-litestream"))
3418 if full {
3419 _ = os.Remove(dbPath)
3420 _ = os.Remove(dbPath + SeedMarkerSuffix)
3421 }
3422}
3423
3424// promoteStagedReplica copies moves/<host>/ over instances/<host>/ in this
3425// box's OWN bucket whenever anything is staged: staging wins when present,
3426// whatever the ordinary prefix holds (an exact mirror, so own keys the
3427// staging lacks are deleted). A no-op (nil error) when there is nothing
3428// staged.
3429func (b *ExecBackend) promoteStagedReplica(ctx context.Context, host string) (map[string]lease.MirrorObject, error) {
3430 // STAGING WINS when present (review round 1). A staged replica exists
3431 // only while an inbound move is landing or has just landed: it was
3432 // mirrored from the then-live data at cut, so it is by construction
3433 // newer than any lineage this box kept from a PREVIOUS residence — the
3434 // boomerang case (ie→uk→ie) that "own lineage never loses" restored
3435 // months-stale data from. An abandoned move's staging can't linger to
3436 // poison this: abort is two-phase, and the source deletes staging
3437 // before the move clears (MoveAborting).
3438 //
3439 // The trailing slash on the guard listing matters: hibernatePrefix has
3440 // none (path.Join), and a slashless List would let app.example.com's
3441 // keys answer for app.example.co.
3442 staged, err := b.Mirror.List(ctx, b.Cfg.Bucket, MovesPrefix(host))
3443 if err != nil {
3444 return nil, err
3445 }
3446 if len(staged) == 0 {
3447 return nil, nil
3448 }
3449 log.Printf("activator: %s: promoting %d staged object(s) from %s (residence move landing)", host, len(staged), MovesPrefix(host))
3450 // No receipts: a staged object is the source box's single-part copy,
3451 // and an in-bucket copy of it keeps its ETag, so plain ETag equality
3452 // is exact here. A deferred object (staging overwritten mid-promote,
3453 // which nothing should do once the move has cut) fails the wake
3454 // rather than restoring from a promotion that is not byte-final.
3455 round, err := lease.MirrorPrefix(ctx, b.Mirror, b.Cfg.Bucket, MovesPrefix(host), b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), lease.Receipts{})
3456 if err != nil {
3457 return nil, err
3458 }
3459 if round.Deferred > 0 {
3460 return nil, fmt.Errorf("promote staged replica for %s: %d staged object(s) changed during the copy; retrying on the next wake", host, round.Deferred)
3461 }
3462 return staged, nil
3463}
3464
3465// dropStagedReplica deletes the staged keys promote just returned — no
3466// third listing of a prefix this restore has already walked twice.
3467// Best-effort: a leftover staging copy is storage, not a correctness
3468// problem.
3469func (b *ExecBackend) dropStagedReplica(ctx context.Context, staged map[string]lease.MirrorObject) {
3470 for k := range staged {
3471 _ = b.Mirror.Delete(ctx, b.Cfg.Bucket, k)
3472 }
3473}
3474