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 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log"
12 "os"
13 "path/filepath"
14 "sort"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/superfly/ltx"
20
21 "github.com/carlosframework/platform/internal/activator"
22 "github.com/carlosframework/platform/internal/alerting"
23 "github.com/carlosframework/platform/internal/appconfig"
24 "github.com/carlosframework/platform/internal/instancekey"
25 "github.com/carlosframework/platform/internal/layout"
26 "github.com/carlosframework/platform/internal/lease"
27 "github.com/carlosframework/platform/internal/moves"
28 "github.com/carlosframework/platform/internal/moves/archive"
29 "github.com/carlosframework/platform/internal/park"
30 "github.com/carlosframework/platform/internal/provision"
31 "github.com/carlosframework/platform/internal/registry"
32 "github.com/carlosframework/platform/internal/release"
33)
34
35// The sealer of an offline operator move (docs/superpowers/plans/2026-10-20-
36// offline-operator-move.md, Task 4): the one source box that turns "every
37// source box holds, fences and reports" into a stop at the data layer, and
38// records what the target must land.
39//
40// The tombstone is the linearization point of the whole move. Every wake on
41// every box of the source region takes the host's single-writer lease
42// (instances/<host>/lock.json in the source's own bucket) before it can
43// write anything, and from the tombstone on that lease is held by
44// moved:<id>. The order is the plan's (Review Focus 2):
45//
46// 1. this box's own report for the pass says held, fenced, quiesced, no
47// process and no layout journal, and no source box keeps a sidecar;
48// 2. the local database is checkpointed unless it is behind the replica
49// (cfg.Checkpoint takes the lease, syncs and releases it);
50// 3. lease.Tombstone, which never replaces a live holder;
51// 4. only then the final position is read from the replica, and every
52// source box's local position must be at or below it;
53// 5. CarryRequest, a final MirrorPrefix round with nothing deferred, and
54// the staged listing;
55// 6. to 9. the files archive, the timers, the env and its pin, the release,
56// replication and next due;
57// 10. seal.json, written once and never rewritten (putOfflineCreateOnly),
58// then the record's CAS to offline-sealed.
59//
60// Every step is an ensure. Until seal.json exists the whole seal is taken
61// again on each pass (a tombstone that is already this move's is a no-op,
62// the mirror resumes from its receipts, the archive is rebuilt and its
63// objects overwritten), so a crash anywhere before it converges. Once
64// seal.json exists the sealed position is fixed, and from then on the
65// tombstone is never taken again: each pass reads lease.LockState first,
66// and a tombstone that is absent, expired or anyone else's is a lost stop,
67// as is a source report written since the seal at a position past the
68// sealed one. The pass reports failed and pages: before open the operator
69// aborts, from open on the runbook fixes it forward. Only a tombstone held
70// by this move is re-asserted (lease.Reassert, If-Match on the ETag it
71// reads, and lease.ErrTombstoneLost, never a new tombstone, when it went
72// in between). After it is lifted (finish, abort) it is never re-asserted.
73
74// The objects the sealer writes beside the sealed archive, under
75// moves.OfflineArchivePrefix(host, id) in the target's data bucket. The
76// landing (Task 5) reads them there.
77const (
78 // offlineArchiveSealed is the sealed files archive (archive.Seal of
79 // archive.Build's output). Its sha256 is the seal's FilesSealed.
80 offlineArchiveSealed = "files.sealed"
81 // offlineArchiveManifest is its archive.Manifest (JSON), with the
82 // sealed data key and every entry.
83 offlineArchiveManifest = "files.json"
84 // offlineTimersObject is the moves.TimersSnapshot of the row's active
85 // timers. moves.TimersDigest of its rows is the seal's TimersDigest.
86 offlineTimersObject = "timers.json"
87 // offlineEnvReportObject is the source's moves.EnvReport (names and
88 // HMACs, never a value), so the landing can name the variables that
89 // differ (moves.CompareEnv) when the env pin does not match. Its
90 // Digest is the seal's EnvDigest.
91 offlineEnvReportObject = "env-report.json"
92)
93
94// offlineSealFresh is how old this box's own source report may be when the
95// sealer reads it: the source step writes it earlier in the same pass, so
96// anything older means that step failed this pass.
97const offlineSealFresh = 2 * time.Minute
98
99// offlinePageLostStop is the page rule of a lost stop: one page per move.
100const offlinePageLostStop = "offline-lost-stop"
101
102// offlineSealFaults is the sealer's test seam: a worker's entry is called
103// after each step with the step's name, and an error returned is a crash
104// there. Keyed by the worker, so tests that run side by side never share
105// one. Empty in production.
106var offlineSealFaults sync.Map // *movesWorker → func(point string) error
107
108func (w *movesWorker) sealFault(point string) error {
109 if f, ok := offlineSealFaults.Load(w); ok {
110 return f.(func(string) error)(point)
111 }
112 return nil
113}
114
115// offlineSealerStep is the sealer's part of an offline move. offlineRound
116// calls it on the box whose label is rec.Move.Sealer, after this box's own
117// source step has written its report for the pass.
118func (w *movesWorker) offlineSealerStep(ctx context.Context, s release.Store, rec provision.Record) error {
119 m := rec.Move
120 if !m.Offline() || w.cfg.Box == "" || m.Sealer != w.cfg.Box {
121 return nil
122 }
123 switch m.State {
124 case provision.OfflineSealing:
125 return w.offlineSeal(ctx, s, rec)
126 case provision.OfflineSealed, provision.OfflineLanding, provision.OfflineLanded, provision.OfflineOpen:
127 raw, err := w.offlineSealRecord(ctx, s, rec)
128 if err != nil {
129 return err
130 }
131 return w.offlineKeepStop(ctx, s, rec, raw)
132 case provision.OfflineFinishing:
133 return w.offlineFinishLift(ctx, s, rec)
134 case provision.OfflineReverting:
135 return w.offlineSealRevert(ctx, s, rec)
136 }
137 return nil
138}
139
140// tombstoneAt is where the host's single-writer lease lives in this box's
141// own bucket: the activator's lease location, as ExecBackend.Acquire takes
142// it for every wake.
143func (c moveCfg) tombstoneAt(host string) (bucket, location, region string, err error) {
144 if c.OwnBucket == "" {
145 return "", "", "", errors.New("offline move: this box has no hibernate bucket")
146 }
147 return c.OwnBucket, activator.HibernatePrefix(c.OwnPrefix, host), c.OwnBucketRegion, nil
148}
149
150// tombState is the lease as lease.LockState read it: an absent lock has no
151// owner and a zero expiry.
152type tombState struct {
153 owner string
154 expires time.Time
155}
156
157// mine reports a tombstone this move holds now. "Expired" is the lease's
158// own reading (Acquire takes a lease the moment its expiry has passed), so
159// it is judged against the wall clock the lease is written by.
160func (t tombState) mine(id string) bool {
161 return t.owner == moves.TombstoneOwner(id) && time.Now().Before(t.expires)
162}
163
164// free reports a lease nothing holds: absent, or past its expiry, whoever
165// wrote it (a lifted tombstone included: lease.IsLiftedTombstone).
166func (t tombState) free() bool {
167 return t.owner == "" || !time.Now().Before(t.expires)
168}
169
170func (t tombState) String() string { return offlineLeaseText(t) }
171
172// offlineLeaseText is the lease as the lost stop's page and the sealer's
173// report name it (copy rows MV-O72 to MV-O75).
174func offlineLeaseText(t tombState) string {
175 switch {
176 case t.owner == "":
177 return "absent"
178 case lease.IsLiftedTombstone(t.owner, t.expires) && t.expires.Unix() == 0:
179 // LiftTombstone's expiry: the epoch.
180 return fmt.Sprintf("lifted (%s)", t.owner)
181 case t.free():
182 return fmt.Sprintf("expired (%s at %s)", t.owner, t.expires.UTC().Format(time.RFC3339))
183 }
184 return fmt.Sprintf("held by %s until %s", t.owner, t.expires.UTC().Format(time.RFC3339))
185}
186
187// offlineLock reads the host's lease.
188func (w *movesWorker) offlineLock(ctx context.Context, host string) (tombState, error) {
189 bucket, loc, region, err := w.cfg.tombstoneAt(host)
190 if err != nil {
191 return tombState{}, err
192 }
193 _, owner, expires, err := lease.LockState(ctx, bucket, loc, region)
194 if err != nil {
195 return tombState{}, fmt.Errorf("read the lease of %s: %w", host, err)
196 }
197 return tombState{owner: owner, expires: expires}, nil
198}
199
200// offlineTarget is the target's coordinates as its edges file publishes
201// them: the box at move.to (the lexically first, as every move reads the
202// fleet), matched exactly and then by placement under the home binding.
203func (w *movesWorker) offlineTarget(ctx context.Context, s release.Store, m *provision.MoveState) (edgesFile, error) {
204 fleet, err := loadMoveFleet(ctx, s)
205 if err != nil {
206 return edgesFile{}, fmt.Errorf("read the fleet's edges files: %w", err)
207 }
208 if t, ok := fleet.targets[m.To]; ok {
209 return t, nil
210 }
211 codes := make([]string, 0, len(fleet.targets))
212 for code := range fleet.targets {
213 codes = append(codes, code)
214 }
215 sort.Strings(codes)
216 for _, code := range codes {
217 if park.PlacementMatches(w.cfg.ParkHomeRegion, m.To, park.PlacementRegion(code)) {
218 return fleet.targets[code], nil
219 }
220 }
221 return edgesFile{}, fmt.Errorf("the target region %q publishes no data bucket yet", m.To)
222}
223
224// offlineMirror is the mirror pair between this box's bucket and region.
225func (w *movesWorker) offlineMirror(ctx context.Context, region string) (lease.MirrorStore, error) {
226 if w.cfg.Mirror == nil {
227 return nil, errors.New("offline move: no S3 mirror on this box")
228 }
229 return w.mirror(ctx, region)
230}
231
232// replicaPosition is the newest TXID of the replica listed in objs under
233// prefix (newestLTX), 0 when it holds no LTX file.
234func replicaPosition(objs map[string]lease.MirrorObject, prefix string) uint64 {
235 newest := newestLTX(objs, prefix, nil)
236 if newest == "" {
237 return 0
238 }
239 _, maxTX, err := ltx.ParseFilename(filepath.Base(newest))
240 if err != nil {
241 return 0
242 }
243 return uint64(maxTX)
244}
245
246// sealRefusal is a seal item that failed for a reason a later pass will
247// meet again: the pass reports it (phase failed) and the operator aborts.
248// Reason is the item's reason as `carlos move offline status` shows it.
249// Each refusal is written as a literal where it is decided, so the copy
250// gate reads every sentence a Reason can carry (copy rows MV-O76 to
251// MV-O84); the two that carry an error's own text (the archive's refusal
252// and the secrets key's) are box diagnostics (movesDiagnosticWrites).
253type sealRefusal struct {
254 item, Reason string
255}
256
257func (r *sealRefusal) Error() string { return r.item + ": " + r.Reason }
258
259// offlineSeal is the sealer's step in offline-sealing.
260func (w *movesWorker) offlineSeal(ctx context.Context, s release.Store, rec provision.Record) error {
261 m, host := rec.Move, rec.Host
262 sealKey := moves.OfflineSealKey(host, m.ID)
263 if sealKey == "" {
264 return fmt.Errorf("offline move %s: %s has no seal key", m.ID, host)
265 }
266 raw, _, err := s.Get(ctx, sealKey)
267 switch {
268 case err == nil:
269 // Sealed on an earlier pass, which crashed before the CAS: the
270 // position is fixed, so the stop is only kept, never taken again.
271 return w.offlineKeepStop(ctx, s, rec, raw)
272 case !errors.Is(err, os.ErrNotExist):
273 return fmt.Errorf("read %s: %w", sealKey, err)
274 }
275 items := map[string]moves.Check{}
276 seal, err := w.offlineTakeSeal(ctx, s, rec, items)
277 var ref *sealRefusal
278 if errors.As(err, &ref) {
279 items[ref.item] = moves.Check{OK: false, Reason: ref.Reason}
280 r := moves.OfflineReport{Op: m.ID, Host: host, Box: w.cfg.Box, Role: moves.OfflineRoleSealer, Phase: moves.OfflinePhaseFailed,
281 At: w.cfg.now().UTC(), Items: items}
282 return errors.Join(fmt.Errorf("offline move %s: %s cannot be sealed: %w", m.ID, host, err), writeOfflineReport(ctx, s, r))
283 }
284 if err != nil {
285 return fmt.Errorf("offline move %s: sealing %s: %w", m.ID, host, err)
286 }
287 body, err := json.Marshal(seal)
288 if err != nil {
289 return err
290 }
291 stored, created, err := putOfflineCreateOnly(ctx, s, sealKey, body)
292 if err != nil {
293 return err
294 }
295 if created {
296 log.Printf("moves: %s: offline move %s: sealed at TXID %d (%d staged objects, files %s)", host, m.ID, seal.FinalTXID, len(seal.Staged), seal.FilesPlain)
297 }
298 if err := w.sealFault("seal"); err != nil {
299 return err
300 }
301 return w.offlineKeepStop(ctx, s, rec, stored)
302}
303
304// offlineTakeSeal runs steps 1 to 9 and returns the seal to write. items
305// collects each item's check as it is decided; a *sealRefusal is the item
306// that failed.
307func (w *movesWorker) offlineTakeSeal(ctx context.Context, s release.Store, rec provision.Record, items map[string]moves.Check) (moves.OfflineReport, error) {
308 cfg := w.cfg
309 m, host := rec.Move, rec.Host
310 var seal moves.OfflineReport
311
312 // 1. The stop, as the source reports say it now: this box held, fenced,
313 // quiesced, with no process and no layout journal; every box the seal
314 // named held; no sidecar anywhere.
315 if len(m.Boxes) == 0 {
316 return seal, errors.New("the record names no source boxes")
317 }
318 reports, err := readOfflineReports(ctx, s, host, m.ID, moves.OfflineRoleSource)
319 if err != nil {
320 return seal, fmt.Errorf("read the source reports: %w", err)
321 }
322 now := cfg.now().UTC()
323 own, ok := reports[cfg.Box]
324 if !ok || own.Phase != moves.OfflinePhaseStopped || !own.Held || !own.Fenced || !own.Quiesced || own.Procs != 0 || own.LayoutJournal || now.Sub(own.At) > offlineSealFresh {
325 return seal, fmt.Errorf("this box has not stopped %s this pass: report found %v, held %v, fenced %v, quiesced %v, processes %d, layout journal %v, at %s",
326 host, ok, own.Held, own.Fenced, own.Quiesced, own.Procs, own.LayoutJournal, own.At.Format(time.RFC3339))
327 }
328 boxes := append([]string(nil), m.Boxes...)
329 sort.Strings(boxes)
330 for _, b := range boxes {
331 r, ok := reports[b]
332 if !ok || !r.Held {
333 return seal, fmt.Errorf("waiting for %s to report %s held", b, host)
334 }
335 }
336 for _, b := range boxes {
337 if reports[b].Sidecar {
338 return seal, &sealRefusal{item: "sidecar", Reason: fmt.Sprintf("%s keeps a sidecar file for %s", b, host)}
339 }
340 }
341 items["sidecar"] = moves.Check{OK: true, Reason: "no source box keeps a sidecar file"}
342
343 rt, err := w.offlineRow(host)
344 if err != nil {
345 return seal, err
346 }
347 if rt == nil {
348 return seal, fmt.Errorf("this box has no row for %s", host)
349 }
350 tgt, err := w.offlineTarget(ctx, s, m)
351 if err != nil {
352 return seal, err
353 }
354 // A staged move hands over in the destination its stage fixed (Astra
355 // round 2, finding 2). A target that publishes anything else now is
356 // not sealed into; the seal door refuses it first (MV-O192).
357 if m.StagedAt != nil && (tgt.DataBucket != m.StageBucket || tgt.DataBucketRegion != m.StageRegion) {
358 return seal, fmt.Errorf("the target now publishes %s (%s), not the staging bucket %s (%s) the stage fixed", tgt.DataBucket, tgt.DataBucketRegion, m.StageBucket, m.StageRegion)
359 }
360 mirror, err := w.offlineMirror(ctx, tgt.DataBucketRegion)
361 if err != nil {
362 return seal, err
363 }
364 bucket, loc, region, err := cfg.tombstoneAt(host)
365 if err != nil {
366 return seal, err
367 }
368 srcPrefix := loc + "/"
369 dstPrefix := activator.MovesPrefix(host)
370 dbPath := rt.DBPath
371 if dbPath == "" {
372 dbPath = cfg.flatDBPath(host, rt)
373 }
374
375 // 2. The checkpoint, while no tombstone is down: the local database is
376 // synced under the lease, which the checkpoint releases again. That
377 // carries a tail the stop failed to checkpoint, whether litestream had
378 // already turned it into a local LTX file (the local position is past
379 // the replica's) or it is still in the WAL (the positions are equal),
380 // as the legacy cut re-checkpoints unconditionally. A local copy behind
381 // the replica is a stale warm cache: the replica is the instance, and
382 // syncing the stale copy into it is the one thing never done. A
383 // tombstone already this move's means an earlier pass checkpointed and
384 // took it, and nothing has woken since; the checkpoint could not take
385 // the lease anyway.
386 local, err := w.localState(host, rt)
387 if err != nil {
388 return seal, err
389 }
390 objs, err := mirror.List(ctx, bucket, srcPrefix)
391 if err != nil {
392 return seal, fmt.Errorf("list the replica: %w", err)
393 }
394 before, err := w.offlineLock(ctx, host)
395 if err != nil {
396 return seal, err
397 }
398 replica := replicaPosition(objs, srcPrefix)
399 switch {
400 case before.mine(m.ID):
401 case local.LocalTXID < replica:
402 log.Printf("moves: %s: offline move %s: the local copy (position %d) is behind the replica (%d); the replica is sealed as it is", host, m.ID, local.LocalTXID, replica)
403 case lexists(dbPath):
404 if cfg.Checkpoint == nil {
405 return seal, errors.New("no checkpoint on this box")
406 }
407 if err := cfg.Checkpoint(ctx, dbPath); err != nil {
408 return seal, fmt.Errorf("final checkpoint of %s: %w", dbPath, err)
409 }
410 log.Printf("moves: %s: offline move %s: checkpointed the local database (position %d, replica %d)", host, m.ID, local.LocalTXID, replica)
411 case local.LocalTXID > replica:
412 return seal, &sealRefusal{item: "db-tail", Reason: fmt.Sprintf("this box's local position %d is past the replica's %d and there is no database at %s to checkpoint", local.LocalTXID, replica, dbPath)}
413 }
414 if err := w.sealFault("checkpoint"); err != nil {
415 return seal, err
416 }
417
418 // 3. The tombstone: from here no wake on any box can take the lease.
419 owner := moves.TombstoneOwner(m.ID)
420 if err := lease.Tombstone(ctx, bucket, loc, region, owner, moves.TombstoneTTL); err != nil {
421 return seal, fmt.Errorf("tombstone %s/%s/lock.json: %w", bucket, loc, err)
422 }
423 if err := w.sealFault("tombstone"); err != nil {
424 return seal, err
425 }
426
427 // 4. The final position, read only now, and no source box past it.
428 objs, err = mirror.List(ctx, bucket, srcPrefix)
429 if err != nil {
430 return seal, fmt.Errorf("list the replica: %w", err)
431 }
432 final := replicaPosition(objs, srcPrefix)
433 if final == 0 {
434 return seal, &sealRefusal{item: "db", Reason: fmt.Sprintf("the replica at %s/%s holds no database", bucket, srcPrefix)}
435 }
436 mine, err := w.localState(host, rt) // after the checkpoint, not as reported before it
437 if err != nil {
438 return seal, err
439 }
440 for _, b := range boxes {
441 pos := reports[b].LocalTXID
442 if b == cfg.Box {
443 pos = mine.LocalTXID
444 }
445 if pos > final {
446 return seal, &sealRefusal{item: "db-tail", Reason: fmt.Sprintf("%s's local position %d is past the replica's final position %d", b, pos, final)}
447 }
448 }
449 items["db-tail"] = moves.Check{OK: true, Reason: fmt.Sprintf("every source box is at or below TXID %d", final)}
450 items["lease"] = moves.Check{OK: true, Reason: fmt.Sprintf("%s holds %s/lock.json", owner, loc)}
451 if err := w.sealFault("final"); err != nil {
452 return seal, err
453 }
454
455 // 5. The request record into the build state, then the byte-final
456 // round into the target's staging lane and its listing.
457 if cfg.CarryRequest != nil {
458 if err := cfg.CarryRequest(ctx, host, bucket, loc); err != nil {
459 return seal, fmt.Errorf("carry the request record into the build state: %w", err)
460 }
461 items["build-state"] = moves.Check{OK: true, Reason: "the request record is in the build state the replica prefix carries"}
462 } else {
463 items["build-state"] = moves.Check{OK: true, Reason: "this box keeps no request records; the build state travels as the replica prefix holds it"}
464 }
465 receipts := lease.Receipts{Bucket: bucket, Key: activator.MoveReceiptsKey(host)}
466 round, err := lease.MirrorPrefix(ctx, mirror, bucket, srcPrefix, tgt.DataBucket, dstPrefix, receipts)
467 if err != nil {
468 return seal, err
469 }
470 if round.Deferred > 0 {
471 return seal, fmt.Errorf("final mirror round: %d object(s) changed at the source during the copy", round.Deferred)
472 }
473 source, err := mirror.List(ctx, bucket, srcPrefix)
474 if err != nil {
475 return seal, fmt.Errorf("list the replica: %w", err)
476 }
477 listed, err := mirror.List(ctx, tgt.DataBucket, dstPrefix)
478 if err != nil {
479 return seal, fmt.Errorf("list the staged copy: %w", err)
480 }
481 staged := map[string]moves.StagedObject{}
482 for k, o := range listed {
483 staged[strings.TrimPrefix(k, dstPrefix)] = moves.StagedObject{Size: o.Size, ETag: o.ETag}
484 }
485 if err := sameListing(source, srcPrefix, staged); err != nil {
486 return seal, fmt.Errorf("the staged copy is not the replica: %w", err)
487 }
488 if replicaPosition(listed, dstPrefix) != final {
489 return seal, fmt.Errorf("the staged copy's newest TXID is %d, the replica's %d", replicaPosition(listed, dstPrefix), final)
490 }
491 items["db"] = moves.Check{OK: true, Reason: fmt.Sprintf("%d objects staged at %s/%s, newest TXID %d", len(staged), tgt.DataBucket, dstPrefix, final)}
492 if err := w.sealFault("mirror"); err != nil {
493 return seal, err
494 }
495
496 // 6. The files and <host>.env, sealed, beside the staging lane.
497 prefix := moves.OfflineArchivePrefix(host, m.ID)
498 files, envPath, configPath, stray, err := w.offlineFilesOf(host, rt)
499 if err != nil {
500 return seal, fmt.Errorf("read what %s keeps: %w", host, err)
501 }
502 if len(stray) > 0 {
503 sort.Strings(stray)
504 // What the app keeps there is the app's data, in a place it reads
505 // from: moving it into the files directory changes that place, and
506 // removing it loses it. So the refusal offers neither (MV-O76).
507 return seal, &sealRefusal{item: "files", Reason: fmt.Sprintf("%s keeps %s beside its files directory, and this move can carry only the files directory. see the offline move runbook", host, strings.Join(stray, ", "))}
508 }
509 a, err := w.offlineArchive(ctx, rec, mirror, tgt.DataBucket, prefix, files, envPath)
510 if err != nil {
511 return seal, err
512 }
513 items["files"] = moves.Check{OK: true, Reason: fmt.Sprintf("%d files, %d bytes, sealed at %s%s", a.entries, a.bytes, prefix, offlineArchiveSealed)}
514 if err := w.sealFault("archive"); err != nil {
515 return seal, err
516 }
517
518 // 7. The timers.
519 if cfg.Registry == nil {
520 return seal, errors.New("no registry on this box")
521 }
522 rows, err := cfg.Registry.TimersFor(host)
523 if err != nil {
524 return seal, fmt.Errorf("read the timers: %w", err)
525 }
526 active := moves.ActiveTimers(rows)
527 timersBody, err := moves.TimersSnapshot{V: 1, Host: host, Rows: active, TakenAt: now}.Encode()
528 if err != nil {
529 return seal, err
530 }
531 if err := mirror.Put(ctx, tgt.DataBucket, prefix+offlineTimersObject, timersBody); err != nil {
532 return seal, fmt.Errorf("put the timers: %w", err)
533 }
534 timersDigest := moves.TimersDigest(rows)
535 items["timers"] = moves.Check{OK: true, Reason: fmt.Sprintf("%d active timers at %s%s", len(active), prefix, offlineTimersObject)}
536 if err := w.sealFault("timers"); err != nil {
537 return seal, err
538 }
539
540 // 8. The env file, its effective instance key, and the env pin.
541 envSHA, keySHA, err := offlineEnvDigests(envPath, configPath, a.envSHA)
542 if err != nil {
543 return seal, err
544 }
545 // The digest itself is the seal's EnvSHA256, not part of the reason.
546 items["env"] = moves.Check{OK: true, Reason: "absent on the source, so none is carried"}
547 if envSHA != "" {
548 items["env"] = moves.Check{OK: true, Reason: "carried in the archive"}
549 }
550 if cfg.LaunchLayers == nil {
551 return seal, &sealRefusal{item: "env-pin", Reason: "this box has no exec backend to compose the launch environment"}
552 }
553 if cfg.SecretsKey == "" {
554 return seal, &sealRefusal{item: "env-pin", Reason: "this box holds no CARLOS_SECRETS_KEY to key the env report"}
555 }
556 env, instance, _, err := cfg.LaunchLayers(*rt)
557 if err != nil {
558 return seal, fmt.Errorf("compose the launch environment: %w", err)
559 }
560 mac, err := moves.ReportKeyFromSecrets(cfg.SecretsKey)
561 if err != nil {
562 return seal, &sealRefusal{item: "env-pin", Reason: err.Error()}
563 }
564 envReport := moves.ReportLaunch(mac, env, instance)
565 envReportBody, err := json.Marshal(envReport)
566 if err != nil {
567 return seal, err
568 }
569 if err := mirror.Put(ctx, tgt.DataBucket, prefix+offlineEnvReportObject, envReportBody); err != nil {
570 return seal, fmt.Errorf("put the env report: %w", err)
571 }
572 items["env-pin"] = moves.Check{OK: true, Reason: "launch environment digest " + envReport.Digest}
573 if err := w.sealFault("env"); err != nil {
574 return seal, err
575 }
576
577 // 9. The build, the replication rung and the next due time.
578 if cfg.LiveVersion == nil {
579 return seal, errors.New("no version store on this box")
580 }
581 version, err := cfg.LiveVersion(host)
582 if err != nil {
583 return seal, fmt.Errorf("read the live build: %w", err)
584 }
585 if version == "" {
586 return seal, &sealRefusal{item: "release", Reason: fmt.Sprintf("no build is live for %s on this box", host)}
587 }
588 items["release"] = moves.Check{OK: true, Reason: "live build " + version}
589 items["replication"] = moves.Check{OK: true, Reason: fmt.Sprintf("replication %q", rt.Replication)}
590 items["next-due"] = moves.Check{OK: true, Reason: fmt.Sprintf("next due %d", rt.NextDue)}
591
592 // The tombstone must still be this move's when the seal is written:
593 // the position it records was read under it. One lost before the seal
594 // is taken again on the next pass, which seals afresh.
595 st, err := w.offlineLock(ctx, host)
596 if err != nil {
597 return seal, err
598 }
599 if !st.mine(m.ID) {
600 return seal, fmt.Errorf("the lease is %s, not this move's tombstone; the next pass takes it again", st)
601 }
602 seal = moves.OfflineReport{
603 V: moves.OfflineReportVersion, Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer, Phase: moves.OfflinePhaseSealed,
604 At: now, Items: items, TombstoneOwner: owner,
605 FinalTXID: final, Staged: staged,
606 FilesPlain: a.plain, FilesSealed: a.sealed, FilesBytes: a.bytes, FilesEntries: a.entries,
607 EnvSHA256: envSHA, KeySHA256: keySHA, EnvDigest: envReport.Digest,
608 Release: version, TimersDigest: timersDigest, Replication: rt.Replication, NextDue: rt.NextDue,
609 }
610 if err := moves.ValidateOfflineSeal(seal, host, m.ID); err != nil {
611 return seal, err
612 }
613 return seal, nil
614}
615
616// sameListing reports whether staged (keyed relative to the staging
617// prefix) holds exactly the source's objects under srcPrefix, lock.json
618// aside (the mirror never copies it), size for size. ETags may differ: a
619// multipart source's copy gets a new one (lease.Receipts).
620func sameListing(source map[string]lease.MirrorObject, srcPrefix string, staged map[string]moves.StagedObject) error {
621 want := map[string]int64{}
622 for k, o := range source {
623 rel := strings.TrimPrefix(k, srcPrefix)
624 if rel == "lock.json" {
625 continue
626 }
627 want[rel] = o.Size
628 }
629 if len(want) != len(staged) {
630 return fmt.Errorf("%d objects at the source, %d staged", len(want), len(staged))
631 }
632 for rel, size := range want {
633 got, ok := staged[rel]
634 if !ok || got.Size != size {
635 return fmt.Errorf("%s: staged %v, source size %d", rel, got, size)
636 }
637 }
638 return nil
639}
640
641// offlineFilesOf is what the archive carries for the row's layout, and
642// what it cannot.
643//
644// The target lands the instance flat (the row is minted at flat paths), so
645// the archive's files root is the app's files directory as the flat layout
646// names it: <DataDir>/<host>, with <host>.env beside it. The layout pass
647// maps the flat data directory onto the nested instance directory name for
648// name (layout/extras.go: an app finds its files beside its database, and
649// nested the database is <instance dir>/<host>.db), so a nested row's
650// files directory is <instance dir>/<host>, read through the raw
651// directory as every agent read of it is, and its env file is
652// <instance dir>/<host>.env. Archiving the whole instance directory would
653// land the app's files one level down on the target (<DataDir>/<host>/
654// <host>/...), where the app would not find them.
655//
656// stray is every entry the app keeps beside its files directory, which
657// the flat files directory cannot carry: on a nested row every entry of
658// the instance directory that is neither the platform's
659// (layout.IsNestedPlatformName: the seed marker and the launch descriptors
660// included) nor <host>; on a flat row every entry of
661// the data directory the layout's naming rules give this host
662// (layout.FlatLeftovers; <host>-blobs is the live example) that is
663// neither. The seal refuses on any, rather than leave it behind.
664//
665// A host that never kept a files directory (an app that writes nothing of
666// its own) has none at the files path: the archive then carries the env
667// file alone, FilesPlain is the digest of that, and the landing extracts
668// an empty files directory.
669//
670// configPath is the rendered configuration beside the env file, which the
671// effective instance key may come from.
672func (w *movesWorker) offlineFilesOf(host string, rt *registry.Route) (files, envPath, configPath string, stray []string, err error) {
673 dd := w.cfg.DataDir
674 platform := func(name string) bool { return layout.IsNestedPlatformName(host, name) }
675 if rt != nil && provision.NestedPaths(dd, *rt) {
676 raw := layout.RawInstanceDir(dd, host)
677 ents, err := os.ReadDir(raw)
678 if err != nil && !errors.Is(err, os.ErrNotExist) {
679 return "", "", "", nil, err
680 }
681 for _, e := range ents {
682 if !platform(e.Name()) && e.Name() != host {
683 stray = append(stray, filepath.Join(raw, e.Name()))
684 }
685 }
686 return filepath.Join(raw, host), filepath.Join(raw, host+".env"), filepath.Join(raw, host+".config.env"), stray, nil
687 }
688 var hosts []string
689 if w.cfg.Registry != nil {
690 rows, err := w.cfg.Registry.List()
691 if err != nil {
692 return "", "", "", nil, err
693 }
694 for _, r := range rows {
695 hosts = append(hosts, r.Host)
696 }
697 }
698 for _, p := range layout.FlatLeftovers(dd, host, hosts) {
699 if name := filepath.Base(p); !platform(name) && name != host {
700 stray = append(stray, p)
701 }
702 }
703 return filepath.Join(dd, host), filepath.Join(dd, host+".env"), filepath.Join(dd, host+".config.env"), stray, nil
704}
705
706// sealedArchive is what the seal records of the archive.
707type sealedArchive struct {
708 plain, sealed string
709 bytes int64
710 entries int
711 // envSHA is the env entry's digest as the archive recorded it
712 // ("sha256:<hex>"), "" when the archive carries no env.
713 envSHA string
714}
715
716// offlineArchive builds the archive of files and envPath into a scratch
717// directory on the data disk, seals it under the deployment's config key
718// bound to the record's account and app, and puts it and its manifest
719// under prefix in the target bucket. A file the archive refuses is a
720// refusal of the files item, never a partial archive; a file that changed
721// while it was read is retried. The files directory holds nothing of the
722// platform's but the files marker, which every build leaves out, so no
723// filter is needed (archive.Build is BuildFiltered with none).
724func (w *movesWorker) offlineArchive(ctx context.Context, rec provision.Record, mirror lease.MirrorStore, bucket, prefix, files, envPath string) (sealedArchive, error) {
725 cfg := w.cfg
726 var out sealedArchive
727 if cfg.ConfigPub == nil {
728 return out, &sealRefusal{item: "files", Reason: "this box holds no CARLOS_SECRETS_KEY to seal the archive under"}
729 }
730 dir := moves.OfflineParkDir(cfg.DataDir, rec.Host, rec.Move.ID, moves.OfflineRoleSealer)
731 if dir == "" {
732 return out, fmt.Errorf("no scratch directory for %s under %q", rec.Host, cfg.DataDir)
733 }
734 if err := os.MkdirAll(dir, 0o700); err != nil {
735 return out, err
736 }
737 tmp, err := os.MkdirTemp(dir, "archive-")
738 if err != nil {
739 return out, err
740 }
741 defer os.RemoveAll(tmp)
742 built, err := os.Create(filepath.Join(tmp, "archive.tar.zst"))
743 if err != nil {
744 return out, err
745 }
746 defer built.Close()
747 plain, entries, size, err := archive.Build(files, envPath, built)
748 switch {
749 case errors.Is(err, archive.ErrChanged):
750 return out, fmt.Errorf("archive the files: %w; built again next pass", err)
751 case err != nil:
752 return out, &sealRefusal{item: "files", Reason: err.Error()}
753 }
754 if _, err := built.Seek(0, io.SeekStart); err != nil {
755 return out, err
756 }
757 sealedFile, err := os.Create(filepath.Join(tmp, "archive.sealed"))
758 if err != nil {
759 return out, err
760 }
761 defer sealedFile.Close()
762 dataKey, sealedSHA, err := archive.Seal(built, cfg.ConfigPub, appconfig.Binding{Account: rec.Account, App: rec.App}, sealedFile)
763 if err != nil {
764 return out, fmt.Errorf("seal the archive: %w", err)
765 }
766 manifest, err := json.Marshal(archive.Manifest{V: archive.ManifestVersion, Op: rec.Move.ID, Plain: plain, Sealed: sealedSHA, Bytes: size,
767 Entries: entries, SealedDataKey: dataKey, Chunk: archive.ChunkSize})
768 if err != nil {
769 return out, err
770 }
771 body, err := os.ReadFile(sealedFile.Name())
772 if err != nil {
773 return out, err
774 }
775 if err := mirror.Put(ctx, bucket, prefix+offlineArchiveSealed, body); err != nil {
776 return out, fmt.Errorf("put the sealed archive: %w", err)
777 }
778 if err := mirror.Put(ctx, bucket, prefix+offlineArchiveManifest, manifest); err != nil {
779 return out, fmt.Errorf("put the archive manifest: %w", err)
780 }
781 out = sealedArchive{plain: plain, sealed: sealedSHA, bytes: size, entries: len(entries)}
782 for _, e := range entries {
783 if e.Path == archive.EnvEntry {
784 out.envSHA = e.SHA256
785 }
786 }
787 return out, nil
788}
789
790// readOwnFile reads a regular file without following a link, as the agent
791// reads every file a nested tenant can write: exists false when there is
792// none.
793func readOwnFile(path string) (body []byte, exists bool, err error) {
794 info, err := os.Lstat(path)
795 if errors.Is(err, os.ErrNotExist) {
796 return nil, false, nil
797 }
798 if err != nil {
799 return nil, false, err
800 }
801 if !info.Mode().IsRegular() {
802 return nil, true, fmt.Errorf("%s is not a regular file", path)
803 }
804 f, err := os.Open(path)
805 if err != nil {
806 return nil, true, err
807 }
808 defer f.Close()
809 got, err := f.Stat()
810 if err != nil {
811 return nil, true, err
812 }
813 if !os.SameFile(info, got) {
814 return nil, true, fmt.Errorf("%s changed while it was read", path)
815 }
816 body, err = io.ReadAll(io.LimitReader(f, instancekey.EnvReadLimit+1))
817 if err != nil {
818 return nil, true, err
819 }
820 if len(body) > instancekey.EnvReadLimit {
821 return nil, true, fmt.Errorf("%s is larger than an env file", path)
822 }
823 return body, true, nil
824}
825
826// offlineEnvDigests is the env item's two values: the lowercase hex sha256
827// of <host>.env's bytes ("" when the source has none), and the lowercase
828// hex sha256 of the instance key a launch hands the app
829// (instancekey.Effective over the rendered configuration and the own file,
830// instancekey.Digest; "" when neither supplies one). archived is the env
831// entry's digest as the archive recorded it ("sha256:<hex>", or "" for no
832// env entry); the file read here must be the one archived.
833func offlineEnvDigests(envPath, configPath, archived string) (envSHA, keySHA string, err error) {
834 own, exists, err := readOwnFile(envPath)
835 if err != nil {
836 return "", "", fmt.Errorf("read the env file: %w", err)
837 }
838 if exists {
839 sum := sha256.Sum256(own)
840 envSHA = hex.EncodeToString(sum[:])
841 }
842 if want := strings.TrimPrefix(archived, "sha256:"); want != envSHA {
843 return "", "", fmt.Errorf("the env file changed while it was archived (archived %q, now %q); sealed again next pass", want, envSHA)
844 }
845 config, _, err := readOwnFile(configPath)
846 if err != nil {
847 return "", "", fmt.Errorf("read the rendered configuration: %w", err)
848 }
849 if key, _ := instancekey.Effective(instancekey.ParseEnv(config), instancekey.ParseEnv(own)); key != "" {
850 keySHA = instancekey.Digest(key)
851 }
852 return envSHA, keySHA, nil
853}
854
855// offlineSealRecord reads seal.json, nil when it is not there.
856func (w *movesWorker) offlineSealRecord(ctx context.Context, s release.Store, rec provision.Record) ([]byte, error) {
857 key := moves.OfflineSealKey(rec.Host, rec.Move.ID)
858 raw, _, err := s.Get(ctx, key)
859 if errors.Is(err, os.ErrNotExist) {
860 return nil, nil
861 }
862 if err != nil {
863 return nil, fmt.Errorf("read %s: %w", key, err)
864 }
865 return raw, nil
866}
867
868// decodeSeal parses and checks the seal record; nil raw is no seal.
869func decodeSeal(raw []byte, host, id string) (*moves.OfflineReport, error) {
870 if raw == nil {
871 return nil, nil
872 }
873 var seal moves.OfflineReport
874 if err := json.Unmarshal(raw, &seal); err != nil {
875 return nil, fmt.Errorf("the seal record does not parse: %w", err)
876 }
877 if err := moves.ValidateOfflineSeal(seal, host, id); err != nil {
878 return nil, err
879 }
880 return &seal, nil
881}
882
883// offlineKeepStop is every pass after the seal, until the tombstone is
884// lifted: read the lease first; a tombstone that is not this move's any
885// more is a lost stop, never taken again. Then the source reports: one
886// written since the seal at a position past the sealed one is a lost stop
887// too (a write the stop did not hold off, which the staged copy lacks).
888// Only then is this move's tombstone re-asserted. In
889// offline-sealing (a seal written by a pass that crashed before the CAS)
890// it also makes the CAS. Then it rewrites the living sealer report: the
891// seal's values and items, the tombstone's owner and expiry, and At.
892func (w *movesWorker) offlineKeepStop(ctx context.Context, s release.Store, rec provision.Record, raw []byte) error {
893 cfg := w.cfg
894 m, host := rec.Move, rec.Host
895 seal, err := decodeSeal(raw, host, m.ID)
896 if err != nil {
897 return fmt.Errorf("offline move %s: %s: %w; it is never rewritten, so abort the move", m.ID, host, err)
898 }
899 if seal == nil && m.State == provision.OfflineSealing {
900 return fmt.Errorf("offline move %s: %s has no seal record", m.ID, host)
901 }
902 st, err := w.offlineLock(ctx, host)
903 if err != nil {
904 return err
905 }
906 if !st.mine(m.ID) {
907 return w.offlineLeaseLost(ctx, s, rec, seal, st)
908 }
909 if seal != nil {
910 if err := w.offlineCheckPositions(ctx, s, rec, seal); err != nil {
911 return err
912 }
913 }
914 bucket, loc, region, err := cfg.tombstoneAt(host)
915 if err != nil {
916 return err
917 }
918 if err := w.sealFault("reassert"); err != nil {
919 return err
920 }
921 if err := lease.Reassert(ctx, bucket, loc, region, moves.TombstoneOwner(m.ID), moves.TombstoneTTL); err != nil {
922 var held *lease.HeldError
923 if errors.As(err, &held) {
924 return w.offlineLeaseLost(ctx, s, rec, seal, tombState{owner: held.Owner, expires: held.ExpiresAt})
925 }
926 if errors.Is(err, lease.ErrTombstoneLost) {
927 // Gone between the read above and Reassert's own: the same
928 // lost stop, and Reassert wrote nothing. Read it again for
929 // what it is now (absent, when even that read fails).
930 now, rerr := w.offlineLock(ctx, host)
931 if rerr != nil {
932 now = tombState{}
933 }
934 return w.offlineLeaseLost(ctx, s, rec, seal, now)
935 }
936 return fmt.Errorf("offline move %s: re-assert the tombstone of %s: %w", m.ID, host, err)
937 }
938 if st, err = w.offlineLock(ctx, host); err != nil {
939 return err
940 }
941 if !st.mine(m.ID) {
942 return w.offlineLeaseLost(ctx, s, rec, seal, st)
943 }
944 if m.State == provision.OfflineSealing {
945 src := provision.MoveSource{
946 FinalTXID: seal.FinalTXID, Replication: seal.Replication, NextDue: seal.NextDue,
947 Files: &provision.MoveFilesDigest{Plain: seal.FilesPlain, Sealed: seal.FilesSealed, Bytes: seal.FilesBytes, Entries: seal.FilesEntries},
948 TimersSHA256: seal.TimersDigest, Release: seal.Release, EnvDigest: seal.EnvDigest,
949 Evidence: moves.OfflineSealEvidence(host, m.ID, raw),
950 }
951 _, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, host, provision.OfflineSealedBy(m.ID, cfg.Box, src))
952 switch {
953 case errors.Is(err, provision.ErrMoveConflict):
954 log.Printf("moves: %s: offline move %s: the record moved on before the seal's CAS: %v", host, m.ID, err)
955 case err != nil:
956 return fmt.Errorf("offline move %s: record the seal of %s: %w", m.ID, host, err)
957 default:
958 nudgeSerial(ctx, s)
959 log.Printf("moves: %s: offline move %s: sealed", host, m.ID)
960 }
961 if err := w.sealFault("cas"); err != nil {
962 return err
963 }
964 }
965 living := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer}
966 if seal != nil {
967 living = *seal
968 }
969 living.Phase = moves.OfflinePhaseSealed
970 living.At = cfg.now().UTC()
971 living.TombstoneOwner = st.owner
972 living.TombstoneUntil = st.expires.UTC()
973 return writeOfflineReport(ctx, s, living)
974}
975
976// offlineSealerRefresh is the sealer's half of the report refresh
977// (offlineReassert), from offline-sealed to offline-landed: offlineKeepStop's
978// checks without its write to the lease. It reads the seal and the lease, a
979// tombstone that is not this move's live one is the lost stop it always is,
980// it checks the source positions against the seal, and it rewrites the
981// living report with the tombstone's owner and expiry exactly as read. It
982// never re-asserts the tombstone (fix round 1, Astra's finding 2: a refresh
983// every 15 s through offlineKeepStop pushed the expiry out seven days each
984// time); the sealer's ordinary pass keeps doing that, once a pass.
985func (w *movesWorker) offlineSealerRefresh(ctx context.Context, s release.Store, rec provision.Record) error {
986 cfg := w.cfg
987 m, host := rec.Move, rec.Host
988 if !m.Offline() || cfg.Box == "" || m.Sealer != cfg.Box {
989 return nil
990 }
991 switch m.State {
992 case provision.OfflineSealed, provision.OfflineLanding, provision.OfflineLanded:
993 default:
994 return nil
995 }
996 raw, err := w.offlineSealRecord(ctx, s, rec)
997 if err != nil {
998 return err
999 }
1000 seal, err := decodeSeal(raw, host, m.ID)
1001 if err != nil {
1002 return fmt.Errorf("offline move %s: %s: %w; it is never rewritten, so abort the move", m.ID, host, err)
1003 }
1004 st, err := w.offlineLock(ctx, host)
1005 if err != nil {
1006 return err
1007 }
1008 if !st.mine(m.ID) {
1009 return w.offlineLeaseLost(ctx, s, rec, seal, st)
1010 }
1011 if seal != nil {
1012 if err := w.offlineCheckPositions(ctx, s, rec, seal); err != nil {
1013 return err
1014 }
1015 }
1016 living := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer}
1017 if seal != nil {
1018 living = *seal
1019 }
1020 living.Phase = moves.OfflinePhaseSealed
1021 living.At = cfg.now().UTC()
1022 living.TombstoneOwner = st.owner
1023 living.TombstoneUntil = st.expires.UTC()
1024 return writeOfflineReport(ctx, s, living)
1025}
1026
1027// offlineCheckPositions is the backstop behind the holds and the fence:
1028// every source report written since the seal (at or after its At) must be
1029// at or below the sealed final position. One past it is a box that wrote
1030// what the staged copy does not hold, so the sealed position is no longer
1031// the instance's: a lost stop, in the db-tail item.
1032func (w *movesWorker) offlineCheckPositions(ctx context.Context, s release.Store, rec provision.Record, seal *moves.OfflineReport) error {
1033 reports, err := readOfflineReports(ctx, s, rec.Host, rec.Move.ID, moves.OfflineRoleSource)
1034 if err != nil {
1035 return fmt.Errorf("read the source reports: %w", err)
1036 }
1037 boxes := make([]string, 0, len(reports))
1038 for b := range reports {
1039 boxes = append(boxes, b)
1040 }
1041 sort.Strings(boxes)
1042 for _, b := range boxes {
1043 r := reports[b]
1044 if r.At.Before(seal.At) || r.LocalTXID <= seal.FinalTXID {
1045 continue
1046 }
1047 return w.offlineLostStop(ctx, s, rec, seal, "db-tail", offlinePastSealCause(b, r.LocalTXID, seal.FinalTXID))
1048 }
1049 return nil
1050}
1051
1052// offlineLeaseLost is the lost stop of a tombstone that is absent,
1053// expired, lifted or someone else's, in the lease item.
1054func (w *movesWorker) offlineLeaseLost(ctx context.Context, s release.Store, rec provision.Record, seal *moves.OfflineReport, st tombState) error {
1055 return w.offlineLostStop(ctx, s, rec, seal, "lease", offlineLeaseCause(st))
1056}
1057
1058// offlineLeaseCause and offlinePastSealCause are the two causes of a lost
1059// stop as its page and the failed item say them (MV-O69, MV-O70).
1060func offlineLeaseCause(st tombState) string {
1061 return fmt.Sprintf("the source's lease is %s, not this move's tombstone", offlineLeaseText(st))
1062}
1063
1064func offlinePastSealCause(box string, pos, final uint64) string {
1065 return fmt.Sprintf("%s reports TXID %d, past the sealed position %d", box, pos, final)
1066}
1067
1068// offlineLostStop reports a stop that no longer holds after the seal:
1069// cause says why, and item is the seal item it fails. The tombstone is
1070// never taken again (a wake may have written the replica in between, so
1071// the sealed position is no longer the source's), the living report says
1072// failed with that item not OK and no tombstone owner (so open refuses),
1073// and an operator is paged once per move.
1074func (w *movesWorker) offlineLostStop(ctx context.Context, s release.Store, rec provision.Record, seal *moves.OfflineReport, item, cause string) error {
1075 cfg := w.cfg
1076 m, host := rec.Move, rec.Host
1077 r := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer}
1078 if seal != nil {
1079 r = *seal
1080 }
1081 r.Phase = moves.OfflinePhaseFailed
1082 r.At = cfg.now().UTC()
1083 r.TombstoneOwner, r.TombstoneUntil = "", time.Time{}
1084 items := map[string]moves.Check{}
1085 for k, v := range r.Items {
1086 items[k] = v
1087 }
1088 items[item] = moves.Check{OK: false, Reason: "lost stop: " + cause}
1089 r.Items = items
1090 werr := writeOfflineReport(ctx, s, r)
1091 subject, body := offlineLostStopPage(host, m.ID, m.State, cause)
1092 w.firePage(ctx, host, m.ID, offlinePageLostStop, alerting.LevelIncident, subject, body)
1093 log.Printf("moves: %s: offline move %s: LOST STOP: %s", host, m.ID, cause)
1094 return errors.Join(fmt.Errorf("offline move %s: %s: lost stop: %s", m.ID, host, cause), werr)
1095}
1096
1097// offlineLostStopPage is the lost stop's page (copy rows MV-O66 to
1098// MV-O68; the item's reason is MV-O71): its subject, and a body for the move's state. Before open,
1099// abort puts the instance back with nothing lost, so the body says to
1100// abort. From open on, abort is refused (MV-O40) and the move is fixed
1101// forward by hand, so the body points to the status and the runbook.
1102func offlineLostStopPage(host, id, state, cause string) (subject, body string) {
1103 subject = fmt.Sprintf("offline move of %s lost its stop", host)
1104 if state == provision.OfflineOpen || state == provision.OfflineFinishing {
1105 body = fmt.Sprintf("The offline move %s of %s is %s, and its stop no longer holds: %s. It cannot be aborted once it is open. Read carlos move offline status --host %s and follow the offline move runbook.", id, host, state, cause, host)
1106 return subject, body
1107 }
1108 body = fmt.Sprintf("The offline move %s of %s is %s, and its stop no longer holds: %s. The move cannot go on. Read carlos move offline status --host %s and abort the move.", id, host, state, cause, host)
1109 return subject, body
1110}
1111
1112// offlineFinishLift is the sealer's step in offline-finishing. Until every
1113// box the seal named reports gone (it parked at open and reconcile has
1114// removed its row since), the stop is kept as before. Then the tombstone
1115// is lifted (only this move's, with the ETag read), the copy receipts are
1116// cleared and the report says lifted. A lease that is already free (lifted
1117// by an earlier pass) is left as it is; one someone else holds live is a
1118// lost stop.
1119func (w *movesWorker) offlineFinishLift(ctx context.Context, s release.Store, rec provision.Record) error {
1120 cfg := w.cfg
1121 m, host := rec.Move, rec.Host
1122 reports, err := readOfflineReports(ctx, s, host, m.ID, moves.OfflineRoleSource)
1123 if err != nil {
1124 return fmt.Errorf("read the source reports: %w", err)
1125 }
1126 gone := len(m.Boxes) > 0
1127 for _, b := range m.Boxes {
1128 if r, ok := reports[b]; !ok || r.Phase != moves.OfflinePhaseGone {
1129 gone = false
1130 }
1131 }
1132 raw, err := w.offlineSealRecord(ctx, s, rec)
1133 if err != nil {
1134 return err
1135 }
1136 if !gone {
1137 return w.offlineKeepStop(ctx, s, rec, raw)
1138 }
1139 st, err := w.offlineLock(ctx, host)
1140 if err != nil {
1141 return err
1142 }
1143 switch {
1144 case st.mine(m.ID):
1145 bucket, loc, region, err := cfg.tombstoneAt(host)
1146 if err != nil {
1147 return err
1148 }
1149 if _, err := lease.LiftTombstone(ctx, bucket, loc, region, func(owner string) bool { return owner == moves.TombstoneOwner(m.ID) }); err != nil {
1150 return fmt.Errorf("offline move %s: lift the tombstone of %s: %w", m.ID, host, err)
1151 }
1152 log.Printf("moves: %s: offline move %s: tombstone lifted", host, m.ID)
1153 case st.free():
1154 default:
1155 seal, _ := decodeSeal(raw, host, m.ID)
1156 return w.offlineLeaseLost(ctx, s, rec, seal, st)
1157 }
1158 if err := w.sealFault("lift"); err != nil {
1159 return err
1160 }
1161 own, err := w.offlineMirror(ctx, cfg.OwnBucketRegion)
1162 if err != nil {
1163 return err
1164 }
1165 clearOfflineReceipts(ctx, own, cfg.OwnBucket, host)
1166 r := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer, Phase: moves.OfflinePhaseLifted, At: cfg.now().UTC()}
1167 return writeOfflineReport(ctx, s, r)
1168}
1169
1170// clearOfflineReceipts is clearMoveReceipts as an ensure: receipts that are
1171// already gone are not deleted again, so a pass that repeats finish or
1172// revert writes nothing (on a versioned bucket every DELETE of an absent
1173// key leaves one more delete marker).
1174func clearOfflineReceipts(ctx context.Context, m lease.MirrorStore, bucket, host string) {
1175 rc := lease.Receipts{Bucket: bucket, Key: activator.MoveReceiptsKey(host)}
1176 if _, err := m.Get(ctx, rc.Bucket, rc.Key); errors.Is(err, lease.ErrMirrorNotFound) {
1177 return
1178 }
1179 clearMoveReceipts(ctx, m, rc, host)
1180}
1181
1182// offlineSealRevert is the sealer's step in offline-reverting: lift the
1183// tombstone if it is this move's (a lease anyone else holds, or none, is
1184// left alone), delete the two prefixes this move wrote in the target's
1185// bucket (the staged replica and the archive), clear the copy receipts,
1186// lift this move's fence (the source step lifts it too) and report
1187// reverted. Every part is an ensure.
1188func (w *movesWorker) offlineSealRevert(ctx context.Context, s release.Store, rec provision.Record) error {
1189 cfg := w.cfg
1190 m, host := rec.Move, rec.Host
1191 bucket, loc, region, err := cfg.tombstoneAt(host)
1192 if err != nil {
1193 return err
1194 }
1195 lifted, err := lease.LiftTombstone(ctx, bucket, loc, region, func(owner string) bool { return owner == moves.TombstoneOwner(m.ID) })
1196 var held *lease.HeldError
1197 switch {
1198 case err == nil:
1199 if lifted {
1200 log.Printf("moves: %s: offline move %s: tombstone lifted on abort", host, m.ID)
1201 }
1202 case errors.As(err, &held):
1203 w.notice(host+"#offline-revert-lease", "moves: %s: offline move %s: the lease is %s's, not this move's; left as it is", host, m.ID, held.Owner)
1204 default:
1205 return fmt.Errorf("offline move %s: lift the tombstone of %s: %w", m.ID, host, err)
1206 }
1207 if err := w.sealFault("revert-lift"); err != nil {
1208 return err
1209 }
1210 tgt, err := w.offlineTarget(ctx, s, m)
1211 if err != nil {
1212 return err
1213 }
1214 mirror, err := w.offlineMirror(ctx, tgt.DataBucketRegion)
1215 if err != nil {
1216 return err
1217 }
1218 for _, prefix := range []string{activator.MovesPrefix(host), moves.OfflineArchivePrefix(host, m.ID)} {
1219 if prefix == "" || prefix == "moves/" {
1220 return fmt.Errorf("offline move %s: no staging prefix for %s", m.ID, host)
1221 }
1222 if err := lease.DeletePrefix(ctx, mirror, tgt.DataBucket, prefix); err != nil {
1223 return fmt.Errorf("offline move %s: delete %s/%s: %w", m.ID, tgt.DataBucket, prefix, err)
1224 }
1225 }
1226 clearOfflineReceipts(ctx, mirror, bucket, host)
1227 w.offlineLiftFence(host, m)
1228 r := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer, Phase: moves.OfflinePhaseReverted, At: cfg.now().UTC()}
1229 return writeOfflineReport(ctx, s, r)
1230}
1231