carlos / platform Public

cli: instances list -match/-health, server-side, with HEALTH column

apiSession.instanceList gains match/health params (sent as q/health query
params) and a total return — the server-side filters and count Task 9
added to the console's /instances endpoint. apiInstanceRecord gains Health.
apiPath learns to join a query string it's handed with one it's adding
(?exact=sqid) via & instead of a second ?, which -account-id plus any
filter would otherwise have produced.

runInstances list gains -match/-health flags, a HEALTH column (em-dash for
an old console with no health field), and a '(showing X of Y matches)'
line when a filter is active and the console reports a total bigger than
the page — 0 from a pre-Task-9 console is treated as unknown, not zero.

deploy.go's own instanceList call passes empty match/health (unfiltered,
as before).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell dcd86ed6474b94a35ce2c9dc33d00d56bd553622 parent 0958fa3
4 files changed, +104 −14
  • apimode.go +37 −9
  • apimode_test.go +41 −0
  • deploy.go +1 −1
  • instances_cli.go +25 −4
diff --git a/apimode.go b/apimode.go
index edf525550..a26cd81bb 100644
--- a/apimode.go
+++ b/apimode.go
@@ -217,7 +217,11 @@ func apiAccount(raw, rawID *string) apiAcct {
func apiPath(a apiAcct, app, tail string) string {
p := "/api/cli/apps/" + url.PathEscape(a.Seg) + "/" + url.PathEscape(app) + tail
if a.Exact {
- p += "?exact=sqid"
+ sep := "?"
+ if strings.Contains(p, "?") {
+ sep = "&"
+ }
+ p += sep + "exact=sqid"
}
return p
}
@@ -1258,6 +1262,12 @@ type apiInstanceRecord struct {
// response, since no box has adopted the record before this call
// returns it.
StagedVersion string `json:"staged_version"`
+ // Health is the console's best-effort read of the instance's live state
+ // (Task 9) — "running", "serving", "setting-up", "asleep", "no-readings",
+ // "not-responding", "failed", or empty when talking to a console that
+ // predates the field. Always empty on a create response, same reason
+ // StagedVersion is.
+ Health string `json:"health"`
}
// instanceCreate asks the console to create one instance record — the
@@ -1285,24 +1295,42 @@ func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, r
return out, nil
}
-// instanceList reads the app's instance records through the console,
-// honest about the server's 500-record cap.
-func (s *apiSession) instanceList(ctx context.Context, a apiAcct, app string) ([]apiInstanceRecord, bool, error) {
- status, body, err := s.getJSON(ctx, apiPath(a, app, "/instances"))
+// instanceList reads the app's instance records through the console, honest
+// about the server's 500-record cap. match and health are the server-side
+// filters Task 9 added (q and health query params) — empty strings send
+// neither, and the console applies no filter. total is the server's count
+// of ALL matching records (not just the page returned here); a console that
+// predates Task 9 sends no "total" field at all, which decodes as 0 — the
+// CLI printer treats that as "unknown" rather than "zero matches", since a
+// non-empty recs slice from the same response proves it isn't really zero.
+func (s *apiSession) instanceList(ctx context.Context, a apiAcct, app, match, health string) ([]apiInstanceRecord, bool, int, error) {
+ tail := "/instances"
+ v := url.Values{}
+ if match != "" {
+ v.Set("q", match)
+ }
+ if health != "" {
+ v.Set("health", health)
+ }
+ if enc := v.Encode(); enc != "" {
+ tail += "?" + enc
+ }
+ status, body, err := s.getJSON(ctx, apiPath(a, app, tail))
if err != nil {
- return nil, false, err
+ return nil, false, 0, err
}
if status != http.StatusOK {
- return nil, false, apiErrorFromBody(status, body)
+ return nil, false, 0, apiErrorFromBody(status, body)
}
var res struct {
Instances []apiInstanceRecord `json:"instances"`
Truncated bool `json:"truncated"`
+ Total int `json:"total"`
}
if err := json.Unmarshal(body, &res); err != nil {
- return nil, false, fmt.Errorf("instances list: unreadable response: %w", err)
+ return nil, false, 0, fmt.Errorf("instances list: unreadable response: %w", err)
}
- return res.Instances, res.Truncated, nil
+ return res.Instances, res.Truncated, res.Total, nil
}
// apiLogsResponse is the console's logs answer (internal/console/apilogs.go)
diff --git a/apimode_test.go b/apimode_test.go
index aee51a0f1..3c3f98f69 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -12,6 +12,7 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "net/url"
"os"
"path/filepath"
"strings"
@@ -605,6 +606,46 @@ func TestAPISessionChannels(t *testing.T) {
}
}
+// TestInstanceListSendsFilters pins two things at once (Task 10): instanceList's
+// own request construction (q/health land as query params, total/truncated
+// decoded from the response) and apiPath's "?"+"&" join fix — an -account-id
+// (Exact) session must still produce a well-formed URL, with BOTH exact=sqid
+// and the filters, when the tail already carries a query string of its own.
+func TestInstanceListSendsFilters(t *testing.T) {
+ var gotPath string
+ var gotQuery url.Values
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ gotQuery = r.URL.Query()
+ json.NewEncoder(w).Encode(map[string]any{"instances": []map[string]any{}, "truncated": false, "total": 0})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, truncated, total, err := s.instanceList(context.Background(), apiAcct{Seg: "bah", Exact: true}, "app", "api", "not-responding")
+ if err != nil {
+ t.Fatalf("instanceList: %v", err)
+ }
+ if truncated {
+ t.Fatal("truncated = true, want false")
+ }
+ if total != 0 {
+ t.Fatalf("total = %d, want 0", total)
+ }
+ if gotPath != "/api/cli/apps/bah/app/instances" {
+ t.Fatalf("path = %q, want /api/cli/apps/bah/app/instances", gotPath)
+ }
+ if got := gotQuery.Get("q"); got != "api" {
+ t.Fatalf("q = %q, want %q", got, "api")
+ }
+ if got := gotQuery.Get("health"); got != "not-responding" {
+ t.Fatalf("health = %q, want %q", got, "not-responding")
+ }
+ if got := gotQuery.Get("exact"); got != "sqid" {
+ t.Fatalf("exact = %q, want sqid — the account-id join must survive alongside q/health", got)
+ }
+}
+
// ---- Task 5: the four verbs wired to resolveMode --------------------------
// loginTo saves one live credential for origin/address and returns it, so a
diff --git a/deploy.go b/deploy.go
index 209930a4c..9b1693322 100644
--- a/deploy.go
+++ b/deploy.go
@@ -62,7 +62,7 @@ func runDeploy(args []string) {
acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below
ctx := context.Background()
- recs, _, err := sess.instanceList(ctx, acct, *app)
+ recs, _, _, err := sess.instanceList(ctx, acct, *app, "", "")
if err != nil {
log.Fatalf("deploy: list instances: %v", err)
}
diff --git a/instances_cli.go b/instances_cli.go
index b7541092d..04b37bca0 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -197,7 +197,7 @@ func instancesEnable(ctx context.Context, s release.Store, acct, app string, m p
}
const instancesRecordsUsage = `usage: carlos instances create -app <a> -host <h> [-region <r>] [-channel <c>]
- carlos instances list -app <a>
+ carlos instances list -app <a> [-match <substr>] [-health <slug>]
carlos instances delete -app <a> -host <h>
Signed-in only (carlos auth login): instance records are console-authorized
@@ -208,6 +208,12 @@ box's reconcile pass materializes the route. Common flags:
-account-id <id> account sqid, matched as a sqid ONLY
-console <origin> console to act through (default: the only logged-in one)
+list-only flags:
+
+ -match <substr> only hosts containing this substring
+ -health <slug> only this state: running, serving, setting-up, asleep,
+ no-readings, not-responding, failed
+
Operators placing routes by hand on a box use carlos add, not this.
`
@@ -255,6 +261,8 @@ func runInstancesCreate(args []string) {
func runInstancesList(args []string) {
fs := flag.NewFlagSet("instances list", flag.ExitOnError)
app := fs.String("app", "", "app whose instance records to list")
+ match := fs.String("match", "", "only hosts containing this substring")
+ health := fs.String("health", "", "only this state: running, serving, setting-up, asleep, no-readings, not-responding, failed")
consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
@@ -263,19 +271,32 @@ func runInstancesList(args []string) {
log.Fatal(instancesRecordsUsage)
}
sess := requireAPISession("list", *consoleFlag)
- recs, truncated, err := sess.instanceList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app)
+ recs, truncated, total, err := sess.instanceList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *match, *health)
if err != nil {
log.Fatalf("instances list: %v", err)
}
w := newTable(stdout)
- fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tVERSION\tCREATED\tBY")
+ fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tVERSION\tHEALTH\tCREATED\tBY")
for _, r := range recs {
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, r.StagedVersion, r.CreatedAt, r.CreatedBy)
+ 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\n", r.Host, r.Region, r.Channel, r.Pool, r.StagedVersion, rowHealth, r.CreatedAt, r.CreatedBy)
}
w.Flush()
if truncated {
mutedf(stdout, "(truncated at 500 records)")
}
+ // total is the server's count of every record matching -match/-health, not
+ // just the page returned above — worth a line only when a filter is
+ // active AND the console actually reported one (a pre-Task-9 console
+ // sends no "total" field, which decodes as 0; treated as unknown here
+ // rather than "zero matches", since len(recs) itself already proves that
+ // wrong whenever recs is non-empty).
+ if (*match != "" || *health != "") && total > len(recs) {
+ mutedf(stdout, "(showing %d of %d matches)", len(recs), total)
+ }
}
func runInstancesDelete(args []string) {