zdd-6 task 9: carlos instances list shows installed, serving and why
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+211
−5
apimode.go+31 −3instances_cli.go+69 −2instances_rollout_test.go+111 −0
diff --git a/apimode.go b/apimode.go| index 8a813d42e..2464d1f82 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -407,6 +407,9 @@ type apiPromoteResult struct { |
| // yet), so deploy needs this fact on the no-op path too, not only on a |
| // fresh promotion. |
| Held bool `json:"held"` |
| + // HeldUntil is when a held promotion lifts, RFC 3339, from a console |
| + // that knows; "" from an older one. |
| + HeldUntil string `json:"held_until"` |
| } |
| type apiShipResult struct { |
| @@ -973,13 +976,14 @@ func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, chann |
| } |
| if status == http.StatusOK { |
| var res struct { |
| - Result string `json:"result"` |
| - Held bool `json:"held"` |
| + Result string `json:"result"` |
| + Held bool `json:"held"` |
| + HeldUntil string `json:"held_until"` |
| } |
| if err := json.Unmarshal(body, &res); err != nil { |
| return apiPromoteResult{}, fmt.Errorf("promote: unreadable response: %w", err) |
| } |
| - return apiPromoteResult{NoOp: res.Result == "noop", Held: res.Held}, nil |
| + return apiPromoteResult{NoOp: res.Result == "noop", Held: res.Held, HeldUntil: res.HeldUntil}, nil |
| } |
| if status == http.StatusAccepted { |
| return apiPromoteResult{}, proposedFromBody(body) |
| @@ -3296,6 +3300,30 @@ type apiInstanceRecord struct { |
| // them, and then the CLI prints today's line. |
| RegionName string `json:"region_name,omitempty"` |
| RegionFrom string `json:"region_from,omitempty"` |
| + // Rollout is the instance's rollout state from its box (zero-downtime |
| + // spec §9.1; the console's rollout join). nil from an older console, and |
| + // 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"` |
| +} |
| + |
| +// apiRollout mirrors the console's rolloutView (internal/console/apiinstances.go). |
| +type apiRollout struct { |
| + Box string `json:"box"` |
| + Installed string `json:"installed"` |
| + Serving string `json:"serving"` |
| + Good string `json:"good"` |
| + Awake bool `json:"awake"` |
| + Attempt string `json:"attempt"` |
| + AttemptBuild string `json:"attempt_build"` |
| + HeldBy string `json:"held_by"` |
| + Reason string `json:"reason"` |
| + ConfigPending bool `json:"config_pending"` |
| + SchedulerIdle bool `json:"scheduler_idle"` |
| + // Unavailable is the box's reason code for being unable to arm its |
| + // rollout scheduler. When set the box reports no per-host state: awake |
| + // and scheduler_idle are meaningless false. |
| + Unavailable string `json:"unavailable,omitempty"` |
| } |
| // apiUpstreamState mirrors status.UpstreamState's wire shape (internal/status) |
diff --git a/instances_cli.go b/instances_cli.go| index 706e6dcbc..2bdaa3963 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -33,6 +33,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rolloutwords" |
| ) |
| const instancesEnableUsage = `usage: carlos instances enable --app <a> [flags] |
| @@ -417,6 +418,64 @@ func upstreamsCell(ups []apiUpstreamState) string { |
| return fmt.Sprintf("%d/%d", up, len(ups)) |
| } |
| +const ( |
| + // TODO-COPY zdd.list.header |
| + instancesListHeader = "HOST\tREGION\tCHANNEL\tPOOL\tINSTALLED\tSERVING\tHEALTH\tUPSTREAMS\tDB\tCREATED\tBY\tREASON" |
| + // TODO-COPY zdd.list.retry-hint |
| + instancesListRetryHint = "a build that failed is tried again on every box with carlos promote --app %s --retry <version> <channel>, or on one instance with carlos instances retry --app %s <host>" |
| +) |
| + |
| +// installedCell is the build the instance's box has installed: the rollout |
| +// report's when there is one, else the staged version VERSION always showed. |
| +func installedCell(r apiInstanceRecord) string { |
| + if r.Rollout != nil && r.Rollout.Installed != "" { |
| + return r.Rollout.Installed |
| + } |
| + return r.StagedVersion |
| +} |
| + |
| +// servingCell is the build answering requests, or a dash where no box |
| +// reports one: an older console, a box with CARLOS_SWAP off, or a box whose |
| +// report is unavailable. |
| +func servingCell(r apiInstanceRecord) string { |
| + if r.Rollout == nil || r.Rollout.Unavailable != "" || r.Rollout.Serving == "" { |
| + return "—" |
| + } |
| + return r.Rollout.Serving |
| +} |
| + |
| +// rolloutReason is the REASON cell (spec §9.1): why the serving build is not |
| +// the installed one, or that new config waits for a process (§8.1). A box that |
| +// cannot arm its scheduler says why first: its other fields are meaningless. |
| +// An asleep instance serves nothing (plan 5 reports Serving "" for it), so its |
| +// installed build is compared with Good, the build it last ran. |
| +func rolloutReason(r *apiRollout) string { |
| + switch { |
| + case r == nil: |
| + return "" |
| + case r.Unavailable != "": |
| + return rolloutwords.Unavailable(r.Unavailable) |
| + case r.HeldBy != "": |
| + return fmt.Sprintf(rolloutwords.StateHeld, r.HeldBy) |
| + case !r.Awake && r.Installed != r.Good: |
| + return fmt.Sprintf(rolloutwords.StateAsleep, r.Installed) |
| + case r.Awake && r.Installed != r.Serving: |
| + return rolloutwords.StateRollingOut |
| + case r.ConfigPending: |
| + return rolloutwords.StateConfigPending |
| + } |
| + return "" |
| +} |
| + |
| +func anyHeld(recs []apiInstanceRecord) bool { |
| + for _, r := range recs { |
| + if r.Rollout != nil && r.Rollout.HeldBy != "" { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| func runInstancesList(args []string) { |
| fs := flag.NewFlagSet("instances list", flag.ExitOnError) |
| app := appFlag(fs, "app whose instance records to list") |
| @@ -435,15 +494,23 @@ func runInstancesList(args []string) { |
| log.Fatalf("instances list: %v", err) |
| } |
| w := newTable(stdout) |
| - fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tVERSION\tHEALTH\tUPSTREAMS\tDB\tCREATED\tBY") |
| + fmt.Fprintln(w, instancesListHeader) |
| for _, r := range recs { |
| rowHealth := r.Health |
| if rowHealth == "" { |
| rowHealth = "—" // an old console: the field predates it, not "unhealthy" |
| } |
| - fmt.Fprintf(w, "%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, r.StagedVersion, rowHealth, upstreamsCell(r.Upstreams), humanBytes(r.DBBytes), r.CreatedAt, r.CreatedBy) |
| + 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)) |
| } |
| w.Flush() |
| + // Spec §8.4: a held build is retried everywhere by `promote --retry`, or |
| + // on one instance by `instances retry`. Said once, under the table, only |
| + // when something is held. |
| + if anyHeld(recs) { |
| + mutedf(stdout, instancesListRetryHint, app2, app2) |
| + } |
| if truncated { |
| mutedf(stdout, "(truncated at 500 records)") |
| } |
diff --git a/instances_rollout_test.go b/instances_rollout_test.go| new file mode 100644 |
| index 000000000..ab4fb7683 |
| --- /dev/null |
| +++ b/instances_rollout_test.go |
| @@ -0,0 +1,111 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/rolloutwords" |
| +) |
| + |
| +func TestInstalledAndServingCells(t *testing.T) { |
| + old := apiInstanceRecord{StagedVersion: "v1"} |
| + if installedCell(old) != "v1" || servingCell(old) != "—" { |
| + t.Errorf("no rollout: %q, %q", installedCell(old), servingCell(old)) |
| + } |
| + r := apiInstanceRecord{StagedVersion: "v1", Rollout: &apiRollout{Installed: "v2", Serving: "v1", Awake: true}} |
| + if installedCell(r) != "v2" || servingCell(r) != "v1" { |
| + t.Errorf("rollout cells = %q, %q", installedCell(r), servingCell(r)) |
| + } |
| + asleep := apiInstanceRecord{StagedVersion: "v1", Rollout: &apiRollout{Installed: "v2", Good: "v1"}} |
| + if servingCell(asleep) != "—" { |
| + t.Errorf("an asleep instance serves nothing: %q", servingCell(asleep)) |
| + } |
| + // A box that reports itself unavailable reports no serving build, even |
| + // if a stale one rides along. |
| + down := apiInstanceRecord{StagedVersion: "v1", Rollout: &apiRollout{Installed: "v2", Serving: "v1", Unavailable: "no-release-key"}} |
| + if installedCell(down) != "v2" || servingCell(down) != "—" { |
| + t.Errorf("unavailable cells = %q, %q", installedCell(down), servingCell(down)) |
| + } |
| +} |
| + |
| +func TestRolloutReason(t *testing.T) { |
| + for _, tc := range []struct { |
| + r *apiRollout |
| + want string |
| + }{ |
| + {nil, ""}, |
| + {&apiRollout{Installed: "v2", Serving: "v2", Awake: true}, ""}, |
| + {&apiRollout{Installed: "v2", Serving: "v1", Awake: true, HeldBy: "v2"}, fmt.Sprintf(rolloutwords.StateHeld, "v2")}, |
| + // Plan 5 reports no serving build for an asleep instance: its last |
| + // good build is what it ran. |
| + {&apiRollout{Installed: "v2", Good: "v1", HeldBy: "v2"}, fmt.Sprintf(rolloutwords.StateHeld, "v2")}, |
| + {&apiRollout{Installed: "v2", Good: "v1"}, fmt.Sprintf(rolloutwords.StateAsleep, "v2")}, |
| + {&apiRollout{Installed: "v2", Good: "v2"}, ""}, |
| + {&apiRollout{Installed: "v2", Serving: "v1", Awake: true}, rolloutwords.StateRollingOut}, |
| + {&apiRollout{Installed: "v2", Serving: "v2", Awake: true, ConfigPending: true}, rolloutwords.StateConfigPending}, |
| + // A box that cannot arm says why, and nothing else: awake and the |
| + // other flags are meaningless false, and a hold does not outrank it. |
| + {&apiRollout{Installed: "v2", Unavailable: "no-release-key"}, rolloutwords.Unavailable("no-release-key")}, |
| + {&apiRollout{Installed: "v2", Unavailable: "generations-off"}, rolloutwords.Unavailable("generations-off")}, |
| + {&apiRollout{Installed: "v2", Unavailable: "generations-off", HeldBy: "v2"}, rolloutwords.Unavailable("generations-off")}, |
| + } { |
| + if got := rolloutReason(tc.r); got != tc.want { |
| + t.Errorf("rolloutReason(%+v) = %q, want %q", tc.r, got, tc.want) |
| + } |
| + } |
| + if rolloutReason(&apiRollout{Installed: "v2", Unavailable: "no-release-key"}) == rolloutReason(&apiRollout{Installed: "v2", Unavailable: "generations-off"}) { |
| + t.Error("a refusing and a declined code read the same") |
| + } |
| + if anyHeld([]apiInstanceRecord{{}, {Rollout: &apiRollout{}}}) || !anyHeld([]apiInstanceRecord{{Rollout: &apiRollout{HeldBy: "v2"}}}) { |
| + t.Error("anyHeld is wrong") |
| + } |
| +} |
| + |
| +// TestRolloutWireShape pins the JSON the CLI reads from Task 8's console: the |
| +// rollout join on a list record (including the unavailable code) and held_until |
| +// on promote's 200. |
| +func TestRolloutWireShape(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if strings.HasSuffix(r.URL.Path, "/promote") { |
| + fmt.Fprint(w, `{"result":"promoted","held":true,"held_until":"2026-10-08T10:00:00Z"}`) |
| + return |
| + } |
| + fmt.Fprint(w, `{"instances":[ |
| + {"host":"a.example","staged_version":"v1","rollout":{"box":"b1","installed":"v2","serving":"v1","good":"v1","awake":true,"held_by":"v2","reason":"health","config_pending":true,"scheduler_idle":true}}, |
| + {"host":"b.example","staged_version":"v1","rollout":{"box":"b2","installed":"v2","unavailable":"no-release-key"}}, |
| + {"host":"c.example","staged_version":"v1"}],"truncated":false,"total":3}`) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + recs, _, _, err := s.instanceList(context.Background(), apiAcct{Seg: "acct"}, "app", "", "") |
| + if err != nil { |
| + t.Fatalf("instanceList: %v", err) |
| + } |
| + want := &apiRollout{Box: "b1", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, HeldBy: "v2", Reason: "health", ConfigPending: true, SchedulerIdle: true} |
| + if len(recs) != 3 || recs[0].Rollout == nil || *recs[0].Rollout != *want { |
| + t.Fatalf("rollout decoded as %+v", recs) |
| + } |
| + if recs[1].Rollout == nil || recs[1].Rollout.Unavailable != "no-release-key" { |
| + t.Errorf("unavailable not decoded: %+v", recs[1].Rollout) |
| + } |
| + if recs[2].Rollout != nil { |
| + t.Errorf("a record with no rollout decoded one: %+v", recs[2].Rollout) |
| + } |
| + // Flag off: installed prints the staged version, serving and reason a dash and nothing. |
| + if installedCell(recs[2]) != "v1" || servingCell(recs[2]) != "—" || rolloutReason(recs[2].Rollout) != "" { |
| + t.Errorf("no-rollout row: %q %q %q", installedCell(recs[2]), servingCell(recs[2]), rolloutReason(recs[2].Rollout)) |
| + } |
| + |
| + res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "v2", "stable", false, false) |
| + if err != nil { |
| + t.Fatalf("promote: %v", err) |
| + } |
| + if !res.Held || res.HeldUntil != "2026-10-08T10:00:00Z" { |
| + t.Errorf("promote result = %+v", res) |
| + } |
| +} |