package host import ( "context" "encoding/json" "path/filepath" "sort" "strings" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostbox" "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/registry" "github.com/carlosframework/platform/internal/release" ) type rootMigrationCollector struct { suspects func(context.Context) ([]string, error) retirements func(context.Context) ([]hostbox.RetirementJournal, error) store release.Store directory hostactors.Directory identity rootMigrationIdentity seeds hostops.SeedStateReader installations func(context.Context) ([]hostguard.FenceView, error) launches func(context.Context) ([]hostbox.LaunchInventory, error) reservations func(context.Context) ([]release.GenerationReservation, error) boot func() (string, error) // routes is the box's registry listing and dataDir its data root. A // legacy route row, an ordinary instance route no claim owns yet, is // reported as the box's route installation of its frozen record, so the // backfill can adopt it to this box. Nil reports none (the census then // refuses the row's files, as before). routes func() ([]registry.Route, error) dataDir string } // legacyRouteRow is the one route shape the migration census reports as a // legacy installation: an ordinary nested-layout instance row, unowned by // any claim, of the frozen record's account and app. These are the routes // the files seed can serve; every other row refuses the census. func legacyRouteRow(row registry.Route, r hostops.Record, dataDir string) bool { return row.Kind == "instance" && r.Holder.Family == "instance" && row.ClaimOwnership == (registry.ClaimOwnership{}) && !row.Replica && row.Seed == "" && row.SeedSource == "" && row.Backing == "" && row.Unit == "" && !row.IsSidecar() && row.Tunnel == "" && row.Account == r.Holder.Account && row.App == r.Holder.App && filepath.IsAbs(dataDir) && row.DBPath == provision.NestedDBPath(dataDir, row.Host) && release.ValidVersion(row.Version) } func (c rootMigrationCollector) collect(ctx context.Context) (rootMigrationSnapshot, error) { s := rootMigrationSnapshot{Items: []rootMigrationItem{}} if c.store == nil || c.seeds == nil || c.installations == nil || c.launches == nil || c.reservations == nil || c.boot == nil { return s, errRootMigrationBusy } raw, tag, err := c.store.Get(ctx, hostops.BackfillKey) if err != nil { return s, err } var plan hostops.BackfillPlan if tag == "" || release.VerifyRootEnvelope(c.directory.PublicKey, raw, &plan) != nil || plan.V != 1 || plan.ID == "" || plan.Phase != "collecting" || plan.Hosts == nil || plan.CreatedAt.IsZero() { return s, errRootMigrationBusy } roster, err := c.directory.Read(ctx) if err != nil { return s, err } box, ok := roster.Roster.Boxes[c.identity.Box] if !ok || box.Enrollment != c.identity.Enrollment || plan.DirectoryRevision != roster.Roster.Revision { return s, errRootMigrationBusy } s.DirectoryRevision = roster.Roster.Revision s.Exclusion = tag + "@" + generationInventoryDigest(plan) boot, err := c.boot() if err != nil || boot == "" { return s, errRootMigrationBusy } s.Report = hostops.CensusReport{Box: c.identity.Box, Enrollment: c.identity.Enrollment, BootID: boot, Reservation: plan.ID, DirectoryRevision: s.DirectoryRevision, CapturedAt: time.Now().UTC(), Installations: []hostops.CensusInstallation{}} // These service-owned fields only refuse; they establish no installation. s.Report.Seeds, err = hostops.CollectCensusSeeds(c.seeds) if err != nil { return s, err } records := map[string]hostops.Record{} apps, err := hostops.Apps(ctx, c.store) if err != nil { return s, err } for _, app := range apps { rows, err := hostops.Records(ctx, c.store, app.Account, app.Name) if err != nil { return s, err } for _, r := range rows { if _, ok := records[r.Holder.Host]; ok { return s, errRootMigrationBusy } frozen, ok := plan.Hosts[r.Holder.Host] if !ok || frozen.Key != r.Holder.Key || frozen.ETag != r.ETag { return s, errRootMigrationBusy } records[r.Holder.Host] = r if r.Holder.Family == "instance" { var rec provision.Record if json.Unmarshal(r.Body, &rec) != nil { return s, errRootMigrationBusy } s.Records = append(s.Records, rec) } } } if len(records) != len(plan.Hosts) { return s, errRootMigrationBusy } keys, err := c.store.List(ctx, "control/hosts/") if err != nil { return s, err } sort.Strings(keys) observed := map[string]hostclaim.Observed{} for _, key := range keys { if !strings.HasSuffix(key, ".json") || strings.Contains(strings.TrimPrefix(key, "control/hosts/"), "/") { continue } host := strings.TrimSuffix(strings.TrimPrefix(key, "control/hosts/"), ".json") want, err := hostclaim.Key(host) if err != nil || key != want { return s, errRootMigrationBusy } o, err := hostclaim.Read(ctx, c.store, host) if err != nil || o.Absent || o.ETag == "" { return s, errRootMigrationBusy } s.Claims = append(s.Claims, *o.Claim) observed[host] = o } retained, err := c.installations(ctx) if err != nil { return s, err } if err := refuseSettledInstallations(retained); err != nil { return s, err } if c.retirements != nil { retired, err := c.retirements(ctx) if err != nil { return s, err } for _, j := range retired { if !j.Complete || j.Target == nil { s.MoveJournals++ } else if _, ok := observed[j.Target.Host]; !ok { return s, errRootMigrationBusy } } } if c.suspects != nil { suspects, err := c.suspects(ctx) if err != nil { return s, err } for _, host := range suspects { if _, ok := observed[host]; !ok { return s, errRootMigrationBusy } } } local := map[string]hostguard.RootInstallation{} for _, v := range retained { local[v.Host] = v.Installation } launches, err := c.launches(ctx) if err != nil { return s, err } for _, l := range launches { if l.Target.Op != "" && !l.WritersClosed { s.MoveJournals++ } if _, ok := local[l.Target.Host]; !ok { return s, errRootMigrationBusy } } reservations, err := c.reservations(ctx) if err != nil { return s, err } // Any unfinished reservation must be reconciled through normal generation // retirement first. A move cannot hide by deleting its record or launch row. for _, r := range reservations { if !r.Closed { s.MoveReservations++ } } names := make([]string, 0, len(observed)) for host := range observed { names = append(names, host) } sort.Strings(names) for _, host := range names { o := observed[host] h := o.Claim.Holder _, retained := local[host] if h == nil { if retained { return s, errRootMigrationBusy } continue } relevant := h.WriterBox == c.identity.Box || o.Claim.Op.Exec == c.identity.Box || retained for b := range o.Claim.Inventory { if b == c.identity.Box { relevant = true } } if !relevant { continue } r, ok := records[host] if !ok || r.Holder.Key != h.Key || r.Holder.Account != h.Account || r.Holder.App != h.App || h.Stamp == 0 || h.Session == 0 { return s, errRootMigrationBusy } in, exists := local[host] if exists { if in.Incarnation != h.Incarnation || in.Stamp != h.Stamp || in.Box != c.identity.Box || in.Enrollment != c.identity.Enrollment || in.Record != h.Key || in.Account != h.Account || in.App != h.App { return s, errRootMigrationBusy } } else { in = hostguard.RootInstallation{Host: host, Incarnation: h.Incarnation, Stamp: h.Stamp, Account: h.Account, App: h.App, Record: h.Key, Box: c.identity.Box, Enrollment: c.identity.Enrollment, Migration: c.identity.Generation} } s.Items = append(s.Items, rootMigrationItem{Installation: in, Session: h.Session, ClaimETag: o.ETag}) s.Report.Installations = append(s.Report.Installations, hostops.CensusInstallation{Host: host, Account: h.Account, App: h.App, Family: h.Family, Key: h.Key, Incarnation: h.Incarnation, Stamp: h.Stamp, Local: true}) delete(local, host) } if len(local) != 0 { return s, errRootMigrationBusy } if c.routes != nil { rows, err := c.routes() if err != nil { return s, err } reported := map[string]int{} for i, in := range s.Report.Installations { reported[in.Host] = i } for _, row := range rows { if _, ok := reported[row.Host]; ok { // A claimed installation of this box: the claim's own // inventory accounts for its row. continue } r, ok := records[row.Host] if !ok || !legacyRouteRow(row, r, c.dataDir) { return s, errRootMigrationBusy } inc, err := r.Incarnation() if err != nil { return s, errRootMigrationBusy } reported[row.Host] = len(s.Report.Installations) s.Report.Installations = append(s.Report.Installations, hostops.CensusInstallation{Host: row.Host, Account: r.Holder.Account, App: r.Holder.App, Family: r.Holder.Family, Key: r.Holder.Key, Incarnation: inc, Route: true}) } sort.Slice(s.Report.Installations, func(i, j int) bool { return s.Report.Installations[i].Host < s.Report.Installations[j].Host }) } fresh, ftag, err := c.store.Get(ctx, hostops.BackfillKey) if err != nil || ftag != tag || string(fresh) != string(raw) { return s, errRootMigrationBusy } s.Complete = true s.Report.Complete = true return s, nil } // refuseSettledInstallations keeps journal-migrate's collector deciding as // it did before roster binding's fix rounds: an installation root resolved // terminally (settled: an obsolete-binding or superseded-claim intent, // quarantined) refuses the collection with ErrFenced, exactly as // MigrationInstallations itself refused every quarantined record. Only // startup's verifyRootMigrationStartup inspects settled installations. func refuseSettledInstallations(views []hostguard.FenceView) error { for _, v := range views { if v.Settled { return hostguard.ErrFenced } } return nil }