| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "io" |
| 11 | "log" |
| 12 | "os" |
| 13 | "reflect" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/hostbox" |
| 17 | "github.com/carlosframework/platform/internal/hostguard" |
| 18 | "github.com/carlosframework/platform/internal/rootstate" |
| 19 | ) |
| 20 | |
| 21 | // servingParkLedger runs behind root authorization and the journal transport. |
| 22 | // Its callbacks are private implementation seams; requests cannot supply a |
| 23 | // source, disposition, timestamp or completion. |
| 24 | type servingParkLedger struct { |
| 25 | historical bool |
| 26 | state rootstate.Store |
| 27 | fs servingParkFilesystem |
| 28 | journal *hostbox.LaunchJournal |
| 29 | now func() time.Time |
| 30 | } |
| 31 | |
| 32 | type servingParkFile struct { |
| 33 | Persistent servingParkPersistentFile `json:",omitzero"` |
| 34 | Name string |
| 35 | Present bool |
| 36 | Device, Inode, Mount, Bytes uint64 |
| 37 | Mode, Links uint32 |
| 38 | Moved, DeleteIntent, Deleted bool |
| 39 | } |
| 40 | |
| 41 | type servingParkSource struct { |
| 42 | ParentPersistent servingParkPersistentDirectory `json:",omitzero"` |
| 43 | Volume string `json:",omitempty"` |
| 44 | Persistent servingParkPersistentDirectory `json:",omitzero"` |
| 45 | BootID string `json:",omitempty"` |
| 46 | Directory servingArtifactDirectory |
| 47 | Parent servingArtifactDirectory |
| 48 | Files [4]servingParkFile |
| 49 | } |
| 50 | |
| 51 | type servingParkRecord struct { |
| 52 | EventPersistent servingParkPersistentDirectory `json:",omitzero"` |
| 53 | CollectAfter time.Time `json:",omitempty"` |
| 54 | Namespace servingArtifactDirectory `json:",omitempty"` |
| 55 | LegacyEvidence *servingLegacyEvidence `json:",omitempty"` |
| 56 | Continuations []servingParkContinuation `json:",omitempty"` |
| 57 | EventRemoveIntent, EventRemoved bool `json:",omitempty"` |
| 58 | Version int |
| 59 | Authorization servingParkAuthorization |
| 60 | Source servingParkSource |
| 61 | Receipts []hostguard.FenceReceipt |
| 62 | Disposition servingParkDisposition |
| 63 | Event servingArtifactDirectory |
| 64 | Prepared, Parked, Complete, Deleting, Closed bool |
| 65 | ParkedAt, ReapAfter time.Time |
| 66 | Cause string |
| 67 | } |
| 68 | |
| 69 | type servingParkContinuation struct { |
| 70 | Authorization servingParkAuthorization |
| 71 | Receipts []hostguard.FenceReceipt |
| 72 | } |
| 73 | |
| 74 | func (r servingParkRecord) activeAuthorization() servingParkAuthorization { |
| 75 | if len(r.Continuations) != 0 { |
| 76 | return r.Continuations[len(r.Continuations)-1].Authorization |
| 77 | } |
| 78 | return r.Authorization |
| 79 | } |
| 80 | |
| 81 | type servingLegacyConsumption struct { |
| 82 | Collected bool `json:",omitempty"` |
| 83 | LegacyEvidence *servingLegacyEvidence `json:",omitempty"` |
| 84 | Receipts []hostguard.FenceReceipt `json:",omitempty"` |
| 85 | Authorization servingParkAuthorization |
| 86 | Source servingParkSource |
| 87 | } |
| 88 | |
| 89 | type servingParkSlot struct { |
| 90 | Version int |
| 91 | Box, Host, ID, Pending, Last string |
| 92 | } |
| 93 | |
| 94 | type servingParkFilesystem interface { |
| 95 | // Capture validates a root-authenticated source before any slot is changed. |
| 96 | Capture(servingParkAuthorization) (servingParkSource, error) |
| 97 | Prepare(*servingParkRecord, func() error) error |
| 98 | Move(*servingParkRecord, func() error) error |
| 99 | Delete(*servingParkRecord, func() error) error |
| 100 | CheckParked(*servingParkRecord) error |
| 101 | } |
| 102 | |
| 103 | func servingParkKey(box, host string) string { |
| 104 | sum := sha256.Sum256([]byte(box + "\x00" + host)) |
| 105 | return hex.EncodeToString(sum[:]) |
| 106 | } |
| 107 | |
| 108 | func servingParkDecode(body []byte, out any) error { |
| 109 | d := json.NewDecoder(bytes.NewReader(body)) |
| 110 | d.DisallowUnknownFields() |
| 111 | if d.Decode(out) != nil || d.Decode(new(any)) != io.EOF { |
| 112 | return hostbox.ErrChanged |
| 113 | } |
| 114 | return nil |
| 115 | } |
| 116 | |
| 117 | func (l *servingParkLedger) write(name string, value any) error { |
| 118 | body, err := json.Marshal(value) |
| 119 | if err != nil { |
| 120 | return err |
| 121 | } |
| 122 | return l.state.Write(name, body) |
| 123 | } |
| 124 | |
| 125 | func (l *servingParkLedger) read(id string) (servingParkRecord, error) { |
| 126 | r, err := l.readHistorical(id) |
| 127 | if err == nil { |
| 128 | err = l.bindRecord(&r, false) |
| 129 | } |
| 130 | if err == nil { |
| 131 | err = validateServingParkRecord(r) |
| 132 | } |
| 133 | return r, err |
| 134 | } |
| 135 | |
| 136 | func (l *servingParkLedger) readHistorical(id string) (servingParkRecord, error) { |
| 137 | var r servingParkRecord |
| 138 | if len(id) != 64 { |
| 139 | return r, hostbox.ErrChanged |
| 140 | } |
| 141 | if _, err := hex.DecodeString(id); err != nil { |
| 142 | return r, hostbox.ErrChanged |
| 143 | } |
| 144 | body, err := l.state.Read("retirement-" + id + ".json") |
| 145 | if err != nil { |
| 146 | return r, err |
| 147 | } |
| 148 | if err = servingParkDecode(body, &r); err != nil { |
| 149 | return r, err |
| 150 | } |
| 151 | if err = validateServingParkRecord(r); err != nil || r.Authorization.ID != id { |
| 152 | return r, hostbox.ErrChanged |
| 153 | } |
| 154 | return r, nil |
| 155 | } |
| 156 | |
| 157 | func validateServingParkRecord(r servingParkRecord) error { |
| 158 | if err := validateServingParkPersistentRecord(r); err != nil { |
| 159 | return err |
| 160 | } |
| 161 | a := r.Authorization |
| 162 | s := a.OriginalSuccessor |
| 163 | if r.Version != 1 || a.ID != servingRetirementID(a.Predecessor, s, a.Legacy) || s.Host == "" || s.Box == "" || s.Incarnation == "" || s.Generation == "" || s.Region == "" || s.Location == "" || s.Stamp == 0 || s.Stamp != s.Session || a.Successor != s || a.Directory != r.Source.Directory || r.Source.Directory.Inode == 0 || r.Source.Directory.Device == 0 || r.Source.Directory.Mount == 0 || r.Source.Parent.Inode == 0 || r.Source.Parent.Device != r.Source.Directory.Device || r.Source.Parent.Mount != r.Source.Directory.Mount { |
| 164 | return hostbox.ErrChanged |
| 165 | } |
| 166 | if a.Legacy { |
| 167 | if a.Predecessor != (servingArtifactIdentity{}) || a.TagsFrom != 0 || r.Source.Files[1].Present { |
| 168 | return hostbox.ErrChanged |
| 169 | } |
| 170 | } else { |
| 171 | p := a.Predecessor |
| 172 | if p.Host != s.Host || p.Box != s.Box || p.Region != s.Region || p.Incarnation != s.Incarnation || p.Location != s.Location || p.Generation == "" || p.Generation == s.Generation || p.Stamp == 0 || p.Stamp != p.Session || p.Stamp >= s.Stamp || a.TagsFrom != p.Stamp { |
| 173 | return hostbox.ErrChanged |
| 174 | } |
| 175 | } |
| 176 | if r.LegacyEvidence != nil && validateServingLegacyEvidence(a, r.LegacyEvidence) != nil { |
| 177 | return hostbox.ErrChanged |
| 178 | } |
| 179 | noProcess := a.Legacy && r.LegacyEvidence != nil && r.LegacyEvidence.NoProcess != nil |
| 180 | if (len(r.Receipts) == 0) != noProcess { |
| 181 | return hostbox.ErrChanged |
| 182 | } |
| 183 | seen := make(map[string]bool) |
| 184 | for _, receipt := range r.Receipts { |
| 185 | t := receipt.Target |
| 186 | if t.Host != s.Host || t.Box != s.Box || t.Region != s.Region || t.Incarnation != s.Incarnation || t.Stamp == 0 || t.Stamp >= s.Stamp || t.Session >= s.Session || t.Op != "" || t.Phase != "" || t.ObligationID != "" || t.FenceID == "" || seen[t.FenceID] || verifyRetirementReceipt(context.Background(), receipt) != nil { |
| 187 | return hostbox.ErrChanged |
| 188 | } |
| 189 | if a.Legacy && r.LegacyEvidence != nil && t.Session != r.LegacyEvidence.Selector.Session { |
| 190 | return hostbox.ErrChanged |
| 191 | } |
| 192 | if !a.Legacy && (t.Stamp != a.Predecessor.Stamp || t.Session != a.Predecessor.Session) { |
| 193 | return hostbox.ErrChanged |
| 194 | } |
| 195 | seen[t.FenceID] = true |
| 196 | } |
| 197 | for i, suffix := range []string{"", ".gen", "-wal", "-shm"} { |
| 198 | f := r.Source.Files[i] |
| 199 | if f.Name != s.Host+".db"+suffix { |
| 200 | return hostbox.ErrChanged |
| 201 | } |
| 202 | if f.Present { |
| 203 | if f.Device != r.Source.Directory.Device || f.Mount != r.Source.Directory.Mount || f.Inode == 0 || f.Mode != 0100000 || f.Links != 1 { |
| 204 | return hostbox.ErrChanged |
| 205 | } |
| 206 | } else if f.Device != 0 || f.Inode != 0 || f.Mount != 0 || f.Mode != 0 || f.Links != 0 || f.Bytes != 0 || f.Moved || f.DeleteIntent || f.Deleted { |
| 207 | return hostbox.ErrChanged |
| 208 | } |
| 209 | if f.Deleted && (!f.DeleteIntent || !r.Deleting) || f.DeleteIntent && !f.Moved { |
| 210 | return hostbox.ErrChanged |
| 211 | } |
| 212 | } |
| 213 | if r.Disposition != servingParkRetain && r.Disposition != servingParkDiscard && r.Disposition != servingParkArtifactFree { |
| 214 | return hostbox.ErrChanged |
| 215 | } |
| 216 | if (r.Disposition == servingParkArtifactFree) == r.Source.Files[0].Present { |
| 217 | return hostbox.ErrChanged |
| 218 | } |
| 219 | if r.Prepared && (r.Event.Inode == 0 || r.Event.Device != r.Source.Directory.Device || r.Event.Mount != r.Source.Directory.Mount) { |
| 220 | return hostbox.ErrChanged |
| 221 | } |
| 222 | if r.Namespace != (servingArtifactDirectory{}) && (!r.Prepared || r.Namespace.Inode == 0 || r.Namespace.Device != r.Source.Parent.Device || r.Namespace.Mount != r.Source.Parent.Mount || r.Namespace == r.Source.Parent || r.Namespace == r.Event) { |
| 223 | return hostbox.ErrChanged |
| 224 | } |
| 225 | if r.Parked { |
| 226 | if !r.Prepared || r.ParkedAt.IsZero() || !r.ReapAfter.Equal(r.ParkedAt.Add(7*24*time.Hour)) { |
| 227 | return hostbox.ErrChanged |
| 228 | } |
| 229 | for _, f := range r.Source.Files { |
| 230 | if f.Present && !f.Moved { |
| 231 | return hostbox.ErrChanged |
| 232 | } |
| 233 | } |
| 234 | } else if !r.ParkedAt.IsZero() || !r.ReapAfter.IsZero() || r.Complete { |
| 235 | return hostbox.ErrChanged |
| 236 | } |
| 237 | if r.Deleting && (r.Cause != "discard" && r.Cause != "replacement" && r.Cause != "expiry" && r.Cause != "pressure") { |
| 238 | return hostbox.ErrChanged |
| 239 | } |
| 240 | if r.Deleting && !r.Parked { |
| 241 | return hostbox.ErrChanged |
| 242 | } |
| 243 | if r.EventRemoved && !r.EventRemoveIntent || r.EventRemoveIntent && !r.Deleting { |
| 244 | return hostbox.ErrChanged |
| 245 | } |
| 246 | if r.EventRemoveIntent { |
| 247 | for _, item := range r.Source.Files { |
| 248 | if item.Present && !item.Deleted { |
| 249 | return hostbox.ErrChanged |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | if r.Closed && !r.Deleting { |
| 254 | return hostbox.ErrChanged |
| 255 | } |
| 256 | if !r.CollectAfter.IsZero() && (!r.Complete || !r.Closed || !r.EventRemoved || r.Namespace == (servingArtifactDirectory{}) || r.CollectAfter.Before(r.ParkedAt.Add(hostbox.JournalReceiptRetention))) { |
| 257 | return hostbox.ErrChanged |
| 258 | } |
| 259 | if r.Closed { |
| 260 | for _, item := range r.Source.Files { |
| 261 | if item.Present && !item.Deleted { |
| 262 | return hostbox.ErrChanged |
| 263 | } |
| 264 | } |
| 265 | } |
| 266 | if r.Complete && r.Disposition != servingParkRetain && !r.Closed { |
| 267 | return hostbox.ErrChanged |
| 268 | } |
| 269 | previous := a |
| 270 | if len(r.Continuations) > 32 { |
| 271 | return hostbox.ErrChanged |
| 272 | } |
| 273 | for _, continuation := range r.Continuations { |
| 274 | next := continuation.Authorization |
| 275 | want := previous |
| 276 | want.Successor = next.Successor |
| 277 | want.TagsFrom = next.TagsFrom |
| 278 | identity := previous.Successor |
| 279 | identity.Stamp, identity.Session, identity.Generation = next.Successor.Stamp, next.Successor.Session, next.Successor.Generation |
| 280 | if next != want || next.Successor != identity || identity.Stamp <= previous.Successor.Stamp || identity.Session != identity.Stamp || identity.Generation == "" || identity.Generation == previous.Successor.Generation || (next.TagsFrom != previous.TagsFrom && next.TagsFrom != previous.Successor.Stamp && next.TagsFrom != next.Successor.Stamp) || len(continuation.Receipts) == 0 || !r.Parked { |
| 281 | return hostbox.ErrChanged |
| 282 | } |
| 283 | for _, receipt := range continuation.Receipts { |
| 284 | t := receipt.Target |
| 285 | p := previous.Successor |
| 286 | if t.Host != p.Host || t.Box != p.Box || t.Region != p.Region || t.Incarnation != p.Incarnation || t.Stamp != p.Stamp || t.Session != p.Session || t.Op != "" || t.Phase != "" || t.ObligationID != "" || verifyRetirementReceipt(context.Background(), receipt) != nil { |
| 287 | return hostbox.ErrChanged |
| 288 | } |
| 289 | } |
| 290 | previous = next |
| 291 | } |
| 292 | return nil |
| 293 | } |
| 294 | |
| 295 | func (l *servingParkLedger) save(r servingParkRecord) error { |
| 296 | if err := l.bindRecord(&r, true); err != nil { |
| 297 | return err |
| 298 | } |
| 299 | if err := validateServingParkRecord(r); err != nil { |
| 300 | return err |
| 301 | } |
| 302 | return l.write("retirement-"+r.Authorization.ID+".json", r) |
| 303 | } |
| 304 | |
| 305 | func (l *servingParkLedger) slot(a servingParkAuthorization) (servingParkSlot, error) { |
| 306 | s := servingParkSlot{Version: 1, Box: a.Successor.Box, Host: a.Successor.Host} |
| 307 | b, err := l.state.Read("slot-" + servingParkKey(s.Box, s.Host) + ".json") |
| 308 | if errors.Is(err, os.ErrNotExist) { |
| 309 | return s, nil |
| 310 | } |
| 311 | if err != nil { |
| 312 | return s, err |
| 313 | } |
| 314 | var saved servingParkSlot |
| 315 | if servingParkDecode(b, &saved) != nil || saved.Version != 1 || saved.Box != s.Box || saved.Host != s.Host { |
| 316 | return s, hostbox.ErrChanged |
| 317 | } |
| 318 | if saved.ID != "" { |
| 319 | r, err := l.readHistorical(saved.ID) |
| 320 | if err != nil || r.Disposition != servingParkRetain || r.Authorization.Successor.Box != s.Box || r.Authorization.Successor.Host != s.Host { |
| 321 | return s, hostbox.ErrChanged |
| 322 | } |
| 323 | } |
| 324 | if saved.Pending != "" { |
| 325 | r, err := l.read(saved.Pending) |
| 326 | if err != nil || r.Authorization.Successor.Box != s.Box || r.Authorization.Successor.Host != s.Host { |
| 327 | return s, hostbox.ErrChanged |
| 328 | } |
| 329 | } |
| 330 | if saved.Last != "" { |
| 331 | r, err := l.readHistorical(saved.Last) |
| 332 | if err != nil || r.Authorization.Successor.Box != s.Box || r.Authorization.Successor.Host != s.Host { |
| 333 | return s, hostbox.ErrChanged |
| 334 | } |
| 335 | } |
| 336 | return saved, nil |
| 337 | } |
| 338 | |
| 339 | // withNamespace precedes the root host guard, slot lock and record lock. The |
| 340 | // authority callback must acquire the host guard and independently authenticate |
| 341 | // the claim, receipts, state directory and launch history while it is held. |
| 342 | func (l *servingParkLedger) withNamespace(ctx context.Context, fn func() error) error { |
| 343 | if ctx == nil || l == nil || l.state == nil || l.fs == nil || l.now == nil || fn == nil { |
| 344 | return hostbox.ErrUnsupported |
| 345 | } |
| 346 | unlock, err := l.state.Lock(ctx, "namespace.lock") |
| 347 | if err != nil { |
| 348 | return err |
| 349 | } |
| 350 | defer unlock() |
| 351 | if err = ctx.Err(); err != nil { |
| 352 | return err |
| 353 | } |
| 354 | return fn() |
| 355 | } |
| 356 | |
| 357 | // parkLocked runs inside independently verified root host authority. Fresh |
| 358 | // capture is never substituted for the immutable source on a replay. |
| 359 | func (l *servingParkLedger) parkLocked(ctx context.Context, a servingParkAuthorization, receipts []hostguard.FenceReceipt) (servingParkRecord, error) { |
| 360 | return l.parkWithEvidenceLocked(ctx, a, receipts, nil) |
| 361 | } |
| 362 | |
| 363 | // capture validates the full source before namespace recovery may write any |
| 364 | // authority. The ledger repeats it before saving a fresh retirement intent. |
| 365 | func (l *servingParkLedger) capture(ctx context.Context, a servingParkAuthorization, receipts []hostguard.FenceReceipt, legacy *servingLegacyEvidence) (servingParkRecord, error) { |
| 366 | var zero servingParkRecord |
| 367 | source, e := l.fs.Capture(a) |
| 368 | if e != nil { |
| 369 | return zero, e |
| 370 | } |
| 371 | disposition := servingParkArtifactFree |
| 372 | if a.Legacy { |
| 373 | if source.Files[1].Present { |
| 374 | return zero, hostbox.ErrChanged |
| 375 | } |
| 376 | if source.Files[0].Present { |
| 377 | disposition = servingParkRetain |
| 378 | } |
| 379 | } else { |
| 380 | disposition, e = servingGenerationParkDisposition(ctx, l.journal, a.Predecessor, receipts, source.Files[0].Present) |
| 381 | if e != nil { |
| 382 | return zero, e |
| 383 | } |
| 384 | } |
| 385 | r := servingParkRecord{Version: 1, Authorization: a, Source: source, Receipts: receipts, LegacyEvidence: legacy, Disposition: disposition} |
| 386 | if legacy != nil && legacy.Selector.Empty { |
| 387 | for _, f := range source.Files { |
| 388 | if f.Present { |
| 389 | return zero, hostbox.ErrChanged |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | if e = validateServingParkRecord(r); e != nil { |
| 394 | return zero, e |
| 395 | } |
| 396 | return r, nil |
| 397 | } |
| 398 | |
| 399 | func (l *servingParkLedger) parkWithEvidenceLocked(ctx context.Context, a servingParkAuthorization, receipts []hostguard.FenceReceipt, legacy *servingLegacyEvidence) (servingParkRecord, error) { |
| 400 | var zero servingParkRecord |
| 401 | if err := ctx.Err(); err != nil { |
| 402 | return zero, err |
| 403 | } |
| 404 | unlock, err := l.state.Lock(ctx, "slot-"+servingParkKey(a.Successor.Box, a.Successor.Host)+".lock") |
| 405 | if err != nil { |
| 406 | return zero, err |
| 407 | } |
| 408 | defer unlock() |
| 409 | slot, err := l.slot(a) |
| 410 | if err != nil { |
| 411 | return zero, err |
| 412 | } |
| 413 | if slot.Pending != "" && slot.Pending != a.ID { |
| 414 | return zero, hostbox.ErrChanged |
| 415 | } |
| 416 | unlockRecord, err := l.state.Lock(ctx, "retirement-"+a.ID+".lock") |
| 417 | if err != nil { |
| 418 | return zero, err |
| 419 | } |
| 420 | defer unlockRecord() |
| 421 | r, err := l.read(a.ID) |
| 422 | if errors.Is(err, os.ErrNotExist) { |
| 423 | // Last survives collection. An old request cannot reconstruct a removed |
| 424 | // intent from a restored source in the same incarnation. The outer root |
| 425 | // guard independently requires the current incarnation and successor. |
| 426 | if slot.Last != "" { |
| 427 | last, e := l.readHistorical(slot.Last) |
| 428 | if e != nil { |
| 429 | return zero, e |
| 430 | } |
| 431 | prior := last.activeAuthorization().Successor |
| 432 | if prior.Incarnation == a.Successor.Incarnation && a.Successor.Stamp <= prior.Stamp { |
| 433 | return zero, hostbox.ErrChanged |
| 434 | } |
| 435 | } |
| 436 | captured, e := l.capture(ctx, a, receipts, legacy) |
| 437 | if e != nil { |
| 438 | return zero, e |
| 439 | } |
| 440 | r = captured |
| 441 | source := r.Source |
| 442 | // Consumption survives slot replacement, expiry and park tombstones. |
| 443 | if a.Legacy { |
| 444 | key := "legacy-" + servingParkKey(a.Successor.Box, a.Successor.Host+"\x00"+a.Successor.Incarnation) + ".json" |
| 445 | var consumed servingLegacyConsumption |
| 446 | want := servingLegacyConsumption{Authorization: a, Source: source, Receipts: receipts, LegacyEvidence: legacy} |
| 447 | body, e := l.state.Read(key) |
| 448 | if e == nil { |
| 449 | if servingParkDecode(body, &consumed) != nil || !sameServingParkSource(consumed.Source, want.Source) { |
| 450 | return zero, hostbox.ErrChanged |
| 451 | } |
| 452 | // A lost old-format intent keeps its original consumption |
| 453 | // evidence. Never infer historical handles from current bytes. |
| 454 | want.Source = consumed.Source |
| 455 | if !reflect.DeepEqual(consumed, want) { |
| 456 | return zero, hostbox.ErrChanged |
| 457 | } |
| 458 | r.Source = consumed.Source |
| 459 | } else if !errors.Is(e, os.ErrNotExist) { |
| 460 | return zero, e |
| 461 | } |
| 462 | if e = l.write(key, want); e != nil { |
| 463 | return zero, e |
| 464 | } |
| 465 | } |
| 466 | if e = l.save(r); e != nil { |
| 467 | return zero, e |
| 468 | } |
| 469 | } else if err != nil { |
| 470 | return zero, err |
| 471 | } |
| 472 | if r.Authorization != a || !sameServingLegacyEvidence(r.LegacyEvidence, legacy) || !sameServingRootStops(r.Receipts, receipts) { |
| 473 | return zero, hostbox.ErrChanged |
| 474 | } |
| 475 | if r.Closed || r.Complete { |
| 476 | return r, nil |
| 477 | } |
| 478 | if slot.Pending == "" { |
| 479 | slot.Pending = a.ID |
| 480 | slot.Last = a.ID |
| 481 | if err = l.write("slot-"+servingParkKey(slot.Box, slot.Host)+".json", slot); err != nil { |
| 482 | return zero, err |
| 483 | } |
| 484 | } |
| 485 | if r.Deleting { |
| 486 | if r.Disposition == servingParkRetain || r.Cause != "discard" { |
| 487 | return zero, hostbox.ErrChanged |
| 488 | } |
| 489 | if err = l.deleteLocked(ctx, &r, "discard"); err != nil { |
| 490 | return zero, err |
| 491 | } |
| 492 | return r, nil |
| 493 | } |
| 494 | if r.Disposition == servingParkRetain && slot.ID != a.ID { |
| 495 | // A completed replay can never reacquire a slot it has lost. |
| 496 | if r.Parked || r.Complete { |
| 497 | return zero, hostbox.ErrChanged |
| 498 | } |
| 499 | fresh, e := l.fs.Capture(a) |
| 500 | if e != nil || !sameServingParkSource(r.Source, fresh) { |
| 501 | return zero, hostbox.ErrChanged |
| 502 | } |
| 503 | if slot.ID != "" { |
| 504 | if e = l.replaceLocked(ctx, slot.ID); e != nil { |
| 505 | return zero, e |
| 506 | } |
| 507 | } |
| 508 | slot.ID = a.ID |
| 509 | if err = l.write("slot-"+servingParkKey(slot.Box, slot.Host)+".json", slot); err != nil { |
| 510 | return zero, err |
| 511 | } |
| 512 | } |
| 513 | save := func() error { |
| 514 | if err := ctx.Err(); err != nil { |
| 515 | return err |
| 516 | } |
| 517 | return l.save(r) |
| 518 | } |
| 519 | if err = l.fs.Prepare(&r, save); err != nil { |
| 520 | return zero, err |
| 521 | } |
| 522 | if err = l.fs.Move(&r, save); err != nil { |
| 523 | return zero, err |
| 524 | } |
| 525 | if !r.Parked { |
| 526 | r.Parked, r.ParkedAt = true, l.now().UTC() |
| 527 | r.ReapAfter = r.ParkedAt.Add(7 * 24 * time.Hour) |
| 528 | if err = save(); err != nil { |
| 529 | return zero, err |
| 530 | } |
| 531 | } |
| 532 | // Even discarded restores are first isolated by recorded rename into the |
| 533 | // root-owned event. A raced source replacement is never unlinked in place. |
| 534 | if r.Disposition != servingParkRetain && !r.Closed { |
| 535 | if err = l.deleteLocked(ctx, &r, "discard"); err != nil { |
| 536 | return zero, err |
| 537 | } |
| 538 | } |
| 539 | return r, nil |
| 540 | } |
| 541 | |
| 542 | // completeLocked follows a fresh root check of the successor's paired tags. |
| 543 | // Its caller holds namespace and host authority, never an agent Work lock. |
| 544 | func (l *servingParkLedger) completeLocked(ctx context.Context, id string, a servingArtifactIdentity) error { |
| 545 | unlock, err := l.state.Lock(ctx, "slot-"+servingParkKey(a.Box, a.Host)+".lock") |
| 546 | if err != nil { |
| 547 | return err |
| 548 | } |
| 549 | defer unlock() |
| 550 | r, err := l.read(id) |
| 551 | if err != nil { |
| 552 | return err |
| 553 | } |
| 554 | if r.activeAuthorization().Successor != a || !r.Parked { |
| 555 | return hostbox.ErrChanged |
| 556 | } |
| 557 | unlockRecord, err := l.state.Lock(ctx, "retirement-"+id+".lock") |
| 558 | if err != nil { |
| 559 | return err |
| 560 | } |
| 561 | defer unlockRecord() |
| 562 | slot, err := l.slot(r.Authorization) |
| 563 | if err != nil { |
| 564 | return err |
| 565 | } |
| 566 | if !r.Complete { |
| 567 | if slot.Pending != id { |
| 568 | return hostbox.ErrChanged |
| 569 | } |
| 570 | if err = l.fs.CheckParked(&r); err != nil { |
| 571 | return err |
| 572 | } |
| 573 | if r.Disposition == servingParkRetain { |
| 574 | if slot.ID != id || r.Closed || r.Deleting { |
| 575 | return hostbox.ErrChanged |
| 576 | } |
| 577 | } else if !r.Closed { |
| 578 | return hostbox.ErrChanged |
| 579 | } |
| 580 | if err = ctx.Err(); err != nil { |
| 581 | return err |
| 582 | } |
| 583 | r.Complete = true |
| 584 | if err = l.save(r); err != nil { |
| 585 | return err |
| 586 | } |
| 587 | } |
| 588 | if slot.Pending == id { |
| 589 | slot.Pending = "" |
| 590 | return l.write("slot-"+servingParkKey(slot.Box, slot.Host)+".json", slot) |
| 591 | } |
| 592 | return nil |
| 593 | } |
| 594 | |
| 595 | // continueLocked records permission from a later stopped successor without |
| 596 | // changing the original retirement, source identities or retention deadline. |
| 597 | // Root has already rechecked the original and every continuation stop. |
| 598 | func (l *servingParkLedger) continueLocked(ctx context.Context, id string, a servingParkAuthorization, receipts []hostguard.FenceReceipt) (servingParkRecord, error) { |
| 599 | var zero servingParkRecord |
| 600 | unlock, err := l.state.Lock(ctx, "slot-"+servingParkKey(a.Successor.Box, a.Successor.Host)+".lock") |
| 601 | if err != nil { |
| 602 | return zero, err |
| 603 | } |
| 604 | defer unlock() |
| 605 | unlockRecord, err := l.state.Lock(ctx, "retirement-"+id+".lock") |
| 606 | if err != nil { |
| 607 | return zero, err |
| 608 | } |
| 609 | defer unlockRecord() |
| 610 | r, err := l.read(id) |
| 611 | if err != nil { |
| 612 | return zero, err |
| 613 | } |
| 614 | if r.activeAuthorization() == a { |
| 615 | return r, nil |
| 616 | } |
| 617 | slot, err := l.slot(a) |
| 618 | if err != nil || slot.Pending != id || r.Complete { |
| 619 | return zero, hostbox.ErrChanged |
| 620 | } |
| 621 | if err = l.fs.CheckParked(&r); err != nil { |
| 622 | return zero, err |
| 623 | } |
| 624 | r.Continuations = append(r.Continuations, servingParkContinuation{Authorization: a, Receipts: receipts}) |
| 625 | if err = ctx.Err(); err != nil { |
| 626 | return zero, err |
| 627 | } |
| 628 | if err = l.save(r); err != nil { |
| 629 | return zero, err |
| 630 | } |
| 631 | return r, nil |
| 632 | } |
| 633 | |
| 634 | func (l *servingParkLedger) deleteLocked(ctx context.Context, r *servingParkRecord, cause string) error { |
| 635 | if r.Closed { |
| 636 | return nil |
| 637 | } |
| 638 | if cause != "discard" && (!r.Complete || r.Disposition != servingParkRetain) { |
| 639 | return hostbox.ErrChanged |
| 640 | } |
| 641 | if cause == "discard" && r.Disposition == servingParkRetain { |
| 642 | return hostbox.ErrChanged |
| 643 | } |
| 644 | if r.Deleting && r.Cause != cause { |
| 645 | return hostbox.ErrChanged |
| 646 | } |
| 647 | save := func() error { |
| 648 | if err := ctx.Err(); err != nil { |
| 649 | return err |
| 650 | } |
| 651 | return l.save(*r) |
| 652 | } |
| 653 | r.Deleting, r.Cause = true, cause |
| 654 | if err := save(); err != nil { |
| 655 | return err |
| 656 | } |
| 657 | if err := l.fs.Delete(r, save); err != nil { |
| 658 | return err |
| 659 | } |
| 660 | r.Closed = true |
| 661 | return save() |
| 662 | } |
| 663 | |
| 664 | func (l *servingParkLedger) replaceLocked(ctx context.Context, id string) error { |
| 665 | unlock, err := l.state.Lock(ctx, "retirement-"+id+".lock") |
| 666 | if err != nil { |
| 667 | return err |
| 668 | } |
| 669 | defer unlock() |
| 670 | old, err := l.readHistorical(id) |
| 671 | if err != nil || !old.Complete { |
| 672 | return hostbox.ErrChanged |
| 673 | } |
| 674 | if old.Closed { |
| 675 | return nil |
| 676 | } |
| 677 | cause := "replacement" |
| 678 | if old.Deleting { |
| 679 | cause = old.Cause |
| 680 | } |
| 681 | current, err := l.read(id) |
| 682 | if err == nil { |
| 683 | err = l.deleteLocked(ctx, ¤t, cause) |
| 684 | } |
| 685 | if err != nil { |
| 686 | log.Printf("Host %q: serving database retirement %q retained: %s.", old.Authorization.Successor.Host, id, err) |
| 687 | } |
| 688 | return ctx.Err() |
| 689 | } |
| 690 | |
| 691 | func sameServingRootStops(a, b []hostguard.FenceReceipt) bool { |
| 692 | if len(a) != len(b) { |
| 693 | return false |
| 694 | } |
| 695 | for i := range a { |
| 696 | if a[i].Target != b[i].Target || !a[i].EvidenceAt.Equal(b[i].EvidenceAt) || !bytes.Equal(a[i].Bytes, b[i].Bytes) { |
| 697 | return false |
| 698 | } |
| 699 | } |
| 700 | return true |
| 701 | } |
| 702 | |