settings-5 task 4: carlos instances list says why an instance is behind
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+467
−4
apimode.go+22 −0instances_cli.go+49 −1instances_reason_test.go+81 −0internal/console/apiinstances.go+105 −1internal/console/apiinstances_test.go+5 −2internal/console/updatesjoin_test.go+205 −0
diff --git a/apimode.go b/apimode.go| index f702d3d6e..00177a653 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -3307,6 +3307,28 @@ type apiInstanceRecord struct { |
| // for a host whose box reports none (CARLOS_SWAP off, or a dark box): |
| // the deploy watch then watches the URL as it always did. |
| Rollout *apiRollout `json:"rollout"` |
| + // Updates is the box's update report for the instance (the console's |
| + // update_report join); UpdatesSetting is the record's own "updates" |
| + // setting. The two keys differ because the list record already owns |
| + // "updates". SettingsDefault says the record and the app's policy ask |
| + // for today's behaviour, so a box that ignores settings is only worth a |
| + // word when something was asked for. |
| + Updates *apiUpdatesJoin `json:"update_report"` |
| + UpdatesSetting string `json:"updates"` |
| + SettingsDefault bool `json:"settings_default"` |
| +} |
| + |
| +// apiUpdatesJoin mirrors the console's updatesJoinView |
| +// (internal/console/apiinstances.go). |
| +type apiUpdatesJoin struct { |
| + Desired string `json:"desired"` |
| + Reason string `json:"reason"` |
| + Detail string `json:"detail"` |
| + Major *int `json:"major"` |
| + Baking string `json:"baking"` |
| + Pending uint64 `json:"pending"` |
| + CompletedFailed bool `json:"completed_failed"` |
| + Enforced bool `json:"enforced"` |
| } |
| // apiRollout mirrors the console's rolloutView (internal/console/apiinstances.go). |
diff --git a/instances_cli.go b/instances_cli.go| index ae595db8d..a147fc534 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -477,6 +477,54 @@ func rolloutReason(r *apiRollout) string { |
| return "" |
| } |
| +// updatesReason is the settings half of REASON (spec §5): the EFFECTIVE |
| +// reason the instance's box reports (Astra I1 #12), whether the record or the |
| +// app's default set it: manual, pinned, held at major N (the number from the |
| +// report), and the other reasons root reports, with a request waiting on bake |
| +// named beside the reason, never instead of it. A reason word from a newer box |
| +// renders nothing: updatewords.Text panics on an id nobody registered. |
| +func updatesReason(u *apiUpdatesJoin, settingsDefault bool) string { |
| + if u == nil { |
| + return "" |
| + } |
| + if !u.Enforced { |
| + if settingsDefault { |
| + return "" |
| + } |
| + return updatewords.Text("settings.cli.not-enforced-reason") |
| + } |
| + var r string |
| + switch u.Reason { |
| + case "major": |
| + n := 0 |
| + if u.Major != nil { |
| + n = *u.Major |
| + } |
| + r = fmt.Sprintf(updatewords.Text("settings.box.reason.major"), n, u.Detail) |
| + case "unknown", "new": |
| + r = updatewords.Text("settings.box.reason." + u.Reason) |
| + case "pin", "request", "manual", "failed": |
| + r = fmt.Sprintf(updatewords.Text("settings.box.reason."+u.Reason), u.Detail) |
| + } |
| + if u.Baking != "" { |
| + b := fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), u.Baking) |
| + if r == "" { |
| + return b |
| + } |
| + return r + "; " + b |
| + } |
| + return r |
| +} |
| + |
| +// reasonCell is the REASON column: a rollout reason first (a failure hold |
| +// outranks a preference), else the settings reason. |
| +func reasonCell(r apiInstanceRecord) string { |
| + if s := rolloutReason(r.Rollout); s != "" { |
| + return s |
| + } |
| + return updatesReason(r.Updates, r.SettingsDefault) |
| +} |
| + |
| func anyHeld(recs []apiInstanceRecord) bool { |
| for _, r := range recs { |
| if r.Rollout != nil && r.Rollout.HeldBy != "" { |
| @@ -512,7 +560,7 @@ func runInstancesList(args []string) { |
| } |
| fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, |
| installedCell(r), servingCell(r), rowHealth, upstreamsCell(r.Upstreams), humanBytes(r.DBBytes), |
| - r.CreatedAt, r.CreatedBy, rolloutReason(r.Rollout)) |
| + r.CreatedAt, r.CreatedBy, reasonCell(r)) |
| } |
| w.Flush() |
| // Spec §8.4: a held build is retried everywhere by `promote --retry`, or |
diff --git a/instances_reason_test.go b/instances_reason_test.go| new file mode 100644 |
| index 000000000..3f40bac5f |
| --- /dev/null |
| +++ b/instances_reason_test.go |
| @@ -0,0 +1,81 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/rolloutwords" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +// Astra I1 #12: the reason is the EFFECTIVE one the box reports, whether the |
| +// record set it or the app's default did; an inherited major hold names its |
| +// number from the report. |
| +func TestUpdatesReason(t *testing.T) { |
| + two := 2 |
| + tx := updatewords.Text |
| + for _, c := range []struct { |
| + u *apiUpdatesJoin |
| + settingsDefault bool |
| + want string |
| + }{ |
| + {nil, true, ""}, |
| + {&apiUpdatesJoin{Reason: "adopt", Enforced: true}, true, ""}, |
| + {&apiUpdatesJoin{Reason: "manual", Detail: "v1", Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.manual"), "v1")}, |
| + {&apiUpdatesJoin{Reason: "pin", Detail: "v3", Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.pin"), "v3")}, |
| + {&apiUpdatesJoin{Reason: "request", Detail: "v5", Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.request"), "v5")}, |
| + {&apiUpdatesJoin{Reason: "failed", Detail: "v6", Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.failed"), "v6")}, |
| + {&apiUpdatesJoin{Reason: "major", Detail: "v2", Major: &two, Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.major"), 2, "v2")}, |
| + {&apiUpdatesJoin{Reason: "major", Detail: "v2", Major: &two, Baking: "v4", Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.major"), 2, "v2") + "; " + fmt.Sprintf(tx("settings.box.reason.baking"), "v4")}, |
| + {&apiUpdatesJoin{Baking: "v4", Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.baking"), "v4")}, |
| + {&apiUpdatesJoin{Enforced: false}, false, tx("settings.cli.not-enforced-reason")}, |
| + {&apiUpdatesJoin{Enforced: false}, true, ""}, |
| + {&apiUpdatesJoin{Reason: "unknown", Enforced: true}, false, tx("settings.box.reason.unknown")}, |
| + {&apiUpdatesJoin{Reason: "new", Enforced: true}, false, tx("settings.box.reason.new")}, |
| + // A reason word from a newer box renders nothing, and never panics. |
| + {&apiUpdatesJoin{Reason: "from-the-future", Detail: "v9", Enforced: true}, false, ""}, |
| + } { |
| + if got := updatesReason(c.u, c.settingsDefault); got != c.want { |
| + t.Errorf("updatesReason(%+v, %v) = %q, want %q", c.u, c.settingsDefault, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// Astra I1 #11: the list record carries the report under update_report and |
| +// the record's own setting under updates; both decode. |
| +func TestTheListRecordDecodesBothUpdatesFields(t *testing.T) { |
| + var r apiInstanceRecord |
| + body := `{"host":"a.shop.example.com","updates":"manual","settings_default":false, |
| + "update_report":{"reason":"manual","detail":"v1","enforced":true}}` |
| + if err := json.Unmarshal([]byte(body), &r); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if r.UpdatesSetting != "manual" || r.Updates == nil || r.Updates.Reason != "manual" || r.SettingsDefault { |
| + t.Fatalf("decoded %+v / %+v / %v", r.UpdatesSetting, r.Updates, r.SettingsDefault) |
| + } |
| +} |
| + |
| +// The REASON cell: a rollout reason (a failure hold above all) outranks a |
| +// preference; with none, the settings reason shows. |
| +func TestReasonCellOrder(t *testing.T) { |
| + manual := &apiUpdatesJoin{Reason: "manual", Detail: "v1", Enforced: true} |
| + manualText := fmt.Sprintf(updatewords.Text("settings.box.reason.manual"), "v1") |
| + held := &apiRollout{Installed: "v2", Serving: "v1", Awake: true, HeldBy: "v2"} |
| + for _, c := range []struct { |
| + name string |
| + r apiInstanceRecord |
| + want string |
| + }{ |
| + {"held outranks manual", apiInstanceRecord{Rollout: held, Updates: manual}, fmt.Sprintf(rolloutwords.StateHeld, "v2")}, |
| + {"no rollout reason shows manual", apiInstanceRecord{Rollout: &apiRollout{Installed: "v1", Serving: "v1", Awake: true}, Updates: manual}, manualText}, |
| + {"no rollout at all shows manual", apiInstanceRecord{Updates: manual}, manualText}, |
| + {"nothing to say", apiInstanceRecord{}, ""}, |
| + {"an old box on a default record", apiInstanceRecord{Updates: &apiUpdatesJoin{}, SettingsDefault: true}, ""}, |
| + {"an old box on a held record", apiInstanceRecord{Updates: &apiUpdatesJoin{}}, updatewords.Text("settings.cli.not-enforced-reason")}, |
| + } { |
| + if got := reasonCell(c.r); got != c.want { |
| + t.Errorf("%s: reasonCell = %q, want %q", c.name, got, c.want) |
| + } |
| + } |
| +} |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 69198c09b..dd75bef58 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -200,6 +200,16 @@ type instanceListRecord struct { |
| // Absent when no fresh box reports one: a box from before plan 5, one |
| // with CARLOS_SWAP off, or one gone dark. The deploy watch reads it. |
| Rollout *rolloutView `json:"rollout,omitempty"` |
| + // UpdateReport is this host's update report as its box last gave it |
| + // (status.BoxMetrics.Updates and UpdateSettings), joined under the same |
| + // staleness and tenant rules as Rollout. It is named update_report |
| + // because the embedded record already owns "updates" (its own setting). |
| + UpdateReport *updatesJoinView `json:"update_report,omitempty"` |
| + // SettingsDefault says the record and the app's policy together ask for |
| + // today's behaviour (provision.EffectiveUpdates(...).Default()), so the |
| + // CLI can tell an instance on a box that ignores settings from one that |
| + // never asked for any. |
| + SettingsDefault bool `json:"settings_default"` |
| } |
| // instanceHealth computes each record host's health word the same way the |
| @@ -340,6 +350,52 @@ type boxJoin struct { |
| upstreams map[string][]status.UpstreamState |
| dbBytes map[string]int64 |
| rollouts map[string]rolloutView |
| + updates map[string]updatesJoinView |
| +} |
| + |
| +// updatesJoinView is one host's update report as a member sees it. account |
| +// and app are the tenant the SAME box's rollout report gives for the host |
| +// (status.HostRollout.Account and .App), absent when the box has no rollout |
| +// report for it; platform says the box is one of the platform's own. The |
| +// report itself (status.HostUpdates) names no tenant, so these two decide |
| +// whether it may be joined (update). |
| +type updatesJoinView struct { |
| + Desired string `json:"desired,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + Detail string `json:"detail,omitempty"` |
| + Baking string `json:"baking,omitempty"` |
| + Major *int `json:"major,omitempty"` |
| + Pending uint64 `json:"pending,omitempty"` |
| + Completed uint64 `json:"completed,omitempty"` |
| + CompletedFailed bool `json:"completed_failed,omitempty"` |
| + // Enforced says the box applies update settings at all |
| + // (status.BoxMetrics.UpdateSettings); a box from before them reports |
| + // nothing, and an empty view must not read as "following its channel". |
| + Enforced bool `json:"enforced"` |
| + account, app string |
| + platform bool |
| +} |
| + |
| +// update is the join's answer for host, or nil unless the report may be this |
| +// account's and this app's. The cross-tenant rule is rollout's: a box's |
| +// report about a host never surfaces on another tenant's list. A view whose |
| +// box names a tenant for the host must name this one; a view whose box names |
| +// none is accepted only from a platform box, whose hosts are unique and which |
| +// the caller has filtered to its own account and app's records. A customer |
| +// fleet box that names no tenant is refused. |
| +func (j boxJoin) update(host, acct, app string) *updatesJoinView { |
| + v, ok := j.updates[host] |
| + if !ok { |
| + return nil |
| + } |
| + if v.account != "" || v.app != "" { |
| + if v.account != acct || v.app != app { |
| + return nil |
| + } |
| + } else if !v.platform { |
| + return nil |
| + } |
| + return &v |
| } |
| // rolloutView is one host's rollout as a member sees it. account and app are |
| @@ -554,10 +610,46 @@ func joinBoxes(boxes []status.Box, now time.Time) boxJoin { |
| j.dbBytes[host] = n |
| } |
| j.foldRollout(b, true) |
| + j.foldUpdates(b, true, true) |
| } |
| return j |
| } |
| +// foldUpdates adds box b's update report to the join, under foldRollout's |
| +// override rule (a fleet box never replaces a host a platform box already |
| +// reported). Every host the box serves gets a view saying whether the box |
| +// enforces settings; the hosts it reports on carry the report. The tenant is |
| +// whatever the same box's rollout report names for the host. |
| +func (j *boxJoin) foldUpdates(b status.Box, override, platform bool) { |
| + m := b.Metrics |
| + enforced := m.UpdateSettings >= status.UpdateSettingsProtocol |
| + if j.updates == nil { |
| + j.updates = map[string]updatesJoinView{} |
| + } |
| + put := func(host string) { |
| + if _, had := j.updates[host]; had && !override { |
| + return |
| + } |
| + v := updatesJoinView{Enforced: enforced, platform: platform} |
| + if u, ok := m.Updates[host]; ok { |
| + v.Desired, v.Reason, v.Detail, v.Baking, v.Major = u.Desired, u.Reason, u.Detail, u.Baking, u.Major |
| + v.Pending, v.Completed, v.CompletedFailed = u.Pending, u.Completed, u.CompletedFailed |
| + } |
| + if m.Rollout != nil { |
| + if hr, ok := m.Rollout.Hosts[host]; ok { |
| + v.account, v.app = hr.Account, hr.App |
| + } |
| + } |
| + j.updates[host] = v |
| + } |
| + for host := range m.RouteVersions { |
| + put(host) |
| + } |
| + for host := range m.Updates { |
| + put(host) |
| + } |
| +} |
| + |
| // foldRollout adds box b's rollout report to the join. override false keeps |
| // a host the join already has: the caller folds platform boxes first, and a |
| // fleet box's word about a host never replaces a platform box's. |
| @@ -627,6 +719,7 @@ func (j *boxJoin) joinFleetRollouts(boxes []status.Box, now time.Time) { |
| continue |
| } |
| j.foldRollout(b, false) |
| + j.foldUpdates(b, false, false) |
| } |
| } |
| @@ -667,6 +760,15 @@ func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) { |
| } |
| join, joinComplete := s.loadRolloutJoin(ctx, acct.Sqid, time.Now(), relevant) |
| served := loadEdgesJoin(ctx, s.cfg.Releases) |
| + // The app's update policy, read once: an absent manifest is no policy, |
| + // and a read error is logged and treated the same. The list never fails |
| + // for it; SettingsDefault is then computed from the record alone. |
| + var policy *provision.UpdatePolicy |
| + if m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app); err == nil { |
| + policy = m.Updates |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("console: api instances list %s/%s: update policy: %v; settings_default computed from the records alone", acct.Sqid, app, err) |
| + } |
| params := r.URL.Query() |
| q := parseInstanceQuery(params) |
| // The default stays the whole cap, so a client that sends no limit gets |
| @@ -697,7 +799,9 @@ func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) { |
| matched = append(matched, instanceListRecord{Record: rec, |
| StagedVersion: join.versions[rec.Host], Health: h, Upstreams: join.upstreams[rec.Host], |
| ServedBy: served[rec.Host], Gated: rec.Gate != "", DBBytes: join.dbSize(rec.Host), |
| - Rollout: join.rollout(rec.Host, acct.Sqid, app)}) |
| + Rollout: join.rollout(rec.Host, acct.Sqid, app), |
| + UpdateReport: join.update(rec.Host, acct.Sqid, app), |
| + SettingsDefault: provision.EffectiveUpdates(rec, policy).Default()}) |
| } |
| total := len(matched) |
| // provision.ListRecords returns records host-sorted and nothing above |
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go| index c90473faa..dcce9d779 100644 |
| --- a/internal/console/apiinstances_test.go |
| +++ b/internal/console/apiinstances_test.go |
| @@ -908,8 +908,11 @@ func TestAPIInstanceListHealthAndFilters(t *testing.T) { |
| // "gated" joined health and staged_version when the gate landed: the |
| // hash itself is shadowed out of the wire (recordView), so what a |
| // list gains is the one bit a member may know. |
| - if !slices.Equal(extra, []string{"gated", "health", "staged_version"}) { |
| - t.Errorf("%s carries extra keys %v, want exactly gated, health and staged_version", rec.Host, extra) |
| + // "settings_default" joined them with the update settings (plan 5): |
| + // the one bit the CLI needs to tell an instance on a box that ignores |
| + // settings from one that never asked for any. |
| + if !slices.Equal(extra, []string{"gated", "health", "settings_default", "staged_version"}) { |
| + t.Errorf("%s carries extra keys %v, want exactly gated, health, settings_default and staged_version", rec.Host, extra) |
| } |
| } |
| } |
diff --git a/internal/console/updatesjoin_test.go b/internal/console/updatesjoin_test.go| new file mode 100644 |
| index 000000000..7e00dd6e9 |
| --- /dev/null |
| +++ b/internal/console/updatesjoin_test.go |
| @@ -0,0 +1,205 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +func updatesBox(label string, enforce bool, hosts map[string]status.HostUpdates, routes map[string]string) status.Box { |
| + m := &status.BoxMetrics{Updates: hosts, RouteVersions: routes} |
| + if enforce { |
| + m.UpdateSettings = status.UpdateSettingsProtocol |
| + } |
| + return status.Box{Label: label, GeneratedAt: time.Now(), Metrics: m} |
| +} |
| + |
| +func TestUpdatesJoinCarriesTheReason(t *testing.T) { |
| + two := 2 |
| + j := joinBoxes([]status.Box{updatesBox("ie-1", true, |
| + map[string]status.HostUpdates{"a.shop.example.com": {Desired: "v1", Reason: "major", Detail: "v2", Baking: "v4", Major: &two, |
| + Pending: 7, Completed: 6, CompletedFailed: true}}, |
| + map[string]string{"a.shop.example.com": "v1"})}, time.Now()) |
| + u := j.update("a.shop.example.com", "bab", "shop") |
| + if u == nil || !u.Enforced || u.Desired != "v1" || u.Reason != "major" || u.Detail != "v2" || u.Baking != "v4" || |
| + u.Major == nil || *u.Major != 2 || u.Pending != 7 || u.Completed != 6 || !u.CompletedFailed { |
| + t.Fatalf("join = %+v", u) |
| + } |
| +} |
| + |
| +// Review Focus 3: a box's report about a host never surfaces on another |
| +// tenant's list. The box's own rollout report names the host's tenant. |
| +func TestUpdatesJoinRefusesAnotherTenantsReport(t *testing.T) { |
| + b := updatesBox("ie-1", true, |
| + map[string]status.HostUpdates{"a.shop.example.com": {Reason: "manual"}}, nil) |
| + b.Metrics.Rollout = &status.RolloutReport{Hosts: map[string]status.HostRollout{ |
| + "a.shop.example.com": {Account: "cdf", App: "blog", Installed: "v1"}}} |
| + j := joinBoxes([]status.Box{b}, time.Now()) |
| + if u := j.update("a.shop.example.com", "bab", "shop"); u != nil { |
| + t.Fatalf("another tenant's report was joined: %+v", u) |
| + } |
| + if u := j.update("a.shop.example.com", "bab", "blog"); u != nil { |
| + t.Fatalf("another account's report was joined on a matching app: %+v", u) |
| + } |
| + if u := j.update("a.shop.example.com", "cdf", "blog"); u == nil || u.Reason != "manual" { |
| + t.Fatalf("the named tenant lost its own report: %+v", u) |
| + } |
| +} |
| + |
| +func TestAnOldBoxIsNotEnforced(t *testing.T) { |
| + j := joinBoxes([]status.Box{updatesBox("ie-1", false, nil, map[string]string{"a.shop.example.com": "v1"})}, time.Now()) |
| + if u := j.update("a.shop.example.com", "bab", "shop"); u == nil || u.Enforced { |
| + t.Fatalf("an old box's host = %+v; want a view saying not enforced", u) |
| + } |
| +} |
| + |
| +// A fleet box's report names no tenant unless its rollout report does, and a |
| +// customer's box is not trusted to speak for a host on its own say-so. |
| +func TestAFleetBoxReportWithoutATenantIsRefused(t *testing.T) { |
| + now := time.Now() |
| + fleet := updatesBox("fleet", true, map[string]status.HostUpdates{"h": {Reason: "manual"}}, map[string]string{"h": "v1"}) |
| + var j boxJoin |
| + j.joinFleetRollouts([]status.Box{fleet}, now) |
| + if u := j.update("h", "bab", "shop"); u != nil { |
| + t.Fatalf("a tenantless fleet report was joined: %+v", u) |
| + } |
| + // With the tenant from its own rollout report it is this tenant's, and |
| + // only this tenant's. |
| + fleet.Metrics.Rollout = &status.RolloutReport{Hosts: map[string]status.HostRollout{"h": {Account: "bab", App: "shop", Installed: "v1"}}} |
| + j = boxJoin{} |
| + j.joinFleetRollouts([]status.Box{fleet}, now) |
| + if u := j.update("h", "bab", "shop"); u == nil || u.Reason != "manual" { |
| + t.Fatalf("a fleet report naming this tenant = %+v", u) |
| + } |
| + if u := j.update("h", "cdf", "shop"); u != nil { |
| + t.Fatalf("a fleet report naming bab was joined for cdf: %+v", u) |
| + } |
| +} |
| + |
| +// A platform box's word wins over a fleet box's for the same host, as the |
| +// rollout join's does. |
| +func TestAFleetBoxNeverOverridesAPlatformBoxUpdates(t *testing.T) { |
| + now := time.Now() |
| + view := func(label, reason string) status.Box { |
| + b := updatesBox(label, true, map[string]status.HostUpdates{"h": {Reason: reason}}, map[string]string{"h": "v1"}) |
| + b.Metrics.Rollout = &status.RolloutReport{Hosts: map[string]status.HostRollout{"h": {Account: "bab", App: "shop", Installed: "v1"}}} |
| + return b |
| + } |
| + j := joinBoxes([]status.Box{view("platform", "manual")}, now) |
| + j.joinFleetRollouts([]status.Box{view("fleet", "pin")}, now) |
| + if u := j.update("h", "bab", "shop"); u == nil || u.Reason != "manual" { |
| + t.Fatalf("h = %+v, want the platform box's report", u) |
| + } |
| + var empty boxJoin |
| + empty.joinFleetRollouts([]status.Box{view("fleet", "pin")}, now) |
| + if u := empty.update("h", "bab", "shop"); u == nil || u.Reason != "pin" { |
| + t.Fatalf("h on an empty join = %+v, want the fleet box's report", u) |
| + } |
| +} |
| + |
| +func TestAStaleBoxReportsNothing(t *testing.T) { |
| + b := updatesBox("ie-1", true, map[string]status.HostUpdates{"h": {Reason: "manual"}}, map[string]string{"h": "v1"}) |
| + b.GeneratedAt = time.Now().Add(-24 * time.Hour) |
| + if u := joinBoxes([]status.Box{b}, time.Now()).update("h", "bab", "shop"); u != nil { |
| + t.Fatalf("a stale box's report was joined: %+v", u) |
| + } |
| +} |
| + |
| +// The list door: update_report rides beside the record's own updates key, |
| +// the other tenant's host carries none, and settings_default tells an |
| +// untouched record from one that asked for a hold. |
| +func TestAPIInstanceListCarriesTheUpdateReport(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-updates") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + u := instancesURL(fx, "bab", "titogo") |
| + for _, h := range []string{"acme.go.tito.io", "beta.go.tito.io"} { |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, fmt.Sprintf(`{"host":%q,"region":"ie"}`, h)); code != http.StatusCreated { |
| + t.Fatalf("create %s = %d %s", h, code, out) |
| + } |
| + } |
| + box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| + box.Metrics = &status.BoxMetrics{ |
| + UpdateSettings: status.UpdateSettingsProtocol, |
| + RouteVersions: map[string]string{"acme.go.tito.io": "v1", "beta.go.tito.io": "v9"}, |
| + Updates: map[string]status.HostUpdates{ |
| + "acme.go.tito.io": {Desired: "v1", Reason: "manual", Detail: "v1"}, |
| + "beta.go.tito.io": {Reason: "pin", Detail: "v9"}, |
| + }, |
| + Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{ |
| + "acme.go.tito.io": {Account: "bab", App: "titogo", Installed: "v1"}, |
| + // A report naming another app under this host: never joined. |
| + "beta.go.tito.io": {Account: "bab", App: "other", Installed: "v9"}, |
| + }}, |
| + } |
| + if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { |
| + t.Fatal(err) |
| + } |
| + type listed struct { |
| + Host string `json:"host"` |
| + Updates string `json:"updates"` |
| + SettingsDefault bool `json:"settings_default"` |
| + UpdateReport *updatesJoinView `json:"update_report"` |
| + } |
| + list := func() []listed { |
| + code, _, out := rawReq(t, http.MethodGet, u, token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("list = %d %s", code, out) |
| + } |
| + var l struct { |
| + Instances []listed `json:"instances"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &l); err != nil { |
| + t.Fatal(err) |
| + } |
| + return l.Instances |
| + } |
| + for _, r := range list() { |
| + switch r.Host { |
| + case "acme.go.tito.io": |
| + if r.UpdateReport == nil || r.UpdateReport.Reason != "manual" || !r.UpdateReport.Enforced { |
| + t.Errorf("acme update_report = %+v", r.UpdateReport) |
| + } |
| + case "beta.go.tito.io": |
| + if r.UpdateReport != nil { |
| + t.Errorf("beta joined another app's report: %+v", r.UpdateReport) |
| + } |
| + if !r.SettingsDefault || r.Updates != "" { |
| + t.Errorf("beta never asked for settings: default=%v updates=%q", r.SettingsDefault, r.Updates) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// The app's policy reaches settings_default: a manual default makes every |
| +// record a non-default one without the record saying so itself. |
| +func TestAPIInstanceListSettingsDefaultFollowsThePolicy(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-updates-default") |
| + m := titoManifest() |
| + m.Updates = &provision.UpdatePolicy{DefaultUpdates: "manual"} |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + code, _, out := rawReq(t, http.MethodGet, u, token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("list = %d %s", code, out) |
| + } |
| + var l struct { |
| + Instances []struct { |
| + SettingsDefault bool `json:"settings_default"` |
| + } `json:"instances"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &l); err != nil || len(l.Instances) != 1 { |
| + t.Fatalf("decode %v: %s", err, out) |
| + } |
| + if l.Instances[0].SettingsDefault { |
| + t.Fatalf("a manual app default read as default: %s", out) |
| + } |
| +} |