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 "log"
8 "os"
9 "os/exec"
10 "sync"
11 "syscall"
12 "time"
13
14 "github.com/carlosframework/platform/internal/cgroup"
15 "github.com/carlosframework/platform/internal/hostbox"
16 "github.com/carlosframework/platform/internal/hostclaim"
17 "github.com/carlosframework/platform/internal/hostwrite"
18 "github.com/carlosframework/platform/internal/moves"
19 "github.com/carlosframework/platform/internal/provision"
20 "github.com/carlosframework/platform/internal/registry"
21 "github.com/carlosframework/platform/internal/release"
22)
23
24var ErrAttemptOver = moves.ErrAttemptOver
25
26type StartPolicy struct {
27 // RecoveryFence is the owning move cut generation, supplied by its adapter.
28 RecoveryFence uint64
29 MaxStarts int
30 Window time.Duration
31 Deadline time.Time
32 Probe bool
33 Prepared *moves.InstallManifest
34 Inputs *provision.MoveInputs
35 // Pin is a hold swap's chosen build (zero-downtime spec §7.8). Prepare
36 // must build a start that execs exactly Pin.StagedPath after checking
37 // Pin.Digest. startAdmitted refuses a ClaimStart whose Version is not
38 // Pin.Build.
39 Pin *Gen
40 // Gen is the generation the activator chose for this start: its number
41 // and the sockets the provider must bind (zero-downtime plan 3, Task
42 // 10). Zero is today's start on the canonical socket. startAdmitted
43 // refuses a ClaimStart that bound anything else (genMatches).
44 Gen Gen
45 // Fallback is a first wake's good build (zero-downtime spec §8.2), set
46 // only beside Pin, by plan 5's planner. It is honoured once the isolated
47 // launch opens; on this base the launch refuses before anything runs
48 // (stepStale), so there is nothing to fall back from. Any fallback arm
49 // added later must record its build first (recordBuild, settings spec
50 // §4.2; ruling R-EXEC-CLAIMFALLBACK).
51 Fallback *Gen
52 // HealthBy, when set, caps the pinned start's Ready window: a first
53 // wake's health check ends at the earlier of Window and HealthBy (spec
54 // §8.2: the waiters' deadline minus 25 s).
55 HealthBy time.Time
56 // Gate, when set, is the attempt gate (taking a.mu itself): a claims-on
57 // hold's pinCheck, or a planned wake's ownership check (plan 5).
58 // startAdmitted runs it in the launch's before hook, immediately before
59 // every exec, after the admission and Restore (spec §6.3, §7.4).
60 Gate func() error
61 // PinCheck, when set, is the planner's selection gate (plan 5's
62 // WakePlan.Check) for the pin about to exec. startAdmitted runs it
63 // beside Gate, at every exec.
64 PinCheck func(pin Gen) error
65 // Held is a held instance's target (settings spec §4.3 rule 3), "" when
66 // the wake is not held. startAdmitted's before hook refuses a start
67 // whose hashed build is neither the target nor a planned wake's
68 // recovery boot (heldIdentityOK), before the record and the exec. The
69 // activator sets it; providers ignore it.
70 Held string
71}
72type startPolicy = StartPolicy
73
74// ClaimRuntime is assembled by the host owner before Service.Start.
75// Admit reserves the first serving attempt and binds its resulting cold session.
76// Prepared probes instead consume the moves owner's single-use admission.
77type ClaimRuntime struct {
78 // ResumedAdmission verifies a journalled recovery that persisted prelaunch
79 // under the existing session during this admission call.
80 ResumedAdmission func(context.Context, hostclaim.Observed, hostclaim.Observed) error
81 RecoveryAttemptsReady func() bool
82 FailedAdmission func(context.Context, hostclaim.Observed, error) error
83 Box string
84 // Bucket and LeaseRegion are the claim actor's bucket and the region its
85 // leases sign for: a claimed wake leases Holder.Location there, and the
86 // instance's build-state.json sits beside that lease (settings spec
87 // §4.2, ruling R-CLAIMPREFIX).
88 Bucket, LeaseRegion string
89 Read func(context.Context, string) (hostclaim.Observed, error)
90 StopForeign func(context.Context, string) error
91 Prerequisites func(context.Context, registry.Route, hostclaim.Observed, startPolicy) error
92 Admit func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (hostclaim.Observed, error)
93 Prepare func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error)
94 // Pins reports that Prepare honours StartPolicy.Pin. A hold swap is
95 // refused on a claims-on box whose runtime does not, before anything
96 // stops.
97 Pins bool
98 // Generations reports that Prepare honours StartPolicy.Gen (binds its
99 // sockets and reports them in ClaimStart.Gen). Without it a claims-on
100 // box keeps one generation per entry, on the canonical socket, and
101 // every swap is refused before anything is raised, stopped or spawned
102 // (errClaimsNoGenerations).
103 Generations bool
104}
105
106// ClaimStart is one admitted session. Its closures retain the admission tuple.
107// Only Restore may purge data. Process retries reuse it and the live replication.
108type ClaimStart struct {
109 Live context.Context
110 PrepareLaunch func(context.Context) (*RoleLaunch, error)
111 CurrentAttempt func() hostclaim.WakeRetryAttempt
112 RoleLauncher *RoleLaunch
113 // OpenProbe journals the first app-start time before execution and returns
114 // that same time on retries, including after controller restart. Its
115 // provider binds the moves attempt and admission, never resets Opened.
116 OpenProbe func(context.Context, time.Time) (time.Time, error)
117 Work hostwrite.Work
118 Identity hostwrite.Identity
119 Manager *cgroup.Manager
120 Host string
121 Limit cgroup.Limit
122 Restore func(context.Context) error
123 Materialize func(context.Context) error
124 RetryClaimWrite func(hostclaim.Observed, hostclaim.Observed) error
125 Build func() *exec.Cmd
126 Ready func(context.Context, *exec.Cmd) error
127 // Reserve(0) validates the already reserved first attempt. Later calls CAS.
128 Reserve func(context.Context, int) error
129 Failed func(context.Context, int) error
130 Healthy func(context.Context, int, *exec.Cmd) error
131 Replicate func(context.Context, string) (Rep, error)
132 Publish func(context.Context, hostclaim.Observed) (hostclaim.Observed, error)
133 // Retain owns uncertain process or replica cleanup until it can be proved.
134 Retain func(Proc, Rep, error)
135 Version string
136 // Staged is the file the launch hashed, from one resolution of the
137 // host's live link (SnapshotClaimLaunch): the version it is staged
138 // under, its path and its digest (ExecutableSHA256). Version is a
139 // separate read of the link, so a repoint between the two can make them
140 // disagree; startAdmitted records Staged.Version, and refuses a start
141 // whose Staged.Version is not Version (settings spec §4.2, ruling
142 // R-EXEC-CLAIMVERSION).
143 Staged release.StagedIdentity
144 // Gen is what the provider bound. startAdmitted refuses a start whose
145 // number, socket or control socket is not StartPolicy.Gen's.
146 Gen Gen
147}
148
149// genMatches reports whether a provider bound exactly the generation the
150// activator chose: its number, its socket and its control socket. A handle
151// that names a socket nobody serves would route requests nowhere.
152func genMatches(want, got Gen) bool {
153 return want.N == got.N && want.Socket == got.Socket && want.Control == got.Control
154}
155
156func (s ClaimStart) valid() bool {
157 return s.Work != nil && s.Identity.Config != "" && s.Identity.Release != "" && s.Host != "" &&
158 s.Restore != nil && s.Materialize != nil && s.Build != nil && s.Ready != nil && s.Reserve != nil &&
159 (s.RoleLauncher != nil || s.PrepareLaunch != nil) && s.CurrentAttempt != nil && s.Failed != nil && s.Healthy != nil && s.Replicate != nil && s.Publish != nil && s.Retain != nil
160}
161
162func (a *Service) doClaimWake(ctx context.Context, e *actEntry) (*wokenInstance, error) {
163 r := a.cfg.Claims
164 if r == nil || r.Box == "" || r.Read == nil || r.StopForeign == nil {
165 return nil, hostwrite.ErrUnavailable
166 }
167 // A failed wake whose generation is not proven stopped still holds the
168 // host, and its process may still run against the database
169 // (zero-downtime spec §7.4). As doWake does, no new wake starts until
170 // the sweep proves it, and a stray legacy sidecar refuses it the same
171 // way.
172 a.mu.Lock()
173 _, unproven := a.unprovenWakes[e.host]
174 unproven = unproven || len(a.straySidecars[e.host]) > 0
175 a.mu.Unlock()
176 if unproven {
177 return nil, fmt.Errorf("%s: refusing to wake: %w", e.host, errWakeUnproven)
178 }
179 observed, err := r.Read(ctx, e.host)
180 if err != nil {
181 return nil, err
182 }
183 // K6 precedes registry reads, lease acquisition, materialization and policy checks.
184 executor := ""
185 if observed.Claim != nil {
186 executor = observed.Claim.Op.Exec
187 if observed.Claim.Op.Kind == hostclaim.KindServing && observed.Claim.Holder != nil {
188 executor = observed.Claim.Holder.WriterBox
189 }
190 }
191 if observed.Claim == nil || executor != r.Box {
192 stopErr := r.StopForeign(ctx, e.host)
193 return nil, probeRefused(e, errors.Join(hostbox.ErrChanged, stopErr))
194 }
195 if e.route.WakeIncarnation != "" && (observed.Claim == nil || observed.Claim.Holder == nil || observed.Claim.Holder.Incarnation != e.route.WakeIncarnation) {
196 return nil, probeRefused(e, hostbox.ErrChanged)
197 }
198 // A probe (ProbeWake, moves Plan 3 Task 5a) is admitted only at its
199 // op's probing phase (K6's probe row) and runs under its own policy.
200 probe := e.probe
201 if err := hostbox.WakePhase(observed.Claim, r.Box, probe != nil || e.recoveryPolicy != nil); err != nil {
202 return nil, probeRefused(e, err)
203 }
204 if probe != nil && observed.Claim.Op.ID != probe.op {
205 return nil, probeRefused(e, errProbeOp)
206 }
207 if e.recoveryPolicy != nil && (!e.recoveryPolicy.Probe || !recoveryProbeKind(observed.Claim.Op.Kind)) {
208 return nil, hostbox.ErrChanged
209 }
210 if r.Prerequisites == nil || r.Admit == nil || r.Prepare == nil || r.FailedAdmission == nil {
211 return nil, hostwrite.ErrUnavailable
212 }
213 rt := e.route
214 if a.Routes != nil {
215 fresh, err := a.Routes.Get(e.host)
216 if err != nil {
217 return nil, err
218 }
219 if fresh == nil {
220 return nil, hostbox.ErrChanged
221 }
222 rt = *fresh
223 }
224 // The held wake check (settings spec §4.3 rule 3), as doWake's step
225 // 0.65, before planning and before the admission. Never for a hold's
226 // pinned wake (e.pinCheck: the swap chose its build; planned claimed
227 // wakes set e.pin too, below, so e.pin alone does not say "hold"), and
228 // never for a moves probe, which runs the landing's prepared build under
229 // its own policy. The launcher execs the claim inputs' file, so a
230 // retained copy cannot be injected here (direct false): a target not
231 // installed is asked of root and waited for, and the row re-read. The
232 // launch's before hook refuses a hashed build that is not the target
233 // (StartPolicy.Held).
234 var held string
235 if e.pinCheck == nil && probe == nil && e.recoveryPolicy == nil {
236 held, _, err = a.wakeCheck(ctx, rt.Host, false)
237 if err != nil {
238 return nil, fmt.Errorf("%s: %w", rt.Host, err)
239 }
240 if held != "" && a.Routes != nil {
241 fresh, err := a.Routes.Get(e.host)
242 if err != nil {
243 return nil, err
244 }
245 if fresh == nil {
246 return nil, hostbox.ErrChanged
247 }
248 rt = *fresh
249 }
250 }
251 // Claimed wakes run plan 3's pinned start (StartPolicy.Pin) and get the
252 // same first-wake budget and fallback as an unclaimed wake (zero-downtime
253 // spec §8.2): firstWake extends the waiters' deadline in begin, and the
254 // policy below carries the health cap and the good build. The planner
255 // is called off a.mu. On this base every claims-on launch refuses
256 // before its exec hook (RoleLaunch.Start), so none of it runs a build.
257 // A moves probe (ProbeWake) is never planned: it runs the landing's
258 // prepared build under its own policy, and a plan's outcome must not be
259 // recorded against a build it did not boot.
260 if a.Planner != nil && e.pin == nil && probe == nil {
261 // As doWake: an earlier planned wake's outcome is delivered before
262 // this one plans (final review F3), and a wait that ends first
263 // refuses the wake (final review wave 2, N1).
264 if err := a.awaitOutcomes(ctx, rt.Host); err != nil {
265 return nil, fmt.Errorf("%s: refusing to wake: %w", rt.Host, err)
266 }
267 plan := a.Planner(ctx, rt)
268 if plan.Refuse != "" {
269 return nil, fmt.Errorf("%s: refusing to wake: %s", rt.Host, plan.Refuse)
270 }
271 if plan.planned() {
272 pin := genOf(plan.Boot)
273 a.mu.Lock()
274 e.pin, e.plan, e.firstWake = &pin, plan, plan.FirstWake
275 a.mu.Unlock()
276 }
277 }
278 // A pinned or planned build is published before the admission, as
279 // doWake does before its lease (spec §4.2, §8.5): claimed wakes never
280 // reach doWake's publication. A failed write refuses the wake with
281 // nothing admitted and no build blamed.
282 a.mu.Lock()
283 staged := e.pin != nil || e.plan.planned()
284 a.mu.Unlock()
285 if staged {
286 if err := a.publishBeforeExec(); err != nil {
287 return nil, fmt.Errorf("%s: refusing to wake: %w", rt.Host, err)
288 }
289 }
290 policy := startPolicy{MaxStarts: 5, Window: 30 * time.Second, Pin: e.pin, Held: held}
291 if e.recoveryPolicy != nil {
292 policy = *e.recoveryPolicy
293 } else if probe != nil {
294 // K1: the row is still held by the probe's hold op.
295 if !probeHeld(rt, probe.holdOp) {
296 return nil, probeRefused(e, errProbeRow)
297 }
298 policy = probe.policy
299 } else if e.pin != nil && e.pinCheck != nil {
300 // A claims-on hold's pinned wake: the attempt gate before anything
301 // is admitted, and again in the launch's before hook at every exec,
302 // after the admission and Restore (spec §6.3, §7.4), as
303 // startPinnedChecked does for a claims-off one.
304 gate := func() error {
305 a.mu.Lock()
306 defer a.mu.Unlock()
307 return e.pinCheck()
308 }
309 if err := gate(); err != nil {
310 return nil, err
311 }
312 policy.Gate = gate
313 }
314 if a.gens && r.Generations {
315 policy.Gen = a.claimGenAt(rt, nextGen())
316 }
317 if p := e.plan; p.planned() && p.FirstWake && p.Mode == ModeOverlap && p.Fallback != nil {
318 // The same budget as spawnPlanned: B's health until the waiters'
319 // deadline minus 25 s, then the good build in its place. Fallback is
320 // declared on StartPolicy but honoured only once the isolated launch
321 // opens (startAdmitted does not run it on this base).
322 fb := genOf(*p.Fallback)
323 policy.Fallback = &fb
324 policy.HealthBy = e.wakeStart.Add(firstWakeBudget - firstWakeKillReserve)
325 }
326 if p := e.plan; p.planned() {
327 // At every claimed planned exec, B and its fallback alike, inside
328 // the work after the admission and Restore (startAdmitted's
329 // beforeStart): the ownership check (spec §7.4), and the planner's
330 // selection gate for the pin about to exec (§8.1–8.4; P5 review
331 // finding 3). A refusal there records nothing and starts nothing;
332 // the wake fails and the next one is planned afresh. Both run off
333 // a.mu (the gate takes it itself).
334 if policy.Gate == nil {
335 policy.Gate = func() error {
336 a.mu.Lock()
337 defer a.mu.Unlock()
338 return a.ownedLocked(e, true)
339 }
340 }
341 if p.Check != nil {
342 policy.PinCheck = func(pin Gen) error {
343 if err := p.Check(pin.Build); err != nil {
344 return fmt.Errorf("%w: %v", errPlanStale, err)
345 }
346 return nil
347 }
348 }
349 }
350 if err := r.Prerequisites(ctx, rt, observed, policy); err != nil {
351 return nil, err
352 }
353 admitted, err := r.Admit(ctx, rt, observed, policy)
354 if err != nil {
355 return nil, err
356 }
357 if admitted.Claim == nil || admitted.Claim.Holder == nil || observed.Claim.Holder == nil || admitted.Claim.Holder.Incarnation != observed.Claim.Holder.Incarnation {
358 return nil, hostbox.ErrChanged
359 }
360 if admitted.Claim.Holder.Session <= observed.Claim.Holder.Session || admitted.Claim.Holder.Generation == observed.Claim.Holder.Generation {
361 if !recoveryProbeKind(observed.Claim.Op.Kind) || !hostclaim.SameWriterIdentity(observed, admitted) || r.ResumedAdmission == nil {
362 return nil, hostbox.ErrChanged
363 }
364 if err := r.ResumedAdmission(ctx, observed, admitted); err != nil {
365 return nil, err
366 }
367 }
368 e.route = rt
369 if e.recoveryPolicy != nil {
370 e.probeAdmitted = admitted
371 }
372 if err := a.startAdmitted(ctx, e, admitted, policy); err != nil {
373 recoveryCtx, cancelRecovery := context.WithTimeout(context.Background(), 10*time.Second)
374 defer cancelRecovery()
375 err = errors.Join(err, r.FailedAdmission(recoveryCtx, admitted, err))
376 if probe != nil {
377 err = errors.Join(ErrProbeFailed, err)
378 }
379 // claimedPlanResult carries a planned wake's outcome to afterWake: a
380 // non-nil result with an error carries no stopOutcome, so wake()
381 // records the uncertain stop it records today. A probe is never
382 // planned, so its result stays nil as on main.
383 return a.claimedPlanResult(e, nil), err
384 }
385 if probe != nil {
386 probe.admitted = &admitted
387 }
388 return a.claimedPlanResult(e, e.claimResult), nil
389}
390
391// claimStartBackoff is startAdmitted's wait before its second start; the
392// wait doubles before each start after that (1, 2, 4, 8 s). A var so tests
393// can shrink it.
394var claimStartBackoff = time.Second
395
396func (a *Service) startAdmitted(ctx context.Context, e *actEntry, admitted hostclaim.Observed, policy startPolicy) (result error) {
397 r := a.cfg.Claims
398 if !a.cfg.HostWrites.Enabled || r == nil || r.Read == nil || r.Prepare == nil || e == nil || admitted.Host != e.host || policy.MaxStarts < 1 || policy.MaxStarts > 5 || policy.Window <= 0 || policy.Window > 30*time.Second {
399 return hostwrite.ErrUnavailable
400 }
401 budget := &probeWindow{policy: policy, now: time.Now}
402 defer func() { result = budget.result(result) }()
403 if !policy.Deadline.IsZero() {
404 bounded, cancel := context.WithDeadline(ctx, policy.Deadline)
405 defer cancel()
406 ctx = bounded
407 }
408 if err := ctx.Err(); err != nil {
409 return err
410 }
411 if err := hostbox.WakePhase(admitted.Claim, r.Box, policy.Probe); err != nil {
412 return err
413 }
414 fresh, err := r.Read(ctx, e.host)
415 if err != nil {
416 return err
417 }
418 if fresh.ETag != admitted.ETag && !(recoveryProbeKind(admitted.Claim.Op.Kind) && hostclaim.SameWriterIdentity(fresh, admitted)) {
419 return hostbox.ErrChanged
420 }
421 s, err := r.Prepare(ctx, e.route, admitted, policy)
422 if err != nil {
423 return err
424 }
425 if !s.valid() || s.Host != e.host || (policy.Probe && s.OpenProbe == nil) {
426 return hostwrite.ErrUnavailable
427 }
428 if policy.Pin != nil && s.Version != policy.Pin.Build {
429 // A hold swap's wake must run the build its attempt chose
430 // (zero-downtime spec §7.8). A start prepared for anything else is
431 // refused before Restore touches the database.
432 return hostwrite.ErrUnavailable
433 }
434 if err := a.claimedBuild(e.host, s); err != nil {
435 // The bytes the snapshot hashed are not the build the start names:
436 // nothing could record the build that would run. Refused before
437 // Restore touches the database, and no failed attempt of the build.
438 return err
439 }
440 if policy.Gen.N != 0 && !genMatches(policy.Gen, s.Gen) {
441 // The provider bound something other than the generation the
442 // activator chose: its handle would name a socket nobody serves.
443 // Refused before Restore touches the database.
444 return hostwrite.ErrUnavailable
445 }
446 if s.Live != nil {
447 if err := s.Live.Err(); err != nil {
448 return err
449 }
450 joined, cancel := context.WithCancel(ctx)
451 defer cancel()
452 stop := context.AfterFunc(s.Live, cancel)
453 defer stop()
454 ctx = joined
455 }
456 prefix, err := hostbox.GenerationPrefix(*admitted.Claim.Holder)
457 if err != nil {
458 return err
459 }
460 if err := s.Restore(ctx); err != nil {
461 return err
462 }
463 var rep Rep
464 var retained bool
465 defer func() {
466 if result == nil || rep == nil || retained {
467 return
468 }
469 cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second)
470 defer cancel()
471 if err := rep.Close(cleanup); err != nil {
472 s.Retain(nil, rep, err)
473 result = errors.Join(result, err)
474 }
475 }()
476 var last error
477 for attempt := 0; attempt < policy.MaxStarts; attempt++ {
478 if err := budget.result(ctx.Err()); err != nil {
479 return err
480 }
481 if err := s.Reserve(ctx, attempt); err != nil {
482 return err
483 }
484 if err := s.Materialize(ctx); err != nil {
485 return errors.Join(err, s.Failed(ctx, attempt))
486 }
487 if s.RoleLauncher == nil {
488 s.RoleLauncher, err = s.PrepareLaunch(ctx)
489 if err != nil {
490 return errors.Join(err, s.Failed(ctx, attempt))
491 }
492 if s.RoleLauncher == nil {
493 return errors.Join(hostwrite.ErrUnavailable, s.Failed(ctx, attempt))
494 }
495 }
496 // This hook runs after strict preparation and immediately before Start.
497 // Restore and materialization consume only the separate cut deadline.
498 reached := false // the exec hook ran: an error before it is the admission's (claimAdmission)
499 unrecorded := false
500 beforeStart := func() error {
501 reached = true
502 if policy.Gate != nil {
503 // The attempt gate at the exec: a claims-on hold's pinCheck,
504 // or a planned wake's ownership check (plan 5).
505 if err := policy.Gate(); err != nil {
506 return err
507 }
508 }
509 if policy.PinCheck != nil && policy.Pin != nil {
510 // The planner's selection gate for the build about to exec
511 // (plan 5): a failure recorded since planning holds it.
512 if err := policy.PinCheck(*policy.Pin); err != nil {
513 return err
514 }
515 }
516 // The build state names the build before it runs against the
517 // database (settings spec §4.2), beside the claimed lease's
518 // lock.json: the claim actor's bucket at Holder.Location, never
519 // the session's g-<gen>/ prefix (ruling R-CLAIMPREFIX). The file
520 // is already open and verified against its digest here
521 // (RoleLaunch.Start). Every exec records, a retry's included;
522 // the same build makes the write idempotent. Moves probes come
523 // through here too (ruling R-EXEC-PROBES).
524 //
525 // A held wake first checks the build the snapshot hashed
526 // (s.Staged, the version of the bytes opened; Task 5d) against
527 // its target (settings spec §4.3 rule 3, Astra I2 #4): root may
528 // have repointed between the wait and the snapshot. A refusal
529 // records nothing, and like a refused record it is terminal for
530 // this loop: no failed attempt, no retry (unrecorded).
531 if policy.Held != "" {
532 a.mu.Lock()
533 plan, route := e.plan, e.route
534 a.mu.Unlock()
535 if !heldIdentityOK(policy.Held, s.Staged, plan, route) {
536 unrecorded = true
537 log.Printf("activator: %s not started: it is held on %s, and the launch hashed %q (the live link moved after the check); the next wake checks again", e.host, policy.Held, s.Staged.Version)
538 return fmt.Errorf("%w: %s would run %q, wants %s", errStaleLink, e.host, s.Staged.Version, policy.Held)
539 }
540 }
541 at := WithBuildStateLocation(ctx, BuildStateLocation{Bucket: r.Bucket, Prefix: admitted.Claim.Holder.Location})
542 // A planned recovery boot records with the planner's selection
543 // (ruling R-PLANNED-RECOVERY), as doWake's step 2b does.
544 a.mu.Lock()
545 plan, route := e.plan, e.route
546 a.mu.Unlock()
547 at = withRecoverySelection(at, plan, route, s.Staged.Version)
548 if err := a.recordBuild(at, e.host, s.Staged.Version); err != nil {
549 unrecorded = true
550 return err
551 }
552 if policy.Gate != nil {
553 // Asked again after the record, as startSidecar does: a
554 // fence raised or a lease lost while the build state was
555 // written (the PUT) execs nothing (Task 12a).
556 if err := policy.Gate(); err != nil {
557 return err
558 }
559 }
560 if err := budget.start(ctx, s.OpenProbe); err != nil {
561 return err
562 }
563 if e.pin != nil {
564 // A hold's pinned wake has reached its build's exec: from
565 // here a failure is the build's (Task 6, markPinExec).
566 a.markPinExec(e)
567 }
568 return nil
569 }
570 cmd, prepared, settled, producer, startErr := s.RoleLauncher.Start(ctx, "main", s.CurrentAttempt(), s.Build, beforeStart)
571 // A refusal that came back before the exec hook (the closed isolated
572 // launch, a claim read, an absent claim, a changed stamp) ran nothing
573 // of the build: it is the admission's, never the build's failure.
574 startErr = claimAdmission(startErr, reached)
575
576 p := newClaimProcessFor(cmd, prepared, s.RoleLauncher.Root != nil)
577 p.settle = settled
578 p.bindProducer(producer)
579 if unrecorded {
580 // A refused record, or a held wake's refused identity, ran
581 // nothing of the build (ruling R-EXEC-CLAIMRETRY): no failed
582 // attempt is written (s.Failed), and the loop does not retry it.
583 // The wake fails as fenced (memberSafeWakeReason).
584 cleanup, cancelCleanup := context.WithTimeout(context.Background(), 10*time.Second)
585 cleanErr := p.Stop(cleanup)
586 cancelCleanup()
587 if cleanErr != nil {
588 s.Retain(p, rep, errors.Join(startErr, cleanErr))
589 retained = true
590 return errors.Join(startErr, cleanErr)
591 }
592 return startErr
593 }
594 if startErr == nil {
595 deadline := time.Now().Add(policy.Window)
596 if policy.Probe {
597 deadline = budget.opened.Add(policy.Window)
598 }
599 if !policy.HealthBy.IsZero() && policy.HealthBy.Before(deadline) {
600 // A first wake's pinned build: health until the waiters'
601 // deadline minus 25 s (zero-downtime spec §8.2).
602 deadline = policy.HealthBy
603 }
604 readyCtx, cancelReady := context.WithDeadline(ctx, deadline)
605 startErr = s.Ready(readyCtx, cmd)
606 cancelReady()
607 startErr = budget.result(startErr)
608 }
609 ready := startErr == nil
610 budget.ready = ready
611 if ready {
612 rep, startErr = s.Replicate(ctx, prefix)
613 if startErr == nil && rep == nil {
614 startErr = hostwrite.ErrUnavailable
615 }
616 }
617 if startErr == nil {
618 current, err := r.Read(ctx, e.host)
619 if err != nil {
620 startErr = err
621 } else if !sameAdmission(current, admitted) {
622 startErr = hostbox.ErrChanged
623 } else {
624 _, startErr = s.Publish(ctx, current)
625 }
626 }
627 if startErr == nil {
628 startErr = s.Healthy(ctx, attempt, cmd)
629 }
630 if startErr == nil {
631 a.mu.Lock()
632 if e.pinFailed != nil && policy.Pin != nil && e.pinFailed.Build == policy.Pin.Build {
633 e.pinFailed, e.pinFailReason = nil, "" // a retry of the same pin served
634 }
635 a.mu.Unlock()
636 // The generation's handle: the sockets the provider bound
637 // (genMatches, above). Zero with generations off, and wake()
638 // falls back to the canonical handle as before.
639 g := policy.Gen
640 if g.N != 0 {
641 g.Build = s.Version
642 if policy.Pin != nil {
643 g.StagedPath, g.Digest, g.Demand = policy.Pin.StagedPath, policy.Pin.Digest, policy.Pin.Demand
644 }
645 }
646 if (policy.Pin == nil || g.N == 0) && s.Staged.StagedPath != "" && s.Staged.Digest != "" && s.Staged.Version == s.Version {
647 // The file this start ran (the hashed resolution of the
648 // live link), so a legacy sidecar restart runs that file
649 // by path, never whatever the link names by then (Task
650 // 5d; ruling R-EXEC-SIDECAR). With no number, wake()
651 // keeps it on the canonical handle it gives this start.
652 // A pinned start with no number takes the same file: its
653 // version was checked against Pin.Build above, and
654 // s.Staged is what ran; the demand is the pin's (Task 12a).
655 g.Build, g.StagedPath, g.Digest = s.Version, s.Staged.StagedPath, s.Staged.Digest
656 if policy.Pin != nil {
657 g.Demand = policy.Pin.Demand
658 }
659 }
660 e.claimResult = &wokenInstance{proc: p, rep: rep, spawnVersion: s.Version, gen: g, stopOutcome: &stopObservation{}}
661 return nil
662 }
663 cleanup, cancelCleanup := context.WithTimeout(context.Background(), 10*time.Second)
664 cleanErr := p.Stop(cleanup)
665 cancelCleanup()
666 if cleanErr != nil {
667 s.Retain(p, rep, errors.Join(startErr, cleanErr))
668 retained = true
669 return errors.Join(startErr, cleanErr)
670 }
671 if err := s.Failed(ctx, attempt); err != nil {
672 return errors.Join(startErr, err)
673 }
674 last = startErr
675 // THE outcome classifier (Task 4): a start the wake's lifecycle ended
676 // (maintenance, Shutdown, a stop), that the attempt gate refused, or
677 // that the launch refused before its exec hook is no failure of the
678 // build and records nothing (spec §6.4, §7.4). Only a pinned build
679 // that ran (cmd) and failed before it was ready is recorded, for plan
680 // 5's events (a failed first wake, spec §8.2).
681 if policy.Pin != nil && !ready && cmd != nil && classifyStep(ctx, startErr) == stepFailed {
682 a.mu.Lock()
683 if e.pinFailed == nil {
684 failed := *policy.Pin
685 e.pinFailed, e.pinFailReason = &failed, startErr.Error()
686 }
687 a.mu.Unlock()
688 }
689 // A replication/publication failure has an uncertain remote outcome.
690 // The next process start cannot reuse that opening as another attempt.
691 if ready {
692 return last
693 }
694 if errors.Is(startErr, ErrAttemptOver) {
695 return startErr
696 }
697 if attempt+1 < policy.MaxStarts {
698 waitCtx := ctx
699 cancelWait := func() {}
700 if policy.Probe && !budget.opened.IsZero() {
701 waitCtx, cancelWait = context.WithDeadline(ctx, budget.opened.Add(policy.Window))
702 }
703 timer := time.NewTimer(claimStartBackoff << attempt)
704 select {
705 case <-waitCtx.Done():
706 timer.Stop()
707 cancelWait()
708 return budget.result(waitCtx.Err())
709 case <-timer.C:
710 cancelWait()
711 }
712 }
713 }
714 return last
715}
716
717type claimContainment interface {
718 KillAndEmpty(context.Context) error
719 Close() error
720}
721
722type claimProcess struct {
723 cmd *exec.Cmd
724 prepared claimContainment
725 done chan struct{}
726 mu sync.Mutex
727 drained, closed bool
728 // root marks a role root launched in an isolated unit: the agent holds
729 // no containment, settle is root's stop of the reservation, and done
730 // follows the unit's main process through a pidfd.
731 root bool
732 settle func(context.Context) error
733 producerFinish func(error)
734}
735
736func newClaimProcess(cmd *exec.Cmd, p *cgroup.Prepared) *claimProcess {
737 c := &claimProcess{cmd: cmd, done: make(chan struct{})}
738 if p != nil {
739 c.prepared = p
740 }
741 if cmd == nil || cmd.Process == nil {
742 close(c.done)
743 } else {
744 go func() { _ = cmd.Wait(); close(c.done) }()
745 }
746 return c
747}
748func (p *claimProcess) Exited() bool {
749 select {
750 case <-p.done:
751 return true
752 default:
753 return false
754 }
755}
756func (p *claimProcess) Stop(ctx context.Context) error {
757 p.mu.Lock()
758 defer p.mu.Unlock()
759 if p.closed {
760 return nil
761 }
762 if p.root {
763 return p.stopRoot(ctx)
764 }
765 if p.prepared == nil {
766 if p.cmd != nil && p.cmd.Process != nil {
767 return hostwrite.ErrUnavailable
768 }
769 p.closed = true
770 if p.producerFinish != nil {
771 p.producerFinish(nil)
772 }
773 return nil
774 }
775 if !p.drained {
776 if err := p.prepared.KillAndEmpty(ctx); err != nil {
777 return err
778 }
779 p.drained = true
780 }
781 select {
782 case <-ctx.Done():
783 return ctx.Err()
784 case <-p.done:
785 }
786 if p.settle != nil {
787 if err := p.settle(ctx); err != nil {
788 return err
789 }
790 }
791 if err := p.prepared.Close(); err != nil {
792 return err
793 }
794 p.closed = true
795 if p.producerFinish != nil {
796 p.producerFinish(nil)
797 }
798 return nil
799}
800
801// Retire is a serving claims-on generation's retirement (zero-downtime spec
802// §7.5 step 6, index P6 #4): SIGTERM to its leader, the app's own drain for
803// up to grace, then Stop, whose KillAndEmpty on the generation's own child
804// and the emptiness proof are unchanged. A candidate that never became
805// ready is not retired: its owner Stops it, which kills at once.
806func (p *claimProcess) Retire(ctx context.Context, grace time.Duration) error {
807 if p.cmd != nil && p.cmd.Process != nil && !p.Exited() {
808 err := p.cmd.Process.Signal(syscall.SIGTERM)
809 switch {
810 case err == nil, errors.Is(err, os.ErrProcessDone):
811 t := time.NewTimer(grace)
812 select {
813 case <-p.done:
814 case <-t.C:
815 case <-ctx.Done():
816 }
817 t.Stop()
818 default:
819 // No drain is possible; Stop still kills and proves it.
820 log.Printf("activator: SIGTERM to a retiring claims-on generation (pid %d): %v; stopping it without a drain", p.cmd.Process.Pid, err)
821 }
822 }
823 return p.Stop(ctx)
824}
825
826// provenStopped is a claims-on generation's termination proof (zero-downtime
827// spec §7.4): its Stop has completed, which takes KillAndEmpty on its own
828// child, the exit, the settle and the close. Stop is idempotent once
829// closed, so this retries a Stop that failed before.
830func (p *claimProcess) provenStopped(ctx context.Context) bool {
831 if err := p.Stop(ctx); err != nil {
832 return false
833 }
834 p.mu.Lock()
835 defer p.mu.Unlock()
836 return p.closed
837}
838
839func sameAdmission(a, b hostclaim.Observed) bool {
840 if a.Absent || b.Absent || a.Host != b.Host || a.Claim == nil || b.Claim == nil || a.Claim.Holder == nil || b.Claim.Holder == nil {
841 return false
842 }
843 x, y := a.Claim, b.Claim
844 return x.Holder.Incarnation == y.Holder.Incarnation && x.Holder.Stamp == y.Holder.Stamp && x.Holder.Session == y.Holder.Session && x.Holder.Generation == y.Holder.Generation && x.Op.ID == y.Op.ID && x.Op.Kind == y.Op.Kind && x.Op.Phase == y.Op.Phase && x.Op.Exec == y.Op.Exec
845}
846
847func (p *claimProcess) bindProducer(producer RoleProducer) {
848 if producer.Context == nil || producer.Finish == nil {
849 return
850 }
851 p.producerFinish = producer.Finish
852 go func() {
853 select {
854 case <-producer.Context.Done():
855 case <-p.done:
856 }
857 cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
858 defer cancel()
859 for {
860 if err := p.Stop(cleanup); err == nil {
861 return
862 }
863 select {
864 case <-cleanup.Done():
865 return
866 case <-time.After(100 * time.Millisecond):
867 }
868 }
869 }()
870}
871