carlos services: mint, list, rotate and revoke from a terminal
Adds the `carlos services` CLI verbs (Task 7 of the service-principals
plan) on top of Task 6's four owner-gated API doors:
POST /api/cli/services/{account} create
GET /api/cli/services/{account} list
POST /api/cli/services/{account}/{name}/rotate rotate
POST /api/cli/services/{account}/{name}/revoke revoke
services.go: servicesUsage, RunServices dispatching create|ls|rotate|revoke,
servicesSignedInSession (fatals with a services-specific message in direct
bucket mode, mirroring fleetsSignedInSession), validServiceName and
validServiceHandle (client-side gates checked before any API call, per
apps.go's validAppName principle) using registry.ValidTunnelName plus the
64-hex listing-id grammar.
apimode.go: four apiSession methods beside createFleet — createService,
listServices, rotateService and revokeService — following its exact
postJSON/getJSON + status check + apiErrorFromBody + typed-result shape.
revokeService returns (matched bool, err error) rather than the brief's
guessed error-only shape: the wire's "matched" field is what tells the
caller whether anything was actually revoked, and the CLI must not render
a false matched as success.
main.go: wires `services` into both the verb switch and the usage block.
Three things the CLI gets right per the brief: a false `matched` on revoke
prints "nothing to revoke" and still exits 0 (the wanted state already
holds); an app-scoped credential's door list says "bound to app X ONLY"
next to the {app}-wildcarded patterns rather than letting them read as
"any app"; the secret is printed exactly once, with the deliver-it line
showing a literal <secret> placeholder rather than a second copy of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
4 files changed,
+1077
−0
apimode.go+139 −0main.go+6 −0services.go+363 −0services_cli_test.go+569 −0
diff --git a/apimode.go b/apimode.go| index 92aa9e78c..cdb4f6afd 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2421,3 +2421,142 @@ func (s *apiSession) place(ctx context.Context, a apiAcct, app, target string) e |
| } |
| return nil |
| } |
| + |
| +// ---- service credentials (services.go's `carlos services` verbs) --------- |
| + |
| +// servicePath builds one /api/cli/services/{account}... path — the service |
| +// credential family's account-scoped door (internal/console/apiservices.go), |
| +// the same sibling-of-apiPath shape fleetPath already established: identity |
| +// here is account + (name or listing id), never account + app. tail is a |
| +// bare path (any name/id segment the caller needs already |
| +// url.PathEscape'd into it) with no query string of its own — exactly |
| +// fleetPath's own contract, for the same reason: -account-id's exact=sqid |
| +// switch is appended by plain concatenation below. |
| +func servicePath(a apiAcct, tail string) string { |
| + p := "/api/cli/services/" + url.PathEscape(a.Seg) + tail |
| + if a.Exact { |
| + p += "?exact=sqid" |
| + } |
| + return p |
| +} |
| + |
| +// apiServiceView is one listed credential's wire shape — deliberately the |
| +// same field set as serviceView (internal/console/apiservices.go): the |
| +// listing id (a sha256 hex, never a secret), name, role, app/label scope, |
| +// the created/rotated/last-used timestamps, and who minted it. There is |
| +// deliberately no "revoked" field — the door only ever lists live rows. |
| +type apiServiceView struct { |
| + ID string `json:"id"` |
| + Name string `json:"name"` |
| + Role string `json:"role"` |
| + Label string `json:"label"` |
| + App string `json:"app"` |
| + Created string `json:"created"` |
| + Rotated string `json:"rotated"` |
| + LastUsed string `json:"last_used"` |
| + MintedBy string `json:"minted_by"` |
| +} |
| + |
| +// createService mints one account-owned service credential, optionally |
| +// scoped to a single app — `carlos services create`. The plaintext secret is |
| +// in the return value exactly once; RunServices prints it exactly once and |
| +// it must never be logged or retried into a second print (only its hash is |
| +// stored server-side, so there is no second copy to hand out later). doors |
| +// is the sorted list of /api/cli/ patterns the granted role reaches |
| +// (handleAPIServiceCreate's doorsFor) — read at print time, not |
| +// hand-maintained here, so this client cannot drift from what the |
| +// credential will actually be admitted to. |
| +func (s *apiSession) createService(ctx context.Context, a apiAcct, name, role, app string) (token string, doors []string, err error) { |
| + in := struct { |
| + Name string `json:"name"` |
| + Role string `json:"role"` |
| + App string `json:"app"` |
| + }{name, role, app} |
| + status, body, err := s.postJSON(ctx, servicePath(a, ""), in) |
| + if err != nil { |
| + return "", nil, err |
| + } |
| + if status != http.StatusCreated { |
| + return "", nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Token string `json:"token"` |
| + Doors []string `json:"doors"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return "", nil, fmt.Errorf("services create: unreadable response: %w", err) |
| + } |
| + return res.Token, res.Doors, nil |
| +} |
| + |
| +// listServices asks the console for every LIVE credential the caller's |
| +// account owns — `carlos services ls`. Never a secret and never a hash; |
| +// apiServiceView carries only what serviceView puts on the wire. |
| +func (s *apiSession) listServices(ctx context.Context, a apiAcct) ([]apiServiceView, error) { |
| + status, body, err := s.getJSON(ctx, servicePath(a, "")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Services []apiServiceView `json:"services"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("services ls: unreadable response: %w", err) |
| + } |
| + return res.Services, nil |
| +} |
| + |
| +// rotateService mints a fresh secret for a credential that already exists, |
| +// addressed by NAME only (handleAPIServiceRotate takes no listing-id form) — |
| +// `carlos services rotate`. This refuses the credential's NEXT call with the |
| +// OLD secret but does not stop anything already in flight; a credential that |
| +// may be compromised needs revokeService, not this. RunServices repeats that |
| +// distinction in its own output, the way printFleetToken does for fleets. |
| +func (s *apiSession) rotateService(ctx context.Context, a apiAcct, name string) (token string, err error) { |
| + status, body, err := s.postJSON(ctx, servicePath(a, "/"+url.PathEscape(name)+"/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("services rotate: unreadable response: %w", err) |
| + } |
| + return res.Token, nil |
| +} |
| + |
| +// revokeService is the hard stop rotation is not: the credential's next call |
| +// fails, whatever secret it holds — `carlos services revoke`, addressed by |
| +// name or by listing id (serviceHandle, internal/console/apiservices.go). |
| +// |
| +// Idempotent server-side: an unknown name, an unknown id, an |
| +// already-revoked credential and a live one all answer 200. matched is the |
| +// field that says which it was (C41) — false means nothing was actually |
| +// revoked, most often a typo'd name, and RunServices must never render that |
| +// as plain success. This method's own signature deliberately deviates from |
| +// the task brief's guessed `error`-only shape: the brief predates knowing |
| +// the wire carries "matched" at all, and dropping that bit here would leave |
| +// no way for the verb above to tell a real revoke from a no-op one. |
| +func (s *apiSession) revokeService(ctx context.Context, a apiAcct, name string) (matched bool, err error) { |
| + status, body, err := s.postJSON(ctx, servicePath(a, "/"+url.PathEscape(name)+"/revoke"), struct{}{}) |
| + if err != nil { |
| + return false, err |
| + } |
| + if status != http.StatusOK { |
| + return false, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Matched bool `json:"matched"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return false, fmt.Errorf("services revoke: unreadable response: %w", err) |
| + } |
| + return res.Matched, nil |
| +} |
diff --git a/main.go b/main.go| index fd96f0e7b..0029f3a87 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -94,6 +94,8 @@ func main() { |
| RunApps(args) |
| case "fleets": |
| RunFleets(args) |
| + case "services": |
| + RunServices(args) |
| case "env": |
| RunEnv(args) |
| case "secrets": |
| @@ -180,6 +182,10 @@ Your deployment — stand up and administer boxes, routes and accounts |
| detach-box | ls — box tokens print exactly once, at mint |
| or rotate, and a compromised box needs detach-box, not |
| rotate-token |
| + services credentials a SERVER holds, not a person: create | ls | |
| + rotate | revoke, owner-only and closed to service |
| + credentials themselves — secrets print exactly once, and |
| + a compromised one needs revoke, not rotate |
| add route a hostname on this box to an app |
| remove drop one of this box's routes |
| route repoint a route's channel or config environment, grant or |
diff --git a/services.go b/services.go| new file mode 100644 |
| index 000000000..dc2a2ded6 |
| --- /dev/null |
| +++ b/services.go |
| @@ -0,0 +1,363 @@ |
| +// The `carlos services` verbs: mint, list, rotate and revoke a service |
| +// credential — a principal a SERVER holds, not an address a person types a |
| +// password for. This is the CLI door onto internal/console/apiservices.go, |
| +// modelled on fleets.go the way that file's own doc comment says: same |
| +// account resolution, same mint-once discipline (the plaintext is printed |
| +// exactly once, here, because only its hash is ever stored), same |
| +// byte-identical refusals. |
| +// |
| +// Every door here is owner-gated and closed to service credentials |
| +// themselves — the escalation cut spec §3 (docs/superpowers/specs/ |
| +// 2026-08-18-service-principals-design.md) turns on, enforced console-side |
| +// before any handler in apiservices.go runs. This file re-checks none of |
| +// that; it is the terminal surface on top of doors that already refuse |
| +// everything but an owner's own session. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "regexp" |
| + "strings" |
| + "text/tabwriter" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +const servicesUsage = `usage: carlos services <create|ls|rotate|revoke> [flags] |
| + |
| + create -role <publish|instance|operate|admin> [-app <app>] [-account <sqid|name>] <name> |
| + mint a credential a SERVER holds, bound to this account — |
| + not to you. Unlike your own CLI token, its reach is fixed |
| + at mint: adding you to another account later does not widen |
| + it, and it draws on its own rate-limit budgets rather than |
| + yours. |
| + THE SECRET IS PRINTED EXACTLY ONCE, HERE. Only its hash is |
| + stored, so losing this output means rotate, not a re-run. |
| + -role admin INCLUDES ` + "`store grant`" + `, which mints a |
| + path-scoped IAM user. It is a real handover, not a |
| + convenience bundle. Prefer the narrowest role that works. |
| + -app binds it to ONE app, which also means it cannot create |
| + apps. Omit -app for an account-scoped credential. |
| + |
| + ls [-account <sqid|name>] |
| + list every LIVE credential the account owns — name, role, |
| + scope, when it was minted and last rotated and used, and |
| + who minted it. A revoked credential does not appear; there |
| + is no history view here. |
| + |
| + rotate [-account <sqid|name>] <name> |
| + mint a FRESH secret for a credential that already exists, |
| + printed exactly once. This refuses the credential's NEXT |
| + call with the old secret. ROTATION IS NOT REVOCATION: it |
| + does not stop work already in flight, and a credential that |
| + may be COMPROMISED needs revoke. |
| + |
| + revoke [-account <sqid|name>] <name-or-id> |
| + the hard stop rotate is not: the credential's next call |
| + fails, whatever secret it holds. Idempotent — revoking a |
| + name twice, or one that never existed, answers the same. |
| + Takes a credential's name, or the listing id ` + "`ls`" + ` shows |
| + (the only handle a nameless legacy credential has). |
| + |
| + -account <sqid|name> account the credential belongs to (default: your only |
| + account, or the configured/chosen one) — |
| + 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), owner-only, and |
| +closed to a service credential itself — no service credential can create, |
| +rotate or revoke any credential, not even itself, not even an admin one. |
| +Service credentials are console-tracked state with no bucket-direct fallback. |
| +` |
| + |
| +// RunServices dispatches the services sub-verbs. |
| +func RunServices(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, servicesUsage) |
| + os.Exit(1) |
| + } |
| + switch args[0] { |
| + case "create": |
| + runServicesCreate(args[1:]) |
| + case "ls": |
| + runServicesList(args[1:]) |
| + case "rotate": |
| + runServicesRotate(args[1:]) |
| + case "revoke": |
| + runServicesRevoke(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, servicesUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// servicesSignedInSession resolves API mode and fatals with a |
| +// services-specific message when this shell is in direct bucket mode — |
| +// every services verb is console-API-only, the same posture |
| +// fleetsSignedInSession takes: a service credential is console-tracked |
| +// state with nothing in the bucket to fall back to. |
| +func servicesSignedInSession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("services works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Service credentials are\n" + |
| + "console-tracked state with no bucket-direct fallback: unset the store env and run\n" + |
| + "carlos auth login -console <url> instead.") |
| + } |
| + return sess |
| +} |
| + |
| +// validServiceName is the client-side gate for a credential name, checked |
| +// BEFORE any API call — the same "a shape the server is certain to refuse |
| +// should cost nothing" principle apps.go's validAppName states, and fleets.go's |
| +// validFleetName/validBoxLabel already apply to the fleets family. Uses |
| +// registry.ValidTunnelName, the same grammar handleAPIServiceCreate checks |
| +// server-side. |
| +func validServiceName(name string) error { |
| + if registry.ValidTunnelName(name) { |
| + return nil |
| + } |
| + return fmt.Errorf("%q is not a usable service credential name — 1-32 characters of a-z, 0-9 and hyphen", name) |
| +} |
| + |
| +// listingIDRe mirrors internal/console/apiservices.go's own listingIDRe: the |
| +// shape of a listing id, 64 hex characters, accepted in either case. Kept as |
| +// its own copy rather than an import — this package has no reach into |
| +// internal/console, and the shape is simple enough that duplicating it here |
| +// costs nothing and drifts from nothing (both sides check "is this 64 hex |
| +// characters", not a computed value). |
| +var listingIDRe = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) |
| + |
| +// validServiceHandle is revoke's client-side gate: a segment must be EITHER |
| +// a usable credential name OR a 64-character hex listing id — the same two |
| +// disjoint grammars serviceHandleFrom accepts server-side. Checked before |
| +// any API call, for the same cost-nothing reason validServiceName is. |
| +func validServiceHandle(handle string) error { |
| + if registry.ValidTunnelName(handle) || listingIDRe.MatchString(handle) { |
| + return nil |
| + } |
| + return fmt.Errorf("%q is not a usable service credential name or listing id — "+ |
| + "a name (1-32 characters of a-z, 0-9 and hyphen) or a 64-character hex id", handle) |
| +} |
| + |
| +func runServicesCreate(args []string) { |
| + fs := flag.NewFlagSet("services create", flag.ExitOnError) |
| + role := fs.String("role", "", "role: publish, instance, operate or admin (required)") |
| + app := fs.String("app", "", "bind the credential to one app (default: account-wide)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) to mint the credential in (default: your only account, or the configured/chosen one)") |
| + 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) |
| + const usageMsg = "usage: carlos services create -role <publish|instance|operate|admin> [-app <app>] " + |
| + "[-account <sqid|name>] [-account-id <sqid>] [-console <origin>] <name>" |
| + if fs.NArg() != 1 || strings.TrimSpace(*role) == "" { |
| + log.Fatal(usageMsg) |
| + } |
| + name := strings.TrimSpace(fs.Arg(0)) |
| + if err := validServiceName(name); err != nil { |
| + log.Fatalf("services create: %v", err) |
| + } |
| + roleV := strings.TrimSpace(*role) |
| + if _, ok := svcrole.Parse(roleV); !ok { |
| + log.Fatalf("services create: unknown role %q — one of: %s", roleV, strings.Join(roleNames(), ", ")) |
| + } |
| + appV := strings.TrimSpace(*app) |
| + |
| + sess := servicesSignedInSession(*consoleFlag) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + token, doors, err := sess.createService(context.Background(), acct, name, roleV, appV) |
| + if err != nil { |
| + log.Fatalf("services create: %v", err) |
| + } |
| + printServiceCreated(acct.Seg, name, roleV, appV, token, doors) |
| +} |
| + |
| +// roleNames is svcrole's catalogue as strings, read from the package rather |
| +// than hand-listed here — the same "read it from the source of truth" |
| +// discipline doorsFor (internal/console/apiservices.go) applies to the door |
| +// list itself. |
| +func roleNames() []string { |
| + roles := svcrole.All() |
| + out := make([]string, len(roles)) |
| + for i, r := range roles { |
| + out[i] = string(r) |
| + } |
| + return out |
| +} |
| + |
| +// printServiceCreated is the ONE place a service credential's secret |
| +// reaches stdout on create — printed exactly once, immediately, with the |
| +// scope it was minted into stated in words (requirement: an app-scoped |
| +// credential's door list must not read as "any app"; the {app} wildcard in |
| +// every releases/promote/etc. pattern below is only ever true for THIS app), |
| +// the doors it reaches, how to deliver it, and how to revoke it. |
| +// |
| +// The deliver line shows a literal "<secret>" placeholder rather than the |
| +// token itself: the secret appears exactly once in this output, on the |
| +// CARLOS_SERVICE_TOKEN= line above, not a second time inside a copy-paste |
| +// command. There is only one plaintext copy of this credential ever |
| +// (only its hash is stored), and this print is it. |
| +func printServiceCreated(acctSeg, name, role, app, token string, doors []string) { |
| + scope := "account-wide" |
| + if app != "" { |
| + scope = "app " + app |
| + } |
| + fmt.Fprintf(stdout, "minted service:%s/%s (role %s, %s)\n\n", acctSeg, name, role, scope) |
| + fmt.Fprintf(stdout, " CARLOS_SERVICE_TOKEN=%s\n\n", token) |
| + fmt.Fprintln(stdout, " ^ shown once. Only its hash is stored — a lost secret means rotate, not a re-run.") |
| + fmt.Fprintln(stdout) |
| + if app != "" { |
| + fmt.Fprintf(stdout, "it may (bound to app %s ONLY — despite the {app} wildcard shown below):\n", app) |
| + } else { |
| + fmt.Fprintln(stdout, "it may:") |
| + } |
| + for _, d := range doors { |
| + method, path, ok := strings.Cut(d, " ") |
| + if !ok { |
| + fmt.Fprintf(stdout, " %s\n", d) |
| + continue |
| + } |
| + fmt.Fprintf(stdout, " %-6s %s\n", method, path) |
| + } |
| + fmt.Fprintln(stdout) |
| + fmt.Fprintln(stdout, "deliver it: carlos env set -app <app> -secret CARLOS_SERVICE_TOKEN=<secret>") |
| + fmt.Fprintf(stdout, "revoke it: carlos services revoke -account %s %s\n", acctSeg, name) |
| +} |
| + |
| +func runServicesList(args []string) { |
| + fs := flag.NewFlagSet("services ls", flag.ExitOnError) |
| + acctRaw := fs.String("account", "", "account (sqid or name) to list credentials for (default: your only account, or the configured/chosen one)") |
| + 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 := servicesSignedInSession(*consoleFlag) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + rows, err := sess.listServices(context.Background(), acct) |
| + if err != nil { |
| + log.Fatalf("services ls: %v", err) |
| + } |
| + if len(rows) == 0 { |
| + fmt.Fprintln(stdout, "no service credentials") |
| + return |
| + } |
| + w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| + fmt.Fprintln(w, "NAME\tROLE\tAPP\tLABEL\tCREATED\tROTATED\tLAST USED\tMINTED BY") |
| + for _, r := range rows { |
| + name, label, app := r.Name, r.Label, r.App |
| + if name == "" { |
| + name = "-" |
| + } |
| + if label == "" { |
| + label = "-" |
| + } |
| + if app == "" { |
| + app = "-" |
| + } |
| + created, rotated, lastUsed := r.Created, r.Rotated, r.LastUsed |
| + if created == "" { |
| + created = "-" |
| + } |
| + if rotated == "" { |
| + rotated = "-" |
| + } |
| + if lastUsed == "" { |
| + lastUsed = "-" |
| + } |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| + name, r.Role, app, label, created, rotated, lastUsed, r.MintedBy) |
| + } |
| + w.Flush() |
| +} |
| + |
| +func runServicesRotate(args []string) { |
| + fs := flag.NewFlagSet("services rotate", flag.ExitOnError) |
| + acctRaw := fs.String("account", "", "account (sqid or name) the credential belongs to (default: your only account, or the configured/chosen one)") |
| + 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() != 1 { |
| + log.Fatal("usage: carlos services rotate [-account <sqid|name>] [-account-id <sqid>] " + |
| + "[-console <origin>] <name>") |
| + } |
| + name := strings.TrimSpace(fs.Arg(0)) |
| + if err := validServiceName(name); err != nil { |
| + log.Fatalf("services rotate: %v", err) |
| + } |
| + |
| + sess := servicesSignedInSession(*consoleFlag) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + token, err := sess.rotateService(context.Background(), acct, name) |
| + if err != nil { |
| + log.Fatalf("services rotate: %v", err) |
| + } |
| + printServiceRotated(name, token) |
| +} |
| + |
| +// printServiceRotated is the ONE place a rotated secret reaches stdout — |
| +// printed exactly once, with the same ROTATION IS NOT REVOCATION warning |
| +// printFleetToken carries for fleets, in this family's own words: rotation |
| +// refuses the credential's NEXT call with the old secret, but does not stop |
| +// anything already in flight, and it is not what a compromised credential |
| +// needs — that is revoke. |
| +func printServiceRotated(name, token string) { |
| + fmt.Fprintf(stdout, "rotated %s\n\n", name) |
| + fmt.Fprintf(stdout, " CARLOS_SERVICE_TOKEN=%s\n\n", token) |
| + fmt.Fprintln(stdout, " ^ shown once. Only its hash is stored — a lost secret means rotate again, not a re-run.") |
| + fmt.Fprintln(stdout) |
| + fmt.Fprintln(stdout, "ROTATION IS NOT REVOCATION: this refuses the credential's NEXT call with the OLD") |
| + fmt.Fprintln(stdout, "secret, but does not stop work already in flight — a credential that may be") |
| + fmt.Fprintf(stdout, "COMPROMISED needs `carlos services revoke -account <sqid|name> %s`, not another rotate.\n", name) |
| +} |
| + |
| +func runServicesRevoke(args []string) { |
| + fs := flag.NewFlagSet("services revoke", flag.ExitOnError) |
| + acctRaw := fs.String("account", "", "account (sqid or name) the credential belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + consoleFlag := fs.String("console", "", "console to revoke through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if fs.NArg() != 1 { |
| + log.Fatal("usage: carlos services revoke [-account <sqid|name>] [-account-id <sqid>] " + |
| + "[-console <origin>] <name-or-id>") |
| + } |
| + handle := strings.TrimSpace(fs.Arg(0)) |
| + if err := validServiceHandle(handle); err != nil { |
| + log.Fatalf("services revoke: %v", err) |
| + } |
| + |
| + sess := servicesSignedInSession(*consoleFlag) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + matched, err := sess.revokeService(context.Background(), acct, handle) |
| + if err != nil { |
| + log.Fatalf("services revoke: %v", err) |
| + } |
| + // matched is the field this door was given for (C41): the request |
| + // itself is idempotent and always answers 200, but a false matched |
| + // means the handle killed nothing — most often a typo. Printing that |
| + // as plain "revoked" is the exact failure the field exists to catch, |
| + // at the door used during an incident, so the two cases get visibly |
| + // different wording. Both still exit 0: the state the caller wanted — |
| + // no live credential of that handle — is the state that holds either |
| + // way. |
| + if matched { |
| + fmt.Fprintf(stdout, "revoked %s — its next call fails, whatever secret it holds\n", handle) |
| + return |
| + } |
| + fmt.Fprintf(stdout, "no live credential of %q — nothing to revoke (already revoked, or never existed)\n", handle) |
| +} |
diff --git a/services_cli_test.go b/services_cli_test.go| new file mode 100644 |
| index 000000000..06eb0b060 |
| --- /dev/null |
| +++ b/services_cli_test.go |
| @@ -0,0 +1,569 @@ |
| +// Tests for the `carlos services` verbs — the CLI door onto |
| +// internal/console/apiservices.go (Task 7). Same three layers |
| +// fleets_cli_test.go uses, for the same reasons (see that file's package |
| +// doc): |
| +// |
| +// - apiSession-level tests drive the four 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. |
| +// - RunServices-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 |
| +// fleetsCLIFixture) — this is what proves the secret really reaches |
| +// stdout exactly once, the door list prints, and the rotate/revoke |
| +// wording is really in the output. |
| +// - subprocess tests (buildCarlos-style, like auth_e2e_test.go and |
| +// fleets_cli_test.go's TestFleetsAndPlaceCLIUsageErrors) drive the |
| +// bad-argument paths, which call log.Fatal/os.Exit and would kill an |
| +// in-process test binary outright. |
| +// |
| +// EVERY test here that shells out to the built carlos binary builds its env |
| +// through carlosTestEnv (masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE) and |
| +// sets cmd.Dir to its own scratch dir — CLAUDE.md's rule, restated in this |
| +// task's brief: a subprocess that inherited a real login has minted real |
| +// accounts on the flagship before. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| +) |
| + |
| +// ---- pure grammar gate ----------------------------------------------------- |
| + |
| +func TestValidServiceName(t *testing.T) { |
| + for _, good := range []string{"acme", "amadan-hub", "a", strings.Repeat("a", 32)} { |
| + if err := validServiceName(good); err != nil { |
| + t.Errorf("validServiceName(%q) = %v, want nil", good, err) |
| + } |
| + } |
| + for _, bad := range []string{"", "Acme", "has spaces", "under_score", strings.Repeat("a", 33)} { |
| + if err := validServiceName(bad); err == nil { |
| + t.Errorf("validServiceName(%q) = nil, want an error", bad) |
| + } |
| + } |
| +} |
| + |
| +// TestValidServiceHandle: revoke's grammar is a name OR a 64-hex listing id, |
| +// in either case — the two grammars are disjoint by length so both can be |
| +// accepted by the same gate with no ambiguity. |
| +func TestValidServiceHandle(t *testing.T) { |
| + for _, good := range []string{ |
| + "acme", |
| + strings.Repeat("a", 64), |
| + strings.ToUpper(strings.Repeat("a", 64)), |
| + strings.Repeat("0123456789abcdef", 4), |
| + } { |
| + if err := validServiceHandle(good); err != nil { |
| + t.Errorf("validServiceHandle(%q) = %v, want nil", good, err) |
| + } |
| + } |
| + for _, bad := range []string{"", "Acme", "has spaces", strings.Repeat("a", 63), strings.Repeat("a", 65), strings.Repeat("g", 64)} { |
| + if err := validServiceHandle(bad); err == nil { |
| + t.Errorf("validServiceHandle(%q) = nil, want an error", bad) |
| + } |
| + } |
| +} |
| + |
| +// ---- apiSession-level: path + body + wire-shape round trips --------------- |
| + |
| +// TestAPISessionServiceCreateAndList: createService POSTs to the account |
| +// door with name/role/app in the body and reads back token+doors; listServices |
| +// GETs the same door and unmarshals the "services" wrapper. |
| +func TestAPISessionServiceCreateAndList(t *testing.T) { |
| + var gotPath, gotMethod string |
| + var gotBody struct{ Name, Role, App 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) |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "name": gotBody.Name, "role": gotBody.Role, "app": gotBody.App, |
| + "account": "bab", "token": "svc-secret-once", |
| + "doors": []string{"POST /api/cli/apps/{account}/new", "GET /api/cli/apps"}, |
| + }) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "services": []map[string]any{ |
| + {"id": strings.Repeat("a", 64), "name": "amadan-hub", "role": "publish", "app": "", |
| + "label": "", "created": "2026-08-18T00:00:00Z", "rotated": "2026-08-18T00:00:00Z", |
| + "last_used": "", "minted_by": "paul@keymail.dev"}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + token, doors, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "") |
| + if err != nil { |
| + t.Fatalf("createService: %v", err) |
| + } |
| + if gotPath != "/api/cli/services/bab" || gotMethod != http.MethodPost { |
| + t.Fatalf("request = %s %s, want POST /api/cli/services/bab", gotMethod, gotPath) |
| + } |
| + if gotBody.Name != "amadan-hub" || gotBody.Role != "publish" || gotBody.App != "" { |
| + t.Fatalf("body = %+v", gotBody) |
| + } |
| + if token != "svc-secret-once" || len(doors) != 2 { |
| + t.Fatalf("createService result: token=%q doors=%v", token, doors) |
| + } |
| + |
| + rows, err := s.listServices(context.Background(), apiAcct{Seg: "bab"}) |
| + if err != nil { |
| + t.Fatalf("listServices: %v", err) |
| + } |
| + if gotPath != "/api/cli/services/bab" || gotMethod != http.MethodGet { |
| + t.Fatalf("request = %s %s, want GET /api/cli/services/bab", gotMethod, gotPath) |
| + } |
| + if len(rows) != 1 || rows[0].Name != "amadan-hub" || rows[0].Role != "publish" || rows[0].MintedBy != "paul@keymail.dev" { |
| + t.Fatalf("listServices result = %+v", rows) |
| + } |
| +} |
| + |
| +// TestAPISessionServiceCreateExactAccountID: -account-id travels as |
| +// exact=sqid, same as every other apiPath/fleetPath-built call. |
| +func TestAPISessionServiceCreateExactAccountID(t *testing.T) { |
| + var gotQuery string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotQuery = r.URL.RawQuery |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{"name": "x", "role": "publish", "app": "", "account": "bab", "token": "t", "doors": []string{}}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + if _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "publish", ""); err != nil { |
| + t.Fatalf("createService: %v", err) |
| + } |
| + if gotQuery != "exact=sqid" { |
| + t.Fatalf("query = %q, want exact=sqid", gotQuery) |
| + } |
| +} |
| + |
| +// TestAPISessionServiceCreateDuplicateName: the server's 409 sentence |
| +// surfaces verbatim, same convention as fleets create's duplicate refusal. |
| +func TestAPISessionServiceCreateDuplicateName(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": "a credential named amadan-hub already exists in this account"}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + if _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", ""); err == nil || |
| + err.Error() != "a credential named amadan-hub already exists in this account" { |
| + t.Fatalf("createService duplicate = %v, want the server's sentence", err) |
| + } |
| +} |
| + |
| +// TestAPISessionServiceRotateAndRevoke: rotate POSTs to the name-addressed |
| +// rotate door and returns a fresh token; revoke POSTs to the revoke door and |
| +// returns the matched boolean — the field that tells the caller whether |
| +// anything was actually revoked. |
| +func TestAPISessionServiceRotateAndRevoke(t *testing.T) { |
| + var gotPath string |
| + step := "rotate" |
| + matched := true |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + switch step { |
| + case "rotate": |
| + json.NewEncoder(w).Encode(map[string]any{"name": "amadan-hub", "account": "bab", "token": "fresh-secret"}) |
| + case "revoke": |
| + json.NewEncoder(w).Encode(map[string]any{"result": "revoked", "matched": matched}) |
| + } |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + acct := apiAcct{Seg: "bab"} |
| + |
| + tok, err := s.rotateService(context.Background(), acct, "amadan-hub") |
| + if err != nil { |
| + t.Fatalf("rotateService: %v", err) |
| + } |
| + if gotPath != "/api/cli/services/bab/amadan-hub/rotate" || tok != "fresh-secret" { |
| + t.Fatalf("rotateService: path=%s token=%s", gotPath, tok) |
| + } |
| + |
| + step = "revoke" |
| + m, err := s.revokeService(context.Background(), acct, "amadan-hub") |
| + if err != nil { |
| + t.Fatalf("revokeService: %v", err) |
| + } |
| + if gotPath != "/api/cli/services/bab/amadan-hub/revoke" || !m { |
| + t.Fatalf("revokeService: path=%s matched=%v, want matched=true", gotPath, m) |
| + } |
| + |
| + matched = false |
| + m, err = s.revokeService(context.Background(), acct, "typo-name") |
| + if err != nil { |
| + t.Fatalf("revokeService (unmatched): %v", err) |
| + } |
| + if m { |
| + t.Fatal("revokeService matched=true, want false — the server said nothing matched") |
| + } |
| +} |
| + |
| +// TestAPISessionServiceDoorsSurfaceServerErrors: a non-2xx from any of the |
| +// four doors surfaces the server's own message, not a swallowed one. |
| +func TestAPISessionServiceDoorsSurfaceServerErrors(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusForbidden) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "only an account owner can manage service credentials"}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + acct := apiAcct{Seg: "bab"} |
| + |
| + if _, _, err := s.createService(context.Background(), acct, "x", "publish", ""); err == nil { |
| + t.Fatal("createService against a 403 must error") |
| + } |
| + if _, err := s.listServices(context.Background(), acct); err == nil { |
| + t.Fatal("listServices against a 403 must error") |
| + } |
| + if _, err := s.rotateService(context.Background(), acct, "x"); err == nil { |
| + t.Fatal("rotateService against a 403 must error") |
| + } |
| + if _, err := s.revokeService(context.Background(), acct, "x"); err == nil { |
| + t.Fatal("revokeService against a 403 must error") |
| + } |
| +} |
| + |
| +// ---- RunServices-level: full verb, good args, real printing --------------- |
| + |
| +// fakeServicesConsole answers the four CLI endpoints RunServices reaches, |
| +// stateful enough to prove create then rotate return DISTINCT secrets and |
| +// that revoke's matched flag really drives what RunServices prints. |
| +type fakeServicesConsole struct { |
| + ts *httptest.Server |
| + |
| + tokenSeq atomic.Int64 |
| + |
| + lastCreateBody atomic.Value // struct{ Name, Role, App string } |
| + lastRevokeName atomic.Value // string |
| + revokeMatched atomic.Bool |
| +} |
| + |
| +func newFakeServicesConsole(t *testing.T) *fakeServicesConsole { |
| + t.Helper() |
| + fc := &fakeServicesConsole{} |
| + fc.revokeMatched.Store(true) |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("POST /api/cli/services/{account}", func(w http.ResponseWriter, r *http.Request) { |
| + var req struct{ Name, Role, App string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + fc.lastCreateBody.Store(req) |
| + n := fc.tokenSeq.Add(1) |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "name": req.Name, "role": req.Role, "app": req.App, "account": "bab", |
| + "token": tokenFor(n), |
| + "doors": []string{ |
| + "POST /api/cli/apps/{account}/{app}/releases", |
| + "POST /api/cli/apps/{account}/{app}/promote", |
| + "GET /api/cli/apps", |
| + }, |
| + }) |
| + }) |
| + mux.HandleFunc("GET /api/cli/services/{account}", func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "services": []map[string]any{ |
| + {"id": strings.Repeat("a", 64), "name": "amadan-hub", "role": "publish", "app": "titogo", |
| + "label": "", "created": "2026-08-18T00:00:00Z", "rotated": "2026-08-18T00:00:00Z", |
| + "last_used": "2026-08-18T01:00:00Z", "minted_by": "paul@keymail.dev"}, |
| + {"id": strings.Repeat("b", 64), "name": "", "role": "instance", "app": "router", |
| + "label": "router", "created": "2026-08-01T00:00:00Z", "rotated": "", |
| + "last_used": "", "minted_by": "paul@keymail.dev"}, |
| + }, |
| + }) |
| + }) |
| + mux.HandleFunc("POST /api/cli/services/{account}/{name}/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/services/{account}/{name}/revoke", func(w http.ResponseWriter, r *http.Request) { |
| + fc.lastRevokeName.Store(r.PathValue("name")) |
| + json.NewEncoder(w).Encode(map[string]any{"result": "revoked", "matched": fc.revokeMatched.Load()}) |
| + }) |
| + fc.ts = httptest.NewServer(mux) |
| + t.Cleanup(fc.ts.Close) |
| + return fc |
| +} |
| + |
| +// servicesCLIFixture wires up a fake console plus a credential pointed at |
| +// it, entirely in-process (no subprocess, no real login flow) — the same |
| +// shortcut fleetsCLIFixture takes. |
| +func servicesCLIFixture(t *testing.T) *fakeServicesConsole { |
| + 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 := newFakeServicesConsole(t) |
| + writeTestCredential(t, creds, fc.ts.URL) |
| + return fc |
| +} |
| + |
| +// TestServicesCreatePrintsTheSecretAndTheDoors — the secret appears exactly |
| +// once, in this output, and the door list is printed with it so an operator |
| +// sees what they granted at the moment they grant it. |
| +func TestServicesCreatePrintsTheSecretAndTheDoors(t *testing.T) { |
| + fc := servicesCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunServices([]string{"create", "-role", "publish", "-account", "bab", "amadan-hub"}) |
| + |
| + got := fc.lastCreateBody.Load().(struct{ Name, Role, App string }) |
| + if got.Name != "amadan-hub" || got.Role != "publish" || got.App != "" { |
| + t.Fatalf("console saw create body = %+v", got) |
| + } |
| + out := buf.String() |
| + tok := tokenFor(1) |
| + if n := strings.Count(out, tok); n != 1 { |
| + t.Fatalf("secret %q appeared %d times in stdout, want exactly 1:\n%s", tok, n, out) |
| + } |
| + if !strings.Contains(out, "shown once") { |
| + t.Fatalf("stdout missing the shown-once warning:\n%s", out) |
| + } |
| + for _, want := range []string{ |
| + "POST", "/api/cli/apps/{account}/{app}/releases", |
| + "/api/cli/apps/{account}/{app}/promote", |
| + "GET", "/api/cli/apps", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("stdout missing door %q:\n%s", want, out) |
| + } |
| + } |
| + if !strings.Contains(out, "account-wide") { |
| + t.Fatalf("stdout missing the account-wide scope label for an -app-less create:\n%s", out) |
| + } |
| +} |
| + |
| +// TestServicesCreateAppScopedSaysSoNextToTheDoors — requirement 2: the door |
| +// list contains {app}-wildcarded patterns that read as "any app"; when the |
| +// credential is scoped to one app, RunServices must say so right where that |
| +// list is rendered, not just leave it to the -app flag the operator typed. |
| +func TestServicesCreateAppScopedSaysSoNextToTheDoors(t *testing.T) { |
| + servicesCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunServices([]string{"create", "-role", "publish", "-app", "titogo", "-account", "bab", "amadan-hub"}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "titogo") { |
| + t.Fatalf("stdout missing the app scope:\n%s", out) |
| + } |
| + if strings.Contains(out, "account-wide") { |
| + t.Fatalf("stdout claims account-wide for an -app-scoped credential:\n%s", out) |
| + } |
| +} |
| + |
| +// TestServicesCreateRefusesABadNameBeforeCalling — a shape the server is |
| +// certain to refuse should cost nothing, the principle apps.go's |
| +// validAppName states. Subprocess: the refusal is a log.Fatal, which would |
| +// kill an in-process test binary. Builds its env through carlosTestEnv (with |
| +// CARLOS_CREDENTIALS overridden to point at a REAL, reachable fake console) |
| +// and runs from a scratch cmd.Dir, so this proves the name gate runs before |
| +// any network call even when a call would otherwise succeed. |
| +func TestServicesCreateRefusesABadNameBeforeCalling(t *testing.T) { |
| + bin := buildCarlos(t) |
| + var reqs atomic.Int64 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + reqs.Add(1) |
| + json.NewEncoder(w).Encode(map[string]any{}) |
| + })) |
| + defer ts.Close() |
| + |
| + creds := filepath.Join(t.TempDir(), "credentials") |
| + writeTestCredential(t, creds, ts.URL) |
| + scratch := t.TempDir() |
| + |
| + cmd := exec.Command(bin, "services", "create", "-role", "publish", "-account", "bab", "Bad Name") |
| + cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds) |
| + cmd.Dir = scratch |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("exited 0, want nonzero (bad name refused):\n%s", out) |
| + } |
| + if !strings.Contains(string(out), "not a usable service credential name") { |
| + t.Fatalf("output missing the name refusal:\n%s", out) |
| + } |
| + if n := reqs.Load(); n != 0 { |
| + t.Fatalf("console recorded %d requests, want 0 — the name gate must run before any API call", n) |
| + } |
| +} |
| + |
| +// TestRunServicesListPrintsEveryRow: `carlos services ls` prints one row per |
| +// credential, including a nameless legacy one, from the fake console's fixed |
| +// listing. |
| +func TestRunServicesListPrintsEveryRow(t *testing.T) { |
| + servicesCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunServices([]string{"ls", "-account", "bab"}) |
| + |
| + out := buf.String() |
| + for _, want := range []string{"amadan-hub", "publish", "titogo", "instance", "router"} { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("services ls stdout missing %q:\n%s", want, out) |
| + } |
| + } |
| +} |
| + |
| +// TestServicesRotateSaysWhatRotationIsNot — rotation refuses the |
| +// credential's next call with the old secret; it does not stop work already |
| +// in flight, and it is not what a compromised credential needs. That is |
| +// `revoke`. The fleets CLI makes the same distinction in words and this must |
| +// too. |
| +func TestServicesRotateSaysWhatRotationIsNot(t *testing.T) { |
| + servicesCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunServices([]string{"rotate", "-account", "bab", "amadan-hub"}) |
| + |
| + out := buf.String() |
| + tok := tokenFor(1) |
| + if n := strings.Count(out, tok); n != 1 { |
| + t.Fatalf("rotated secret appeared %d times, want exactly 1:\n%s", n, out) |
| + } |
| + if !strings.Contains(out, "shown once") { |
| + t.Fatalf("stdout missing the shown-once warning:\n%s", out) |
| + } |
| + lower := strings.ToLower(out) |
| + if !strings.Contains(lower, "not revocation") && !strings.Contains(lower, "is not revocation") { |
| + t.Fatalf("stdout missing the ROTATION IS NOT REVOCATION wording:\n%s", out) |
| + } |
| + if !strings.Contains(out, "revoke") { |
| + t.Fatalf("stdout missing the pointer to revoke for a COMPROMISED credential:\n%s", out) |
| + } |
| +} |
| + |
| +// TestRunServicesRevokeMatchedTrue: an ordinary revoke of a live credential |
| +// prints an unambiguous "revoked" and exits 0. |
| +func TestRunServicesRevokeMatchedTrue(t *testing.T) { |
| + fc := servicesCLIFixture(t) |
| + fc.revokeMatched.Store(true) |
| + buf := captureStdout(t) |
| + |
| + RunServices([]string{"revoke", "-account", "bab", "amadan-hub"}) |
| + |
| + if got := fc.lastRevokeName.Load(); got != "amadan-hub" { |
| + t.Fatalf("console saw revoke name %v, want amadan-hub", got) |
| + } |
| + if !strings.Contains(buf.String(), "revoked") { |
| + t.Fatalf("stdout = %q, want it to confirm the revoke", buf.String()) |
| + } |
| +} |
| + |
| +// TestRunServicesRevokeMatchedFalseDoesNotPrintSuccess — the door used |
| +// during an incident. `matched: false` must not print as plain "revoked": |
| +// that is the exact failure this field was added to prevent (a typo'd name |
| +// reading as success). It must still exit 0 — the state the caller wanted |
| +// (no live credential of that name) is the state that holds. |
| +func TestRunServicesRevokeMatchedFalseDoesNotPrintSuccess(t *testing.T) { |
| + fc := servicesCLIFixture(t) |
| + fc.revokeMatched.Store(false) |
| + buf := captureStdout(t) |
| + |
| + RunServices([]string{"revoke", "-account", "bab", "typo-name"}) |
| + |
| + out := buf.String() |
| + if strings.TrimSpace(out) == "" { |
| + t.Fatal("revoke with matched=false printed nothing") |
| + } |
| + lower := strings.ToLower(out) |
| + if lower == "revoked\n" || strings.HasPrefix(strings.TrimSpace(lower), "revoked typo-name") { |
| + t.Fatalf("stdout reads as plain success for matched=false:\n%s", out) |
| + } |
| + if !strings.Contains(lower, "nothing to revoke") && !strings.Contains(lower, "no live credential") { |
| + t.Fatalf("stdout missing the matched=false wording:\n%s", out) |
| + } |
| +} |
| + |
| +// ---- subprocess: the usage-error / bad-argument paths --------------------- |
| + |
| +// TestServicesCLIUsageErrors drives every services verb with missing or |
| +// malshaped arguments as a real subprocess (buildCarlos, same helper |
| +// auth_e2e_test.go's TestAuthCLI and fleets_cli_test.go's |
| +// TestFleetsAndPlaceCLIUsageErrors use) — 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 TestServicesCLIUsageErrors(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 |
| + }{ |
| + {"services no args", []string{"services"}, "usage: carlos services"}, |
| + {"services bogus verb", []string{"services", "bogus"}, "usage: carlos services"}, |
| + {"services create no args", []string{"services", "create"}, "usage: carlos services create"}, |
| + {"services create missing role", []string{"services", "create", "-account", "bab", "amadan-hub"}, "usage: carlos services create"}, |
| + {"services create bad role", []string{"services", "create", "-role", "wizard", "-account", "bab", "amadan-hub"}, "unknown role"}, |
| + {"services create bad name", []string{"services", "create", "-role", "publish", "-account", "bab", "Bad Name"}, "not a usable service credential name"}, |
| + {"services rotate no args", []string{"services", "rotate"}, "usage: carlos services rotate"}, |
| + {"services rotate bad name", []string{"services", "rotate", "-account", "bab", "Bad Name"}, "not a usable service credential name"}, |
| + {"services revoke no args", []string{"services", "revoke"}, "usage: carlos services revoke"}, |
| + {"services revoke bad handle", []string{"services", "revoke", "-account", "bab", "Bad Name"}, "not a usable"}, |
| + } { |
| + 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) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestServicesDirectBucketModeRefusesWithServicesSpecificMessage: signed-out |
| +// / direct-bucket-mode shells get a services-specific fatal message, the |
| +// same posture fleetsSignedInSession takes for `carlos fleets`. |
| +func TestServicesDirectBucketModeRefusesWithServicesSpecificMessage(t *testing.T) { |
| + bin := buildCarlos(t) |
| + storeDir := filepath.Join(t.TempDir(), "store") |
| + if err := os.MkdirAll(storeDir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + scratch := t.TempDir() |
| + |
| + cmd := exec.Command(bin, "services", "ls") |
| + cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| + cmd.Dir = scratch |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("exited 0, want nonzero (direct bucket mode has no services door):\n%s", out) |
| + } |
| + if !strings.Contains(string(out), "services works through a console") { |
| + t.Fatalf("output missing the services-specific direct-bucket-mode message:\n%s", out) |
| + } |
| +} |