package hostbox import ( "context" "database/sql" "errors" "os" "path/filepath" "reflect" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" ) // ServingHandover retains the stopped installation's state directory. Its // barrier covers inventory and row transfer; database retirement follows later. type ServingHandover struct { ColdHandover Validate func(context.Context, *hostclaim.Claim, hostguard.FenceTarget, ColdSuccessor, StopInventory, hostguard.RootInstallation) error VerifyDirectory func(context.Context, hostguard.FenceTarget, StopInventoryEntry, Artifact) error checkpoint func(string) error } func (h ServingHandover) Run(ctx context.Context, prior hostguard.FenceTarget, next ColdSuccessor) error { if h.Guard.Root == nil || h.Validate == nil || h.Claims == nil || h.Registry == nil || h.Inventory == nil || h.Inventory.Remote() || h.Launches == nil || h.VerifyReceipt == nil || !filepath.IsAbs(h.JournalDir) || h.Roots["data"] != h.Guard.DataDir || prior.Op != "" || prior.Phase != "" || prior.ObligationID != "" || next.Op != "" || next.Phase != "" || next.Host != prior.Host || next.Incarnation != prior.Incarnation || next.Box != prior.Box || next.Enrollment != prior.Enrollment || next.Stamp <= prior.Stamp || next.Session != next.Stamp || next.Session <= prior.Session || next.Generation == "" || next.ETag == "" { return ErrUnsupported } id := "serving-handover-" + ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box) path := filepath.Join(h.JournalDir, id+".json") var j coldJournal body, err := readTrustedJournal(path) if err == nil { if decodeStrict(body, &j) != nil || j.V != 1 || j.ID != id || j.Prior != prior || j.Successor != next || j.ServingInstallation == nil || (j.Phase != "prepared" && j.Phase != "complete") { return ErrTypedJournal } } else if !errors.Is(err, os.ErrNotExist) { return err } else { captured := hostguard.Captured{Incarnation: prior.Incarnation, Stamp: prior.Stamp} view, err := h.Guard.Root.View(ctx, prior.Host, captured) if err != nil { return err } in, rev, err := h.Inventory.Snapshot(ctx, prior.Host, captured) if err != nil { return err } if view.Installation.Stamp != prior.Stamp || view.Installation.InventoryRevision != rev || !in.Complete || in.Box != prior.Box || in.Enrollment != prior.Enrollment || in.DirectoryRevision != prior.DirectoryRevision { return ErrTypedInventory } var receipt hostguard.FenceReceipt if prior.Session != 0 { receipt, err = h.Guard.FenceReceiptFor(ctx, prior) if err != nil { return err } if err = h.VerifyReceipt(ctx, receipt); err != nil { return err } } row, err := h.Registry.Get(prior.Host) if err != nil { return err } if row == nil || row.Kind != "instance" || row.Account != view.Installation.Account || row.App != view.Installation.App || row.Socket != provision.NestedSocket(prior.Host) || row.DBPath != provision.NestedDBPath(h.Roots["data"], prior.Host) || row.Unit != "" || row.Addr != "" || row.Addrs != "" || row.Backing != "" || row.Tunnel != "" || row.Platform || row.Canary || row.Replica { return ErrTypedInventory } timers, err := h.Registry.TimersFor(prior.Host) if err != nil { return err } before := registry.ColdRows{Route: *row, Timers: timers} after := registry.ColdRows{Route: *row, Timers: append([]registry.Timer(nil), timers...)} owner := ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box) after.Route.ClaimOwnership = registry.ClaimOwnership{ClaimIncarnation: next.Incarnation, ClaimStamp: next.Stamp, ClaimOwner: owner, ClaimRowID: rowIDFor(owner, "route", next.Host)} for i := range after.Timers { after.Timers[i].ClaimOwnership = after.Route.ClaimOwnership after.Timers[i].ClaimRowID = rowIDFor(owner, "timer", after.Timers[i].Name) } barrier := prior barrier.FenceID, barrier.Stamp, barrier.Session, barrier.ClaimETag, barrier.DirectoryRevision = id, next.Stamp, next.Session, next.ETag, next.DirectoryRevision j = coldJournal{V: 1, ID: id, Phase: "prepared", Prior: prior, Barrier: barrier, Successor: next, Inventory: in, Receipt: receipt, Before: before, After: after, ServingInstallation: &view.Installation} err = h.Guard.WithServingRetirement(ctx, next.Host, hostguard.Captured{Incarnation: next.Incarnation, Stamp: next.Stamp}, func(fresh hostclaim.Observed, locked hostguard.FenceView) error { if locked.Installation != view.Installation { return ErrChanged } if fresh.ETag != next.ETag { return ErrChanged } if err = h.validate(ctx, j, fresh.Claim); err != nil { return err } saved, err := readTrustedJournal(path) if err == nil { var existing coldJournal if decodeStrict(saved, &existing) != nil || !reflect.DeepEqual(existing, j) { return ErrTypedJournal } return nil } if !errors.Is(err, os.ErrNotExist) { return err } return writeColdJournal(path, j) }) if err != nil { return err } } check := func(c *hostclaim.Claim) error { return h.validate(ctx, j, c) } if j.Phase == "complete" { return h.Guard.CompleteColdHandover(ctx, j.Barrier, true, check, func() error { return nil }) } if err = h.at("intent"); err != nil { return err } if err = h.Guard.BeginServingHandover(ctx, prior, j.Barrier, *j.ServingInstallation, check); err != nil { return err } if err = h.at("barrier"); err != nil { return err } return h.Guard.CompleteColdHandover(ctx, j.Barrier, false, check, func() error { if prior.Session != 0 { if err := h.VerifyReceipt(ctx, j.Receipt); err != nil { return err } } launches, err := h.Launches.RootSessionLaunches(ctx, next.Host, next.Incarnation, next.Stamp, next.Session, next.Box, next.Enrollment) if err != nil { return err } if len(launches) != 0 { return ErrChanged } rebound := j.Inventory rebound.Entries = append([]StopInventoryEntry(nil), j.Inventory.Entries...) rebound.InstallationStamp, rebound.DirectoryRevision = next.Stamp, next.DirectoryRevision for i := range rebound.Entries { e := &rebound.Entries[i] if e.Kind == "path" { a, err := h.directory(ctx, prior, *e) if err != nil { return err } e.Device = a.Dev e.Identity = a.Identity } e.Ownership = j.After.Route.ClaimOwner if e.Kind == "route" { e.RowIdentity = j.After.Route.ClaimRowID } if e.Kind == "timer" { for _, timer := range j.After.Timers { if e.RelativePath == next.Host+"/"+timer.Name { e.RowIdentity = timer.ClaimRowID } } } } existing, err := h.Inventory.ReadInstallation(ctx, next.Host, next.Incarnation, next.Stamp) if err == nil { if !reflect.DeepEqual(existing, rebound) { return ErrTypedInventory } } else if !errors.Is(err, sql.ErrNoRows) { return err } else if err = h.Inventory.WriteStopInventory(ctx, rebound); err != nil { return err } if err = h.at("inventory"); err != nil { return err } if err = h.Registry.RebindClaimRows(ctx, j.Before, j.After); err != nil { return err } if err = h.at("rows"); err != nil { return err } j.Phase = "complete" if err = writeColdJournal(path, j); err != nil { return err } return h.at("complete-journal") }) } func (h ServingHandover) validate(ctx context.Context, j coldJournal, c *hostclaim.Claim) error { n, p := j.Successor, j.Prior if c == nil || c.Host != n.Host || c.Holder == nil || c.Op.Kind != hostclaim.KindServing || c.Op.ID != "" || c.Op.Phase != "" || c.Op.Exec != n.Box || c.Holder.Incarnation != n.Incarnation || c.Holder.Stamp != n.Stamp || c.Holder.Session != n.Session || c.Holder.Generation != n.Generation || c.Holder.WriterBox != n.Box { return ErrChanged } if j.ServingInstallation == nil || (p.Session != 0 && (j.Receipt.Target != p || h.VerifyReceipt(ctx, j.Receipt) != nil)) || (p.Session == 0 && !reflect.DeepEqual(j.Receipt, hostguard.FenceReceipt{})) { return ErrTypedJournal } current, rev, err := h.Inventory.Snapshot(ctx, p.Host, hostguard.Captured{Incarnation: p.Incarnation, Stamp: p.Stamp}) if err != nil { return err } if rev != j.ServingInstallation.InventoryRevision || !reflect.DeepEqual(current, j.Inventory) { return ErrTypedInventory } if err = ValidateAgentInventory(j.Inventory, &j.Before.Route, h.Roots["data"]); err != nil { return err } stateRelative, err := filepath.Rel(h.Roots["data"], filepath.Dir(provision.NestedDBPath(h.Roots["data"], p.Host))) if err != nil { return err } routes, paths, timers := 0, 0, 0 for _, e := range j.Inventory.Entries { if e.Materialization != "materialized" || e.Ownership != j.Before.Route.ClaimOwner { return ErrTypedInventory } switch e.Kind { case "route": routes++ case "path": paths++ if e.ManagedRoot != "data" || e.RelativePath != stateRelative || e.Type != 0040000 || e.Device == 0 || e.Inode == 0 { return ErrTypedInventory } if _, err := h.directory(ctx, p, e); err != nil { return err } case "timer": timers++ found := 0 for _, timer := range j.Before.Timers { if e.RelativePath == p.Host+"/"+timer.Name && timer.ClaimOwnership.Valid() && timer.ClaimIncarnation == p.Incarnation && timer.ClaimStamp == p.Stamp && timer.ClaimOwner == e.Ownership && timer.ClaimRowID == e.RowIdentity && timer.Registration != "" { found++ } } if found != 1 { return ErrTypedInventory } default: return ErrTypedInventory } } if routes != 1 || paths > 1 || (paths == 0 && p.Session != 0) || timers != len(j.Before.Timers) { return ErrTypedInventory } return h.Validate(ctx, c, p, n, j.Inventory, *j.ServingInstallation) } func (h ServingHandover) directory(ctx context.Context, prior hostguard.FenceTarget, e StopInventoryEntry) (Artifact, error) { a, err := (TypedParker{Roots: h.Roots, ParkedRoots: h.Roots, Incarnation: prior.Incarnation, Host: prior.Host, Retirement: "serving-handover"}).CaptureArtifact(e.ManagedRoot, e.RelativePath, e.Ownership, ArtifactPath) if err != nil { return a, err } if a.Ino != e.Inode || a.Type != e.Type { return a, ErrChanged } if h.VerifyDirectory == nil { return a, ErrChanged } if err := h.VerifyDirectory(ctx, prior, e, a); err != nil { return a, err } return a, nil } func (h ServingHandover) Recover(ctx context.Context) error { entries, err := readTrustedJournalDir(h.JournalDir) if err != nil { return err } if len(entries) == 0 { return nil } pending, err := h.Guard.PendingFences(ctx) if err != nil { return err } var failures []error for _, e := range entries { if e.IsDir() || filepath.Ext(e.Name()) != ".json" { continue } body, err := readTrustedJournal(filepath.Join(h.JournalDir, e.Name())) if err != nil { failures = append(failures, err) continue } var j coldJournal if decodeStrict(body, &j) != nil || j.V != 1 || j.ID+".json" != e.Name() || j.ServingInstallation == nil { failures = append(failures, ErrTypedJournal) continue } if j.Phase == "complete" { found := false for _, target := range pending { found = found || target == j.Barrier } if !found { continue } } if err = h.Run(ctx, j.Prior, j.Successor); err != nil { failures = append(failures, err) continue } } return errors.Join(failures...) } func (h ServingHandover) at(point string) error { if h.checkpoint != nil { return h.checkpoint(point) } return nil }