| 1 | package rollout |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "sync" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/activator" |
| 11 | "github.com/carlosframework/platform/internal/appconfig" |
| 12 | "github.com/carlosframework/platform/internal/registry" |
| 13 | ) |
| 14 | |
| 15 | func TestPolicyFromManifest(t *testing.T) { |
| 16 | cases := []struct { |
| 17 | m *appconfig.Manifest |
| 18 | want AppPolicy |
| 19 | }{ |
| 20 | {nil, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}}, |
| 21 | {&appconfig.Manifest{}, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}}, |
| 22 | {&appconfig.Manifest{Swap: "overlap", SwapConcurrency: 4}, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 4}}, |
| 23 | {&appconfig.Manifest{Swap: "hold"}, AppPolicy{Swap: activator.ModeHold, Concurrency: 1}}, |
| 24 | // A value this binary does not know never permits an overlap. |
| 25 | {&appconfig.Manifest{Swap: "blue-green"}, AppPolicy{Swap: activator.ModeHold, Concurrency: 1}}, |
| 26 | {&appconfig.Manifest{SwapConcurrency: -3}, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}}, |
| 27 | } |
| 28 | for _, c := range cases { |
| 29 | if got := PolicyFromManifest(c.m); got != c.want { |
| 30 | t.Errorf("PolicyFromManifest(%+v) = %+v, want %+v", c.m, got, c.want) |
| 31 | } |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | func TestModesHoldIfEitherSaysHold(t *testing.T) { |
| 36 | overlap, hold := AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}, AppPolicy{Swap: activator.ModeHold, Concurrency: 1} |
| 37 | cases := []struct { |
| 38 | p AppPolicy |
| 39 | demand string |
| 40 | want activator.SwapMode |
| 41 | }{ |
| 42 | {overlap, "", activator.ModeOverlap}, |
| 43 | {overlap, "hold", activator.ModeHold}, |
| 44 | {hold, "", activator.ModeHold}, |
| 45 | {hold, "hold", activator.ModeHold}, |
| 46 | {overlap, "something-new", activator.ModeHold}, |
| 47 | } |
| 48 | for _, c := range cases { |
| 49 | if got := AttemptMode(c.p, c.demand); got != c.want { |
| 50 | t.Errorf("AttemptMode(%v, %q) = %v, want %v", c.p.Swap, c.demand, got, c.want) |
| 51 | } |
| 52 | // Recovery reads the RETAINED demand with the CURRENT policy (spec |
| 53 | // §4.2, round 12): an app switched to hold later still gets hold. |
| 54 | if got := RecoveryMode(c.p, c.demand); got != c.want { |
| 55 | t.Errorf("RecoveryMode(%v, %q) = %v, want %v", c.p.Swap, c.demand, got, c.want) |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // Review focus 2: a bucket blip while the policy is read. |
| 61 | func TestPolicyCacheNeverGuessesOverlap(t *testing.T) { |
| 62 | now := time.Unix(1_700_000_000, 0) |
| 63 | var calls int |
| 64 | var answer *appconfig.Manifest |
| 65 | var fail error |
| 66 | c := &policyCache{ |
| 67 | load: func(context.Context, string, string) (*appconfig.Manifest, error) { |
| 68 | calls++ |
| 69 | return answer, fail |
| 70 | }, |
| 71 | ttl: 30 * time.Second, |
| 72 | now: func() time.Time { return now }, |
| 73 | m: map[appKey]*policyEntry{}, |
| 74 | } |
| 75 | ctx := context.Background() |
| 76 | fail = errors.New("SlowDown") |
| 77 | if got := c.get(ctx, "bab", "blog"); got.Swap != activator.ModeHold { |
| 78 | t.Fatalf("never-read policy = %v, want hold", got.Swap) |
| 79 | } |
| 80 | now = now.Add(policyRetryAfter) // the failed read's backoff (Task 11) |
| 81 | fail, answer = nil, &appconfig.Manifest{SwapConcurrency: 2} |
| 82 | if got := c.get(ctx, "bab", "blog"); got != (AppPolicy{Swap: activator.ModeOverlap, Concurrency: 2}) { |
| 83 | t.Fatalf("first read = %+v", got) |
| 84 | } |
| 85 | n := calls |
| 86 | if c.get(ctx, "bab", "blog"); calls != n { |
| 87 | t.Fatal("a fresh entry was read again inside the TTL") |
| 88 | } |
| 89 | now = now.Add(31 * time.Second) |
| 90 | fail = errors.New("SlowDown") |
| 91 | if got := c.get(ctx, "bab", "blog"); got != (AppPolicy{Swap: activator.ModeOverlap, Concurrency: 2}) { |
| 92 | t.Fatalf("after a failed refresh = %+v, want the last known policy", got) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // Task 11 rule 1 (Astra R1 Flag triage T3): two fills of one app's policy |
| 97 | // can overlap, and the older one can finish last. Its result is older than |
| 98 | // what the newer fill stored, so it must not replace it. |
| 99 | func TestPolicyCacheAnOlderFillNeverOverwritesANewerOne(t *testing.T) { |
| 100 | now := time.Unix(1_700_000_000, 0) |
| 101 | entered, release := make(chan struct{}), make(chan struct{}) |
| 102 | var mu sync.Mutex |
| 103 | calls := 0 |
| 104 | c := &policyCache{ |
| 105 | load: func(ctx context.Context, _, _ string) (*appconfig.Manifest, error) { |
| 106 | if _, ok := ctx.Deadline(); !ok { |
| 107 | t.Error("a policy load ran with no deadline (readBound)") |
| 108 | } |
| 109 | mu.Lock() |
| 110 | calls++ |
| 111 | n := calls |
| 112 | mu.Unlock() |
| 113 | if n == 1 { |
| 114 | close(entered) |
| 115 | <-release |
| 116 | return &appconfig.Manifest{Swap: "hold"}, nil |
| 117 | } |
| 118 | return &appconfig.Manifest{SwapConcurrency: 3}, nil |
| 119 | }, |
| 120 | ttl: 30 * time.Second, |
| 121 | now: func() time.Time { return now }, |
| 122 | m: map[appKey]*policyEntry{}, |
| 123 | } |
| 124 | ctx := context.Background() |
| 125 | older := make(chan AppPolicy) |
| 126 | go func() { older <- c.get(ctx, "bab", "blog") }() |
| 127 | <-entered |
| 128 | newer := AppPolicy{Swap: activator.ModeOverlap, Concurrency: 3} |
| 129 | if got := c.get(ctx, "bab", "blog"); got != newer { |
| 130 | t.Fatalf("the newer fill = %+v, want %+v", got, newer) |
| 131 | } |
| 132 | close(release) |
| 133 | if got := <-older; got != newer { |
| 134 | t.Fatalf("the older fill, finishing last, answered %+v; want the newer stored %+v", got, newer) |
| 135 | } |
| 136 | if got := c.get(ctx, "bab", "blog"); got != newer { |
| 137 | t.Fatalf("after the older fill finished = %+v, want the newer fill's %+v", got, newer) |
| 138 | } |
| 139 | if calls != 2 { |
| 140 | t.Fatalf("%d loads, want 2: the stored value is fresh", calls) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // Task 11 rule 1 (Astra R1 Flag triage T3): a failed read is not retried for |
| 145 | // policyRetryAfter, whether or not a value was ever read. Meanwhile the |
| 146 | // cache answers its last value, or hold for an app never read. A missing |
| 147 | // bundle (appconfig.Get's empty manifest) is the defaults; an error never is. |
| 148 | func TestPolicyCacheBacksOffAFailedRead(t *testing.T) { |
| 149 | now := time.Unix(1_700_000_000, 0) |
| 150 | calls := 0 |
| 151 | var answer *appconfig.Manifest |
| 152 | fail := errors.New("SlowDown") |
| 153 | c := &policyCache{ |
| 154 | load: func(context.Context, string, string) (*appconfig.Manifest, error) { |
| 155 | calls++ |
| 156 | return answer, fail |
| 157 | }, |
| 158 | ttl: 30 * time.Second, |
| 159 | now: func() time.Time { return now }, |
| 160 | m: map[appKey]*policyEntry{}, |
| 161 | } |
| 162 | ctx := context.Background() |
| 163 | hold := AppPolicy{Swap: activator.ModeHold, Concurrency: 1} |
| 164 | for i := range 3 { |
| 165 | if got := c.get(ctx, "bab", "blog"); got != hold { |
| 166 | t.Fatalf("never-read policy, read %d = %+v, want hold", i, got) |
| 167 | } |
| 168 | now = now.Add(time.Second) |
| 169 | } |
| 170 | if calls != 1 { |
| 171 | t.Fatalf("%d loads inside policyRetryAfter of a failure, want 1", calls) |
| 172 | } |
| 173 | now = now.Add(policyRetryAfter) |
| 174 | fail, answer = nil, &appconfig.Manifest{Schema: 1, App: "blog"} // missing: appconfig.Get's empty manifest |
| 175 | if got := c.get(ctx, "bab", "blog"); got != DefaultPolicy() || calls != 2 { |
| 176 | t.Fatalf("after the backoff = %+v (%d loads), want the defaults from a second load", got, calls) |
| 177 | } |
| 178 | now = now.Add(31 * time.Second) |
| 179 | fail = errors.New("SlowDown") |
| 180 | for i := range 3 { |
| 181 | if got := c.get(ctx, "bab", "blog"); got != DefaultPolicy() { |
| 182 | t.Fatalf("stale policy with a failing read, read %d = %+v, want the last known", i, got) |
| 183 | } |
| 184 | now = now.Add(time.Second) |
| 185 | } |
| 186 | if calls != 3 { |
| 187 | t.Fatalf("%d loads, want 3: one failed refresh, then none inside policyRetryAfter", calls) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestManagedIsTheActivatorsExecRoutes(t *testing.T) { |
| 192 | cases := []struct { |
| 193 | rt registry.Route |
| 194 | want bool |
| 195 | }{ |
| 196 | {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true}, true}, |
| 197 | {registry.Route{Kind: "instance", App: "blog", Status: "ready"}, false}, // a systemd unit, not an activator child |
| 198 | {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true, Unit: "console.service"}, false}, // named-unit route (spec §13) |
| 199 | {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true, Backing: registry.BackingUnit}, false}, // carlos-app@ |
| 200 | {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true, Platform: true}, false}, |
| 201 | {registry.Route{Kind: "sidecar", App: "eleven", Status: "ready"}, true}, |
| 202 | {registry.Route{Kind: "static", App: "site", Status: "ready", Hibernate: true}, false}, |
| 203 | } |
| 204 | for _, c := range cases { |
| 205 | if got := Managed(c.rt); got != c.want { |
| 206 | t.Errorf("Managed(%+v) = %v, want %v", c.rt, got, c.want) |
| 207 | } |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | // Task 11 fix round 1, review finding 3: a read the caller abandoned (its |
| 212 | // own context ended, a wake or an attempt that stopped) says nothing about |
| 213 | // the store, so it is not a failed read and does not hold off the next |
| 214 | // caller for policyRetryAfter. The read bound's own deadline, with the |
| 215 | // caller still waiting, is a failed read. |
| 216 | func TestPolicyCacheACallersCancellationIsNotAFailedRead(t *testing.T) { |
| 217 | now := time.Unix(1_700_000_000, 0) |
| 218 | calls := 0 |
| 219 | var fail error |
| 220 | c := &policyCache{ |
| 221 | load: func(ctx context.Context, _, _ string) (*appconfig.Manifest, error) { |
| 222 | calls++ |
| 223 | if err := ctx.Err(); err != nil { |
| 224 | return nil, err |
| 225 | } |
| 226 | if fail != nil { |
| 227 | return nil, fail |
| 228 | } |
| 229 | return &appconfig.Manifest{Schema: 1, App: "blog", Swap: appconfig.SwapOverlap, SwapConcurrency: 2}, nil |
| 230 | }, |
| 231 | ttl: 30 * time.Second, |
| 232 | now: func() time.Time { return now }, |
| 233 | m: map[appKey]*policyEntry{}, |
| 234 | } |
| 235 | hold := AppPolicy{Swap: activator.ModeHold, Concurrency: 1} |
| 236 | gone, cancel := context.WithCancel(context.Background()) |
| 237 | cancel() |
| 238 | if got := c.get(gone, "bab", "blog"); got != hold { |
| 239 | t.Fatalf("a cancelled caller's read = %+v, want hold", got) |
| 240 | } |
| 241 | want := AppPolicy{Swap: activator.ModeOverlap, Concurrency: 2} |
| 242 | if got := c.get(context.Background(), "bab", "blog"); got != want || calls != 2 { |
| 243 | 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) |
| 244 | } |
| 245 | now = now.Add(31 * time.Second) |
| 246 | fail = context.DeadlineExceeded // the read bound ran out, the caller still waiting |
| 247 | c.get(context.Background(), "bab", "blog") |
| 248 | now = now.Add(time.Second) |
| 249 | if got := c.get(context.Background(), "bab", "blog"); got != want || calls != 3 { |
| 250 | t.Fatalf("read inside policyRetryAfter of a timed-out read = %+v after %d loads, want the last value and 3 loads", got, calls) |
| 251 | } |
| 252 | } |
| 253 | |