carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package provision
2
3import (
4 "context"
5 "errors"
6 "log"
7 "os"
8
9 "github.com/carlosframework/platform/internal/registry"
10 "github.com/carlosframework/platform/internal/release"
11 "github.com/carlosframework/platform/internal/seeds"
12)
13
14// SeedHooks is what the provision pass needs to act on a seeded record
15// (seeded-instances spec §3.2). Nil on ReconcileConfig: no seeded record is
16// minted or acted on, which is the safe answer for a box that cannot place
17// one.
18type SeedHooks struct {
19 // Data is this region's data bucket (seeds/ lives there).
20 Data release.Store
21 // LegacyOwners reads the operator assignment of legacy route names
22 // (spec §6.4): the swap's only authority over a row filed under the
23 // operator account. Nil, or an error, defers such a swap.
24 LegacyOwners func(ctx context.Context) (*seeds.LegacyOwners, error)
25 // NestedOK is the box's live nested probe, asked after the layout
26 // intent says nested (spec §5.4 "Layout"). An error defers the swap to
27 // the next pass; it never refuses (only the intent does). Nil: the
28 // intent alone decides.
29 NestedOK func() error
30 // EnvBefore reports what the host's own env file holds, at the nested
31 // path beside dbPath, as one of registry's EnvBefore values. The swap
32 // asks it before the import's row exists and keeps the answer in the
33 // journal, so a rollback can tell the instance key the import's first
34 // launch minted from one that was there before (RetireKey). Nil, or an
35 // error, defers the swap.
36 EnvBefore func(dbPath, host string) (string, error)
37
38 // The rollback's box effects (spec §5.6, seedrollback.go). All seven
39 // or none: a box without them leaves every abandoned import journaled,
40 // gated and untouched.
41 //
42 // FenceForRollback fences and stops host under the rollback's fence
43 // generation, by the layout pass's rule: a fence already holding gen is
44 // this rollback's own from an earlier pass. proceed false defers the
45 // rollback, and the hook has lifted any fence it installed.
46 FenceForRollback func(ctx context.Context, host string, gen uint64) (proceed bool, err error)
47 // LiftFence lifts the fence gen, if it is the one host holds.
48 LiftFence func(host string, gen uint64)
49 // RemoveLocal removes the import's local data for its row (the
50 // database, -wal, -shm, <db>.seed, litestream's metadata and any seed
51 // fetch leftovers) through instancefs. Nothing there is not an error.
52 RemoveLocal func(rt registry.Route) error
53 // DeleteReplica deletes host's instances/ replica in the region data
54 // bucket. Nothing there is not an error.
55 DeleteReplica func(ctx context.Context, host string) error
56 // NextFenceGen mints a fence generation no fence on this box holds,
57 // never one it minted before, across agent restarts: the journal
58 // stores it, and a resumed rollback recognises and lifts its fence by
59 // it. An error, or 0, closes nothing this pass.
60 NextFenceGen func() (uint64, error)
61 // LocalSeedID is the seed id the <db>.seed marker beside dbPath names
62 // ("" for none or no marker), read through instancefs. An error is a
63 // marker that could not be read.
64 LocalSeedID func(dbPath string) (string, error)
65 // RetireKey retires the instance key the import created in the host's
66 // own env file beside dbPath, by the journal's EnvBefore: the whole
67 // file when there was none, its key lines when the file had no key,
68 // nothing when the key was there before or the journal does not say.
69 // It never removes a key or a line that predates the import, and it is
70 // not part of RemoveLocal. Nothing there is not an error.
71 RetireKey func(dbPath, host, envBefore string) error
72}
73
74type seedVerdict int
75
76const (
77 seedOrdinary seedVerdict = iota
78 seedSkip
79 seedFinishAbort
80 seedSwap
81)
82
83// seedAction is §3.2's dispatch, run before any other per-record handling.
84// row is this box's row for the host, nil when it holds none. nested is
85// asked only for a mint, through the pass's lazy layout reader. journalOK
86// is whether this pass read the import journal: when it could not, no
87// import is acted on, since the journal decides every import it holds a
88// row for (spec §5.7) and the no-journal paths must never run while a
89// journal row might exist.
90func seedAction(rec Record, row *registry.Route, cfg ReconcileConfig, nested func() (bool, error), journalOK bool) seedVerdict {
91 src := rec.Seed
92 if src == nil {
93 return seedOrdinary
94 }
95 if cfg.Seeds == nil {
96 return seedSkip
97 }
98 if src.Import() && !journalOK {
99 log.Printf("provision: %s: import %s left alone this pass: the import journal could not be read", rec.Host, src.ID)
100 return seedSkip
101 }
102 hasRow := row != nil
103 switch src.State {
104 case seeds.StateAborted:
105 return seedFinishAbort
106 case seeds.StateRolledBack, seeds.StateReserving, seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused:
107 return seedSkip
108 case seeds.StateReady:
109 if src.Import() {
110 return seedSwap // seedswap.go
111 }
112 if hasRow {
113 return seedOrdinary
114 }
115 if cfg.ClaimEnabled {
116 log.Printf("provision: %s: not minting a seeded instance: claim control is on for this box", rec.Host)
117 return seedSkip
118 }
119 // Only an activator-managed row is minted nested (routeFor): any
120 // other row would start without its seed and without the seed
121 // command, which is an empty database.
122 if !rec.DerivesPaths() || !rec.HibernateOrDefault() {
123 log.Printf("provision: %s: not minting a seeded instance: its record does not describe a hibernating instance", rec.Host)
124 return seedSkip
125 }
126 if ok, err := nested(); err != nil || !ok {
127 log.Printf("provision: %s: not minting a seeded instance: this box does not mint nested (%v)", rec.Host, err)
128 return seedSkip
129 }
130 return seedOrdinary
131 case seeds.StateSwapped, seeds.StatePlaced:
132 // Before committed, an import's row is made only by the swap,
133 // gated (spec §5.4, §8). A box with no row, or the swapping box
134 // after its gated row was removed, must not mint one: routeFor
135 // never sets the gate, so ordinary traffic would reach an
136 // unverified import.
137 if src.Import() && !hasRow {
138 log.Printf("provision: %s: not minting import %s: it is %s but not committed, and only its swap makes its row", rec.Host, src.ID, src.State)
139 return seedSkip
140 }
141 // The swap's row is gated until commit. An ungated row here was
142 // put there by hand, and adopting or converging it would serve
143 // the unverified import to ordinary traffic.
144 if src.Import() && row.ImportGate == "" {
145 log.Printf("provision: %s: not converging import %s: it is %s but not committed, and the row on this box has no import gate", rec.Host, src.ID, src.State)
146 return seedSkip
147 }
148 return seedOrdinary
149 case seeds.StateCommitted:
150 return seedOrdinary
151 }
152 log.Printf("provision: %s: seed %s is in an unknown state %q; leaving it alone", rec.Host, src.ID, src.State)
153 return seedSkip
154}
155
156// retireAbortedSeedRow removes the row of an aborted --from seed that this
157// box minted (task-4 review I1). The seed can be aborted after its publish
158// landed (a lost publish answer), and then a box may have minted the row and
159// be running it, holding the host's lease and writing its replica. The
160// console's finish deletes that replica, so the row must stop first. The
161// record stays aborted this pass; a later pass that finds no row rolls the
162// seed back (finishAbortedSeed).
163//
164// It removes the row exactly as the removal pass removes a row whose record
165// is gone, with the same authority: a Provisioned row of this pass's own
166// kind and scope, not a running operation's participant, through
167// claimRetire (reg.Delete, which the activator follows by stopping it). It
168// also requires the row to be this record's (account and app). A row it
169// may not remove is left alone, logged, and the seed stays aborted.
170//
171// Only --from seeds: an import's row is the instance the import replaces,
172// which its rollback restores (Plan 7) and never removes.
173func retireAbortedSeedRow(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, rec Record, rt registry.Route) {
174 if rt.Kind == SidecarKind || !rt.Provisioned || !cfg.inScope(rt.Region, rt.Pool, everywhereRoute(rt)) ||
175 rt.Account != rec.Account || rt.App != rec.App {
176 log.Printf("provision: %s: aborted seed %s has a row this pass may not remove; leaving the seed aborted", rec.Host, rec.Seed.ID)
177 return
178 }
179 if cfg.KeepParticipant != nil && cfg.KeepParticipant(rt.Host) {
180 log.Printf("provision: %s: aborted seed %s: not removing its row: participant of a running operation", rec.Host, rec.Seed.ID)
181 return
182 }
183 if cfg.DryRun {
184 log.Printf("provision: dry-run: would remove %s route %s (its seed %s was aborted)", rt.Kind, rt.Host, rec.Seed.ID)
185 return
186 }
187 if err := cfg.claimRetire(ctx, reg, rt); err != nil {
188 log.Printf("provision: remove %s route %s (its seed %s was aborted): %v", rt.Kind, rt.Host, rec.Seed.ID, err)
189 return
190 }
191 log.Printf("provision: removed %s route %s (its seed %s was aborted); the next pass rolls it back", rt.Kind, rt.Host, rec.Seed.ID)
192}
193
194// finishAbortedSeed is §3.2's no-journal path for an aborted seed: remove
195// a --from seed's local database (purgeAbortedFromLocal), delete this
196// seed's snapshot (only this seed's id, codex R4-1), then CAS the record to
197// rolled-back. Idempotent: an absent snapshot and a lost CAS are both done.
198// The console finishes from rolled-back (Plan 8).
199//
200// A host or id that fails seeds.Check names no snapshot (no valid key
201// could hold one), so nothing is deleted and the record is still rolled
202// back.
203func finishAbortedSeed(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, rec Record) {
204 if cfg.DryRun {
205 log.Printf("provision: dry-run: would finish aborted seed %s of %s", rec.Seed.ID, rec.Host)
206 return
207 }
208 if rec.Seed.Kind == seeds.KindReplica && !purgeAbortedFromLocal(ctx, reg, cfg, rec) {
209 return
210 }
211 if err := seeds.Check(rec.Host, rec.Seed.ID); err != nil {
212 log.Printf("provision: %s: aborted seed %q names no snapshot: %v", rec.Host, rec.Seed.ID, err)
213 } else {
214 key := seeds.SnapshotKey(rec.Host, rec.Seed.ID)
215 if err := cfg.Seeds.Data.Delete(ctx, key); err != nil && !errors.Is(err, os.ErrNotExist) {
216 log.Printf("provision: %s: delete aborted seed's snapshot: %v", rec.Host, err)
217 return
218 }
219 }
220 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(rec.Seed.ID)); err != nil && !errors.Is(err, ErrSeedConflict) {
221 log.Printf("provision: %s: mark seed %s rolled back: %v", rec.Host, rec.Seed.ID, err)
222 return
223 }
224 log.Printf("provision: %s: aborted seed %s rolled back (no box had swapped it)", rec.Host, rec.Seed.ID)
225}
226
227// rollbackArmed reports whether h has the rollback's seven box hooks.
228func rollbackArmed(h *SeedHooks) bool {
229 return h != nil && h.FenceForRollback != nil && h.LiftFence != nil && h.RemoveLocal != nil && h.DeleteReplica != nil &&
230 h.NextFenceGen != nil && h.LocalSeedID != nil && h.RetireKey != nil
231}
232
233// purgeAbortedFromLocal removes an aborted --from seed's local database
234// before the seed is rolled back (final review F2), on a pass that found
235// no row for the host. The seed may have been aborted after its publish
236// landed (a lost publish answer), and a wake then placed its database at
237// the host's default nested path, where a later plain instance at the
238// host would open it as a warm cache. The console's finish deletes the
239// replica and the record, so nothing else would ever remove the file.
240//
241// It is Plan 7's rule for an import whose row is gone (removeOrphanedLocal),
242// decided from the file: the database goes only when its <db>.seed marker
243// names this seed and the host still has no row, with the host fenced and
244// its stop proven. The marker is on disk, so this holds after a restart
245// too. A marker that is absent or names another seed does not prove the
246// data is this seed's: logged, and the seed rolls back with the file left.
247// A marker that cannot be read, a fence that does not proceed, a row that
248// appears or a removal that fails defers the rollback (ok false).
249//
250// A box without the rollback's hooks places no seed (they are armed with
251// the seed reader, and only claim control leaves them off, which refuses
252// every seed), so it has nothing of this seed's to remove: ok.
253func purgeAbortedFromLocal(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, rec Record) (ok bool) {
254 h := cfg.Seeds
255 if !rollbackArmed(h) {
256 log.Printf("provision: %s: aborted seed %s: this box cannot read seed markers, and places no seed; nothing local is removed", rec.Host, rec.Seed.ID)
257 return true
258 }
259 db := NestedDBPath(cfg.DataDir, rec.Host)
260 ours := func() (bool, bool) {
261 id, err := h.LocalSeedID(db)
262 if err != nil {
263 log.Printf("provision: %s: aborted seed %s stays aborted: the seed marker beside %s cannot be read: %v", rec.Host, rec.Seed.ID, db, err)
264 return false, false
265 }
266 if id != rec.Seed.ID {
267 log.Printf("provision: %s: aborted seed %s rolls back with the database at %s left: its seed marker names %q, not this seed", rec.Host, rec.Seed.ID, db, id)
268 return false, true
269 }
270 return true, true
271 }
272 if mine, ok := ours(); !mine {
273 return ok
274 }
275 gen, err := h.NextFenceGen()
276 if err == nil && gen == 0 {
277 err = errors.New("the generation source answered 0")
278 }
279 if err != nil {
280 log.Printf("provision: %s: aborted seed %s stays aborted: no fence generation: %v", rec.Host, rec.Seed.ID, err)
281 return false
282 }
283 proceed, err := h.FenceForRollback(ctx, rec.Host, gen)
284 defer h.LiftFence(rec.Host, gen) // a hook that failed may have fenced first
285 if err != nil || !proceed {
286 log.Printf("provision: %s: aborted seed %s stays aborted: the host is not fenced and stopped yet (%v)", rec.Host, rec.Seed.ID, err)
287 return false
288 }
289 // Under the fence: still no row, and the marker still names this seed.
290 row, err := reg.Get(rec.Host)
291 if err != nil {
292 log.Printf("provision: %s: aborted seed %s stays aborted: the route could not be read: %v", rec.Host, rec.Seed.ID, err)
293 return false
294 }
295 if row != nil {
296 log.Printf("provision: %s: aborted seed %s stays aborted: a route appeared at the host, so its local database is left", rec.Host, rec.Seed.ID)
297 return false
298 }
299 if mine, ok := ours(); !mine {
300 return ok
301 }
302 if err := h.RemoveLocal(registry.Route{Host: rec.Host, Kind: "instance", DBPath: db}); err != nil {
303 log.Printf("provision: %s: aborted seed %s stays aborted: remove its local data: %v", rec.Host, rec.Seed.ID, err)
304 return false
305 }
306 log.Printf("provision: %s: aborted seed %s has no route on this box: its local data is removed", rec.Host, rec.Seed.ID)
307 return true
308}
309
310// journalPass is spec §5.7: the box's import journal is read before the
311// records are acted on, every pass, and each row drives its host (§5.4
312// "Crashes", §5.6). handled hosts are skipped by the record loop;
313// journaled hosts (every journal row) by the removal phase, since the
314// journal owns the row until the row is gone.
315//
316// The journal is read whatever cfg.Seeds is (preflight M1): with seed
317// hooks unarmed every journaled host is still protected and handled,
318// with no action. With claim control on for this box it is the same:
319// every journaled host is protected and handled, and nothing moves
320// forward (no rollback, no redone record CAS, no cleared gate). ok is
321// false when the journal cannot be read. Reconcile then treats the pass
322// as a failed read for removal, and seedAction acts on no import.
323//
324// A journal row at swapped is decided by the record that answers to it,
325// which is only ever the record under the journal's own key
326// (journalRecord): another record for the same host can only vouch for
327// the import, never revoke it.
328//
329// - gone, no seed, another seed, or this seed aborted, refused or rolled
330// back: the import is abandoned, so it is rolled back. A refused
331// record with a journal row is a refusal that lost the race to this
332// box's swap: the journal wins;
333// - this seed at ready: the swap's record CAS is redone;
334// - this seed at committed: the gate is cleared and the journal row
335// dropped (CommitImport), and the loop converges the row;
336// - this seed at swapped or placed: the loop handles it as usual.
337//
338// The loop converges a host only when the pass's own record for it is the
339// journal's: when another record won the host, or the journal's record is
340// out of scope, the host stays handled, so no other record touches the
341// row. Every other case leaves the host alone this pass, logged.
342func journalPass(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig,
343 recs map[string]Record, readFailed bool, protect map[string]bool) (handled, journaled map[string]bool, ok bool) {
344 handled, journaled = map[string]bool{}, map[string]bool{}
345 rows, err := reg.ImportJournals()
346 if err != nil {
347 log.Printf("provision: the import journal could not be read: %v (no import is acted on and no route is removed this pass)", err)
348 return handled, journaled, false
349 }
350 for _, j := range rows {
351 journaled[j.Host] = true
352 handled[j.Host] = true // cleared below for the two cases the loop converges
353 if cfg.Seeds == nil {
354 log.Printf("provision: %s: import %s is journaled, but this box cannot act on imports; its row is left alone", j.Host, j.SeedID)
355 continue
356 }
357 // With claim control on, a journal row is only protected (merge
358 // review S2). The box arms no swap and no rollback there, and the
359 // claim console refuses commit, abort and finish, so nothing this
360 // pass could do would be one step of a whole: redoing the swap's
361 // record CAS writes a record, and clearing a committed gate opens
362 // the row to ordinary traffic, each on a box whose claim has not
363 // taken the host. The host stays handled (the loop converges
364 // nothing) and journaled (the removal phase leaves its row), until
365 // the claim's own seed algorithm exists (#250). Activation refuses
366 // while a journal row exists, so this is an import that was caught
367 // by a claim that came on anyway.
368 if cfg.ClaimEnabled {
369 log.Printf("provision: %s: import %s is journaled at %s and claim control is on for this box; its row is protected and nothing else is done", j.Host, j.SeedID, j.State)
370 continue
371 }
372 rec, inPass, known := journalRecord(ctx, s, j, recs, readFailed, protect)
373 if !known {
374 log.Printf("provision: %s: import %s left alone this pass: its record could not be read", j.Host, j.SeedID)
375 continue
376 }
377 // journalRecord answers only with the journal's own record; this
378 // holds before any decision below, rollbacks first of all.
379 if rec != nil && RecordKey(rec.Account, rec.App, rec.Host) != j.RecordKey {
380 log.Printf("provision: %s: import %s left alone: the journal holds it under %s, not %s", j.Host, j.SeedID,
381 j.RecordKey, RecordKey(rec.Account, rec.App, rec.Host))
382 continue
383 }
384 switch j.State {
385 case registry.JournalCleanupPending, registry.JournalRollingBack:
386 startRollback(ctx, s, reg, cfg, j, rec)
387 continue
388 case registry.JournalSwapped:
389 default:
390 log.Printf("provision: %s: import %s is journaled in an unknown state %q; leaving it alone", j.Host, j.SeedID, j.State)
391 continue
392 }
393 var seed *SeedSource
394 if rec != nil {
395 seed = rec.Seed
396 }
397 switch {
398 case seed == nil || seed.ID != j.SeedID:
399 startRollback(ctx, s, reg, cfg, j, rec)
400 case seed.State == seeds.StateAborted || seed.State == seeds.StateRefused || seed.State == seeds.StateRolledBack:
401 startRollback(ctx, s, reg, cfg, j, rec)
402 case seed.State == seeds.StateReady:
403 if cfg.DryRun {
404 log.Printf("provision: dry-run: would redo %s's swap record CAS for import %s", j.Host, j.SeedID)
405 } else {
406 markSwapped(ctx, s, *rec)
407 }
408 case seed.State == seeds.StateCommitted:
409 if cfg.DryRun {
410 log.Printf("provision: dry-run: would clear %s's import gate", j.Host)
411 } else if err := reg.CommitImport(j.Host, j.SeedID); err != nil {
412 log.Printf("provision: %s: clear the import gate: %v", j.Host, err)
413 } else {
414 log.Printf("provision: %s: import %s committed: its gate is cleared and its journal row dropped", j.Host, j.SeedID)
415 }
416 handled[j.Host] = !inPass
417 case seed.State == seeds.StateSwapped || seed.State == seeds.StatePlaced:
418 handled[j.Host] = !inPass
419 default:
420 // reserving, awaiting-upload and uploaded come before any swap.
421 log.Printf("provision: %s: import %s is journaled but its record says %s; leaving it alone", j.Host, j.SeedID, seed.State)
422 }
423 }
424 return handled, journaled, true
425}
426
427// startRollback hands an abandoned import to its rollback, which a dry
428// run never starts.
429func startRollback(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, j registry.ImportJournal, rec *Record) {
430 if cfg.DryRun {
431 log.Printf("provision: dry-run: would roll back %s's import %s", j.Host, j.SeedID)
432 return
433 }
434 rollbackImport(ctx, s, reg, cfg, j, rec)
435}
436
437// journalRecord is the record under the journal's own key, nil when it is
438// gone, and known false when this pass cannot tell. inPass says it is the
439// record the pass holds for the host, so the loop may converge with it.
440//
441// recs holds the first in-scope record per host that read cleanly, which
442// need not be the journal's: another app's or account's record can win a
443// duplicate host, or hold the host while the journal's record is out of
444// this box's scope. So recs answers only when its record's key is the
445// journal's; otherwise the record is read by the journal's key, and only
446// os.ErrNotExist means gone. A protected host, a failed read, a journal
447// row with no key, and a body under the key that names another host or
448// key all decide nothing this pass: a committed import must never be
449// rolled back on a guess.
450func journalRecord(ctx context.Context, s release.Store, j registry.ImportJournal, recs map[string]Record,
451 readFailed bool, protect map[string]bool) (rec *Record, inPass, known bool) {
452 if protect[j.Host] || j.RecordKey == "" {
453 return nil, false, false
454 }
455 if r, ok := recs[j.Host]; ok && RecordKey(r.Account, r.App, r.Host) == j.RecordKey {
456 return &r, true, true
457 }
458 if readFailed {
459 return nil, false, false
460 }
461 r, err := journalRecordByKey(ctx, s, j)
462 if err != nil {
463 log.Printf("provision: %s: the import's record %s is not one this pass can use (%v)", j.Host, j.RecordKey, err)
464 return nil, false, false
465 }
466 return r, false, true
467}
468