cli: carlos fleets verbs + apps -place
Adds the carlos fleets verbs on the apiSession pattern (mirrors apps.go's runAppsCreate: resolveMode + session verbs, console-API-only with no bucket-direct fallback): carlos fleets create -bucket <bucket> [-account <sqid|name>] <name> carlos fleets add-box [-account <sqid|name>] <fleet> <label> carlos fleets rotate-token [-account <sqid|name>] <fleet> <label> carlos fleets detach-box [-account <sqid|name>] <fleet> <label> carlos fleets ls [-account <sqid|name>] add-box and rotate-token both print their token exactly once, through one shared helper (printFleetToken) that carries Task 3's review finding in both verbs' output and help text: rotation refuses a box's NEXT dial-in but does NOT evict a channel it already holds — a compromised box needs detach-box, not rotate-token. apps.go: runAppsCreate gains -place fleet/<f> (client-side grammar checked before any API call, sent in the create body per the console-side ruling), and a new `carlos apps place -app <name> -target fleet/<f>|""` verb. main.go's dispatch gains `case "fleets"`. Tests (fleets_cli_test.go): apiSession-level path/body/wire-shape round trips for all six new client calls against httptest fakes; RunFleets/ RunApps-level tests with good args (in-process, credentials written directly, no login round trip) proving the token appears in stdout exactly once with the detach warning, add-box vs rotate-token return distinct tokens, fleets ls lists every box, and create-with-place / apps place reach the console with the right body; one subprocess test (buildCarlos, carlosTestEnv-masked env, cmd.Dir a scratch dir) covering every verb's usage-error and bad-argument exit paths, which call log.Fatal/os.Exit and so cannot be driven in-process. Full gate green (18/18 packages), gofmt clean.
6 files changed,
+1254
−24
apimode.go+167 −3apimode_test.go+46 −4apps.go+105 −6fleets.go+308 −0fleets_cli_test.go+606 −0main.go+22 −11
diff --git a/apimode.go b/apimode.go| index 09378fb82..93b75c420 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -356,10 +356,19 @@ type apiCreateAppResult struct { |
| // same claim the browser's New app form makes. The path is the literal |
| // `new` segment (mirroring /a/{sqid}/apps/new), which apiPath builds when |
| // handed "new" as the app. |
| -func (s *apiSession) createApp(ctx context.Context, a apiAcct, app string) (apiCreateAppResult, error) { |
| +// |
| +// place is "" or a "fleet/<name>" target (runAppsCreate's -place flag, |
| +// already grammar-checked client-side before this is ever called) — the |
| +// server (handleAPICreateApp, internal/console/apicli.go) applies the SAME |
| +// two checks `carlos apps place` uses on its own target (grammar again, |
| +// server-side, plus fleet ownership) before the app is created, so an |
| +// invalid or foreign place refuses the whole create rather than silently |
| +// creating an unplaced app. |
| +func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string) (apiCreateAppResult, error) { |
| in := struct { |
| - Name string `json:"name"` |
| - }{app} |
| + Name string `json:"name"` |
| + Place string `json:"place,omitempty"` |
| + }{app, place} |
| status, body, err := s.postJSON(ctx, apiPath(a, "new", ""), in) |
| if err != nil { |
| return apiCreateAppResult{}, err |
| @@ -937,3 +946,158 @@ func writeFilePart(mw *multipart.Writer, name, path string) error { |
| _, err = io.Copy(part, f) |
| return err |
| } |
| + |
| +// ---- customer fleets (fleets.go's `carlos fleets` verbs + apps place) ----- |
| + |
| +// fleetPath builds one /api/cli/fleets/{account}... path — the fleets |
| +// family's account-scoped door (internal/console/fleets.go), sibling to |
| +// apiPath's /api/cli/apps/{account}/{app} shape but with no app segment: a |
| +// fleet's identity is account + name, never account + app + anything. tail |
| +// is a bare path (any fleet/label segments the caller needs already |
| +// url.PathEscape'd into it) with no query string of its own — exactly |
| +// apiPath's own contract, for the same reason: -account-id's exact=sqid |
| +// switch is appended by plain concatenation below. |
| +func fleetPath(a apiAcct, tail string) string { |
| + p := "/api/cli/fleets/" + url.PathEscape(a.Seg) + tail |
| + if a.Exact { |
| + p += "?exact=sqid" |
| + } |
| + return p |
| +} |
| + |
| +// apiFleetBox and apiFleetView are the wire shapes handleAPIFleetList and |
| +// handleAPIFleetNew send (internal/console/fleets.go's fleetBoxView / |
| +// fleetView) — deliberately distinct types from anything in internal/registry: |
| +// this client never sees a token hash, only ever a label and a status. |
| +type apiFleetBox struct { |
| + Label string `json:"label"` |
| + Status string `json:"status"` |
| +} |
| + |
| +type apiFleetView struct { |
| + Name string `json:"name"` |
| + Bucket string `json:"bucket"` |
| + Boxes []apiFleetBox `json:"boxes"` |
| +} |
| + |
| +// createFleet mints one account-owned fleet record — `carlos fleets create`. |
| +func (s *apiSession) createFleet(ctx context.Context, a apiAcct, name, bucket string) (apiFleetView, error) { |
| + in := struct { |
| + Name string `json:"name"` |
| + Bucket string `json:"bucket"` |
| + }{name, bucket} |
| + status, body, err := s.postJSON(ctx, fleetPath(a, "/new"), in) |
| + if err != nil { |
| + return apiFleetView{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiFleetView{}, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Fleet apiFleetView `json:"fleet"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiFleetView{}, fmt.Errorf("fleets create: unreadable response: %w", err) |
| + } |
| + return res.Fleet, nil |
| +} |
| + |
| +// listFleets asks the console for every fleet the caller's account owns — |
| +// `carlos fleets ls`. |
| +func (s *apiSession) listFleets(ctx context.Context, a apiAcct) ([]apiFleetView, error) { |
| + status, body, err := s.getJSON(ctx, fleetPath(a, "")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Fleets []apiFleetView `json:"fleets"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("fleets ls: unreadable response: %w", err) |
| + } |
| + return res.Fleets, nil |
| +} |
| + |
| +// addFleetBox mints a box and its bearer token — `carlos fleets add-box`. |
| +// The plaintext is in the return value exactly once; RunFleets prints it |
| +// exactly once and it must never be logged or retried into a second print. |
| +func (s *apiSession) addFleetBox(ctx context.Context, a apiAcct, fleet, label string) (token string, err error) { |
| + in := struct { |
| + Label string `json:"label"` |
| + }{label} |
| + status, body, err := s.postJSON(ctx, fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes"), in) |
| + if err != nil { |
| + return "", err |
| + } |
| + if status != http.StatusOK { |
| + return "", apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Token string `json:"token"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return "", fmt.Errorf("fleets add-box: unreadable response: %w", err) |
| + } |
| + return res.Token, nil |
| +} |
| + |
| +// rotateFleetBoxToken mints a fresh token for a box that already exists — |
| +// `carlos fleets rotate-token`. Per handleAPIFleetRotateBox's doc comment |
| +// (internal/console/fleets.go): this refuses the box's NEXT dial-in attempt |
| +// with the OLD token, but does NOT evict a channel that is already |
| +// connected — a box whose credential may be compromised needs detach-box, |
| +// not this. RunFleets repeats that warning in this verb's own output. |
| +func (s *apiSession) rotateFleetBoxToken(ctx context.Context, a apiAcct, fleet, label string) (token string, err error) { |
| + status, body, err := s.postJSON(ctx, |
| + fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes/"+url.PathEscape(label)+"/rotate"), struct{}{}) |
| + if err != nil { |
| + return "", err |
| + } |
| + if status != http.StatusOK { |
| + return "", apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Token string `json:"token"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return "", fmt.Errorf("fleets rotate-token: unreadable response: %w", err) |
| + } |
| + return res.Token, nil |
| +} |
| + |
| +// detachFleetBox flips a box to "detached" — the hard revocation rotation is |
| +// not: the edge closes an already-connected channel too |
| +// (handleAPIFleetDetachBox). `carlos fleets detach-box`. |
| +func (s *apiSession) detachFleetBox(ctx context.Context, a apiAcct, fleet, label string) error { |
| + status, body, err := s.postJSON(ctx, |
| + fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes/"+url.PathEscape(label)+"/detach"), struct{}{}) |
| + if err != nil { |
| + return err |
| + } |
| + if status != http.StatusOK { |
| + return apiErrorFromBody(status, body) |
| + } |
| + return nil |
| +} |
| + |
| +// place asks the console to repoint where app's routes — and its |
| +// already-provisioned instances — should run: `carlos apps place`. target "" |
| +// clears placement back to platform-served/unconstrained; any other value |
| +// must be "fleet/<name>" naming a fleet the caller's own account owns |
| +// (handleAPIPlace, internal/console/fleets.go). |
| +func (s *apiSession) place(ctx context.Context, a apiAcct, app, target string) error { |
| + in := struct { |
| + Target string `json:"target"` |
| + }{target} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/place"), in) |
| + if err != nil { |
| + return err |
| + } |
| + if status != http.StatusOK { |
| + return apiErrorFromBody(status, body) |
| + } |
| + return nil |
| +} |
diff --git a/apimode_test.go b/apimode_test.go| index 7fbff641b..fd1ea7257 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -2498,7 +2498,7 @@ func TestAPISessionCreateApp(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul") |
| + res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul", "") |
| if err != nil { |
| t.Fatalf("createApp: %v", err) |
| } |
| @@ -2509,7 +2509,7 @@ func TestAPISessionCreateApp(t *testing.T) { |
| t.Fatalf("result = %+v", res) |
| } |
| - if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul"); err != nil { |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul", ""); err != nil { |
| t.Fatalf("createApp exact: %v", err) |
| } |
| if gotQuery != "exact=sqid" { |
| @@ -2517,14 +2517,56 @@ func TestAPISessionCreateApp(t *testing.T) { |
| } |
| status = http.StatusConflict |
| - if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul"); err == nil || |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul", ""); err == nil || |
| err.Error() != "that name is taken" { |
| t.Fatalf("409 err = %v, want the server's sentence", err) |
| } |
| status = http.StatusNotFound |
| - if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul"); err == nil || |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul", ""); err == nil || |
| !strings.Contains(err.Error(), "check -account,") { |
| t.Fatalf("404 err = %v, want create's account-only wording", err) |
| } |
| } |
| + |
| +// TestAPISessionCreateAppSendsPlace: the optional place argument (`carlos |
| +// apps create -place fleet/<f>`, 2026-08-06 ruling) travels in the create |
| +// body's "place" field, and an empty place is simply absent from the |
| +// request semantics the server reads as "no place given" either way — this |
| +// pins that the WIRE VALUE sent is exactly what was passed, not silently |
| +// dropped or renamed. |
| +func TestAPISessionCreateAppSendsPlace(t *testing.T) { |
| + var gotPlace string |
| + var sawPlaceKey bool |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var raw map[string]any |
| + body, _ := io.ReadAll(r.Body) |
| + json.Unmarshal(body, &raw) |
| + _, sawPlaceKey = raw["place"] |
| + var req struct { |
| + Name string `json:"name"` |
| + Place string `json:"place"` |
| + } |
| + json.Unmarshal(body, &req) |
| + gotPlace = req.Place |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "account": "bae", "app": req.Name, "url": "https://console.example/a/bae/apps/" + req.Name, |
| + }) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "titogo", "fleet/pi"); err != nil { |
| + t.Fatalf("createApp with place: %v", err) |
| + } |
| + if gotPlace != "fleet/pi" { |
| + t.Fatalf("wire place = %q, want fleet/pi", gotPlace) |
| + } |
| + |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "unplaced", ""); err != nil { |
| + t.Fatalf("createApp with no place: %v", err) |
| + } |
| + if sawPlaceKey && gotPlace != "" { |
| + t.Fatalf("empty place must not travel as a non-empty value; got %q", gotPlace) |
| + } |
| +} |
diff --git a/apps.go b/apps.go| index d255eab49..9635537ce 100644 |
| --- a/apps.go |
| +++ b/apps.go |
| @@ -30,10 +30,11 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -const appsUsage = `usage: carlos apps <create|delete|restore> [flags] <app> |
| +const appsUsage = `usage: carlos apps <create|place|delete|restore> [flags] <app> |
| create -app <name> [flags] claim a new app name in an account, through |
| the console API — the same claim the console's New app |
| @@ -42,6 +43,21 @@ const appsUsage = `usage: carlos apps <create|delete|restore> [flags] <app> |
| form is not, and is the fallback. Signed-in mode only: |
| in direct bucket mode the first ship brings an app into |
| existence, and "console grant" is the box-side path. |
| + -place fleet/<f> place the app on a customer fleet at the same |
| + instant it is created — equivalent to running "apps place" |
| + right after, but works even though the fresh app has zero |
| + routes yet (the console stores placement on the app record |
| + itself, not only on its routes). Validated the same way |
| + -target below is: the grammar here, ownership server-side; |
| + an invalid or foreign fleet refuses the WHOLE create. |
| + |
| + place -app <name> -target fleet/<f>|"" repoint where an app's routes — |
| + and its already-provisioned instances — should run: onto a |
| + customer fleet ("fleet/<name>", a fleet your account owns), |
| + or "" to clear back to platform-served. Every NEW instance |
| + created afterwards inherits this target automatically; |
| + every instance that already exists is repointed by this |
| + call itself. |
| delete [flags] <app> move an app to the Trash: every route drops |
| (custom domains included), its name stays reserved, and it |
| @@ -88,6 +104,8 @@ func RunApps(args []string) { |
| switch args[0] { |
| case "create": |
| runAppsCreate(args[1:]) |
| + case "place": |
| + runAppsPlace(args[1:]) |
| case "delete": |
| runAppsDelete(args[1:]) |
| case "restore": |
| @@ -330,9 +348,33 @@ func runAppsRestore(args []string) { |
| "carlos add <host> to serve it again\n", app) |
| } |
| +// validPlaceTarget is the client-side grammar gate for a fleet placement |
| +// target, shared by -place (apps create) and -target (apps place): checked |
| +// BEFORE any API call, on the same "a shape the server is certain to refuse |
| +// should cost nothing" principle validAppName's own doc comment states — |
| +// for -place at create time this matters doubly, since a wasted round trip |
| +// there would also spend the create verb's own scarce per-day budget on a |
| +// typo. "" is always valid (no placement, or clearing one); anything else |
| +// must parse as "fleet/<name>" per registry.FleetPlacement — the exact |
| +// grammar the server re-checks itself. This is belt only, never the buckle: |
| +// whether the named fleet exists and is owned by the caller's account is a |
| +// check only the server can make, and both server doors (handleAPICreateApp, |
| +// handleAPIPlace) still make it themselves. |
| +func validPlaceTarget(target string) error { |
| + if target == "" { |
| + return nil |
| + } |
| + if _, ok := registry.FleetPlacement(target); !ok { |
| + return fmt.Errorf(`%q is not a usable placement target — want "" or "fleet/<name>" `+ |
| + "(1-32 characters of a-z, 0-9 and hyphen)", target) |
| + } |
| + return nil |
| +} |
| + |
| func runAppsCreate(args []string) { |
| fs := flag.NewFlagSet("apps create", flag.ExitOnError) |
| app := fs.String("app", "", "app name to claim (lowercase letters, digits, dashes)") |
| + place := fs.String("place", "", `place the app on a customer fleet at create time — "" (default) or "fleet/<name>"`) |
| // Not accountFlags: that helper also registers -registry for direct-mode |
| // resolution, and this verb never resolves locally — the console resolves |
| // the account against the caller's own memberships. |
| @@ -341,14 +383,19 @@ func runAppsCreate(args []string) { |
| consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| fs.Parse(args) |
| if strings.TrimSpace(*app) == "" { |
| - log.Fatal("usage: carlos apps create -app <name> [-account <sqid|name>] [-account-id <sqid>] [-console <origin>]") |
| + log.Fatal("usage: carlos apps create -app <name> [-place fleet/<f>] [-account <sqid|name>] " + |
| + "[-account-id <sqid>] [-console <origin>]") |
| } |
| - // Client-side gate before the server's: the create limiter charges |
| - // ATTEMPTS, so a name the server is certain to 400 should cost a typo |
| - // nothing. |
| + // Client-side gates before the server's: the create limiter charges |
| + // ATTEMPTS, so a name — or a place value — the server is certain to |
| + // refuse should cost a typo nothing. |
| if err := validAppName(strings.TrimSpace(*app)); err != nil { |
| log.Fatalf("apps create: %v", err) |
| } |
| + place2 := strings.TrimSpace(*place) |
| + if err := validPlaceTarget(place2); err != nil { |
| + log.Fatalf("apps create: %v", err) |
| + } |
| sess, err := resolveMode(*consoleFlag) |
| if err != nil { |
| @@ -366,11 +413,63 @@ func runAppsCreate(args []string) { |
| } |
| acct := apiAccount(acctRaw, acctIDRaw) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| - res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app)) |
| + res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2) |
| if err != nil { |
| log.Fatalf("apps create: %v", err) |
| } |
| fmt.Fprintf(stdout, "created app %s in account %s — it's yours\n", res.App, res.Account) |
| fmt.Fprintf(stdout, " open: %s\n", res.URL) |
| fmt.Fprintf(stdout, " ship: carlos ship -account %s -app %s ...\n", res.Account, res.App) |
| + if place2 != "" { |
| + fmt.Fprintf(stdout, " placed on %s\n", place2) |
| + } |
| +} |
| + |
| +// runAppsPlace repoints where app's routes and already-provisioned |
| +// instances should run: `carlos apps place -app <name> -target |
| +// fleet/<f>|""`. Every field is client-side validated before the API call |
| +// exactly the way the server itself validates them, so a typo's cost is a |
| +// local refusal, not a spent write-rate-limit slot. |
| +func runAppsPlace(args []string) { |
| + fs := flag.NewFlagSet("apps place", flag.ExitOnError) |
| + app := fs.String("app", "", "app to place") |
| + target := fs.String("target", "", `fleet target — "" clears placement, or "fleet/<name>"`) |
| + // Not accountFlags: that helper also registers -registry for direct-mode |
| + // resolution, and this verb never resolves locally — placement is |
| + // console-tracked state, API mode only, same as apps create above. |
| + 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; this is the flag an ambiguity error tells you to use") |
| + consoleFlag := fs.String("console", "", "console to place through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if strings.TrimSpace(*app) == "" { |
| + log.Fatal(`usage: carlos apps place -app <name> -target fleet/<f>|"" [-account <sqid|name>] ` + |
| + "[-account-id <sqid>] [-console <origin>]") |
| + } |
| + if err := validAppName(strings.TrimSpace(*app)); err != nil { |
| + log.Fatalf("apps place: %v", err) |
| + } |
| + target2 := strings.TrimSpace(*target) |
| + if err := validPlaceTarget(target2); err != nil { |
| + log.Fatalf("apps place: %v", err) |
| + } |
| + |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("apps place works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Placement is console-tracked\n" + |
| + "state: log in instead — carlos auth login -console <url>") |
| + } |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + if err := sess.place(context.Background(), acct, strings.TrimSpace(*app), target2); err != nil { |
| + log.Fatalf("apps place: %v", err) |
| + } |
| + if target2 == "" { |
| + fmt.Fprintf(stdout, "cleared placement for %s\n", strings.TrimSpace(*app)) |
| + return |
| + } |
| + fmt.Fprintf(stdout, "placed %s on %s\n", strings.TrimSpace(*app), target2) |
| } |
diff --git a/fleets.go b/fleets.go| new file mode 100644 |
| index 000000000..604b36a6a |
| --- /dev/null |
| +++ b/fleets.go |
| @@ -0,0 +1,308 @@ |
| +// The `carlos fleets` verbs: mint an account-owned fleet, add/rotate/detach |
| +// its boxes, and list what an account owns. A fleet is a named group of |
| +// remote boxes (customer-fleets plan) that dial this console over a channel |
| +// identified by "<fleet>/<label>" — console-tracked state |
| +// (internal/registry's fleets/fleet_boxes tables), reached only through the |
| +// console's API doors (internal/console/fleets.go). There is no |
| +// bucket-direct fallback, the same posture `carlos apps create` takes: a |
| +// fleet cannot exist without a console to claim its name and mint its |
| +// boxes' tokens. |
| +// |
| +// `carlos apps place` — which repoints an EXISTING app onto a fleet, and |
| +// apps create's -place flag, which does the same at create time — live in |
| +// apps.go, not here: their subject is an app, and a fleet name is only ever |
| +// data in their request body (mirroring where the console itself put the |
| +// /place route — see fleets.go's package doc there). |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "strings" |
| + "text/tabwriter" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|detach-box|ls> [flags] |
| + |
| + create -bucket <bucket> [-account <sqid|name>] <name> |
| + mint an account-owned fleet: a named group of remote boxes |
| + that dial this console over a channel identified by |
| + "<fleet>/<label>". <bucket> is the customer's OWN data |
| + bucket, taken on trust that it already exists. The fleets |
| + namespace is flat and global across every account — a name |
| + once taken (by ANY account) cannot be reused, so the "that |
| + name is taken" refusal on a retry does not necessarily mean |
| + YOU took it. |
| + |
| + add-box [-account <sqid|name>] <fleet> <label> |
| + register one box to a fleet and mint its bearer token. THE |
| + TOKEN IS PRINTED EXACTLY ONCE, HERE — the console never |
| + stores or re-shows the plaintext, so losing this output |
| + means rotate-token, not a re-run of add-box (which just |
| + 409s on the label). Put it in the box's credential store |
| + right away. |
| + ROTATION IS NOT REVOCATION: rotating a token later (below) |
| + refuses the box's NEXT dial-in with the OLD token but does |
| + NOT evict a channel it already holds — a box whose |
| + credential may be COMPROMISED needs detach-box, not |
| + rotate-token. |
| + |
| + rotate-token [-account <sqid|name>] <fleet> <label> |
| + mint a FRESH token for a box that already exists, printed |
| + exactly once, same as add-box. THIS REFUSES THE BOX'S NEXT |
| + DIAL-IN with the old token, but does NOT evict a channel it |
| + is already connected on — a box whose credential may be |
| + COMPROMISED needs detach-box, not this. |
| + |
| + detach-box [-account <sqid|name>] <fleet> <label> |
| + the hard revocation rotate-token is not: flips the box to |
| + "detached", which refuses its next dial-in AND closes any |
| + channel it already holds. This is what a compromised box |
| + needs. |
| + |
| + ls [-account <sqid|name>] |
| + list every fleet the account owns, with each box's label |
| + and status. |
| + |
| + -account <sqid|name> account the fleet belongs to (default: ops) — |
| + resolved by the console against your own |
| + memberships. |
| + -account-id <sqid> account sqid, matched as a sqid ONLY — supersedes |
| + -account |
| + -console <origin> which logged-in console to use (only needed with |
| + several) |
| + |
| +Every verb here is signed-in only (carlos auth login) — fleets are |
| +console-tracked state, with no bucket-direct fallback. |
| +` |
| + |
| +// RunFleets dispatches the fleets sub-verbs. |
| +func RunFleets(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, fleetsUsage) |
| + os.Exit(1) |
| + } |
| + switch args[0] { |
| + case "create": |
| + runFleetsCreate(args[1:]) |
| + case "add-box": |
| + runFleetsAddBox(args[1:]) |
| + case "rotate-token": |
| + runFleetsRotateToken(args[1:]) |
| + case "detach-box": |
| + runFleetsDetachBox(args[1:]) |
| + case "ls": |
| + runFleetsList(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, fleetsUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// fleetsSignedInSession resolves API mode and fatals with a fleets-specific |
| +// message when this shell is in direct bucket mode — every fleets verb is |
| +// console-API-only, the same posture apps create takes (apps.go's |
| +// runAppsCreate): a fleet is console-tracked state with nothing in the |
| +// bucket to fall back to. |
| +func fleetsSignedInSession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("fleets works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Fleets are console-tracked\n" + |
| + "state with no bucket-direct fallback: unset the store env and run\n" + |
| + "carlos auth login -console <url> instead.") |
| + } |
| + return sess |
| +} |
| + |
| +// validFleetName and validBoxLabel are the client-side gates for the two |
| +// name shapes every fleets verb below takes — checked BEFORE any API call, |
| +// on the same "a shape the server is certain to refuse should cost nothing" |
| +// principle apps.go's validAppName states. Both use |
| +// registry.ValidTunnelName: a fleet name and a box label are the two halves |
| +// of the "<fleet>/<label>" channel identity, and share its grammar exactly. |
| +func validFleetName(name string) error { |
| + if registry.ValidTunnelName(name) { |
| + return nil |
| + } |
| + return fmt.Errorf("%q is not a usable fleet name — 1-32 characters of a-z, 0-9 and hyphen", name) |
| +} |
| + |
| +func validBoxLabel(label string) error { |
| + if registry.ValidTunnelName(label) { |
| + return nil |
| + } |
| + return fmt.Errorf("%q is not a usable box label — 1-32 characters of a-z, 0-9 and hyphen", label) |
| +} |
| + |
| +func runFleetsCreate(args []string) { |
| + fs := flag.NewFlagSet("fleets create", flag.ExitOnError) |
| + bucket := fs.String("bucket", "", "the customer's own data bucket (must already exist)") |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) to own the fleet") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if fs.NArg() != 1 || strings.TrimSpace(*bucket) == "" { |
| + log.Fatal("usage: carlos fleets create -bucket <bucket> [-account <sqid|name>] " + |
| + "[-account-id <sqid>] [-console <origin>] <name>") |
| + } |
| + name := strings.TrimSpace(fs.Arg(0)) |
| + if err := validFleetName(name); err != nil { |
| + log.Fatalf("fleets create: %v", err) |
| + } |
| + bucketV := strings.TrimSpace(*bucket) |
| + |
| + sess := fleetsSignedInSession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + f, err := sess.createFleet(context.Background(), acct, name, bucketV) |
| + if err != nil { |
| + log.Fatalf("fleets create: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "created fleet %s (bucket %s)\n", f.Name, f.Bucket) |
| + fmt.Fprintf(stdout, " add a box: carlos fleets add-box %s <label>\n", f.Name) |
| +} |
| + |
| +func runFleetsAddBox(args []string) { |
| + fs := flag.NewFlagSet("fleets add-box", flag.ExitOnError) |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + consoleFlag := fs.String("console", "", "console to add the box through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if fs.NArg() != 2 { |
| + log.Fatal("usage: carlos fleets add-box [-account <sqid|name>] [-account-id <sqid>] " + |
| + "[-console <origin>] <fleet> <label>") |
| + } |
| + fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1)) |
| + if err := validFleetName(fleet); err != nil { |
| + log.Fatalf("fleets add-box: %v", err) |
| + } |
| + if err := validBoxLabel(label); err != nil { |
| + log.Fatalf("fleets add-box: %v", err) |
| + } |
| + |
| + sess := fleetsSignedInSession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + token, err := sess.addFleetBox(context.Background(), acct, fleet, label) |
| + if err != nil { |
| + log.Fatalf("fleets add-box: %v", err) |
| + } |
| + printFleetToken(fleet, label, token, false) |
| +} |
| + |
| +func runFleetsRotateToken(args []string) { |
| + fs := flag.NewFlagSet("fleets rotate-token", flag.ExitOnError) |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + consoleFlag := fs.String("console", "", "console to rotate through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if fs.NArg() != 2 { |
| + log.Fatal("usage: carlos fleets rotate-token [-account <sqid|name>] [-account-id <sqid>] " + |
| + "[-console <origin>] <fleet> <label>") |
| + } |
| + fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1)) |
| + if err := validFleetName(fleet); err != nil { |
| + log.Fatalf("fleets rotate-token: %v", err) |
| + } |
| + if err := validBoxLabel(label); err != nil { |
| + log.Fatalf("fleets rotate-token: %v", err) |
| + } |
| + |
| + sess := fleetsSignedInSession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + token, err := sess.rotateFleetBoxToken(context.Background(), acct, fleet, label) |
| + if err != nil { |
| + log.Fatalf("fleets rotate-token: %v", err) |
| + } |
| + printFleetToken(fleet, label, token, true) |
| +} |
| + |
| +// printFleetToken is the ONE place a fleet box token reaches stdout — for |
| +// both add-box and rotate-token, so the two verbs can never drift apart on |
| +// the warning that matters most (carried from Task 3's review, per this |
| +// task's brief): rotating (or minting) a token refuses a box's NEXT dial-in |
| +// with the credential just replaced, but does NOT evict a channel the box |
| +// already holds — a COMPROMISED box needs `fleets detach-box`, not |
| +// rotate-token. Printed exactly once, immediately after the token itself, |
| +// on every call — never logged, never re-shown. |
| +func printFleetToken(fleet, label, token string, rotated bool) { |
| + fmt.Fprintf(stdout, "%s/%s token: %s\n", fleet, label, token) |
| + fmt.Fprintln(stdout, " (shown once — the console does not store or re-show the plaintext)") |
| + if rotated { |
| + fmt.Fprintln(stdout, " ROTATION IS NOT REVOCATION: this refuses the box's NEXT dial-in with the OLD") |
| + fmt.Fprintln(stdout, " token, but does NOT evict a channel it already holds — a COMPROMISED box needs") |
| + fmt.Fprintln(stdout, " `carlos fleets detach-box`, not another rotate-token.") |
| + return |
| + } |
| + fmt.Fprintln(stdout, " NOTE: rotating this token later (rotate-token) refuses the box's next dial-in but") |
| + fmt.Fprintln(stdout, " does NOT evict a live session — a COMPROMISED box needs `fleets detach-box` instead.") |
| +} |
| + |
| +func runFleetsDetachBox(args []string) { |
| + fs := flag.NewFlagSet("fleets detach-box", flag.ExitOnError) |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if fs.NArg() != 2 { |
| + log.Fatal("usage: carlos fleets detach-box [-account <sqid|name>] [-account-id <sqid>] " + |
| + "[-console <origin>] <fleet> <label>") |
| + } |
| + fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1)) |
| + if err := validFleetName(fleet); err != nil { |
| + log.Fatalf("fleets detach-box: %v", err) |
| + } |
| + if err := validBoxLabel(label); err != nil { |
| + log.Fatalf("fleets detach-box: %v", err) |
| + } |
| + |
| + sess := fleetsSignedInSession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + if err := sess.detachFleetBox(context.Background(), acct, fleet, label); err != nil { |
| + log.Fatalf("fleets detach-box: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "detached %s/%s — its next dial-in is refused and any live channel is closed\n", fleet, label) |
| +} |
| + |
| +func runFleetsList(args []string) { |
| + fs := flag.NewFlagSet("fleets ls", flag.ExitOnError) |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) to list fleets for") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + |
| + sess := fleetsSignedInSession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + fleets, err := sess.listFleets(context.Background(), acct) |
| + if err != nil { |
| + log.Fatalf("fleets ls: %v", err) |
| + } |
| + if len(fleets) == 0 { |
| + fmt.Fprintln(stdout, "no fleets") |
| + return |
| + } |
| + w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| + fmt.Fprintln(w, "FLEET\tBUCKET\tBOX\tSTATUS") |
| + for _, f := range fleets { |
| + if len(f.Boxes) == 0 { |
| + fmt.Fprintf(w, "%s\t%s\t-\t-\n", f.Name, f.Bucket) |
| + continue |
| + } |
| + for _, b := range f.Boxes { |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Name, f.Bucket, b.Label, b.Status) |
| + } |
| + } |
| + w.Flush() |
| +} |
diff --git a/fleets_cli_test.go b/fleets_cli_test.go| new file mode 100644 |
| index 000000000..e75db593f |
| --- /dev/null |
| +++ b/fleets_cli_test.go |
| @@ -0,0 +1,606 @@ |
| +// Tests for the `carlos fleets` verbs and `carlos apps place` — the CLI |
| +// door onto internal/console/fleets.go (Task 7). Three layers: |
| +// |
| +// - apiSession-level tests drive the six new client methods (apimode.go) |
| +// directly against an httptest fake console: path, body, and wire-shape |
| +// round trips, with no flag parsing or os.Exit in the loop. |
| +// - RunFleets/RunApps-level tests drive the full verb (flag parsing, |
| +// mode resolution, printing) with GOOD arguments only, against the |
| +// same kind of fake console, credentials written straight to disk |
| +// (mirroring auth_e2e_test.go's TestAccountsCreateUsesTheLoggedInIdentity |
| +// rather than a real login round trip) — this is what proves the token |
| +// really reaches stdout exactly once and the detach-not-rotate warning |
| +// is really in the output. |
| +// - one subprocess test (TestFleetsAndPlaceCLIUsageErrors, buildCarlos- |
| +// style like auth_e2e_test.go) drives every verb's BAD-argument path: |
| +// those call log.Fatal/os.Exit, which would kill the test binary if |
| +// driven in-process (the reason instances_cli_test.go gives for |
| +// skipping runInstancesEnable's own argv parsing) — a real subprocess |
| +// is the only way to observe "exits nonzero and says usage" without |
| +// restructuring the verbs around it. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// ---- pure grammar gates ----------------------------------------------------- |
| + |
| +func TestValidFleetNameAndBoxLabel(t *testing.T) { |
| + for _, good := range []string{"acme", "pi-1", "a", strings.Repeat("a", 32)} { |
| + if err := validFleetName(good); err != nil { |
| + t.Errorf("validFleetName(%q) = %v, want nil", good, err) |
| + } |
| + if err := validBoxLabel(good); err != nil { |
| + t.Errorf("validBoxLabel(%q) = %v, want nil", good, err) |
| + } |
| + } |
| + for _, bad := range []string{"", "Acme", "has spaces", "under_score", strings.Repeat("a", 33)} { |
| + if err := validFleetName(bad); err == nil { |
| + t.Errorf("validFleetName(%q) = nil, want an error", bad) |
| + } |
| + if err := validBoxLabel(bad); err == nil { |
| + t.Errorf("validBoxLabel(%q) = nil, want an error", bad) |
| + } |
| + } |
| +} |
| + |
| +func TestValidPlaceTarget(t *testing.T) { |
| + for _, good := range []string{"", "fleet/pi", "fleet/acme-1"} { |
| + if err := validPlaceTarget(good); err != nil { |
| + t.Errorf("validPlaceTarget(%q) = %v, want nil", good, err) |
| + } |
| + } |
| + for _, bad := range []string{"eu", "fleet/", "fleet/Acme", "pi"} { |
| + if err := validPlaceTarget(bad); err == nil { |
| + t.Errorf("validPlaceTarget(%q) = nil, want an error", bad) |
| + } |
| + } |
| +} |
| + |
| +// ---- apiSession-level: path + body + wire-shape round trips --------------- |
| + |
| +// TestAPISessionFleetCreateAndList: createFleet posts to the literal `new` |
| +// door with name+bucket in the body and unmarshals the "fleet" wrapper; |
| +// listFleets GETs the account door and unmarshals "fleets". |
| +func TestAPISessionFleetCreateAndList(t *testing.T) { |
| + var gotPath, gotMethod string |
| + var gotBody map[string]string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.Path, r.Method |
| + if r.Method == http.MethodPost { |
| + json.NewDecoder(r.Body).Decode(&gotBody) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "fleet": map[string]any{"name": gotBody["name"], "bucket": gotBody["bucket"], "boxes": []any{}}, |
| + }) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "fleets": []map[string]any{ |
| + {"name": "acme", "bucket": "acme-data", "boxes": []map[string]any{ |
| + {"label": "pi-1", "status": "active"}, |
| + }}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + f, err := s.createFleet(context.Background(), apiAcct{Seg: "bab"}, "acme", "acme-data") |
| + if err != nil { |
| + t.Fatalf("createFleet: %v", err) |
| + } |
| + if gotPath != "/api/cli/fleets/bab/new" || gotMethod != http.MethodPost { |
| + t.Fatalf("request = %s %s, want POST /api/cli/fleets/bab/new", gotMethod, gotPath) |
| + } |
| + if gotBody["name"] != "acme" || gotBody["bucket"] != "acme-data" { |
| + t.Fatalf("body = %v", gotBody) |
| + } |
| + if f.Name != "acme" || f.Bucket != "acme-data" || len(f.Boxes) != 0 { |
| + t.Fatalf("createFleet result = %+v", f) |
| + } |
| + |
| + fleets, err := s.listFleets(context.Background(), apiAcct{Seg: "bab"}) |
| + if err != nil { |
| + t.Fatalf("listFleets: %v", err) |
| + } |
| + if gotPath != "/api/cli/fleets/bab" || gotMethod != http.MethodGet { |
| + t.Fatalf("request = %s %s, want GET /api/cli/fleets/bab", gotMethod, gotPath) |
| + } |
| + if len(fleets) != 1 || fleets[0].Name != "acme" || len(fleets[0].Boxes) != 1 || fleets[0].Boxes[0].Label != "pi-1" { |
| + t.Fatalf("listFleets result = %+v", fleets) |
| + } |
| +} |
| + |
| +// TestAPISessionFleetCreateExactAccountID: -account-id travels as |
| +// exact=sqid, same as every other apiPath-built call. |
| +func TestAPISessionFleetCreateExactAccountID(t *testing.T) { |
| + var gotQuery string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotQuery = r.URL.RawQuery |
| + json.NewEncoder(w).Encode(map[string]any{"fleet": map[string]any{"name": "x", "bucket": "y", "boxes": []any{}}}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + if _, err := s.createFleet(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "y"); err != nil { |
| + t.Fatalf("createFleet: %v", err) |
| + } |
| + if gotQuery != "exact=sqid" { |
| + t.Fatalf("query = %q, want exact=sqid", gotQuery) |
| + } |
| +} |
| + |
| +// TestAPISessionFleetCreateDuplicateName: the server's 409 sentence surfaces |
| +// verbatim, same convention as apps create's duplicate refusal. |
| +func TestAPISessionFleetCreateDuplicateName(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusConflict) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "that name is taken"}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + if _, err := s.createFleet(context.Background(), apiAcct{Seg: "bab"}, "acme", "d"); err == nil || |
| + err.Error() != "that name is taken" { |
| + t.Fatalf("createFleet duplicate = %v, want the server's sentence", err) |
| + } |
| +} |
| + |
| +// TestAPISessionFleetAddBoxRotateDetach: add-box and rotate-token both POST |
| +// to their own path and return the plaintext token in the "token" field; |
| +// detach-box POSTs and returns no token at all. |
| +func TestAPISessionFleetAddBoxRotateDetach(t *testing.T) { |
| + var gotPath, gotLabel string |
| + step := "add" |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + switch step { |
| + case "add": |
| + var req struct{ Label string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + gotLabel = req.Label |
| + json.NewEncoder(w).Encode(map[string]any{"label": req.Label, "token": "tok-first-64hex"}) |
| + case "rotate": |
| + json.NewEncoder(w).Encode(map[string]any{"token": "tok-second-64hex"}) |
| + case "detach": |
| + json.NewEncoder(w).Encode(map[string]any{}) |
| + } |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + acct := apiAcct{Seg: "bab"} |
| + |
| + tok, err := s.addFleetBox(context.Background(), acct, "acme", "pi-1") |
| + if err != nil { |
| + t.Fatalf("addFleetBox: %v", err) |
| + } |
| + if gotPath != "/api/cli/fleets/bab/acme/boxes" || gotLabel != "pi-1" || tok != "tok-first-64hex" { |
| + t.Fatalf("addFleetBox: path=%s label=%s token=%s", gotPath, gotLabel, tok) |
| + } |
| + |
| + step = "rotate" |
| + tok, err = s.rotateFleetBoxToken(context.Background(), acct, "acme", "pi-1") |
| + if err != nil { |
| + t.Fatalf("rotateFleetBoxToken: %v", err) |
| + } |
| + if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/rotate" || tok != "tok-second-64hex" { |
| + t.Fatalf("rotateFleetBoxToken: path=%s token=%s", gotPath, tok) |
| + } |
| + |
| + step = "detach" |
| + if err := s.detachFleetBox(context.Background(), acct, "acme", "pi-1"); err != nil { |
| + t.Fatalf("detachFleetBox: %v", err) |
| + } |
| + if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/detach" { |
| + t.Fatalf("detachFleetBox path = %s", gotPath) |
| + } |
| +} |
| + |
| +// TestAPISessionFleetDoorsSurfaceServerErrors: a non-200 from any of the |
| +// three box doors surfaces the server's own message, not a swallowed one. |
| +func TestAPISessionFleetDoorsSurfaceServerErrors(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNotFound) |
| + w.Write([]byte("404 page not found\n")) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + acct := apiAcct{Seg: "bab"} |
| + |
| + if _, err := s.addFleetBox(context.Background(), acct, "ghost", "x"); err == nil { |
| + t.Fatal("addFleetBox against a 404 must error") |
| + } |
| + if _, err := s.rotateFleetBoxToken(context.Background(), acct, "ghost", "x"); err == nil { |
| + t.Fatal("rotateFleetBoxToken against a 404 must error") |
| + } |
| + if err := s.detachFleetBox(context.Background(), acct, "ghost", "x"); err == nil { |
| + t.Fatal("detachFleetBox against a 404 must error") |
| + } |
| +} |
| + |
| +// TestAPISessionPlace: place POSTs the target in the body to .../place, for |
| +// both a fleet target and the clearing "". |
| +func TestAPISessionPlace(t *testing.T) { |
| + var gotPath string |
| + var gotTarget string |
| + var sawTarget bool |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + var req struct { |
| + Target string `json:"target"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + gotTarget, sawTarget = req.Target, true |
| + json.NewEncoder(w).Encode(map[string]any{}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "fleet/pi"); err != nil { |
| + t.Fatalf("place: %v", err) |
| + } |
| + if gotPath != "/api/cli/apps/bab/titogo/place" || !sawTarget || gotTarget != "fleet/pi" { |
| + t.Fatalf("place request: path=%s target=%q sawTarget=%v", gotPath, gotTarget, sawTarget) |
| + } |
| + |
| + if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", ""); err != nil { |
| + t.Fatalf("place clear: %v", err) |
| + } |
| + if gotTarget != "" { |
| + t.Fatalf("clearing place: target=%q, want empty", gotTarget) |
| + } |
| +} |
| + |
| +// TestAPISessionPlaceSurfacesRefusal: an invalid-shape 400 and a |
| +// foreign-fleet 404 both surface through place unchanged. |
| +func TestAPISessionPlaceSurfacesRefusal(t *testing.T) { |
| + status := http.StatusBadRequest |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if status == http.StatusNotFound { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.WriteHeader(status) |
| + json.NewEncoder(w).Encode(map[string]string{"error": `invalid target — want "" or "fleet/<name>"`}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "eu"); err == nil || |
| + !strings.Contains(err.Error(), "invalid target") { |
| + t.Fatalf("place with a bad shape = %v, want the server's message", err) |
| + } |
| + |
| + status = http.StatusNotFound |
| + if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "fleet/ghost"); err == nil { |
| + t.Fatal("place onto a foreign/unknown fleet must error") |
| + } |
| +} |
| + |
| +// ---- RunFleets / RunApps-level: full verb, good args, real printing ------- |
| + |
| +// fakeFleetsConsole answers the CLI endpoints RunFleets/apps-create/apps-place |
| +// reach, stateful enough to prove add-box then rotate-token return DISTINCT |
| +// tokens and that create-with-place's "place" field actually arrives. |
| +type fakeFleetsConsole struct { |
| + ts *httptest.Server |
| + |
| + tokenSeq atomic.Int64 |
| + |
| + lastAuth atomic.Value // string |
| + |
| + // what the last relevant call carried, for assertions. |
| + lastCreateBucket atomic.Value // string |
| + lastCreatePlace atomic.Value // string |
| + lastPlaceTarget atomic.Value // string |
| + lastDetachPath atomic.Value // string |
| +} |
| + |
| +func newFakeFleetsConsole(t *testing.T) *fakeFleetsConsole { |
| + t.Helper() |
| + fc := &fakeFleetsConsole{} |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("POST /api/cli/fleets/{account}/new", func(w http.ResponseWriter, r *http.Request) { |
| + fc.lastAuth.Store(r.Header.Get("Authorization")) |
| + var req struct{ Name, Bucket string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + fc.lastCreateBucket.Store(req.Bucket) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "fleet": map[string]any{"name": req.Name, "bucket": req.Bucket, "boxes": []any{}}, |
| + }) |
| + }) |
| + mux.HandleFunc("GET /api/cli/fleets/{account}", func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "fleets": []map[string]any{ |
| + {"name": "acme", "bucket": "acme-data", "boxes": []map[string]any{ |
| + {"label": "pi-1", "status": "active"}, |
| + {"label": "pi-2", "status": "detached"}, |
| + }}, |
| + }, |
| + }) |
| + }) |
| + mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes", func(w http.ResponseWriter, r *http.Request) { |
| + var req struct{ Label string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + n := fc.tokenSeq.Add(1) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "label": req.Label, "token": tokenFor(n), |
| + }) |
| + }) |
| + mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", func(w http.ResponseWriter, r *http.Request) { |
| + n := fc.tokenSeq.Add(1) |
| + json.NewEncoder(w).Encode(map[string]any{"token": tokenFor(n)}) |
| + }) |
| + mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", func(w http.ResponseWriter, r *http.Request) { |
| + fc.lastDetachPath.Store(r.URL.Path) |
| + json.NewEncoder(w).Encode(map[string]any{}) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{account}/new", func(w http.ResponseWriter, r *http.Request) { |
| + var req struct{ Name, Place string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + fc.lastCreatePlace.Store(req.Place) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "account": "bab", "app": req.Name, "url": fc.ts.URL + "/a/bab/apps/" + req.Name, |
| + }) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{account}/{app}/place", func(w http.ResponseWriter, r *http.Request) { |
| + var req struct{ Target string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + fc.lastPlaceTarget.Store(req.Target) |
| + json.NewEncoder(w).Encode(map[string]any{}) |
| + }) |
| + fc.ts = httptest.NewServer(mux) |
| + t.Cleanup(fc.ts.Close) |
| + return fc |
| +} |
| + |
| +// tokenFor mints a distinguishable fake token per call so a test can prove |
| +// add-box and a later rotate-token did NOT return the same value. |
| +func tokenFor(n int64) string { |
| + return strings.Repeat("a", 60) + "-" + string(rune('0'+n%10)) |
| +} |
| + |
| +// writeTestCredential drops a credential straight on disk for console at |
| +// url with a fixed bearer, the same shortcut |
| +// TestAccountsCreateUsesTheLoggedInIdentity (auth_e2e_test.go) takes to |
| +// avoid a login round trip in a test that isn't about login. |
| +func writeTestCredential(t *testing.T, path, url string) { |
| + t.Helper() |
| + f := &credentialFile{Version: 1, Credentials: []credential{{ |
| + Console: url, Address: "paul@keymail.dev", Token: "cli-token", |
| + CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(24 * time.Hour).UTC(), |
| + }}} |
| + raw, err := json.Marshal(f) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, raw, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// fleetsCLIFixture wires up a fake console plus a credential pointed at it, |
| +// entirely in-process (no subprocess, no real login flow) — the "package |
| +// level tests against httptest console servers" shape. |
| +func fleetsCLIFixture(t *testing.T) *fakeFleetsConsole { |
| + t.Helper() |
| + home := t.TempDir() |
| + t.Setenv("HOME", home) |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| + t.Chdir(t.TempDir()) |
| + creds := filepath.Join(home, "carlos-credentials") |
| + t.Setenv("CARLOS_CREDENTIALS", creds) |
| + fc := newFakeFleetsConsole(t) |
| + writeTestCredential(t, creds, fc.ts.URL) |
| + return fc |
| +} |
| + |
| +// TestRunFleetsCreateHitsRightPathWithBody drives `carlos fleets create` |
| +// with good args end to end (flag parsing included) and checks the console |
| +// saw the right bucket and the verb printed the fleet name back. |
| +func TestRunFleetsCreateHitsRightPathWithBody(t *testing.T) { |
| + fc := fleetsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunFleets([]string{"create", "-bucket", "acme-data", "-account", "bab", "acme"}) |
| + |
| + if got := fc.lastCreateBucket.Load(); got != "acme-data" { |
| + t.Fatalf("console saw bucket %v, want acme-data", got) |
| + } |
| + if !strings.Contains(buf.String(), "created fleet acme") { |
| + t.Fatalf("stdout = %q, want it to confirm the created fleet", buf.String()) |
| + } |
| +} |
| + |
| +// TestRunFleetsAddBoxPrintsTokenOnceWithDetachWarning: add-box's token |
| +// appears in stdout EXACTLY ONCE, and the output carries the detach-not- |
| +// rotate warning verbatim (Task 3's review finding, carried into this |
| +// task's brief). |
| +func TestRunFleetsAddBoxPrintsTokenOnceWithDetachWarning(t *testing.T) { |
| + fleetsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunFleets([]string{"add-box", "-account", "bab", "acme", "pi-1"}) |
| + |
| + out := buf.String() |
| + tok := tokenFor(1) |
| + if n := strings.Count(out, tok); n != 1 { |
| + t.Fatalf("token %q appeared %d times in stdout, want exactly 1:\n%s", tok, n, out) |
| + } |
| + if !strings.Contains(out, "detach-box") { |
| + t.Fatalf("stdout missing the detach-box pointer:\n%s", out) |
| + } |
| + if !strings.Contains(strings.ToLower(out), "not evict") { |
| + t.Fatalf("stdout missing the does-NOT-evict-a-live-session warning:\n%s", out) |
| + } |
| +} |
| + |
| +// TestRunFleetsRotateTokenPrintsFreshTokenOnceWithWarning: rotate-token's |
| +// output carries the SAME warning as add-box's (rotation refuses the next |
| +// dial-in but does not evict a live channel), and a token distinct from a |
| +// prior add-box's. |
| +func TestRunFleetsRotateTokenPrintsFreshTokenOnceWithWarning(t *testing.T) { |
| + fleetsCLIFixture(t) |
| + addBuf := captureStdout(t) |
| + RunFleets([]string{"add-box", "-account", "bab", "acme", "pi-1"}) |
| + firstToken := tokenFor(1) |
| + if !strings.Contains(addBuf.String(), firstToken) { |
| + t.Fatalf("add-box did not print its own token:\n%s", addBuf.String()) |
| + } |
| + |
| + rotBuf := captureStdout(t) |
| + RunFleets([]string{"rotate-token", "-account", "bab", "acme", "pi-1"}) |
| + out := rotBuf.String() |
| + secondToken := tokenFor(2) |
| + if n := strings.Count(out, secondToken); n != 1 { |
| + t.Fatalf("rotated token %q appeared %d times, want exactly 1:\n%s", secondToken, n, out) |
| + } |
| + if strings.Contains(out, firstToken) { |
| + t.Fatalf("rotate-token output leaked the OLD token:\n%s", out) |
| + } |
| + if !strings.Contains(out, "detach-box") || !strings.Contains(strings.ToLower(out), "not evict") { |
| + t.Fatalf("rotate-token stdout missing the detach-not-rotate warning:\n%s", out) |
| + } |
| +} |
| + |
| +// TestRunFleetsDetachBoxHitsRightPath: detach-box reaches the right door and |
| +// confirms in its own words (not the token-print wording). |
| +func TestRunFleetsDetachBoxHitsRightPath(t *testing.T) { |
| + fc := fleetsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunFleets([]string{"detach-box", "-account", "bab", "acme", "pi-1"}) |
| + |
| + if got := fc.lastDetachPath.Load(); got != "/api/cli/fleets/bab/acme/boxes/pi-1/detach" { |
| + t.Fatalf("detach path = %v, want the labelled detach door", got) |
| + } |
| + if !strings.Contains(buf.String(), "detached acme/pi-1") { |
| + t.Fatalf("stdout = %q, want it to confirm the detach", buf.String()) |
| + } |
| +} |
| + |
| +// TestRunFleetsListPrintsEveryBox: `carlos fleets ls` prints one row per |
| +// box, including a detached one, from the fake console's fixed listing. |
| +func TestRunFleetsListPrintsEveryBox(t *testing.T) { |
| + fleetsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunFleets([]string{"ls", "-account", "bab"}) |
| + |
| + out := buf.String() |
| + for _, want := range []string{"acme", "acme-data", "pi-1", "active", "pi-2", "detached"} { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("fleets ls stdout missing %q:\n%s", want, out) |
| + } |
| + } |
| +} |
| + |
| +// TestRunAppsCreateWithPlaceSendsPlaceField: `carlos apps create -place |
| +// fleet/<f>` reaches the console with place in the body (the 2026-08-06 |
| +// ruling's CLI surface). |
| +func TestRunAppsCreateWithPlaceSendsPlaceField(t *testing.T) { |
| + fc := fleetsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunApps([]string{"create", "-app", "titogo", "-place", "fleet/pi", "-account", "bab"}) |
| + |
| + if got := fc.lastCreatePlace.Load(); got != "fleet/pi" { |
| + t.Fatalf("console saw place %v, want fleet/pi", got) |
| + } |
| + if !strings.Contains(buf.String(), "placed on fleet/pi") { |
| + t.Fatalf("stdout = %q, want it to confirm the placement", buf.String()) |
| + } |
| +} |
| + |
| +// TestRunAppsPlaceHitsRightPathAndClears: `carlos apps place` sends the |
| +// target, and passing "" clears it — both print their own confirmation. |
| +func TestRunAppsPlaceHitsRightPathAndClears(t *testing.T) { |
| + fc := fleetsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunApps([]string{"place", "-app", "titogo", "-target", "fleet/pi", "-account", "bab"}) |
| + if got := fc.lastPlaceTarget.Load(); got != "fleet/pi" { |
| + t.Fatalf("console saw target %v, want fleet/pi", got) |
| + } |
| + if !strings.Contains(buf.String(), "placed titogo on fleet/pi") { |
| + t.Fatalf("stdout = %q, want the placed confirmation", buf.String()) |
| + } |
| + |
| + buf2 := captureStdout(t) |
| + RunApps([]string{"place", "-app", "titogo", "-target", "", "-account", "bab"}) |
| + if got := fc.lastPlaceTarget.Load(); got != "" { |
| + t.Fatalf("console saw target %v, want \"\" (cleared)", got) |
| + } |
| + if !strings.Contains(buf2.String(), "cleared placement for titogo") { |
| + t.Fatalf("stdout = %q, want the cleared confirmation", buf2.String()) |
| + } |
| +} |
| + |
| +// ---- subprocess: the usage-error / bad-argument paths --------------------- |
| + |
| +// TestFleetsAndPlaceCLIUsageErrors drives every fleets verb and apps place |
| +// with missing or malshaped arguments as a real subprocess (buildCarlos, |
| +// same helper auth_e2e_test.go's TestAuthCLI uses) — the ONLY way to observe |
| +// a log.Fatal/os.Exit refusal without killing the test binary. Every case |
| +// here must exit nonzero without ever reaching the network: carlosTestEnv |
| +// masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is a scratch dir |
| +// (CLAUDE.md's rule — a subprocess that inherited a real login has minted |
| +// real accounts on the flagship before), so a case that DID try to dial out |
| +// would fail on "not logged in", not silently succeed. |
| +func TestFleetsAndPlaceCLIUsageErrors(t *testing.T) { |
| + bin := buildCarlos(t) |
| + scratch := t.TempDir() |
| + env := carlosTestEnv(t) |
| + |
| + run := func(args ...string) (string, error) { |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = env |
| + cmd.Dir = scratch |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + for _, tc := range []struct { |
| + name string |
| + args []string |
| + want string // substring the combined output must contain |
| + }{ |
| + {"fleets no args", []string{"fleets"}, "usage: carlos fleets"}, |
| + {"fleets bogus verb", []string{"fleets", "bogus"}, "usage: carlos fleets"}, |
| + {"fleets create no args", []string{"fleets", "create"}, "usage: carlos fleets create"}, |
| + {"fleets create missing bucket", []string{"fleets", "create", "-account", "bab", "acme"}, "usage: carlos fleets create"}, |
| + {"fleets create bad name", []string{"fleets", "create", "-bucket", "d", "-account", "bab", "Bad Name"}, "not a usable fleet name"}, |
| + {"fleets add-box no args", []string{"fleets", "add-box"}, "usage: carlos fleets add-box"}, |
| + {"fleets add-box missing label", []string{"fleets", "add-box", "acme"}, "usage: carlos fleets add-box"}, |
| + {"fleets add-box bad label", []string{"fleets", "add-box", "acme", "Bad Label"}, "not a usable box label"}, |
| + {"fleets rotate-token no args", []string{"fleets", "rotate-token"}, "usage: carlos fleets rotate-token"}, |
| + {"fleets detach-box no args", []string{"fleets", "detach-box"}, "usage: carlos fleets detach-box"}, |
| + {"apps place missing app", []string{"apps", "place", "-target", "fleet/pi"}, "usage: carlos apps place"}, |
| + {"apps place bad target shape", []string{"apps", "place", "-app", "titogo", "-target", "eu"}, "not a usable placement target"}, |
| + {"apps create bad place shape", []string{"apps", "create", "-app", "titogo", "-place", "eu"}, "not a usable placement target"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + out, err := run(tc.args...) |
| + if err == nil { |
| + t.Fatalf("carlos %s exited 0, want a nonzero (usage/refusal) exit:\n%s", strings.Join(tc.args, " "), out) |
| + } |
| + if !strings.Contains(out, tc.want) { |
| + t.Fatalf("carlos %s output missing %q:\n%s", strings.Join(tc.args, " "), tc.want, out) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/main.go b/main.go| index fb8b01722..57b161ef1 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -49,6 +49,8 @@ func main() { |
| RunAccounts(args) |
| case "apps": |
| RunApps(args) |
| + case "fleets": |
| + RunFleets(args) |
| case "env": |
| RunEnv(args) |
| case "secrets": |
| @@ -109,17 +111,26 @@ commands: |
| auth login | whoami | logout — who this terminal is |
| (headless? don't log in: pass -owner instead) |
| accounts create | list | migrate — the tenancy primitive |
| - apps create | delete | restore — claim a new app |
| - name (console API only, rate limited per |
| - address per day; the console form is not), |
| - trash an app for 30 days (routes drop |
| - everywhere; refused while flagged production), |
| - or undo that within the window (no routes come |
| - back; carlos add <host> after); delete/restore |
| - run signed-in via the console or headless |
| - against the bucket (-account-id required |
| - headless); no purge here — "Delete now" is |
| - console-only |
| + apps create | place | delete | restore — claim a new |
| + app name (console API only, rate limited per |
| + address per day; the console form is not, |
| + -place fleet/<f> places it the same instant), |
| + repoint an app's routes/instances onto a |
| + customer fleet or back to "", trash an app for |
| + 30 days (routes drop everywhere; refused while |
| + flagged production), or undo that within the |
| + window (no routes come back; carlos add <host> |
| + after); delete/restore run signed-in via the |
| + console or headless against the bucket |
| + (-account-id required headless); no purge here |
| + — "Delete now" is console-only |
| + fleets create | add-box | rotate-token | detach-box | |
| + ls — account-owned groups of remote boxes that |
| + dial this console (customer fleets); console |
| + API only, no bucket-direct fallback. Box |
| + tokens print exactly once, at mint/rotate; |
| + rotate-token does NOT evict a live channel — a |
| + compromised box needs detach-box |
| env set | unset | list — plain per-app config vars |
| secrets set | unset | list | genkey — sealed per-app secrets |
| ship | promote | channels publish and promote releases (-account <sqid|name>) |