package host import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/park" "github.com/carlosframework/platform/internal/registry" "strconv" ) func claimRecoveryObservation(o hostclaim.Observed) bool { return o.Claim != nil && claimRecoveryKind(o.Claim.Op.Kind) } func recoveryPredecessor(o hostclaim.Observed) hostclaim.AdmissionRecord { h := o.Claim.Holder p := hostclaim.AdmissionRecord{Box: h.WriterBox, Session: h.Session, Generation: h.Generation} if len(h.Admissions) > 0 { p = h.Admissions[len(h.Admissions)-1] } return p } func (h claimCrossBoxHandover) recoveryEvidence(ctx context.Context, o hostclaim.Observed) error { if !claimRecoveryObservation(o) || o.Absent || o.ETag == "" || o.Claim.Holder == nil || h.Evidence == nil || h.Box == "" { return hostbox.ErrChanged } c := o.Claim p := recoveryPredecessor(o) op := c.Op if claimInitialSourceRecovery(o, h.Box) { return h.sourceRecoveryEvidence(ctx, o) } if op.ID == "" || op.Phase != hostclaim.PhaseProbing || op.Exec != h.Box || op.Intent["source"] != h.Box || p.Box == "" || p.Box == h.Box || p.Box != op.Intent["target"] || p.Box != c.Holder.WriterBox || p.Session != c.Holder.Session { return hostbox.ErrChanged } lossID, err := hostclaim.OperationLossID(c) if err != nil { return err } if lossID != "" { l, err := hostclaim.ReadLossRecord(ctx, h.Evidence, h.PublicKey, lossID) if err != nil { return err } r := l.Record if r.State != "committed" || r.Host != o.Host || r.OpID != op.ID || r.HolderIncarnation != c.Holder.Incarnation || r.Box != p.Box { return hostclaim.ErrLossRecord } for _, source := range r.WriteSources { if source.Reason == "target" && source.Box == p.Box && source.Session == p.Session && source.Generation == p.Generation { return nil } } return hostclaim.ErrLossRecord } if _, ok := hostclaim.ConsumedMoveTargetFence(c); ok { return nil } if op.Journal["fence."+p.Box] != "" { var ref hostclaim.FenceRef if json.Unmarshal([]byte(op.Journal["fence."+p.Box]), &ref) != nil || op.Intent["target_enrollment"] == "" || ref.Enrollment != op.Intent["target_enrollment"] { return hostclaim.ErrFence } verified, err := hostclaim.VerifyFence(ctx, h.Evidence, h.FenceAuthority, ref) if err != nil { return err } if (op.Intent["home_region"] != "" && op.Intent["home_region"] != verified.FleetHomeRegion()) || verified.Box() != p.Box || !park.SameRegion(op.Intent["home_region"], verified.Region(), op.Intent["to"]) { return hostclaim.ErrFence } return nil } if key := op.Journal["target_stop_receipt"]; key != "" { return h.recoveryStopReceipt(ctx, o, p, key, op.Journal["target_stop_sha256"]) } key := op.Journal[hostclaim.JournalEvidenceTarget] expected := moves.StoppedKey(moves.ReportPrefix(c.Holder.Account, c.Holder.App, o.Host, op.ID), "target") session, err := strconv.ParseUint(op.Journal[hostclaim.JournalTargetSession], 10, 64) if err != nil || session != p.Session || op.Journal[hostclaim.JournalTargetGeneration] != p.Generation || key != expected { return hostbox.ErrChanged } report, err := readStoppedReport(ctx, h.Evidence, key) if err != nil { return err } if report.Role != "target" || report.Box != p.Box || report.Host != o.Host || report.OpID != op.ID || report.Incarnation != c.Holder.Incarnation { return hostbox.ErrChanged } return nil } func (h claimCrossBoxHandover) beforeRecovery(ctx context.Context, o hostclaim.Observed) error { if err := h.recoveryEvidence(ctx, o); err != nil { return err } old := o.Claim.Holder return h.Guard.With(ctx, o.Host, hostguard.Captured{Incarnation: old.Incarnation, Stamp: old.Stamp}, func(c *hostclaim.Claim) error { if !hostclaim.SameWriterIdentity(o, hostclaim.Observed{Host: o.Host, Claim: c}) { return hostbox.ErrChanged } return nil }, func() error { return nil }) } func (h claimCrossBoxHandover) afterRecovery(ctx context.Context, prior, admitted hostclaim.Observed) error { if err := h.recoveryEvidence(ctx, prior); err != nil { return err } if admitted.Absent || admitted.Claim == nil || admitted.Claim.Holder == nil || admitted.Host != prior.Host { return hostbox.ErrChanged } old, next := prior.Claim.Holder, admitted.Claim.Holder op, ac := prior.Claim.Op, admitted.Claim if ac.Op.ID != op.ID || ac.Op.Kind != op.Kind || ac.Op.Phase != op.Phase || ac.Op.Exec != h.Box || next.Incarnation != old.Incarnation || next.Session <= old.Session || next.Stamp != next.Session || next.WriterBox != h.Box || len(next.Admissions) == 0 { return hostbox.ErrChanged } p := recoveryPredecessor(prior) a := next.Admissions[len(next.Admissions)-1] if a.Session != next.Session || a.Box != h.Box || a.Mode != "cold" || a.Generation != next.Generation || a.Predecessor != p.Generation { return hostbox.ErrChanged } lossID, err := hostclaim.OperationLossID(prior.Claim) if err != nil { return err } if lossID != "" && (a.LossID != lossID || a.Coverage != "" || (p.Session != 0 && (a.WritesLost == nil || a.WritesLost.Box != p.Box || a.WritesLost.Session != p.Session))) { return hostclaim.ErrLossRecord } if lossID == "" && a.LossID != "" { return hostclaim.ErrLossRecord } current, err := hostclaim.Read(ctx, h.Evidence, admitted.Host) if err != nil { return err } if !hostclaim.SameWriterIdentity(current, admitted) { return hostbox.ErrChanged } if h.Registry == nil { return hostbox.ErrTypedInventory } if h.Inventory.Remote() || h.Guard.FenceView != nil { return h.installRecoveryRemote(ctx, prior, admitted) } // Activate retires the prior local stamp through the descriptor-based guard. // The restore hook subsequently retires the source database and marker. if err = h.Guard.Activate(ctx, admitted.Host, h.Box, nil); err != nil { return err } return h.Guard.With(ctx, admitted.Host, hostguard.Captured{Incarnation: next.Incarnation, Stamp: next.Stamp}, func(c *hostclaim.Claim) error { if !hostclaim.SameWriterIdentity(admitted, hostclaim.Observed{Host: admitted.Host, Claim: c}) { return hostbox.ErrChanged } return nil }, func() error { return h.installRecoveryInventory(ctx, prior, admitted) }) } // The first return to the cut installation has not launched under this operation. // Later source sessions cannot reuse the cut's stop evidence. func claimInitialSourceRecovery(o hostclaim.Observed, box string) bool { if !claimRecoveryObservation(o) || o.Claim.Holder == nil { return false } c, h := o.Claim, o.Claim.Holder cut, e := strconv.ParseUint(c.Op.Journal[hostclaim.JournalCutSession], 10, 64) stamp, se := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) return e == nil && se == nil && stamp > 0 && h.Stamp == stamp && c.Op.ID != "" && c.Op.Phase == hostclaim.PhaseProbing && c.Op.Exec == box && c.Op.Intent["source"] == box && h.Session == cut && (h.WriterBox == box || h.Session == 0 && h.WriterBox == "" && len(h.Admissions) == 0) && h.Generation == c.Op.Journal[hostclaim.JournalSrcGen] } func (h claimCrossBoxHandover) sourceRecoveryEvidence(ctx context.Context, o hostclaim.Observed) error { if !claimInitialSourceRecovery(o, h.Box) || h.Evidence == nil { return hostbox.ErrChanged } c := o.Claim key := moves.StoppedKey(moves.ReportPrefix(c.Holder.Account, c.Holder.App, o.Host, c.Op.ID), "source") if c.Op.Journal[hostclaim.JournalEvidenceSource] != key { return hostbox.ErrChanged } report, err := readStoppedReport(ctx, h.Evidence, key) if err != nil { return err } if report.Role != "source" || report.Box != h.Box || report.Host != o.Host || report.OpID != c.Op.ID || report.Incarnation != c.Holder.Incarnation { return hostbox.ErrChanged } return nil } // The guard is already held. Preserve exact inventory entries and their physical // identities; only ownership moves to the admitted installation. The old inventory // remains available for retirement and replay. Never infer an empty installation. func (h claimCrossBoxHandover) installRecoveryInventory(ctx context.Context, prior, admitted hostclaim.Observed) error { if h.Inventory == nil { return hostbox.ErrTypedInventory } actor, err := h.Identity.Verify(ctx) if err != nil { return err } if actor.Box != h.Box { return hostbox.ErrChanged } old, next := prior.Claim.Holder, admitted.Claim.Holder stamp, err := strconv.ParseUint(prior.Claim.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) if err != nil || stamp == 0 || stamp >= next.Stamp { return hostbox.ErrChanged } inv, err := h.Inventory.ReadInstallation(ctx, prior.Host, old.Incarnation, stamp) if err != nil { return err } // The predecessor is reused only under today's binding (roster binding // R3); one stamped under another binding is refused before any reuse. if !inv.Complete || inv.Host != prior.Host || inv.Incarnation != old.Incarnation || inv.InstallationStamp != stamp || inv.Box != actor.Box || inv.Enrollment != actor.Enrollment || actor.Binding == "" || inv.DirectoryRevision != actor.Binding { return hostbox.ErrTypedInventory } sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", prior.Host, next.Incarnation, next.Stamp, actor.Box))) owner := hex.EncodeToString(sum[:]) current, err := h.Inventory.ReadInstallation(ctx, prior.Host, next.Incarnation, next.Stamp) if err == nil { if !current.Complete || current.Box != actor.Box || current.Enrollment != actor.Enrollment || current.DirectoryRevision != actor.Binding { return hostbox.ErrTypedInventory } if h.Registry == nil { return hostbox.ErrTypedInventory } row, e := h.Registry.Get(prior.Host) if e != nil { return e } if err := hostbox.ValidateAgentInventory(current, row, h.Guard.DataDir); err != nil { return err } inv.InstallationStamp = next.Stamp return h.rebindRecoveryRows(ctx, &inv, stamp, owner) } if !errors.Is(err, sql.ErrNoRows) { return err } inv.InstallationStamp, inv.DirectoryRevision = next.Stamp, actor.Binding if err := h.rebindRecoveryRows(ctx, &inv, stamp, owner); err != nil { return err } for i := range inv.Entries { inv.Entries[i].Ownership = owner } if h.afterRecoveryRows != nil { if err := h.afterRecoveryRows(); err != nil { return err } } return h.Inventory.WriteStopInventory(ctx, inv) } func (h claimCrossBoxHandover) rebindRecoveryRows(ctx context.Context, inv *hostbox.StopInventory, oldStamp uint64, owner string) error { if h.Registry == nil { return hostbox.ErrTypedInventory } row, err := h.Registry.Get(inv.Host) if err != nil { return err } if row == nil || row.ClaimIncarnation != inv.Incarnation || (row.ClaimStamp != oldStamp && row.ClaimStamp != inv.InstallationStamp) || !row.ClaimOwnership.Valid() { return hostbox.ErrTypedInventory } timers, err := h.Registry.TimersFor(inv.Host) if err != nil { return err } old := registry.ColdRows{Route: *row, Timers: append([]registry.Timer(nil), timers...)} next := registry.ColdRows{Route: *row, Timers: append([]registry.Timer(nil), timers...)} ownership := func(kind, name string) registry.ClaimOwnership { sum := sha256.Sum256([]byte(owner + "\x00" + kind + "\x00" + name)) return registry.ClaimOwnership{ClaimIncarnation: inv.Incarnation, ClaimStamp: inv.InstallationStamp, ClaimOwner: owner, ClaimRowID: hex.EncodeToString(sum[:])} } next.Route.ClaimOwnership = ownership("route", inv.Host) for i := range next.Timers { next.Timers[i].ClaimOwnership = ownership("timer", next.Timers[i].Name) } // Retained source inventory recovers the old row identities after a lost reply. routeFound := false for i := range inv.Entries { e := &inv.Entries[i] if e.Kind == "route" { if routeFound || e.RelativePath != inv.Host { return hostbox.ErrTypedInventory } routeFound = true old.Route.ClaimOwnership = registry.ClaimOwnership{ClaimIncarnation: inv.Incarnation, ClaimStamp: oldStamp, ClaimOwner: e.Ownership, ClaimRowID: e.RowIdentity} e.RowIdentity = next.Route.ClaimRowID } if e.Kind == "timer" { found := false for j := range old.Timers { if e.RelativePath == inv.Host+"/"+old.Timers[j].Name { old.Timers[j].ClaimOwnership = registry.ClaimOwnership{ClaimIncarnation: inv.Incarnation, ClaimStamp: oldStamp, ClaimOwner: e.Ownership, ClaimRowID: e.RowIdentity} e.RowIdentity = next.Timers[j].ClaimRowID found = true } } if !found { return hostbox.ErrTypedInventory } } } if !routeFound { return hostbox.ErrTypedInventory } source := *inv source.InstallationStamp = oldStamp source.Entries = append([]hostbox.StopInventoryEntry(nil), inv.Entries...) for i := range source.Entries { e := &source.Entries[i] if e.Kind == "route" { e.RowIdentity = old.Route.ClaimRowID } if e.Kind == "timer" { for _, t := range old.Timers { if e.RelativePath == inv.Host+"/"+t.Name { e.RowIdentity = t.ClaimRowID } } } } if err := hostbox.ValidateAgentInventory(source, &old.Route, h.Guard.DataDir); err != nil { return err } return h.Registry.RebindClaimRows(ctx, old, next) }