carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package moves
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "fmt"
7 "path/filepath"
8 "time"
9
10 "github.com/carlosframework/platform/internal/provision"
11)
12
13// The offline operator move's contract (docs/superpowers/plans/2026-10-20-
14// offline-operator-move.md, Task 1): the dependency manifest, the report each
15// box writes at each step, and the keys, owners and paths every task shares.
16// Nothing here writes anything. The record side is provision/offline_move.go.
17
18// OfflineItem is one row of the dependency list: a thing the instance needs
19// that the move must carry, rebuild or consciously leave behind. ID names the
20// item in a report's Items. Disposition says what the move does with it.
21//
22// Owner is the step that proves the item where it must end up, and the
23// door that step gates requires that step's OK:
24//
25// door a door refuses the move up front (nothing to check later)
26// seal the sealer's report proves it (the source's own state)
27// landing the target's landing report proves it; the open door requires it
28// none no report proves it; the Disposition says why
29//
30// Source is the step that reports the expected value on the source side: the
31// sealer's report ("seal"), which the flip door requires, or "" when the item
32// has no source-side value. A carried item is Owner landing and Source seal:
33// the sealer records what it copied, the target proves it arrived equal.
34// Exactly one Owner per row; Source is independent of it.
35type OfflineItem struct{ ID, Item, Disposition, Owner, Source string }
36
37// The owners and sources.
38const (
39 OfflineOwnerDoor = "door"
40 OfflineOwnerSeal = "seal"
41 OfflineOwnerLanding = "landing"
42 OfflineOwnerNone = "none"
43
44 OfflineSourceSeal = "seal"
45)
46
47// OfflineManifest is the plan's dependency list, one row per item. It is the
48// checklist: flip needs every Source "seal" item OK in the sealer's report,
49// open needs every Owner "landing" item OK in the target's, a report may
50// carry only items listed here, and TestOfflineManifestIsCovered fails if a
51// row has no owner. A Disposition begins with its kind (carried, rebuilt,
52// refused, parked, not carried) and a reason after the first colon.
53var OfflineManifest = []OfflineItem{
54 {"db", "SQLite DB at its final position", "carried: instances/<host>/ is mirrored to the target's moves/<host>/ and promoted by the target's first wake after open", OfflineOwnerLanding, OfflineSourceSeal},
55 {"db-tail", "writes the source's stop failed to checkpoint", "carried: the sealer checkpoints first when a source report is ahead of the replica", OfflineOwnerSeal, OfflineSourceSeal},
56 {"build-state", "instances/<host>/build-state.json", "carried: inside the replica prefix, after CarryRequest", OfflineOwnerSeal, OfflineSourceSeal},
57 {"lease", "instances/<host>/lock.json", "not carried: the source's lock becomes the tombstone until finish; a stale tombstone of another move in the target bucket is lifted at landing", OfflineOwnerLanding, OfflineSourceSeal},
58 {"files", "flat <DataDir>/<host>/ and the nested instance directory minus platform artifacts", "carried: sealed archive at moves/@offline/<host>/<id>/files.sealed in the target bucket", OfflineOwnerLanding, OfflineSourceSeal},
59 {"env", "<host>.env: instance key, admin token and overrides", "carried: byte for byte inside the archive", OfflineOwnerLanding, OfflineSourceSeal},
60 {"key-adoption", "<host>.instance-key-adoption.json", "not carried: written only for claim launches; a copy on the target is parked", OfflineOwnerLanding, ""},
61 {"config", "<host>.config.env: the app's settings, store binding and secrets", "rebuilt: rendered on the target from the bucket bundle", OfflineOwnerLanding, ""},
62 {"env-pin", "the composed launch environment", "rebuilt: app settings must be equal on both rows; box settings are not compared", OfflineOwnerLanding, OfflineSourceSeal},
63 {"release", "the build the source runs", "rebuilt: not copied, ordinary adoption stages the channel's build for the ready row after open", OfflineOwnerLanding, OfflineSourceSeal},
64 {"store", "object store bucket and its credentials", "not carried: the bucket stays where it is and its binding travels in config, so env-pin covers it", OfflineOwnerNone, ""},
65 {"timers", "timer rows, once-timers the app armed included", "carried: a timers snapshot beside the archive, replaced on the held row", OfflineOwnerLanding, OfflineSourceSeal},
66 {"next-due", "the row's NextDue", "carried: in the journal's source baselines", OfflineOwnerLanding, OfflineSourceSeal},
67 {"replication", "the row's replication rung", "carried: in the journal's source baselines", OfflineOwnerLanding, OfflineSourceSeal},
68 {"row", "the registry row", "rebuilt: reconcile mints it from the record, held at flat paths", OfflineOwnerLanding, ""},
69 {"routing", "edges file, backhaul, certificates, derived aliases", "not carried: produced by the existing passes once the row is ready, and the runbook confirms a certificate path before the move", OfflineOwnerNone, ""},
70 {"source-retire", "everything the source box keeps for the host after open", "parked: renamed, never deleted, and listed path by path in the source's parked report", OfflineOwnerNone, ""},
71 {"sidecar", "<host>-sidecar.env", "refused: a source report that sees one fails the seal", OfflineOwnerSeal, OfflineSourceSeal},
72 {"restore-verify", "control/restore-verify.json entry", "not carried: the next drill writes a fresh verdict, and until then the old one describes the source copy", OfflineOwnerNone, ""},
73 {"warm-cache", "local DB, meta dirs, files and env a box holds for the host", "parked: renamed under .offline-moves on the source after open and on a target that already has them", OfflineOwnerLanding, ""},
74 {"receipts", "move-receipts/<host>.json in the source bucket", "not carried: cleared at finish, and self-checking", OfflineOwnerNone, ""},
75 {"class", "kind, addr, fleet, pool, hibernate, replica, replicas, steering, unit, seed and open run", "refused: the start door and the start transition refuse every class the move cannot carry", OfflineOwnerDoor, ""},
76}
77
78// OfflineItemOwner is the owner of manifest item id.
79func OfflineItemOwner(id string) (owner string, ok bool) {
80 for _, it := range OfflineManifest {
81 if it.ID == id {
82 return it.Owner, true
83 }
84 }
85 return "", false
86}
87
88func offlineItem(id string) (OfflineItem, bool) {
89 for _, it := range OfflineManifest {
90 if it.ID == id {
91 return it, true
92 }
93 }
94 return OfflineItem{}, false
95}
96
97// OfflineOwned lists the manifest items an owner decides, in manifest order.
98func OfflineOwned(owner string) []OfflineItem {
99 var out []OfflineItem
100 for _, it := range OfflineManifest {
101 if it.Owner == owner {
102 out = append(out, it)
103 }
104 }
105 return out
106}
107
108// OfflineSourced lists the manifest items whose expected value the source
109// side reports (Source "seal"), in manifest order.
110func OfflineSourced() []OfflineItem {
111 var out []OfflineItem
112 for _, it := range OfflineManifest {
113 if it.Source == OfflineSourceSeal {
114 out = append(out, it)
115 }
116 }
117 return out
118}
119
120// The roles a report names: a source-region box that stopped the host, the
121// one source box that seals, the target box that lands, and the one source
122// box that pre-copies the replica while the move is offline-staging (the
123// stager, which the stage door picks with OfflineStager and pins on the
124// record as move.stager).
125const (
126 OfflineRoleSource = "source"
127 OfflineRoleSealer = "sealer"
128 OfflineRoleTarget = "target"
129 OfflineRoleStager = "stager"
130)
131
132// The phases a report names.
133const (
134 OfflinePhaseStopped = "stopped"
135 OfflinePhaseSealed = "sealed"
136 OfflinePhaseLanded = "landed"
137 OfflinePhaseOpened = "opened"
138 OfflinePhaseParked = "parked"
139 OfflinePhaseGone = "gone"
140 OfflinePhaseLifted = "lifted"
141 OfflinePhaseReverted = "reverted"
142 OfflinePhaseFailed = "failed"
143 // OfflinePhaseStaged is a stager's report after a pre-copy round.
144 OfflinePhaseStaged = "staged"
145)
146
147// OfflineReportVersion is OfflineReport.V.
148const OfflineReportVersion = 1
149
150// StagedObject is one object of the staged replica prefix: its size and ETag,
151// keyed in OfflineReport.Staged by the key relative to the prefix.
152type StagedObject struct {
153 Size int64 `json:"size"`
154 ETag string `json:"etag"`
155}
156
157// RetiredPath is one line of a source's parked report: a path the box kept
158// for the host, what was done to it (parked, absent or left) and why.
159type RetiredPath struct {
160 Path string `json:"path"`
161 Action string `json:"action"`
162 Reason string `json:"reason,omitempty"`
163}
164
165// The actions a RetiredPath may name.
166const (
167 RetiredParked = "parked"
168 RetiredAbsent = "absent"
169 RetiredLeft = "left"
170)
171
172// OfflineReport is what a box writes at control/offline-moves/ at each step
173// of an offline move (OfflineReportKey). It is evidence, never a decision: the
174// doors read it and decide. One struct serves every role, and a role fills
175// only the fields it observed.
176type OfflineReport struct {
177 V int `json:"v"`
178 Op string `json:"op"`
179 Host string `json:"host"`
180 Box string `json:"box"`
181 // Role is source, sealer or target; Phase is stopped, sealed, landed,
182 // opened, parked, gone, lifted, reverted or failed.
183 Role string `json:"role"`
184 Phase string `json:"phase"`
185 Items map[string]Check `json:"items,omitempty"`
186 At time.Time `json:"at"`
187
188 Held bool `json:"held"`
189 Fenced bool `json:"fenced"`
190 Quiesced bool `json:"quiesced"`
191 // LocalDB: the DB file or the activator's litestream meta dir is present.
192 LocalDB bool `json:"local_db"`
193 Files bool `json:"files"`
194 Env bool `json:"env"`
195 Sidecar bool `json:"sidecar"`
196 RowPresent bool `json:"row_present"`
197 LocalTXID uint64 `json:"local_txid,omitempty"`
198
199 FinalTXID uint64 `json:"final_txid,omitempty"`
200 Staged map[string]StagedObject `json:"staged,omitempty"`
201 FilesPlain string `json:"files_plain,omitempty"`
202 FilesSealed string `json:"files_sealed,omitempty"`
203 FilesBytes int64 `json:"files_bytes,omitempty"`
204 FilesEntries int `json:"files_entries,omitempty"`
205
206 EnvSHA256 string `json:"env_sha256,omitempty"`
207 KeySHA256 string `json:"key_sha256,omitempty"`
208 EnvDigest string `json:"env_digest,omitempty"`
209 Release string `json:"release,omitempty"`
210 TimersDigest string `json:"timers_digest,omitempty"`
211 Replication string `json:"replication,omitempty"`
212 NextDue int64 `json:"next_due,omitempty"`
213
214 TombstoneOwner string `json:"tombstone_owner,omitempty"`
215 TombstoneUntil time.Time `json:"tombstone_until,omitzero"`
216
217 Procs int `json:"procs,omitempty"`
218 LayoutJournal bool `json:"layout_journal,omitempty"`
219
220 // Retired is the parked report's inventory of what the source kept.
221 Retired []RetiredPath `json:"retired,omitempty"`
222
223 // The stager's progress, rewritten after every pre-copy round while
224 // the move is offline-staging (At is when the round ended): the
225 // replica as the round's own listing after it found it, lock.json
226 // aside; how much of it the staged copy holds, key and size for key
227 // and size; and what the round itself copied. A failed round keeps
228 // the last progress it had and says why in StageError, a box
229 // diagnostic and never copy.
230 ReplicaObjects int `json:"replica_objects,omitempty"`
231 ReplicaBytes int64 `json:"replica_bytes,omitempty"`
232 StagedObjects int `json:"staged_objects,omitempty"`
233 StagedBytes int64 `json:"staged_bytes,omitempty"`
234 RoundObjects int `json:"round_objects,omitempty"`
235 RoundBytes int64 `json:"round_bytes,omitempty"`
236 StageError string `json:"stage_error,omitempty"`
237 // StageRequest is the record's staged_at the round answered: one round
238 // per stage request (provision.OfflineStage, OfflineRestage), and a
239 // stager runs none while its report already answers the record's.
240 StageRequest time.Time `json:"stage_request,omitzero"`
241}
242
243var (
244 offlineRoles = map[string]bool{OfflineRoleSource: true, OfflineRoleSealer: true, OfflineRoleTarget: true, OfflineRoleStager: true}
245 offlinePhases = map[string]bool{OfflinePhaseStopped: true, OfflinePhaseSealed: true, OfflinePhaseLanded: true, OfflinePhaseOpened: true,
246 OfflinePhaseParked: true, OfflinePhaseGone: true, OfflinePhaseLifted: true, OfflinePhaseReverted: true, OfflinePhaseFailed: true,
247 OfflinePhaseStaged: true}
248)
249
250// offlineCarries reports whether a role's report may carry the item. A
251// stager's report carries none: the pre-copy decides nothing the doors
252// check, and the seal's own round proves the db item.
253func offlineCarries(role string, it OfflineItem) bool {
254 switch role {
255 case OfflineRoleSource, OfflineRoleSealer:
256 return it.Source == OfflineSourceSeal
257 case OfflineRoleTarget:
258 return it.Owner == OfflineOwnerLanding
259 }
260 return false
261}
262
263// ValidateOfflineReport reports why a report cannot be trusted as a report: a
264// wrong version, an unknown role or phase, an unusable key part, or an item
265// the manifest does not list or that this role does not carry. A reader
266// calls it before it reads anything else.
267func ValidateOfflineReport(r OfflineReport) error {
268 if r.V != OfflineReportVersion {
269 return fmt.Errorf("offline report: version %d, want %d", r.V, OfflineReportVersion)
270 }
271 if OfflineReportKey(r.Host, r.Op, r.Role, r.Box) == "" {
272 return fmt.Errorf("offline report: host, op, role or box is not usable")
273 }
274 if !offlinePhases[r.Phase] {
275 return fmt.Errorf("offline report: unknown phase %q", r.Phase)
276 }
277 for id := range r.Items {
278 it, ok := offlineItem(id)
279 if !ok {
280 return fmt.Errorf("offline report: item %q is not in the manifest", id)
281 }
282 if !offlineCarries(r.Role, it) {
283 return fmt.Errorf("offline report: item %q (owner %s, source %q) is not carried by a %s report", id, it.Owner, it.Source, r.Role)
284 }
285 }
286 return nil
287}
288
289// ItemsOK says whether the report holds an OK Check for every manifest
290// item the owner decides, and names the ones that are missing or not OK, in
291// manifest order. The open door uses it for owner landing; flip checks the
292// sealer's source values with SourceItemsOK.
293func (r OfflineReport) ItemsOK(owner string) (ok bool, bad []string) {
294 for _, it := range OfflineOwned(owner) {
295 if c, found := r.Items[it.ID]; !found || !c.OK {
296 bad = append(bad, it.ID)
297 }
298 }
299 return len(bad) == 0, bad
300}
301
302// SourceItemsOK is ItemsOK for the flip door: whether the report holds an OK
303// Check for every item the source side reports (Source "seal"), and the ones
304// that are missing or not OK.
305func (r OfflineReport) SourceItemsOK() (ok bool, bad []string) {
306 for _, it := range OfflineSourced() {
307 if c, found := r.Items[it.ID]; !found || !c.OK {
308 bad = append(bad, it.ID)
309 }
310 }
311 return len(bad) == 0, bad
312}
313
314// offlineSegment reports a string fit to be one path or key segment: not
315// empty, not a dot name, and made only of letters, digits and _ . - so it can
316// never escape the directory or prefix it is joined under.
317func offlineSegment(s string) bool {
318 if s == "" || s == "." || s == ".." {
319 return false
320 }
321 for _, c := range s {
322 switch {
323 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '.', c == '-':
324 default:
325 return false
326 }
327 }
328 return true
329}
330
331// OfflineReportKey is where a box writes its report:
332// control/offline-moves/<host>/<op>/<role>/<box>.json. It is "" when host
333// fails provision.ValidHostShape, role is not source, sealer, target or
334// stager, or op
335// or box is not a plain name; a caller that gets "" has a bug and writes
336// nothing. The prefix is outside every create-only and no-delete prefix, so
337// a box may rewrite its own report.
338func OfflineReportKey(host, op, role, box string) string {
339 if !provision.ValidHostShape(host) || !offlineSegment(op) || !offlineRoles[role] || !offlineSegment(box) {
340 return ""
341 }
342 return "control/offline-moves/" + host + "/" + op + "/" + role + "/" + box + ".json"
343}
344
345// OfflineReportPrefix is the prefix a door lists to read every report of one
346// move, "" for a host that fails provision.ValidHostShape or an unusable op.
347func OfflineReportPrefix(host, op string) string {
348 if !provision.ValidHostShape(host) || !offlineSegment(op) {
349 return ""
350 }
351 return "control/offline-moves/" + host + "/" + op + "/"
352}
353
354// OfflineSealKey is where the sealer writes the seal, once and create-only:
355// control/offline-moves/<host>/<op>/seal.json. Unlike a report it is never
356// rewritten. It is an OfflineReport (role sealer, phase sealed) holding what
357// the sealer decided and recorded at the seal: the sealed position
358// (FinalTXID, Staged), the files and env digests, the env pin, the release,
359// the timers digest, the replication rung and next due, TombstoneOwner
360// (moved:<op>), and Items, an OK Check for every manifest item with Source
361// "seal". At is when it was sealed; TombstoneUntil is zero (the live
362// expiry is the sealer's per-pass report). The record's source.evidence is
363// this key and the sha256 of its bytes (OfflineSealEvidence). "" for a host
364// that fails provision.ValidHostShape or an unusable op.
365func OfflineSealKey(host, op string) string {
366 if !provision.ValidHostShape(host) || !offlineSegment(op) {
367 return ""
368 }
369 return "control/offline-moves/" + host + "/" + op + "/seal.json"
370}
371
372// OfflineSealEvidence is source.evidence for a seal written as raw: the seal
373// key, "@", and the hex sha256 of the bytes. "" for a bad host or op.
374func OfflineSealEvidence(host, op string, raw []byte) string {
375 key := OfflineSealKey(host, op)
376 if key == "" {
377 return ""
378 }
379 sum := sha256.Sum256(raw)
380 return key + "@" + hex.EncodeToString(sum[:])
381}
382
383// ValidateOfflineSeal reports why a seal cannot be trusted as the seal of op
384// on host: it is a valid sealer report of that move, phase sealed, that names
385// the move's tombstone owner and carries an OK Check for every item the
386// source side reports.
387func ValidateOfflineSeal(r OfflineReport, host, op string) error {
388 if err := ValidateOfflineReport(r); err != nil {
389 return err
390 }
391 if r.Host != host || r.Op != op || r.Role != OfflineRoleSealer || r.Phase != OfflinePhaseSealed {
392 return fmt.Errorf("offline seal: not the sealed report of this move")
393 }
394 if r.TombstoneOwner != TombstoneOwner(op) {
395 return fmt.Errorf("offline seal: tombstone owner %q, want %q", r.TombstoneOwner, TombstoneOwner(op))
396 }
397 if ok, bad := r.SourceItemsOK(); !ok {
398 return fmt.Errorf("offline seal: items not OK: %v", bad)
399 }
400 return nil
401}
402
403// OfflineStager is the stage door's choice of the box that pre-copies the
404// replica while a move is offline-staging: the lexically first of boxes,
405// the source region's eligible boxes as the fleet's edges files list them,
406// "" for none. The door pins it on the record (MoveState.Stager) and it
407// never changes for the move: a box that joins later never stages or
408// cleans for it, so exactly one box ever writes the staging prefix (Astra
409// round 1, finding 2).
410func OfflineStager(boxes []string) string {
411 stager := ""
412 for _, b := range boxes {
413 if b != "" && (stager == "" || b < stager) {
414 stager = b
415 }
416 }
417 return stager
418}
419
420// OfflineActiveKey is the one lock of the offline move: while it names a
421// host whose record still carries that move, no other offline move starts.
422const OfflineActiveKey = "control/offline-moves/active.json"
423
424// OfflineActive is the lock's body.
425type OfflineActive struct {
426 Host string `json:"host"`
427 Op string `json:"op"`
428 Account string `json:"account"`
429 App string `json:"app"`
430 At time.Time `json:"at"`
431}
432
433// OfflineArchivePrefix is where the move's sealed files archive and its
434// timers snapshot live in the target bucket: moves/@offline/<host>/<op>/. The
435// "@" cannot start a host, so it never collides with a host's own staging
436// prefix. "" for a host that fails provision.ValidHostShape or an unusable op.
437func OfflineArchivePrefix(host, op string) string {
438 if !provision.ValidHostShape(host) || !offlineSegment(op) {
439 return ""
440 }
441 return "moves/@offline/" + host + "/" + op + "/"
442}
443
444// TombstoneOwner is the owner string of the source lease's tombstone for a
445// move: "moved:" + op. The lease package acts only on this owner.
446func TombstoneOwner(op string) string { return "moved:" + op }
447
448// TombstoneTTL is the longest a tombstone is written for. The sealer
449// re-asserts it every pass and finish lifts it.
450const TombstoneTTL = 7 * 24 * time.Hour
451
452// The roles OfflineParkDir accepts besides the report roles: the target parks
453// what it finds before landing under "target-before", and the landing's
454// verify scratch under "verify" (Task 5). Nothing else is accepted; a numbered
455// suffix ("target-before-2") is the caller's, built by renaming within the
456// directory this returns, never passed through the helper.
457const (
458 OfflineParkTargetBefore = "target-before"
459 OfflineParkVerify = "verify"
460)
461
462var offlineParkRoles = map[string]bool{
463 OfflineRoleSource: true, OfflineRoleSealer: true, OfflineRoleTarget: true,
464 OfflineParkTargetBefore: true, OfflineParkVerify: true,
465}
466
467// OfflineParkDir is where a box parks what it renames for the host:
468// <dataDir>/.offline-moves/<host>/<op>/<role>/. role is source, sealer,
469// target, OfflineParkTargetBefore or OfflineParkVerify. "" when dataDir is
470// empty, host fails provision.ValidHostShape, role is none of those, or op is
471// not a plain name; a caller must not rename into "".
472func OfflineParkDir(dataDir, host, op, role string) string {
473 if dataDir == "" || !provision.ValidHostShape(host) || !offlineSegment(op) || !offlineParkRoles[role] {
474 return ""
475 }
476 return filepath.Join(dataDir, ".offline-moves", host, op, role)
477}
478
479// OfflineNamesOK is the validity the key helpers share, for a caller that
480// wants to ask before it builds anything.
481func OfflineNamesOK(host, op, role, box string) bool {
482 return OfflineReportKey(host, op, role, box) != ""
483}
484