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 "encoding/json"
6 "errors"
7 "fmt"
8 "log"
9 "os"
10 "path"
11 "path/filepath"
12 "sort"
13 "strings"
14 "sync"
15 "sync/atomic"
16 "syscall"
17 "time"
18
19 "github.com/carlosframework/platform/internal/activator"
20 "github.com/carlosframework/platform/internal/hostwrite"
21 "github.com/carlosframework/platform/internal/instancectl"
22 "github.com/carlosframework/platform/internal/layout"
23 "github.com/carlosframework/platform/internal/lease"
24 "github.com/carlosframework/platform/internal/moves"
25 "github.com/carlosframework/platform/internal/park"
26 "github.com/carlosframework/platform/internal/provision"
27 "github.com/carlosframework/platform/internal/registry"
28 "github.com/carlosframework/platform/internal/release"
29)
30
31// The offline operator move on a box (docs/superpowers/plans/2026-10-20-
32// offline-operator-move.md, Task 3): the hold reconcile asks, the keep view,
33// the capability, the moves pass's dispatch, and the source boxes' steps.
34// The sealer's step is movesoffline_seal.go (Task 4) and the target's is
35// movesoffline_land.go (Task 5).
36//
37// The stop has three layers. This file holds the first two on every
38// source-region box: reconcile holds the row from the record alone
39// (offlineHold), and each pass fences the activator and writes the box's
40// source report. The third, the tombstone lease, is the sealer's.
41//
42// Dormant, with no offline record anywhere, nothing here acts: the hold
43// answers false for every record, keep answers false once the startup
44// listing is read, the dispatch matches no record, and the edges file
45// carries one more capability string.
46
47// capOfflineMove1 is the edges-file capability a box publishes when it can
48// take part in an offline move (offlineCapable). The console's start door
49// refuses a region holding a box that does not publish it.
50const capOfflineMove1 = "offline-move-1"
51
52// offlineCapable is the box's own witness that it can take part in an
53// offline move: hibernation is configured and neither CARLOS_HOST_CLAIM nor
54// CARLOS_MOVES_PHASE1 is set to anything at all. The guard mode is not read
55// (platform-1e's ruling of 2026-10-20, the plan's Global Constraints): the
56// target's build stages through ordinary adoption, which root's own floor
57// check already covers on a guarded box. A box that is not capable publishes
58// no capability and runs no offline step.
59func offlineCapable(getenv func(string) string, hibernation bool) bool {
60 if !hibernation || hostwrite.Enabled(getenv) {
61 return false
62 }
63 return strings.TrimSpace(getenv(moves.SwitchEnv)) == "" && !moves.Enabled(getenv)
64}
65
66// hibernationConfigured is the edges file's HibernationConfigured, read the
67// same way, so the capability and the field beside it never disagree.
68func hibernationConfigured(getenv func(string) string) bool {
69 return strings.TrimSpace(getenv("CARLOS_HIBERNATE_BUCKET")) != ""
70}
71
72// offlineFenceOwner is the activator fence owner of one offline move.
73func offlineFenceOwner(id string) string { return "offline:" + id }
74
75// offlineHold is reconcile's Hold for offline moves: a pure function of the
76// record it is handed and this box's placement, never of a snapshot (Fable's
77// B1). A target box that restarts after the flip reconciles before its first
78// moves pass, and must mint the landing row held even then.
79//
80// - A source-region box (placement equals move.from) holds the host's row
81// "moving" under the move id from offline-stopping through
82// offline-sealed. After the flip the record names the target, the row
83// is out of this box's scope and offlineView.keep keeps it, held as it
84// is, until offline-finishing.
85// - A target-region box (placement equals move.to) holds it "landing"
86// while the record names the target and the move is offline-landing or
87// offline-landed. offline-open is the target's to release (Task 5).
88//
89// Placements are compared as movesNamesBox compares them, by
90// park.PlacementMatches under the verified home binding, so the home's ""
91// and its roster name are one.
92func offlineHold(box moveCfg) func(rec provision.Record) (status, op string, ok bool) {
93 home, here := box.ParkHomeRegion, box.parkRegion()
94 return func(rec provision.Record) (string, string, bool) {
95 m := rec.Move
96 if !m.Offline() || m.ID == "" || here == "" {
97 return "", "", false
98 }
99 switch m.State {
100 case provision.OfflineStopping, provision.OfflineSealing, provision.OfflineSealed:
101 if park.PlacementMatches(home, m.From, here) {
102 return "moving", m.ID, true
103 }
104 case provision.OfflineLanding, provision.OfflineLanded:
105 if rec.Region == m.To && park.PlacementMatches(home, m.To, here) {
106 return "landing", m.ID, true
107 }
108 }
109 return "", "", false
110 }
111}
112
113// offlineView is what reconcile's KeepParticipant asks about offline moves:
114// the offline moves in flight, as the last listing read them. Only keep
115// reads it; the hold never does (offlineHold).
116//
117// A source box's view has seen the move before the record flips: the seal
118// door needs this box's report for the move, and the pass that writes it
119// refreshes the view first; an agent restart reads the listing again at
120// start. From then until finishing every listing carries the move.
121//
122// It is refreshed from every moves pass's record list and once at agent
123// start, before the reconcile loops (refreshOfflineView). keep fails closed,
124// keeping every row, only while no listing has been read (reconcileSerialized-
125// With reads one first when the view is unbuilt or its last listing failed,
126// so a failed startup listing heals on the next reconcile), and for a host
127// whose record does not decode. Once a listing is read, a host with no
128// offline move is not kept, so removals happen on the first pass as they
129// always have.
130type offlineView struct {
131 home, region string
132 // written is reconcile's HoldWritten for the offline hooks: the
133 // stage-requested marker after a hold write (registry.SetHold's
134 // contract, G10). Nil writes nothing.
135 written func(host string)
136 snap atomic.Pointer[offlineSnapshot]
137 logged sync.Map // host -> time.Time: the last unreadable-record line
138}
139
140type offlineSnapshot struct {
141 moves map[string]provision.MoveState // host -> its offline move
142 owners map[string][2]string // host -> the account and app its record lives under
143 failed bool // the listing failed: keep everything
144 // unreadable are the hosts whose record read but did not decode, or
145 // disagreed with its path (provision.ListAllRecordsStrict): their move
146 // cannot be known, so their rows are kept.
147 unreadable map[string]bool
148}
149
150// newOfflineView builds the view for the box cfg describes. Unbuilt until
151// its first refresh.
152func newOfflineView(cfg moveCfg, written func(host string)) *offlineView {
153 return &offlineView{home: cfg.ParkHomeRegion, region: cfg.parkRegion(), written: written}
154}
155
156// refresh rebuilds the view from a record list that was read, with the
157// hosts provision.ListAllRecordsStrict could not decode: those are kept
158// (fail closed for keep only; the hold never reads the view) and logged at
159// most once a minute each.
160func (v *offlineView) refresh(recs []provision.Record, unreadable map[string]bool) {
161 next := &offlineSnapshot{moves: map[string]provision.MoveState{}, owners: map[string][2]string{}, unreadable: map[string]bool{}}
162 for _, rec := range recs {
163 if rec.Move.Offline() {
164 next.moves[rec.Host] = *rec.Move
165 next.owners[rec.Host] = [2]string{rec.Account, rec.App}
166 }
167 }
168 now := time.Now()
169 for host := range unreadable {
170 next.unreadable[host] = true
171 if last, ok := v.logged.Load(host); !ok || now.Sub(last.(time.Time)) >= time.Minute {
172 v.logged.Store(host, now)
173 log.Printf("moves: offline view: %s: the record does not decode; its row is kept until it does", host)
174 }
175 }
176 v.snap.Store(next)
177}
178
179// fail records a listing that could not be read, before any was: keep
180// answers true for every host until a listing is read.
181func (v *offlineView) fail() { v.snap.Store(&offlineSnapshot{failed: true}) }
182
183// built reports whether any listing, read or failed, has been recorded.
184func (v *offlineView) built() bool { return v.snap.Load() != nil }
185
186// failed reports whether the last listing recorded failed: keep answers true
187// for every host until one reads.
188func (v *offlineView) failed() bool {
189 snap := v.snap.Load()
190 return snap != nil && snap.failed
191}
192
193// keep is reconcile's KeepParticipant: the row of a host whose offline move
194// names this box's region stays, whatever the record's region says. The
195// source keeps it until offline-finishing (when it is removed, as the plan's
196// finishing step relies on); the target keeps it until the move is cleared
197// (the target's revert deletes it itself). An unknown state keeps.
198func (v *offlineView) keep(host string) bool {
199 snap := v.snap.Load()
200 if snap == nil || snap.failed || snap.unreadable[host] {
201 return true
202 }
203 m, ok := snap.moves[host]
204 if !ok || v.region == "" {
205 return false
206 }
207 if park.PlacementMatches(v.home, m.To, v.region) {
208 return true
209 }
210 if park.PlacementMatches(v.home, m.From, v.region) {
211 return m.State != provision.OfflineFinishing
212 }
213 return false
214}
215
216// elsewhere reports whether host's offline move has its record name a region
217// other than this box's: the target's from landing to finishing, the source's
218// while reverting. Such a host is not this box's to touch whatever its row
219// says (scheduleSkip). A view with no listing yet, and a host with no
220// offline move, answer false: the row's own hold answers for those.
221func (v *offlineView) elsewhere(host string) bool {
222 snap := v.snap.Load()
223 if snap == nil || snap.failed || v.region == "" {
224 return false
225 }
226 m, ok := snap.moves[host]
227 if !ok {
228 return false
229 }
230 region := m.From
231 switch m.State {
232 case provision.OfflineLanding, provision.OfflineLanded, provision.OfflineOpen, provision.OfflineFinishing:
233 region = m.To
234 }
235 return !park.PlacementMatches(v.home, region, v.region)
236}
237
238// refreshOfflineView reads the record list once, outside a moves pass: at
239// agent start, before the reconcile loops, next to refreshMovesHolds. A
240// failed listing leaves keep failing closed until a listing is read.
241func refreshOfflineView(ctx context.Context, s release.Store, v *offlineView) {
242 if v == nil || s == nil {
243 return
244 }
245 recs, unreadable, err := provision.ListAllRecordsStrict(ctx, s)
246 if err != nil {
247 log.Printf("moves: offline view: list records: %v; every row is kept until a listing reads", err)
248 v.fail()
249 return
250 }
251 v.refresh(recs, unreadable)
252}
253
254// armedOfflineView is the armed worker's view, nil when no worker is armed
255// or the box cannot take part in an offline move.
256func armedOfflineView() *offlineView {
257 w := armedMovesWorker.Load()
258 if w == nil || !w.cfg.OfflineCapable {
259 return nil
260 }
261 return w.cfg.Offline
262}
263
264// armedOfflineCapable is whether this box publishes offline-move-1: the
265// armed worker is capable and has its keep view, exactly the condition
266// under which reconcileSerializedWith installs the offline hooks.
267func armedOfflineCapable() bool { return armedOfflineView() != nil }
268
269// withOfflineHolds gives a reconcile pass the offline hooks, only when the
270// caller and Phase 1's view set none (withMovesHolds runs first): the two
271// are never armed together, since a box with Phase 1 on is not capable.
272func withOfflineHolds(cfg provision.ReconcileConfig, hold func(provision.Record) (string, string, bool), v *offlineView) provision.ReconcileConfig {
273 if hold == nil || v == nil || cfg.Hold != nil || cfg.KeepParticipant != nil {
274 return cfg
275 }
276 cfg.Hold, cfg.KeepParticipant = hold, v.keep
277 if cfg.HoldWritten == nil && v.written != nil {
278 cfg.HoldWritten = v.written
279 }
280 return cfg
281}
282
283// newMovesWorker is the worker for cfg: armMovesPass's, and each test box's.
284func newMovesWorker(cfg moveCfg) *movesWorker {
285 return &movesWorker{cfg: cfg, mirrors: map[string]lease.MirrorStore{}}
286}
287
288// fenceAsWith is moveCfg.FenceAs over an activator: FenceAndStopAs under a
289// short deadline, so one slow stop does not hold up the pass.
290// ErrStopUnproven still means the fence is installed, which is all the step
291// asks of it; Quiesced answers the rest.
292func fenceAsWith(svc *activator.Service) func(ctx context.Context, host string, generation uint64, owner string) error {
293 return func(ctx context.Context, host string, generation uint64, owner string) error {
294 fctx, cancel := context.WithTimeout(ctx, 2*time.Second)
295 defer cancel()
296 if _, err := svc.FenceAndStopAs(fctx, host, generation, owner); err != nil && !errors.Is(err, activator.ErrStopUnproven) {
297 return err
298 }
299 return nil
300 }
301}
302
303// hostSliceCgroup is the absolute cgroup directory of a nested host's
304// systemd slice under cgroupRoot (the cgroup v2 mount): every role and
305// generation unit of the host runs in it.
306func hostSliceCgroup(cgroupRoot, host string) string {
307 return filepath.Join(cgroupRoot, filepath.FromSlash(path.Dir(instancectl.ClaimUnitControlGroup(host))))
308}
309
310// offlineRound is the moves pass's round for a record with an offline move,
311// reached before every other branch of the pass (the Phase 1 skip and the
312// lost-box gate included). A box that is not capable logs one notice per
313// host and does nothing. Otherwise it runs the source step when its
314// placement is move.from, the sealer's when it is move.sealer, and the
315// target's when its placement is move.to, in that order, so the sealer
316// reads the report this box has just written.
317func (w *movesWorker) offlineRound(ctx context.Context, s release.Store, rec provision.Record) error {
318 cfg := w.cfg
319 m := rec.Move
320 if !m.Offline() {
321 return nil
322 }
323 here := cfg.parkRegion()
324 source := here != "" && park.PlacementMatches(cfg.ParkHomeRegion, m.From, here)
325 target := here != "" && park.PlacementMatches(cfg.ParkHomeRegion, m.To, here)
326 sealer := cfg.Box != "" && cfg.Box == m.Sealer
327 if !cfg.OfflineCapable {
328 if source || target || sealer {
329 w.notice(rec.Host+"#offline", "moves: %s: offline move %s waits: this box cannot take part in an offline move (it needs hibernation configured, and %s and %s unset)", rec.Host, m.ID, "CARLOS_HOST_CLAIM", moves.SwitchEnv)
330 }
331 return nil
332 }
333 if m.State == provision.OfflineStaging {
334 // The pre-copy (movesoffline_stage.go): nothing holds or fences
335 // the instance, and only the stager acts.
336 if source {
337 return w.offlineStageStep(ctx, s, rec)
338 }
339 return nil
340 }
341 var errs []error
342 if source {
343 errs = append(errs, w.offlineSourceStep(ctx, s, rec))
344 }
345 if sealer {
346 errs = append(errs, w.offlineSealerStep(ctx, s, rec))
347 }
348 if target {
349 errs = append(errs, w.offlineTargetStep(ctx, s, rec))
350 }
351 return errors.Join(errs...)
352}
353
354// offlineReassertEvery is how often a box that takes part in an offline move
355// rewrites the reports a door judges by age (harness Run 9, finding 7). The
356// doors believe a report for two minutes (offlineReportFresh, internal/
357// console), and the moves pass runs on a serial move or every five to six
358// minutes. So a report written on the pass one door's bump started was stale
359// by the time an operator who waited over two minutes ran the next door, and
360// flip and open refused until the timed pass: most of Run 9's 366 s offline.
361// At this cadence a participant's report is never much more than 20 s old.
362const offlineReassertEvery = 15 * time.Second
363
364// offlineReassertRoles says which of this box's steps keep its report current
365// while a move is in m's state: a source box's stop from stopping to landed,
366// and the sealer's keep from sealed to landed. Those are the reports seal,
367// flip and open judge by age. The seal itself (sealing) and the target's
368// landing are not repeated here: they are long, and they run on the pass a
369// door's serial bump starts. From open on no door reads a report by age.
370// The sealer only re-reads and reports (offlineSealerRefresh): its ordinary
371// pass re-asserts the tombstone, and the refresh never does.
372func offlineReassertRoles(m *provision.MoveState, source, sealer bool) (asSource, asSealer bool) {
373 switch m.State {
374 case provision.OfflineStopping, provision.OfflineSealing:
375 return source, false
376 case provision.OfflineSealed, provision.OfflineLanding, provision.OfflineLanded:
377 return source, sealer
378 }
379 return false, false
380}
381
382// offlineReassert is one tick of offlineReassertLoop: for each offline move
383// the view holds in which this box's report is judged by age, it reads the
384// record again and runs this box's source or sealer step for it, in
385// offlineRound's order, under the pass lock. Each step is an ensure that ends
386// by rewriting the box's report (offlineSourceStop, and offlineSealerRefresh,
387// which checks the tombstone but never re-asserts it). With no
388// such move in flight it reads nothing at all. It returns how many records it
389// acted on.
390func (w *movesWorker) offlineReassert(ctx context.Context, s release.Store) int {
391 cfg := w.cfg
392 v := cfg.Offline
393 if !cfg.OfflineCapable || v == nil || s == nil || cfg.Box == "" {
394 return 0
395 }
396 snap := v.snap.Load()
397 if snap == nil || snap.failed || len(snap.moves) == 0 {
398 return 0
399 }
400 here := cfg.parkRegion()
401 roles := func(m *provision.MoveState) (bool, bool) {
402 source := here != "" && park.PlacementMatches(cfg.ParkHomeRegion, m.From, here)
403 return offlineReassertRoles(m, source, m.Sealer == cfg.Box)
404 }
405 var hosts []string
406 for host, m := range snap.moves {
407 if asSource, asSealer := roles(&m); asSource || asSealer {
408 hosts = append(hosts, host)
409 }
410 }
411 if len(hosts) == 0 {
412 return 0
413 }
414 sort.Strings(hosts)
415 w.pass.Lock()
416 defer w.pass.Unlock()
417 acted := 0
418 for _, host := range hosts {
419 owner := snap.owners[host]
420 rec, err := provision.GetRecord(ctx, s, owner[0], owner[1], host)
421 if err != nil {
422 w.logTrouble(host, fmt.Errorf("offline move: read the record to report again: %w", err))
423 continue
424 }
425 if !rec.Move.Offline() {
426 continue
427 }
428 asSource, asSealer := roles(rec.Move)
429 if !asSource && !asSealer {
430 continue
431 }
432 acted++
433 var errs []error
434 if asSource {
435 errs = append(errs, w.offlineSourceStep(ctx, s, *rec))
436 }
437 if asSealer {
438 errs = append(errs, w.offlineSealerRefresh(ctx, s, *rec))
439 }
440 if err := errors.Join(errs...); err != nil {
441 w.logTrouble(host, err)
442 }
443 }
444 return acted
445}
446
447// now is cfg.Now, or the wall clock when a test left it unset.
448func (c moveCfg) now() time.Time {
449 if c.Now != nil {
450 return c.Now()
451 }
452 return time.Now()
453}
454
455// writeOfflineReport writes r at its key (moves.OfflineReportKey), stamped
456// with the report version, after the shape check every reader makes. The
457// source, sealer and target steps all write through it.
458func writeOfflineReport(ctx context.Context, s release.Store, r moves.OfflineReport) error {
459 r.V = moves.OfflineReportVersion
460 key := moves.OfflineReportKey(r.Host, r.Op, r.Role, r.Box)
461 if key == "" {
462 return fmt.Errorf("offline report: host %q, op %q, role %q or box %q is not usable", r.Host, r.Op, r.Role, r.Box)
463 }
464 if err := moves.ValidateOfflineReport(r); err != nil {
465 return err
466 }
467 body, err := json.Marshal(r)
468 if err != nil {
469 return err
470 }
471 // A report is rewritten every pass: read its etag and replace it
472 // (release.Store.Put with no etag is create-only).
473 return moves.PutReplace(ctx, s, key, body)
474}
475
476// putOfflineCreateOnly writes body at key only if nothing is there yet:
477// the immutable seal record (control/offline-moves/<host>/<id>/seal.json,
478// moves.OfflineSealKey, written once by the sealer and never rewritten).
479// It is an ensure: when the key already exists it writes nothing and
480// returns what is stored, with created false, so a sealer re-running after
481// a crash compares rather than overwrites, and a reader (flip, the landing)
482// always sees the first write. key must be non-empty.
483func putOfflineCreateOnly(ctx context.Context, s release.Store, key string, body []byte) (stored []byte, created bool, err error) {
484 if key == "" {
485 return nil, false, errors.New("offline move: no key to write")
486 }
487 _, err = s.Put(ctx, key, body, "")
488 if err == nil {
489 return body, true, nil
490 }
491 if !errors.Is(err, release.ErrCASConflict) {
492 return nil, false, fmt.Errorf("write %s: %w", key, err)
493 }
494 stored, _, err = s.Get(ctx, key)
495 if err != nil {
496 return nil, false, fmt.Errorf("read %s after a create-only write found it: %w", key, err)
497 }
498 return stored, false, nil
499}
500
501// readOfflineReports reads every report of one role for one move, keyed by
502// box. A report that does not validate is an error, never skipped.
503func readOfflineReports(ctx context.Context, s release.Store, host, op, role string) (map[string]moves.OfflineReport, error) {
504 prefix := moves.OfflineReportPrefix(host, op)
505 if prefix == "" {
506 return nil, fmt.Errorf("offline report: host %q or op %q is not usable", host, op)
507 }
508 prefix += role + "/"
509 keys, err := s.List(ctx, prefix)
510 if err != nil {
511 return nil, err
512 }
513 out := map[string]moves.OfflineReport{}
514 for _, key := range keys {
515 if !strings.HasSuffix(key, ".json") {
516 continue
517 }
518 body, _, err := s.Get(ctx, key)
519 if err != nil {
520 return nil, err
521 }
522 var r moves.OfflineReport
523 if err := json.Unmarshal(body, &r); err != nil {
524 return nil, fmt.Errorf("%s: %w", key, err)
525 }
526 if err := moves.ValidateOfflineReport(r); err != nil {
527 return nil, fmt.Errorf("%s: %w", key, err)
528 }
529 if moves.OfflineReportKey(r.Host, r.Op, r.Role, r.Box) != key {
530 return nil, fmt.Errorf("%s: the report names %s/%s/%s/%s", key, r.Host, r.Op, r.Role, r.Box)
531 }
532 out[r.Box] = r
533 }
534 return out, nil
535}
536
537// offlineSourceStep is a source-region box's part of an offline move. Every
538// state's step is an ensure: it reads what is on disk, in the registry and
539// in the activator, does only what is missing, and reports what it found.
540func (w *movesWorker) offlineSourceStep(ctx context.Context, s release.Store, rec provision.Record) error {
541 if w.cfg.Box == "" {
542 return errors.New("offline move: this box has no label to report under")
543 }
544 switch rec.Move.State {
545 case provision.OfflineStopping, provision.OfflineSealing, provision.OfflineSealed, provision.OfflineLanding, provision.OfflineLanded:
546 // The pinned stager's stop acknowledgement first: the seal door
547 // needs it beside this box's stop (movesoffline_stage.go).
548 return errors.Join(w.offlineStagerStopped(ctx, s, rec), w.offlineSourceStop(ctx, s, rec))
549 case provision.OfflineOpen:
550 return w.offlineSourcePark(ctx, s, rec)
551 case provision.OfflineFinishing:
552 return w.offlineSourceGone(ctx, s, rec)
553 case provision.OfflineReverting:
554 return w.offlineSourceRevert(ctx, s, rec)
555 }
556 return nil
557}
558
559// offlineFence installs, or meets again, this move's activator fence for
560// host. Fences live in the activator's memory, so every pass installs it
561// again and an agent restart regains it on its next pass. True when the
562// fence is this move's now.
563func (w *movesWorker) offlineFence(ctx context.Context, host string, m *provision.MoveState) bool {
564 if w.cfg.FenceAs == nil {
565 return false
566 }
567 if err := w.cfg.FenceAs(ctx, host, moveFenceGeneration(m), offlineFenceOwner(m.ID)); err != nil {
568 w.notice(host+"#offline-fence", "moves: %s: offline move %s: the wake fence is not installed: %v", host, m.ID, err)
569 return false
570 }
571 return true
572}
573
574// offlineLiftFence lifts this move's fence, and only it.
575func (w *movesWorker) offlineLiftFence(host string, m *provision.MoveState) {
576 if w.cfg.LiftFenceAs != nil && w.cfg.LiftFenceAs(host, moveFenceGeneration(m), offlineFenceOwner(m.ID)) {
577 log.Printf("moves: %s: offline move %s: wake fence lifted", host, m.ID)
578 }
579}
580
581// offlineRow reads the host's row on this box; nil when it has none.
582func (w *movesWorker) offlineRow(host string) (*registry.Route, error) {
583 if w.cfg.Route == nil {
584 return nil, errors.New("offline move: no registry on this box")
585 }
586 return w.cfg.Route(host)
587}
588
589// heldBy reports whether rt is held by op. An absent row counts: nothing
590// can serve or wake a host this box has no row for.
591func heldBy(rt *registry.Route, op string) bool {
592 return rt == nil || (rt.HoldOp == op && registry.IsHold(rt.Status))
593}
594
595// offlineLocal is what a box keeps on disk for a host, judged by lstat and
596// directory listings only, never by following a link. A row's own layout is
597// read at the row's paths; a box with no row, or a leftover of the other
598// layout, is read at both.
599type offlineLocal struct {
600 LocalDB, Files, Env, Sidecar bool
601 LocalTXID uint64
602}
603
604// flatDBPath is where a flat row's database is: the row's own path when it
605// has one and is flat, else the default.
606func (c moveCfg) flatDBPath(host string, rt *registry.Route) string {
607 if rt != nil && rt.DBPath != "" && !provision.NestedPaths(c.DataDir, *rt) {
608 return rt.DBPath
609 }
610 return provision.DefaultDBPath(c.DataDir, host)
611}
612
613// activatorMetaDir is the activator's litestream meta directory beside a
614// database (localLTXPosition reads it).
615func activatorMetaDir(dbPath string) string {
616 return filepath.Join(filepath.Dir(dbPath), ".activator-"+filepath.Base(dbPath)+"-litestream")
617}
618
619func lexists(p string) bool {
620 _, err := os.Lstat(p)
621 return err == nil
622}
623
624func isDir(p string) bool {
625 st, err := os.Lstat(p)
626 return err == nil && st.IsDir()
627}
628
629// offlinePlatformName reports a nested instance directory's platform entry
630// (layout.IsNestedPlatformName: the database and its files, the env files,
631// the generation, seed and files markers, the adoption record, and the
632// launch descriptors, launch.gen.<n>.json included): everything else in it
633// is the app's files.
634func offlinePlatformName(host, name string) bool { return layout.IsNestedPlatformName(host, name) }
635
636// localState reads what this box keeps for host: the DB file or either
637// litestream meta dir (S8: a meta dir alone is local state, since
638// localLTXPosition reads it), the files directory, the env file and a
639// sidecar env file, under the row's layout and any leftover of the other.
640func (w *movesWorker) localState(host string, rt *registry.Route) (offlineLocal, error) {
641 cfg := w.cfg
642 var out offlineLocal
643 nestedRow := rt != nil && provision.NestedPaths(cfg.DataDir, *rt)
644 // Flat: a flat row's own paths, or a nested row's flat leftovers.
645 {
646 db := cfg.flatDBPath(host, rt)
647 out.LocalDB = lexists(db) || lexists(db+"-litestream") || lexists(activatorMetaDir(db))
648 out.Files = isDir(filepath.Join(cfg.DataDir, host))
649 out.Env = lexists(filepath.Join(cfg.DataDir, host+".env"))
650 out.Sidecar = lexists(filepath.Join(cfg.DataDir, host+"-sidecar.env"))
651 pos, err := localLTXPosition(db)
652 if err != nil {
653 return out, err
654 }
655 out.LocalTXID = pos
656 }
657 // Nested, read through the raw directory (layout.RawInstanceDir).
658 raw := layout.RawInstanceDir(cfg.DataDir, host)
659 if nestedRow || isDir(raw) {
660 db := filepath.Join(raw, host+".db")
661 out.LocalDB = out.LocalDB || lexists(db) || lexists(db+"-litestream") || lexists(activatorMetaDir(db))
662 out.Env = out.Env || lexists(filepath.Join(raw, host+".env"))
663 out.Sidecar = out.Sidecar || lexists(filepath.Join(raw, host+"-sidecar.env"))
664 ents, err := os.ReadDir(raw)
665 if err != nil && !errors.Is(err, os.ErrNotExist) {
666 return out, err
667 }
668 for _, e := range ents {
669 if !offlinePlatformName(host, e.Name()) {
670 out.Files = true
671 break
672 }
673 }
674 dbPath := db
675 if nestedRow && rt.DBPath != "" {
676 dbPath = rt.DBPath
677 }
678 pos, err := localLTXPosition(dbPath)
679 if err != nil {
680 return out, err
681 }
682 out.LocalTXID = max(out.LocalTXID, pos)
683 }
684 return out, nil
685}
686
687// procs is the process count for the report: -1 when it cannot be read,
688// which the flip door reads as not stopped.
689func (w *movesWorker) procs(host string) int {
690 if w.cfg.Procs == nil {
691 return -1
692 }
693 n, err := w.cfg.Procs(host)
694 if err != nil {
695 w.notice(host+"#offline-procs", "moves: %s: the host's processes cannot be counted on this box: %v", host, err)
696 return -1
697 }
698 return n
699}
700
701// sourceReport is the source report's evidence as it stands now.
702func (w *movesWorker) sourceReport(host string, m *provision.MoveState, rt *registry.Route, fenced bool, phase string) (moves.OfflineReport, error) {
703 cfg := w.cfg
704 local, err := w.localState(host, rt)
705 if err != nil {
706 return moves.OfflineReport{}, err
707 }
708 quiesced := cfg.Quiesced != nil && cfg.Quiesced(host)
709 return moves.OfflineReport{
710 Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSource, Phase: phase, At: cfg.now().UTC(),
711 Held: heldBy(rt, m.ID), Fenced: fenced, Quiesced: quiesced, RowPresent: rt != nil,
712 LocalDB: local.LocalDB, Files: local.Files, Env: local.Env, Sidecar: local.Sidecar, LocalTXID: local.LocalTXID,
713 Procs: w.procs(host), LayoutJournal: layout.JournalPresent(cfg.DataDir, host),
714 }, nil
715}
716
717// offlineSourceStop is the source step from offline-stopping through
718// offline-landed: install the fence, read the row and the local state, and
719// rewrite this box's source report with At on every pass (the open door
720// needs it current). The row's hold is reconcile's (offlineHold) and,
721// after the flip, its keep (offlineView.keep).
722func (w *movesWorker) offlineSourceStop(ctx context.Context, s release.Store, rec provision.Record) error {
723 m := rec.Move
724 fenced := w.offlineFence(ctx, rec.Host, m)
725 rt, err := w.offlineRow(rec.Host)
726 if err != nil {
727 return err
728 }
729 r, err := w.sourceReport(rec.Host, m, rt, fenced, moves.OfflinePhaseStopped)
730 if err != nil {
731 return err
732 }
733 return writeOfflineReport(ctx, s, r)
734}
735
736// retirePath is one path of the manifest's source-retire row on this box:
737// where it is, its name in the park directory, and, for a path the agent
738// never renames, why.
739type retirePath struct {
740 path, name, left string
741}
742
743// Why a path is left where it is.
744const (
745 leftRootOwned = "root-owned: listed for the census, not renamed"
746 leftView = "the mount view of the instance directory: it stays, empty"
747 leftInstance = "the instance directory stays, empty"
748)
749
750// nestedParkPrefix is where a nested instance directory's entries go inside
751// the park directory, apart from the flat names.
752const nestedParkPrefix = "instance"
753
754// retirePaths is the manifest's source-retire row as paths on this box, in
755// a fixed order: the row's layout in full, the other layout's paths only
756// where they exist, the app's own entries of a nested instance directory as
757// found there and in the park directory, then what is never renamed.
758func (w *movesWorker) retirePaths(host, parkDir string, rt *registry.Route) ([]retirePath, error) {
759 cfg := w.cfg
760 nestedRow := rt != nil && provision.NestedPaths(cfg.DataDir, *rt)
761 raw := layout.RawInstanceDir(cfg.DataDir, host)
762 var out []retirePath
763 add := func(p, name string, always bool) {
764 if always || lexists(p) {
765 out = append(out, retirePath{path: p, name: name})
766 }
767 }
768 flatDB := cfg.flatDBPath(host, rt)
769 flat := !nestedRow
770 add(flatDB, filepath.Base(flatDB), flat)
771 add(flatDB+"-wal", filepath.Base(flatDB)+"-wal", flat)
772 add(flatDB+"-shm", filepath.Base(flatDB)+"-shm", flat)
773 add(flatDB+"-litestream", filepath.Base(flatDB)+"-litestream", flat)
774 add(activatorMetaDir(flatDB), filepath.Base(activatorMetaDir(flatDB)), flat)
775 add(filepath.Join(cfg.DataDir, host), host, flat)
776 add(filepath.Join(cfg.DataDir, host+".env"), host+".env", flat)
777 add(filepath.Join(cfg.DataDir, host+".config.env"), host+".config.env", flat)
778
779 nested := nestedRow || isDir(raw)
780 if nested {
781 named := map[string]bool{}
782 for _, n := range []string{host + ".db", host + ".db-wal", host + ".db-shm", host + ".db-litestream", ".activator-" + host + ".db-litestream", host + ".env", host + ".config.env"} {
783 named[n] = true
784 add(filepath.Join(raw, n), nestedParkPrefix+"/"+n, true)
785 }
786 // The app's files, and every other entry the directory holds: what
787 // is still there, and what an earlier pass already parked.
788 // A numbered copy in the park directory (parkName) belongs to the
789 // entry it is a copy of, not to the app.
790 others := map[string]bool{}
791 listed := map[string][]string{}
792 for _, dir := range []string{raw, filepath.Join(parkDir, nestedParkPrefix)} {
793 ents, err := os.ReadDir(dir)
794 if err != nil && !errors.Is(err, os.ErrNotExist) {
795 return nil, err
796 }
797 for _, e := range ents {
798 listed[dir] = append(listed[dir], e.Name())
799 }
800 }
801 known := map[string]bool{}
802 for n := range named {
803 known[n] = true
804 }
805 for _, n := range listed[raw] {
806 known[n] = true
807 }
808 for _, n := range listed[filepath.Join(parkDir, nestedParkPrefix)] {
809 known[n] = true
810 }
811 for _, n := range listed[raw] {
812 if !named[n] {
813 others[n] = true
814 }
815 }
816 for _, n := range listed[filepath.Join(parkDir, nestedParkPrefix)] {
817 if !named[n] && !isNumberedCopy(n, known) {
818 others[n] = true
819 }
820 }
821 names := make([]string, 0, len(others))
822 for n := range others {
823 names = append(names, n)
824 }
825 sort.Strings(names)
826 for _, n := range names {
827 add(filepath.Join(raw, n), nestedParkPrefix+"/"+n, true)
828 }
829 }
830 add(filepath.Join(cfg.DataDir, "programs", host+".json"), host+".json", true)
831 if nested {
832 out = append(out, retirePath{path: provision.InstanceDir(cfg.DataDir, host), left: leftView})
833 out = append(out, retirePath{path: raw, left: leftInstance})
834 }
835 optDir := cfg.OptDir
836 if optDir == "" {
837 optDir = release.OptDir()
838 }
839 out = append(out, retirePath{path: filepath.Join(optDir, "live", host), left: leftRootOwned})
840 return out, nil
841}
842
843// parkCopySep separates a name from its copy number in the park directory
844// (parkName). Chosen to be unlikely in an app's own file names.
845const parkCopySep = ".parked-"
846
847// isNumberedCopy reports whether n is a known entry's numbered park copy
848// (parkName's "<name>.parked-<n>"), which belongs to that entry.
849func isNumberedCopy(n string, known map[string]bool) bool {
850 i := strings.LastIndex(n, parkCopySep)
851 if i <= 0 || !known[n[:i]] || i+len(parkCopySep) == len(n) {
852 return false
853 }
854 for _, c := range n[i+len(parkCopySep):] {
855 if c < '0' || c > '9' {
856 return false
857 }
858 }
859 return true
860}
861
862// parkName is where name goes in dir: name itself, or name.parked-<n> for
863// the first free n when something of that name was parked before (the
864// source path came back after an earlier pass parked it). The park
865// directory is the move's own, so the first park of every path lands at its
866// plain name and a later pass reports it from there.
867func parkName(dir, name string) string {
868 dst := filepath.Join(dir, name)
869 if !lexists(dst) {
870 return dst
871 }
872 for n := 1; ; n++ {
873 if c := dst + parkCopySep + fmt.Sprint(n); !lexists(c) {
874 return c
875 }
876 }
877}
878
879// Why a rename the park attempted did not happen. A path whose rename fails
880// with an error a later pass would meet again (a mount, EXDEV, EBUSY; a path
881// the agent may not rename, EPERM or EACCES; a read-only filesystem) is
882// left for good: reported once, with leftCannot and the error, and never
883// tried again for this move, so a pass does not re-fence, fail and lift
884// over it. Any other error is leftNotYet, and the next pass tries again.
885const (
886 leftCannot = "cannot be renamed: "
887 leftNotYet = "not renamed yet: "
888)
889
890// renameTerminal reports whether a rename error is one a later pass would
891// meet again.
892func renameTerminal(err error) bool {
893 return errors.Is(err, os.ErrPermission) || errors.Is(err, syscall.EXDEV) ||
894 errors.Is(err, syscall.EBUSY) || errors.Is(err, syscall.EROFS)
895}
896
897// retire reports one path's action from what is on disk, renaming it into
898// the park directory first when park is true and it is there to rename.
899// cannot is the reason an earlier pass recorded for a path it found it can
900// never rename ("" when none): such a path is reported left again, as it
901// is, and not tried. Nothing is ever deleted.
902func retire(p retirePath, parkDir string, park bool, cannot string) moves.RetiredPath {
903 present := lexists(p.path)
904 switch {
905 case p.left != "" && present:
906 return moves.RetiredPath{Path: p.path, Action: moves.RetiredLeft, Reason: p.left}
907 case p.left != "":
908 return moves.RetiredPath{Path: p.path, Action: moves.RetiredAbsent}
909 case present && cannot != "":
910 return moves.RetiredPath{Path: p.path, Action: moves.RetiredLeft, Reason: cannot}
911 case present && park:
912 dst := parkName(parkDir, p.name)
913 err := os.MkdirAll(filepath.Dir(dst), 0o700)
914 if err == nil {
915 err = os.Rename(p.path, dst)
916 }
917 switch {
918 case err == nil:
919 return moves.RetiredPath{Path: p.path, Action: moves.RetiredParked}
920 case errors.Is(err, os.ErrNotExist) && !lexists(p.path):
921 return moves.RetiredPath{Path: p.path, Action: moves.RetiredAbsent}
922 case renameTerminal(err):
923 return moves.RetiredPath{Path: p.path, Action: moves.RetiredLeft, Reason: leftCannot + err.Error()}
924 }
925 return moves.RetiredPath{Path: p.path, Action: moves.RetiredLeft, Reason: leftNotYet + err.Error()}
926 case present:
927 return moves.RetiredPath{Path: p.path, Action: moves.RetiredLeft, Reason: leftNotYet + "the stop could not be confirmed this pass"}
928 case lexists(filepath.Join(parkDir, p.name)):
929 return moves.RetiredPath{Path: p.path, Action: moves.RetiredParked}
930 }
931 return moves.RetiredPath{Path: p.path, Action: moves.RetiredAbsent}
932}
933
934// cannotRename reads this box's own last report for the move and returns
935// the paths it recorded as never renameable (leftCannot), with their
936// reasons. Reading them from the report, not from memory, keeps the answer
937// across an agent restart.
938func (w *movesWorker) cannotRename(ctx context.Context, s release.Store, host, op string) (map[string]string, error) {
939 out := map[string]string{}
940 body, _, err := s.Get(ctx, moves.OfflineReportKey(host, op, moves.OfflineRoleSource, w.cfg.Box))
941 if errors.Is(err, os.ErrNotExist) {
942 return out, nil
943 }
944 if err != nil {
945 return nil, err
946 }
947 var r moves.OfflineReport
948 if err := json.Unmarshal(body, &r); err != nil {
949 return nil, err
950 }
951 for _, rp := range r.Retired {
952 if rp.Action == moves.RetiredLeft && strings.HasPrefix(rp.Reason, leftCannot) {
953 out[rp.Path] = rp.Reason
954 }
955 }
956 return out, nil
957}
958
959// offlineSourcePark is the source step at offline-open: with the fence held,
960// the row held (or absent), the host quiesced, no process left and no layout
961// journal, rename every path of the source-retire row that is still here
962// into the park directory; then lift the fence and report parked with the
963// inventory. A pass that finds nothing left to rename does not fence again:
964// it reports from what is parked. A path that can never be renamed
965// (leftCannot) is reported left with its reason and does not count as left
966// to rename, so it neither blocks the parked report nor brings the fence
967// back on every pass. A rename that may yet succeed (leftNotYet) reports
968// failed, and the next pass tries again. Nothing is deleted.
969func (w *movesWorker) offlineSourcePark(ctx context.Context, s release.Store, rec provision.Record) error {
970 cfg := w.cfg
971 m, host := rec.Move, rec.Host
972 parkDir := moves.OfflineParkDir(cfg.DataDir, host, m.ID, moves.OfflineRoleSource)
973 if parkDir == "" {
974 return fmt.Errorf("offline move %s: no park directory for %s under %q", m.ID, host, cfg.DataDir)
975 }
976 rt, err := w.offlineRow(host)
977 if err != nil {
978 return err
979 }
980 paths, err := w.retirePaths(host, parkDir, rt)
981 if err != nil {
982 return err
983 }
984 cannot, err := w.cannotRename(ctx, s, host, m.ID)
985 if err != nil {
986 return err
987 }
988 pending := false
989 for _, p := range paths {
990 if p.left == "" && cannot[p.path] == "" && lexists(p.path) {
991 pending = true
992 break
993 }
994 }
995 if pending {
996 fenced := w.offlineFence(ctx, host, m)
997 quiesced := cfg.Quiesced != nil && cfg.Quiesced(host)
998 procs := w.procs(host)
999 journal := layout.JournalPresent(cfg.DataDir, host)
1000 if !fenced || !heldBy(rt, m.ID) || !quiesced || procs != 0 || journal {
1001 return fmt.Errorf("offline move %s: not parking %s yet: fenced %v, held %v, quiesced %v, processes %d, layout journal %v",
1002 m.ID, host, fenced, heldBy(rt, m.ID), quiesced, procs, journal)
1003 }
1004 if err := os.MkdirAll(parkDir, 0o700); err != nil {
1005 return err
1006 }
1007 }
1008 retired := make([]moves.RetiredPath, 0, len(paths))
1009 retry := false
1010 for _, p := range paths {
1011 rp := retire(p, parkDir, pending, cannot[p.path])
1012 if rp.Action == moves.RetiredLeft && strings.HasPrefix(rp.Reason, leftNotYet) {
1013 retry = true
1014 }
1015 retired = append(retired, rp)
1016 }
1017 w.offlineLiftFence(host, m)
1018 phase := moves.OfflinePhaseParked
1019 if retry {
1020 phase = moves.OfflinePhaseFailed
1021 }
1022 r, err := w.sourceReport(host, m, rt, false, phase)
1023 if err != nil {
1024 return err
1025 }
1026 r.Retired = retired
1027 if err := writeOfflineReport(ctx, s, r); err != nil {
1028 return err
1029 }
1030 if retry {
1031 return fmt.Errorf("offline move %s: %s: some paths were not renamed this pass; the next pass tries again", m.ID, host)
1032 }
1033 return nil
1034}
1035
1036// offlineSourceGone is the source step at offline-finishing: keep no longer
1037// holds the row, so reconcile removes it; once it is gone, report gone. The
1038// fence was lifted at open and is lifted again, should a restart have
1039// re-fenced in between.
1040func (w *movesWorker) offlineSourceGone(ctx context.Context, s release.Store, rec provision.Record) error {
1041 m, host := rec.Move, rec.Host
1042 rt, err := w.offlineRow(host)
1043 if err != nil {
1044 return err
1045 }
1046 w.offlineLiftFence(host, m)
1047 if rt != nil {
1048 w.notice(host+"#offline-gone", "moves: %s: offline move %s: waiting for reconcile to remove the row", host, m.ID)
1049 return nil
1050 }
1051 r, err := w.sourceReport(host, m, nil, false, moves.OfflinePhaseGone)
1052 if err != nil {
1053 return err
1054 }
1055 return writeOfflineReport(ctx, s, r)
1056}
1057
1058// offlineSourceRevert is every source box's part of offline-reverting: the
1059// row leaves this move's hold (ReleaseHold under the host's hold lock), the
1060// fence is lifted, and the box reports reverted. The sealer's extra part is
1061// its own step's (Task 4).
1062//
1063// The release also holds reconcileMu: a reconcile pass that listed the
1064// records before the abort would otherwise mint a missing row held again
1065// from its stale listing after the release, and a row held by a move that
1066// has finished is never released. A pass that starts after the release
1067// lists the record at offline-reverting, which holds nothing.
1068func (w *movesWorker) offlineSourceRevert(ctx context.Context, s release.Store, rec provision.Record) error {
1069 cfg := w.cfg
1070 m, host := rec.Move, rec.Host
1071 if cfg.ReleaseHold == nil {
1072 return errors.New("offline move: no registry to release the hold through")
1073 }
1074 var relErr error
1075 reconcileMu.Lock()
1076 provision.WithHostHoldLock(host, func() {
1077 _, relErr = cfg.ReleaseHold(host, m.ID)
1078 })
1079 reconcileMu.Unlock()
1080 if relErr != nil {
1081 return relErr
1082 }
1083 w.offlineLiftFence(host, m)
1084 rt, err := w.offlineRow(host)
1085 if err != nil {
1086 return err
1087 }
1088 if rt != nil && rt.HoldOp == m.ID {
1089 return fmt.Errorf("offline move %s: %s is still held by the move", m.ID, host)
1090 }
1091 // A staged move's pre-copy is removed before this box says reverted.
1092 if err := w.offlineUnstage(ctx, s, rec); err != nil {
1093 return err
1094 }
1095 r, err := w.sourceReport(host, m, rt, false, moves.OfflinePhaseReverted)
1096 if err != nil {
1097 return err
1098 }
1099 r.Held = false
1100 return writeOfflineReport(ctx, s, r)
1101}
1102