carlos / platform Public

zdd-6 task 9: carlos instances list shows installed, serving and why

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ea06e4e32c4019e9e92ba032da35e5bfe2881743 parent eca3d3a
3 files changed, +211 −5
  • apimode.go +31 −3
  • instances_cli.go +69 −2
  • instances_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)
+ }
+}