package host import ( "context" "encoding/json" "errors" "fmt" "os" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/rootparticipation" "github.com/carlosframework/platform/internal/rootstate" ) type rootMigrationIdentity struct { Deployment, Box, Enrollment string Generation uint64 } type rootMigrationItem struct { Installation hostguard.RootInstallation Session uint64 ClaimETag string } type rootMigrationSnapshot struct { Complete bool DirectoryRevision uint64 Exclusion string Records []provision.Record Claims []hostclaim.Claim Items []rootMigrationItem MoveJournals, MoveReservations int Report hostops.CensusReport } type rootMigrationRecord struct { Schema int Identity rootMigrationIdentity Phase string Observation string Snapshot rootMigrationSnapshot } type rootMigration struct { prepareRecovery func(context.Context) error prepareFloors func(context.Context) error guardReady func(context.Context) error state, execution rootstate.Store identity rootMigrationIdentity quiesce func(context.Context) (func(), error) observe func(context.Context) (rootMigrationSnapshot, error) deny, recover func(context.Context, rootMigrationItem) error ready func(context.Context, rootMigrationSnapshot) error publish func(context.Context, hostops.CensusReport) error } func (s rootMigrationSnapshot) preflight(id rootMigrationIdentity) error { if s.Exclusion == "" || s.DirectoryRevision == 0 || s.MoveJournals != 0 || s.MoveReservations != 0 || s.Items == nil || !s.Report.Complete || s.Report.Box != id.Box || s.Report.Enrollment != id.Enrollment || s.Report.DirectoryRevision != s.DirectoryRevision || s.Report.Reservation == "" || s.Report.BootID == "" || s.Report.CapturedAt.IsZero() || s.Report.Installations == nil || s.Report.Seeds == nil || len(s.Report.Seeds.Journaled) > 0 || len(s.Report.Seeds.Gated) > 0 { return errRootMigrationBusy } for _, rec := range s.Records { if !claimNamedUnit4aComplete && rec.Unit != "" { for _, item := range s.Items { if item.Installation.Host == rec.Host { return errRootMigrationBusy } } } if rec.Seed.Unfinished() { return errRootMigrationBusy } } seen := map[string]bool{} for _, item := range s.Items { in := item.Installation if in.Host == "" || seen[fmt.Sprintf("%s/%s/%d/%d", in.Host, in.Incarnation, in.Stamp, item.Session)] || in.Incarnation == "" || in.Stamp == 0 || in.Account == "" || in.App == "" || in.Record == "" || in.Box != id.Box || in.Enrollment != id.Enrollment || in.Migration != id.Generation || item.Session == 0 || item.ClaimETag == "" { return errRootMigrationBusy } seen[fmt.Sprintf("%s/%s/%d/%d", in.Host, in.Incarnation, in.Stamp, item.Session)] = true } return rootMigrationPreflight(s.Complete, s.Records, s.Claims) } func (s rootMigrationSnapshot) fingerprint() string { // Wall time is report metadata, not census membership or exclusion identity. s.Report.CapturedAt = time.Time{} s.Report.BootID = "" s.Items = append([]rootMigrationItem(nil), s.Items...) for i := range s.Items { s.Items[i].Installation.InventoryRevision = 0 s.Items[i].Installation.Artifacts = hostguard.InstallationArtifacts{} } return generationInventoryDigest(s) } func (m rootMigration) write(name string, v any) error { raw, err := json.Marshal(v) if err != nil { return err } return m.state.Write(name, raw) } func (m rootMigration) census(complete bool) error { c := rootparticipation.Census{Schema: 1, Box: m.identity.Box, Enrollment: m.identity.Enrollment, Migration: m.identity.Generation, Revision: 1, LaunchRevision: 1, Complete: complete} if !complete { c.Pending = "migration" } return (rootparticipation.Ledger{State: m.execution}).MigrationCensus(context.Background(), c, false) } func (m rootMigration) run(ctx context.Context) error { if m.state == nil || m.execution == nil || m.identity.Deployment == "" || m.identity.Box == "" || m.identity.Enrollment == "" || m.identity.Generation == 0 || m.quiesce == nil || m.observe == nil || m.deny == nil || m.recover == nil || m.ready == nil || m.publish == nil { return errRootMigrationBusy } unlock, err := m.state.Lock(ctx, "migration.lock") if err != nil { return err } defer unlock() var saved rootMigrationRecord raw, err := m.state.Read("migration.json") exists := err == nil if exists { if rootparticipation.Decode(raw, &saved) != nil || saved.Schema != 1 || saved.Identity != m.identity || saved.Snapshot.preflight(m.identity) != nil { return errRootMigrationBusy } switch saved.Phase { case "prepared", "fenced", "recovered", "publishing", "complete": default: return errRootMigrationBusy } } else if !errors.Is(err, os.ErrNotExist) { return err } release, err := m.quiesce(ctx) if err != nil { return err } defer release() if exists && saved.Phase == "complete" { return (rootparticipation.Ledger{State: m.execution}).MigrationCensus(ctx, rootparticipation.Census{Box: m.identity.Box, Enrollment: m.identity.Enrollment, Migration: m.identity.Generation}, true) } if m.prepareFloors == nil || m.guardReady == nil { return errRootMigrationBusy } // The floor directories are repaired under the lock and quiesce, before // guardReady's first floor read: a directory an earlier attempt left // 0700 would otherwise refuse that read on every rerun. if err := m.prepareFloors(ctx); err != nil { return err } if err := m.guardReady(ctx); err != nil { return err } if exists && saved.Phase == "publishing" { if err := m.census(true); err != nil { return err } if err := m.publish(ctx, saved.Snapshot.Report); err != nil { return err } saved.Phase = "complete" return m.write("migration.json", saved) } observe := func() (rootMigrationSnapshot, error) { s, e := m.observe(ctx) if e == nil { e = s.preflight(m.identity) } return s, e } first, err := observe() if err != nil { return err } fresh, err := observe() if err != nil { return err } if first.fingerprint() != fresh.fingerprint() { return errRootMigrationBusy } if !exists { saved = rootMigrationRecord{Schema: 1, Identity: m.identity} } // Keep every old tuple while updating current claim evidence. A second // observation above proves the new snapshot under the same exclusion. items := append([]rootMigrationItem(nil), fresh.Items...) for _, old := range saved.Snapshot.Items { found := false for _, item := range items { if old.Installation.Host == item.Installation.Host && old.Installation.Incarnation == item.Installation.Incarnation && old.Installation.Stamp == item.Installation.Stamp && old.Session == item.Session { found = true break } } if !found { items = append(items, old) } } saved.Observation = fresh.fingerprint() saved.Snapshot = fresh saved.Snapshot.Items = items if saved.Snapshot.Items == nil { saved.Snapshot.Items = []rootMigrationItem{} } if err := m.census(false); err != nil { return err } saved.Phase = "prepared" if err := m.write("migration.json", saved); err != nil { return err } for _, item := range saved.Snapshot.Items { if err := m.deny(ctx, item); err != nil { return err } if err := m.write("deny-"+generationInventoryDigest([]any{item.Installation.Host, item.Installation.Incarnation, item.Installation.Stamp, item.Session})+".json", item); err != nil { return err } } saved.Phase = "fenced" if err := m.write("migration.json", saved); err != nil { return err } if m.prepareRecovery != nil { if err := m.prepareRecovery(ctx); err != nil { return err } } for _, item := range saved.Snapshot.Items { // Reverify even a visible receipt on retry: no record can manufacture stop // evidence, and the recovery adapter preserves the original signed time. if err := m.recover(ctx, item); err != nil { return err } if err := m.write("receipt-"+generationInventoryDigest([]any{item.Installation.Host, item.Installation.Incarnation, item.Installation.Stamp, item.Session})+".json", item); err != nil { return err } } saved.Phase = "recovered" if err := m.write("migration.json", saved); err != nil { return err } fresh, err = observe() if err != nil { return err } if saved.Observation != fresh.fingerprint() { return errRootMigrationBusy } if err := m.ready(ctx, saved.Snapshot); err != nil { return err } if err := m.write("report.json", saved.Snapshot.Report); err != nil { return err } saved.Phase = "publishing" if err := m.write("migration.json", saved); err != nil { return err } if err := m.census(true); err != nil { return err } if err := m.publish(ctx, saved.Snapshot.Report); err != nil { return err } saved.Phase = "complete" return m.write("migration.json", saved) }