carlos / platform Public

settings-5 task 4: carlos instances list says why an instance is behind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 80a18a6baec5b6392de49ee25b688f843f7da770 parent 5b9c883
6 files changed, +467 −4
  • apimode.go +22 −0
  • instances_cli.go +49 −1
  • instances_reason_test.go +81 −0
  • internal/console/apiinstances.go +105 −1
  • internal/console/apiinstances_test.go +5 −2
  • internal/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)
+ }
+}