package host // The box half of a narrowed restart (discussion #16): that a request naming // one route cycles one route, that the rules restart.go pays for — seed // don't replay, never wake a sleeper, one cycle per host per pass — survive // the narrowing, and that a selector this build cannot read cycles NOTHING. import ( "bytes" "context" "crypto/ed25519" "encoding/json" "errors" "fmt" "log" "os" "path/filepath" "slices" "strings" "testing" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rollout" ) // askScoped is a member's `carlos restart --host/--environment` landing in the // bucket. func askScoped(t *testing.T, ctx context.Context, s release.Store, acct, app, kind, value string) release.RestartRequest { t.Helper() req, err := release.AppendRestartScope(ctx, s, acct, app, release.RestartScope{Kind: kind, Value: value}) if err != nil { t.Fatalf("AppendRestartScope %s/%s (%s %s): %v", acct, app, kind, value, err) } return req } // envRoute is a unit-backed instance bound to a named config bundle — the // shape `--environment` exists for, and the shape only the root pass can // cycle. func envRoute(host, acct, app, env string) registry.Route { rt := unitRoute(host, acct, app) rt.Environment = env return rt } // titogoRoutes is the fixture the whole discussion is about, small: one app, // several routes, two bundles between them, one on nothing. func titogoRoutes() []registry.Route { return []registry.Route{ envRoute("connect.example.com", "bab", "titogo", "relay"), envRoute("relay2.example.com", "bab", "titogo", "relay"), envRoute("platform.example.com", "bab", "titogo", "router"), unitRoute("bill.example.com", "bab", "titogo"), } } // ---- the agent's sweep ------------------------------------------------------- // TestRestartScopePassFirstSightIsSilent: a box that has never looked records // where the log stands and asks for nothing. Without this, a box coming up // after a request would cycle for it a second time — and a box whose memory // was lost would cycle for every request still in the log. func TestRestartScopePassFirstSightIsSilent(t *testing.T) { ctx, s := genKit(t) askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") cache := map[[2]string]string{} work, errs := restartScopePass(ctx, titogoRoutes(), s, cache) if len(work) != 0 || len(errs) != 0 { t.Fatalf("first sight asked for %v (errs %v) — it must only record", work, errs) } if len(cache) != 1 { t.Fatalf("cache = %v, want the app recorded", cache) } } // TestRestartScopePassCarriesEveryRequestInAWindow is why the object is a log: // two requests between two sweeps are TWO selectors, and collapsing them into // the last one would silently drop a restart somebody was told was requested. func TestRestartScopePassCarriesEveryRequestInAWindow(t *testing.T) { ctx, s := genKit(t) routes := titogoRoutes() cache := map[[2]string]string{} restartScopePass(ctx, routes, s, cache) // baseline askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "router") work, _ := restartScopePass(ctx, routes, s, cache) if len(work) != 1 || len(work[0].requests) != 2 { t.Fatalf("work = %+v, want one app carrying both requests", work) } hosts := scopedHosts(routes, work[0].app, work[0].requests, func(registry.Route) bool { return true }) if !slices.Equal(hosts, []string{"bill.example.com", "platform.example.com"}) { t.Fatalf("addressed %v, want exactly the two named routes", hosts) } // And an unmoved log asks for nothing on the next sweep. if work, _ := restartScopePass(ctx, routes, s, cache); len(work) != 0 { t.Fatalf("an unmoved log asked for %+v", work) } } // TestRestartScopePassReBaselinesALostPosition is the agent's own half of the // rolled-off-mark rule, exercised directly rather than through the root pass: // a box away long enough for its position to leave the log has no honest // answer to "what did I miss", so it records where the log now stands and asks // for nothing. Replaying instead would cycle routes for requests served days // ago; a dropped one is recovered by typing the verb again. func TestRestartScopePassReBaselinesALostPosition(t *testing.T) { ctx, s := genKit(t) routes := titogoRoutes() req := askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "relay") // This box acted up to a request that has since been trimmed off the front // of the log. cache := map[[2]string]string{{"bab", "titogo"}: "rolled-off"} work, errs := restartScopePass(ctx, routes, s, cache) if len(errs) != 0 { t.Fatalf("unexpected read errors: %v", errs) } if len(work) != 0 { t.Fatalf("a lost position asked for %+v — it must re-baseline instead of replaying the log", work) } if got := cache[[2]string{"bab", "titogo"}]; got != req.ID { t.Fatalf("cache = %q, want the re-baseline to the log's newest %q", got, req.ID) } // And the NEXT request is acted on: re-baselining is not a box giving up. next := askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") work, _ = restartScopePass(ctx, routes, s, cache) if len(work) != 1 || len(work[0].requests) != 1 || work[0].requests[0].ID != next.ID { t.Fatalf("work = %+v, want the one request made after the re-baseline", work) } } // TestScopedHostsNarrowsToTheSelector is the whole complaint, tested: one // credential delivered to one bundle must cycle the routes reading that // bundle, not every route the app owns. func TestScopedHostsNarrowsToTheSelector(t *testing.T) { routes := titogoRoutes() app := [2]string{"bab", "titogo"} all := func(registry.Route) bool { return true } got := scopedHosts(routes, app, []release.RestartRequest{ {ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeEnvironment, Value: "relay"}}, }, all) if !slices.Equal(got, []string{"connect.example.com", "relay2.example.com"}) { t.Fatalf("environment relay addressed %v, want the two relay routes and nothing else", got) } got = scopedHosts(routes, app, []release.RestartRequest{ {ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeHost, Value: "bill.example.com"}}, }, all) if !slices.Equal(got, []string{"bill.example.com"}) { t.Fatalf("host selector addressed %v, want the one route", got) } // Another account's route with the same host is not this app's route. other := append(routes, envRoute("connect.example.com", "zzz", "titogo", "relay")) got = scopedHosts(other, app, []release.RestartRequest{ {ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeHost, Value: "connect.example.com"}}, }, all) if !slices.Equal(got, []string{"connect.example.com"}) { t.Fatalf("addressed %v — a host must be resolved within this account and app only", got) } } // TestScopedHostsIgnoresAnUnknownSelector: the forward-compatibility rule at // the point it matters. A `--region` invented after this binary shipped must // address nothing here, never fall back to the app. func TestScopedHostsIgnoresAnUnknownSelector(t *testing.T) { got := scopedHosts(titogoRoutes(), [2]string{"bab", "titogo"}, []release.RestartRequest{ {ID: "1", Scope: release.RestartScope{Kind: "region", Value: "eu-west-1"}}, }, func(registry.Route) bool { return true }) if len(got) != 0 { t.Fatalf("an unreadable selector addressed %v — a box must never widen what it cannot understand", got) } } // TestScopedHostsDeduplicates: two requests naming the same route mean one // cycle of it. A verb whose job is stopping processes must not manufacture a // second stop out of somebody typing the same thing twice. func TestScopedHostsDeduplicates(t *testing.T) { got := scopedHosts(titogoRoutes(), [2]string{"bab", "titogo"}, []release.RestartRequest{ {ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeHost, Value: "connect.example.com"}}, {ID: "2", Scope: release.RestartScope{Kind: release.RestartScopeEnvironment, Value: "relay"}}, }, func(registry.Route) bool { return true }) if !slices.Equal(got, []string{"connect.example.com", "relay2.example.com"}) { t.Fatalf("addressed %v, want each route once", got) } } // TestBounceScopedRoutesCyclesOnlyItsOwnChildren: the agent's half reaches // exec children and nothing else, exactly as the app-wide bounce does — the // two halves must never cycle one process twice. func TestBounceScopedRoutesCyclesOnlyItsOwnChildren(t *testing.T) { routes := []registry.Route{ execRoute("exec.example.com", "bab", "titogo"), unitRoute("unit.example.com", "bab", "titogo"), } routes[0].Environment, routes[1].Environment = "relay", "relay" var bounced []string n := bounceScopedRoutes(routes, []scopedRestart{{ app: [2]string{"bab", "titogo"}, requests: []release.RestartRequest{{ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeEnvironment, Value: "relay"}}}, }}, func(h string) bool { bounced = append(bounced, h); return true }) if n != 1 || !slices.Equal(bounced, []string{"exec.example.com"}) { t.Fatalf("bounced %v (n=%d), want the exec child only — the unit route is root's", bounced, n) } } // TestRestartRequestLegCarriesBothSignals: an app-wide request and a narrowed // one can land in the same poll window, and the leg that stopped at the first // would leave the other to the hourly floor. func TestRestartRequestLegCarriesBothSignals(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := []registry.Route{ execRoute("a.example.com", "bab", "eleven"), execRoute("b.example.com", "bab", "eleven"), } routes[1].Environment = "relay" mem := newRestartMemory() var bounced []string bounce := func(h string) bool { bounced = append(bounced, h); return true } if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem); asked { t.Fatal("the baselining leg asked for a root pass") } askScoped(t, ctx, s, "bab", "eleven", release.RestartScopeEnvironment, "relay") if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem); !asked { t.Fatal("a narrowed request did not ask for a root pass") } if !slices.Equal(bounced, []string{"b.example.com"}) { t.Fatalf("bounced %v, want only the route reading relay", bounced) } if _, err := os.Stat(filepath.Join(dd, restartRequestMarker)); err != nil { t.Fatalf("no marker after a narrowed request: %v", err) } } // ---- the root pass ----------------------------------------------------------- // TestRestartPassCyclesOnlyTheNarrowedRoutes is the fix itself: a request // naming one environment cycles the routes on it, and leaves the other // seventeen production routes running. func TestRestartPassCyclesOnlyTheNarrowedRoutes(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := titogoRoutes() marks := restartMarksPath(dd) restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) // baseline askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "relay") rec := &recordingRestart{} restartPass(ctx, routes, s, marks, rec.fn) if !slices.Equal(rec.hosts, []string{"connect.example.com", "relay2.example.com"}) { t.Fatalf("cycled %v, want only the routes reading relay", rec.hosts) } // And the request is not served twice. rec2 := &recordingRestart{} restartPass(ctx, routes, s, marks, rec2.fn) if len(rec2.hosts) != 0 { t.Fatalf("a second pass cycled %v for a request already served", rec2.hosts) } } // TestRestartPassSeedsNarrowedRequestsWithoutReplaying: a box that has never // looked at the log records where it stands and cycles nothing — the property // that keeps a lost marks file from bouncing a box's whole route table. func TestRestartPassSeedsNarrowedRequestsWithoutReplaying(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := titogoRoutes() req := askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") rec := &recordingRestart{} restartPass(ctx, routes, s, restartMarksPath(dd), rec.fn) if len(rec.hosts) != 0 { t.Fatalf("the seeding pass cycled %v", rec.hosts) } m := marksOf(t, restartMarksPath(dd)) if got := m.Scopes[restartMarkKey("bab", "titogo")]; got != req.ID { t.Fatalf("scope mark = %q, want the newest request %q", got, req.ID) } } // TestRestartPassLeavesAHibernatingRouteAsleep: the load-bearing one. A // narrowed request that names a sleeping instance must not wake it — the // instance execs fresh on its next wake, which IS the restart. func TestRestartPassLeavesAHibernatingRouteAsleep(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() sleeper := envRoute("sleeper.example.com", "bab", "titogo", "relay") sleeper.Hibernate = true routes := append(titogoRoutes(), sleeper) marks := restartMarksPath(dd) restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "relay") rec := &recordingRestart{} restartPass(ctx, routes, s, marks, rec.fn) if slices.Contains(rec.hosts, "sleeper.example.com") { t.Fatalf("cycled %v — a hibernating instance was woken by a restart request", rec.hosts) } if !slices.Equal(rec.hosts, []string{"connect.example.com", "relay2.example.com"}) { t.Fatalf("cycled %v, want the two awake relay routes", rec.hosts) } } // TestRestartPassCyclesEachHostOnceAcrossBothSignals: an app-wide request and // a narrowed one landing in the same window are one cycle per route, not two. func TestRestartPassCyclesEachHostOnceAcrossBothSignals(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := titogoRoutes() marks := restartMarksPath(dd) restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) askRestart(t, ctx, s, "bab", "titogo") askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "relay") rec := &recordingRestart{} restartPass(ctx, routes, s, marks, rec.fn) slices.Sort(rec.hosts) want := []string{"bill.example.com", "connect.example.com", "platform.example.com", "relay2.example.com"} if !slices.Equal(rec.hosts, want) { t.Fatalf("cycled %v, want each route exactly once: %v", rec.hosts, want) } } // TestRestartPassIgnoresAnUnreadableSelector: the box-level half of the // never-widen rule. A request this build cannot interpret cycles nothing, and // the mark still advances so the pass does not re-read it hourly forever. func TestRestartPassIgnoresAnUnreadableSelector(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := titogoRoutes() marks := restartMarksPath(dd) restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) // Written straight to the bucket, because AppendRestartScope refuses to // write a kind it does not know — this is a NEWER console's request // reaching an older box, which is the case that matters. raw, err := json.Marshal(release.ScopedRestartGeneration{Schema: 1, Requests: []release.RestartRequest{ {ID: "future-1", Scope: release.RestartScope{Kind: "region", Value: "eu-west-1"}}, }}) if err != nil { t.Fatalf("marshal: %v", err) } if _, err := s.Put(ctx, release.RestartScopeKey("bab", "titogo"), raw, ""); err != nil { t.Fatalf("put a future request: %v", err) } rec := &recordingRestart{} restartPass(ctx, routes, s, marks, rec.fn) if len(rec.hosts) != 0 { t.Fatalf("cycled %v for a selector this build cannot read — it must cycle nothing", rec.hosts) } if got := marksOf(t, marks).Scopes[restartMarkKey("bab", "titogo")]; got != "future-1" { t.Fatalf("scope mark = %q, want the request stepped over rather than re-read forever", got) } } // TestRestartPassReBaselinesALostPosition: a box away long enough for its mark // to roll off the log re-baselines and cycles nothing. A dropped request is // recoverable by typing the verb again; a replayed one is not. func TestRestartPassReBaselinesALostPosition(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := titogoRoutes() marks := restartMarksPath(dd) restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) raw, err := json.Marshal(release.ScopedRestartGeneration{Schema: 1, Requests: []release.RestartRequest{ {ID: "gone", Scope: release.RestartScope{Kind: release.RestartScopeHost, Value: "bill.example.com"}}, }}) if err != nil { t.Fatalf("marshal: %v", err) } if _, err := s.Put(ctx, release.RestartScopeKey("bab", "titogo"), raw, ""); err != nil { t.Fatalf("seed a log: %v", err) } // This box acted up to a request that has since been trimmed away. m := marksOf(t, marks) m.Scopes[restartMarkKey("bab", "titogo")] = "rolled-off" saveMarks(t, marks, m) rec := &recordingRestart{} restartPass(ctx, routes, s, marks, rec.fn) if len(rec.hosts) != 0 { t.Fatalf("cycled %v after losing its position — it must re-baseline instead", rec.hosts) } if got := marksOf(t, marks).Scopes[restartMarkKey("bab", "titogo")]; got != "gone" { t.Fatalf("scope mark = %q, want the re-baseline to the log's newest", got) } } // ---- the handover on a box that predates narrowing --------------------------- // writePreNarrowingMarks puts on disk exactly what the binary before this // feature wrote: schema, apps, and no `scopes` key at all. Every box that has // ever run the app-wide restart has one of these. func writePreNarrowingMarks(t *testing.T, path string, apps map[string]string) { t.Helper() raw, err := json.Marshal(map[string]any{"schema": 1, "apps": apps}) if err != nil { t.Fatalf("marshal old marks: %v", err) } if err := os.WriteFile(path, raw, 0o644); err != nil { t.Fatalf("write old marks: %v", err) } } // armAgent is one agent start: the two silent sweeps that fill its memory, and // the seed that either writes the root pass's baseline or reports that the one // already on disk is missing a narrowed mark. func armAgent(t *testing.T, ctx context.Context, routes []registry.Route, s release.Store, dd string) (*restartMemory, bool) { t.Helper() mem := newRestartMemory() if _, errs := restartGenPass(ctx, routes, s, mem.gen); len(errs) != 0 { t.Fatalf("gen sweep: %v", errs) } if _, errs := restartScopePass(ctx, routes, s, mem.scope); len(errs) != 0 { t.Fatalf("scope sweep: %v", errs) } need, err := seedRestartMarks(restartMarksPath(dd), mem) if err != nil { t.Fatalf("seed: %v", err) } return mem, need } // TestAPreNarrowingMarksFileIsCompletedBeforeTheNextRequestIsDropped is the // shape of every production box on the day this rolls, and the drop it used to // cause. // // The marks file was written before narrowed restarts existed, so it has // `apps` and no `scopes`. The agent's create-only seed will not touch it, // which leaves the ROOT pass with no narrowed position — and a missing // position reads as a FIRST SIGHT, so that pass records the log's newest id // and cycles nothing. An operator who ran `carlos restart --host relay2` in // that window was told "restart requested" and got no restart: the exec-shaped // routes survive because the agent bounces its own children out of memory, and // the unit-shaped ones — the relay and the router these flags exist for — are // the ones that vanish. // // The fix is the seed REPORTING the gap so that one root pass records the // marks. This walks the whole handover: arm, let that pass run, then ask. func TestAPreNarrowingMarksFileIsCompletedBeforeTheNextRequestIsDropped(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := titogoRoutes() // unit-shaped: only the root pass can cycle these marks := restartMarksPath(dd) // A request from before the roll, already in the log. Nothing here may // replay it. askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "router") writePreNarrowingMarks(t, marks, map[string]string{ restartMarkKey("bab", "titogo"): genAbsent, // no app-wide object; the old binary recorded the absence }) if _, need := armAgent(t, ctx, routes, s, dd); !need { t.Fatal("the agent saw a marks file with no narrowed mark and asked for nothing — " + `the next narrowed request would be answered "requested" and cycle nothing unit-shaped`) } if got := marksOf(t, marks).Scopes; len(got) != 0 { t.Fatalf("the seed wrote %v into an existing marks file — it must only report, never race the root pass's own write", got) } // The root pass that touch asks for. It records and cycles nothing: the // request already in the log is this box's history, not work it owes. rec := &recordingRestart{} restartPass(ctx, routes, s, marks, rec.fn) if len(rec.hosts) != 0 { t.Fatalf("the baselining pass cycled %v — a box completing its baseline must replay nothing", rec.hosts) } if marksOf(t, marks).Scopes[restartMarkKey("bab", "titogo")] == "" { t.Fatal("the pass recorded no scope mark, so the box would re-seed forever") } // And NOW the operator types the verb. This is the request that used to be // dropped. askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "relay2.example.com") rec = &recordingRestart{} restartPass(ctx, routes, s, marks, rec.fn) if !slices.Equal(rec.hosts, []string{"relay2.example.com"}) { t.Fatalf("cycled %v, want the one route the operator named", rec.hosts) } // A second agent start over a now-complete baseline asks for nothing: the // repair is one pass per box, not one per agent start. if _, need := armAgent(t, ctx, routes, s, dd); need { t.Fatal("an agent restarting over a complete baseline asked for another root pass") } } // TestSeedOnAFreshBoxAsksForNoRootPass is the genuinely-first-sight case, kept // separate because it is the one the create-only seed already handles: with no // marks file at all the agent writes BOTH memories itself, so there is nothing // left for a root pass to fill in — and the next narrowed request is acted on. func TestSeedOnAFreshBoxAsksForNoRootPass(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := titogoRoutes() marks := restartMarksPath(dd) askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "router") // history if _, need := armAgent(t, ctx, routes, s, dd); need { t.Fatal("a seed that wrote the whole baseline itself still asked for a root pass") } if marksOf(t, marks).Scopes[restartMarkKey("bab", "titogo")] == "" { t.Fatal("the fresh seed recorded no scope mark") } askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") rec := &recordingRestart{} restartPass(ctx, routes, s, marks, rec.fn) if !slices.Equal(rec.hosts, []string{"bill.example.com"}) { t.Fatalf("cycled %v, want the named route — the seeded baseline is what the root pass trusts", rec.hosts) } } // TestRestartSeedAsksTheRootPassThroughTheMarker is the same repair through // the real entry point, so the marker file — the only way this unprivileged // process can reach a root pass at all — is pinned rather than assumed. func TestRestartSeedAsksTheRootPassThroughTheMarker(t *testing.T) { ctx, s := genKit(t) reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } defer reg.Close() for _, rt := range titogoRoutes() { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } dd := t.TempDir() writePreNarrowingMarks(t, restartMarksPath(dd), map[string]string{ restartMarkKey("bab", "titogo"): genAbsent, }) restartSeed(ctx, reg, s, dd, newRestartMemory()) if _, err := os.Stat(filepath.Join(dd, restartRequestMarker)); err != nil { t.Fatalf("no marker after arming over a pre-narrowing baseline: %v — "+ "the root pass would record no narrowed mark until the hourly floor", err) } // A baseline this agent wrote itself leaves the marker alone: an agent that // touched it on every start would start a root pass on every start. fresh := t.TempDir() restartSeed(ctx, reg, s, fresh, newRestartMemory()) if _, err := os.Stat(filepath.Join(fresh, restartRequestMarker)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("the fresh seed touched the marker (err %v), want no request at all", err) } } // TestRestartPassAdvancesTheScopeMarkOnAFailedDispatch: a request is a // point-in-time event, not a desired state. A unit that will not come back // must not be retried on every pass forever for something somebody asked once. func TestRestartPassAdvancesTheScopeMarkOnAFailedDispatch(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := titogoRoutes() marks := restartMarksPath(dd) restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) req := askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") rec := &recordingRestart{fail: map[string]error{"bill.example.com": os.ErrPermission}} restartPass(ctx, routes, s, marks, rec.fn) if !slices.Equal(rec.hosts, []string{"bill.example.com"}) { t.Fatalf("tried %v, want the named route", rec.hosts) } if got := marksOf(t, marks).Scopes[restartMarkKey("bab", "titogo")]; got != req.ID { t.Fatalf("scope mark = %q after a failed dispatch, want %q — an unadvanced mark retries forever", got, req.ID) } } // saveMarks rewrites the marks file the way the pass does, so a test can put // this box's memory where it needs it. func saveMarks(t *testing.T, path string, m *restartMarks) { t.Helper() raw, err := json.Marshal(m) if err != nil { t.Fatalf("marshal marks: %v", err) } if err := os.WriteFile(path, raw, 0o644); err != nil { t.Fatalf("write marks: %v", err) } } // A retry request clears one host's hold through the rollout manager, cycles // nothing, and asks root for no restart pass. The grant's build, book and // epoch are what the manager is handed, once. func TestRestartLegHandsARetryToTheRolloutManager(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := []registry.Route{execRoute("a.example.com", "bab", "eleven"), execRoute("b.example.com", "bab", "eleven")} mem, priv := retryMemory(t) var retried []string mem.retryHost = func(h, b, bk string, e uint64) bool { retried = append(retried, fmt.Sprintf("%s %s %s %d", h, b, bk, e)) return true } bounce := func(h string) bool { t.Errorf("%s was bounced for a retry", h); return true } if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem); asked { t.Fatal("the baselining leg asked for a root pass") } askRetry(t, ctx, s, priv, "bab", "eleven", "b.example.com", "v2", "bk", 5) askRetry(t, ctx, s, priv, "bab", "eleven", "elsewhere.example.com", "v2", "bk", 5) if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem); asked { t.Fatal("a retry-only move asked for a root restart pass") } if !slices.Equal(retried, []string{"b.example.com v2 bk 5"}) { t.Fatalf("retried %v, want only this box's b.example.com, for v2 at epoch 5 of book bk", retried) } } // retryMemory is a restart memory that verifies retries against a fresh // root key, returned for the test's console to sign with. func retryMemory(t *testing.T) (*restartMemory, ed25519.PrivateKey) { t.Helper() pub, priv, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } mem := newRestartMemory() mem.pub = pub return mem, priv } // askRetry is the console's signed `carlos instances retry ` landing // in the bucket (internal/console requestHostRetry). func askRetry(t *testing.T, ctx context.Context, s release.Store, priv ed25519.PrivateKey, acct, app, host, build, book string, epoch uint64) string { t.Helper() wire, err := release.SignHostRetry(release.RootSigner(priv), acct, app, host, build, book, epoch, time.Now()) if err != nil { t.Fatal(err) } askSignedRetry(t, ctx, s, acct, app, host, wire) return wire } // askSignedRetry is a console's append of a signed grant to the host-retry // log (release.AppendHostRetry). func askSignedRetry(t *testing.T, ctx context.Context, s release.Store, acct, app, host, wire string) { t.Helper() if _, err := release.AppendHostRetry(ctx, s, acct, app, host, wire); err != nil { t.Fatal(err) } } // rawRetry appends a retry entry to the log at key with no validation: an // unsigned one in the host-retry log (any box can write it), or a retry in // the restart-scope log as a console built from main before the host-retry // log wrote it (legacyRetry), signed or, from an older one, not. func rawRetry(t *testing.T, ctx context.Context, s release.Store, key, host, wire string) string { t.Helper() g := &release.ScopedRestartGeneration{Schema: 1} raw, etag, err := s.Get(ctx, key) switch { case errors.Is(err, os.ErrNotExist): etag = "" case err != nil: t.Fatal(err) default: if err := json.Unmarshal(raw, g); err != nil { t.Fatal(err) } } id := fmt.Sprintf("raw-%d-%d", len(g.Requests), time.Now().UnixNano()) g.Requests = append(g.Requests, release.RestartRequest{ID: id, RequestedAt: time.Now().UTC().Format(time.RFC3339), Scope: release.RestartScope{Kind: release.RestartScopeRetry, Value: host, Signed: wire}}) body, err := json.Marshal(g) if err != nil { t.Fatal(err) } if _, err := s.Put(ctx, key, body, etag); err != nil { t.Fatal(err) } return id } // legacyRetry is a retry in the restart-scope log, as a console built from // main before the host-retry log appended it. func legacyRetry(t *testing.T, ctx context.Context, s release.Store, acct, app, host, wire string) { t.Helper() rawRetry(t, ctx, s, release.RestartScopeKey(acct, app), host, wire) } // legacySignedRetry signs a grant as legacyRetry's console did. func legacySignedRetry(t *testing.T, ctx context.Context, s release.Store, priv ed25519.PrivateKey, acct, app, host, build, book string, epoch uint64) { t.Helper() wire, err := release.SignHostRetry(release.RootSigner(priv), acct, app, host, build, book, epoch, time.Now()) if err != nil { t.Fatal(err) } legacyRetry(t, ctx, s, acct, app, host, wire) } // Spec §11, Astra impl-6 finding 5: every box can write the restart-scope // log, so only a grant the root signed for this entry's account, app and // host reaches the manager. An unsigned entry (another box's, or an older // console's), one signed by another key, one whose grant names another host // or app than its entry, and any retry on a box with no root key are // dropped with a line saying why. func TestRestartLegDropsARetryThatIsNotTheRootsGrant(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := []registry.Route{execRoute("a.example.com", "bab", "eleven"), execRoute("b.example.com", "bab", "eleven")} mem, priv := retryMemory(t) _, other, _ := ed25519.GenerateKey(nil) mem.retryHost = func(h, b, _ string, e uint64) bool { t.Errorf("retryHost(%s, %s, %d) was called", h, b, e) return true } bounce := func(h string) bool { t.Errorf("%s was bounced", h); return true } restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) // baseline rawRetry(t, ctx, s, release.HostRetryKey("bab", "eleven"), "a.example.com", "") askRetry(t, ctx, s, other, "bab", "eleven", "a.example.com", "v2", "bk", 5) forB, _ := release.SignHostRetry(release.RootSigner(priv), "bab", "eleven", "b.example.com", "v2", "bk", 5, time.Now()) askSignedRetry(t, ctx, s, "bab", "eleven", "a.example.com", forB) forShop, _ := release.SignHostRetry(release.RootSigner(priv), "bab", "shop", "a.example.com", "v2", "bk", 5, time.Now()) askSignedRetry(t, ctx, s, "bab", "eleven", "a.example.com", forShop) var logbuf bytes.Buffer log.SetOutput(&logbuf) defer log.SetOutput(os.Stderr) restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) if n := strings.Count(logbuf.String(), "agent: retry requested for a.example.com, dropped:"); n != 4 { t.Fatalf("log = %q, want four dropped retries", logbuf.String()) } if !strings.Contains(logbuf.String(), "unsigned") { t.Fatalf("log = %q, want the unsigned entry named as such", logbuf.String()) } // A box with no root key cannot tell a grant from a forgery. logbuf.Reset() mem.pub = nil askRetry(t, ctx, s, priv, "bab", "eleven", "a.example.com", "v2", "bk", 5) restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) if !strings.Contains(logbuf.String(), "agent: retry requested for a.example.com, dropped: this box has no root public key") { t.Fatalf("log = %q, want the keyless drop named", logbuf.String()) } } // The grants against a real failure book (plan review findings 5 and 13): a // grant whose epoch predates a second failure of the same build on the same // host, a grant for another build, a grant for another book (final review // round 1, finding 3), and a used grant replayed after the book is reopened // each clear nothing; the grant for the current restriction clears it. func TestRestartLegRetryGrantsAgainstTheBook(t *testing.T) { ctx, s := genKit(t) dd, bookDir := t.TempDir(), t.TempDir() const host = "a.example.com" routes := []registry.Route{execRoute(host, "bab", "eleven")} book, err := rollout.OpenFailureBook(bookDir) if err != nil { t.Fatal(err) } mem, priv := retryMemory(t) var cleared int mem.retryHost = func(h, b, bk string, e uint64) bool { rec, mark, _, _ := book.RetryHostAt("bab", "eleven", b, h, bk, e) if rec || mark { cleared++ } return rec || mark } bounce := func(h string) bool { t.Errorf("%s was bounced", h); return true } restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) // baseline held := func() bool { return book.Blocks("bab", "eleven", "v2", host, false) } book.Record(rollout.FailureRecord{Account: "bab", App: "eleven", Build: "v2", Host: host}) first := book.RestrictionEpoch("bab", "eleven", "v2", host) stale := askRetry(t, ctx, s, priv, "bab", "eleven", host, "v2", book.ID(), first) // The same build fails again on the same host before the box reads it. book.Record(rollout.FailureRecord{Account: "bab", App: "eleven", Build: "v2", Host: host, HeldDown: []string{host}}) restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) if cleared != 0 || !held() { t.Fatal("a grant predating the second failure cleared it") } now := book.RestrictionEpoch("bab", "eleven", "v2", host) askRetry(t, ctx, s, priv, "bab", "eleven", host, "v9", book.ID(), now) restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) if cleared != 0 || !held() { t.Fatal("a grant for another build cleared v2's hold") } // Final review round 1, finding 3: the current epoch signed for another // book (another box's, or this host's before a relocation) clears // nothing, and nor does one that names no book (an older console). askRetry(t, ctx, s, priv, "bab", "eleven", host, "v2", "another-book", now) askRetry(t, ctx, s, priv, "bab", "eleven", host, "v2", "", now) restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) if cleared != 0 || !held() { t.Fatal("a grant at the current epoch for another book, or for none, cleared v2's hold") } used := askRetry(t, ctx, s, priv, "bab", "eleven", host, "v2", book.ID(), now) restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) if cleared != 1 || held() { t.Fatalf("the current grant: %d clears, held %v; want one clear", cleared, held()) } // The agent restarts; the retried canary fails again; the used grant // and the stale one are replayed under new entry ids. if book, err = rollout.OpenFailureBook(bookDir); err != nil { t.Fatal(err) } book.Record(rollout.FailureRecord{Account: "bab", App: "eleven", Build: "v2", Host: host, HeldDown: []string{host}, Seq: book.Snapshot()}) if !held() { t.Fatal("the failure after the retry did not hold the host") } askSignedRetry(t, ctx, s, "bab", "eleven", host, used) askSignedRetry(t, ctx, s, "bab", "eleven", host, stale) restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) if cleared != 1 || !held() { t.Fatal("a replayed grant cleared the failure that followed it") } } // With no manager armed (flag off, or a box that declined or refused it) // nothing is held: a retry an older console left in the restart-scope log // is consumed, logged, and cycles nothing. Such a box does not read the // host-retry log at all (hostRetryPass): nothing it holds could be // cleared. func TestRestartLegDropsARetryWithNoManager(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} mem := newRestartMemory() bounce := func(h string) bool { t.Errorf("%s was bounced", h); return true } restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) legacyRetry(t, ctx, s, "bab", "eleven", "a.example.com", "") var logbuf bytes.Buffer log.SetOutput(&logbuf) defer log.SetOutput(os.Stderr) if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem); asked { t.Fatal("a retry with no manager asked for a root pass") } if got := logbuf.String(); !strings.Contains(got, "retry requested for a.example.com") || !strings.Contains(got, "rollout manager not armed") { t.Fatalf("log = %q, want the dropped retry named", got) } // Consumed: the next pass does not see it again. logbuf.Reset() if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem); asked { t.Fatal("the second pass asked for a root pass") } if got := logbuf.String(); strings.Contains(got, "retry requested") { t.Fatalf("the retry was seen again on the next pass: %q", got) } } // Flag-off behaviour is unchanged: a box with no retry path that gains an // app, or first sees one, whose restart-scope log holds a legacy retry reads // it as history and says nothing about it. (A retry asked for since the last // look still gets its dropped line: TestRestartLegDropsARetryWithNoManager.) func TestRestartLegSaysNothingOfAHistoricRetryOnABoxWithNoManager(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() one := []registry.Route{execRoute("a.example.com", "bab", "eleven")} both := append(slices.Clone(one), execRoute("c.example.com", "bab", "carloku")) mem := newRestartMemory() bounce := func(h string) bool { t.Errorf("%s was bounced", h); return true } restartRequestLeg(ctx, one, s, dd, bounce, nil, mem) // baseline: carloku is not here yet legacyRetry(t, ctx, s, "bab", "carloku", "c.example.com", "") var logbuf bytes.Buffer log.SetOutput(&logbuf) defer log.SetOutput(os.Stderr) if asked := restartRequestLeg(ctx, both, s, dd, bounce, nil, mem); asked { t.Fatal("a historic retry asked for a root pass") } if got := logbuf.String(); strings.Contains(got, "retry") { t.Fatalf("a box with no manager logged about a historic retry: %q", got) } } // A retry names a host but is not a restart: the narrowing that resolves hosts // must never address one for it, on the agent's half or the root pass's. func TestScopedHostsAndRootPassStepOverARetry(t *testing.T) { routes := titogoRoutes() var logbuf bytes.Buffer log.SetOutput(&logbuf) defer log.SetOutput(os.Stderr) got := scopedHosts(routes, [2]string{"bab", "titogo"}, []release.RestartRequest{ {ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeRetry, Value: "bill.example.com"}}, }, func(registry.Route) bool { return true }) if len(got) != 0 { t.Fatalf("a retry addressed %v for a restart", got) } if strings.Contains(logbuf.String(), "does not understand") { t.Fatalf("a retry was logged as an unknown selector: %q", logbuf.String()) } ctx, s := genKit(t) marks := restartMarksPath(t.TempDir()) restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) // baseline legacyRetry(t, ctx, s, "bab", "titogo", "bill.example.com", "") rec := &recordingRestart{} restartPass(ctx, routes, s, marks, rec.fn) if len(rec.hosts) != 0 { t.Fatalf("the root pass cycled %v for a retry", rec.hosts) } } // A retry and a real narrowed restart in one poll window: the retry goes to // the manager, only the restart cycles, and root is asked for its pass. func TestRestartLegSeparatesARetryFromARestartInOneWindow(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := []registry.Route{execRoute("a.example.com", "bab", "eleven"), execRoute("b.example.com", "bab", "eleven")} mem, priv := retryMemory(t) var retried, bounced []string mem.retryHost = func(h, _, _ string, _ uint64) bool { retried = append(retried, h); return true } bounce := func(h string) bool { bounced = append(bounced, h); return true } restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) // baseline askRetry(t, ctx, s, priv, "bab", "eleven", "a.example.com", "v2", "bk", 5) askScoped(t, ctx, s, "bab", "eleven", release.RestartScopeHost, "b.example.com") if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem); !asked { t.Fatal("the restart in the window did not ask for a root pass") } if !slices.Equal(retried, []string{"a.example.com"}) || !slices.Equal(bounced, []string{"b.example.com"}) { t.Fatalf("retried %v, bounced %v; want the retry to a.example.com and the bounce of b.example.com only", retried, bounced) } } // A retry for a host the manager holds nothing for is logged as such, not // as a hold cleared. func TestRestartLegLogsARetryWithNothingHeld(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} mem, priv := retryMemory(t) mem.retryHost = func(string, string, string, uint64) bool { return false } bounce := func(h string) bool { t.Errorf("%s was bounced", h); return true } restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) askRetry(t, ctx, s, priv, "bab", "eleven", "a.example.com", "v2", "bk", 5) var logbuf bytes.Buffer log.SetOutput(&logbuf) defer log.SetOutput(os.Stderr) restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) got := logbuf.String() if !strings.Contains(got, "retry for a.example.com: nothing cleared") || strings.Contains(got, "hold is cleared") { t.Fatalf("log = %q", got) } } // Plan 9 (Astra), a console built from main before the host-retry log: its // signed, book-bound grant in the restart-scope log is verified and applied // with the same checks as one in the host-retry log, and an unsigned or // foreign one there is dropped with a line and clears nothing. An unchanged // log is not handled twice. func TestRestartLegAppliesALegacySignedRetryWithTheSameChecks(t *testing.T) { ctx, s := genKit(t) dd := t.TempDir() routes := []registry.Route{execRoute("a.example.com", "bab", "eleven"), execRoute("b.example.com", "bab", "eleven")} mem, priv := retryMemory(t) _, other, _ := ed25519.GenerateKey(nil) var retried []string mem.retryHost = func(h, b, bk string, e uint64) bool { retried = append(retried, fmt.Sprintf("%s %s %s %d", h, b, bk, e)) return true } bounce := func(h string) bool { t.Errorf("%s was bounced for a retry", h); return true } restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) // baseline legacySignedRetry(t, ctx, s, priv, "bab", "eleven", "b.example.com", "v2", "bk", 5) legacyRetry(t, ctx, s, "bab", "eleven", "a.example.com", "") legacySignedRetry(t, ctx, s, other, "bab", "eleven", "a.example.com", "v2", "bk", 5) var logbuf bytes.Buffer log.SetOutput(&logbuf) defer log.SetOutput(os.Stderr) if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem); asked { t.Fatal("a retry-only move asked for a root restart pass") } if !slices.Equal(retried, []string{"b.example.com v2 bk 5"}) { t.Fatalf("retried %v, want the legacy signed grant for b.example.com only", retried) } got := logbuf.String() if strings.Count(got, "agent: retry requested for a.example.com, dropped:") != 2 || !strings.Contains(got, "unsigned") { t.Fatalf("log = %q, want the unsigned and the foreign legacy entries dropped", got) } logbuf.Reset() restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) if len(retried) != 1 || strings.Contains(logbuf.String(), "retry") { t.Fatalf("an unchanged log was handled again: retried %v, log %q", retried, logbuf.String()) } } // Plan 9 (Astra), the ruling that grants are not seed-skipped: a grant // written before an agent restart, and not yet applied, is applied after // it, whichever log it rides, because the restart memory is rebuilt and a // spent or stale grant is a no-op under the generation rule. A restart // request written before the restart keeps today's seed-skip: it cycles // nothing. The grants are handled once. func TestARetryGrantWrittenBeforeAnAgentRestartIsApplied(t *testing.T) { ctx, s := genKit(t) reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } defer reg.Close() routes := []registry.Route{execRoute("a.example.com", "bab", "eleven"), execRoute("b.example.com", "bab", "eleven")} for _, rt := range routes { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } dd := t.TempDir() first, priv := retryMemory(t) first.retryHost = func(h, b, _ string, e uint64) bool { t.Errorf("the first agent applied %s %s %d", h, b, e) return true } restartSeed(ctx, reg, s, dd, first) // The grants and a restart land; the agent restarts before its next // serial move. askRetry(t, ctx, s, priv, "bab", "eleven", "a.example.com", "v2", "bk", 5) legacySignedRetry(t, ctx, s, priv, "bab", "eleven", "b.example.com", "v3", "bk", 6) askScoped(t, ctx, s, "bab", "eleven", release.RestartScopeHost, "a.example.com") second := newRestartMemory() second.pub = first.pub var retried []string second.retryHost = func(h, b, bk string, e uint64) bool { retried = append(retried, fmt.Sprintf("%s %s %s %d", h, b, bk, e)) return true } restartSeed(ctx, reg, s, dd, second) slices.Sort(retried) if !slices.Equal(retried, []string{"a.example.com v2 bk 5", "b.example.com v3 bk 6"}) { t.Fatalf("after the restart retried %v, want both grants written before it", retried) } bounce := func(h string) bool { t.Errorf("%s was bounced: restarted before the agent", h); return true } if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, second); asked { t.Fatal("the first move after the restart asked for a root pass") } if len(retried) != 2 { t.Fatalf("the grants were handled again: %v", retried) } }