package claimactivation import ( "context" "errors" "os" "reflect" "sort" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/release" ) // BackfillInput is the backfill phases' plan: the // reservation id the boxes' census reports are published under // (control/host-census//.json). Nothing else is typed: the hosts // come from the bucket's own record listing, and each host's box and region // from the boxes' signed census reports. type BackfillInput struct { ID string `json:"id"` } // BackfillHost is one frozen host, as read back from the signed plan. type BackfillHost struct { Host string `json:"host"` hostops.BackfillHost } // BackfillSummary is what the backfill phases read back from the signed // plan after writing it (or, on a dry run, what they would write). type BackfillSummary struct { ID string `json:"id"` Phase string `json:"phase"` DirectoryRevision uint64 `json:"directory_revision"` Hosts []BackfillHost `json:"hosts"` // Reports is each box's census report digest, adopting only. Reports map[string]string `json:"reports,omitempty"` // Unowned lists the hosts no box reported. They keep no box and are // never adopted. Adopting only. Unowned []string `json:"unowned,omitempty"` // Unreported lists the roster boxes with no census key, which // backfill-done did not ask to report. Done only. Unreported []string `json:"unreported,omitempty"` } var ( // ErrBackfillState is the refusal when the roster or the floor does not // allow a backfill yet. ErrBackfillState = errors.New("backfill refused: the roster must be published with a home region and a verified floor") // ErrBackfillExists is backfill-collect's refusal over an existing plan. ErrBackfillExists = errors.New("backfill refused: a backfill plan already exists") // ErrBackfillRecords is backfill-collect's refusal when a record cannot // be frozen: one without an acquisition, or records that changed while // the plan was written. ErrBackfillRecords = errors.New("backfill refused: every instance record must carry an acquisition and stay unchanged while the plan is written") // ErrBackfillPlan is backfill-adopt's refusal when the plan is not the // named reservation, still collecting, at this roster revision. ErrBackfillPlan = errors.New("backfill refused: the plan must be the named reservation, still collecting, at this roster revision") // ErrBackfillInstallations is backfill-done's refusal when a box's // census report lists an installation: done is for an empty cohort, // and there is no override. ErrBackfillInstallations = errors.New("backfill refused: a census report lists installations; this phase is for an empty cohort") ) // backfillReady is both phases' refusal up front: a published roster with a // home region, whose floor digest is the published floor's. func backfillReady(ctx context.Context, d hostactors.Directory, o hostactors.Observed) error { if o.Roster.Publication != "published" || o.Roster.HomeRegion == "" || o.Roster.FloorSHA256 == "" { return ErrBackfillState } floor, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket) if err != nil { return errors.Join(ErrBackfillState, err) } if floor.Digest != o.Roster.FloorSHA256 { return ErrBackfillState } return nil } // listBackfillHosts enumerates every record in the deployment through the // same listing the box census and the adopters read (hostops.Apps and // hostops.Records). func listBackfillHosts(ctx context.Context, s release.Store) (map[string]hostops.BackfillHost, error) { apps, err := hostops.Apps(ctx, s) if err != nil { return nil, err } hosts := map[string]hostops.BackfillHost{} for _, app := range apps { records, err := hostops.Records(ctx, s, app.Account, app.Name) if err != nil { return nil, err } for _, r := range records { if _, dup := hosts[r.Holder.Host]; dup || r.Holder.Host == "" || r.Holder.Key == "" || r.ETag == "" { return nil, ErrBackfillRecords } if r.Holder.Acquisition == "" { return nil, ErrBackfillRecords } hosts[r.Holder.Host] = hostops.BackfillHost{Key: r.Holder.Key, ETag: r.ETag, Acquisition: r.Holder.Acquisition} } } return hosts, nil } func backfillSummary(p hostops.BackfillPlan) BackfillSummary { out := BackfillSummary{ID: p.ID, Phase: p.Phase, DirectoryRevision: p.DirectoryRevision, Hosts: []BackfillHost{}, Reports: p.Reports} for host, h := range p.Hosts { out.Hosts = append(out.Hosts, BackfillHost{Host: host, BackfillHost: h}) if p.Phase == "adopting" && h.Box == "" { out.Unowned = append(out.Unowned, host) } } sort.Slice(out.Hosts, func(i, j int) bool { return out.Hosts[i].Host < out.Hosts[j].Host }) sort.Strings(out.Unowned) return out } // readSignedPlan reads the backfill plan and verifies its root signature. func readSignedPlan(ctx context.Context, d hostactors.Directory) (hostops.BackfillPlan, string, error) { var p hostops.BackfillPlan raw, tag, err := d.Store.Get(ctx, hostops.BackfillKey) if err != nil { return p, "", err } if tag == "" || release.VerifyRootEnvelope(d.PublicKey, raw, &p) != nil || p.V != 1 { return p, "", ErrBackfillPlan } return p, tag, nil } // backfillCollect writes the signed collecting plan, create-only. The plan // freezes every instance record in the deployment by key and etag: the box // census refuses any record it does not name, and claim-mode record writers // refuse while it exists. It is read back, verified, and the records are // listed again afterwards: a record that moved while the plan was written // is a refusal, because the box census would refuse the plan anyway. func (r Runner) backfillCollect(ctx context.Context, d hostactors.Directory, o hostactors.Observed, in BackfillInput, result *Result) error { if !validBackfillID(in.ID) { return ErrActivation } if err := backfillReady(ctx, d, o); err != nil { return err } if _, _, err := d.Store.Get(ctx, hostops.BackfillKey); err == nil { return ErrBackfillExists } else if !errors.Is(err, os.ErrNotExist) { return err } hosts, err := listBackfillHosts(ctx, d.Store) if err != nil { return err } p := hostops.BackfillPlan{V: 1, ID: in.ID, Phase: "collecting", DirectoryRevision: o.Roster.Revision, CreatedAt: time.Now().UTC(), Hosts: hosts} body, err := release.SignRootEnvelope(r.Signer, p) if err != nil { return err } if _, err = d.Store.Put(ctx, hostops.BackfillKey, body, ""); err != nil { return err } got, _, err := readSignedPlan(ctx, d) if err != nil { return err } if got.Phase != "collecting" || got.ID != in.ID || got.DirectoryRevision != o.Roster.Revision || !reflect.DeepEqual(got.Hosts, hosts) { return ErrBackfillPlan } again, err := listBackfillHosts(ctx, d.Store) if err != nil { return err } if !reflect.DeepEqual(again, hosts) { return ErrBackfillRecords } summary := backfillSummary(got) result.Backfill = &summary return nil } // backfillAdopt advances the named collecting plan to adopting through // hostops' Backfill.Publish, under the plan's own etag (If-Match). Publish // requires every roster box's signed census report for the reservation, // freezes their digests, takes each host's box and region from them, and // refuses unfinished seed state. The adopters' own acceptance // (VerifyAdopting) is then run on the result as readback. func (r Runner) backfillAdopt(ctx context.Context, d hostactors.Directory, o hostactors.Observed, in BackfillInput, result *Result) error { if !validBackfillID(in.ID) { return ErrActivation } if err := backfillReady(ctx, d, o); err != nil { return err } p, tag, err := readSignedPlan(ctx, d) if err != nil { return err } if p.ID != in.ID || p.Phase != "collecting" || p.DirectoryRevision != o.Roster.Revision { return ErrBackfillPlan } b := &hostops.Backfill{Store: d.Store, Directory: d} if err := b.Publish(ctx, r.Signer, tag); err != nil { return err } got, err := b.VerifyAdopting(ctx) if err != nil { return err } if got.ID != in.ID || got.DirectoryRevision != o.Roster.Revision || len(got.Hosts) != len(p.Hosts) { return ErrBackfillPlan } for host, h := range p.Hosts { n, ok := got.Hosts[host] if !ok || n.Key != h.Key || n.ETag != h.ETag || n.Acquisition != h.Acquisition { return ErrBackfillPlan } } summary := backfillSummary(got) result.Backfill = &summary return nil } // backfillDone closes the named collecting plan straight to done, for an // empty cohort (claim trial Option B, Paul's D1 of 2026-10-20), through // hostops' Backfill.Done under the plan's own etag (If-Match). Done adopts // nothing: it refuses unless the records are the plan's, no host claim or // app admission exists, and every roster box with a census key reports no // installation. Boxes with no key are listed as unreported. The plan is then // read back as done. func (r Runner) backfillDone(ctx context.Context, d hostactors.Directory, o hostactors.Observed, in BackfillInput, result *Result) error { if !validBackfillID(in.ID) { return ErrActivation } if err := backfillReady(ctx, d, o); err != nil { return err } p, tag, err := readSignedPlan(ctx, d) if err != nil { return err } if p.ID != in.ID || p.Phase != "collecting" || p.DirectoryRevision != o.Roster.Revision { return ErrBackfillPlan } b := &hostops.Backfill{Store: d.Store, Directory: d} unreported, err := b.Done(ctx, r.Signer, tag) switch { case errors.Is(err, hostops.ErrDoneInstallations): return errors.Join(ErrBackfillInstallations, err) case errors.Is(err, hostops.ErrDoneRecords): return errors.Join(ErrBackfillRecords, err) case errors.Is(err, hostops.ErrDoneETag), errors.Is(err, hostops.ErrDoneEnvelope), errors.Is(err, hostops.ErrDonePhase): return errors.Join(ErrBackfillPlan, err) case err != nil: return err } got, _, err := readSignedPlan(ctx, d) if err != nil { return err } if got.ID != in.ID || got.Phase != "done" || got.DirectoryRevision != o.Roster.Revision || len(got.Hosts) != len(p.Hosts) { return ErrBackfillPlan } for host, h := range p.Hosts { if got.Hosts[host] != h { return ErrBackfillPlan } } summary := backfillSummary(got) summary.Unreported = unreported result.Backfill = &summary return nil } // validBackfillID keeps the reservation id one safe path segment. func validBackfillID(id string) bool { if id == "" || len(id) > 64 || id == "." || id == ".." { return false } for _, c := range id { if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.') { return false } } return true }