carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "errors"
6 "log"
7 "sync"
8 "sync/atomic"
9 "time"
10
11 "github.com/carlosframework/platform/internal/activator"
12 "github.com/carlosframework/platform/internal/appconfig"
13 "github.com/carlosframework/platform/internal/registry"
14 "github.com/carlosframework/platform/internal/release"
15 "github.com/carlosframework/platform/internal/rollout"
16 "github.com/carlosframework/platform/internal/status"
17)
18
19// rolloutBookHonoured is RolloutReport.FailureBook's code for a box that
20// declined CARLOS_SWAP=overlap and honours the failure records an earlier
21// armed run left (honourDeclinedFailures). A code, not member-facing text.
22const rolloutBookHonoured = "honoured"
23
24// refuseNoSwaps is the wake refusal for a managed instance whose build needs
25// the scheduler this box cannot run: refuseManaged's text for a reason it has
26// no words for, and a declined box's for a build a failure record holds.
27const refuseNoSwaps = "this box cannot run two builds side by side, which this instance's build needs"
28
29// failureHolds is the failure book a box that declined CARLOS_SWAP=overlap
30// still honours (final review F6, which replaced PF15's "today's paths"). An
31// earlier armed run recorded these failures, and losing the delegation the
32// scheduler needs is not an operator clearing them: a hold build whose
33// migration failed must not run again on the live link. So, with generations
34// off and no scheduler, the box keeps today's paths except that a managed
35// wake onto a build the book holds for that instance is refused (Planner),
36// and refused again at the real launch, against the build that launch runs
37// (gate, Task 9); and the converge leg, the adopt bounce, the restart leg and
38// config sync's restart leave such an instance running rather than stop it
39// into a refused wake. The book holds records and hold-ran markers.
40//
41// The book is read once, at arming, and nothing on a declined box records a
42// failure or a marker, or prunes (Astra R2 ruling 194, "startup-only
43// records"). But it is cleared while the agent runs, as an armed box's is
44// (Task 10): a verified signed `carlos instances retry` (retryHost), a
45// `carlos promote --retry` pointer and a signed rollback pointer
46// (applyPointers) each clear in this one book and save it. The planner, the
47// launch gate and the legs read it live, so a clear lets the instance start
48// at once, with no agent restart. It is also written at arming when it owes
49// a save (an armed run left intents in it, or an earlier agent wrote it),
50// and by the periodic pass while that save keeps failing (pass; the
51// crash-epoch hard limit, rule 6).
52type failureHolds struct {
53 book *rollout.FailureBook
54 // get reads a route by host, for the legs that are handed only a host.
55 get func(host string) (*registry.Route, error)
56 // list reads every route, for the box's status report.
57 list func() ([]registry.Route, error)
58 // optDir is where the live links are: a wake execs the build
59 // live/<host> names, which root repoints before it stamps the row.
60 optDir string
61 // said is the (leg, host, build) triples already logged, so a leg that
62 // runs every two seconds says once that it is leaving a host alone.
63 said sync.Map
64}
65
66// declinedHolds is set by honourDeclinedFailures, before any leg is built,
67// and only when the book holds at least one record or hold-ran marker. Nil:
68// the flag is off, armed, refusing, or declined with an empty book, and
69// every leg is as its own branch says.
70var declinedHolds atomic.Pointer[failureHolds]
71
72// heldHolds is declinedHolds' value.
73func heldHolds() *failureHolds { return declinedHolds.Load() }
74
75// honourDeclinedFailures is armRollout's branch for a box that declined the
76// flag (spec §§6.4, 8.4; final review F6). An absent book, or one with no
77// records and no hold-ran markers, leaves everything exactly as today: no
78// hook, no leg changed, LiveExecGate nil. An unreadable one fails closed as
79// refuseManaged does for an armed box, since any record in it could be a
80// hold-down. A readable one with records or markers is honoured, read-only,
81// through the Planner, the launch gate and the legs (failureHolds).
82func honourDeclinedFailures(svc *activator.Service, reg *registry.Registry, dd, optDir string) {
83 book, err := rollout.OpenFailureBook(dd)
84 if err != nil {
85 log.Printf("agent: %s=overlap declined, and the failure book is unreadable: %v", rollout.SwapEnv, err)
86 refuseManaged(svc, rolloutBookUnreadable)
87 return
88 }
89 if n := book.Reconciled(); n > 0 {
90 // An armed run's executions left unrecorded (Task 5): their records
91 // are honoured below whether or not the save below lands, since the
92 // book in memory holds them either way, and the next restart
93 // reconciles the intents again if it did not.
94 log.Printf("agent: %s=overlap declined: %d execution(s) an armed run left unrecorded are now failure records", rollout.SwapEnv, n)
95 }
96 // Records or hold-ran markers (Task 9): a marker alone still bars the good
97 // build from an instance whose database a hold build may have migrated.
98 recs, marks := len(book.List()), book.HoldRanCount()
99 if recs == 0 && marks == 0 {
100 return
101 }
102 // A book that owes a save is saved now, whatever reconciliation found
103 // (the crash-epoch hard limit, rule 6): reconciled records, a new
104 // identity for a book an earlier agent wrote (rule 4), epochs given to
105 // older entries. Until it lands, status publishes no generation and no
106 // retry grant clears anything; the periodic pass (pass) retries it. A
107 // book this rule wrote and the open did not change owes nothing, and is
108 // not written.
109 if err := book.SaveIfDirty(); err != nil {
110 log.Printf("agent: %s=overlap declined: save the failure book after opening it: %v (retried by the periodic pass)", rollout.SwapEnv, err)
111 }
112 h := &failureHolds{book: book, get: reg.Get, list: reg.List, optDir: optDir}
113 declinedHolds.Store(h)
114 svc.Planner = h.plan
115 // And at the real launch, with the build actually started (Task 9): the
116 // plan reads the live link before the lease and the restore, and root
117 // can repoint it at a held build in between.
118 svc.SetLiveExecGate(h.gate)
119 log.Printf("agent: %s=overlap declined, but %d failure record(s) and %d hold-ran marker(s) from an armed run are honoured: "+
120 "no managed instance wakes, starts, or is stopped, onto a build they hold for it", rollout.SwapEnv, recs, marks)
121}
122
123// refusal is the wake refusal for starting build on rt, "" when it may
124// start. Only a managed route can be held: the scheduler records nothing
125// else.
126//
127// The rule is the armed planner's (rollout.decideWake and decideHoldRan,
128// spec §8.5, §6.4), with the one difference that a declined box cannot boot
129// the good build instead, so it refuses where the armed box would recover.
130// A hold-down holds whatever the instance served, in that build's words.
131// Then, with a hold-ran marker of rt's app standing for the instance (Task
132// 9; Astra R3 N5/N7, ruling 205):
133// - the marked build may start, unless the instance has a good build and
134// a record holds the marked build for it. A record never holds a build
135// the instance served, so a marker naming the good build itself lets it
136// start (the marked-build rule), whether the stamp or the live link
137// names it: unlike the armed planner, a declined box never boots the
138// good build as a recovery, so the build being started is always the
139// installed one, stamped or linked (fix round 1, ruling 4a);
140// - otherwise the good build is refused: an installed good build is no
141// proof of a signed rollback, since root's automatic sibling repoint
142// installs it too, and only the pointer pass clears the marker on one
143// (Task 10);
144// - any other build a record holds for the instance is refused.
145//
146// All three refuse in HeldDownRefusal's words for the marked build, as the
147// armed planner does, so "roll back or retry" stays true here. Without a
148// marker, a plain record holds a build only when the instance has a good
149// build to fall back to (a build it has served is never held by a record
150// alone); an instance with none boots its installed build as today, because
151// refusing it would turn one sibling's failure into an outage for every
152// sleeping sibling. It reads only memory, so the activator may ask it under
153// its own lock (gate).
154func (h *failureHolds) refusal(rt registry.Route, build string) string {
155 return h.holdOf(rt, build).refuse
156}
157
158// hold is why the book holds an instance off one build: the wake refusal,
159// the build whose restriction refuses it (by), which is what a retry of
160// the instance must name (RetryHostAt), and whether the instance stays down
161// for it (a hold-down, or a hold-ran marker) rather than on what it runs.
162type hold struct {
163 refuse, by string
164 down bool
165}
166
167// holdOf is refusal with the build it holds by: the hold-down's build, the
168// marker's build when the marker refuses the marked build or the good
169// build, and otherwise the build a record holds, which a marker may refuse
170// in its own words while only that record's retry lifts it.
171//
172// Eligibility is rollout.Restrictable, not Managed (final review round 1,
173// finding 2): a row a move holds (landing, restoring) is the row a moves
174// probe or landing launches, and the book's restriction of the instance
175// applies there as on its ready row.
176func (h *failureHolds) holdOf(rt registry.Route, build string) hold {
177 if !rollout.Restrictable(rt) || build == "" {
178 return hold{}
179 }
180 acct, app, host := release.RouteAccount(rt), rt.App, rt.Host
181 hasGood := rt.GoodVersion != ""
182 if h.book.HeldDown(acct, app, build, host) {
183 return hold{rollout.HeldDownRefusal(build), build, true}
184 }
185 if mark, ok := h.book.HoldRanOn(host); ok && mark.Account == acct && mark.App == app {
186 isGood := hasGood && build == rt.GoodVersion
187 switch {
188 case build == mark.Build:
189 if hasGood && h.book.Blocks(acct, app, build, host, isGood) {
190 return hold{rollout.HeldDownRefusal(mark.Build), mark.Build, true}
191 }
192 return hold{}
193 case isGood:
194 return hold{rollout.HeldDownRefusal(mark.Build), mark.Build, true}
195 case hasGood && h.book.Blocks(acct, app, build, host, false):
196 return hold{rollout.HeldDownRefusal(mark.Build), build, true}
197 }
198 return hold{}
199 }
200 if hasGood && h.book.Blocks(acct, app, build, host, build == rt.GoodVersion) {
201 return hold{refuseNoSwaps, build, false}
202 }
203 return hold{}
204}
205
206// heldBuild is the build the book holds rt off and its hold (holdOf), ""
207// when nothing holds it. Both the installed build (root's stamp) and the
208// build live/<host> names are asked, because a wake execs the live link and
209// root repoints it before it stamps the row; an unreadable link is left to
210// the stamp.
211func (h *failureHolds) heldBuild(rt registry.Route) (string, hold) {
212 if !rollout.Restrictable(rt) {
213 return "", hold{}
214 }
215 builds := []string{rt.Version}
216 if live, err := release.LiveVersion(h.optDir, rt.Host); err == nil && live != "" && live != rt.Version {
217 builds = append(builds, live)
218 }
219 for _, b := range builds {
220 if hd := h.holdOf(rt, b); hd.refuse != "" {
221 return b, hd
222 }
223 }
224 return "", hold{}
225}
226
227// plan is the declined box's Planner: a managed wake onto a held build is
228// refused with fixed text that names the build at most, never an error; any
229// other wake gets the zero plan, which is today's wake from the live link.
230// rt is the row doWake has just re-read (Service.Routes).
231func (h *failureHolds) plan(_ context.Context, rt registry.Route) activator.WakePlan {
232 _, hd := h.heldBuild(rt)
233 return activator.WakePlan{Refuse: hd.refuse}
234}
235
236// report is the declined box's per-host rollout status (Task 10, plan
237// review #5): each managed instance the book holds, with the build that
238// holds it (HeldBy) and that restriction's generation as published
239// (HeldEpoch, rollout.FailureBook.PublishedEpoch: 0 while the book owes a
240// save), which the console's retry doors sign into their grant, attempt
241// down when it stays down for the hold and failed otherwise. Nil when
242// nothing is held or the routes cannot be read. A declined box runs no
243// scheduler, so it reports no serving build and no attempt in progress.
244func (h *failureHolds) report() map[string]status.HostRollout {
245 routes, err := h.list()
246 if err != nil {
247 return nil
248 }
249 var out map[string]status.HostRollout
250 for _, rt := range routes {
251 build, hd := h.heldBuild(rt)
252 if build == "" {
253 continue
254 }
255 acct := release.RouteAccount(rt)
256 attempt := status.AttemptFailed
257 if hd.down {
258 attempt = status.AttemptDown
259 }
260 if out == nil {
261 out = map[string]status.HostRollout{}
262 }
263 out[rt.Host] = status.HostRollout{Account: acct, App: rt.App, Installed: rt.Version, Good: rt.GoodVersion,
264 Attempt: attempt, AttemptBuild: hd.by, HeldBy: hd.by, HeldEpoch: h.book.PublishedEpoch(acct, rt.App, hd.by, rt.Host),
265 HeldBook: h.book.ID(), // the book the epoch counts in (final review round 1, finding 3)
266 RetryV: status.HostRetryV} // grants ride the host-retry log (readiness round 2, Task 3)
267 }
268 return out
269}
270
271// retryHost is restartMemory.retryHost on a declined box (Task 10, ruling
272// 194): a `carlos instances retry` whose root-signed grant the restart leg
273// has verified, applied to this box's book exactly as the armed box applies
274// it (RetryHostAt): while build's restriction of host is still at epoch, in
275// this box's book (book, final review round 1, finding 3), it
276// clears build's record for host and host's marker when the marker names
277// build, and saves. A grant for another build, or from before the
278// restriction last changed, clears nothing. There is no scheduler to run a
279// canary: the next wake, or the converge leg, starts the build as today.
280func (h *failureHolds) retryHost(host, build, book string, epoch uint64) bool {
281 rt, err := h.get(host)
282 // Restrictable, not Managed (finding 2): a grant that arrives while a
283 // move holds the row (draining, moving, landing) is applied, not read
284 // once by the restart leg and dropped.
285 if err != nil || rt == nil || !rollout.Restrictable(*rt) || build == "" {
286 return false
287 }
288 acct := release.RouteAccount(*rt)
289 rec, mark, now, err := h.book.RetryHostAt(acct, rt.App, build, host, book, epoch)
290 if err != nil && (rec || mark) {
291 log.Printf("agent: retry %s on %s: save the failure book: %v (cleared in memory)", build, host, err)
292 } else if err != nil {
293 // The book owed a save and it failed again: nothing is cleared until
294 // one lands (the crash-epoch hard limit, rule 2).
295 log.Printf("agent: retry %s on %s: the failure book cannot be saved (%v), so nothing was cleared", build, host, err)
296 } else if !rec && !mark && now != epoch {
297 log.Printf("agent: retry %s on %s: the grant is for epoch %d, the restriction's is %d, so nothing was cleared", build, host, epoch, now)
298 } else if !rec && !mark && book != h.book.ID() {
299 log.Printf("agent: retry %s on %s: the grant is for book %q, not this box's, so nothing was cleared", build, host, book)
300 }
301 if rec || mark {
302 nudgeStatus()
303 }
304 return rec || mark
305}
306
307// applyPointers is the declined box's pointer pass (Task 10, ruling 194;
308// Correction 3): the armed box's two pointer rules (rollout.ApplyPointers)
309// on this box's book, for every app with a record or a marker a signed
310// rollback could clear. read is release.Channel with the agent's store and
311// root key. The caller paces it (pointerLoop).
312func (h *failureHolds) applyPointers(ctx context.Context, read rollout.PointerRead, routes []registry.Route) {
313 if rollout.ApplyPointers(ctx, h.book, read, routes, log.Printf) {
314 nudgeStatus()
315 }
316}
317
318// pass is one turn of the declined box's periodic pass: the save the book
319// owes, if any (the crash-epoch hard limit, rule 6), so a box whose disk
320// recovers publishes its generations again with no pointer or grant
321// arriving, then the pointer pass over the registry's routes.
322func (h *failureHolds) pass(ctx context.Context, read rollout.PointerRead) {
323 if h.book.SaveOwed() {
324 if err := h.book.SaveIfDirty(); err != nil {
325 log.Printf("agent: declined pass: save the failure book: %v", err)
326 } else {
327 nudgeStatus()
328 }
329 }
330 if routes, err := h.list(); err != nil {
331 log.Printf("agent: declined pointer pass: list routes: %v", err)
332 } else {
333 h.applyPointers(ctx, read, routes)
334 }
335}
336
337// pointerLoop runs pass, at most every rollout.PointerReadEvery, for the
338// life of the agent. An app with nothing the pointer pass could clear costs
339// no read.
340func (h *failureHolds) pointerLoop(ctx context.Context, read rollout.PointerRead) {
341 for {
342 h.pass(ctx, read)
343 select {
344 case <-ctx.Done():
345 return
346 case <-time.After(rollout.PointerReadEvery):
347 }
348 }
349}
350
351// rolloutRetryHost is restartMemory.retryHost for this box: the armed
352// manager's, a declined box's that honours its book (failureHolds.retryHost,
353// Task 10), or nil, which drops every retry because nothing is held.
354func rolloutRetryHost() func(host, build, book string, epoch uint64) bool {
355 if m := armedRollout(); m != nil {
356 return m.RetryHost
357 }
358 if h := heldHolds(); h != nil {
359 return h.retryHost
360 }
361 return nil
362}
363
364// gate is the declined box's LiveExecGate (Task 9; Astra R1 #6, the R2/R3
365// declined residuals): the plan's refusal asked again at the real launch,
366// about the build that launch is about to run, which root may have
367// repointed the live link to since the plan. The backend asks it at a flat
368// or nested instance's live-link start and a worker's spawn from the live
369// link, the activator before a worker's converge or a recycle's move onto
370// the live link, and a claims-backed wake once its snapshot has resolved the
371// link (claimServingLaunchSource). An empty build (a link outside the
372// version store, or one that could not be read) is left to the stamp, as
373// heldBuild leaves it. It reads only memory and rt, and writes nothing: the
374// activator asks it under its own lock.
375func (h *failureHolds) gate(rt registry.Route, build string) error {
376 if build == "" {
377 build = rt.Version
378 }
379 if r := h.refusal(rt, build); r != "" {
380 return errors.New(r)
381 }
382 return nil
383}
384
385// heldRoute reports whether rt is held, logging it once per leg and build.
386func (h *failureHolds) heldRoute(leg string, rt registry.Route) bool {
387 build, _ := h.heldBuild(rt)
388 if build == "" {
389 return false
390 }
391 if _, said := h.said.LoadOrStore(leg+"\x00"+rt.Host+"\x00"+build, true); !said {
392 log.Printf("agent: %s: leaving %s running: a failure record or hold-ran marker from an armed run holds build %s for it, and %s=overlap is declined on this box",
393 leg, rt.Host, build, rollout.SwapEnv)
394 }
395 return true
396}
397
398// heldHost is heldRoute for a leg handed only a host. A row that cannot be
399// read counts as held: the leg leaves the instance running, which is what
400// makeRestartRoute does with such a row anyway.
401func (h *failureHolds) heldHost(leg, host string) bool {
402 rt, err := h.get(host)
403 if err != nil {
404 return true
405 }
406 return rt != nil && h.heldRoute(leg, *rt)
407}
408
409// skipGet wraps the converge leg's row read: a held row reads as no row, so
410// convergeAdopted passes the host by.
411func (h *failureHolds) skipGet(get func(string) (*registry.Route, error)) func(string) (*registry.Route, error) {
412 return func(host string) (*registry.Route, error) {
413 rt, err := get(host)
414 if err == nil && rt != nil && h.heldRoute("converge", *rt) {
415 return nil, nil
416 }
417 return rt, err
418 }
419}
420
421// skipHost wraps a bounce handed only a host (the adopt bounce): a held host
422// is not stopped, which its caller reads as nothing running to cycle.
423func (h *failureHolds) skipHost(leg string, bounce func(string) bool) func(string) bool {
424 return func(host string) bool {
425 if h.heldHost(leg, host) {
426 return false
427 }
428 return bounce(host)
429 }
430}
431
432// skipRoutes wraps the restart leg's bounce over the routes it was handed,
433// so it reads no row of its own.
434func (h *failureHolds) skipRoutes(routes []registry.Route, bounce func(string) bool) func(string) bool {
435 if bounce == nil {
436 return nil
437 }
438 byHost := make(map[string]registry.Route, len(routes))
439 for _, rt := range routes {
440 byHost[rt.Host] = rt
441 }
442 return func(host string) bool {
443 if rt, ok := byHost[host]; ok && h.heldRoute("restart", rt) {
444 return false
445 }
446 return bounce(host)
447 }
448}
449
450// configCallbacks is config sync's callbacks on a declined box that honours
451// records: today's restart and ack for every host but a held one, which is
452// not stopped into a refused wake. Its ack must not claim a restart: an
453// awake held instance still runs its old config, so the restart answers
454// rolloutRestartRefused (the ack's RestartError); a sleeping one reads the
455// file when it next wakes, so its ack says delivered on wake.
456func (h *failureHolds) configCallbacks(ack func(appconfig.ConfigAck), legs configLegs) appconfig.Callbacks {
457 restart := func(host string) error {
458 if h.heldHost("config sync", host) {
459 if legs.awake != nil && legs.awake(host) {
460 return errors.New(rolloutRestartRefused)
461 }
462 return appconfig.ErrDeliveredOnWake
463 }
464 if legs.restart == nil {
465 return nil
466 }
467 return legs.restart(host)
468 }
469 return appconfig.Callbacks{Restart: restart, Ack: ack}
470}
471
472// declinedHoldsStay is fastConvergeLoop's one line, at arming, when a box
473// that honours its failure book has no deploy store or no release key (Task
474// 10 rule 2): with neither a pointer can be read nor a retry's grant
475// verified, so no pointer pass runs and no retry is read, and what the book
476// holds stays held until the agent restarts with both.
477func declinedHoldsStay(why string) {
478 if heldHolds() == nil {
479 return
480 }
481 log.Printf("agent: %s=overlap declined: %s, so no signed retry or channel pointer can clear the failure records and hold-ran markers honoured here",
482 rollout.SwapEnv, why)
483}
484