package rollout import ( "context" "errors" "sync" "testing" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/registry" ) func TestPolicyFromManifest(t *testing.T) { cases := []struct { m *appconfig.Manifest want AppPolicy }{ {nil, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}}, {&appconfig.Manifest{}, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}}, {&appconfig.Manifest{Swap: "overlap", SwapConcurrency: 4}, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 4}}, {&appconfig.Manifest{Swap: "hold"}, AppPolicy{Swap: activator.ModeHold, Concurrency: 1}}, // A value this binary does not know never permits an overlap. {&appconfig.Manifest{Swap: "blue-green"}, AppPolicy{Swap: activator.ModeHold, Concurrency: 1}}, {&appconfig.Manifest{SwapConcurrency: -3}, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}}, } for _, c := range cases { if got := PolicyFromManifest(c.m); got != c.want { t.Errorf("PolicyFromManifest(%+v) = %+v, want %+v", c.m, got, c.want) } } } func TestModesHoldIfEitherSaysHold(t *testing.T) { overlap, hold := AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}, AppPolicy{Swap: activator.ModeHold, Concurrency: 1} cases := []struct { p AppPolicy demand string want activator.SwapMode }{ {overlap, "", activator.ModeOverlap}, {overlap, "hold", activator.ModeHold}, {hold, "", activator.ModeHold}, {hold, "hold", activator.ModeHold}, {overlap, "something-new", activator.ModeHold}, } for _, c := range cases { if got := AttemptMode(c.p, c.demand); got != c.want { t.Errorf("AttemptMode(%v, %q) = %v, want %v", c.p.Swap, c.demand, got, c.want) } // Recovery reads the RETAINED demand with the CURRENT policy (spec // §4.2, round 12): an app switched to hold later still gets hold. if got := RecoveryMode(c.p, c.demand); got != c.want { t.Errorf("RecoveryMode(%v, %q) = %v, want %v", c.p.Swap, c.demand, got, c.want) } } } // Review focus 2: a bucket blip while the policy is read. func TestPolicyCacheNeverGuessesOverlap(t *testing.T) { now := time.Unix(1_700_000_000, 0) var calls int var answer *appconfig.Manifest var fail error c := &policyCache{ load: func(context.Context, string, string) (*appconfig.Manifest, error) { calls++ return answer, fail }, ttl: 30 * time.Second, now: func() time.Time { return now }, m: map[appKey]*policyEntry{}, } ctx := context.Background() fail = errors.New("SlowDown") if got := c.get(ctx, "bab", "blog"); got.Swap != activator.ModeHold { t.Fatalf("never-read policy = %v, want hold", got.Swap) } now = now.Add(policyRetryAfter) // the failed read's backoff (Task 11) fail, answer = nil, &appconfig.Manifest{SwapConcurrency: 2} if got := c.get(ctx, "bab", "blog"); got != (AppPolicy{Swap: activator.ModeOverlap, Concurrency: 2}) { t.Fatalf("first read = %+v", got) } n := calls if c.get(ctx, "bab", "blog"); calls != n { t.Fatal("a fresh entry was read again inside the TTL") } now = now.Add(31 * time.Second) fail = errors.New("SlowDown") if got := c.get(ctx, "bab", "blog"); got != (AppPolicy{Swap: activator.ModeOverlap, Concurrency: 2}) { t.Fatalf("after a failed refresh = %+v, want the last known policy", got) } } // Task 11 rule 1 (Astra R1 Flag triage T3): two fills of one app's policy // can overlap, and the older one can finish last. Its result is older than // what the newer fill stored, so it must not replace it. func TestPolicyCacheAnOlderFillNeverOverwritesANewerOne(t *testing.T) { now := time.Unix(1_700_000_000, 0) entered, release := make(chan struct{}), make(chan struct{}) var mu sync.Mutex calls := 0 c := &policyCache{ load: func(ctx context.Context, _, _ string) (*appconfig.Manifest, error) { if _, ok := ctx.Deadline(); !ok { t.Error("a policy load ran with no deadline (readBound)") } mu.Lock() calls++ n := calls mu.Unlock() if n == 1 { close(entered) <-release return &appconfig.Manifest{Swap: "hold"}, nil } return &appconfig.Manifest{SwapConcurrency: 3}, nil }, ttl: 30 * time.Second, now: func() time.Time { return now }, m: map[appKey]*policyEntry{}, } ctx := context.Background() older := make(chan AppPolicy) go func() { older <- c.get(ctx, "bab", "blog") }() <-entered newer := AppPolicy{Swap: activator.ModeOverlap, Concurrency: 3} if got := c.get(ctx, "bab", "blog"); got != newer { t.Fatalf("the newer fill = %+v, want %+v", got, newer) } close(release) if got := <-older; got != newer { t.Fatalf("the older fill, finishing last, answered %+v; want the newer stored %+v", got, newer) } if got := c.get(ctx, "bab", "blog"); got != newer { t.Fatalf("after the older fill finished = %+v, want the newer fill's %+v", got, newer) } if calls != 2 { t.Fatalf("%d loads, want 2: the stored value is fresh", calls) } } // Task 11 rule 1 (Astra R1 Flag triage T3): a failed read is not retried for // policyRetryAfter, whether or not a value was ever read. Meanwhile the // cache answers its last value, or hold for an app never read. A missing // bundle (appconfig.Get's empty manifest) is the defaults; an error never is. func TestPolicyCacheBacksOffAFailedRead(t *testing.T) { now := time.Unix(1_700_000_000, 0) calls := 0 var answer *appconfig.Manifest fail := errors.New("SlowDown") c := &policyCache{ load: func(context.Context, string, string) (*appconfig.Manifest, error) { calls++ return answer, fail }, ttl: 30 * time.Second, now: func() time.Time { return now }, m: map[appKey]*policyEntry{}, } ctx := context.Background() hold := AppPolicy{Swap: activator.ModeHold, Concurrency: 1} for i := range 3 { if got := c.get(ctx, "bab", "blog"); got != hold { t.Fatalf("never-read policy, read %d = %+v, want hold", i, got) } now = now.Add(time.Second) } if calls != 1 { t.Fatalf("%d loads inside policyRetryAfter of a failure, want 1", calls) } now = now.Add(policyRetryAfter) fail, answer = nil, &appconfig.Manifest{Schema: 1, App: "blog"} // missing: appconfig.Get's empty manifest if got := c.get(ctx, "bab", "blog"); got != DefaultPolicy() || calls != 2 { t.Fatalf("after the backoff = %+v (%d loads), want the defaults from a second load", got, calls) } now = now.Add(31 * time.Second) fail = errors.New("SlowDown") for i := range 3 { if got := c.get(ctx, "bab", "blog"); got != DefaultPolicy() { t.Fatalf("stale policy with a failing read, read %d = %+v, want the last known", i, got) } now = now.Add(time.Second) } if calls != 3 { t.Fatalf("%d loads, want 3: one failed refresh, then none inside policyRetryAfter", calls) } } func TestManagedIsTheActivatorsExecRoutes(t *testing.T) { cases := []struct { rt registry.Route want bool }{ {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true}, true}, {registry.Route{Kind: "instance", App: "blog", Status: "ready"}, false}, // a systemd unit, not an activator child {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true, Unit: "console.service"}, false}, // named-unit route (spec §13) {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true, Backing: registry.BackingUnit}, false}, // carlos-app@ {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true, Platform: true}, false}, {registry.Route{Kind: "sidecar", App: "eleven", Status: "ready"}, true}, {registry.Route{Kind: "static", App: "site", Status: "ready", Hibernate: true}, false}, } for _, c := range cases { if got := Managed(c.rt); got != c.want { t.Errorf("Managed(%+v) = %v, want %v", c.rt, got, c.want) } } } // Task 11 fix round 1, review finding 3: a read the caller abandoned (its // own context ended, a wake or an attempt that stopped) says nothing about // the store, so it is not a failed read and does not hold off the next // caller for policyRetryAfter. The read bound's own deadline, with the // caller still waiting, is a failed read. func TestPolicyCacheACallersCancellationIsNotAFailedRead(t *testing.T) { now := time.Unix(1_700_000_000, 0) calls := 0 var fail error c := &policyCache{ load: func(ctx context.Context, _, _ string) (*appconfig.Manifest, error) { calls++ if err := ctx.Err(); err != nil { return nil, err } if fail != nil { return nil, fail } return &appconfig.Manifest{Schema: 1, App: "blog", Swap: appconfig.SwapOverlap, SwapConcurrency: 2}, nil }, ttl: 30 * time.Second, now: func() time.Time { return now }, m: map[appKey]*policyEntry{}, } hold := AppPolicy{Swap: activator.ModeHold, Concurrency: 1} gone, cancel := context.WithCancel(context.Background()) cancel() if got := c.get(gone, "bab", "blog"); got != hold { t.Fatalf("a cancelled caller's read = %+v, want hold", got) } want := AppPolicy{Swap: activator.ModeOverlap, Concurrency: 2} if got := c.get(context.Background(), "bab", "blog"); got != want || calls != 2 { t.Fatalf("the next caller's read = %+v after %d loads, want %+v from a second load: a cancelled caller held it off", got, calls, want) } now = now.Add(31 * time.Second) fail = context.DeadlineExceeded // the read bound ran out, the caller still waiting c.get(context.Background(), "bab", "blog") now = now.Add(time.Second) if got := c.get(context.Background(), "bab", "blog"); got != want || calls != 3 { t.Fatalf("read inside policyRetryAfter of a timed-out read = %+v after %d loads, want the last value and 3 loads", got, calls) } }