| 1 | package release |
| 2 | |
| 3 | // The narrowed restart's WIRE SHAPE (discussion #16): what lands in the |
| 4 | // bucket, what a reader makes of it, and — the properties the whole design |
| 5 | // rests on — that an old reader is untouched by it and a new reader never |
| 6 | // widens a request it cannot understand. |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "encoding/json" |
| 11 | "os" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | ) |
| 17 | |
| 18 | func loadScopes(t *testing.T, s Store, acct, app string) *ScopedRestartGeneration { |
| 19 | t.Helper() |
| 20 | g, _, err := LoadRestartScopes(context.Background(), s, acct, app) |
| 21 | if err != nil { |
| 22 | t.Fatalf("LoadRestartScopes: %v", err) |
| 23 | } |
| 24 | return g |
| 25 | } |
| 26 | |
| 27 | // TestAppendRestartScopeRecordsEachRequest: the object is a LOG, not a |
| 28 | // generation. Two requests inside one poll window must both survive — the etag |
| 29 | // would have collapsed them into whichever landed last, and the earlier |
| 30 | // selector would vanish having been answered "requested". |
| 31 | func TestAppendRestartScopeRecordsEachRequest(t *testing.T) { |
| 32 | s := NewDirStore(t.TempDir()) |
| 33 | ctx := context.Background() |
| 34 | first, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}) |
| 35 | if err != nil { |
| 36 | t.Fatalf("first append: %v", err) |
| 37 | } |
| 38 | second, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeEnvironment, Value: "relay"}) |
| 39 | if err != nil { |
| 40 | t.Fatalf("second append: %v", err) |
| 41 | } |
| 42 | if first.ID == second.ID { |
| 43 | t.Fatalf("both requests share id %q — a box could not tell them apart", first.ID) |
| 44 | } |
| 45 | if _, err := time.Parse(time.RFC3339, first.RequestedAt); err != nil { |
| 46 | t.Fatalf("RequestedAt %q is not RFC3339: %v", first.RequestedAt, err) |
| 47 | } |
| 48 | g := loadScopes(t, s, "bab", "titogo") |
| 49 | if g.Schema != 1 { |
| 50 | t.Fatalf("Schema = %d, want 1", g.Schema) |
| 51 | } |
| 52 | if len(g.Requests) != 2 || g.Requests[0].ID != first.ID || g.Requests[1].ID != second.ID { |
| 53 | t.Fatalf("log = %+v, want both requests, oldest first", g.Requests) |
| 54 | } |
| 55 | if g.Newest() != second.ID { |
| 56 | t.Fatalf("Newest = %q, want the last request %q", g.Newest(), second.ID) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // TestAppendRestartScopeLeavesTheAppWideObjectAlone is the ADDITIVE property |
| 61 | // stated as a test: an old box watches restart-gen.json's etag and nothing |
| 62 | // else, so a narrowed request must not touch it. If it did, every box in the |
| 63 | // fleet that predates narrowing would read the movement the only way it knows |
| 64 | // how — restart the whole app — which is the accident this feature prevents. |
| 65 | func TestAppendRestartScopeLeavesTheAppWideObjectAlone(t *testing.T) { |
| 66 | s := NewDirStore(t.TempDir()) |
| 67 | ctx := context.Background() |
| 68 | if err := TouchRestartGen(ctx, s, "bab", "titogo"); err != nil { |
| 69 | t.Fatalf("TouchRestartGen: %v", err) |
| 70 | } |
| 71 | before := restartEtag(t, s, "bab", "titogo") |
| 72 | if _, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}); err != nil { |
| 73 | t.Fatalf("append: %v", err) |
| 74 | } |
| 75 | if after := restartEtag(t, s, "bab", "titogo"); after != before { |
| 76 | t.Fatalf("restart-gen etag moved from %q to %q on a narrowed request — every old box would cycle the whole app", before, after) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // TestRestartScopeAbsentIsLegal: an app nobody has narrowed a restart for has |
| 81 | // no object, forever, and reading that is not an error. |
| 82 | func TestRestartScopeAbsentIsLegal(t *testing.T) { |
| 83 | s := NewDirStore(t.TempDir()) |
| 84 | g, found, err := LoadRestartScopes(context.Background(), s, "bab", "keymail") |
| 85 | if err != nil { |
| 86 | t.Fatalf("LoadRestartScopes on an absent object: %v", err) |
| 87 | } |
| 88 | if found { |
| 89 | t.Fatal("found = true for an app with no object") |
| 90 | } |
| 91 | if g.Newest() != RestartScopeNone { |
| 92 | t.Fatalf("Newest = %q, want the recorded-nothing sentinel", g.Newest()) |
| 93 | } |
| 94 | pending, ok := g.PendingSince(RestartScopeNone) |
| 95 | if !ok || len(pending) != 0 { |
| 96 | t.Fatalf("PendingSince(none) = %v, %v — an absent object owes nobody a restart", pending, ok) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | // TestPendingSinceReturnsOnlyWhatIsNew: a box that acted up to a mark owes |
| 101 | // exactly the suffix after it, and nothing else. |
| 102 | func TestPendingSinceReturnsOnlyWhatIsNew(t *testing.T) { |
| 103 | s := NewDirStore(t.TempDir()) |
| 104 | ctx := context.Background() |
| 105 | var ids []string |
| 106 | for _, env := range []string{"relay", "router", "demo"} { |
| 107 | req, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeEnvironment, Value: env}) |
| 108 | if err != nil { |
| 109 | t.Fatalf("append %s: %v", env, err) |
| 110 | } |
| 111 | ids = append(ids, req.ID) |
| 112 | } |
| 113 | g := loadScopes(t, s, "bab", "titogo") |
| 114 | |
| 115 | // A box that has never looked records "none" and then owes everything. |
| 116 | pending, ok := g.PendingSince(RestartScopeNone) |
| 117 | if !ok || len(pending) != 3 { |
| 118 | t.Fatalf("PendingSince(none) = %d requests, %v; want all three", len(pending), ok) |
| 119 | } |
| 120 | pending, ok = g.PendingSince(ids[0]) |
| 121 | if !ok || len(pending) != 2 || pending[0].Scope.Value != "router" { |
| 122 | t.Fatalf("PendingSince(first) = %+v, %v; want router and demo", pending, ok) |
| 123 | } |
| 124 | if pending, ok := g.PendingSince(ids[2]); !ok || len(pending) != 0 { |
| 125 | t.Fatalf("PendingSince(newest) = %+v, %v; want nothing owed", pending, ok) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // TestPendingSinceReportsALostPosition: a mark that has rolled off the end of |
| 130 | // the log has no honest answer, and the caller must be told so rather than |
| 131 | // handed a guess. Replaying the whole log would cycle production for requests |
| 132 | // served days ago; the box re-baselines instead. |
| 133 | func TestPendingSinceReportsALostPosition(t *testing.T) { |
| 134 | g := &ScopedRestartGeneration{Schema: 1, Requests: []RestartRequest{ |
| 135 | {ID: "b", Scope: RestartScope{Kind: RestartScopeHost, Value: "b.example.com"}}, |
| 136 | }} |
| 137 | if pending, ok := g.PendingSince("a-that-rolled-off"); ok || len(pending) != 0 { |
| 138 | t.Fatalf("PendingSince(lost) = %+v, %v; want nothing and a report that the position is unknown", pending, ok) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // TestRestartScopeLogIsBounded: the object stays small, and the oldest entries |
| 143 | // are what go. A box far enough behind to lose its position re-baselines, |
| 144 | // which is the safe direction. |
| 145 | func TestRestartScopeLogIsBounded(t *testing.T) { |
| 146 | s := NewDirStore(t.TempDir()) |
| 147 | ctx := context.Background() |
| 148 | var first RestartRequest |
| 149 | for i := 0; i < restartScopeKeep+5; i++ { |
| 150 | req, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}) |
| 151 | if err != nil { |
| 152 | t.Fatalf("append %d: %v", i, err) |
| 153 | } |
| 154 | if i == 0 { |
| 155 | first = req |
| 156 | } |
| 157 | } |
| 158 | g := loadScopes(t, s, "bab", "titogo") |
| 159 | if len(g.Requests) != restartScopeKeep { |
| 160 | t.Fatalf("log holds %d requests, want it trimmed to %d", len(g.Requests), restartScopeKeep) |
| 161 | } |
| 162 | if _, ok := g.PendingSince(first.ID); ok { |
| 163 | t.Fatal("the oldest request survived the trim — the log is unbounded") |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // TestRestartScopeSelectsNarrowsAndNeverWidens is the forward-compatibility |
| 168 | // rule: an unknown selector kind addresses NOTHING and says it did not |
| 169 | // understand, so a `--region` invented later cannot make this binary fall back |
| 170 | // to cycling the app. |
| 171 | func TestRestartScopeSelectsNarrowsAndNeverWidens(t *testing.T) { |
| 172 | relay := registry.Route{Host: "connect.example.com", App: "titogo", Environment: "relay"} |
| 173 | plain := registry.Route{Host: "bill.example.com", App: "titogo"} |
| 174 | |
| 175 | if sel, ok := (RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}).Selects(plain); !sel || !ok { |
| 176 | t.Fatalf("host selector missed its own route (sel %v, understood %v)", sel, ok) |
| 177 | } |
| 178 | if sel, _ := (RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}).Selects(relay); sel { |
| 179 | t.Fatal("host selector matched another route") |
| 180 | } |
| 181 | if sel, ok := (RestartScope{Kind: RestartScopeEnvironment, Value: "relay"}).Selects(relay); !sel || !ok { |
| 182 | t.Fatalf("environment selector missed a bound route (sel %v, understood %v)", sel, ok) |
| 183 | } |
| 184 | // An unbound route reads the app's DEFAULT bundle. "" is the absence of an |
| 185 | // environment, not the name of one, so an environment selector never |
| 186 | // reaches it — otherwise a bundle-shaped request would silently become an |
| 187 | // app-wide one for every route nobody had bound. |
| 188 | if sel, _ := (RestartScope{Kind: RestartScopeEnvironment, Value: ""}).Selects(plain); sel { |
| 189 | t.Fatal("an empty environment selector matched an unbound route — that is an app-wide restart in disguise") |
| 190 | } |
| 191 | for _, rt := range []registry.Route{relay, plain} { |
| 192 | sel, understood := (RestartScope{Kind: "region", Value: "eu-west-1"}).Selects(rt) |
| 193 | if sel || understood { |
| 194 | t.Fatalf("a selector kind this build does not know addressed %s (sel %v, understood %v)", rt.Host, sel, understood) |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | // TestValidRestartScopeRefusesWhatNoBoxCouldAct: the last gate before the |
| 200 | // bucket. A request nothing could ever match must not be written, or a box |
| 201 | // would step over it forever. |
| 202 | func TestValidRestartScopeRefusesWhatNoBoxCouldAct(t *testing.T) { |
| 203 | for _, sc := range []RestartScope{ |
| 204 | {Kind: "", Value: "x"}, |
| 205 | {Kind: "region", Value: "eu-west-1"}, |
| 206 | {Kind: RestartScopeApp, Value: ""}, // app-wide has its own object |
| 207 | {Kind: RestartScopeHost, Value: ""}, |
| 208 | {Kind: RestartScopeEnvironment, Value: "bad\nvalue"}, |
| 209 | } { |
| 210 | if err := ValidRestartScope(sc); err == nil { |
| 211 | t.Fatalf("ValidRestartScope(%+v) accepted it", sc) |
| 212 | } |
| 213 | } |
| 214 | if err := ValidRestartScope(RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err != nil { |
| 215 | t.Fatalf("a good host scope was refused: %v", err) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | // TestRestartScopeRefusesANewerSchema: a log written by a console this binary |
| 220 | // is older than must not be guessed at, and must not be OVERWRITTEN either — |
| 221 | // appending to it would silently drop entries this build cannot see. Both |
| 222 | // paths refuse, which leaves the request to be picked up after an upgrade. |
| 223 | func TestRestartScopeRefusesANewerSchema(t *testing.T) { |
| 224 | s := NewDirStore(t.TempDir()) |
| 225 | ctx := context.Background() |
| 226 | raw, err := json.Marshal(map[string]any{"schema": 2, "requests": []any{}}) |
| 227 | if err != nil { |
| 228 | t.Fatalf("marshal: %v", err) |
| 229 | } |
| 230 | if _, err := s.Put(ctx, RestartScopeKey("bab", "titogo"), raw, ""); err != nil { |
| 231 | t.Fatalf("seed a newer object: %v", err) |
| 232 | } |
| 233 | if _, _, err := LoadRestartScopes(ctx, s, "bab", "titogo"); err == nil { |
| 234 | t.Fatal("a schema-2 log parsed as though this build understood it") |
| 235 | } |
| 236 | if _, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err == nil { |
| 237 | t.Fatal("appended to a schema-2 log — entries this build cannot read would have been dropped") |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // TestAppendRestartScopeRetriesACASConflict is the difference from |
| 242 | // TouchRestartGen, which treats a conflict as success. There, any touch fires |
| 243 | // the one signal that exists; here the other writer's request names a |
| 244 | // DIFFERENT set of routes, so losing ours would answer "requested" for a |
| 245 | // restart that never happens. |
| 246 | func TestAppendRestartScopeRetriesACASConflict(t *testing.T) { |
| 247 | s := &conflictOnceStore{Store: NewDirStore(t.TempDir())} |
| 248 | ctx := context.Background() |
| 249 | if _, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err != nil { |
| 250 | t.Fatalf("append through one conflict: %v", err) |
| 251 | } |
| 252 | if s.conflicts != 1 { |
| 253 | t.Fatalf("the store served %d conflicts, want the one this test staged", s.conflicts) |
| 254 | } |
| 255 | if g := loadScopes(t, s.Store, "bab", "titogo"); len(g.Requests) != 1 { |
| 256 | t.Fatalf("log = %+v, want the retried request to have landed", g.Requests) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // conflictOnceStore fails the FIRST Put with a CAS conflict, as a racing |
| 261 | // writer would, and behaves normally after that. |
| 262 | type conflictOnceStore struct { |
| 263 | Store |
| 264 | conflicts int |
| 265 | } |
| 266 | |
| 267 | func (c *conflictOnceStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 268 | if c.conflicts == 0 { |
| 269 | c.conflicts++ |
| 270 | return "", ErrCASConflict |
| 271 | } |
| 272 | return c.Store.Put(ctx, key, body, ifMatch) |
| 273 | } |
| 274 | |
| 275 | // TestRestartScopeKeyIsUnderTheAppPrefix: everything an app owns lives under |
| 276 | // one account-scoped prefix, permanently — trash, retention and per-app |
| 277 | // deletion all depend on it. |
| 278 | func TestRestartScopeKeyIsUnderTheAppPrefix(t *testing.T) { |
| 279 | if got, want := RestartScopeKey("bab", "titogo"), AppPrefix("bab", "titogo")+"restart-scope.json"; got != want { |
| 280 | t.Fatalf("RestartScopeKey = %q, want %q", got, want) |
| 281 | } |
| 282 | if RestartScopeKey("bab", "titogo") == RestartGenKey("bab", "titogo") { |
| 283 | t.Fatal("the narrowed log shares a key with the app-wide generation — an old box would read it as an app-wide request") |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | // TestAppendRestartScopeRefusesABadName keeps a hostile account or app out of |
| 288 | // the key, the same gate TouchRestartGen applies. |
| 289 | func TestAppendRestartScopeRefusesABadName(t *testing.T) { |
| 290 | s := NewDirStore(t.TempDir()) |
| 291 | ctx := context.Background() |
| 292 | if _, err := AppendRestartScope(ctx, s, "../etc", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err == nil { |
| 293 | t.Fatal("a traversal-shaped account was accepted") |
| 294 | } |
| 295 | if _, err := AppendRestartScope(ctx, s, "bab", "../x", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err == nil { |
| 296 | t.Fatal("a traversal-shaped app was accepted") |
| 297 | } |
| 298 | if _, err := os.Stat(t.TempDir()); err != nil { |
| 299 | t.Fatalf("temp dir: %v", err) |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | // A retry is not a restart: Selects must not read one (an older console's, |
| 304 | // left in this log) as a restart of anything. No new write puts one here |
| 305 | // (TestValidRestartScopeRefusesARetryAndASignature). |
| 306 | func TestRetryScopeSelectsNothing(t *testing.T) { |
| 307 | sc := RestartScope{Kind: RestartScopeRetry, Value: "a.example.com"} |
| 308 | if sel, understood := sc.Selects(registry.Route{Host: "a.example.com"}); sel || understood { |
| 309 | t.Fatalf("Selects = %v, %v; a retry must never select a route for a restart", sel, understood) |
| 310 | } |
| 311 | } |
| 312 | |