cli: services create --rungs and a RUNGS column (#229)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
3 files changed,
+104
−24
apimode.go+14 −9services.go+28 −10services_cli_test.go+62 −5
diff --git a/apimode.go b/apimode.go| index be71d6130..2e75665df 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -4292,6 +4292,9 @@ type apiServiceView struct { |
| Rotated string `json:"rotated"` |
| LastUsed string `json:"last_used"` |
| MintedBy string `json:"minted_by"` |
| + // Rungs is a deploy credential's rungs, canonical order; empty for every |
| + // other role (the console never sends null). |
| + Rungs []string `json:"rungs"` |
| } |
| // createService mints one account-owned service credential, optionally |
| @@ -4303,22 +4306,24 @@ type apiServiceView struct { |
| // (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) { |
| +func (s *apiSession) createService(ctx context.Context, a apiAcct, name, role, app string, rungs []string) (token string, doors []string, gotRungs []string, err error) { |
| in := struct { |
| - Name string `json:"name"` |
| - Role string `json:"role"` |
| - App string `json:"app"` |
| - }{name, role, app} |
| + Name string `json:"name"` |
| + Role string `json:"role"` |
| + App string `json:"app"` |
| + Rungs []string `json:"rungs,omitempty"` |
| + }{name, role, app, rungs} |
| status, body, err := s.postJSON(ctx, servicePath(a, ""), in) |
| if err != nil { |
| - return "", nil, err |
| + return "", nil, nil, err |
| } |
| if status != http.StatusCreated { |
| - return "", nil, apiErrorFromBody(status, body) |
| + return "", nil, nil, apiErrorFromBody(status, body) |
| } |
| var res struct { |
| Token string `json:"token"` |
| Doors []string `json:"doors"` |
| + Rungs []string `json:"rungs"` |
| } |
| if err := json.Unmarshal(body, &res); err != nil { |
| // The 201 already happened: the credential exists server-side and |
| @@ -4326,11 +4331,11 @@ func (s *apiSession) createService(ctx context.Context, a apiAcct, name, role, a |
| // — was in this unreadable body. Silence here would leave the |
| // operator not knowing the mint even landed. Point at the one way |
| // to recover a working secret for it. |
| - return "", nil, fmt.Errorf("services create %s: minted, but the response was unreadable (%v) — "+ |
| + return "", nil, nil, fmt.Errorf("services create %s: minted, but the response was unreadable (%v) — "+ |
| "the credential exists and its only plaintext copy is lost; check `carlos services ls` "+ |
| "and rotate it", name, err) |
| } |
| - return res.Token, res.Doors, nil |
| + return res.Token, res.Doors, res.Rungs, nil |
| } |
| // listServices asks the console for every LIVE credential the caller's |
diff --git a/services.go b/services.go| index a059d1b1e..777c470de 100644 |
| --- a/services.go |
| +++ b/services.go |
| @@ -154,15 +154,16 @@ func validServiceHandle(handle string) error { |
| func runServicesCreate(args []string) { |
| fs := flag.NewFlagSet("services create", flag.ExitOnError) |
| - role := fs.String("role", "", "role: publish, instance, operate, service or admin (required)") |
| + role := fs.String("role", "", "role: publish, deploy, instance, operate, service or admin (required)") |
| app := appFlag(fs, "bind the credential to one app (default: account-wide)") |
| + rungsRaw := fs.String("rungs", "", "rungs a deploy credential may move, comma-separated: canary, edge, beta (required with --role deploy)") |
| 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 := consoleOption(fs, "console to create it through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| - const usageMsg = "usage: carlos services create --role <publish|instance|operate|service|admin> [--app <app>] " + |
| - "[--account <sqid|name>] [--account-id <sqid>] [--console <origin>] <name>" |
| + const usageMsg = "usage: carlos services create --role <publish|deploy|instance|operate|service|admin> [--app <app>] " + |
| + "[--rungs <list>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>] <name>" |
| if fs.NArg() != 1 || strings.TrimSpace(*role) == "" { |
| log.Fatal(usageMsg) |
| } |
| @@ -174,16 +175,25 @@ func runServicesCreate(args []string) { |
| if _, ok := svcrole.Parse(roleV); !ok { |
| log.Fatalf("services create: unknown role %q — one of: %s", roleV, strings.Join(roleNames(), ", ")) |
| } |
| + var rungsV []string |
| + for _, r := range strings.Split(*rungsRaw, ",") { |
| + if r = strings.TrimSpace(r); r != "" { |
| + rungsV = append(rungsV, r) |
| + } |
| + } |
| + if len(rungsV) > 0 && roleV != string(svcrole.Deploy) { |
| + log.Fatal("services create: --rungs is only for --role deploy") |
| + } |
| 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) |
| + token, doors, gotRungs, err := sess.createService(context.Background(), acct, name, roleV, appV, rungsV) |
| if err != nil { |
| log.Fatalf("services create: %v", err) |
| } |
| - printServiceCreated(acct.Seg, name, roleV, appV, token, doors) |
| + printServiceCreated(acct.Seg, name, roleV, appV, gotRungs, token, doors) |
| } |
| // roleNames is svcrole's catalogue as strings, read from the package rather |
| @@ -233,12 +243,16 @@ func deliverLine(appHint string) string { |
| // 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. |
| -func printServiceCreated(acctSeg, name, role, app, token string, doors []string) { |
| +func printServiceCreated(acctSeg, name, role, app string, rungs []string, 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) |
| + if role == string(svcrole.Deploy) { |
| + fmt.Fprintf(stdout, "minted service:%s/%s (role deploy, app %s, rungs %s)\n\n", acctSeg, name, app, strings.Join(rungs, ", ")) |
| + } else { |
| + 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) |
| @@ -289,7 +303,7 @@ func runServicesList(args []string) { |
| return |
| } |
| w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| - fmt.Fprintln(w, "NAME\tROLE\tAPP\tLABEL\tCREATED\tROTATED\tLAST USED\tMINTED BY") |
| + fmt.Fprintln(w, "NAME\tROLE\tAPP\tRUNGS\tLABEL\tCREATED\tROTATED\tLAST USED\tMINTED BY") |
| for _, r := range rows { |
| name, label, app := r.Name, r.Label, r.App |
| if name == "" { |
| @@ -301,6 +315,10 @@ func runServicesList(args []string) { |
| if app == "" { |
| app = "-" |
| } |
| + rungs := strings.Join(r.Rungs, ",") |
| + if rungs == "" { |
| + rungs = "-" |
| + } |
| created, rotated, lastUsed := r.Created, r.Rotated, r.LastUsed |
| if created == "" { |
| created = "-" |
| @@ -311,8 +329,8 @@ func runServicesList(args []string) { |
| 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) |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| + name, r.Role, app, rungs, label, created, rotated, lastUsed, r.MintedBy) |
| } |
| w.Flush() |
| } |
diff --git a/services_cli_test.go b/services_cli_test.go| index 2a47204bf..9684f14e8 100644 |
| --- a/services_cli_test.go |
| +++ b/services_cli_test.go |
| @@ -27,11 +27,13 @@ package main |
| import ( |
| "context" |
| "encoding/json" |
| + "io" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| "os/exec" |
| "path/filepath" |
| + "reflect" |
| "strings" |
| "sync/atomic" |
| "testing" |
| @@ -104,7 +106,7 @@ func TestAPISessionServiceCreateAndList(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - token, doors, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "") |
| + token, doors, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil) |
| if err != nil { |
| t.Fatalf("createService: %v", err) |
| } |
| @@ -141,7 +143,7 @@ func TestAPISessionServiceCreateExactAccountID(t *testing.T) { |
| })) |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - if _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "publish", ""); err != nil { |
| + if _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "publish", "", nil); err != nil { |
| t.Fatalf("createService: %v", err) |
| } |
| if gotQuery != "exact=sqid" { |
| @@ -158,7 +160,7 @@ func TestAPISessionServiceCreateDuplicateName(t *testing.T) { |
| })) |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - if _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", ""); err == nil || |
| + if _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil); 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) |
| } |
| @@ -223,7 +225,7 @@ func TestAPISessionServiceDoorsSurfaceServerErrors(t *testing.T) { |
| s := newTestSession(ts.URL) |
| acct := apiAcct{Seg: "bab"} |
| - if _, _, err := s.createService(context.Background(), acct, "x", "publish", ""); err == nil { |
| + if _, _, _, err := s.createService(context.Background(), acct, "x", "publish", "", nil); err == nil { |
| t.Fatal("createService against a 403 must error") |
| } |
| if _, err := s.listServices(context.Background(), acct); err == nil { |
| @@ -251,7 +253,7 @@ func TestAPISessionServiceCreateUnreadableBodyTellsOperatorToRecover(t *testing. |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "") |
| + _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil) |
| if err == nil { |
| t.Fatal("createService with an unreadable 201 body must error") |
| } |
| @@ -827,3 +829,58 @@ func TestServicesDirectBucketModeRefusesWithServicesSpecificMessage(t *testing.T |
| t.Fatalf("output missing the services-specific direct-bucket-mode message:\n%s", out) |
| } |
| } |
| + |
| +// TestAPISessionServiceCreateCarriesRungs: the request body carries the |
| +// rungs exactly as given, the response's canonical order comes back as the |
| +// third return, and a listing row's rungs decode into apiServiceView.Rungs. |
| +func TestAPISessionServiceCreateCarriesRungs(t *testing.T) { |
| + var gotRaw []byte |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method == http.MethodPost { |
| + gotRaw, _ = io.ReadAll(r.Body) |
| + w.WriteHeader(http.StatusCreated) |
| + w.Write([]byte(`{"token":"t","doors":[],"rungs":["canary","edge"]}`)) |
| + return |
| + } |
| + w.Write([]byte(`{"services":[{"id":"x","name":"ci","role":"deploy","app":"hello","rungs":["edge"]}]}`)) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + _, _, gotRungs, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "ci", "deploy", "hello", []string{"edge", "canary"}) |
| + if err != nil { |
| + t.Fatalf("createService: %v", err) |
| + } |
| + if !strings.Contains(string(gotRaw), `"rungs":["edge","canary"]`) { |
| + t.Fatalf("request body = %s, want it to carry \"rungs\":[\"edge\",\"canary\"]", gotRaw) |
| + } |
| + if want := []string{"canary", "edge"}; !reflect.DeepEqual(gotRungs, want) { |
| + t.Fatalf("gotRungs = %v, want %v", gotRungs, want) |
| + } |
| + |
| + rows, err := s.listServices(context.Background(), apiAcct{Seg: "bab"}) |
| + if err != nil { |
| + t.Fatalf("listServices: %v", err) |
| + } |
| + if len(rows) != 1 || !reflect.DeepEqual(rows[0].Rungs, []string{"edge"}) { |
| + t.Fatalf("listServices rows = %+v, want one row with Rungs [edge]", rows) |
| + } |
| +} |
| + |
| +// TestServicesCreatePrintsTheRungsForADeployCredential: a deploy credential's |
| +// first line names its rungs; every other role keeps today's line. |
| +func TestServicesCreatePrintsTheRungsForADeployCredential(t *testing.T) { |
| + buf := captureStdout(t) |
| + printServiceCreated("bab", "ci", "deploy", "hello", []string{"canary", "edge"}, "tok", nil) |
| + first, _, _ := strings.Cut(buf.String(), "\n") |
| + if want := "minted service:bab/ci (role deploy, app hello, rungs canary, edge)"; first != want { |
| + t.Fatalf("first line = %q, want %q", first, want) |
| + } |
| + |
| + buf2 := captureStdout(t) |
| + printServiceCreated("bab", "hub", "publish", "", nil, "tok", nil) |
| + first, _, _ = strings.Cut(buf2.String(), "\n") |
| + if want := "minted service:bab/hub (role publish, account-wide)"; first != want { |
| + t.Fatalf("publish first line = %q, want %q", first, want) |
| + } |
| +} |