hostclaim, hostbox: enrolled identity, durable wake retries and reconcile admission (claim Plan 6 Task 7)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+693
−0
internal/hostbox/identity.go+106 −0internal/hostbox/identity_test.go+36 −0internal/hostbox/reconcile.go+133 −0internal/hostbox/reconcile_test.go+41 −0internal/hostbox/work.go+13 −0internal/hostclaim/claim.go+8 −0internal/hostclaim/generation.go+6 −0internal/hostclaim/wakeretry.go+193 −0internal/hostclaim/wakeretry_test.go+157 −0
diff --git a/internal/hostbox/identity.go b/internal/hostbox/identity.go| new file mode 100644 |
| index 000000000..36e56a0c2 |
| --- /dev/null |
| +++ b/internal/hostbox/identity.go |
| @@ -0,0 +1,106 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "crypto/x509" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| +) |
| + |
| +// Actor is the verified projection of the sole control/host-actors.json roster. |
| +// Roster authenticates that document and its revision before returning a row. |
| +type Actor struct { |
| + Box, Enrollment string |
| + Revision uint64 |
| + Region, Bucket, Namespace string |
| + Cloud CloudIdentity |
| + Eligible bool |
| + Quarantined bool |
| +} |
| +type CloudIdentity struct{ Provider, Account, Region, InstanceID string } |
| +type Roster interface { |
| + Actor(context.Context, string) (Actor, error) |
| +} |
| +type BoxEvidence interface { |
| + Load(context.Context, string) (boxid.Record, []byte, *boxid.Bind, error) |
| +} |
| + |
| +type IdentityProvider struct { |
| + Roster Roster |
| + Evidence BoxEvidence |
| + Account, Box, Region, Bucket, Namespace string |
| + Certs map[string]*x509.Certificate |
| +} |
| + |
| +func (p IdentityProvider) Verify(ctx context.Context) (Actor, error) { |
| + if p.Roster == nil || p.Evidence == nil || p.Account == "" || p.Box == "" || p.Region == "" || p.Bucket == "" || p.Namespace == "" || len(p.Certs) == 0 { |
| + return Actor{}, ErrUnsupported |
| + } |
| + a, err := p.Roster.Actor(ctx, p.Box) |
| + if err != nil { |
| + return Actor{}, err |
| + } |
| + if a.Box != p.Box || a.Enrollment == "" || a.Revision == 0 || !a.Eligible || a.Quarantined || a.Region != p.Region || a.Bucket != p.Bucket || a.Namespace != p.Namespace || a.Cloud.Provider != "aws" || a.Cloud.Account != p.Account || a.Cloud.Region != p.Region || a.Cloud.InstanceID == "" { |
| + return Actor{}, ErrChanged |
| + } |
| + rec, raw, bind, err := p.Evidence.Load(ctx, p.Box) |
| + if err != nil { |
| + return Actor{}, err |
| + } |
| + if rec.Box != p.Box { |
| + return Actor{}, ErrChanged |
| + } |
| + id, err := (boxid.Verifier{Account: p.Account, Certs: p.Certs}).Verify(rec, raw, bind) |
| + if err != nil { |
| + return Actor{}, err |
| + } |
| + if id.AccountID != a.Cloud.Account || id.Region != a.Cloud.Region || id.InstanceID != a.Cloud.InstanceID { |
| + return Actor{}, ErrChanged |
| + } |
| + return a, nil |
| +} |
| + |
| +// LocalAuthority is Plan 8's nonblocking local-state reader. It checks its own |
| +// boot-clock deadline, loss latch, controller identity, quarantine and fences. |
| +// No second execution lease state machine is introduced here. |
| +type LocalAuthority interface { |
| + Check(context.Context, Actor, *hostclaim.Claim, Selection) error |
| +} |
| + |
| +func (p IdentityProvider) Local(authority LocalAuthority) func(context.Context, *hostclaim.Claim, Selection) error { |
| + return func(ctx context.Context, c *hostclaim.Claim, s Selection) error { |
| + if authority == nil { |
| + return ErrUnsupported |
| + } |
| + actor, err := p.Verify(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + if s.Box != actor.Box || s.Region != actor.Region { |
| + return ErrChanged |
| + } |
| + return authority.Check(ctx, actor, c, s) |
| + } |
| +} |
| + |
| +// Startup evaluates the switch before constructing any authority provider. |
| +func Startup(ctx context.Context, getenv func(string) string, construct func(context.Context) (hostwrite.Selector, error)) (hostwrite.Config, error) { |
| + c := hostwrite.Config{Enabled: hostwrite.Enabled(getenv)} |
| + if !c.Enabled { |
| + return c, nil |
| + } |
| + if construct == nil { |
| + return c, hostwrite.ErrUnavailable |
| + } |
| + selector, err := construct(ctx) |
| + if err != nil { |
| + return c, err |
| + } |
| + if selector == nil { |
| + return c, hostwrite.ErrUnavailable |
| + } |
| + c.Selector = selector |
| + return c, nil |
| +} |
diff --git a/internal/hostbox/identity_test.go b/internal/hostbox/identity_test.go| new file mode 100644 |
| index 000000000..3782fa162 |
| --- /dev/null |
| +++ b/internal/hostbox/identity_test.go |
| @@ -0,0 +1,36 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "testing" |
| +) |
| + |
| +func TestStartupFlagOnceAndInert(t *testing.T) { |
| + for _, value := range []string{"", " ", "0", "1"} { |
| + reads, calls := 0, 0 |
| + cfg, err := Startup(context.Background(), func(name string) string { |
| + reads++ |
| + if name != "CARLOS_HOST_CLAIM" { |
| + t.Fatal(name) |
| + } |
| + return value |
| + }, func(context.Context) (hostwrite.Selector, error) { calls++; return nil, hostwrite.ErrUnavailable }) |
| + on := value == "0" || value == "1" |
| + if reads != 1 || cfg.Enabled != on || (calls == 1) != on || (err != nil) != on { |
| + t.Fatalf("%q: %+v %v %d %d", value, cfg, err, reads, calls) |
| + } |
| + } |
| +} |
| + |
| +type refuseRoster struct{} |
| + |
| +func (refuseRoster) Actor(context.Context, string) (Actor, error) { |
| + return Actor{}, errors.New("unverified roster") |
| +} |
| +func TestIdentityHasNoRegionOnlyFallback(t *testing.T) { |
| + if _, err := (IdentityProvider{Box: "box", Region: "ie", Roster: refuseRoster{}}).Verify(context.Background()); err == nil { |
| + t.Fatal("identity without boxid evidence") |
| + } |
| +} |
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go| new file mode 100644 |
| index 000000000..7cf5d4a59 |
| --- /dev/null |
| +++ b/internal/hostbox/reconcile.go |
| @@ -0,0 +1,133 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "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 { |
| + 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 |
| + // Adopt is Plan 5's #232 backfill coordinator, never a second adoption rule. |
| + Adopt func(context.Context, RecordSelection) (hostclaim.Observed, error) |
| +} |
| +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 |
| +} |
| +func (r Reconciler) check(ctx context.Context, obs hostclaim.Observed, selected RecordSelection) 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 |
| + } |
| + if err := Serving(obs.Claim, Selection{Box: r.Box}); err != nil { |
| + return err |
| + } |
| + if until, ok := obs.Claim.QuietAfter[r.Region]; ok && !r.Now().After(until) { |
| + return ErrChanged |
| + } |
| + if err := r.CheckRecord(ctx, selected); err != nil { |
| + return err |
| + } |
| + return r.CheckLocal(ctx, obs, selected) |
| +} |
| +func (r Reconciler) Install(ctx context.Context, selected RecordSelection, write func() error) error { |
| + if !r.Writes.Enabled { |
| + if selected.DryRun { |
| + return nil |
| + } |
| + return write() |
| + } |
| + 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 |
| + } |
| + } |
| + if err := r.check(ctx, obs, selected); err != nil { |
| + return err |
| + } |
| + if selected.DryRun { |
| + return nil |
| + } |
| + // 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 := hostguard.Capture(guard.DataDir, selected.Host) |
| + if 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 |
| + } |
| + 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 work.With(ctx, hostwrite.Identity{}, write) |
| +} |
diff --git a/internal/hostbox/reconcile_test.go b/internal/hostbox/reconcile_test.go| new file mode 100644 |
| index 000000000..71f87c248 |
| --- /dev/null |
| +++ b/internal/hostbox/reconcile_test.go |
| @@ -0,0 +1,41 @@ |
| +//go:build unix |
| + |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/park" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestReconcileDryRunAndExecutorValidation(t *testing.T) { |
| + source, store, obs := workFixture(t) |
| + h := obs.Claim.Holder |
| + selected := RecordSelection{Host: obs.Host, Key: h.Key, ETag: "record-etag", Incarnation: h.Incarnation, Family: h.Family, Layout: "flat", Paths: []string{"shop.example.db"}, DryRun: true} |
| + checks, writes := 0, 0 |
| + r := Reconciler{Claims: store, Guard: source.Guard, Local: park.Local{Guard: source.Guard, Claims: store, DataDir: source.Guard.DataDir, Box: "box"}, Writes: hostwrite.Config{Enabled: true, Selector: source}, Box: "box", Region: "ie", Now: func() time.Time { return time.Unix(100, 0) }, CheckRecord: func(context.Context, RecordSelection) error { return nil }, CheckLocal: func(context.Context, hostclaim.Observed, RecordSelection) error { checks++; return nil }} |
| + if err := r.Install(context.Background(), selected, func() error { writes++; return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if writes != 0 || checks != 1 { |
| + t.Fatal(writes, checks) |
| + } |
| + selected.DryRun = false |
| + r.CheckLocal = func(context.Context, hostclaim.Observed, RecordSelection) error { |
| + checks++ |
| + if checks >= 3 { |
| + return errors.New("quarantined after precheck") |
| + } |
| + return nil |
| + } |
| + if err := r.Install(context.Background(), selected, func() error { writes++; return nil }); err == nil { |
| + t.Fatal("executor bypassed fresh local check") |
| + } |
| + if writes != 0 { |
| + t.Fatal("row materialized") |
| + } |
| +} |
diff --git a/internal/hostbox/work.go b/internal/hostbox/work.go| index 66ac634c2..ad9fa22c7 100644 |
| --- a/internal/hostbox/work.go |
| +++ b/internal/hostbox/work.go |
| @@ -89,3 +89,16 @@ func Serving(c *hostclaim.Claim, selected Selection) error { |
| } |
| return nil |
| } |
| + |
| +// SelectAt captures an already chosen installation without relabelling it. |
| +func (s WorkSource) SelectAt(ctx context.Context, host, action string, captured hostguard.Captured) (hostwrite.Work, error) { |
| + work, err := s.Select(ctx, host, action) |
| + if err != nil { |
| + return nil, err |
| + } |
| + selected := work.(selectedWork) |
| + if selected.selection.Captured != captured { |
| + return nil, ErrChanged |
| + } |
| + return work, nil |
| +} |
diff --git a/internal/hostclaim/claim.go b/internal/hostclaim/claim.go| index 43ed0bb84..4773e9539 100644 |
| --- a/internal/hostclaim/claim.go |
| +++ b/internal/hostclaim/claim.go |
| @@ -58,6 +58,7 @@ type Holder struct { |
| Session uint64 `json:"session,omitempty"` |
| WriterBox string `json:"writer_box,omitempty"` |
| Admissions []AdmissionRecord `json:"admissions,omitempty"` |
| + WakeRetry *WakeRetry `json:"wake_retry,omitempty"` |
| } |
| type AdmissionRecord struct { |
| @@ -253,6 +254,13 @@ func (c *Claim) Validate() error { |
| return fmt.Errorf("%w: admission without region", ErrMalformed) |
| } |
| } |
| + if err := h.WakeRetry.validate(); err != nil { |
| + return err |
| + } |
| + if h.WakeRetry != nil && h.WakeRetry.State == "attempting" && h.WakeRetry.Attempt.Session != 0 && |
| + (h.WakeRetry.Attempt.Session != h.Session || h.WakeRetry.Attempt.Box != h.WriterBox) { |
| + return fmt.Errorf("%w: wake retry attempt is not the live writer session", ErrMalformed) |
| + } |
| } |
| if len(c.RetiredIncarnations) > retiredCap { |
| return fmt.Errorf("%w: retired_incarnations over cap", ErrMalformed) |
diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go| index 973e71dac..33d5cc4f2 100644 |
| --- a/internal/hostclaim/generation.go |
| +++ b/internal/hostclaim/generation.go |
| @@ -40,6 +40,12 @@ func AdmitWriter(in Admission) func(*Claim) error { |
| return fmt.Errorf("%w: op kind %q cannot admit a writer", ErrWrongState, c.Op.Kind) |
| } |
| h := c.Holder |
| + if c.Op.Kind == KindServing && h.WakeRetry != nil { |
| + r := h.WakeRetry |
| + if r.State != "attempting" || r.Attempt == nil || r.Attempt.Session != 0 || r.Attempt.Box != in.Box { |
| + return fmt.Errorf("%w: serving writer admission has no matching retry reservation", ErrWrongState) |
| + } |
| + } |
| if h.Session != in.ObservedSession || h.Generation != in.ObservedGeneration { |
| return fmt.Errorf("%w: writer session or generation moved since it was observed", ErrWrongState) |
| } |
diff --git a/internal/hostclaim/wakeretry.go b/internal/hostclaim/wakeretry.go| new file mode 100644 |
| index 000000000..cd68cc933 |
| --- /dev/null |
| +++ b/internal/hostclaim/wakeretry.go |
| @@ -0,0 +1,193 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "fmt" |
| + "time" |
| +) |
| + |
| +const WakeRetryLimit = 5 |
| + |
| +var wakeRetryDelays = [...]time.Duration{time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second} |
| + |
| +type WakeRetry struct { |
| + EpisodeID string `json:"episode_id"` |
| + AttemptsReserved int `json:"attempts_reserved"` |
| + State string `json:"state"` |
| + NotBefore time.Time `json:"not_before,omitempty"` |
| + Attempt *WakeRetryAttempt `json:"attempt,omitempty"` |
| + ResetRequest string `json:"reset_request,omitempty"` |
| +} |
| + |
| +type WakeRetryAttempt struct { |
| + ID string `json:"id"` |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + BootID string `json:"boot_id"` |
| + ControllerID string `json:"controller_id"` |
| + Session uint64 `json:"session,omitempty"` |
| + InvocationID string `json:"invocation_id"` |
| + LaunchInputs string `json:"launch_inputs"` |
| +} |
| + |
| +func (r *WakeRetry) validate() error { |
| + if r == nil { |
| + return nil |
| + } |
| + if r.EpisodeID == "" || r.AttemptsReserved < 0 || r.AttemptsReserved > WakeRetryLimit { |
| + return fmt.Errorf("%w: wake retry episode", ErrMalformed) |
| + } |
| + switch r.State { |
| + case "open": |
| + if r.Attempt != nil || r.AttemptsReserved >= WakeRetryLimit { |
| + return fmt.Errorf("%w: open wake retry", ErrMalformed) |
| + } |
| + case "attempting": |
| + if r.AttemptsReserved == 0 || r.Attempt == nil || !r.NotBefore.IsZero() || !r.Attempt.valid() { |
| + return fmt.Errorf("%w: attempting wake retry", ErrMalformed) |
| + } |
| + case "failed": |
| + if r.AttemptsReserved != WakeRetryLimit || r.Attempt != nil || r.NotBefore.IsZero() { |
| + return fmt.Errorf("%w: failed wake retry", ErrMalformed) |
| + } |
| + default: |
| + return fmt.Errorf("%w: wake retry state %q", ErrMalformed, r.State) |
| + } |
| + return nil |
| +} |
| + |
| +func (a *WakeRetryAttempt) valid() bool { |
| + return a.ID != "" && a.Box != "" && a.Enrollment != "" && a.BootID != "" && |
| + a.ControllerID != "" && a.InvocationID != "" && a.LaunchInputs != "" |
| +} |
| + |
| +type WakeRetryReservation struct { |
| + HolderIncarnation string |
| + EpisodeID string |
| + Attempt WakeRetryAttempt |
| + Now time.Time |
| +} |
| + |
| +// ReserveWakeRetryAttempt spends one serving retry after the caller has |
| +// established all read-only prerequisites. Session zero denotes the first |
| +// admission and must be bound with BindWakeRetrySession before app code runs. |
| +func ReserveWakeRetryAttempt(in WakeRetryReservation) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if c.Holder == nil || c.Op.Kind != KindServing || c.Holder.Incarnation != in.HolderIncarnation || |
| + in.Now.IsZero() || !in.Attempt.valid() || (in.Attempt.Session != 0 && in.Attempt.Session != c.Holder.Session) { |
| + return ErrWrongState |
| + } |
| + r := c.Holder.WakeRetry |
| + if r == nil { |
| + if in.EpisodeID == "" { |
| + return ErrWrongState |
| + } |
| + r = &WakeRetry{EpisodeID: in.EpisodeID, State: "open"} |
| + c.Holder.WakeRetry = r |
| + } else if in.EpisodeID != "" && in.EpisodeID != r.EpisodeID { |
| + return ErrWrongState |
| + } |
| + if r.State != "open" || r.AttemptsReserved >= WakeRetryLimit || in.Now.Before(r.NotBefore) { |
| + return ErrWrongState |
| + } |
| + r.AttemptsReserved++ |
| + r.State, r.NotBefore = "attempting", time.Time{} |
| + a := in.Attempt |
| + r.Attempt = &a |
| + return nil |
| + } |
| +} |
| + |
| +// BindWakeRetrySession attaches the cold admission resulting from a first |
| +// reservation. It is required before that invocation may execute app code. |
| +func BindWakeRetrySession(incarnation, episode, attempt string, session uint64) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if c.Holder == nil || c.Holder.Incarnation != incarnation || session == 0 || c.Holder.Session != session { |
| + return ErrWrongState |
| + } |
| + r := c.Holder.WakeRetry |
| + if r == nil || r.EpisodeID != episode || r.State != "attempting" || r.Attempt == nil || |
| + r.Attempt.ID != attempt || r.Attempt.Session != 0 { |
| + return ErrWrongState |
| + } |
| + r.Attempt.Session = session |
| + return nil |
| + } |
| +} |
| + |
| +type WakeRetryResult struct { |
| + HolderIncarnation string |
| + EpisodeID string |
| + Attempt WakeRetryAttempt |
| + Now time.Time |
| +} |
| + |
| +func sameWakeAttempt(a *WakeRetryAttempt, b WakeRetryAttempt) bool { return a != nil && *a == b } |
| + |
| +// FailWakeRetryAttempt settles the exact reserved attempt. A crash or |
| +// ambiguous result uses this transition too; the reservation is never refunded. |
| +func FailWakeRetryAttempt(in WakeRetryResult) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if c.Holder == nil || c.Holder.Incarnation != in.HolderIncarnation || in.Now.IsZero() { |
| + return ErrWrongState |
| + } |
| + r := c.Holder.WakeRetry |
| + if r == nil || r.EpisodeID != in.EpisodeID || r.State != "attempting" || !sameWakeAttempt(r.Attempt, in.Attempt) { |
| + return ErrWrongState |
| + } |
| + r.Attempt = nil |
| + r.NotBefore = in.Now.Add(wakeRetryDelays[r.AttemptsReserved-1]) |
| + if r.AttemptsReserved == WakeRetryLimit { |
| + r.State = "failed" |
| + } else { |
| + r.State = "open" |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +type WakeRetryHealth struct { |
| + WakeRetryResult |
| + ReadinessPassed bool |
| +} |
| + |
| +// ClearWakeRetryHealthy accepts only a readiness result for the exact live |
| +// reservation. The caller is responsible for performing the backend checks. |
| +func ClearWakeRetryHealthy(in WakeRetryHealth) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if !in.ReadinessPassed || c.Holder == nil || c.Holder.Incarnation != in.HolderIncarnation || |
| + in.Attempt.Session == 0 || c.Holder.Session != in.Attempt.Session { |
| + return ErrWrongState |
| + } |
| + r := c.Holder.WakeRetry |
| + if r == nil || r.EpisodeID != in.EpisodeID || r.State != "attempting" || !sameWakeAttempt(r.Attempt, in.Attempt) { |
| + return ErrWrongState |
| + } |
| + c.Holder.WakeRetry = nil |
| + return nil |
| + } |
| +} |
| + |
| +type WakeRetryReset struct { |
| + HolderIncarnation string |
| + ObservedEpisode string |
| + NewEpisodeID string |
| + OperatorRequest string |
| + AttemptSettled bool |
| +} |
| + |
| +// ResetWakeRetry is the operator-authorized primitive. Authorization and any |
| +// required fencing happen outside this package and are asserted by the input. |
| +func ResetWakeRetry(in WakeRetryReset) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if c.Holder == nil || c.Holder.Incarnation != in.HolderIncarnation || in.ObservedEpisode == "" || |
| + in.NewEpisodeID == "" || in.NewEpisodeID == in.ObservedEpisode || in.OperatorRequest == "" { |
| + return ErrWrongState |
| + } |
| + r := c.Holder.WakeRetry |
| + if r == nil || r.EpisodeID != in.ObservedEpisode || (r.State == "attempting" && !in.AttemptSettled) { |
| + return ErrWrongState |
| + } |
| + c.Holder.WakeRetry = &WakeRetry{EpisodeID: in.NewEpisodeID, State: "open", ResetRequest: in.OperatorRequest} |
| + return nil |
| + } |
| +} |
diff --git a/internal/hostclaim/wakeretry_test.go b/internal/hostclaim/wakeretry_test.go| new file mode 100644 |
| index 000000000..b7ec7e108 |
| --- /dev/null |
| +++ b/internal/hostclaim/wakeretry_test.go |
| @@ -0,0 +1,157 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func retryAttempt(id string, session uint64) WakeRetryAttempt { |
| + return WakeRetryAttempt{ID: id, Box: "adi-knitting", Enrollment: "enroll-1", BootID: "boot-1", |
| + ControllerID: "controller-1", Session: session, InvocationID: "invoke-" + id, LaunchInputs: "sha256-inputs-" + id} |
| +} |
| + |
| +func TestWakeRetryDurableBudgetAndBackoff(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, ctx, s) |
| + inc := o.Claim.Holder.Incarnation |
| + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) |
| + |
| + for n := 1; n <= WakeRetryLimit; n++ { |
| + a := retryAttempt(string(rune('0'+n)), o.Claim.Holder.Session) |
| + var err error |
| + o, err = Update(ctx, s, o, ReserveWakeRetryAttempt(WakeRetryReservation{ |
| + HolderIncarnation: inc, EpisodeID: "episode-1", Attempt: a, Now: now, |
| + })) |
| + if err != nil { |
| + t.Fatalf("reserve %d: %v", n, err) |
| + } |
| + if o.Claim.Holder.WakeRetry.AttemptsReserved != n || o.Claim.Holder.WakeRetry.State != "attempting" { |
| + t.Fatalf("reserved %d = %+v", n, o.Claim.Holder.WakeRetry) |
| + } |
| + // A reread proves the budget is stored in the claim rather than process memory. |
| + if o, err = Read(ctx, s, testHost); err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = Update(ctx, s, o, FailWakeRetryAttempt(WakeRetryResult{ |
| + HolderIncarnation: inc, EpisodeID: "episode-1", Attempt: a, Now: now, |
| + })) |
| + if err != nil { |
| + t.Fatalf("fail %d: %v", n, err) |
| + } |
| + wantState := "open" |
| + if n == WakeRetryLimit { |
| + wantState = "failed" |
| + } |
| + if r := o.Claim.Holder.WakeRetry; r.State != wantState || !r.NotBefore.Equal(now.Add(wakeRetryDelays[n-1])) { |
| + t.Fatalf("settled %d = %+v", n, r) |
| + } |
| + if _, err := Update(ctx, s, o, ReserveWakeRetryAttempt(WakeRetryReservation{HolderIncarnation: inc, Attempt: retryAttempt("early", 0), Now: now})); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("early reserve %d = %v", n, err) |
| + } |
| + now = now.Add(wakeRetryDelays[n-1]) |
| + } |
| + if _, err := Update(ctx, s, o, ReserveWakeRetryAttempt(WakeRetryReservation{HolderIncarnation: inc, Attempt: retryAttempt("six", 0), Now: now})); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("sixth reserve = %v", err) |
| + } |
| + if _, err := Update(ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "new-after-failure", PredecessorCertified: true})); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("serving admission after exhausted budget = %v", err) |
| + } |
| +} |
| + |
| +func TestWakeRetryColdAdmissionBindingAndExactHealth(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, ctx, s) |
| + inc := o.Claim.Holder.Incarnation |
| + a := retryAttempt("first", 0) |
| + now := time.Date(2026, 10, 6, 13, 0, 0, 0, time.UTC) |
| + var err error |
| + o, err = Update(ctx, s, o, ReserveWakeRetryAttempt(WakeRetryReservation{HolderIncarnation: inc, EpisodeID: "episode", Attempt: a, Now: now})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = Update(ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "generation-1", PredecessorCertified: true})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + session := o.Claim.Holder.Session |
| + if o.Claim.Holder.WakeRetry.AttemptsReserved != 1 { // cold admission did not reset the episode |
| + t.Fatalf("episode after admission = %+v", o.Claim.Holder.WakeRetry) |
| + } |
| + if _, err := Update(ctx, s, o, ClearWakeRetryHealthy(WakeRetryHealth{WakeRetryResult: WakeRetryResult{HolderIncarnation: inc, EpisodeID: "episode", Attempt: a}, ReadinessPassed: true})); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("health before session binding = %v", err) |
| + } |
| + o, err = Update(ctx, s, o, BindWakeRetrySession(inc, "episode", "first", session)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a.Session = session |
| + wrong := a |
| + wrong.InvocationID = "other-invocation" |
| + if _, err := Update(ctx, s, o, ClearWakeRetryHealthy(WakeRetryHealth{WakeRetryResult: WakeRetryResult{HolderIncarnation: inc, EpisodeID: "episode", Attempt: wrong}, ReadinessPassed: true})); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("wrong invocation health = %v", err) |
| + } |
| + if _, err := Update(ctx, s, o, ClearWakeRetryHealthy(WakeRetryHealth{WakeRetryResult: WakeRetryResult{HolderIncarnation: inc, EpisodeID: "episode", Attempt: a}})); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("failed readiness = %v", err) |
| + } |
| + o, err = Update(ctx, s, o, ClearWakeRetryHealthy(WakeRetryHealth{WakeRetryResult: WakeRetryResult{HolderIncarnation: inc, EpisodeID: "episode", Attempt: a}, ReadinessPassed: true})) |
| + if err != nil || o.Claim.Holder.WakeRetry != nil { |
| + t.Fatalf("healthy clear = %+v, %v", o.Claim.Holder.WakeRetry, err) |
| + } |
| +} |
| + |
| +func TestWakeRetryOperatorResetRequiresSettledAttempt(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, ctx, s) |
| + inc := o.Claim.Holder.Incarnation |
| + a := retryAttempt("active", o.Claim.Holder.Session) |
| + var err error |
| + o, err = Update(ctx, s, o, ReserveWakeRetryAttempt(WakeRetryReservation{HolderIncarnation: inc, EpisodeID: "old", Attempt: a, Now: time.Now()})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reset := WakeRetryReset{HolderIncarnation: inc, ObservedEpisode: "old", NewEpisodeID: "new", OperatorRequest: "request-42"} |
| + if _, err := Update(ctx, s, o, ResetWakeRetry(reset)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("unsettled reset = %v", err) |
| + } |
| + reset.AttemptSettled = true |
| + o, err = Update(ctx, s, o, ResetWakeRetry(reset)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := o.Claim.Holder.WakeRetry; got.EpisodeID != "new" || got.State != "open" || got.AttemptsReserved != 0 || got.ResetRequest != "request-42" { |
| + t.Fatalf("reset episode = %+v", got) |
| + } |
| +} |
| + |
| +func TestWakeRetryValidationAndWireShape(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, ctx, s) |
| + bad := []WakeRetry{ |
| + {EpisodeID: "e", State: "open", AttemptsReserved: 5}, |
| + {EpisodeID: "e", State: "attempting", AttemptsReserved: 1}, |
| + {EpisodeID: "e", State: "failed", AttemptsReserved: 4, NotBefore: time.Now()}, |
| + {EpisodeID: "e", State: "unknown"}, |
| + } |
| + for i := range bad { |
| + if _, err := Update(ctx, s, o, func(c *Claim) error { r := bad[i]; c.Holder.WakeRetry = &r; return nil }); !errors.Is(err, ErrMalformed) { |
| + t.Errorf("bad %d = %v", i, err) |
| + } |
| + } |
| + raw, err := json.Marshal(o.Claim.Holder) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bytes.Contains(raw, []byte(`"wake_retry"`)) { |
| + t.Fatalf("legacy holder gained wake_retry: %s", raw) |
| + } |
| +} |