package activator import ( "context" "errors" "sync" "testing" "time" "github.com/carlosframework/platform/internal/hostwrite" ) var errClaimChanged = errors.New("test: claim changed") // swapWork is claim-backed work. denyAt makes its n-th With refuse before // running fn, which is how hostguard refuses a changed claim or stamp. type swapWork struct { mu sync.Mutex calls int denyAt int } func (w *swapWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { w.mu.Lock() w.calls++ n := w.calls w.mu.Unlock() if id != (hostwrite.Identity{}) { return errors.New("test: a swap must not claim config or release inputs") } if n == w.denyAt { return errClaimChanged } return fn() } func (w *swapWork) withCalls() int { w.mu.Lock() defer w.mu.Unlock() return w.calls } type swapSelector struct { work *swapWork err error } func (s *swapSelector) Select(_ context.Context, _, action string) (hostwrite.Work, error) { if action != "spawn" { return nil, errors.New("test: unexpected action " + action) } if s.work == nil { return nil, s.err } return s.work, s.err } // The four §7.4 checks, one at a time. func TestSwapChecks(t *testing.T) { a, b, _, _ := testActivator(t, time.Hour) host := "checks.example.com" arrangeAwake(t, a, testRoute(host)) at := Attempt{Host: host, SchedRev: 4} four := func(string) uint64 { return 4 } check := func(newest func(string) uint64) error { a.mu.Lock() defer a.mu.Unlock() return a.swapChecksLocked(a.entries[host], at, newest) } if err := check(four); err != nil { t.Fatalf("an awake, leased, newest, unfenced entry was refused: %v", err) } if err := check(func(string) uint64 { return 5 }); !errors.Is(err, errSwapSuperseded) { t.Fatalf("superseded: %v", err) } a.mu.Lock() a.fences = map[string]uint64{host: 3} a.mu.Unlock() if err := check(four); !errors.Is(err, errSwapFenced) { t.Fatalf("fenced: %v", err) } a.mu.Lock() delete(a.fences, host) a.shuttingDown = true a.mu.Unlock() if err := check(four); !errors.Is(err, errSwapShutdown) { t.Fatalf("shutting down: %v", err) } a.mu.Lock() a.shuttingDown = false a.mu.Unlock() b.lastRep().setErr(errors.New("renewal failed")) if err := check(four); !errors.Is(err, errSwapLeaseLost) { t.Fatalf("lease lost: %v", err) } b.lastRep().setErr(nil) a.mu.Lock() a.entries[host].state = actStopping a.mu.Unlock() if err := check(four); !errors.Is(err, errSwapNotAwake) { t.Fatalf("stopping: %v", err) } a.mu.Lock() a.entries[host].state = actAwake err := a.swapChecksLocked(nil, at, four) a.mu.Unlock() if !errors.Is(err, errSwapNotAwake) { t.Fatalf("asleep: %v", err) } } // Spec §7.4: ownership is checked apart from supersession, so an exec that // is not an attempt's (a crash restart, a fallback, a restore, a sidecar) // can ask it. A superseded attempt still owns the host; a lost lease does // not, whatever the revision says. func TestOwnershipIsCheckedApartFromSupersession(t *testing.T) { a, b, _, _ := testActivator(t, time.Hour) host := "owned.example.com" arrangeAwake(t, a, testRoute(host)) owned := func(waking bool) error { a.mu.Lock() defer a.mu.Unlock() return a.ownedLocked(a.entries[host], waking) } if err := owned(false); err != nil { t.Fatalf("an awake, leased, unfenced entry: %v", err) } b.lastRep().setErr(errors.New("renewal failed")) if err := owned(false); !errors.Is(err, errSwapLeaseLost) { t.Fatalf("lease lost: %v", err) } if err := owned(true); err != nil { t.Fatalf("a waking entry's lease is the wake's own: %v", err) } b.lastRep().setErr(nil) a.mu.Lock() a.fences = map[string]uint64{host: 1} a.mu.Unlock() if err := owned(true); !errors.Is(err, errSwapFenced) { t.Fatalf("fenced while waking: %v", err) } } // Claims off: hostwrite's legacy work. The check runs alone, under the // entry lock that fn takes itself. func TestAdmitClaimsOffRunsTheCheckAlone(t *testing.T) { a := New(Config{}, &fakeBackend{}, nil) work, err := a.selectWork(context.Background(), "h.example.com") if err != nil || !hostwrite.Legacy(work) { t.Fatalf("claims off selected %T, %v; want the legacy work", work, err) } ran := 0 if err := a.admit(context.Background(), work, func() error { ran++; return nil }); err != nil || ran != 1 { t.Fatalf("admit = %v, ran %d", err, ran) } } // Claims on: the claim and stamp are validated first, and a refusal is // final. A selector that cannot answer refuses. Nothing is ever read as // claims off. func TestAdmitClaimsOnValidatesFirstAndNeverFallsBack(t *testing.T) { ctx := context.Background() w := &swapWork{} a := New(Config{HostWrites: hostwrite.Config{Enabled: true, Selector: &swapSelector{work: w}}}, &fakeBackend{}, nil) work, err := a.selectWork(ctx, "h.example.com") if err != nil || hostwrite.Legacy(work) { t.Fatalf("claims on selected %T, %v", work, err) } ran := 0 fn := func() error { ran++; return nil } if err := a.admit(ctx, work, fn); err != nil || ran != 1 || w.withCalls() != 1 { t.Fatalf("admit = %v, ran %d, With %d", err, ran, w.withCalls()) } w.denyAt = 2 if err := a.admit(ctx, work, fn); !errors.Is(err, errClaimChanged) || ran != 1 { t.Fatalf("refused claim: err %v, ran %d (fn must not run)", err, ran) } for name, sel := range map[string]hostwrite.Selector{ "selector error": &swapSelector{err: hostwrite.ErrUnavailable}, "no selector": nil, "nil work": &swapSelector{}, } { a.cfg.HostWrites.Selector = sel if _, err := a.selectWork(ctx, "h.example.com"); !errors.Is(err, hostwrite.ErrUnavailable) { t.Errorf("%s: %v, want hostwrite.ErrUnavailable", name, err) } } }