package release // The narrowed restart's WIRE SHAPE (discussion #16): what lands in the // bucket, what a reader makes of it, and — the properties the whole design // rests on — that an old reader is untouched by it and a new reader never // widens a request it cannot understand. import ( "context" "encoding/json" "os" "testing" "time" "github.com/carlosframework/platform/internal/registry" ) func loadScopes(t *testing.T, s Store, acct, app string) *ScopedRestartGeneration { t.Helper() g, _, err := LoadRestartScopes(context.Background(), s, acct, app) if err != nil { t.Fatalf("LoadRestartScopes: %v", err) } return g } // TestAppendRestartScopeRecordsEachRequest: the object is a LOG, not a // generation. Two requests inside one poll window must both survive — the etag // would have collapsed them into whichever landed last, and the earlier // selector would vanish having been answered "requested". func TestAppendRestartScopeRecordsEachRequest(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() first, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}) if err != nil { t.Fatalf("first append: %v", err) } second, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeEnvironment, Value: "relay"}) if err != nil { t.Fatalf("second append: %v", err) } if first.ID == second.ID { t.Fatalf("both requests share id %q — a box could not tell them apart", first.ID) } if _, err := time.Parse(time.RFC3339, first.RequestedAt); err != nil { t.Fatalf("RequestedAt %q is not RFC3339: %v", first.RequestedAt, err) } g := loadScopes(t, s, "bab", "titogo") if g.Schema != 1 { t.Fatalf("Schema = %d, want 1", g.Schema) } if len(g.Requests) != 2 || g.Requests[0].ID != first.ID || g.Requests[1].ID != second.ID { t.Fatalf("log = %+v, want both requests, oldest first", g.Requests) } if g.Newest() != second.ID { t.Fatalf("Newest = %q, want the last request %q", g.Newest(), second.ID) } } // TestAppendRestartScopeLeavesTheAppWideObjectAlone is the ADDITIVE property // stated as a test: an old box watches restart-gen.json's etag and nothing // else, so a narrowed request must not touch it. If it did, every box in the // fleet that predates narrowing would read the movement the only way it knows // how — restart the whole app — which is the accident this feature prevents. func TestAppendRestartScopeLeavesTheAppWideObjectAlone(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() if err := TouchRestartGen(ctx, s, "bab", "titogo"); err != nil { t.Fatalf("TouchRestartGen: %v", err) } before := restartEtag(t, s, "bab", "titogo") if _, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}); err != nil { t.Fatalf("append: %v", err) } if after := restartEtag(t, s, "bab", "titogo"); after != before { t.Fatalf("restart-gen etag moved from %q to %q on a narrowed request — every old box would cycle the whole app", before, after) } } // TestRestartScopeAbsentIsLegal: an app nobody has narrowed a restart for has // no object, forever, and reading that is not an error. func TestRestartScopeAbsentIsLegal(t *testing.T) { s := NewDirStore(t.TempDir()) g, found, err := LoadRestartScopes(context.Background(), s, "bab", "keymail") if err != nil { t.Fatalf("LoadRestartScopes on an absent object: %v", err) } if found { t.Fatal("found = true for an app with no object") } if g.Newest() != RestartScopeNone { t.Fatalf("Newest = %q, want the recorded-nothing sentinel", g.Newest()) } pending, ok := g.PendingSince(RestartScopeNone) if !ok || len(pending) != 0 { t.Fatalf("PendingSince(none) = %v, %v — an absent object owes nobody a restart", pending, ok) } } // TestPendingSinceReturnsOnlyWhatIsNew: a box that acted up to a mark owes // exactly the suffix after it, and nothing else. func TestPendingSinceReturnsOnlyWhatIsNew(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() var ids []string for _, env := range []string{"relay", "router", "demo"} { req, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeEnvironment, Value: env}) if err != nil { t.Fatalf("append %s: %v", env, err) } ids = append(ids, req.ID) } g := loadScopes(t, s, "bab", "titogo") // A box that has never looked records "none" and then owes everything. pending, ok := g.PendingSince(RestartScopeNone) if !ok || len(pending) != 3 { t.Fatalf("PendingSince(none) = %d requests, %v; want all three", len(pending), ok) } pending, ok = g.PendingSince(ids[0]) if !ok || len(pending) != 2 || pending[0].Scope.Value != "router" { t.Fatalf("PendingSince(first) = %+v, %v; want router and demo", pending, ok) } if pending, ok := g.PendingSince(ids[2]); !ok || len(pending) != 0 { t.Fatalf("PendingSince(newest) = %+v, %v; want nothing owed", pending, ok) } } // TestPendingSinceReportsALostPosition: a mark that has rolled off the end of // the log has no honest answer, and the caller must be told so rather than // handed a guess. Replaying the whole log would cycle production for requests // served days ago; the box re-baselines instead. func TestPendingSinceReportsALostPosition(t *testing.T) { g := &ScopedRestartGeneration{Schema: 1, Requests: []RestartRequest{ {ID: "b", Scope: RestartScope{Kind: RestartScopeHost, Value: "b.example.com"}}, }} if pending, ok := g.PendingSince("a-that-rolled-off"); ok || len(pending) != 0 { t.Fatalf("PendingSince(lost) = %+v, %v; want nothing and a report that the position is unknown", pending, ok) } } // TestRestartScopeLogIsBounded: the object stays small, and the oldest entries // are what go. A box far enough behind to lose its position re-baselines, // which is the safe direction. func TestRestartScopeLogIsBounded(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() var first RestartRequest for i := 0; i < restartScopeKeep+5; i++ { req, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}) if err != nil { t.Fatalf("append %d: %v", i, err) } if i == 0 { first = req } } g := loadScopes(t, s, "bab", "titogo") if len(g.Requests) != restartScopeKeep { t.Fatalf("log holds %d requests, want it trimmed to %d", len(g.Requests), restartScopeKeep) } if _, ok := g.PendingSince(first.ID); ok { t.Fatal("the oldest request survived the trim — the log is unbounded") } } // TestRestartScopeSelectsNarrowsAndNeverWidens is the forward-compatibility // rule: an unknown selector kind addresses NOTHING and says it did not // understand, so a `--region` invented later cannot make this binary fall back // to cycling the app. func TestRestartScopeSelectsNarrowsAndNeverWidens(t *testing.T) { relay := registry.Route{Host: "connect.example.com", App: "titogo", Environment: "relay"} plain := registry.Route{Host: "bill.example.com", App: "titogo"} if sel, ok := (RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}).Selects(plain); !sel || !ok { t.Fatalf("host selector missed its own route (sel %v, understood %v)", sel, ok) } if sel, _ := (RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}).Selects(relay); sel { t.Fatal("host selector matched another route") } if sel, ok := (RestartScope{Kind: RestartScopeEnvironment, Value: "relay"}).Selects(relay); !sel || !ok { t.Fatalf("environment selector missed a bound route (sel %v, understood %v)", sel, ok) } // An unbound route reads the app's DEFAULT bundle. "" is the absence of an // environment, not the name of one, so an environment selector never // reaches it — otherwise a bundle-shaped request would silently become an // app-wide one for every route nobody had bound. if sel, _ := (RestartScope{Kind: RestartScopeEnvironment, Value: ""}).Selects(plain); sel { t.Fatal("an empty environment selector matched an unbound route — that is an app-wide restart in disguise") } for _, rt := range []registry.Route{relay, plain} { sel, understood := (RestartScope{Kind: "region", Value: "eu-west-1"}).Selects(rt) if sel || understood { t.Fatalf("a selector kind this build does not know addressed %s (sel %v, understood %v)", rt.Host, sel, understood) } } } // TestValidRestartScopeRefusesWhatNoBoxCouldAct: the last gate before the // bucket. A request nothing could ever match must not be written, or a box // would step over it forever. func TestValidRestartScopeRefusesWhatNoBoxCouldAct(t *testing.T) { for _, sc := range []RestartScope{ {Kind: "", Value: "x"}, {Kind: "region", Value: "eu-west-1"}, {Kind: RestartScopeApp, Value: ""}, // app-wide has its own object {Kind: RestartScopeHost, Value: ""}, {Kind: RestartScopeEnvironment, Value: "bad\nvalue"}, } { if err := ValidRestartScope(sc); err == nil { t.Fatalf("ValidRestartScope(%+v) accepted it", sc) } } if err := ValidRestartScope(RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err != nil { t.Fatalf("a good host scope was refused: %v", err) } } // TestRestartScopeRefusesANewerSchema: a log written by a console this binary // is older than must not be guessed at, and must not be OVERWRITTEN either — // appending to it would silently drop entries this build cannot see. Both // paths refuse, which leaves the request to be picked up after an upgrade. func TestRestartScopeRefusesANewerSchema(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() raw, err := json.Marshal(map[string]any{"schema": 2, "requests": []any{}}) if err != nil { t.Fatalf("marshal: %v", err) } if _, err := s.Put(ctx, RestartScopeKey("bab", "titogo"), raw, ""); err != nil { t.Fatalf("seed a newer object: %v", err) } if _, _, err := LoadRestartScopes(ctx, s, "bab", "titogo"); err == nil { t.Fatal("a schema-2 log parsed as though this build understood it") } if _, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err == nil { t.Fatal("appended to a schema-2 log — entries this build cannot read would have been dropped") } } // TestAppendRestartScopeRetriesACASConflict is the difference from // TouchRestartGen, which treats a conflict as success. There, any touch fires // the one signal that exists; here the other writer's request names a // DIFFERENT set of routes, so losing ours would answer "requested" for a // restart that never happens. func TestAppendRestartScopeRetriesACASConflict(t *testing.T) { s := &conflictOnceStore{Store: NewDirStore(t.TempDir())} ctx := context.Background() if _, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err != nil { t.Fatalf("append through one conflict: %v", err) } if s.conflicts != 1 { t.Fatalf("the store served %d conflicts, want the one this test staged", s.conflicts) } if g := loadScopes(t, s.Store, "bab", "titogo"); len(g.Requests) != 1 { t.Fatalf("log = %+v, want the retried request to have landed", g.Requests) } } // conflictOnceStore fails the FIRST Put with a CAS conflict, as a racing // writer would, and behaves normally after that. type conflictOnceStore struct { Store conflicts int } func (c *conflictOnceStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if c.conflicts == 0 { c.conflicts++ return "", ErrCASConflict } return c.Store.Put(ctx, key, body, ifMatch) } // TestRestartScopeKeyIsUnderTheAppPrefix: everything an app owns lives under // one account-scoped prefix, permanently — trash, retention and per-app // deletion all depend on it. func TestRestartScopeKeyIsUnderTheAppPrefix(t *testing.T) { if got, want := RestartScopeKey("bab", "titogo"), AppPrefix("bab", "titogo")+"restart-scope.json"; got != want { t.Fatalf("RestartScopeKey = %q, want %q", got, want) } if RestartScopeKey("bab", "titogo") == RestartGenKey("bab", "titogo") { t.Fatal("the narrowed log shares a key with the app-wide generation — an old box would read it as an app-wide request") } } // TestAppendRestartScopeRefusesABadName keeps a hostile account or app out of // the key, the same gate TouchRestartGen applies. func TestAppendRestartScopeRefusesABadName(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() if _, err := AppendRestartScope(ctx, s, "../etc", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err == nil { t.Fatal("a traversal-shaped account was accepted") } if _, err := AppendRestartScope(ctx, s, "bab", "../x", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err == nil { t.Fatal("a traversal-shaped app was accepted") } if _, err := os.Stat(t.TempDir()); err != nil { t.Fatalf("temp dir: %v", err) } } // A retry is not a restart: Selects must not read one (an older console's, // left in this log) as a restart of anything. No new write puts one here // (TestValidRestartScopeRefusesARetryAndASignature). func TestRetryScopeSelectsNothing(t *testing.T) { sc := RestartScope{Kind: RestartScopeRetry, Value: "a.example.com"} if sel, understood := sc.Selects(registry.Route{Host: "a.example.com"}); sel || understood { t.Fatalf("Selects = %v, %v; a retry must never select a route for a restart", sel, understood) } }