package activator import ( "context" "errors" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "os/exec" "testing" "time" ) func TestStartAdmittedPreparedInputsAndRestoreOutsideProbeWindow(t *testing.T) { manifest := &moves.InstallManifest{Op: "op", Host: "x.example", SnapshotVersion: "pinned"} inputs := &provision.MoveInputs{Release: "release", OverlayETag: "overlay"} policy := startPolicy{MaxStarts: 5, Window: time.Millisecond, Deadline: time.Now().Add(time.Minute), Probe: true, Prepared: manifest, Inputs: inputs} obs := hostclaim.Observed{Host: "x.example", ETag: "one", Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Location: "instances/x.example/inc", Session: 1, Generation: "one"}, Op: hostclaim.Op{Kind: "landing", ID: "op", Phase: "probing", Exec: "box"}}} work, _ := (hostwrite.Config{}).Select(t.Context(), obs.Host, "spawn") stop := errors.New("stop before kernel start") restored, materialized, journaled := false, false, false a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil }, Prepare: func(ctx context.Context, rt registry.Route, got hostclaim.Observed, p startPolicy) (ClaimStart, error) { if p.Prepared != manifest || p.Inputs != inputs { t.Fatal("C1 inputs lost before preparation") } return ClaimStart{RoleLauncher: &RoleLaunch{}, CurrentAttempt: func() hostclaim.WakeRetryAttempt { return hostclaim.WakeRetryAttempt{} }, Host: obs.Host, Work: work, Identity: hostwrite.Identity{Config: "c", Release: "r"}, OpenProbe: func(context.Context, time.Time) (time.Time, error) { journaled = true; return time.Now(), nil }, Restore: func(ctx context.Context) error { restored = true deadline, ok := ctx.Deadline() if !ok || !deadline.Equal(policy.Deadline) { t.Fatal("restore consumed probe window") } time.Sleep(5 * policy.Window) return ctx.Err() }, Materialize: func(context.Context) error { materialized = true; return stop }, Build: func() *exec.Cmd { return nil }, Ready: func(context.Context, *exec.Cmd) error { return nil }, Reserve: func(context.Context, int) error { return nil }, Failed: func(context.Context, int) error { return nil }, Healthy: func(context.Context, int, *exec.Cmd) error { return nil }, Replicate: func(context.Context, string) (Rep, error) { return nil, nil }, Publish: func(context.Context, hostclaim.Observed) (hostclaim.Observed, error) { return obs, nil }, Retain: func(Proc, Rep, error) {}, }, nil }}}} err := a.startAdmitted(t.Context(), &actEntry{host: obs.Host}, obs, policy) if !errors.Is(err, stop) || !restored || !materialized || journaled { t.Fatalf("err=%v restore=%v materialize=%v journal=%v", err, restored, materialized, journaled) } policy.Deadline = time.Now().Add(-time.Second) if err := a.startAdmitted(t.Context(), &actEntry{host: obs.Host}, obs, policy); !errors.Is(err, ErrAttemptOver) { t.Fatalf("cut deadline: %v", err) } } func TestClaimProbeWindowJournalsFirstStartAndSurvivesRetry(t *testing.T) { now := time.Unix(1000, 0) var saved time.Time policy := startPolicy{Probe: true, Window: 30 * time.Second, Deadline: now.Add(time.Minute)} journal := func(_ context.Context, proposed time.Time) (time.Time, error) { if saved.IsZero() { saved = proposed } return saved, nil } b := &probeWindow{policy: policy, now: func() time.Time { return now }} // Restore took longer than the window, but has not started the app. now = now.Add(40 * time.Second) if err := b.start(t.Context(), journal); err != nil { t.Fatal(err) } first := saved now = now.Add(time.Second) if err := b.start(t.Context(), journal); err != nil || saved != first { t.Fatal("retry reset", err, saved) } b = &probeWindow{policy: policy, now: func() time.Time { return now }} if err := b.start(t.Context(), journal); err != nil || b.opened != first { t.Fatal("restart reset", err, b.opened) } now = policy.Deadline if !errors.Is(b.result(nil), ErrAttemptOver) { t.Fatal("late readiness accepted after cut") } b.policy.Deadline = time.Time{} now = first.Add(policy.Window) if err := b.result(nil); !errors.Is(err, ErrAttemptOver) { t.Fatalf("late readiness ignored context: %v", err) } if err := b.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) { t.Fatalf("expired retry: %v", err) } } // Ported from the model's TestProbeWindow and wProbeWindowAt (R12-2, // INV-19, internal/sim/hostlife/witness_test.go): readiness k seconds after // the attempt's first start. Main's boundary is adopted (ruling H17): the // window and the cut deadline both fail at the instant, not after it // (claimwindow.go: !now.Before), so 29 is accepted, 30 and 31 are not. // // The loop around probeWindow is not reachable here. Its Ready hook runs // only after RoleLaunch.Start, which needs the isolated root, so the // readiness decision is tested where the loop reads it: probeWindow.start // before a start and probeWindow.result after readiness. func TestProbeWindowBoundaries(t *testing.T) { t0 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) far := t0.Add(time.Hour) newWindow := func(deadline time.Time, now *time.Time) (*probeWindow, func(context.Context, time.Time) (time.Time, error), *int) { var saved time.Time calls := 0 journal := func(_ context.Context, proposed time.Time) (time.Time, error) { calls++ if saved.IsZero() { saved = proposed } return saved, nil } return &probeWindow{policy: startPolicy{MaxStarts: 5, Probe: true, Window: 30 * time.Second, Deadline: deadline}, now: func() time.Time { return *now }}, journal, &calls } for k, want := range map[int]bool{29: true, 30: false, 31: false} { now := t0 b, journal, _ := newWindow(far, &now) if err := b.start(t.Context(), journal); err != nil { t.Fatalf("k=%d first start: %v", k, err) } now = t0.Add(time.Duration(k) * time.Second) err := b.result(nil) if (err == nil) != want || (err != nil && !errors.Is(err, ErrAttemptOver)) { t.Fatalf("k=%d: readiness result %v, accepted want %v", k, err, want) } } // The environment becomes ready only after the window: no further start // is journaled, and the first start's time is not reset. now := t0 b, journal, calls := newWindow(far, &now) if err := b.start(t.Context(), journal); err != nil || *calls != 1 { t.Fatalf("first start: %v (journal calls %d)", err, *calls) } now = t0.Add(30 * time.Second) if err := b.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) || *calls != 1 { t.Fatalf("start after the window: %v (journal calls %d)", err, *calls) } // The absolute cut deadline bounds the whole, inside the window. now = t0 c, journal, _ := newWindow(t0.Add(4*time.Second), &now) if err := c.start(t.Context(), journal); err != nil { t.Fatal(err) } now = t0.Add(3 * time.Second) if err := c.result(nil); err != nil { t.Fatalf("readiness before the cut deadline: %v", err) } now = t0.Add(4 * time.Second) if err := c.result(nil); !errors.Is(err, ErrAttemptOver) { t.Fatalf("readiness at the cut deadline: %v", err) } now = t0.Add(5 * time.Second) if err := c.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) { t.Fatalf("start past the cut deadline: %v", err) } // A readiness already accepted is not revisited by a later clock. now = t0 d, journal, _ := newWindow(far, &now) if err := d.start(t.Context(), journal); err != nil { t.Fatal(err) } d.ready = true now = t0.Add(time.Hour) if err := d.result(nil); err != nil { t.Fatalf("accepted readiness revisited: %v", err) } // Five starts at most, whatever the clock says. a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Read: func(context.Context, string) (hostclaim.Observed, error) { return hostclaim.Observed{}, nil }, Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) { t.Fatal("six starts were allowed to prepare") return ClaimStart{}, nil }}}} obs := hostclaim.Observed{Host: "x.example"} if err := a.startAdmitted(t.Context(), &actEntry{host: obs.Host}, obs, startPolicy{MaxStarts: 6, Window: 30 * time.Second, Probe: true}); !errors.Is(err, hostwrite.ErrUnavailable) { t.Fatalf("a sixth start: %v", err) } }