| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "log" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strconv" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | "github.com/carlosframework/platform/internal/hostguard" |
| 16 | "github.com/carlosframework/platform/internal/hostwrite" |
| 17 | "github.com/carlosframework/platform/internal/moves" |
| 18 | "github.com/carlosframework/platform/internal/provision" |
| 19 | "github.com/carlosframework/platform/internal/registry" |
| 20 | "github.com/carlosframework/platform/internal/release" |
| 21 | ) |
| 22 | |
| 23 | // The landing on the target (moves spec §5.1, §7.5, §7.6), behind |
| 24 | // CARLOS_MOVES_PHASE1. After the flip the record's region is the target's |
| 25 | // and reconcile has minted the held row, so the target's pass visits the |
| 26 | // record (OwnsRoute) and phase1MoveRound dispatches it here (ruling G34). |
| 27 | // One pass drives one landing record as far as it can, as the cut does: |
| 28 | // each phase does its work, then advances landing_phase by one guarded CAS, |
| 29 | // then mirrors the step into the claim's op.phase (§7.5: the record is the |
| 30 | // decision, the claim follows it). Nothing is carried in memory between |
| 31 | // passes but the own-location lease (building through published): every |
| 32 | // phase resumes from the record, the claim, this box's own bucket and the |
| 33 | // op journal (moves.Journal, <DataDir>/.moves/<host>/<op>/). checking is |
| 34 | // the landing's own; parking to restored run in the generation installer |
| 35 | // the dirty return shares (movesinstall.go, Plan 4 Task 3), bound to this |
| 36 | // landing by installSource. |
| 37 | // |
| 38 | // Phase names (ruling G8, the cut's convention). A record at X means every |
| 39 | // phase before X is complete and X's own work may have begun; a replay of X |
| 40 | // redoes X's work from durable state, and each phase's comment says what it |
| 41 | // finds. The one name read the other way is `restored`: it is written by |
| 42 | // the CAS that ends `configured`, after the database is installed and its |
| 43 | // markers written, stamping landing.installed_db_sha256, so Plan 3's probe |
| 44 | // reads `restored` as "the install is complete". Every later phase is the |
| 45 | // probe's (Plan 3). |
| 46 | // |
| 47 | // The claim never leads the record. Every phase starts with syncClaimPhase: |
| 48 | // equal is the common case; a claim behind the record (a crash between the |
| 49 | // record CAS and the claim CAS) is walked forward along LandingOrder with |
| 50 | // one claim CAS per adjacent step; a claim ahead is left alone and logged, and the phase's own |
| 51 | // RequireMoveOp then refuses, so nothing is written over it. |
| 52 | // |
| 53 | // Nothing here ever serves from moves/: the lane is read by exact version |
| 54 | // into the generation the build writes and into the files directory the |
| 55 | // archive is extracted into (M14, M15). A failure that is permanent for this |
| 56 | // landing is a rollback (§7.8), which Plan 4 builds: until then the record |
| 57 | // carries the reason (provision.MoveMarkRollback) and this pass leaves a |
| 58 | // marked record alone. The landing's deadline passing before `restored` |
| 59 | // marks it too: the earlier of the cut's absolute cut_deadline and the |
| 60 | // flip's landing_deadline (spec §7.4; landingDeadlineOf). |
| 61 | // |
| 62 | // Local installation (final review, findings 1 and 2). Every destructive |
| 63 | // local mutation (the files and env install, .files-gen, the database |
| 64 | // rename and <dbPath>.gen) runs inside the host guard at the installation |
| 65 | // stamp captured from the record (landingRun.guarded, the pattern of |
| 66 | // hostbox ColdRestore.Install): the claim and the record are read fresh |
| 67 | // there and must still be this landing at this phase, and the destination |
| 68 | // is checked again there. Retirement (park.Local) takes the guard itself |
| 69 | // and runs outside those blocks. The landing never deletes at the live |
| 70 | // paths: the archive is extracted into the op's own staging and moved into |
| 71 | // place only when nothing it did not write is there. |
| 72 | // |
| 73 | // Arming (ruling G17; Plan 5 Task 9a). armPhase1 wires the hooks that exist |
| 74 | // on main (the guard, park.Local, the own-location lease, RenderHeld, the |
| 75 | // registry setters, the restore-verification record, the marker writer). |
| 76 | // ParkLeases, ParkView, ParkAuthorize and ParkLocalAuthorize are the |
| 77 | // claim's (MoveParks), and ParkRunHeld is park.Engine.RunHeld over the |
| 78 | // claim's leases (M5), all set by armMovesParks only once the wake-source |
| 79 | // gate opens (movesWakeSourceRegistered, Plan 5 Task 9b); nothing stands in |
| 80 | // for them. landingArmed therefore answers false in production and the pass |
| 81 | // logs a standing notice instead of landing; the tests arm everything with |
| 82 | // fakes (movesparkfakes_test.go). |
| 83 | // Under |
| 84 | // CARLOS_HOST_CLAIM the landing's held writes select their Work from the |
| 85 | // claim (Task 7, Q2): the timers as held-timers, the render as held-config |
| 86 | // and the release as held-release, which the configured step now publishes |
| 87 | // (T7 only stages under the claim; Paul, 2026-10-12). hostbox.Policy.Moves, |
| 88 | // MovesClaimPolicy (movespolicy.go), decides them. The landing still refuses |
| 89 | // at checking with a recorded reason (ruling G16, RollbackClaimWired) until |
| 90 | // the wake-source gate opens (movesWakeSourceRegistered), and lands after. |
| 91 | |
| 92 | // landingStepsPerPass bounds the phases one pass runs. |
| 93 | const landingStepsPerPass = 8 |
| 94 | |
| 95 | // landingRequestLimit is the residue parks' request limit: the longest a |
| 96 | // request against the location runs (hostbox.RequestLimit, the cut's too). |
| 97 | const landingRequestLimit = cutRequestLimit |
| 98 | |
| 99 | // Op journal steps the landing writes (moves.Journal). Each sub-step of |
| 100 | // `local` is a step, so a replay skips what is durable and finishes the |
| 101 | // rest; `.files-gen` is written last and vouches for them all (§7.6). |
| 102 | const ( |
| 103 | landStepPrevious = "previous" // the earlier stay's stamp S' (decimal), noted before Activate |
| 104 | landStepResidue = "residue" // the generations whose residue parks were queued (JSON list) |
| 105 | landStepInstall = "install" // the completed install manifest (JSON) |
| 106 | landStepPublished = "published" // the build is complete and recorded (R10-3: no generation.json) |
| 107 | landStepRetired = "retired" // the earlier stay's artifacts are parked, or there were none |
| 108 | landStepStaged = "staged" // the archive is extracted into <JournalDir>/extract/ (JSON stagedFiles) |
| 109 | landStepFilesPlaced = "files_placed" // the install receipt: the staged tree is at the live paths (JSON stagedFiles) |
| 110 | landStepExtracted = "extracted" // the archive is in the files directory and <host>.env |
| 111 | landStepInventory = "inventory" // the placed files and env are registered under F |
| 112 | landStepTimers = "timers" // the timers table is the sealed snapshot |
| 113 | landStepColumns = "columns" // replication and next_due carried over |
| 114 | landStepVerify = "verify" // the restore-verification entry is gone |
| 115 | landStepFilesManifest = "files_manifest" // the prepared files manifest's binding (JSON preparedManifest: key, sha256, op, stamp; C8, Task 6) |
| 116 | landStepFilesGen = "files_gen" // .files-gen written |
| 117 | landStepPreInstall = "pre_install" // the database's siblings absent before its install (JSON preInstall) |
| 118 | landStepInventoryDB = "inventory_db" // the installed database and its marker are registered under F (carry (b)) |
| 119 | landStepPreRender = "pre_render" // the held render's ownership before its write (JSON preRender, carry (c)) |
| 120 | landStepRendered = "rendered" // the held render's file, "sha256:<hex>", journalled even when its ack failed (carry (c)) |
| 121 | ) |
| 122 | |
| 123 | // landingFilesManifestSkipped is what the files_manifest step recorded |
| 124 | // before C8 (the prepared-manifest build, Task 6) existed. Nothing writes |
| 125 | // it now; a journal that holds it counts as not done (filesManifestBuilt), |
| 126 | // so a replay builds the manifest (ruling Q3 item 2). |
| 127 | const landingFilesManifestSkipped = "skipped: the prepared files manifest needs C8 (files.BuildPrepared)" |
| 128 | |
| 129 | // landingRun is one pass's state for one landing record. |
| 130 | type landingRun struct { |
| 131 | w *movesWorker |
| 132 | s release.Store |
| 133 | rec provision.Record |
| 134 | g provision.MoveGuard |
| 135 | guard hostguard.Guard |
| 136 | } |
| 137 | |
| 138 | // parkHooksArmed reports whether the claim's three park hooks are wired: |
| 139 | // the leases, the view and the authorizer (M17). Every moves arm requires |
| 140 | // all three; nothing stands in for a missing one. |
| 141 | func parkHooksArmed(cfg moveCfg) bool { |
| 142 | return cfg.ParkLeases != nil && cfg.ParkView != nil && cfg.ParkAuthorize != nil |
| 143 | } |
| 144 | |
| 145 | // landingArmed reports whether this box can run the landing: every hook |
| 146 | // the phases call is wired, the claim's three park hooks, RunHeld (M5: the |
| 147 | // residue parks run under the landing's own lease) and the claim's local |
| 148 | // authorizer (M9: the install's park.Local) among them. In production |
| 149 | // armMovesParks sets those only once the wake-source gate opens |
| 150 | // (movesWakeSourceRegistered, Plan 5 Task 9b), so this answers false there. |
| 151 | func landingArmed(cfg moveCfg) bool { |
| 152 | return cfg.Box != "" && cfg.Guard.DataDir != "" && cfg.Local.DataDir != "" && parkHooksArmed(cfg) && cfg.ParkRunHeld != nil && cfg.ParkLocalAuthorize != nil && |
| 153 | cfg.OwnLease != nil && cfg.RenderHeld != nil && cfg.ReplaceTimers != nil && cfg.SetReplication != nil && |
| 154 | cfg.SetNextDue != nil && cfg.RemoveVerifyEntry != nil && cfg.WriteGen != nil && cfg.Route != nil && |
| 155 | cfg.FilesDir != nil && cfg.Versioned != nil && cfg.ReleaseReady != nil && cfg.SecretsPriv != nil |
| 156 | } |
| 157 | |
| 158 | // landingPass is the MoveLanding arm of phase1MoveRound. |
| 159 | func (w *movesWorker) landingPass(ctx context.Context, s release.Store, rec provision.Record) error { |
| 160 | cfg := w.cfg |
| 161 | // The rollback decision (movesrollback.go, J10, J11): a recorded mark |
| 162 | // or the landing's deadline, at any phase, armed or not. A landing the |
| 163 | // decision took is abandoned, and with it the location's lease a build |
| 164 | // keeps across passes (building through published): it is released |
| 165 | // here, before the pass ends, or the target's cleaning could never take |
| 166 | // the same lease (the final review's finding 2). |
| 167 | if done, err := w.rollBackLanding(ctx, s, rec); done || err != nil { |
| 168 | if done { |
| 169 | releaseOwnLease(w, rec.Host) |
| 170 | } |
| 171 | return err |
| 172 | } |
| 173 | if !landingArmed(cfg) { |
| 174 | w.notice(rec.Host+"#landing", "moves: %s: the landing of %s waits: this box's landing is not armed (the claim's park hooks are set from MoveParks only once this box's moves wake source is registered, Plan 5 Task 9b)", rec.Host, rec.Move.ID) |
| 175 | return nil |
| 176 | } |
| 177 | l := &landingRun{w: w, s: s, rec: rec, g: provision.MoveGuard{ID: rec.Move.ID, Incarnation: rec.Move.Incarnation}} |
| 178 | l.guard = cfg.Guard |
| 179 | if l.guard.Claims == nil { |
| 180 | l.guard.Claims = hostguard.StoreReader{Store: s} |
| 181 | } |
| 182 | defer l.settleLease() |
| 183 | for i := 0; i < landingStepsPerPass; i++ { |
| 184 | advanced, err := l.step(ctx) |
| 185 | if err != nil || !advanced { |
| 186 | return err |
| 187 | } |
| 188 | fresh, err := provision.GetRecord(ctx, s, rec.Account, rec.App, rec.Host) |
| 189 | if err != nil { |
| 190 | return fmt.Errorf("re-read the record: %w", err) |
| 191 | } |
| 192 | if fresh.Move == nil || fresh.Move.ID != l.g.ID || fresh.Move.Incarnation != l.g.Incarnation || fresh.Move.State != provision.MoveLanding { |
| 193 | return nil |
| 194 | } |
| 195 | l.rec = *fresh |
| 196 | } |
| 197 | return nil |
| 198 | } |
| 199 | |
| 200 | func (l *landingRun) cfg() moveCfg { return l.w.cfg } |
| 201 | |
| 202 | // fault is the test seam after a side effect. |
| 203 | func (l *landingRun) fault(point string) error { |
| 204 | if f := l.w.cfg.landFault; f != nil { |
| 205 | return f(point) |
| 206 | } |
| 207 | return nil |
| 208 | } |
| 209 | |
| 210 | // step runs the record's current phase once. advanced is true when it |
| 211 | // wrote the record (a phase or the rollback marker). |
| 212 | func (l *landingRun) step(ctx context.Context) (bool, error) { |
| 213 | m := l.rec.Move |
| 214 | if m.State != provision.MoveLanding { |
| 215 | return false, nil |
| 216 | } |
| 217 | if m.Reason != "" { |
| 218 | // Marked: the next pass's rollback decision (rollBackLanding) rolls |
| 219 | // it back; markRollback nudged the serial for it. |
| 220 | return false, nil |
| 221 | } |
| 222 | // Every phase checks the landing's deadline at entry: the install's |
| 223 | // phases (P2-deadline), and restored, probing and probed (H8: the |
| 224 | // probe works to the same deadline). |
| 225 | if l.pastDeadline() { |
| 226 | return l.markRollback(ctx, provision.RollbackLandingDeadline) |
| 227 | } |
| 228 | switch m.LandingPhase { |
| 229 | case provision.LandChecking: |
| 230 | return l.checking(ctx) |
| 231 | case provision.LandParking, provision.LandBuilding, provision.LandPublished, provision.LandLocal, provision.LandConfigured: |
| 232 | // parking to restored are the shared installer's (movesinstall.go). |
| 233 | advanced, _, err := installGeneration(ctx, l.s, l.w, l.rec, l.installSource()) |
| 234 | return advanced, err |
| 235 | case provision.LandRestored: |
| 236 | // The install is complete. The claim half of the last CAS first: a |
| 237 | // crash after the record's restored, or a mirror that failed, leaves |
| 238 | // the claim behind (fix round 1, item 1). Then the probe begins, |
| 239 | // when this box has a probe wake (unarmed in production). |
| 240 | if _, err := l.syncClaimPhase(ctx); err != nil { |
| 241 | return false, err |
| 242 | } |
| 243 | if l.cfg().Probe == nil { |
| 244 | l.w.notice(l.rec.Host+"#probe", "moves: %s: landing %s waits at restored: this box has no probe wake (claim Plan 11's launcher and Plan 5 Task 9's arming are needed)", l.rec.Host, l.g.ID) |
| 245 | return false, nil |
| 246 | } |
| 247 | return l.advance(ctx, provision.LandRestored, provision.LandProbing, nil) |
| 248 | case provision.LandProbing: |
| 249 | return l.probing(ctx) |
| 250 | case provision.LandProbed: |
| 251 | // The probe passed: the commit (moveslandcommit.go). |
| 252 | return l.probed(ctx) |
| 253 | } |
| 254 | return false, nil |
| 255 | } |
| 256 | |
| 257 | // landingDeadlineOf is the deadline a landing before restored works to: |
| 258 | // provision.LandingDeadlineOf, the earlier of the cut's absolute deadline |
| 259 | // and landing_deadline, which the move's timings share (Plan 6 preflight |
| 260 | // L20). which names the one that applies, for the log. |
| 261 | func landingDeadlineOf(m *provision.MoveState) (d *time.Time, which string) { |
| 262 | return provision.LandingDeadlineOf(m) |
| 263 | } |
| 264 | |
| 265 | // pastDeadline reports whether the landing's deadline (landingDeadlineOf) |
| 266 | // has passed now. It is checked at every phase's entry (step), and in |
| 267 | // configured again before the install and before the transition that |
| 268 | // names it complete. |
| 269 | func (l *landingRun) pastDeadline() bool { |
| 270 | d, which := landingDeadlineOf(l.rec.Move) |
| 271 | if d == nil || !l.cfg().Now().After(*d) { |
| 272 | return false |
| 273 | } |
| 274 | log.Printf("moves: %s: landing %s is past its %s (%s)", l.rec.Host, l.g.ID, which, d.UTC().Format(time.RFC3339)) |
| 275 | return true |
| 276 | } |
| 277 | |
| 278 | // landingBefore reports whether phase comes before other in LandingOrder. |
| 279 | func landingBefore(phase, other string) bool { |
| 280 | order := provision.LandingOrder() |
| 281 | return landingIndex(order, phase) >= 0 && landingIndex(order, phase) < landingIndex(order, other) |
| 282 | } |
| 283 | |
| 284 | func landingIndex(order []string, phase string) int { |
| 285 | for i, p := range order { |
| 286 | if p == phase { |
| 287 | return i |
| 288 | } |
| 289 | } |
| 290 | return -1 |
| 291 | } |
| 292 | |
| 293 | // transition writes one guarded record transition. A conflict is someone |
| 294 | // else's decision (a rollback, a console write): logged, and the pass ends |
| 295 | // without error. |
| 296 | func (l *landingRun) transition(ctx context.Context, what string, mut func(*provision.Record) error) (bool, error) { |
| 297 | var pre provision.Record |
| 298 | after, err := provision.SetRecordMove(ctx, l.s, l.rec.Account, l.rec.App, l.rec.Host, observedMove(&pre, mut)) |
| 299 | if err != nil { |
| 300 | if errors.Is(err, provision.ErrMoveConflict) { |
| 301 | log.Printf("moves: %s: %s of %s refused (%v); the record moved on", l.rec.Host, what, l.g.ID, err) |
| 302 | return false, nil |
| 303 | } |
| 304 | return false, fmt.Errorf("%s: %w", what, err) |
| 305 | } |
| 306 | l.w.logTransition(ctx, l.s, pre, *after) |
| 307 | return true, nil |
| 308 | } |
| 309 | |
| 310 | // advance writes the record's step and then mirrors it into the claim. A |
| 311 | // mirror that fails is logged: the next pass's syncClaimPhase writes it. |
| 312 | func (l *landingRun) advance(ctx context.Context, from, to string, stamp func(*provision.MoveState)) (bool, error) { |
| 313 | ok, err := l.transition(ctx, "the landing "+from+" to "+to, provision.MoveLandingPhase(l.g, from, to, stamp)) |
| 314 | if !ok { |
| 315 | return ok, err |
| 316 | } |
| 317 | log.Printf("moves: %s: landing %s is %s", l.rec.Host, l.g.ID, to) |
| 318 | if err := l.fault("after-record-" + to); err != nil { |
| 319 | return true, err |
| 320 | } |
| 321 | if err := l.mirrorPhase(ctx, from, to); err != nil { |
| 322 | log.Printf("moves: %s: the claim half of landing %s waits for the next pass: %v", l.rec.Host, to, err) |
| 323 | } |
| 324 | return true, nil |
| 325 | } |
| 326 | |
| 327 | // mirrorPhase is the claim CAS that follows a record step (§7.5). |
| 328 | func (l *landingRun) mirrorPhase(ctx context.Context, from, to string) error { |
| 329 | obs, err := l.claim(ctx) |
| 330 | if err != nil { |
| 331 | return err |
| 332 | } |
| 333 | step := hostclaim.SetMovePhase(l.g.ID, hostclaim.KindLanding, from, to) |
| 334 | if hostclaim.SessionPhase(hostclaim.KindLanding, from) && hostclaim.SessionPhase(hostclaim.KindLanding, to) { |
| 335 | // probing to probed (and Task 7's committed): the admitted probe |
| 336 | // session is the serving session, so the CAS must keep it (H3). |
| 337 | step = hostclaim.KeepWriterSession(l.g.ID, step) |
| 338 | } |
| 339 | _, err = hostclaim.Update(ctx, l.s, obs, step) |
| 340 | return err |
| 341 | } |
| 342 | |
| 343 | // markRollback records why the landing stopped (§7.6; Plan 4's rollback |
| 344 | // transition replaces this one call site). |
| 345 | func (l *landingRun) markRollback(ctx context.Context, reason string) (bool, error) { |
| 346 | ok, err := l.transition(ctx, "the rollback marker", provision.MoveMarkRollback(l.g, reason)) |
| 347 | if ok { |
| 348 | log.Printf("moves: %s: landing %s stops for a rollback: %s", l.rec.Host, l.g.ID, reason) |
| 349 | nudgeSerial(ctx, l.s) |
| 350 | } |
| 351 | return ok, err |
| 352 | } |
| 353 | |
| 354 | // claim reads the host claim and checks it is this move's op for the |
| 355 | // record's incarnation. |
| 356 | func (l *landingRun) claim(ctx context.Context) (hostclaim.Observed, error) { |
| 357 | obs, err := hostclaim.Read(ctx, l.s, l.rec.Host) |
| 358 | if err != nil { |
| 359 | return obs, fmt.Errorf("read the host claim: %w", err) |
| 360 | } |
| 361 | if obs.Absent { |
| 362 | return obs, fmt.Errorf("move %s has no host claim", l.g.ID) |
| 363 | } |
| 364 | if err := hostclaim.RequireOp(l.g.ID)(obs.Claim); err != nil { |
| 365 | return obs, fmt.Errorf("move %s: %w", l.g.ID, err) |
| 366 | } |
| 367 | if h := obs.Claim.Holder; h == nil || h.Incarnation != l.rec.Move.Incarnation { |
| 368 | return obs, fmt.Errorf("move %s: the host claim names another incarnation", l.g.ID) |
| 369 | } |
| 370 | return obs, nil |
| 371 | } |
| 372 | |
| 373 | // syncClaimPhase is every phase's first step (§7.5): the claim's op.phase |
| 374 | // follows the record's landing_phase. Behind, it is walked forward one step |
| 375 | // at a time, each in its own CAS; ahead, it is left alone and logged (the phase's own |
| 376 | // RequireMoveOp then refuses); equal is the common case. The observation |
| 377 | // returned is the one the phase works from, and names this box as the |
| 378 | // executor. |
| 379 | func (l *landingRun) syncClaimPhase(ctx context.Context) (hostclaim.Observed, error) { |
| 380 | cfg := l.cfg() |
| 381 | obs, err := l.claim(ctx) |
| 382 | if err != nil { |
| 383 | return obs, err |
| 384 | } |
| 385 | op := obs.Claim.Op |
| 386 | if op.Kind != hostclaim.KindLanding { |
| 387 | return obs, fmt.Errorf("move %s: the claim is %s/%q, not landing", l.g.ID, op.Kind, op.Phase) |
| 388 | } |
| 389 | if cfg.Box == "" || op.Exec != cfg.Box { |
| 390 | return obs, fmt.Errorf("move %s: the claim's executor is %q, not this box", l.g.ID, op.Exec) |
| 391 | } |
| 392 | order := provision.LandingOrder() |
| 393 | ci, ri := landingIndex(order, op.Phase), landingIndex(order, l.rec.Move.LandingPhase) |
| 394 | switch { |
| 395 | case ci < 0 || ri < 0: |
| 396 | return obs, fmt.Errorf("move %s: landing phases %q (claim) and %q (record) are not both known", l.g.ID, op.Phase, l.rec.Move.LandingPhase) |
| 397 | case ci == ri: |
| 398 | return obs, nil |
| 399 | case ci > ri: |
| 400 | log.Printf("moves: %s: the claim's landing phase %q is ahead of the record's %q; left alone", l.rec.Host, op.Phase, l.rec.Move.LandingPhase) |
| 401 | return obs, nil |
| 402 | } |
| 403 | for i := ci; i < ri; i++ { |
| 404 | step := hostclaim.SetMovePhase(l.g.ID, hostclaim.KindLanding, order[i], order[i+1]) |
| 405 | if hostclaim.SessionPhase(hostclaim.KindLanding, order[i]) && hostclaim.SessionPhase(hostclaim.KindLanding, order[i+1]) { |
| 406 | step = hostclaim.KeepWriterSession(l.g.ID, step) |
| 407 | } |
| 408 | obs, err = hostclaim.Update(ctx, l.s, obs, step) |
| 409 | if err != nil { |
| 410 | return obs, fmt.Errorf("catch the claim up to %s: %w", l.rec.Move.LandingPhase, err) |
| 411 | } |
| 412 | } |
| 413 | log.Printf("moves: %s: the claim's landing phase caught up from %q to %q", l.rec.Host, op.Phase, l.rec.Move.LandingPhase) |
| 414 | return obs, nil |
| 415 | } |
| 416 | |
| 417 | // requirePhase is the phase's K5 revalidation on the synced observation. |
| 418 | func (l *landingRun) requirePhase(obs hostclaim.Observed, phase string) error { |
| 419 | if err := hostclaim.RequireMoveOp(l.g.ID, hostclaim.KindLanding, phase)(obs.Claim); err != nil { |
| 420 | return fmt.Errorf("move %s: %w", l.g.ID, err) |
| 421 | } |
| 422 | return nil |
| 423 | } |
| 424 | |
| 425 | // landingCheck is the claim check the guard runs before a local write of |
| 426 | // phase: this landing's op, at that phase, with this box executing. |
| 427 | func (l *landingRun) landingCheck(phase string) func(*hostclaim.Claim) error { |
| 428 | return func(c *hostclaim.Claim) error { |
| 429 | if err := hostclaim.RequireMoveOp(l.g.ID, hostclaim.KindLanding, phase)(c); err != nil { |
| 430 | return err |
| 431 | } |
| 432 | if c.Op.Exec != l.cfg().Box { |
| 433 | return fmt.Errorf("%w: the executor is %q, not this box", hostclaim.ErrWrongState, c.Op.Exec) |
| 434 | } |
| 435 | return nil |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | // route is the host's registry row on this box. |
| 440 | func (l *landingRun) route() (registry.Route, error) { |
| 441 | rt, err := l.cfg().Route(l.rec.Host) |
| 442 | if err != nil { |
| 443 | return registry.Route{}, fmt.Errorf("read the registry row: %w", err) |
| 444 | } |
| 445 | if rt == nil { |
| 446 | return registry.Route{}, fmt.Errorf("%s has no registry row on this box", l.rec.Host) |
| 447 | } |
| 448 | return *rt, nil |
| 449 | } |
| 450 | |
| 451 | func (l *landingRun) dbPath() string { return provision.DefaultDBPath(l.cfg().DataDir, l.rec.Host) } |
| 452 | |
| 453 | func (l *landingRun) journal() (*moves.Journal, error) { |
| 454 | return moves.ReadJournal(l.cfg().DataDir, l.rec.Host, l.g.ID) |
| 455 | } |
| 456 | |
| 457 | // ensureLocalDirs creates the two directories the host guard and park.Local |
| 458 | // require under the data directory (hostguard/ for the installation record, |
| 459 | // .parked/ for retired artifacts). On a box with claims on they exist |
| 460 | // already; a Phase 1 landing on a box without them creates them. |
| 461 | func (l *landingRun) ensureLocalDirs() error { |
| 462 | for _, d := range []string{"hostguard", ".parked"} { |
| 463 | if err := os.MkdirAll(filepath.Join(l.cfg().DataDir, d), 0o770); err != nil { |
| 464 | return err |
| 465 | } |
| 466 | } |
| 467 | return nil |
| 468 | } |
| 469 | |
| 470 | // checking (§7.6): the refusals this box can know (G16, G11), the flip's |
| 471 | // claim half if the source crashed before it (G12), then the claim's word: |
| 472 | // landing/checking for this move with this box executing and a root-layout |
| 473 | // holder; the source's complete stop evidence; this box in touched.boxes |
| 474 | // (K12, before any local write); move.json's incarnation; the earlier |
| 475 | // stay's stamp S' noted in the journal before the installation record is |
| 476 | // activated at F (holder.stamp, the flip's); and a park-local queued only |
| 477 | // when the inventory has paths tagged S' (ruling G5). No lease is taken |
| 478 | // here: the residue parks take the location's lease themselves. |
| 479 | // |
| 480 | // A replay finds checking and runs it all again: every write is |
| 481 | // idempotent (the flip, Touch and the park queue under the same op, the |
| 482 | // journal step written once, Activate at the same stamp), and Capture after |
| 483 | // Activate reads F, which is never noted as a previous stay. |
| 484 | func (l *landingRun) checking(ctx context.Context) (bool, error) { |
| 485 | cfg := l.cfg() |
| 486 | host, id := l.rec.Host, l.g.ID |
| 487 | // Ruling G16, as Task 7 settles it (Q2.3): with host claims on, the |
| 488 | // landing's held writes run under the claim's selections (heldRenderer, |
| 489 | // heldReleaseHooks, heldTimersReplacer) and MovesClaimPolicy decides |
| 490 | // them, but they are granted only once this box's moves source is |
| 491 | // registered with the claim. Until the wake-source gate opens |
| 492 | // (movesWakeSourceRegistered, Q1's one gate) the landing keeps refusing |
| 493 | // here, as before; once it opens it lands. RollbackClaimWired stays |
| 494 | // (and stays a legal reason in provision), because old records carry it. |
| 495 | if hostwrite.Enabled(os.Getenv) { |
| 496 | if err := movesWakeSourceRegistered(); err != nil { |
| 497 | log.Printf("moves: %s: landing %s under CARLOS_HOST_CLAIM before the wake source is registered: %v", host, id, err) |
| 498 | return l.markRollback(ctx, provision.RollbackClaimWired) |
| 499 | } |
| 500 | } |
| 501 | obs, err := l.claim(ctx) |
| 502 | if err != nil { |
| 503 | return false, err |
| 504 | } |
| 505 | if op := obs.Claim.Op; op.Kind == hostclaim.KindMoving && op.Phase == hostclaim.PhaseSealed && op.Exec == "" && op.Journal[hostclaim.JournalEvidenceSource] != "" { |
| 506 | // The record flipped and the source died before the claim half |
| 507 | // (ruling G12): the target writes it, with the same preconditions |
| 508 | // the source would have met. |
| 509 | h := obs.Claim.Holder |
| 510 | if obs, err = hostclaim.Update(ctx, l.s, obs, hostclaim.Flip(id, cfg.Box, h.Stamp, h.Generation)); err != nil { |
| 511 | return false, fmt.Errorf("the claim half of the flip: %w", err) |
| 512 | } |
| 513 | log.Printf("moves: %s: the claim half of the flip was written by the target", host) |
| 514 | if err := l.fault("after-target-flip"); err != nil { |
| 515 | return false, err |
| 516 | } |
| 517 | } |
| 518 | if err := l.requirePhase(obs, provision.LandChecking); err != nil { |
| 519 | return false, err |
| 520 | } |
| 521 | if cfg.Box == "" || obs.Claim.Op.Exec != cfg.Box { |
| 522 | return false, fmt.Errorf("move %s: the claim's executor is %q, not this box", id, obs.Claim.Op.Exec) |
| 523 | } |
| 524 | if cfg.registerLanding != nil { |
| 525 | if err := cfg.registerLanding(ctx, obs); err != nil { |
| 526 | return false, err |
| 527 | } |
| 528 | } |
| 529 | rt, err := l.route() |
| 530 | if err != nil { |
| 531 | return false, err |
| 532 | } |
| 533 | if rt.DBPath != l.dbPath() { |
| 534 | log.Printf("moves: %s: the held row's database is at %s, not the flat %s", host, rt.DBPath, l.dbPath()) |
| 535 | return l.markRollback(ctx, provision.RollbackLandingLayout) |
| 536 | } |
| 537 | if !registry.IsHold(rt.Status) || rt.HoldOp != id { |
| 538 | return false, fmt.Errorf("the registry row is %q held by %q, not held by landing %s", rt.Status, rt.HoldOp, id) |
| 539 | } |
| 540 | holder := *obs.Claim.Holder |
| 541 | if _, err := writerLease(cfg.OwnPrefix, host, holder); err != nil { |
| 542 | return false, fmt.Errorf("move %s: %w", id, err) |
| 543 | } |
| 544 | src := l.rec.Move.Source |
| 545 | if src == nil || src.Evidence == "" { |
| 546 | return false, fmt.Errorf("move %s: the record has no source baselines", id) |
| 547 | } |
| 548 | if obs.Claim.Op.Journal[hostclaim.JournalEvidenceSource] != src.Evidence { |
| 549 | return false, fmt.Errorf("move %s: the claim cites %q, the record %q", id, obs.Claim.Op.Journal[hostclaim.JournalEvidenceSource], src.Evidence) |
| 550 | } |
| 551 | report, err := readStoppedReport(ctx, l.s, src.Evidence) |
| 552 | if err != nil { |
| 553 | return false, err |
| 554 | } |
| 555 | if report.Host != host || report.OpID != id || report.Role != "source" || report.FinalTXID != src.FinalTXID { |
| 556 | return false, fmt.Errorf("move %s: %s is %s %s %s at TXID %d, not this move's source at %d", id, src.Evidence, report.Host, report.OpID, report.Role, report.FinalTXID, src.FinalTXID) |
| 557 | } |
| 558 | if !containsString(obs.Claim.Touched.Boxes, cfg.Box) { |
| 559 | if obs, err = hostclaim.Update(ctx, l.s, obs, hostclaim.Touch(id, nil, []string{cfg.Box})); err != nil { |
| 560 | return false, fmt.Errorf("touch the claim: %w", err) |
| 561 | } |
| 562 | } |
| 563 | own, err := l.w.mirror(ctx, cfg.OwnBucketRegion) |
| 564 | if err != nil { |
| 565 | return false, err |
| 566 | } |
| 567 | body, err := own.Get(ctx, cfg.OwnBucket, moves.MoveJSONKey(host, id)) |
| 568 | if err != nil { |
| 569 | return false, fmt.Errorf("read move.json: %w", err) |
| 570 | } |
| 571 | var mj moves.MoveJSON |
| 572 | if err := json.Unmarshal(body, &mj); err != nil { |
| 573 | return false, fmt.Errorf("move.json: %w", err) |
| 574 | } |
| 575 | if mj.Incarnation != l.rec.Move.Incarnation || holder.Incarnation != l.rec.Move.Incarnation { |
| 576 | return false, fmt.Errorf("move %s: move.json names incarnation %q, the record %q", id, mj.Incarnation, l.rec.Move.Incarnation) |
| 577 | } |
| 578 | if err := l.ensureLocalDirs(); err != nil { |
| 579 | return false, err |
| 580 | } |
| 581 | j, err := l.journal() |
| 582 | if err != nil { |
| 583 | return false, err |
| 584 | } |
| 585 | captured, err := hostguard.Capture(cfg.DataDir, host) |
| 586 | switch { |
| 587 | case errors.Is(err, hostguard.ErrNoRecord): |
| 588 | // No earlier stay on this box. |
| 589 | case err != nil: |
| 590 | return false, fmt.Errorf("read the installation record: %w", err) |
| 591 | case !j.Done(landStepPrevious) && captured.Stamp != 0 && captured.Stamp != holder.Stamp && hostclaim.InventoryHasStamp(obs.Claim, cfg.Box, captured.Stamp): |
| 592 | j.Phase = provision.LandChecking |
| 593 | j.Mark(landStepPrevious, strconv.FormatUint(captured.Stamp, 10)) |
| 594 | if err := j.Commit(cfg.DataDir); err != nil { |
| 595 | return false, err |
| 596 | } |
| 597 | log.Printf("moves: %s: an earlier stay at stamp %d will be retired by local", host, captured.Stamp) |
| 598 | } |
| 599 | if err := l.fault("after-previous"); err != nil { |
| 600 | return false, err |
| 601 | } |
| 602 | if err := l.guard.Activate(ctx, host, cfg.Box, nil); err != nil { |
| 603 | return false, fmt.Errorf("activate the installation record: %w", err) |
| 604 | } |
| 605 | if err := l.fault("after-activate"); err != nil { |
| 606 | return false, err |
| 607 | } |
| 608 | if j.Done(landStepPrevious) { |
| 609 | if obs, err = l.claim(ctx); err != nil { |
| 610 | return false, err |
| 611 | } |
| 612 | if _, err := hostclaim.Update(ctx, l.s, obs, hostclaim.QueueLocalPark(id, cfg.Box, obligationID)); err != nil { |
| 613 | return false, fmt.Errorf("queue the local park: %w", err) |
| 614 | } |
| 615 | } |
| 616 | // Spec §7.6 checking: the lease on the location in this box's own |
| 617 | // bucket, held through published (M5). The residue parks run under it |
| 618 | // (ParkRunHeld), so the landing never waits on its own lock. |
| 619 | if _, waiting, err := acquireOwnLease(ctx, l.w, host, holder); waiting || err != nil { |
| 620 | return false, err |
| 621 | } |
| 622 | return l.advance(ctx, provision.LandChecking, provision.LandParking, nil) |
| 623 | } |
| 624 | |
| 625 | func srcTXID(src *provision.MoveSource) uint64 { |
| 626 | if src == nil { |
| 627 | return 0 |
| 628 | } |
| 629 | return src.FinalTXID |
| 630 | } |
| 631 | |
| 632 | // installSource binds the shared installer (movesinstall.go) to this |
| 633 | // landing: the claim follows each record step (syncClaimPhase walks it, |
| 634 | // mirrorPhase writes it), the claim check is the landing op at the phase |
| 635 | // with this box executing, the record check a landing at the phase with no |
| 636 | // rollback reason, the refusal the rollback marker (Plan 4's rollback |
| 637 | // transition replaces markRollback), the phase advance MoveLandingPhase |
| 638 | // stamping landing.installed_db_sha256 at the end of configured, and the |
| 639 | // generation, the hold and the archive all the move id's. |
| 640 | func (l *landingRun) installSource() installSource { |
| 641 | m := l.rec.Move |
| 642 | return installSource{ |
| 643 | OpID: l.g.ID, Host: l.rec.Host, Role: roleTarget, Name: "landing", |
| 644 | Lane: moves.StagingPrefix(l.rec.Host, l.g.ID), Seal: journalledSeal, Baseline: m.Source, |
| 645 | GenerationOp: l.g.ID, HoldOp: l.g.ID, PinInputs: m.Inputs, |
| 646 | Phases: [6]string{provision.LandParking, provision.LandBuilding, provision.LandPublished, |
| 647 | provision.LandLocal, provision.LandConfigured, provision.LandRestored}, |
| 648 | Phase: func(from, to string, st installStamp) func(*provision.Record) error { |
| 649 | var stamp func(*provision.MoveState) |
| 650 | if sha := st.InstalledDBSHA256; sha != "" { |
| 651 | stamp = func(m *provision.MoveState) { |
| 652 | if m.Landing == nil { |
| 653 | m.Landing = &provision.MoveLandingJournal{} |
| 654 | } |
| 655 | m.Landing.InstalledDBSHA256 = sha |
| 656 | } |
| 657 | } |
| 658 | return provision.MoveLandingPhase(l.g, from, to, stamp) |
| 659 | }, |
| 660 | Mirror: l.mirrorPhase, |
| 661 | Sync: l.syncClaimPhase, |
| 662 | ClaimCheck: l.landingCheck, |
| 663 | RecordPhase: func(rec *provision.Record) (string, error) { |
| 664 | m := rec.Move |
| 665 | if m == nil || m.ID != l.g.ID || m.Incarnation != l.g.Incarnation || m.State != provision.MoveLanding || m.Reason != "" { |
| 666 | return "", fmt.Errorf("%w: the record is no longer landing %s", errLandingChanged, l.g.ID) |
| 667 | } |
| 668 | return m.LandingPhase, nil |
| 669 | }, |
| 670 | Refuse: func(ctx context.Context, why installRefusal) (bool, string, error) { |
| 671 | var reason string |
| 672 | switch why { |
| 673 | case refuseFilesOccupied: |
| 674 | reason = provision.RollbackFilesOccupied |
| 675 | case refuseManifestOccupied: |
| 676 | reason = provision.RollbackManifestOccupied |
| 677 | case refuseInstallMismatch: |
| 678 | reason = provision.RollbackInstallMismatch |
| 679 | case refuseConfigChanged: |
| 680 | reason = provision.RollbackConfigChanged |
| 681 | case refuseDeadline: |
| 682 | reason = provision.RollbackLandingDeadline |
| 683 | default: |
| 684 | return false, "", fmt.Errorf("move %s: a landing has no refusal %d", l.g.ID, why) |
| 685 | } |
| 686 | ok, err := l.markRollback(ctx, reason) |
| 687 | if !ok { |
| 688 | return false, "", err |
| 689 | } |
| 690 | return true, reason, err |
| 691 | }, |
| 692 | PastDeadline: l.pastDeadline, |
| 693 | } |
| 694 | } |
| 695 | |