carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os/exec"
8
9 "github.com/carlosframework/platform/internal/cgroup"
10 "github.com/carlosframework/platform/internal/instancectl"
11 "github.com/carlosframework/platform/internal/registry"
12)
13
14var (
15 errNoGenContainment = errors.New("activator: generation containment is off")
16 errNoGenControl = errors.New("activator: the instance control channel cannot run generation units")
17 // errGenUnproven is a generation start that failed and whose kill did
18 // not prove the generation gone. It may still be running: its listeners
19 // and launch file are kept, and a caller must keep whatever it owns for
20 // the host (a later plan's swap keeps the instance's ownership).
21 errGenUnproven = errors.New("activator: generation start failed and its termination is unproven")
22)
23
24// startUncertain marks a start's error when the start was handed to
25// something that may have run it before failing (final review wave 2, N4):
26// root's instance-control start call answered an error other than
27// instancectl.ErrStagedExe, or no invocation. Root may have started the
28// unit, and the build may have run, whatever became of it after. The
29// wrapper changes neither the text nor errors.Is, so every existing
30// classification and member text is the same; only startMayHaveRun reads
31// it.
32type startUncertain struct{ err error }
33
34func (e startUncertain) Error() string { return e.err.Error() }
35func (e startUncertain) Unwrap() error { return e.err }
36
37// markStartUncertain wraps err as startUncertain.
38func markStartUncertain(err error) error { return startUncertain{err: err} }
39
40// legacyStartUncertain marks a failed start of a nested host's original
41// run unit (generation 0, carlos-instance@) that root was asked for and may
42// still carry out (#292): cancelling the client's /start does not cancel a
43// start job systemd has queued, and that job can launch a writer later.
44// The failed wake's cleanup must stop the unit and prove it gone
45// (legacyRunProver) before the lease goes; unproven, the lease is kept.
46// Like startUncertain it changes neither the text nor errors.Is, so the
47// member's wording is unchanged.
48type legacyStartUncertain struct{ err error }
49
50func (e legacyStartUncertain) Error() string { return e.err.Error() }
51func (e legacyStartUncertain) Unwrap() error { return e.err }
52
53// markLegacyStartUncertain wraps err as legacyStartUncertain.
54func markLegacyStartUncertain(err error) error { return legacyStartUncertain{err: err} }
55
56// launchRefused marks a start that its pre-exec gate refused
57// (runExecGate): the caller's exec gate, the wake's pending record or the
58// recheck after it answered no, immediately before the exec or before root
59// was asked. Nothing of that launch ran, so a failed wake whose start comes
60// back marked is known not started, not uncertain (seeded pre-land review,
61// P2). Like startUncertain it changes neither the text nor errors.Is.
62type launchRefused struct{ err error }
63
64func (e launchRefused) Error() string { return e.err.Error() }
65func (e launchRefused) Unwrap() error { return e.err }
66
67// launchNotStarted reports whether err is a start its pre-exec gate
68// refused (launchRefused), and nothing marks it as one that may have run.
69func launchNotStarted(err error) bool {
70 var r launchRefused
71 return errors.As(err, &r) && !legacyStartMayRun(err) && !startMayHaveRun(nil, err)
72}
73
74// legacyStartMayRun reports whether err is a legacy run unit's start whose
75// queued job may still run (legacyStartUncertain).
76func legacyStartMayRun(err error) bool {
77 var l legacyStartUncertain
78 return errors.As(err, &l)
79}
80
81// legacyRunProver is the backend half of that proof: stop the host's
82// original run unit through instance-control and prove it gone (stop
83// answered, unit inactive, its cgroup empty). nil is the proof.
84// ExecBackend has it; SplitBackend passes it to its exec side.
85type legacyRunProver interface {
86 LegacyRunGone(ctx context.Context, rt registry.Route) error
87}
88
89// errNoLegacyProof: the backend cannot stop a legacy run unit, so nothing
90// proves an aborted start gone and its lease is kept.
91var errNoLegacyProof = errors.New("activator: this backend cannot prove a run unit stopped")
92
93// legacyStopBudget bounds one legacy proof: the stop call and the wait for
94// the unit to read stopped, on a context of its own (a cancelled wake's
95// context is often why the start was aborted).
96const legacyStopBudget = instancectl.DeadlineControl
97
98// legacyRunGone runs the backend's proof for rt under legacyStopBudget and
99// never past Shutdown's own budget (Astra, rounds 2 and 3): a proof started
100// once Shutdown has begun runs under its context, and every proof is
101// registered so that one already running when Shutdown began is cancelled
102// the moment that budget ends (cancelProofs). A proof cut short is
103// unproven, so the lease is kept.
104func (a *Service) legacyRunGone(rt registry.Route) error {
105 p, ok := a.backend.(legacyRunProver)
106 if !ok {
107 return errNoLegacyProof
108 }
109 parent := context.Background()
110 token := new(byte)
111 a.mu.Lock()
112 if a.shutdownCtx != nil {
113 parent = a.shutdownCtx
114 }
115 ctx, cancel := context.WithTimeout(parent, legacyStopBudget)
116 if a.proofCancels == nil {
117 a.proofCancels = make(map[*byte]context.CancelFunc)
118 }
119 a.proofCancels[token] = cancel
120 a.mu.Unlock()
121 defer func() {
122 a.mu.Lock()
123 delete(a.proofCancels, token)
124 a.mu.Unlock()
125 cancel()
126 }()
127 return p.LegacyRunGone(ctx, rt)
128}
129
130// cancelProofs cancels every legacy proof still running: Shutdown arms it
131// on its own context, so none outlives the shutdown budget.
132func (a *Service) cancelProofs() {
133 a.mu.Lock()
134 defer a.mu.Unlock()
135 for _, cancel := range a.proofCancels {
136 cancel()
137 }
138}
139
140// errStartedThenExited is the one uncertain start whose outcome is known to
141// be a crash at the start: root started the unit and it had exited before
142// root read its invocation (Astra R3 ruling 206, zdd-readiness Task 6). The
143// build ran and died on its own, so a planned wake fails the build in
144// overlap mode too, where any other uncertain start is only "may have run".
145// It is wrapped by markStartUncertain, so startMayHaveRun still reads it.
146var errStartedThenExited = errors.New("generation exited before it was observed")
147
148// exitedUnobserved is errStartedThenExited in the unit's own words. Its
149// text is exactly the one spawnUnitFrom always wrote, because a flag-off
150// nested wake fails with it and memberSafeWakeReason passes it to the
151// member; only errors.Is learns the sentinel.
152type exitedUnobserved struct{ unit string }
153
154func (e exitedUnobserved) Error() string { return e.unit + " exited before it was observed" }
155func (e exitedUnobserved) Is(target error) bool { return target == errStartedThenExited }
156
157// startMayHaveRun is a spawn's execution evidence, tri-state collapsed to
158// what an attempt may rely on (final review wave 2, N4): false only when it
159// is PROVEN that nothing of the build ran (no process came back, and the
160// error neither says the start's kill is unproven nor that the start was
161// handed off with an uncertain outcome); true when it ran or may have. A
162// flat start that failed is proven: the exec never happened. Every refusal
163// before the start (a gate, a preparation step, root's ErrStagedExe) is
164// proven too.
165func startMayHaveRun(proc Proc, err error) bool {
166 if proc != nil || genStartUnproven(err) {
167 return true
168 }
169 var u startUncertain
170 return errors.As(err, &u)
171}
172
173// spawnKind is which child shape a spawn is, for placeFor.
174type spawnKind int
175
176const (
177 spawnInstance spawnKind = iota
178 spawnSidecar // the legacy co-spawned sidecar (<host>-sidecar.env)
179 spawnSeed
180 spawnWorker // workers and native sidecar members (StartWorker)
181)
182
183// placeFor names the child cgroup a spawn lands in under the generation
184// layout (zero-downtime spec §7.3, plan 2 D4): g<n> with the tier's full
185// limits for an instance generation; role-sidecar and role-seed, with no
186// limit of their own, for the legacy co-spawned sidecar and the seed, which
187// share the instance's host cgroup and so cannot sit in a parent that holds
188// no processes. Workers keep one cgroup per host because they never overlap
189// (§7.9). "" (today's single-level cgroup) for every kind while
190// GenContainment is off.
191func (b *ExecBackend) placeFor(kind spawnKind, n uint64) (child string, ownTier bool, err error) {
192 if !b.GenContainment {
193 return "", false, nil
194 }
195 switch kind {
196 case spawnInstance:
197 if n == 0 {
198 return "", false, errors.New("activator: generation 0 is not a generation")
199 }
200 return cgroup.GenChild(n), true, nil
201 case spawnSidecar:
202 return cgroup.RoleChild("sidecar"), false, nil
203 case spawnSeed:
204 return cgroup.RoleChild("seed"), false, nil
205 }
206 return "", false, nil
207}
208
209// spawnInCgroupAt is spawnInCgroupAtTier with the live tier: the spawn of a
210// placed child whose artifact is the one the live link names.
211func (b *ExecBackend) spawnInCgroupAt(rt registry.Route, child string, ownTier bool, build func() *exec.Cmd) (*exec.Cmd, func(), error) {
212 return b.spawnInCgroupAtTier(rt, child, ownTier, cgroup.Limit{}, build, nil)
213}
214
215// spawnInCgroupAtTier is spawnInCgroup for a placed child, with the
216// artifact's tier named: the generation child's own limit, and the parent's
217// floor for StartChild (which never lowers a parent a swap has raised). Zero
218// tier is b.tier(rt). With no child it IS spawnInCgroup at that tier, so a
219// flag-off spawn with a zero tier is byte for byte today's, and a recorded
220// build's (ruling R-TIER) caps the host cgroup at its own tier.
221//
222// A placed spawn is never strict: strict containment of a generation layout
223// is the claim path's (PrepareStrictParent, plan 5). It still honours the
224// host gate, though, so a placed spawn can never start beside a strict
225// owner's group, and a host that is strict is refused rather than started
226// outside its strict group. The returned settle is spawnInCgroup's: a no-op
227// here. A spawn that fails removes the child it may have created.
228//
229// before, when set, is the cgroup manager's before-start hook (final review
230// F1): asked immediately before each start attempt, after the parent and
231// child are prepared, and on the PID-move retry too. A refusal starts
232// nothing, is cleaned up as a failed start is, and comes back as before's
233// own error. Nil is the spawn exactly as it was.
234func (b *ExecBackend) spawnInCgroupAtTier(rt registry.Route, child string, ownTier bool, tier cgroup.Limit, build func() *exec.Cmd, before func() error) (*exec.Cmd, func(), error) {
235 if b.Cfg.HostWrites.Enabled {
236 return nil, nil, ErrClaimIsolationUnavailable
237 }
238 if child == "" {
239 return b.spawnInCgroupGated(rt, tier, build, before)
240 }
241 start := func() (*exec.Cmd, func(), error) {
242 t := tier
243 if t == (cgroup.Limit{}) {
244 t = b.tier(rt)
245 }
246 own := cgroup.Limit{}
247 if ownTier {
248 own = t
249 }
250 cmd, err := b.Cgroups.StartChildGated(rt.Host, child, t, own, build, before)
251 if err != nil {
252 b.Cgroups.RemoveChild(rt.Host, child)
253 return nil, nil, err
254 }
255 return cmd, func() {}, nil
256 }
257 if b.Strict == nil {
258 return start()
259 }
260 g := b.gate(rt.Host)
261 g.mu.Lock()
262 defer g.mu.Unlock()
263 if b.Strict(rt.Host) {
264 return nil, nil, fmt.Errorf("%s: %w: a generation layout is not started strict here", rt.Host, ErrStrictRequired)
265 }
266 if o := g.owner; o != nil {
267 cause := ErrStrictGroupInUse
268 if o.pending {
269 cause = ErrStrictCleanupPending
270 }
271 return nil, nil, fmt.Errorf("%s: %w", rt.Host, cause)
272 }
273 return start()
274}
275
276// genControl is Control's generation half, or errNoGenControl.
277func (b *ExecBackend) genControl() (GenControl, error) {
278 if b.Control == nil {
279 return nil, errNoGenControl
280 }
281 gc, ok := b.Control.(GenControl)
282 if !ok {
283 return nil, errNoGenControl
284 }
285 return gc, nil
286}
287
288// errNoStagedExeControl refuses a start or seed of an original template
289// that names a staged build when the control channel cannot name one.
290// Binding the live link instead would run a build nobody recorded.
291var errNoStagedExeControl = errors.New("activator: the instance control channel cannot name an executable for this unit")
292
293// stagedExeControl is Control's original-template executable half, or
294// errNoStagedExeControl.
295func (b *ExecBackend) stagedExeControl() (StagedExeControl, error) {
296 if b.Control == nil {
297 return nil, errNoStagedExeControl
298 }
299 xc, ok := b.Control.(StagedExeControl)
300 if !ok {
301 return nil, errNoStagedExeControl
302 }
303 return xc, nil
304}
305
306// unitGen is the generation a nested run unit is started as: 0 (the
307// original template) while GenContainment is off, n otherwise. n of 0
308// with containment on is refused.
309func (b *ExecBackend) unitGen(n uint64) (uint64, error) {
310 if !b.GenContainment {
311 return 0, nil
312 }
313 if n == 0 {
314 return 0, errors.New("activator: generation 0 is not a generation")
315 }
316 return n, nil
317}
318
319// startRole starts one role's unit and returns its invocation: the
320// original template through Control, a generation through GenControl. exe
321// names a staged build (nil: the live link, plan 2's StartGen), and tier
322// is that build's own (zero: b.tier(rt)).
323//
324// At generation 0 a named exe is the build a cold wake recorded (settings
325// spec §4.2), started through StagedExeControl. A channel without it
326// refuses the start: dropping exe would bind the live link, a build
327// nobody recorded.
328func (b *ExecBackend) startRole(ctx context.Context, rt registry.Route, role instancectl.Role, gen uint64, exe *instancectl.StagedExe, tier cgroup.Limit) (string, error) {
329 if b.Cfg.HostWrites.Enabled {
330 return "", ErrClaimIsolationUnavailable
331 }
332 if gen == 0 {
333 if exe == nil {
334 return b.Control.Start(ctx, rt.Host, role, b.tier(rt).MemoryMax)
335 }
336 xc, err := b.stagedExeControl()
337 if err != nil {
338 return "", fmt.Errorf("%s: not started on %s: %w", rt.Host, exe.Version, err)
339 }
340 if tier == (cgroup.Limit{}) {
341 tier = b.tier(rt)
342 }
343 return xc.StartExe(ctx, rt.Host, role, tier.MemoryMax, *exe)
344 }
345 gc, err := b.genControl()
346 if err != nil {
347 return "", err
348 }
349 if tier == (cgroup.Limit{}) {
350 tier = b.tier(rt)
351 }
352 if exe != nil {
353 // The staged artifact's own tier, not the live link's.
354 return gc.StartGenExe(ctx, rt.Host, gen, tier.MemoryMax, *exe)
355 }
356 return gc.StartGen(ctx, rt.Host, gen, tier.MemoryMax)
357}
358
359// ParentLimit sets rt's generation parent to l: carlos-exec@<host> for a
360// flat or foreign host, the host slice for a nested one (spec §7.3). The
361// swap raises it to cgroup.OverlapLimit before the successor spawns and
362// restores the tier once the retiring generation's child is empty. With
363// GenContainment off there is no parent, and it refuses, so a caller can
364// never believe it raised a limit that does not exist.
365func (b *ExecBackend) ParentLimit(ctx context.Context, rt registry.Route, l cgroup.Limit) error {
366 if !b.GenContainment {
367 return errNoGenContainment
368 }
369 switch p := b.pathsFor(rt); p.layout {
370 case LayoutNested:
371 gc, err := b.genControl()
372 if err != nil {
373 return err
374 }
375 return gc.Parent(ctx, rt.Host, l.MemoryMax)
376 case LayoutFenced, LayoutConflict:
377 return fmt.Errorf("%s: refusing to set a parent limit: %s", rt.Host, p.reason)
378 }
379 // The manager's SetParentLimit answers nil with no manager; here that
380 // would admit an overlap with no parent raised and no generation child
381 // to kill, so it refuses.
382 if b.Cgroups == nil {
383 return cgroup.ErrNoDelegation
384 }
385 return b.Cgroups.SetParentLimit(rt.Host, l)
386}
387
388// KillGen kills generation n outright and returns once its child is empty
389// (spec §7.3, §8.2 step 1). It acts on that generation's child only.
390func (b *ExecBackend) KillGen(ctx context.Context, rt registry.Route, n uint64) error {
391 if !b.GenContainment || n == 0 {
392 return errNoGenContainment
393 }
394 switch p := b.pathsFor(rt); p.layout {
395 case LayoutNested:
396 gc, err := b.genControl()
397 if err != nil {
398 return err
399 }
400 return gc.StopGen(ctx, rt.Host, n, true)
401 case LayoutFenced, LayoutConflict:
402 return fmt.Errorf("%s: refusing to kill generation %d: %s", rt.Host, n, p.reason)
403 }
404 if b.Cgroups == nil {
405 return cgroup.ErrNoDelegation
406 }
407 child := cgroup.GenChild(n)
408 if err := b.killChild(ctx, rt.Host, child); err != nil {
409 return err
410 }
411 b.Cgroups.RemoveChild(rt.Host, child)
412 return nil
413}
414
415// killChild kills whatever is in one child of host's generation parent and
416// returns once that child is empty. It touches no sibling. An absent child
417// is empty; no delegated cgroup at all is cgroup.ErrNoDelegation. The child
418// is kept: the caller removes it (RemoveChild) once it is done with it.
419func (b *ExecBackend) killChild(ctx context.Context, host, child string) error {
420 c, err := b.Cgroups.OpenCleanupChild(host, child)
421 if errors.Is(err, cgroup.ErrNoCgroup) {
422 return nil // already removed, so already empty
423 }
424 if err != nil {
425 return err
426 }
427 if err := c.KillAndEmpty(ctx); err != nil {
428 _ = c.Close()
429 return err
430 }
431 return c.Close()
432}
433