package hostbox import ( "context" "slices" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/park" "github.com/carlosframework/platform/internal/release" ) type RecordSelection struct { Host, Key, ETag, Incarnation, Family string Layout string Paths []string DryRun bool } type Reconciler struct { PrepareInventory func(context.Context, RecordSelection, hostguard.Captured) error Prepare func(context.Context, hostclaim.Observed) error HomeRegion string Claims release.Store Guard hostguard.Guard Local park.Local Writes hostwrite.Config Box, Region string Now func() time.Time // CheckRecord directly checks the selected record ETag and ownership acquisition. CheckRecord func(context.Context, RecordSelection) error // CheckLocal includes complete census and journal recovery before initialization. CheckLocal func(context.Context, hostclaim.Observed, RecordSelection) error // CheckHeld is the hold-only path's local check, in CheckLocal's place // there: this box's own existing installation for the row it would // hold, never the claim holder's stamp (Task 10b, Astra round 1). // Production: EmptyInstallationCensus.CheckHeld, which // ReconcileAdapter.Install sets from its census. nil refuses the hold. CheckHeld func(context.Context, hostclaim.Observed, RecordSelection) error // Adopt is Plan 5's #232 backfill coordinator, never a second adoption rule. Adopt func(context.Context, RecordSelection) (hostclaim.Observed, error) // FenceClosed reports whether this box's admission fence for the host // is closed on the claim given (region HA §5.3: the source in // releasing is ready until then). It is asked only where the row // decision depends on it (rowDecision). It is called under the host lock // and must not take it (hostguard.Guard.AdmissionFenced). nil: never // closed, today's behaviour. An error keeps rows and mints none (C6). // Production: AdmissionFenceClosed, region HA on only (Task 10b). FenceClosed func(context.Context, hostclaim.Observed) (bool, error) // HoldRow keeps an existing row held for an operation without // installation authority (ruling 17). Production: RegistryHoldRow, // region HA on only (Task 10b). HoldRow func(ctx context.Context, host, holdOp string) error } // relocationAware is region HA on: Task 10b sets FenceClosed and HoldRow // only with CARLOS_REGION_HA (host's relocationSourceRows). With neither, // reconcile is today's: the serving executor alone (Serving), and no // hold-only path, the completed relocation's catch-up hold included. func (r Reconciler) relocationAware() bool { return r.FenceClosed != nil || r.HoldRow != nil } // relocates is the row decision's scope: a relocating claim with region HA // on. Every other operation, moving included, keeps main's path (Serving), // HA on or off: moves owns its holds, in provision's pass. The one // exception is a serving claim's completed relocation, whose catch-up hold // (completedHold) is decided apart from this scope. func (r Reconciler) relocates(c *hostclaim.Claim) bool { return r.relocationAware() && c != nil && c.Op.Kind == hostclaim.KindRelocating } // completedHold is the catch-up hold (L18 joint round, finding 3): the // source's fence closed in releasing, but its next reconcile ran only once // the target had completed, so the claim serves on another box and // rowDecision's scope (relocates) no longer reaches it, and main's Serving // would refuse with the row left ready, which the edge serves, until the // park-local retires it. Completion waits on no acknowledgement of the // source's hold (ruling 80), so reconcile catches up here: the row is held // for the completed op, a RowRetiring hold, until the park-local retires // it (Task 10b decision 3: nothing un-holds). // // In scope only with region HA on, and only on positive evidence on the // fresh claim: a serving op on another box (not this one, which serves // through Serving) that journals a completed relocation (not completed on // this box) and a park-local this box owes for exactly that op, which // CompleteRelocation queues for the source and every installed box but // the target. A box on lost is left to its park-local (decision 3). A // park-local for another op, or none, is out of scope and main's path // runs. A completion record that cannot be read is its error, never "no // completion": the row is kept and nothing is minted. The path is hold // only (Install, checkOn): no activation, no inventory, no execution. func (r Reconciler) completedHold(c *hostclaim.Claim) (RowDecision, bool, error) { if !r.relocationAware() || c == nil || c.Holder == nil || r.Box == "" || c.Op.Kind != hostclaim.KindServing || c.Op.Exec == r.Box || slices.Contains(c.Lost, r.Box) { return RowDecision{}, false, nil } done, ok, err := hostclaim.ReadCompletedRelocation(c) if err != nil { return RowDecision{}, false, err } if !ok || done.Stop.Box == r.Box { return RowDecision{}, false, nil } for _, o := range c.Obligations { if o.Kind == "park-local" && o.Box == r.Box && o.Op == done.Op { return RowDecision{Case: RowRetiring, HoldOp: done.Op}, true, nil } } return RowDecision{}, false, nil } func (r Reconciler) fenceClosed(ctx context.Context, obs hostclaim.Observed) (bool, error) { if r.FenceClosed == nil { return false, nil } return r.FenceClosed(ctx, obs) } // rowDecision is §5.3's decision for this box on a fresh read. The fence // observer is asked only when the decision depends on its answer, which // is the source in releasing alone: every other relocating box, a target // before it has a local record included, decides without it. closed is // the observer's answer, false when it was not asked. func (r Reconciler) rowDecision(ctx context.Context, obs hostclaim.Observed) (d RowDecision, closed bool, err error) { open := RelocationRow(obs.Claim, r.Box, true, false) shut := RelocationRow(obs.Claim, r.Box, true, true) if open == shut { return open, false, nil } closed, err = r.fenceClosed(ctx, obs) if err != nil { return RowDecision{}, false, err } if closed { return shut, true, nil } return open, false, nil } type activationReader struct { r Reconciler selected RecordSelection } func (a activationReader) Read(ctx context.Context, host string) (hostclaim.Observed, error) { if host != a.selected.Host { return hostclaim.Observed{}, ErrChanged } obs, err := hostclaim.Read(ctx, a.r.Claims, host) if err != nil { return obs, err } if err := a.r.check(ctx, obs, a.selected); err != nil { return hostclaim.Observed{}, err } return obs, nil } // decided is Install's one row decision for a relocating claim and the // path it chose: holdOnly is a kept row's hold write, with no execution. type decided struct { row RowDecision holdOnly bool } func (r Reconciler) check(ctx context.Context, obs hostclaim.Observed, selected RecordSelection) error { return r.checkOn(ctx, obs, selected, nil) } // checkOn is check on the row decision dec when the caller already made it // (Install), or on its own fresh one (dec nil). The hold-only write is not // execution, so the region's quiet window does not gate it; every other // path, the activation reader's and inventory's included, is gated. func (r Reconciler) checkOn(ctx context.Context, obs hostclaim.Observed, selected RecordSelection, dec *decided) error { if r.Claims == nil || r.CheckRecord == nil || r.CheckLocal == nil || r.Now == nil || r.Box == "" || r.Region == "" || selected.ETag == "" || obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { return ErrUnsupported } h := obs.Claim.Holder if obs.Host != selected.Host || h.Key != selected.Key || h.Incarnation != selected.Incarnation || h.Family != selected.Family || h.Stamp == 0 { return ErrChanged } holdOnly := false caught, catchUp, err := r.completedHold(obs.Claim) if err != nil { return err } switch { case catchUp: // The catch-up hold is hold only, on Install's own decision: the // activation reader, the inventory callback and any caller // without that decision are refused before anything is written. if dec == nil || !dec.holdOnly || dec.row != caught { return ErrChanged } holdOnly = true case !r.relocates(obs.Claim): if err := Serving(obs.Claim, Selection{Box: r.Box}); err != nil { return err } default: d := RowDecision{} if dec != nil { d, holdOnly = dec.row, dec.holdOnly } else { var err error if d, _, err = r.rowDecision(ctx, obs); err != nil { return err } } switch d.Case { case RowReady, RowHeld: default: return ErrChanged } if !holdOnly { // Claim Plan 11's policy gate admits no "reconcile" action under // a relocating claim (R1: only op.exec's probe), so the // activation path would end refused at work.With after it had // activated the guard and written the inventory. Refuse here, // before anything is written; the dry run, the activation reader // and the inventory callback all agree. The hold-only write // takes no policy and is unchanged. return ErrChanged } } if until := park.QuietUntil(obs.Claim.QuietAfter, r.HomeRegion, r.Region); !holdOnly && !until.IsZero() && !r.Now().After(until) { return ErrChanged } if err := r.CheckRecord(ctx, selected); err != nil { return err } if holdOnly { // Holding is not installing (Task 10b, Astra round 1): the source // at installing or later is held against its own installation, // which CheckHeld checks; CheckLocal would ask for the holder's // stamp, the target's, and refuse for good. if r.CheckHeld == nil { return ErrUnsupported } return r.CheckHeld(ctx, obs, selected) } return r.CheckLocal(ctx, obs, selected) } func (r Reconciler) Install(ctx context.Context, selected RecordSelection, write func() error) error { return r.installWithWork(ctx, selected, func(work hostwrite.Work) error { return work.With(ctx, hostwrite.Identity{}, write) }) } func (r Reconciler) installWithWork(ctx context.Context, selected RecordSelection, write func(hostwrite.Work) error) error { if !r.Writes.Enabled { if selected.DryRun { return nil } work, err := r.Writes.Select(ctx, selected.Host, "reconcile") if err != nil { return err } return write(work) } if write == nil || r.Claims == nil { return ErrUnsupported } obs, err := hostclaim.Read(ctx, r.Claims, selected.Host) if err != nil { return err } if obs.Absent { if selected.DryRun { return ErrUnsupported } if r.Adopt == nil { return ErrUnsupported } obs, err = r.Adopt(ctx, selected) if err != nil { return err } } var dec *decided if r.relocates(obs.Claim) { // One answer from the observer, for the check, the decision and // the path, asked only when the decision depends on it. d, closed, err := r.rowDecision(ctx, obs) if err != nil { return err } // No installation of ours to activate, or one whose admission // fence is closed (the source in releasing, still op.exec, which // With refuses): a kept row's hold only, under the flock. A // target before installing has no row and mints none (ruling 17). dec = &decided{row: d, holdOnly: d.Case == RowHeld && (obs.Claim.Op.Exec != r.Box || closed)} } else if d, ok, err := r.completedHold(obs.Claim); err != nil { return err } else if ok { // A completed relocation's catch-up hold: always hold only. dec = &decided{row: d, holdOnly: true} } if err := r.checkOn(ctx, obs, selected, dec); err != nil { return err } if selected.DryRun { return nil } if dec != nil && dec.holdOnly { return r.holdOnly(ctx, selected, dec.row) } if r.Prepare != nil { if err := r.Prepare(ctx, obs); err != nil { return err } } // Activate's participant callback is not an admission validator for executors. // The reader validates EVERY fresh read while Activate holds hostguard. guard := r.Guard guard.Claims = activationReader{r: r, selected: selected} if err := guard.Activate(ctx, selected.Host, r.Box, nil); err != nil { return err } captured, err := guard.Capture(selected.Host) if err != nil { return err } if r.PrepareInventory != nil { if err := r.PrepareInventory(ctx, selected, captured); err != nil { return err } } selector, ok := r.Writes.Selector.(interface { SelectAt(context.Context, string, string, hostguard.Captured) (hostwrite.Work, error) }) if !ok { return ErrUnsupported } work, err := selector.SelectAt(ctx, selected.Host, "reconcile", captured) if err != nil { return err } work = installationWork(work) local := r.Local local.Guard = guard check := func(c *hostclaim.Claim) error { return r.check(ctx, hostclaim.Observed{Host: selected.Host, Claim: c}, selected) } if err := local.RecordInventory(ctx, selected.Host, captured, selected.Layout, selected.Paths, check); err != nil { return err } return write(work) } // holdOnly keeps an existing row held for the operation, with no // installation authority: the write is conditional on the row's observed // hold (registry.SetHoldIf, through RegistryHoldRow), under the host's // flock. The decision is remade on a fresh read under that lock. // ErrChanged when there is no row: nothing is minted. func (r Reconciler) holdOnly(ctx context.Context, selected RecordSelection, d RowDecision) error { if r.HoldRow == nil { return ErrUnsupported } return r.Guard.WithLocal(ctx, selected.Host, func() error { fresh, err := hostclaim.Read(ctx, r.Claims, selected.Host) if err != nil { return err } if fresh.Absent || fresh.Claim == nil || fresh.Claim.Holder == nil || fresh.Claim.Holder.Incarnation != selected.Incarnation { return ErrChanged } // The decision is remade in whichever scope the fresh read is in: // the catch-up decision (RowRetiring) and rowDecision's held one // (RowHeld) never compare equal, so a scope change is ErrChanged. again, catchUp, err := r.completedHold(fresh.Claim) switch { case err != nil: return err case catchUp: case r.relocates(fresh.Claim): if again, _, err = r.rowDecision(ctx, fresh); err != nil { return err } default: return ErrChanged } if again != d { return ErrChanged } if err := r.checkOn(ctx, fresh, selected, &decided{row: d, holdOnly: true}); err != nil { return err } return r.HoldRow(ctx, selected.Host, d.HoldOp) }) }