package host import ( "errors" "testing" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) func TestTheReportCarriesRootsDecision(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Desired: "v1", Reason: "major", Detail: "v1", Completed: 2, Baking: "v4"}); err != nil { t.Fatal(err) } two := 2 if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &two, InstallRequest: 3}.Column()); err != nil { t.Fatal(err) } got := updatesReport(reg, b.optDir)["a.example.com"] if got.Desired != "v1" || got.Reason != "major" || got.Detail != "v1" || got.Pending != 3 || got.Completed != 2 || got.Baking != "v4" || got.Major == nil || *got.Major != 2 { t.Fatalf("report = %+v; want root's decision, the baking head beside it, and the effective major", got) } rep, err := updatesReader(reg, b.optDir)("a.example.com") if err != nil || rep.Desired != "v1" || rep.Reason != "major" || rep.Settings.Major == nil { t.Fatalf("socket report = %+v, %v", rep, err) } } // Astra r1 (R18): each entry names the instance's tenant from the box's own // route, as the rollout report does (release.RouteAccount), so the console // never needs the rollout report to tell whose report it is. func TestTheReportNamesTheTenant(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) if err := reg.Upsert(registry.Route{Host: "b.example.com", Kind: "instance", Account: "bab", App: "shop", Hibernate: true, Socket: "/run/carlos/b.sock"}); err != nil { t.Fatal(err) } // Root names the tenant it decided for (R22); the report checks it. acct := map[string][2]string{"a.example.com": {release.OpsAccount, "km"}, "b.example.com": {"bab", "shop"}} for _, h := range []string{"a.example.com", "b.example.com"} { if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: h, Account: acct[h][0], App: acct[h][1], Desired: "v1", Reason: "manual"}); err != nil { t.Fatal(err) } } got := updatesReport(reg, b.optDir) if u := got["b.example.com"]; u.Account != "bab" || u.App != "shop" || u.Reason != "manual" { t.Fatalf("b = %+v; want tenant bab/shop", u) } // A route with no account of its own is the platform's, as everywhere. if u := got["a.example.com"]; u.Account != release.OpsAccount || u.App != "km" { t.Fatalf("a = %+v; want tenant %s/km", u, release.OpsAccount) } } func TestEveryBoxOfThisBinaryReportsTheProtocol(t *testing.T) { if m := boxSettingsMetrics(); m != status.UpdateSettingsProtocol { t.Fatalf("this binary reports %d", m) } // Astra I3 #1: a box whose build-state service failed does not claim to // enforce settings, so the console keeps refusing them. buildStatesFailed.Store(true) t.Cleanup(func() { buildStatesFailed.Store(false) }) if m := boxSettingsMetrics(); m != 0 { t.Fatalf("a box with no build-state service reports %d", m) } } // The agent's note of a failed build-state service is what the report reads: // a box with no replica bucket (nil, nil) has nothing to enforce and reports // the protocol; one whose store failed to start reports 0. func TestTheProtocolFollowsTheBuildStateService(t *testing.T) { t.Cleanup(func() { buildStatesFailed.Store(false) }) noteBuildStatesInit(nil) if m := boxSettingsMetrics(); m != status.UpdateSettingsProtocol { t.Fatalf("a box with no replica bucket reports %d, want the protocol", m) } noteBuildStatesInit(errors.New("no regions")) if m := boxSettingsMetrics(); m != 0 { t.Fatalf("a box whose build-state store failed reports %d, want 0", m) } noteBuildStatesInit(nil) if m := boxSettingsMetrics(); m != status.UpdateSettingsProtocol { t.Fatalf("a recovered start reports %d, want the protocol", m) } } func TestTheStatusProbesCarryTheProtocolAndTheDecisions(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Desired: "v1", Reason: "manual"}); err != nil { t.Fatal(err) } var p status.Probes fillUpdateProbes(&p, reg, b.optDir) if p.UpdateSettings != status.UpdateSettingsProtocol || p.Updates["a.example.com"].Reason != "manual" { t.Fatalf("probes = %d, %+v", p.UpdateSettings, p.Updates) } } // A newer console may add a Control name this binary does not know; the // report must still be made, with the hold it carries (R-SETTINGS-UNREADABLE). func TestTheReportSurvivesAnUnknownControlName(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Desired: "v1", Reason: "manual"}); err != nil { t.Fatal(err) } col := `{"updates":"manual","channel":"stable","control":["updates","from-the-future"]}` if err := reg.SetUpdateSettings("a.example.com", col); err != nil { t.Fatal(err) } if got := updatesReport(reg, b.optDir)["a.example.com"]; got.Reason != "manual" { t.Fatalf("report = %+v; want the decision despite the unknown control name", got) } rep, err := updatesReader(reg, b.optDir)("a.example.com") if err != nil || rep.Reason != "manual" || rep.Settings.Updates != release.UpdatesManual || rep.Settings.Channel != "stable" { t.Fatalf("socket report = %+v, %v", rep, err) } if len(rep.MayChange) != 1 || rep.MayChange[0] != release.ControlUpdates { t.Fatalf("may change = %v; want only the known name", rep.MayChange) } } // A route with the default settings and no decision has nothing to say; the // serving build is added to the socket report; the channel fallback stays. func TestTheReaderKeepsTheChannelFallbackAndAddsTheServingBuild(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) if got := updatesReport(reg, b.optDir); got != nil { t.Fatalf("report = %+v; want none for a default instance root has not decided", got) } rt, _ := reg.Get("a.example.com") rt.Channel = "beta" if err := reg.Upsert(*rt); err != nil { t.Fatal(err) } if err := reg.SetServingVersion("a.example.com", "v7"); err != nil { t.Fatal(err) } rep, err := updatesReader(reg, b.optDir)("a.example.com") if err != nil || rep.Settings.Channel != "beta" || rep.Serving != "v7" { t.Fatalf("socket report = %+v, %v; want the route's channel and its serving build", rep, err) } } // Consumed.Detail for reason unknown can be a raw parse error; it must not // reach a member. A build version is the one detail that may. func TestAnUnknownReasonReportsNoRawDetail(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) raw := `update settings: invalid character 'x' looking for beginning of value` for _, c := range []struct{ detail, want string }{{raw, ""}, {"v3", "v3"}} { if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Reason: "unknown", Detail: c.detail}); err != nil { t.Fatal(err) } got := updatesReport(reg, b.optDir)["a.example.com"] if got.Reason != "unknown" || got.Detail != c.want { t.Fatalf("detail %q: report = %+v, want detail %q", c.detail, got, c.want) } } if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Reason: "major", Detail: "not a version!"}); err != nil { t.Fatal(err) } if got := updatesReport(reg, b.optDir)["a.example.com"]; got.Detail != "not a version!" { t.Fatalf("a reason other than unknown keeps root's detail, got %+v", got) } } // The watermark joins the consumed file and the build state only when the // build-state copy has been read. Unread, the report is the consumed file's // alone, and a request is pending only above that. func TestAnUnreadBuildStateReportsTheConsumedFileAlone(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Reason: "request", Completed: 2}); err != nil { t.Fatal(err) } set := func(req uint64) { t.Helper() if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesAuto, InstallRequest: req}.Column()); err != nil { t.Fatal(err) } } setCopy := func(c release.BuildStateCopy) { t.Helper() if err := reg.SetBuildState("a.example.com", c.Column()); err != nil { t.Fatal(err) } } // Unread copy that nevertheless holds a higher completed id: not used. setCopy(release.BuildStateCopy{Known: false, State: &release.BuildState{Schema: 1, Host: "a.example.com", Completed: 9, CompletedFailed: true}}) set(5) got := updatesReport(reg, b.optDir)["a.example.com"] if got.Completed != 2 || got.Pending != 5 || got.CompletedFailed { t.Fatalf("unread: report = %+v; want completed 2 from the consumed file, pending 5", got) } set(2) if got := updatesReport(reg, b.optDir)["a.example.com"]; got.Pending != 0 || got.Completed != 2 { t.Fatalf("unread, request at the watermark: report = %+v; want nothing pending", got) } // Read: the build state's higher completed id counts. setCopy(release.BuildStateCopy{Known: true, State: &release.BuildState{Schema: 1, Host: "a.example.com", Completed: 9, CompletedFailed: true}}) set(5) got = updatesReport(reg, b.optDir)["a.example.com"] if got.Completed != 9 || got.Pending != 0 || !got.CompletedFailed { t.Fatalf("read: report = %+v; want completed 9, failed, nothing pending", got) } } // Astra r2 N1 (ruling R22): the consumed file is keyed by hostname only. A // hostname that moves from tenant A to tenant B on one box keeps A's file // until root decides for B; the report labels its entry with B's route, so // it must carry none of A's decision. B's own column (its major) stays. func TestAMovedHostnameReportsNoPreviousTenantsDecision(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) const host = "a.example.com" three, five := 3, 5 // Tenant A's route, and root's real write for it. a := registry.Route{Host: host, Kind: "instance", Account: "cdf", App: "shop", Hibernate: true, Socket: "/run/carlos/a.sock"} if err := reg.Upsert(a); err != nil { t.Fatal(err) } ua := release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &five, InstallRequest: 6} if err := reg.SetUpdateSettings(host, ua.Column()); err != nil { t.Fatal(err) } a.UpdateSettings = ua.Column() in := release.TargetInput{Route: a, Settings: ua, Pointer: &release.ChannelPointer{Channel: "stable", Version: "v-a-head"}, HeadHeld: "baking", Build: release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{Schema: 1, Host: host}}} if err := release.WriteConsumed(b.optDir, release.NextConsumed(in, release.TargetDecision{Version: "v-a-pin", Reason: release.ReasonPin, Detail: "v-a-pin", Fail: 4})); err != nil { t.Fatal(err) } if got := updatesReport(reg, b.optDir)[host]; got.Desired != "v-a-pin" || got.Baking != "v-a-head" || got.Completed != 4 { t.Fatalf("A's own report = %+v; want A's decision (the fixture is wrong otherwise)", got) } // The route is retired (claims off: the row is deleted) and B's arrives on // the same hostname before root has decided for it. if err := reg.Delete(host); err != nil { t.Fatal(err) } if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "dee", App: "blog", Hibernate: true, Socket: "/run/carlos/a.sock"}); err != nil { t.Fatal(err) } ub := release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable", Major: &three, InstallRequest: 2} if err := reg.SetUpdateSettings(host, ub.Column()); err != nil { t.Fatal(err) } if err := reg.SetBuildState(host, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{Schema: 1, Host: host, Completed: 1}}.Column()); err != nil { t.Fatal(err) } got, ok := updatesReport(reg, b.optDir)[host] if !ok { t.Fatal("B's route has settings of its own and no entry") } if got.Account != "dee" || got.App != "blog" { t.Fatalf("B's entry names %s/%s, want dee/blog", got.Account, got.App) } if got.Desired != "" || got.Reason != "" || got.Detail != "" || got.Baking != "" || got.Completed != 0 || got.CompletedFailed || got.Pending != 0 { t.Fatalf("B's entry carries a decision root did not make for B: %+v", got) } if got.Major == nil || *got.Major != 3 { t.Fatalf("B's entry major = %v, want B's own 3", got.Major) } rep, err := updatesReader(reg, b.optDir)(host) if err != nil || rep.Desired != "" || rep.Reason != "" { t.Fatalf("B's socket report = %+v, %v; want none of A's decision", rep, err) } if rep.Settings.Updates != release.UpdatesManual || rep.Settings.Major == nil || *rep.Settings.Major != 3 { t.Fatalf("B's socket report settings = %+v, want B's own", rep.Settings) } // Root's next pass decides for B, over A's file: now the decision is B's. b2 := registry.Route{Host: host, Kind: "instance", Account: "dee", App: "blog", UpdateSettings: ub.Column()} prev, err := release.ReadConsumed(b.optDir, host) if err != nil { t.Fatal(err) } inB := release.TargetInput{Route: b2, Settings: ub, Consumed: prev, Build: release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{Schema: 1, Host: host, Completed: 1}}} if err := release.WriteConsumed(b.optDir, release.NextConsumed(inB, release.TargetDecision{Version: "v-b", Reason: release.ReasonManual, Detail: "v-b"})); err != nil { t.Fatal(err) } got = updatesReport(reg, b.optDir)[host] if got.Account != "dee" || got.App != "blog" || got.Desired != "v-b" || got.Reason != "manual" || got.Detail != "v-b" { t.Fatalf("B's entry after root decided for B = %+v; want B's decision", got) } if rep, err := updatesReader(reg, b.optDir)(host); err != nil || rep.Desired != "v-b" || rep.Reason != "manual" { t.Fatalf("B's socket report after root decided for B = %+v, %v", rep, err) } } // R22: a consumed file from before root named the tenant is decided for no // one: the entry names the route's tenant and carries the route's own major, // and nothing that came from the file or the build state. func TestAnUnattributedConsumedFileReportsNoDecision(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) const host = "a.example.com" if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: host, Desired: "v1", Reason: "pin", Detail: "v1", Baking: "v2", Completed: 3, CompletedFailed: true}); err != nil { t.Fatal(err) } two := 2 if err := reg.SetUpdateSettings(host, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &two, InstallRequest: 7}.Column()); err != nil { t.Fatal(err) } if err := reg.SetBuildState(host, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{Schema: 1, Host: host, Completed: 5}}.Column()); err != nil { t.Fatal(err) } got := updatesReport(reg, b.optDir)[host] if got.Account != release.OpsAccount || got.App != "km" || got.Major == nil || *got.Major != 2 { t.Fatalf("entry = %+v; want tenant %s/km and the route's own major", got, release.OpsAccount) } if got.Desired != "" || got.Reason != "" || got.Detail != "" || got.Baking != "" || got.Completed != 0 || got.CompletedFailed || got.Pending != 0 { t.Fatalf("an unattributed file's decision was reported: %+v", got) } if rep, err := updatesReader(reg, b.optDir)(host); err != nil || rep.Desired != "" || rep.Reason != "" { t.Fatalf("socket report = %+v, %v; want no decision from an unattributed file", rep, err) } // A route with default settings and only an unattributed file has // nothing of its own to report, as before root decides for it. if err := reg.SetUpdateSettings(host, ""); err != nil { t.Fatal(err) } if got := updatesReport(reg, b.optDir); got != nil { t.Fatalf("report = %+v; want none for a default route with no decision of its own", got) } } // R22: a file that names the route's tenant is reported in full; one that // names the same account and another app, or another account and the same // app, is not. func TestAConsumedFileIsReportedOnlyForTheTenantItNames(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) const host = "a.example.com" if err := reg.SetUpdateSettings(host, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", InstallRequest: 7}.Column()); err != nil { t.Fatal(err) } write := func(account, app string) { t.Helper() if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: host, Account: account, App: app, Desired: "v1", Reason: "pin", Detail: "v1", Baking: "v2", Completed: 3, CompletedFailed: true}); err != nil { t.Fatal(err) } } write(release.OpsAccount, "km") got := updatesReport(reg, b.optDir)[host] if got.Desired != "v1" || got.Reason != "pin" || got.Detail != "v1" || got.Baking != "v2" || got.Completed != 3 || !got.CompletedFailed || got.Pending != 7 { t.Fatalf("matching file: entry = %+v; want the whole decision", got) } if rep, err := updatesReader(reg, b.optDir)(host); err != nil || rep.Desired != "v1" || rep.Reason != "pin" { t.Fatalf("matching file: socket report = %+v, %v", rep, err) } for _, other := range [][2]string{{release.OpsAccount, "shop"}, {"cdf", "km"}, {"", "km"}, {release.OpsAccount, ""}} { write(other[0], other[1]) got := updatesReport(reg, b.optDir)[host] if got.Desired != "" || got.Reason != "" || got.Detail != "" || got.Baking != "" || got.Completed != 0 || got.CompletedFailed || got.Pending != 0 { t.Fatalf("file for %v: entry = %+v; want no decision", other, got) } } }