cli: carlos store create/status/grant/rotate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+1082
−0
apimode.go+156 −0main.go+3 −0store.go+357 −0store_test.go+566 −0
diff --git a/apimode.go b/apimode.go| index 6ae3887e6..55f89e685 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -668,6 +668,162 @@ func (s *apiSession) restoreApp(ctx context.Context, a apiAcct, app string) (api |
| return apiRestoreResult{Note: res.Note}, nil |
| } |
| +// ---- object storage (issue #55) --------------------------------------------- |
| +// |
| +// apiStoreReply mirrors (*console.Server).storeReply's JSON exactly |
| +// (internal/console/apistore.go): app, this DEPLOYMENT's backend ("" |
| +// unconfigured), the three record blocks (declared/grant/provisioned, |
| +// null-or-present — a client reads them rather than testing for absence), |
| +// the env names this store delivers, whether a rotation is only half |
| +// finished, and which delivered variables have not reached the app's config |
| +// yet. |
| +// |
| +// The three block types are redeclared here rather than imported from |
| +// internal/objstore: this package (main, the CLI binary) does not otherwise |
| +// import that package, and pulling it in for three struct shapes this client |
| +// only ever decodes (never constructs server-side logic from) would be |
| +// coupling this binary to the console's storage internals for no reason a |
| +// wire client has. Field names and JSON tags are kept byte-for-byte |
| +// identical to objstore.Declared/GrantBlock/Provisioned so a decode here can |
| +// never silently drop a field the server sends. |
| +type apiStoreDeclared struct { |
| + EnvPrefix string `json:"env_prefix"` |
| + At time.Time `json:"at"` |
| + By string `json:"by"` |
| +} |
| + |
| +type apiStoreGrantBlock struct { |
| + Backend string `json:"backend"` |
| + Bucket string `json:"bucket,omitempty"` // set only when the grant ADOPTED an existing bucket |
| + At time.Time `json:"at"` |
| + By string `json:"by"` |
| +} |
| + |
| +type apiStoreProvisioned struct { |
| + Bucket string `json:"bucket"` |
| + Region string `json:"region"` |
| + Endpoint string `json:"endpoint,omitempty"` |
| + IAMUser string `json:"iam_user"` |
| + UserARN string `json:"user_arn"` |
| + KeyID string `json:"key_id"` |
| + PrevKeyID string `json:"prev_key_id,omitempty"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| +type apiStoreReply struct { |
| + App string `json:"app"` |
| + Backend string `json:"backend"` |
| + Declared *apiStoreDeclared `json:"declared"` |
| + Grant *apiStoreGrantBlock `json:"grant"` |
| + Provisioned *apiStoreProvisioned `json:"provisioned"` |
| + EnvNames []string `json:"env_names"` |
| + PendingFinish bool `json:"pending_finish"` |
| + MissingVars []string `json:"missing_vars"` |
| +} |
| + |
| +// storeCreate asks the console to declare (or re-declare) a store for app, |
| +// under envPrefix ("" takes the console's own default — objstore. |
| +// DefaultEnvPrefix). Mirrors restart's shape (apimode.go:438): postJSON, a |
| +// 200 decodes, anything else is apiErrorFromBody's job. |
| +// |
| +// The request omits env_prefix entirely (json:",omitempty") when envPrefix |
| +// is "", so a bare `carlos store create -app x` sends the literal `{}` body |
| +// handleAPIStoreCreate documents as its own default case — never a |
| +// zero-length body, which that door's readJSON would refuse as malformed. |
| +func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, envPrefix string) (apiStoreReply, error) { |
| + in := struct { |
| + EnvPrefix string `json:"env_prefix,omitempty"` |
| + }{envPrefix} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/store"), in) |
| + if err != nil { |
| + return apiStoreReply{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiStoreReply{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiStoreReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiStoreReply{}, fmt.Errorf("store create: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// storeGet asks the console for app's store record (`carlos store status`). |
| +// A 404 here is a real diagnosis, not the byte-identical membership 404 |
| +// apiAccountApp gives — handleAPIStoreGet answers it with an actual |
| +// {"error": "no store is declared..."} body once membership is already |
| +// proven, so apiErrorFromBody's json-first decode surfaces it verbatim like |
| +// any other server diagnosis; no special case is needed here. |
| +func (s *apiSession) storeGet(ctx context.Context, a apiAcct, app string) (apiStoreReply, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/store")) |
| + if err != nil { |
| + return apiStoreReply{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiStoreReply{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiStoreReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiStoreReply{}, fmt.Errorf("store status: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// storeGrant asks the console to provision app's declared store and deliver |
| +// its credentials (`carlos store grant`) — the one call in this file that |
| +// spends money in the deployment's own cloud account, gated server-side on |
| +// CARLOS_STORE_OPERATORS (a 403 for anyone else, member or not, surfaced |
| +// verbatim by apiErrorFromBody like every other server diagnosis here: this |
| +// client never pattern-matches the message, only prints it). |
| +// |
| +// bucket == "" mints a fresh bucket; a non-empty bucket asks the walk to |
| +// ADOPT that existing one instead. Same omitempty shape as storeCreate's |
| +// prefix: a bare `-app x` with no `-bucket` sends `{}`. |
| +func (s *apiSession) storeGrant(ctx context.Context, a apiAcct, app, bucket string) (apiStoreReply, error) { |
| + in := struct { |
| + Bucket string `json:"bucket,omitempty"` |
| + }{bucket} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/store/grant"), in) |
| + if err != nil { |
| + return apiStoreReply{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiStoreReply{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiStoreReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiStoreReply{}, fmt.Errorf("store grant: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// storeRotate asks the console to cycle app's store credential |
| +// (`carlos store rotate`), or — with finish — to delete the previous key and |
| +// close the two-key window a rotation leaves open |
| +// (`carlos store rotate -finish`). Two different doors |
| +// (POST .../store/rotate vs POST .../store/rotate/finish), chosen here |
| +// rather than by a body field, matching the server's own route table |
| +// (internal/console/authcli.go) and restart's "no body, the whole request is |
| +// which door you POST to" shape (handleAPIStoreRotate's doc comment). |
| +func (s *apiSession) storeRotate(ctx context.Context, a apiAcct, app string, finish bool) (apiStoreReply, error) { |
| + tail := "/store/rotate" |
| + if finish { |
| + tail = "/store/rotate/finish" |
| + } |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, tail), struct{}{}) |
| + if err != nil { |
| + return apiStoreReply{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiStoreReply{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiStoreReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiStoreReply{}, fmt.Errorf("store rotate: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| // ledgerAppend asks the console to CAS-append one entry to name's chain, |
| // stamping writer from the caller's own authenticated identity server-side |
| // (never client-supplied — apiledger.go). A 503 here is contention (too many |
diff --git a/main.go b/main.go| index 62106f5e4..0e14ff07c 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -61,6 +61,8 @@ func main() { |
| runDeploy(args) |
| case "restart": |
| runRestart(args) |
| + case "store": |
| + runStore(args) |
| case "logs": |
| runLogs(args) |
| case "channels": |
| @@ -124,6 +126,7 @@ Your apps — ship and run software on a CARLOS deployment |
| (reaching stable cuts the release's semver tag) |
| deploy ship + promote + wait until the URL serves the new version |
| restart cycle an app's instances — no new version, no config change |
| + store object storage for an app: create | status | grant | rotate |
| rollback point a channel back at an earlier version |
| channels show what each of an app's channels is serving |
| releases every version shipped, newest first, with labels and tags |
diff --git a/store.go b/store.go| new file mode 100644 |
| index 000000000..ae24517f8 |
| --- /dev/null |
| +++ b/store.go |
| @@ -0,0 +1,357 @@ |
| +// store.go is `carlos store`: declare, inspect, grant and rotate an app's |
| +// object-storage estate (issue #55). API mode ONLY, like restart and apps |
| +// create — the record lives in the console's database (well, its deployment |
| +// bucket, but there is no local copy to write bucket-direct against) and the |
| +// grant/rotate walks spend money in the deployment's own cloud account, so |
| +// there is nothing a headless box could do here that this CLI could |
| +// meaningfully drive on its own. |
| +// |
| +// Four sub-verbs, one flag family: |
| +// |
| +// create declare that an app wants a store, under an env prefix. A |
| +// REQUEST, not a provision — see printStoreCreate. |
| +// status the record's three blocks plus drift (missing_vars) and any |
| +// half-finished rotation (pending_finish) — the two facts a |
| +// member with no box access cannot see any other way. |
| +// grant operator-only: provision the declared store and deliver its |
| +// credentials. The one verb here that spends the deployment's |
| +// own money. |
| +// rotate mint a fresh credential; -finish retires the previous one. |
| +// |
| +// The output-honesty rule every line here follows is restart.go's: say what |
| +// was REQUESTED or RECORDED, never what a box has since done with it. A grant |
| +// or a rotate is answered by the console's own walk (it already ran, inline, |
| +// before this process gets its 200), so those two verbs get to say more than |
| +// restart can — but delivery into a running instance's environment is still a |
| +// converge a box does on its own schedule, which is why grant's own line |
| +// says "pick them up within seconds" rather than claiming they already have. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const storeUsage = `usage: carlos store <create|status|grant|rotate> [flags] |
| + |
| + create -app <a> [-env-prefix <PREFIX>] |
| + declare that this app wants object storage, under |
| + an env prefix (default CARLOS_STORE). A request, |
| + not a provision: nothing exists in any cloud |
| + account until an operator grants it. |
| + status -app <a> show what's declared, granted and provisioned, |
| + which delivered variables have not reached the |
| + app's config yet, and whether a rotation is only |
| + half finished. |
| + grant -app <a> [-bucket <name>] |
| + provision the declared store and deliver its |
| + credentials. Needs a deployment operator |
| + (CARLOS_STORE_OPERATORS) — a 403 for anyone else, |
| + member or not. -bucket adopts an existing bucket |
| + instead of minting one. |
| + rotate -app <a> [-finish] |
| + mint a fresh credential, leaving the old one live |
| + until -finish deletes it. A member verb: spends |
| + nothing new, so there is no operator gate here. |
| + |
| + -account <sqid|name> account the app belongs to (default: ops) — |
| + resolved by the console against your own |
| + memberships; signed-in mode only. |
| + -account-id <sqid> account sqid, matched as a sqid ONLY. |
| + -console <origin> which logged-in console to use (only needed with |
| + several) |
| + |
| +Signed-in only (carlos auth login): the store record lives in the console's |
| +own deployment bucket and its grant/rotate walks touch the deployment's own |
| +cloud account — there is no direct-bucket equivalent for any of these four |
| +verbs. |
| +` |
| + |
| +// runStore dispatches the store sub-verbs, mirroring RunApps's arg slicing |
| +// (apps.go): args[0] is the sub-verb, the rest is that sub-verb's own flag |
| +// set. |
| +func runStore(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, storeUsage) |
| + os.Exit(1) |
| + } |
| + switch args[0] { |
| + case "create": |
| + runStoreCreate(args[1:]) |
| + case "status": |
| + runStoreStatus(args[1:]) |
| + case "grant": |
| + runStoreGrant(args[1:]) |
| + case "rotate": |
| + runStoreRotate(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, storeUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// requireStoreAPISession resolves signed-in mode for every store sub-verb, |
| +// refusing direct-bucket mode with store's OWN message — the pattern |
| +// requireRestartAPISession sets (restart.go), and for the same reason: a |
| +// refusal that names another verb's advice sends someone to a fix that |
| +// cannot help them. |
| +// |
| +// There is no headless fallback, and for a stronger reason than restart's: |
| +// restart's request is a console write a fleet listens for, but grant and |
| +// rotate don't just write an object — they call out to the deployment's own |
| +// cloud account (objstore.Grant/Rotate), an authority this binary has no |
| +// other way to exercise. A bucket-mode operator asking for a store has no |
| +// walk to drive bucket-direct at all. |
| +func requireStoreAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos store works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). store is a member/operator verb with\n" + |
| + "no direct-bucket equivalent — the record lives in the console's deployment bucket, and grant\n" + |
| + "and rotate call out to the deployment's own cloud account, an authority only the console can\n" + |
| + "exercise. Unset the store env and run: carlos auth login -console <url>.") |
| + } |
| + return sess |
| +} |
| + |
| +func runStoreCreate(args []string) { |
| + fs := flag.NewFlagSet("store create", flag.ExitOnError) |
| + app := fs.String("app", "", "app to declare a store for") |
| + envPrefix := fs.String("env-prefix", "", "env var prefix to deliver under (default CARLOS_STORE)") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to declare through (API mode only; default: the only logged-in one)") |
| + fs.Parse(args) |
| + name := strings.TrimSpace(*app) |
| + if name == "" { |
| + log.Fatal("usage: carlos store create -app <a> [-env-prefix <PREFIX>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| + } |
| + // Client-side gate before the server's, the same one every write verb in |
| + // this file applies: a name the console is certain to refuse should cost |
| + // a typo nothing. |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("store create: %v", err) |
| + } |
| + |
| + sess := requireStoreAPISession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + printUsing(stdout, sess) |
| + res, err := sess.storeCreate(context.Background(), acct, name, strings.TrimSpace(*envPrefix)) |
| + if err != nil { |
| + log.Fatalf("store create: %v", err) |
| + } |
| + printStoreCreate(stdout, res) |
| +} |
| + |
| +func runStoreStatus(args []string) { |
| + fs := flag.NewFlagSet("store status", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose store to show") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| + fs.Parse(args) |
| + name := strings.TrimSpace(*app) |
| + if name == "" { |
| + log.Fatal("usage: carlos store status -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| + } |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("store status: %v", err) |
| + } |
| + |
| + sess := requireStoreAPISession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + printUsing(stdout, sess) |
| + res, err := sess.storeGet(context.Background(), acct, name) |
| + if err != nil { |
| + log.Fatalf("store status: %v", err) |
| + } |
| + printStoreStatus(stdout, res) |
| +} |
| + |
| +func runStoreGrant(args []string) { |
| + fs := flag.NewFlagSet("store grant", flag.ExitOnError) |
| + app := fs.String("app", "", "app to grant a store for") |
| + bucket := fs.String("bucket", "", "adopt this existing bucket instead of minting one") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to grant through (API mode only; default: the only logged-in one)") |
| + fs.Parse(args) |
| + name := strings.TrimSpace(*app) |
| + if name == "" { |
| + log.Fatal("usage: carlos store grant -app <a> [-bucket <name>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| + } |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("store grant: %v", err) |
| + } |
| + |
| + sess := requireStoreAPISession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + printUsing(stdout, sess) |
| + res, err := sess.storeGrant(context.Background(), acct, name, strings.TrimSpace(*bucket)) |
| + if err != nil { |
| + log.Fatalf("store grant: %v", err) |
| + } |
| + printStoreGrant(stdout, res) |
| +} |
| + |
| +func runStoreRotate(args []string) { |
| + fs := flag.NewFlagSet("store rotate", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose store credential to rotate") |
| + finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to rotate through (API mode only; default: the only logged-in one)") |
| + fs.Parse(args) |
| + name := strings.TrimSpace(*app) |
| + if name == "" { |
| + log.Fatal("usage: carlos store rotate -app <a> [-finish] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| + } |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("store rotate: %v", err) |
| + } |
| + |
| + sess := requireStoreAPISession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + printUsing(stdout, sess) |
| + res, err := sess.storeRotate(context.Background(), acct, name, *finish) |
| + if err != nil { |
| + log.Fatalf("store rotate: %v", err) |
| + } |
| + printStoreRotate(stdout, res, *finish) |
| +} |
| + |
| +// ---- output ----------------------------------------------------------------- |
| +// |
| +// Every function below takes the reply the console already sent and prints |
| +// it — no field here is guessed or extrapolated, and no line claims a box has |
| +// converged. That split (network in run*, rendering in print*) is what lets |
| +// the honesty rules below be tested directly against a literal apiStoreReply, |
| +// with no stub server or credential file in the loop at all. |
| + |
| +// printStoreCreate answers `carlos store create`: the prefix now declared, |
| +// the env names it will eventually deliver, and the one sentence that keeps |
| +// this from reading like a provision — nothing exists in any cloud account |
| +// yet. When the deployment itself has no backend at all (Backend == ""), |
| +// that fact is added too: a member cannot see CARLOS_STORE_BACKEND for |
| +// themselves, and "declared, nothing happening" with no way to tell a queued |
| +// request from a deployment that will never grant one fails the no-infra- |
| +// access rule (CLAUDE.md) just as surely as a missing CLI command would. |
| +func printStoreCreate(w io.Writer, res apiStoreReply) { |
| + c := colorFor(w) |
| + prefix := "" |
| + if res.Declared != nil { |
| + prefix = res.Declared.EnvPrefix |
| + } |
| + fmt.Fprintf(w, "%s %s under prefix %s — delivers %s\n", |
| + c.ok("declared a store for"), res.App, prefix, strings.Join(res.EnvNames, ", ")) |
| + mutedf(w, "nothing is provisioned until a deployment operator grants it (carlos store grant -app %s)", res.App) |
| + if res.Backend == "" { |
| + warnf(w, "this deployment has no object storage backend configured yet (the operator sets "+ |
| + "CARLOS_STORE_BACKEND) — the declaration will simply wait until one is") |
| + } |
| +} |
| + |
| +// printStoreGrant answers `carlos store grant`: the walk already ran inline |
| +// (the console's handleAPIStoreGrant does not return 200 until it has), |
| +// which is why this can name the bucket/region/user rather than hedging like |
| +// create does. What it does NOT claim is that a running instance has the new |
| +// credential yet — that is still a converge a box does on its own poll, spec |
| +// unchanged by grant having succeeded here. |
| +func printStoreGrant(w io.Writer, res apiStoreReply) { |
| + c := colorFor(w) |
| + if res.Provisioned == nil { |
| + // Defensive only: handleAPIStoreGrant never answers 200 without a |
| + // provisioned block. If a future console did, this is still an |
| + // honest thing to print — the record's own state, nothing invented. |
| + fmt.Fprintf(w, "%s %s\n", c.ok("store granted for"), res.App) |
| + return |
| + } |
| + p := res.Provisioned |
| + fmt.Fprintf(w, "%s %s — bucket %s in %s, user %s\n", |
| + c.ok("store granted for"), res.App, p.Bucket, p.Region, p.IAMUser) |
| + mutedf(w, "credentials are sealed into the app's config — running instances pick them up within seconds") |
| +} |
| + |
| +// printStoreRotate answers both `carlos store rotate` and `carlos store |
| +// rotate -finish` — two different confirmations of two different walks, kept |
| +// in one function because they share the app name and the ok() styling and |
| +// nothing else worth factoring further. |
| +func printStoreRotate(w io.Writer, res apiStoreReply, finish bool) { |
| + c := colorFor(w) |
| + if finish { |
| + // The walk deletes the previous key; the reply's own Provisioned |
| + // block (PrevKeyID now empty, PendingFinish now false) is the |
| + // record of that, not a claim this line re-derives. |
| + fmt.Fprintf(w, "%s for %s\n", c.ok("previous key deleted"), res.App) |
| + return |
| + } |
| + key := "" |
| + if res.Provisioned != nil { |
| + key = res.Provisioned.KeyID |
| + } |
| + fmt.Fprintf(w, "%s %s — new key %s\n", c.ok("rotated the store credential for"), res.App, key) |
| + mutedf(w, "the old key stays active until `carlos store rotate -finish -app %s`", res.App) |
| +} |
| + |
| +// printStoreStatus answers `carlos store status` — the one sub-verb a member |
| +// with no box access has for seeing all three of a record's blocks, plus the |
| +// two facts nothing else in the CLI surfaces: config-delivery drift |
| +// (missing_vars) and a rotation stuck mid-window (pending_finish). |
| +// |
| +// The provisioned timestamp is labelled "last credential change", never |
| +// "provisioned at" — review finding from the walk design: it MOVES on every |
| +// rotate and every drift repair, so a fixed-sounding label would misdescribe |
| +// it the very first time either happens. |
| +func printStoreStatus(w io.Writer, res apiStoreReply) { |
| + c := colorFor(w) |
| + fmt.Fprintf(w, "%s %s\n", c.head("store for"), res.App) |
| + if res.Backend == "" { |
| + warnf(w, "this deployment has no object storage backend configured yet (the operator sets CARLOS_STORE_BACKEND)") |
| + } |
| + if res.Declared == nil { |
| + mutedf(w, "not declared — carlos store create -app %s", res.App) |
| + return |
| + } |
| + fmt.Fprintf(w, " declared: prefix %s, by %s at %s\n", |
| + res.Declared.EnvPrefix, res.Declared.By, res.Declared.At.Format(time.RFC3339)) |
| + |
| + if res.Grant != nil { |
| + fmt.Fprintf(w, " grant: by %s at %s", res.Grant.By, res.Grant.At.Format(time.RFC3339)) |
| + if res.Grant.Bucket != "" { |
| + // Only set when the grant ADOPTED an existing bucket — a minted |
| + // one's name lives in Provisioned.Bucket, printed below. |
| + fmt.Fprintf(w, " (adopting bucket %s)", res.Grant.Bucket) |
| + } |
| + fmt.Fprintln(w) |
| + } |
| + |
| + if res.Provisioned != nil { |
| + p := res.Provisioned |
| + fmt.Fprintf(w, " provisioned: bucket %s in %s, user %s, key %s — last credential change %s\n", |
| + p.Bucket, p.Region, p.IAMUser, p.KeyID, p.At.Format(time.RFC3339)) |
| + } else { |
| + mutedf(w, " nothing is provisioned yet — waiting on a deployment operator (carlos store grant -app %s)", res.App) |
| + } |
| + |
| + if len(res.EnvNames) > 0 { |
| + fmt.Fprintf(w, " delivers: %s\n", strings.Join(res.EnvNames, ", ")) |
| + } |
| + if res.PendingFinish { |
| + warnf(w, "a rotation is pending finish — the previous key is still live: carlos store rotate -finish -app %s", res.App) |
| + } |
| + if len(res.MissingVars) > 0 { |
| + // Named as drift, not as a fresh failure: this is the same state a |
| + // healthy store passes through for a few seconds after every grant |
| + // or rotate, until the config reaches the app. It only means |
| + // something is actually wrong once it persists — which this CLI has |
| + // no way to know from one snapshot, so the line names the repair |
| + // rather than pronouncing a verdict. |
| + warnf(w, "drift: %s not yet delivered to this app's config — re-run `carlos store grant -app %s` to "+ |
| + "repair (this rotates the credential)", strings.Join(res.MissingVars, ", "), res.App) |
| + } |
| +} |
diff --git a/store_test.go b/store_test.go| new file mode 100644 |
| index 000000000..db046ab76 |
| --- /dev/null |
| +++ b/store_test.go |
| @@ -0,0 +1,566 @@ |
| +// Tests for `carlos store` (issue #55): the wire shape the four apiSession |
| +// methods send (request body/method/path, matching apistore_test.go's server |
| +// side), the honesty rules the print* functions must follow (create/status/ |
| +// grant/rotate — no line claims a box has converged), and the two ways the |
| +// verb refuses before it ever dials anything (no -app, direct bucket mode) — |
| +// driven through the real binary via carlosTestEnv + a scratch cmd.Dir, the |
| +// same isolation restart_test.go's refusal tests use. |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os/exec" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// ---- apiSession.store* wire shape ------------------------------------------- |
| + |
| +// TestAPISessionStoreCreateWireShape pins handleAPIStoreCreate's contract: a |
| +// bearer POST to .../store, and — the review-tested case — a bare create |
| +// (no -env-prefix) sends the literal `{}` body, never a key with an empty |
| +// string value, since the door treats an ABSENT key and an empty one the |
| +// same way but a differently-shaped body is still the wrong thing to send. |
| +func TestAPISessionStoreCreateWireShape(t *testing.T) { |
| + var gotPath, gotMethod, gotAuth, gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") |
| + body := new(bytes.Buffer) |
| + body.ReadFrom(r.Body) |
| + gotBody = body.String() |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "app": "keymail", "backend": "aws", |
| + "declared": map[string]any{"env_prefix": "CARLOS_STORE", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"}, |
| + "grant": nil, |
| + "provisioned": nil, |
| + "env_names": []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION"}, |
| + "pending_finish": false, |
| + "missing_vars": []string{}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail", "") |
| + if err != nil { |
| + t.Fatalf("storeCreate: %v", err) |
| + } |
| + if gotMethod != http.MethodPost { |
| + t.Fatalf("method = %s, want POST", gotMethod) |
| + } |
| + if gotPath != "/api/cli/apps/keymail-team/keymail/store" { |
| + t.Fatalf("path = %s", gotPath) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("auth = %q", gotAuth) |
| + } |
| + if strings.TrimSpace(gotBody) != "{}" { |
| + t.Fatalf("body = %q, want the bare {} a default create sends", gotBody) |
| + } |
| + if res.App != "keymail" || res.Backend != "aws" { |
| + t.Fatalf("result = %+v, want the console's echo", res) |
| + } |
| + if res.Declared == nil || res.Declared.EnvPrefix != "CARLOS_STORE" { |
| + t.Fatalf("Declared = %+v, want the echoed prefix", res.Declared) |
| + } |
| +} |
| + |
| +// TestAPISessionStoreCreateSendsChosenPrefix pins the other half: a chosen |
| +// -env-prefix travels verbatim, not silently defaulted client-side (the |
| +// server owns the default, per handleAPIStoreCreate's own doc comment). |
| +func TestAPISessionStoreCreateSendsChosenPrefix(t *testing.T) { |
| + var gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + body := new(bytes.Buffer) |
| + body.ReadFrom(r.Body) |
| + gotBody = body.String() |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "app": "keymail", "backend": "aws", |
| + "declared": map[string]any{"env_prefix": "TITO_S3", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"}, |
| + "env_names": []string{"TITO_S3_BUCKET"}, |
| + "pending_finish": false, |
| + "missing_vars": []string{}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + if _, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "TITO_S3"); err != nil { |
| + t.Fatalf("storeCreate: %v", err) |
| + } |
| + var sent struct { |
| + EnvPrefix string `json:"env_prefix"` |
| + } |
| + if err := json.Unmarshal([]byte(gotBody), &sent); err != nil || sent.EnvPrefix != "TITO_S3" { |
| + t.Fatalf("body = %q, want env_prefix TITO_S3", gotBody) |
| + } |
| +} |
| + |
| +// TestAPISessionStoreGetWireShape pins handleAPIStoreGet's contract: a GET |
| +// to the same .../store path, decoding the full reply including drift and |
| +// pending-finish. |
| +func TestAPISessionStoreGetWireShape(t *testing.T) { |
| + var gotPath, gotMethod string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.RequestURI(), r.Method |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "app": "keymail", "backend": "aws", |
| + "declared": map[string]any{"env_prefix": "CARLOS_STORE", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"}, |
| + "grant": map[string]any{"backend": "aws", "at": "2026-08-10T12:01:00Z", "by": "ops@keymail.dev"}, |
| + "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "iam_user": "u1", "user_arn": "arn:aws:iam::1:user/u1", "key_id": "AKIA1", "at": "2026-08-10T12:02:00Z"}, |
| + "env_names": []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION", "CARLOS_STORE_ACCESS_KEY", "CARLOS_STORE_SECRET_KEY"}, |
| + "pending_finish": true, |
| + "missing_vars": []string{"CARLOS_STORE_SECRET_KEY"}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail") |
| + if err != nil { |
| + t.Fatalf("storeGet: %v", err) |
| + } |
| + if gotMethod != http.MethodGet || gotPath != "/api/cli/apps/acct/keymail/store" { |
| + t.Fatalf("method/path = %s %s, want GET .../store", gotMethod, gotPath) |
| + } |
| + if !res.PendingFinish { |
| + t.Fatal("PendingFinish = false, want true") |
| + } |
| + if len(res.MissingVars) != 1 || res.MissingVars[0] != "CARLOS_STORE_SECRET_KEY" { |
| + t.Fatalf("MissingVars = %v, want the one undelivered var", res.MissingVars) |
| + } |
| + if res.Provisioned == nil || res.Provisioned.KeyID != "AKIA1" { |
| + t.Fatalf("Provisioned = %+v, want the echoed key id", res.Provisioned) |
| + } |
| +} |
| + |
| +// TestAPISessionStoreGetNotDeclaredSurfacesServerText pins the real (not |
| +// byte-identical-membership) 404 handleAPIStoreGet answers once membership |
| +// is already proven: "no store is declared" reaches the terminal verbatim. |
| +func TestAPISessionStoreGetNotDeclaredSurfacesServerText(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNotFound) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "no store is declared for this app — run `carlos store create`"}) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail") |
| + if err == nil || !strings.Contains(err.Error(), "no store is declared") { |
| + t.Fatalf("storeGet 404 = %v, want the server's own text", err) |
| + } |
| +} |
| + |
| +// TestAPISessionStoreGrantWireShape pins handleAPIStoreGrant's contract: a |
| +// POST to .../store/grant, and — the review-tested case — a bare grant (no |
| +// -bucket) sends `{}`. |
| +func TestAPISessionStoreGrantWireShape(t *testing.T) { |
| + var gotPath, gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.RequestURI() |
| + body := new(bytes.Buffer) |
| + body.ReadFrom(r.Body) |
| + gotBody = body.String() |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "app": "keymail", "backend": "aws", |
| + "provisioned": map[string]any{"bucket": "carlos-keymail-abc", "region": "eu-west-1", "iam_user": "carlos-keymail", "user_arn": "arn:aws:iam::1:user/carlos-keymail", "key_id": "AKIA1", "at": "2026-08-10T12:02:00Z"}, |
| + "env_names": []string{"CARLOS_STORE_BUCKET"}, |
| + "pending_finish": false, |
| + "missing_vars": []string{}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "") |
| + if err != nil { |
| + t.Fatalf("storeGrant: %v", err) |
| + } |
| + if gotPath != "/api/cli/apps/acct/keymail/store/grant" { |
| + t.Fatalf("path = %q, want the grant door", gotPath) |
| + } |
| + if strings.TrimSpace(gotBody) != "{}" { |
| + t.Fatalf("body = %q, want the bare {} a default grant sends", gotBody) |
| + } |
| + if res.Provisioned == nil || res.Provisioned.Bucket != "carlos-keymail-abc" { |
| + t.Fatalf("Provisioned = %+v, want the minted bucket echoed", res.Provisioned) |
| + } |
| +} |
| + |
| +// TestAPISessionStoreGrantSendsChosenBucket pins the adopt case: a -bucket |
| +// travels verbatim in the request body. |
| +func TestAPISessionStoreGrantSendsChosenBucket(t *testing.T) { |
| + var gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + body := new(bytes.Buffer) |
| + body.ReadFrom(r.Body) |
| + gotBody = body.String() |
| + json.NewEncoder(w).Encode(map[string]any{"app": "keymail", "env_names": []string{}, "missing_vars": []string{}}) |
| + })) |
| + defer ts.Close() |
| + |
| + if _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "my-existing-bucket"); err != nil { |
| + t.Fatalf("storeGrant: %v", err) |
| + } |
| + var sent struct { |
| + Bucket string `json:"bucket"` |
| + } |
| + if err := json.Unmarshal([]byte(gotBody), &sent); err != nil || sent.Bucket != "my-existing-bucket" { |
| + t.Fatalf("body = %q, want bucket my-existing-bucket", gotBody) |
| + } |
| +} |
| + |
| +// TestAPISessionStoreGrant403SurfacesVerbatim pins the operator-allowlist |
| +// refusal: printed exactly as the console worded it, never pattern-matched |
| +// or rewritten — the same rule apiErrorFromBody already gives every other |
| +// verb's server diagnosis. |
| +func TestAPISessionStoreGrant403SurfacesVerbatim(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": "store grants need a deployment operator (CARLOS_STORE_OPERATORS)"}) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "") |
| + if err == nil || err.Error() != "store grants need a deployment operator (CARLOS_STORE_OPERATORS)" { |
| + t.Fatalf("storeGrant 403 = %v, want the console's own text byte-for-byte", err) |
| + } |
| +} |
| + |
| +// TestAPISessionStoreRotateWireShape pins handleAPIStoreRotate's contract: |
| +// POST .../store/rotate, no body. |
| +func TestAPISessionStoreRotateWireShape(t *testing.T) { |
| + var gotPath, gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.RequestURI() |
| + body := new(bytes.Buffer) |
| + body.ReadFrom(r.Body) |
| + gotBody = body.String() |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "app": "keymail", |
| + "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "key_id": "AKIA2", "prev_key_id": "AKIA1", "at": "2026-08-10T12:03:00Z"}, |
| + "pending_finish": true, |
| + "env_names": []string{}, |
| + "missing_vars": []string{}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", false) |
| + if err != nil { |
| + t.Fatalf("storeRotate: %v", err) |
| + } |
| + if gotPath != "/api/cli/apps/acct/keymail/store/rotate" { |
| + t.Fatalf("path = %q, want the rotate door (not /finish)", gotPath) |
| + } |
| + if strings.TrimSpace(gotBody) != "{}" { |
| + t.Fatalf("body = %q, want no arguments", gotBody) |
| + } |
| + if res.Provisioned == nil || res.Provisioned.KeyID != "AKIA2" { |
| + t.Fatalf("Provisioned = %+v, want the new key echoed", res.Provisioned) |
| + } |
| +} |
| + |
| +// TestAPISessionStoreRotateFinishHitsFinishDoor pins the ONE thing that |
| +// distinguishes rotate from rotate -finish on the wire: which path is |
| +// POSTed. Everything else about the request is identical. |
| +func TestAPISessionStoreRotateFinishHitsFinishDoor(t *testing.T) { |
| + var gotPath string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.RequestURI() |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "app": "keymail", |
| + "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "key_id": "AKIA2", "at": "2026-08-10T12:04:00Z"}, |
| + "pending_finish": false, |
| + "env_names": []string{}, |
| + "missing_vars": []string{}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", true) |
| + if err != nil { |
| + t.Fatalf("storeRotate(finish=true): %v", err) |
| + } |
| + if gotPath != "/api/cli/apps/acct/keymail/store/rotate/finish" { |
| + t.Fatalf("path = %q, want the finish door", gotPath) |
| + } |
| + if res.PendingFinish { |
| + t.Fatal("PendingFinish = true after finish, want false") |
| + } |
| +} |
| + |
| +// TestAPISessionStoreRotateConflictSurfacesVerbatim pins the "nothing |
| +// provisioned to rotate" 409, printed as the console worded it. |
| +func TestAPISessionStoreRotateConflictSurfacesVerbatim(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": "no provisioned store to rotate — grant one first"}) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", false) |
| + if err == nil || !strings.Contains(err.Error(), "grant one first") { |
| + t.Fatalf("storeRotate 409 = %v, want the console's own text", err) |
| + } |
| +} |
| + |
| +// ---- print* output honesty --------------------------------------------------- |
| +// |
| +// Each print function is driven directly against a literal apiStoreReply — |
| +// no stub server, no credential file — so the wording rules stay pinned |
| +// independent of the wire plumbing above. |
| + |
| +func TestPrintStoreCreate(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreCreate(&buf, apiStoreReply{ |
| + App: "keymail", |
| + Backend: "aws", |
| + Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, |
| + EnvNames: []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION"}, |
| + }) |
| + out := buf.String() |
| + for _, want := range []string{"keymail", "CARLOS_STORE", "CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION", |
| + "nothing is provisioned", "operator grants it"} { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("printStoreCreate missing %q:\n%s", want, out) |
| + } |
| + } |
| + if strings.Contains(out, "CARLOS_STORE_BACKEND") { |
| + t.Fatalf("printStoreCreate with a configured backend must not print the not-configured line:\n%s", out) |
| + } |
| +} |
| + |
| +// TestPrintStoreCreateNoBackendWarnsOperatorGap pins the honesty rule: a |
| +// deployment with NO object storage backend at all must say so, naming the |
| +// env var an operator sets — a member cannot see CARLOS_STORE_BACKEND for |
| +// themselves, and "declared, nothing happening" with no way to tell a queued |
| +// request from one that will never be granted fails the no-infra-access rule. |
| +func TestPrintStoreCreateNoBackendWarnsOperatorGap(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreCreate(&buf, apiStoreReply{ |
| + App: "keymail", |
| + Backend: "", |
| + Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, |
| + EnvNames: []string{"CARLOS_STORE_BUCKET"}, |
| + }) |
| + out := buf.String() |
| + if !strings.Contains(out, "CARLOS_STORE_BACKEND") { |
| + t.Fatalf("printStoreCreate with backend==\"\" must name CARLOS_STORE_BACKEND:\n%s", out) |
| + } |
| + if !strings.Contains(out, "no object storage backend") { |
| + t.Fatalf("printStoreCreate with backend==\"\" must say this deployment has none:\n%s", out) |
| + } |
| +} |
| + |
| +func TestPrintStoreGrant(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreGrant(&buf, apiStoreReply{ |
| + App: "keymail", |
| + Provisioned: &apiStoreProvisioned{ |
| + Bucket: "carlos-keymail-abc", Region: "eu-west-1", IAMUser: "carlos-keymail", |
| + }, |
| + }) |
| + out := buf.String() |
| + for _, want := range []string{"keymail", "carlos-keymail-abc", "eu-west-1", "carlos-keymail", |
| + "sealed into the app's config", "within seconds"} { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("printStoreGrant missing %q:\n%s", want, out) |
| + } |
| + } |
| + // The lesson restart.go paid for: never claim a box has already acted. |
| + for _, mustNotClaim := range []string{"instance has the", "instances have the", "delivered"} { |
| + if strings.Contains(strings.ToLower(out), strings.ToLower(mustNotClaim)) { |
| + t.Fatalf("printStoreGrant over-claims delivery already happened (%q):\n%s", mustNotClaim, out) |
| + } |
| + } |
| +} |
| + |
| +func TestPrintStoreRotate(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreRotate(&buf, apiStoreReply{ |
| + App: "keymail", |
| + Provisioned: &apiStoreProvisioned{KeyID: "AKIANEW"}, |
| + }, false) |
| + out := buf.String() |
| + for _, want := range []string{"keymail", "AKIANEW", "old key stays active", "carlos store rotate -finish -app keymail"} { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("printStoreRotate missing %q:\n%s", want, out) |
| + } |
| + } |
| +} |
| + |
| +func TestPrintStoreRotateFinish(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreRotate(&buf, apiStoreReply{App: "keymail"}, true) |
| + out := buf.String() |
| + if !strings.Contains(out, "previous key deleted") || !strings.Contains(out, "keymail") { |
| + t.Fatalf("printStoreRotate(finish) = %q, want the previous-key-deleted confirmation", out) |
| + } |
| + if strings.Contains(out, "stays active") { |
| + t.Fatalf("finish output must not repeat the pending-finish nag:\n%s", out) |
| + } |
| +} |
| + |
| +// TestPrintStoreStatusPendingFinishNag pins the nag: a status read on a |
| +// half-rotated store must name the exact finish command. |
| +func TestPrintStoreStatusPendingFinishNag(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreStatus(&buf, apiStoreReply{ |
| + App: "keymail", |
| + Backend: "aws", |
| + Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, |
| + Provisioned: &apiStoreProvisioned{ |
| + Bucket: "b1", Region: "eu-west-1", IAMUser: "u1", KeyID: "AKIA2", PrevKeyID: "AKIA1", |
| + }, |
| + PendingFinish: true, |
| + }) |
| + out := buf.String() |
| + if !strings.Contains(out, "pending finish") { |
| + t.Fatalf("status output missing the pending-finish nag:\n%s", out) |
| + } |
| + if !strings.Contains(out, "carlos store rotate -finish -app keymail") { |
| + t.Fatalf("status output does not name the exact finish command:\n%s", out) |
| + } |
| + if !strings.Contains(out, "last credential change") { |
| + t.Fatalf("status output must label the provisioned timestamp \"last credential change\":\n%s", out) |
| + } |
| + if strings.Contains(out, "provisioned at") { |
| + t.Fatalf("status output must NOT say \"provisioned at\" — that label goes stale on rotate/repair:\n%s", out) |
| + } |
| +} |
| + |
| +// TestPrintStoreStatusMissingVarsWarnsDriftAndRepair pins the drift warning: |
| +// it must name `carlos store grant` as the repair AND note that a re-run |
| +// rotates the credential — the two facts the reviewer required together, so |
| +// nobody re-runs grant expecting a no-op. |
| +func TestPrintStoreStatusMissingVarsWarnsDriftAndRepair(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreStatus(&buf, apiStoreReply{ |
| + App: "keymail", |
| + Backend: "aws", |
| + Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, |
| + Provisioned: &apiStoreProvisioned{ |
| + Bucket: "b1", Region: "eu-west-1", IAMUser: "u1", KeyID: "AKIA1", |
| + }, |
| + MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}, |
| + }) |
| + out := buf.String() |
| + if !strings.Contains(out, "CARLOS_STORE_SECRET_KEY") { |
| + t.Fatalf("status output missing the drifted variable's name:\n%s", out) |
| + } |
| + if !strings.Contains(out, "carlos store grant -app keymail") { |
| + t.Fatalf("status output does not name the repair command:\n%s", out) |
| + } |
| + if !strings.Contains(out, "rotates the credential") { |
| + t.Fatalf("status output does not warn that the repair rotates the credential:\n%s", out) |
| + } |
| +} |
| + |
| +// TestPrintStoreStatusNoBackend pins the same not-configured line status |
| +// owes that create does. |
| +func TestPrintStoreStatusNoBackend(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreStatus(&buf, apiStoreReply{App: "keymail", Backend: "", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}}) |
| + out := buf.String() |
| + if !strings.Contains(out, "CARLOS_STORE_BACKEND") { |
| + t.Fatalf("status output with backend==\"\" must name CARLOS_STORE_BACKEND:\n%s", out) |
| + } |
| +} |
| + |
| +// TestPrintStoreStatusNotDeclared pins the "nothing to show" shape — no |
| +// panics on nil blocks, and the create command named as the way forward. |
| +func TestPrintStoreStatusNotDeclared(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreStatus(&buf, apiStoreReply{App: "keymail", Backend: "aws"}) |
| + out := buf.String() |
| + if !strings.Contains(out, "not declared") || !strings.Contains(out, "carlos store create -app keymail") { |
| + t.Fatalf("status output for an undeclared store = %q, want the create pointer", out) |
| + } |
| +} |
| + |
| +// TestPrintStoreStatusDeclaredNotProvisioned pins the waiting-on-an-operator |
| +// state: a declared-but-not-granted store must say it is waiting, not |
| +// silently omit the provisioned section. |
| +func TestPrintStoreStatusDeclaredNotProvisioned(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreStatus(&buf, apiStoreReply{ |
| + App: "keymail", Backend: "aws", |
| + Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, |
| + }) |
| + out := buf.String() |
| + if !strings.Contains(out, "waiting on a deployment operator") { |
| + t.Fatalf("status output for a declared-not-granted store missing the waiting line:\n%s", out) |
| + } |
| + if !strings.Contains(out, "carlos store grant -app keymail") { |
| + t.Fatalf("status output missing the grant pointer:\n%s", out) |
| + } |
| +} |
| + |
| +// ---- runStore dispatch / refusals (real binary) ----------------------------- |
| + |
| +// TestStoreVerbDispatchAndRefusals drives the real binary through |
| +// carlosTestEnv with a scratch cmd.Dir — the repo rule (CLAUDE.md) — so a |
| +// logged-in laptop's real credential and console can never be what this test |
| +// exercises: unknown sub-verb, missing -app on each sub-verb, and direct |
| +// bucket mode refused with store's own sentence. |
| +func TestStoreVerbDispatchAndRefusals(t *testing.T) { |
| + bin := buildCarlos(t) |
| + scratch := t.TempDir() |
| + run := func(env []string, args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = carlosTestEnv(t, env...) |
| + cmd.Dir = scratch |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + // No sub-verb at all, and an unknown one: both print the usage block. |
| + for _, args := range [][]string{{"store"}, {"store", "wat"}} { |
| + out, err := run(nil, args...) |
| + if err == nil { |
| + t.Fatalf("carlos %v exited 0:\n%s", args, out) |
| + } |
| + if !strings.Contains(out, "usage: carlos store <create|status|grant|rotate>") { |
| + t.Fatalf("carlos %v = %q, want the store usage block", args, out) |
| + } |
| + } |
| + |
| + // Missing -app, one representative per sub-verb (each builds its own |
| + // usage line — the shape restart.go's own missing-app test pins). |
| + for _, tc := range []struct { |
| + sub, want string |
| + }{ |
| + {"create", "usage: carlos store create -app"}, |
| + {"status", "usage: carlos store status -app"}, |
| + {"grant", "usage: carlos store grant -app"}, |
| + {"rotate", "usage: carlos store rotate -app"}, |
| + } { |
| + out, err := run(nil, "store", tc.sub) |
| + if err == nil { |
| + t.Fatalf("carlos store %s with no -app exited 0:\n%s", tc.sub, out) |
| + } |
| + if !strings.Contains(out, tc.want) { |
| + t.Fatalf("carlos store %s with no -app = %q, want %q", tc.sub, out, tc.want) |
| + } |
| + } |
| + |
| + // Direct bucket mode: refused in store's OWN words, naming the login |
| + // that fixes it — never another verb's advice. |
| + out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "store", "status", "-app", "keymail") |
| + if err == nil { |
| + t.Fatalf("store in direct bucket mode exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "carlos store works through a console") || !strings.Contains(out, "carlos auth login") { |
| + t.Fatalf("direct-mode refusal = %q, want store's own message and the login fix", out) |
| + } |
| + |
| + // A malshaped app name is refused at the terminal before any dial. |
| + out, err = run(nil, "store", "status", "-app", "Not An App") |
| + if err == nil { |
| + t.Fatalf("store status with a bad app name exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "not a usable app name") { |
| + t.Fatalf("bad app name = %q, want the name gate", out) |
| + } |
| +} |