carlos / platform Public

cli: services create --rungs and a RUNGS column (#229)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a54f7cac1a3fa47443ec5f927879f9ac1a167038 parent 5525f19
3 files changed, +104 −24
  • apimode.go +14 −9
  • services.go +28 −10
  • services_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)
+ }
+}