| 1 | package hostbox |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "database/sql" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "errors" |
| 11 | "fmt" |
| 12 | "io/fs" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "strings" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/hostclaim" |
| 18 | "github.com/carlosframework/platform/internal/hostguard" |
| 19 | "github.com/carlosframework/platform/internal/registry" |
| 20 | "github.com/carlosframework/platform/internal/release" |
| 21 | ) |
| 22 | |
| 23 | type ColdSuccessor struct { |
| 24 | Host, Incarnation, Box, Enrollment, DirectoryRevision, ETag, Generation, Op, Phase string |
| 25 | Stamp, Session uint64 |
| 26 | } |
| 27 | type ColdHandover struct { |
| 28 | OperationsOnly bool |
| 29 | checkpoint func(string) |
| 30 | afterIntent func() error |
| 31 | afterPark func() error |
| 32 | Guard hostguard.Guard |
| 33 | Claims release.Store |
| 34 | Registry *registry.Registry |
| 35 | Inventory *StopInventoryDB |
| 36 | Launches *LaunchJournal |
| 37 | Roots map[string]string |
| 38 | JournalDir string |
| 39 | VerifyReceipt hostguard.CleanupVerify |
| 40 | // Admit, when set, is root's fresh successor admission (roster binding |
| 41 | // N5, B1). Run calls it before any other step, for a new handover and |
| 42 | // for every replay of a journaled one, startup recovery included, so a |
| 43 | // successor is started or resumed only under the binding the roster |
| 44 | // gives this box now. |
| 45 | Admit func(context.Context, ColdSuccessor) error |
| 46 | // Obsolete, when set, reports a journaled successor stamped under a |
| 47 | // binding other than root's startup binding (roster binding B3). |
| 48 | // Recover never resumes such a handover; it settles it (settleObsolete). |
| 49 | Obsolete func(ColdSuccessor) bool |
| 50 | } |
| 51 | type coldJournal struct { |
| 52 | ServingInstallation *hostguard.RootInstallation `json:",omitempty"` |
| 53 | V int |
| 54 | IntendedAbsent bool |
| 55 | ID, Phase string |
| 56 | Prior hostguard.FenceTarget |
| 57 | Barrier hostguard.FenceTarget |
| 58 | Successor ColdSuccessor |
| 59 | Inventory StopInventory |
| 60 | Receipt hostguard.FenceReceipt |
| 61 | Before, After registry.ColdRows |
| 62 | } |
| 63 | |
| 64 | func ownershipFor(host, incarnation string, stamp uint64, box string) string { |
| 65 | sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", host, incarnation, stamp, box))) |
| 66 | return hex.EncodeToString(sum[:]) |
| 67 | } |
| 68 | func rowIDFor(owner, kind, name string) string { |
| 69 | sum := sha256.Sum256([]byte(owner + "\x00" + kind + "\x00" + name)) |
| 70 | return hex.EncodeToString(sum[:]) |
| 71 | } |
| 72 | func (h ColdHandover) Run(ctx context.Context, prior hostguard.FenceTarget, next ColdSuccessor) error { |
| 73 | if h.OperationsOnly && (next.Op == "" || next.Phase == "") { |
| 74 | return ErrUnsupported |
| 75 | } |
| 76 | if h.Claims == nil || h.Registry == nil || h.Inventory == nil || h.Launches == nil || h.VerifyReceipt == nil || !filepath.IsAbs(h.JournalDir) || len(h.Roots) == 0 || next.Host != prior.Host || next.Incarnation != prior.Incarnation || next.Stamp <= prior.Stamp || next.Session <= prior.Session || next.Stamp != next.Session || next.Box != prior.Box || next.Enrollment != prior.Enrollment || next.ETag == "" || next.Generation == "" { |
| 77 | return ErrUnsupported |
| 78 | } |
| 79 | if h.Admit != nil { |
| 80 | if err := h.Admit(ctx, next); err != nil { |
| 81 | return err |
| 82 | } |
| 83 | } |
| 84 | check := func(c *hostclaim.Claim) error { |
| 85 | if c == nil || c.Host != next.Host || c.Holder == nil || c.Holder.Family != "instance" || c.Holder.Incarnation != next.Incarnation || c.Holder.Stamp != next.Stamp || c.Holder.Session != next.Session || c.Holder.Generation != next.Generation || c.Holder.WriterBox != next.Box || c.Op.ID != next.Op || c.Op.Phase != next.Phase || c.Op.Exec != next.Box { |
| 86 | return ErrChanged |
| 87 | } |
| 88 | found := 0 |
| 89 | for _, admission := range c.Holder.Admissions { |
| 90 | if admission.Session == next.Session && admission.Box == next.Box && admission.Generation == next.Generation && admission.Mode == "cold" { |
| 91 | found++ |
| 92 | } |
| 93 | } |
| 94 | if found != 1 { |
| 95 | return ErrChanged |
| 96 | } |
| 97 | return nil |
| 98 | } |
| 99 | observed, err := hostclaim.Read(ctx, h.Claims, next.Host) |
| 100 | if err != nil { |
| 101 | return err |
| 102 | } |
| 103 | if err = check(observed.Claim); err != nil { |
| 104 | return err |
| 105 | } |
| 106 | id := "handover-" + ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box) |
| 107 | path := filepath.Join(h.JournalDir, id+".json") |
| 108 | var j coldJournal |
| 109 | body, err := readTrustedJournal(path) |
| 110 | if err == nil { |
| 111 | if decodeStrict(body, &j) != nil || j.V != 1 || j.ID != id || j.Prior != prior || j.Successor != next || (j.Phase != "prepared" && j.Phase != "parked" && j.Phase != "complete") { |
| 112 | return ErrTypedJournal |
| 113 | } |
| 114 | if j.Phase == "complete" { |
| 115 | return h.Guard.CompleteColdHandover(ctx, j.Barrier, true, check, func() error { return nil }) |
| 116 | } |
| 117 | } else if !errors.Is(err, os.ErrNotExist) { |
| 118 | return err |
| 119 | } else { |
| 120 | if observed.ETag != next.ETag { |
| 121 | return ErrChanged |
| 122 | } |
| 123 | receipt, err := h.Guard.FenceReceiptFor(ctx, prior) |
| 124 | if err != nil { |
| 125 | return err |
| 126 | } |
| 127 | if err = h.VerifyReceipt(ctx, receipt); err != nil { |
| 128 | return err |
| 129 | } |
| 130 | barrier := prior |
| 131 | barrier.FenceID = id |
| 132 | barrier.Stamp = next.Stamp |
| 133 | barrier.Session = next.Session |
| 134 | barrier.Op = next.Op |
| 135 | barrier.Phase = next.Phase |
| 136 | barrier.ClaimETag = next.ETag |
| 137 | barrier.DirectoryRevision = next.DirectoryRevision |
| 138 | |
| 139 | err = h.Guard.CleanupWith(ctx, prior, h.VerifyReceipt, func() error { |
| 140 | saved, loadErr := readTrustedJournal(path) |
| 141 | if loadErr == nil { |
| 142 | var existing coldJournal |
| 143 | if decodeStrict(saved, &existing) != nil || existing.ID != id || existing.Prior != prior || existing.Successor != next { |
| 144 | return ErrTypedJournal |
| 145 | } |
| 146 | j = existing |
| 147 | return nil |
| 148 | } |
| 149 | if !errors.Is(loadErr, os.ErrNotExist) { |
| 150 | return loadErr |
| 151 | } |
| 152 | in, err := h.Inventory.ReadExactStopInventory(ctx, prior) |
| 153 | if err != nil { |
| 154 | return err |
| 155 | } |
| 156 | if !in.Complete || in.Host != prior.Host || in.Incarnation != prior.Incarnation || in.InstallationStamp != prior.Stamp || in.Box != prior.Box || in.Enrollment != prior.Enrollment || in.DirectoryRevision != prior.DirectoryRevision { |
| 157 | return ErrTypedInventory |
| 158 | } |
| 159 | row, err := h.Registry.Get(prior.Host) |
| 160 | if err != nil { |
| 161 | return err |
| 162 | } |
| 163 | if row == nil || !row.ClaimOwnership.Valid() || row.ClaimIncarnation != prior.Incarnation || row.ClaimStamp != prior.Stamp || row.Kind != "instance" || row.Unit != "" || row.Addr != "" || row.Addrs != "" || row.Backing != "" || row.Tunnel != "" || row.Platform || row.Canary || row.Replica { |
| 164 | return ErrUnsupported |
| 165 | } |
| 166 | if err := ValidateAgentInventory(in, row, h.Roots["data"]); err != nil { |
| 167 | return err |
| 168 | } |
| 169 | timers, err := h.Registry.TimersFor(prior.Host) |
| 170 | if err != nil { |
| 171 | return err |
| 172 | } |
| 173 | for _, timer := range timers { |
| 174 | if timer.ClaimIncarnation != prior.Incarnation || timer.ClaimStamp != prior.Stamp || timer.ClaimOwner != row.ClaimOwner || timer.ClaimRowID == "" || timer.Registration == "" { |
| 175 | return ErrTypedInventory |
| 176 | } |
| 177 | } |
| 178 | before := registry.ColdRows{Route: *row, Timers: timers} |
| 179 | after := registry.ColdRows{Route: *row, Timers: append([]registry.Timer(nil), timers...)} |
| 180 | owner := ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box) |
| 181 | after.Route.ClaimOwnership = registry.ClaimOwnership{ClaimIncarnation: next.Incarnation, ClaimStamp: next.Stamp, ClaimOwner: owner, ClaimRowID: rowIDFor(owner, "route", next.Host)} |
| 182 | for i := range after.Timers { |
| 183 | after.Timers[i].ClaimOwnership = after.Route.ClaimOwnership |
| 184 | after.Timers[i].ClaimRowID = rowIDFor(owner, "timer", after.Timers[i].Name) |
| 185 | } |
| 186 | j = coldJournal{V: 1, ID: id, Phase: "prepared", Prior: prior, Barrier: barrier, Successor: next, Inventory: in, Receipt: receipt, Before: before, After: after} |
| 187 | return writeColdJournal(path, j) |
| 188 | }) |
| 189 | if err != nil { |
| 190 | return err |
| 191 | } |
| 192 | } |
| 193 | if j.Phase == "complete" { |
| 194 | return h.Guard.CompleteColdHandover(ctx, j.Barrier, true, check, func() error { return nil }) |
| 195 | } |
| 196 | if err := ValidateAgentInventory(j.Inventory, &j.Before.Route, h.Roots["data"]); err != nil { |
| 197 | return err |
| 198 | } |
| 199 | if j.Phase == "prepared" { |
| 200 | h.atCheckpoint("intent") |
| 201 | } |
| 202 | if h.afterIntent != nil && j.Phase == "prepared" { |
| 203 | if err := h.afterIntent(); err != nil { |
| 204 | return err |
| 205 | } |
| 206 | } |
| 207 | if err = h.Guard.BeginColdHandover(ctx, prior, j.Barrier, check); err != nil { |
| 208 | return err |
| 209 | } |
| 210 | if err = h.VerifyReceipt(ctx, j.Receipt); err != nil { |
| 211 | return err |
| 212 | } |
| 213 | if effects, err := h.Launches.HasInstallationEffects(ctx, next.Host, next.Incarnation, next.Stamp); err != nil { |
| 214 | return err |
| 215 | } else if effects { |
| 216 | return ErrChanged |
| 217 | } |
| 218 | if j.Phase == "prepared" { |
| 219 | inventory := RetirementInventory{Host: prior.Host, Captured: hostguard.Captured{Incarnation: prior.Incarnation, Stamp: prior.Stamp}, Ownership: j.Before.Route.ClaimOwner} |
| 220 | var intended []StopInventoryEntry |
| 221 | names := map[string]int{} |
| 222 | for _, entry := range j.Inventory.Entries { |
| 223 | if entry.Kind != "route" && entry.Kind != "timer" { |
| 224 | names[entry.ManagedRoot+"\x00"+entry.RelativePath]++ |
| 225 | } |
| 226 | if entry.Ownership != inventory.Ownership || entry.Kind == "unit-state" { |
| 227 | return ErrTypedInventory |
| 228 | } |
| 229 | // An intended path is an intent PrepareState persisted before its |
| 230 | // mkdir and never finalized. It carries no captured identity, so it |
| 231 | // is handled only by retireIntended below, never as an artifact. |
| 232 | if entry.Kind == "timer" && (entry.Materialization == "intended" || entry.TimerRetired) { |
| 233 | if entry.Device != 0 || entry.Inode != 0 || entry.Type != 0 || entry.ManagedRoot != "registry" || entry.RowIdentity == "" { |
| 234 | return ErrTypedInventory |
| 235 | } |
| 236 | present := false |
| 237 | for _, timer := range j.Before.Timers { |
| 238 | if entry.RelativePath != timer.Host+"/"+timer.Name { |
| 239 | continue |
| 240 | } |
| 241 | if entry.TimerRetired || timer.Registration != entry.RowIdentity || timer.ClaimRowID != entry.RowIdentity || timer.ClaimOwner != entry.Ownership { |
| 242 | return ErrTypedInventory |
| 243 | } |
| 244 | present = true |
| 245 | } |
| 246 | if !present { |
| 247 | inventory.Artifacts = append(inventory.Artifacts, Artifact{Kind: ArtifactTimer, Root: entry.ManagedRoot, Name: entry.RelativePath, Owner: entry.Ownership, RowID: entry.RowIdentity + "~" + entry.RowIdentity, AllowAbsent: true}) |
| 248 | } |
| 249 | continue |
| 250 | } |
| 251 | if entry.Materialization == "intended" { |
| 252 | if entry.Kind != "path" || entry.Device != 0 || entry.Inode != 0 || !managedName(entry.RelativePath) || h.Roots[entry.ManagedRoot] == "" { |
| 253 | return ErrTypedInventory |
| 254 | } |
| 255 | intended = append(intended, entry) |
| 256 | continue |
| 257 | } |
| 258 | if entry.Materialization != "materialized" { |
| 259 | return ErrTypedInventory |
| 260 | } |
| 261 | if entry.Kind == "timer" { |
| 262 | continue |
| 263 | } |
| 264 | inventory.Artifacts = append(inventory.Artifacts, Artifact{Kind: ArtifactKind(entry.Kind), Root: entry.ManagedRoot, Name: entry.RelativePath, Owner: entry.Ownership, RowID: entry.RowIdentity, Identity: entry.Identity, Dev: entry.Device, Ino: entry.Inode, Type: entry.Type}) |
| 265 | } |
| 266 | for _, timer := range j.Before.Timers { |
| 267 | inventory.Artifacts = append(inventory.Artifacts, Artifact{Kind: ArtifactTimer, Root: "registry", Name: timer.Host + "/" + timer.Name, Owner: timer.ClaimOwner, RowID: timer.ClaimRowID + "~" + timer.Registration}) |
| 268 | } |
| 269 | for _, entry := range intended { |
| 270 | if names[entry.ManagedRoot+"\x00"+entry.RelativePath] != 1 { |
| 271 | return ErrTypedInventory |
| 272 | } |
| 273 | } |
| 274 | intent, err := NewRetirementJournal(j.ID+"-old", inventory) |
| 275 | if err != nil { |
| 276 | return err |
| 277 | } |
| 278 | intent.Target = &j.Prior |
| 279 | intent.StopReceipt = append([]byte(nil), j.Receipt.Bytes...) |
| 280 | if err = h.retireIntended(ctx, j, intended); err != nil { |
| 281 | return err |
| 282 | } |
| 283 | retire := TypedRetirement{JournalDir: filepath.Join(h.JournalDir, "retired"), Authority: InstallationCleanup{Guard: h.Guard, Verify: h.VerifyReceipt}, Filesystem: TypedParker{CapturePersistent: h.Guard.Root != nil, Roots: h.Roots, ParkedRoots: h.Roots, Incarnation: prior.Incarnation, Host: prior.Host, Retirement: intent.ID}, Rows: RegistryRows{Registry: h.Registry, Inventory: inventory}, Ack: func(context.Context, RetirementJournal) error { return nil }} |
| 284 | if err = retire.Run(ctx, prior, intent); err != nil { |
| 285 | return err |
| 286 | } |
| 287 | |
| 288 | if err = h.Guard.CleanupWith(ctx, prior, h.VerifyReceipt, func() error { |
| 289 | current, err := readTrustedJournal(path) |
| 290 | if err != nil { |
| 291 | return err |
| 292 | } |
| 293 | var saved coldJournal |
| 294 | if decodeStrict(current, &saved) != nil || saved.ID != j.ID || saved.Prior != j.Prior || saved.Successor != j.Successor { |
| 295 | return ErrTypedJournal |
| 296 | } |
| 297 | if saved.Phase == "complete" || saved.Phase == "parked" { |
| 298 | j = saved |
| 299 | return nil |
| 300 | } |
| 301 | if saved.Phase != "prepared" { |
| 302 | return ErrTypedJournal |
| 303 | } |
| 304 | saved.Phase = "parked" |
| 305 | j = saved |
| 306 | return writeColdJournal(path, j) |
| 307 | }); err != nil { |
| 308 | return err |
| 309 | } |
| 310 | } |
| 311 | if j.Phase == "complete" { |
| 312 | return h.Guard.CompleteColdHandover(ctx, j.Barrier, true, check, func() error { return nil }) |
| 313 | } |
| 314 | h.atCheckpoint("parked") |
| 315 | if h.afterPark != nil { |
| 316 | if err := h.afterPark(); err != nil { |
| 317 | return err |
| 318 | } |
| 319 | } |
| 320 | return h.Guard.CompleteColdHandover(ctx, j.Barrier, false, check, func() error { |
| 321 | if effects, err := h.Launches.HasInstallationEffects(ctx, next.Host, next.Incarnation, next.Stamp); err != nil { |
| 322 | return err |
| 323 | } else if effects { |
| 324 | return ErrChanged |
| 325 | } |
| 326 | for _, entry := range j.Inventory.Entries { |
| 327 | if entry.Kind == "route" || entry.Kind == "timer" { |
| 328 | continue |
| 329 | } |
| 330 | rootPath := h.Roots[entry.ManagedRoot] |
| 331 | if rootPath == "" { |
| 332 | return ErrTypedInventory |
| 333 | } |
| 334 | root, err := os.OpenRoot(rootPath) |
| 335 | if err != nil { |
| 336 | return err |
| 337 | } |
| 338 | _, statErr := root.Lstat(entry.RelativePath) |
| 339 | root.Close() |
| 340 | if !errors.Is(statErr, os.ErrNotExist) { |
| 341 | if statErr != nil { |
| 342 | return statErr |
| 343 | } |
| 344 | return ErrChanged |
| 345 | } |
| 346 | } |
| 347 | if err := coldNamespaceEmpty(h.Roots, next.Host, h.Guard.LockDir, filepath.Join(h.Guard.DataDir, "hostguard"), h.JournalDir); err != nil { |
| 348 | return err |
| 349 | } |
| 350 | owner := j.After.Route.ClaimOwnership |
| 351 | successor := StopInventory{Complete: true, Host: next.Host, Incarnation: next.Incarnation, InstallationStamp: next.Stamp, Box: next.Box, Enrollment: next.Enrollment, DirectoryRevision: next.DirectoryRevision, Entries: []StopInventoryEntry{{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: next.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "intended"}}} |
| 352 | existing, err := h.Inventory.ReadInstallation(ctx, next.Host, next.Incarnation, next.Stamp) |
| 353 | if err == nil { |
| 354 | if len(existing.Entries) != 1 || existing.Entries[0].Kind != "route" { |
| 355 | return ErrTypedInventory |
| 356 | } |
| 357 | existing.Entries[0].Materialization = "intended" |
| 358 | a, _ := json.Marshal(existing) |
| 359 | b, _ := json.Marshal(successor) |
| 360 | if !bytes.Equal(a, b) { |
| 361 | return ErrTypedInventory |
| 362 | } |
| 363 | } else if !errors.Is(err, sql.ErrNoRows) { |
| 364 | return err |
| 365 | } |
| 366 | if err = h.Inventory.WriteStopInventory(ctx, successor); err != nil { |
| 367 | return err |
| 368 | } |
| 369 | h.atCheckpoint("inventory-intent") |
| 370 | if err = h.Registry.RestoreClaimRows(ctx, j.ID, j.ID+"-old", j.Before, j.After); err != nil { |
| 371 | return err |
| 372 | } |
| 373 | h.atCheckpoint("rows") |
| 374 | successor.Entries[0].Materialization = "materialized" |
| 375 | if err = h.Inventory.WriteStopInventory(ctx, successor); err != nil { |
| 376 | return err |
| 377 | } |
| 378 | h.atCheckpoint("inventory-materialized") |
| 379 | j.Phase = "complete" |
| 380 | if err := writeColdJournal(path, j); err != nil { |
| 381 | return err |
| 382 | } |
| 383 | h.atCheckpoint("complete-journal") |
| 384 | return nil |
| 385 | }) |
| 386 | } |
| 387 | |
| 388 | // retireIntended settles prior state paths whose intent is durable but whose |
| 389 | // materialization is not. PrepareState verifies absence before persisting the |
| 390 | // intent and refuses an intended name it finds present, so with the prior |
| 391 | // controller fenced dead, the successor barred and without effects or path |
| 392 | // inventory, the only writer of such a name is the prior's own mkdir. An absent |
| 393 | // name moves nothing. A present one must still be an empty directory of the |
| 394 | // recorded type; it is captured into a typed retirement journal and parked by |
| 395 | // inode under exact cleanup authority, so it is preserved, never unlinked. |
| 396 | // Anything else refuses before any artifact moves. |
| 397 | func (h ColdHandover) retireIntended(ctx context.Context, j coldJournal, intended []StopInventoryEntry) error { |
| 398 | if len(intended) == 0 { |
| 399 | return nil |
| 400 | } |
| 401 | if successor, err := h.Inventory.ReadInstallation(ctx, j.Successor.Host, j.Successor.Incarnation, j.Successor.Stamp); err == nil { |
| 402 | for _, entry := range successor.Entries { |
| 403 | if entry.Kind != "route" { |
| 404 | return ErrChanged |
| 405 | } |
| 406 | } |
| 407 | } else if !errors.Is(err, sql.ErrNoRows) { |
| 408 | return err |
| 409 | } |
| 410 | id := j.ID + "-intended" |
| 411 | dir := filepath.Join(h.JournalDir, "retired") |
| 412 | parker := TypedParker{CapturePersistent: h.Guard.Root != nil, Roots: h.Roots, ParkedRoots: h.Roots, Incarnation: j.Prior.Incarnation, Host: j.Prior.Host, Retirement: id} |
| 413 | want := RetirementInventory{Host: j.Prior.Host, Captured: hostguard.Captured{Incarnation: j.Prior.Incarnation, Stamp: j.Prior.Stamp}, Ownership: j.Before.Route.ClaimOwner} |
| 414 | body, err := readTrustedJournal(filepath.Join(dir, id+".json")) |
| 415 | resumed := err == nil |
| 416 | if resumed { |
| 417 | // The captured identities were decided once, durably, by this |
| 418 | // controller. Never recapture: the parked inode must be the journalled one. |
| 419 | var saved RetirementJournal |
| 420 | if decodeStrict(body, &saved) != nil || saved.ID != id || saved.Inventory.Host != want.Host || saved.Inventory.Captured != want.Captured || saved.Inventory.Ownership != want.Ownership { |
| 421 | return ErrTypedJournal |
| 422 | } |
| 423 | want.Artifacts = saved.Inventory.Artifacts |
| 424 | for _, a := range want.Artifacts { |
| 425 | found := 0 |
| 426 | for _, entry := range intended { |
| 427 | if a.Root == entry.ManagedRoot && a.Name == entry.RelativePath { |
| 428 | found++ |
| 429 | } |
| 430 | } |
| 431 | if found != 1 { |
| 432 | return ErrTypedJournal |
| 433 | } |
| 434 | } |
| 435 | } else if !errors.Is(err, os.ErrNotExist) { |
| 436 | return err |
| 437 | } |
| 438 | resumed = resumed || j.IntendedAbsent |
| 439 | for _, entry := range intended { |
| 440 | journalled := false |
| 441 | for _, a := range want.Artifacts { |
| 442 | if a.Root == entry.ManagedRoot && a.Name == entry.RelativePath { |
| 443 | if a.Kind != ArtifactPath || a.Owner != entry.Ownership || entry.Type == 0 || a.Type != entry.Type { |
| 444 | return ErrTypedJournal |
| 445 | } |
| 446 | journalled = true |
| 447 | } |
| 448 | } |
| 449 | if journalled { |
| 450 | continue |
| 451 | } |
| 452 | // Absence is read below the configured root only; a missing root is |
| 453 | // not evidence that the name was never materialized. |
| 454 | if info, err := os.Lstat(h.Roots[entry.ManagedRoot]); err != nil || !info.IsDir() { |
| 455 | return ErrTypedInventory |
| 456 | } |
| 457 | a, err := parker.CaptureArtifact(entry.ManagedRoot, entry.RelativePath, entry.Ownership, ArtifactPath) |
| 458 | if errors.Is(err, fs.ErrNotExist) { |
| 459 | continue |
| 460 | } |
| 461 | if err != nil { |
| 462 | return err |
| 463 | } |
| 464 | // A name that was absent when the journal was written has no recorded |
| 465 | // decision; its later appearance is unknown ownership. |
| 466 | if resumed || entry.Type == 0 || a.Type != entry.Type { |
| 467 | return ErrChanged |
| 468 | } |
| 469 | if err = parker.VerifyEmptyDirectory(a); err != nil { |
| 470 | return err |
| 471 | } |
| 472 | again, err := parker.CaptureArtifact(entry.ManagedRoot, entry.RelativePath, entry.Ownership, ArtifactPath) |
| 473 | if err != nil || again != a { |
| 474 | return ErrChanged |
| 475 | } |
| 476 | want.Artifacts = append(want.Artifacts, a) |
| 477 | } |
| 478 | if len(want.Artifacts) == 0 { |
| 479 | if j.IntendedAbsent { |
| 480 | return nil |
| 481 | } |
| 482 | // Persist the all-absent decision before the main retirement can move |
| 483 | // anything. Replay must not attribute a newly appearing name to prior. |
| 484 | return h.Guard.CleanupWith(ctx, j.Prior, h.VerifyReceipt, func() error { |
| 485 | path := filepath.Join(h.JournalDir, j.ID+".json") |
| 486 | body, err := readTrustedJournal(path) |
| 487 | if err != nil { |
| 488 | return err |
| 489 | } |
| 490 | var saved coldJournal |
| 491 | if decodeStrict(body, &saved) != nil || saved.ID != j.ID || saved.Prior != j.Prior || saved.Successor != j.Successor || saved.Phase != "prepared" { |
| 492 | return ErrTypedJournal |
| 493 | } |
| 494 | saved.IntendedAbsent = true |
| 495 | return writeColdJournal(path, saved) |
| 496 | }) |
| 497 | } |
| 498 | intent, err := NewRetirementJournal(id, want) |
| 499 | if err != nil { |
| 500 | return err |
| 501 | } |
| 502 | target := j.Prior |
| 503 | intent.Target = &target |
| 504 | intent.StopReceipt = append([]byte(nil), j.Receipt.Bytes...) |
| 505 | retire := TypedRetirement{JournalDir: dir, Authority: InstallationCleanup{Guard: h.Guard, Verify: h.VerifyReceipt}, Filesystem: parker, Rows: RegistryRows{Registry: h.Registry, Inventory: want}, Ack: func(context.Context, RetirementJournal) error { return nil }} |
| 506 | if err = retire.Run(ctx, j.Prior, intent); err != nil { |
| 507 | return err |
| 508 | } |
| 509 | // Every captured name is parked now; one present again is not the prior's. |
| 510 | for _, a := range want.Artifacts { |
| 511 | if _, err := parker.CaptureArtifact(a.Root, a.Name, a.Owner, ArtifactPath); !errors.Is(err, fs.ErrNotExist) { |
| 512 | if err != nil { |
| 513 | return err |
| 514 | } |
| 515 | return ErrChanged |
| 516 | } |
| 517 | } |
| 518 | return nil |
| 519 | } |
| 520 | |
| 521 | func writeColdJournal(path string, j coldJournal) error { |
| 522 | if err := checkTrustedJournalDir(filepath.Dir(path), true); err != nil { |
| 523 | return err |
| 524 | } |
| 525 | body, err := json.Marshal(j) |
| 526 | if err != nil { |
| 527 | return err |
| 528 | } |
| 529 | file, err := os.CreateTemp(filepath.Dir(path), ".handover-*") |
| 530 | if err != nil { |
| 531 | return err |
| 532 | } |
| 533 | defer os.Remove(file.Name()) |
| 534 | if _, err = file.Write(body); err == nil { |
| 535 | err = file.Sync() |
| 536 | } |
| 537 | if closeErr := file.Close(); err == nil { |
| 538 | err = closeErr |
| 539 | } |
| 540 | if err != nil { |
| 541 | return err |
| 542 | } |
| 543 | if err = os.Rename(file.Name(), path); err != nil { |
| 544 | return err |
| 545 | } |
| 546 | dir, err := os.Open(filepath.Dir(path)) |
| 547 | if err != nil { |
| 548 | return err |
| 549 | } |
| 550 | defer dir.Close() |
| 551 | return dir.Sync() |
| 552 | } |
| 553 | func (j *LaunchJournal) HasInstallationEffects(ctx context.Context, host, incarnation string, stamp uint64) (bool, error) { |
| 554 | if j == nil { |
| 555 | return false, ErrUnsupported |
| 556 | } |
| 557 | if j.remote != nil { |
| 558 | out, err := journalCall(ctx, j.remote, JournalRequest{Op: "has-effects", Target: hostguard.FenceTarget{Host: host, Incarnation: incarnation, Stamp: stamp}}) |
| 559 | return out.Found, err |
| 560 | } |
| 561 | |
| 562 | if j == nil || j.db == nil { |
| 563 | return false, ErrUnsupported |
| 564 | } |
| 565 | rows, err := journalExecutorFor(ctx, j.db).QueryContext(ctx, "SELECT body FROM launches") |
| 566 | if err != nil { |
| 567 | return false, err |
| 568 | } |
| 569 | defer rows.Close() |
| 570 | for rows.Next() { |
| 571 | var body []byte |
| 572 | if err = rows.Scan(&body); err != nil { |
| 573 | return false, err |
| 574 | } |
| 575 | var in LaunchInventory |
| 576 | if err = json.Unmarshal(body, &in); err != nil { |
| 577 | return false, err |
| 578 | } |
| 579 | if !in.Completed.Valid() { |
| 580 | return false, ErrTypedInventory |
| 581 | } |
| 582 | if in.Target.Host == host && in.Target.Incarnation == incarnation && in.Target.Stamp == stamp && (in.Completed.Count > 0 || len(in.Reservations) > 0 || len(in.Files) > 0 || len(in.ProducerIDs) > 0) { |
| 583 | return true, nil |
| 584 | } |
| 585 | } |
| 586 | return false, rows.Err() |
| 587 | } |
| 588 | |
| 589 | func coldNamespaceEmpty(roots map[string]string, host string, operational ...string) error { |
| 590 | for name, root := range roots { |
| 591 | if !component(name) || !filepath.IsAbs(root) { |
| 592 | return ErrTypedInventory |
| 593 | } |
| 594 | info, err := os.Lstat(root) |
| 595 | if err != nil { |
| 596 | return err |
| 597 | } |
| 598 | if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { |
| 599 | return ErrTypedInventory |
| 600 | } |
| 601 | err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { |
| 602 | if walkErr != nil { |
| 603 | return walkErr |
| 604 | } |
| 605 | rel, err := filepath.Rel(root, path) |
| 606 | if err != nil { |
| 607 | return err |
| 608 | } |
| 609 | if rel == "." { |
| 610 | return nil |
| 611 | } |
| 612 | for _, dir := range operational { |
| 613 | if dir != "" && path == filepath.Clean(dir) && entry.IsDir() { |
| 614 | return filepath.SkipDir |
| 615 | } |
| 616 | } |
| 617 | if entry.IsDir() && (rel == ".parked" || rel == "hostguard" || rel == ".hostclaims" || (name == "opt" && rel == "releases")) { |
| 618 | return filepath.SkipDir |
| 619 | } |
| 620 | if strings.Contains(rel, host) || strings.Contains(rel, strings.ReplaceAll(host, "-", `\x2d`)) { |
| 621 | return ErrTypedInventory |
| 622 | } |
| 623 | return nil |
| 624 | }) |
| 625 | if err != nil { |
| 626 | return err |
| 627 | } |
| 628 | } |
| 629 | return nil |
| 630 | } |
| 631 | |
| 632 | func (h ColdHandover) Recover(ctx context.Context) error { |
| 633 | entries, err := readTrustedJournalDir(h.JournalDir) |
| 634 | if err != nil { |
| 635 | return err |
| 636 | } |
| 637 | if len(entries) == 0 { |
| 638 | return nil |
| 639 | } |
| 640 | pending, err := h.Guard.PendingFences(ctx) |
| 641 | if err != nil { |
| 642 | return err |
| 643 | } |
| 644 | var settleErr error |
| 645 | for _, entry := range entries { |
| 646 | if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { |
| 647 | continue |
| 648 | } |
| 649 | body, err := readTrustedJournal(filepath.Join(h.JournalDir, entry.Name())) |
| 650 | if err != nil { |
| 651 | return err |
| 652 | } |
| 653 | var j coldJournal |
| 654 | if decodeStrict(body, &j) != nil || j.ID+".json" != entry.Name() || j.V != 1 { |
| 655 | return ErrTypedJournal |
| 656 | } |
| 657 | if j.Phase == "complete" { |
| 658 | needsFinish := false |
| 659 | for _, target := range pending { |
| 660 | if target == j.Barrier { |
| 661 | needsFinish = true |
| 662 | } |
| 663 | } |
| 664 | if !needsFinish { |
| 665 | continue |
| 666 | } |
| 667 | } |
| 668 | if j.Phase == "settled" { |
| 669 | continue |
| 670 | } |
| 671 | if h.Obsolete != nil && h.Obsolete(j.Successor) { |
| 672 | // A failed settlement leaves the handover as it was, for the |
| 673 | // next startup; the other journals are still recovered. |
| 674 | settleErr = errors.Join(settleErr, h.settleObsolete(ctx, filepath.Join(h.JournalDir, entry.Name()), j)) |
| 675 | continue |
| 676 | } |
| 677 | if err = h.Run(ctx, j.Prior, j.Successor); err != nil { |
| 678 | if h.Guard.Root != nil && h.Guard.AbandonColdHandover(ctx, j.Barrier) == nil { |
| 679 | continue |
| 680 | } |
| 681 | return errors.Join(settleErr, err) |
| 682 | } |
| 683 | } |
| 684 | return settleErr |
| 685 | } |
| 686 | |
| 687 | // settleObsolete settles a journaled cold handover whose successor binding |
| 688 | // is obsolete (roster binding B3), whatever phase it reached short of |
| 689 | // complete. The guard clears the barrier and retires the successor's stamp |
| 690 | // under the host lock, then the journal is rewritten as "settled": Recover |
| 691 | // skips it from then on and Run refuses it, so the successor is never |
| 692 | // resumed or reused. The predecessor is untouched: its receipt and active |
| 693 | // stamp stay, so its stop and retirement still complete, and a new |
| 694 | // successor admitted under today's binding can take over from it. If the |
| 695 | // journal rewrite fails, the next startup settles again; the guard step is |
| 696 | // idempotent. |
| 697 | func (h ColdHandover) settleObsolete(ctx context.Context, path string, j coldJournal) error { |
| 698 | if j.Phase != "prepared" && j.Phase != "parked" && j.Phase != "complete" { |
| 699 | return ErrTypedJournal |
| 700 | } |
| 701 | return h.Guard.SettleObsoleteColdHandover(ctx, j.Prior, j.Barrier, func() error { |
| 702 | j.Phase = "settled" |
| 703 | return writeColdJournal(path, j) |
| 704 | }) |
| 705 | } |
| 706 | |
| 707 | func (h ColdHandover) atCheckpoint(point string) { |
| 708 | if h.checkpoint != nil { |
| 709 | h.checkpoint(point) |
| 710 | } |
| 711 | } |
| 712 | |