carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// store.go is `carlos store`: declare, inspect, grant and rotate an app's
2// object-storage estate (issue #55). API mode ONLY, like restart and apps
3// create — the record lives in the console's database (well, its deployment
4// bucket, but there is no local copy to write bucket-direct against) and the
5// grant/rotate walks spend money in the deployment's own cloud account, so
6// there is nothing a headless box could do here that this CLI could
7// meaningfully drive on its own.
8//
9// Five sub-verbs, one flag family:
10//
11// create declare that an app wants a store, under an env prefix. A
12// REQUEST, not a provision — see printStoreCreate.
13// status the record's three blocks plus drift (missing_vars) and any
14// half-finished rotation (pending_finish) — the two facts a
15// member with no box access cannot see any other way.
16// grant operator-only: provision the declared store and deliver its
17// credentials. The one verb here that spends the deployment's
18// own money.
19// rotate mint a fresh credential; --finish retires the previous one.
20// scan the virus-scanning family (scan.go): request, status, grant,
21// revoke, sweep, enforce. Off by default and opt-in — a
22// deployment that sets nothing has no engine and no sweep.
23//
24// The output-honesty rule every line here follows is restart.go's: say what
25// was REQUESTED or RECORDED, never what a box has since done with it. A grant
26// or a rotate is answered by the console's own walk (it already ran, inline,
27// before this process gets its 200), so those two verbs get to say more than
28// restart can — but delivery into a running instance's environment is still a
29// converge a box does on its own schedule, which is why grant's own line
30// says "pick them up within seconds" rather than claiming they already have.
31package main
32
33import (
34 "context"
35 "errors"
36 "flag"
37 "fmt"
38 "io"
39 "log"
40 "os"
41 "strings"
42 "time"
43
44 "github.com/carlosframework/platform/internal/domains"
45 "github.com/carlosframework/platform/internal/objstore"
46)
47
48const storeUsage = `usage: carlos store <create|status|apply|approvals|approve|decline|grant|rotate|scan> [flags]
49
50
51 create --app <a> [--env-prefix <PREFIX>] [--environment <e>]
52 declare that this app wants object storage, under
53 an env prefix (default CARLOS_STORE). For an
54 approved account, this also provisions the store.
55 --environment declares a store belonging to one
56 named config bundle, with its own bucket and its
57 own credential, delivered into that bundle.
58 status --app <a> [--environment <e>]
59 show what's declared, granted and provisioned,
60 which delivered variables have not reached the
61 app's config yet, and whether a rotation is only
62 half finished. Without --environment it also names
63 every environment this app has a store for.
64 apply [--note <text>] apply for object storage for an account you own.
65 approvals list pending account applications (operator only).
66 approve --account <sqid>
67 approve an account for object storage (operator
68 only; an explicit account sqid is required).
69 decline --account <sqid> --reason <text>
70 decline an account application (operator only;
71 an explicit account sqid and reason are required).
72 grant --app <a> [--bucket <name>] [--environment <e>]
73 provision the declared store and deliver its
74 credentials. Needs a deployment operator
75 (CARLOS_STORE_OPERATORS) — a 403 for anyone else,
76 member or not. --bucket adopts an existing bucket
77 instead of minting one.
78 rotate --app <a> [--finish] [--environment <e>]
79 mint a fresh credential, leaving the old one live
80 until --finish deletes it. A member verb: spends
81 nothing new, so there is no operator gate here.
82 scan <sub-verb> --app <a>
83 virus scanning over the store's objects — off by
84 default. Run carlos store scan on its own to list
85 the sub-verbs. A clean result on a client-encrypted
86 store means nothing, and the CLI says so rather
87 than claiming coverage it cannot have.
88
89 --account <sqid|name> account the app belongs to (default: your only
90 account, or the configured/chosen one) —
91 resolved by the console against your own
92 memberships; signed-in mode only.
93 --account-id <sqid> account sqid, matched as a sqid ONLY.
94 --console <origin> which logged-in console to use (only needed with
95 several)
96
97Apply, create, status, grant and rotate resolve accounts through your own
98memberships. Approve and decline take an explicit sqid and do not require
99membership in the target account.
100
101Signed-in only (carlos auth login): storage records live in the console's
102own deployment bucket and its grant/rotate walks touch the deployment's own
103cloud account — there is no direct-bucket equivalent for any of these
104verbs.
105
106`
107
108// runStore dispatches the store sub-verbs, mirroring RunApps's arg slicing
109// (apps.go): args[0] is the sub-verb, the rest is that sub-verb's own flag
110// set.
111func runStore(args []string) {
112 if len(args) == 0 {
113 fmt.Fprint(os.Stderr, storeUsage)
114 os.Exit(1)
115 }
116 switch args[0] {
117 case "create":
118 runStoreCreate(args[1:])
119 case "status":
120 runStoreStatus(args[1:])
121 case "grant":
122 runStoreGrant(args[1:])
123 case "rotate":
124 runStoreRotate(args[1:])
125 case "apply":
126 runStoreApply(args[1:])
127 case "approvals":
128 runStoreApprovals(args[1:])
129 case "approve":
130 runStoreApprove(args[1:])
131 case "decline":
132 runStoreDecline(args[1:])
133 case "scan":
134 runStoreScan(args[1:])
135 default:
136 fmt.Fprint(os.Stderr, storeUsage)
137 os.Exit(1)
138 }
139}
140
141// requireStoreAPISession resolves signed-in mode for every store sub-verb,
142// refusing direct-bucket mode with store's OWN message — the pattern
143// requireRestartAPISession sets (restart.go), and for the same reason: a
144// refusal that names another verb's advice sends someone to a fix that
145// cannot help them.
146//
147// There is no headless fallback, and for a stronger reason than restart's:
148// restart's request is a console write a fleet listens for, but grant and
149// rotate don't just write an object — they call out to the deployment's own
150// cloud account (objstore.Grant/Rotate), an authority this binary has no
151// other way to exercise. A bucket-mode operator asking for a store has no
152// walk to drive bucket-direct at all.
153func requireStoreAPISession(consoleFlag string) *apiSession {
154 sess, err := resolveMode(consoleFlag)
155 if err != nil {
156 log.Fatal(err)
157 }
158 if sess == nil {
159 log.Fatal("carlos store works through a console, and this shell is in direct bucket mode\n" +
160 "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). store is a member/operator verb with\n" +
161 "no direct-bucket equivalent — the record lives in the console's deployment bucket, and grant\n" +
162 "and rotate call out to the deployment's own cloud account, an authority only the console can\n" +
163 "exercise. Unset the store env and run: carlos auth login --console <url>.")
164 }
165 return sess
166}
167
168func runStoreCreate(args []string) {
169 fs := flag.NewFlagSet("store create", flag.ExitOnError)
170 app := appFlag(fs, "app to declare a store for")
171 envPrefix := fs.String("env-prefix", "", "env var prefix to deliver under (default CARLOS_STORE)")
172 environment := storeEnvironmentFlag(fs, "declare a store for this config environment instead of the app itself")
173 acctRaw, acctIDRaw, _ := accountFlags(fs)
174 consoleFlag := consoleOption(fs, "console origin to declare through (API mode only; default: the only logged-in one)")
175 fs.Usage = flagUsage(fs, "")
176 fs.Parse(args)
177 name := strings.TrimSpace(*app)
178 usage := "usage: carlos store create --app <a> [--env-prefix <PREFIX>] [--environment <e>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
179 // Client-side gate before the server's, the same one every write verb in
180 // this file applies: a name the console is certain to refuse should cost
181 // a typo nothing.
182 if name != "" {
183 if err := validAppName(name); err != nil {
184 log.Fatalf("store create: %v", err)
185 }
186 }
187
188 sess := requireStoreAPISession(*consoleFlag)
189 printUsing(stdout, sess)
190 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
191 name = resolvedApp(sess, acct, name, usage)
192 if err := storeCreateAndProvision(context.Background(), sess, acct, name,
193 strings.TrimSpace(*environment), strings.TrimSpace(*envPrefix), stdout); err != nil {
194 log.Fatalf("store create: %v", err)
195 }
196}
197
198// storeEnvironmentFlag is the --environment flag every store verb takes: the
199// config environment whose store to act on, or none for the app's own.
200//
201// Declared in one place so the four verbs cannot drift on its spelling or on
202// what it means. The name matches `carlos env --environment` and
203// `carlos instances create --environment` deliberately: it is the same named
204// bundle in all three, and a store delivers INTO that bundle.
205func storeEnvironmentFlag(fs *flag.FlagSet, usage string) *string {
206 return fs.String("environment", "", usage)
207}
208
209// storeEnvMismatch refuses a reply that does not name the environment we
210// asked about.
211//
212// A console older than per-environment stores does not read ?environment= at
213// all: it answers about the app's OWN store, with a 200 and a perfectly
214// ordinary body. So `carlos store create --app x --environment beta` against
215// an un-upgraded console would declare — or grant, or rotate — the WRONG
216// store and report success. The env prefix is fixed once granted, and a
217// rotation mints a credential, so that mistake is not always reversible.
218//
219// Every console that knows about environment stores echoes the environment
220// on every store reply, including "" for the app's own. An older one omits
221// the key, which decodes as "" — so a reply that does not say what we asked
222// about is the tell, and it is a refusal rather than a warning because the
223// caller cannot inspect what happened without the very verb that just lied.
224func storeEnvMismatch(res apiStoreReply, env string) error {
225 if env == "" || res.Environment == env {
226 return nil
227 }
228 return fmt.Errorf("this console answered about the app's own store, not the %q environment — "+
229 "it is older than per-environment object storage. Upgrade the console, or drop --environment", env)
230}
231
232func storeCreateAndProvision(ctx context.Context, sess *apiSession, acct apiAcct, app, env, envPrefix string, out io.Writer) error {
233 res, err := sess.storeCreate(ctx, acct, app, env, envPrefix)
234 if err != nil {
235 return err
236 }
237 // Before printing: a reply about the wrong store must not be narrated as
238 // if it were the right one.
239 if err := storeEnvMismatch(res, env); err != nil {
240 return err
241 }
242 printStoreCreate(out, res)
243 if res.Backend == "" || !res.Entitlement.Approved() {
244 return nil
245 }
246 res, err = sess.storeProvision(ctx, acct, app, env)
247 if err != nil {
248 return err
249 }
250 if err := storeEnvMismatch(res, env); err != nil {
251 return err
252 }
253 printStoreCreate(out, res)
254 return nil
255}
256
257func runStoreStatus(args []string) {
258 fs := flag.NewFlagSet("store status", flag.ExitOnError)
259 app := appFlag(fs, "app whose store to show")
260 environment := storeEnvironmentFlag(fs, "show this config environment's store instead of the app's own")
261 acctRaw, acctIDRaw, _ := accountFlags(fs)
262 consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)")
263 fs.Usage = flagUsage(fs, "")
264 fs.Parse(args)
265 name := strings.TrimSpace(*app)
266 usage := "usage: carlos store status --app <a> [--environment <e>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
267 if name != "" {
268 if err := validAppName(name); err != nil {
269 log.Fatalf("store status: %v", err)
270 }
271 }
272
273 sess := requireStoreAPISession(*consoleFlag)
274 printUsing(stdout, sess)
275 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
276 name = resolvedApp(sess, acct, name, usage)
277 res, err := sess.storeGet(context.Background(), acct, name, strings.TrimSpace(*environment))
278 if err != nil {
279 log.Fatalf("store status: %v", err)
280 }
281 if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil {
282 log.Fatalf("store status: %v", err)
283 }
284 printStoreStatus(stdout, res)
285}
286
287func runStoreGrant(args []string) {
288 fs := flag.NewFlagSet("store grant", flag.ExitOnError)
289 app := appFlag(fs, "app to grant a store for")
290 bucket := fs.String("bucket", "", "adopt this existing bucket instead of minting one")
291 environment := storeEnvironmentFlag(fs, "grant this config environment's store instead of the app's own")
292 acctRaw, acctIDRaw, _ := accountFlags(fs)
293 consoleFlag := consoleOption(fs, "console origin to grant through (API mode only; default: the only logged-in one)")
294 fs.Usage = flagUsage(fs, "")
295 fs.Parse(args)
296 name := strings.TrimSpace(*app)
297 usage := "usage: carlos store grant --app <a> [--bucket <name>] [--environment <e>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
298 if name != "" {
299 if err := validAppName(name); err != nil {
300 log.Fatalf("store grant: %v", err)
301 }
302 }
303
304 sess := requireStoreAPISession(*consoleFlag)
305 printUsing(stdout, sess)
306 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
307 name = resolvedApp(sess, acct, name, usage)
308 res, err := sess.storeGrant(context.Background(), acct, name, strings.TrimSpace(*environment), strings.TrimSpace(*bucket))
309 if err != nil {
310 log.Fatalf("store grant: %v", err)
311 }
312 if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil {
313 log.Fatalf("store grant: %v", err)
314 }
315 printStoreGrant(stdout, res)
316}
317
318func runStoreRotate(args []string) {
319 fs := flag.NewFlagSet("store rotate", flag.ExitOnError)
320 app := appFlag(fs, "app whose store credential to rotate")
321 finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one")
322 environment := storeEnvironmentFlag(fs, "rotate this config environment's store instead of the app's own")
323 acctRaw, acctIDRaw, _ := accountFlags(fs)
324 consoleFlag := consoleOption(fs, "console origin to rotate through (API mode only; default: the only logged-in one)")
325 fs.Usage = flagUsage(fs, "")
326 fs.Parse(args)
327 name := strings.TrimSpace(*app)
328 usage := "usage: carlos store rotate --app <a> [--finish] [--environment <e>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
329 if name != "" {
330 if err := validAppName(name); err != nil {
331 log.Fatalf("store rotate: %v", err)
332 }
333 }
334
335 sess := requireStoreAPISession(*consoleFlag)
336 printUsing(stdout, sess)
337 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
338 name = resolvedApp(sess, acct, name, usage)
339 res, err := sess.storeRotate(context.Background(), acct, name, strings.TrimSpace(*environment), *finish)
340 if err != nil {
341 log.Fatalf("store rotate: %v", err)
342 }
343 if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil {
344 log.Fatalf("store rotate: %v", err)
345 }
346 printStoreRotate(stdout, res, *finish)
347}
348
349func runStoreApply(args []string) {
350 fs := flag.NewFlagSet("store apply", flag.ExitOnError)
351 note := fs.String("note", "", "what you plan to use object storage for")
352 acctRaw, acctIDRaw, _ := accountFlags(fs)
353 consoleFlag := consoleOption(fs, "console origin to apply through")
354 fs.Usage = flagUsage(fs, "")
355 fs.Parse(args)
356 if fs.NArg() != 0 {
357 log.Fatalf("store apply: unexpected argument %q", fs.Arg(0))
358 }
359 sess := requireStoreAPISession(*consoleFlag)
360 printUsing(stdout, sess)
361 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
362 res, err := sess.storeApply(context.Background(), acct, strings.TrimSpace(*note))
363 if err != nil {
364 log.Fatalf("store apply: %v", err)
365 }
366 printStoreApplication(stdout, res)
367}
368
369func runStoreApprovals(args []string) {
370 fs := flag.NewFlagSet("store approvals", flag.ExitOnError)
371 consoleFlag := consoleOption(fs, "console origin to read through")
372 fs.Usage = flagUsage(fs, "")
373 fs.Parse(args)
374 if fs.NArg() != 0 {
375 log.Fatalf("store approvals: unexpected argument %q", fs.Arg(0))
376 }
377 sess := requireStoreAPISession(*consoleFlag)
378 printUsing(stdout, sess)
379 res, err := sess.storeApprovals(context.Background())
380 if err != nil {
381 log.Fatalf("store approvals: %v", err)
382 }
383 printStoreApprovals(stdout, res)
384}
385
386func explicitStoreAccount(raw string) (string, error) {
387 account := strings.TrimSpace(raw)
388 if account == "" {
389 return "", fmt.Errorf("--account requires an account sqid")
390 }
391 if !domains.ValidSqid(account) {
392 return "", fmt.Errorf("--account %q is not an account sqid", account)
393 }
394 return account, nil
395}
396
397func runStoreApprove(args []string) {
398 if err := runStoreApproveWithSession(context.Background(), nil, args, stdout); err != nil {
399 if errors.Is(err, flag.ErrHelp) {
400 return
401 }
402 log.Fatalf("store approve: %v", err)
403 }
404}
405
406func runStoreApproveWithSession(ctx context.Context, sess *apiSession, args []string, out io.Writer) error {
407 fs := flag.NewFlagSet("store approve", flag.ContinueOnError)
408 fs.SetOutput(out)
409 accountFlag := fs.String("account", "", "account sqid to approve")
410 consoleFlag := consoleOption(fs, "console origin to approve through")
411 fs.Usage = flagUsage(fs, "")
412 if err := fs.Parse(args); err != nil {
413 return err
414 }
415 if fs.NArg() != 0 {
416 return fmt.Errorf("unexpected argument %q", fs.Arg(0))
417 }
418 account, err := explicitStoreAccount(*accountFlag)
419 if err != nil {
420 return err
421 }
422 if sess == nil {
423 sess = requireStoreAPISession(*consoleFlag)
424 printUsing(out, sess)
425 }
426 res, err := sess.storeApprove(ctx, account)
427 if err != nil {
428 return err
429 }
430 printStoreApplication(out, res)
431 return nil
432}
433
434func runStoreDecline(args []string) {
435 fs := flag.NewFlagSet("store decline", flag.ExitOnError)
436 accountFlag := fs.String("account", "", "account sqid to decline")
437 reason := fs.String("reason", "", "reason for declining object storage")
438 consoleFlag := consoleOption(fs, "console origin to decline through")
439 fs.Usage = flagUsage(fs, "")
440 fs.Parse(args)
441 if fs.NArg() != 0 {
442 log.Fatalf("store decline: unexpected argument %q", fs.Arg(0))
443 }
444 account, err := explicitStoreAccount(*accountFlag)
445 if err != nil {
446 log.Fatalf("store decline: %v", err)
447 }
448 if strings.TrimSpace(*reason) == "" {
449 log.Fatal("store decline: --reason is required")
450 }
451 sess := requireStoreAPISession(*consoleFlag)
452 printUsing(stdout, sess)
453 res, err := sess.storeDecline(context.Background(), account, strings.TrimSpace(*reason))
454 if err != nil {
455 log.Fatalf("store decline: %v", err)
456 }
457 printStoreApplication(stdout, res)
458}
459
460// ---- output -----------------------------------------------------------------
461//
462// Every function below takes the reply the console already sent and prints
463// it — no field here is guessed or extrapolated, and no line claims a box has
464// converged. That split (network in run*, rendering in print*) is what lets
465// the honesty rules below be tested directly against a literal apiStoreReply,
466// with no stub server or credential file in the loop at all.
467
468// printStoreCreate answers `carlos store create`: the prefix now declared,
469// the env names it will eventually deliver, and the one sentence that keeps
470// this from reading like a provision — nothing exists in any cloud account
471// yet. When the deployment itself has no backend at all (Backend == ""),
472// that fact is added too: a member cannot see CARLOS_STORE_BACKEND for
473// themselves, and "declared, nothing happening" with no way to tell a queued
474// request from a deployment that will never grant one fails the no-infra-
475// access rule (AGENTS.md) just as surely as a missing CLI command would.
476func printStoreCreate(w io.Writer, res apiStoreReply) {
477 c := colorFor(w)
478 prefix := ""
479 if res.Declared != nil {
480 prefix = res.Declared.EnvPrefix
481 }
482 fmt.Fprintf(w, "%s %s under prefix %s — delivers %s\n",
483 c.ok("declared a store for"), res.App, prefix, strings.Join(res.EnvNames, ", "))
484 if res.Environment != "" {
485 // Named on its own line rather than folded into the sentence above:
486 // the variables are delivered into THAT bundle, so only the
487 // instances bound to it read them.
488 fmt.Fprintf(w, " environment: %s — delivered into that bundle, not the app's default one\n", res.Environment)
489 }
490 if res.Provisioned != nil {
491 p := res.Provisioned
492 fmt.Fprintf(w, " provisioned: bucket %s in %s, user %s\n", p.Bucket, p.Region, p.IAMUser)
493 if len(res.MissingVars) > 0 {
494 verb := "grant"
495 if res.Entitlement.Approved() {
496 verb = "create"
497 }
498 warnf(w, "credentials are not fully delivered: %s. re-run `carlos store %s --app %s` to repair",
499 strings.Join(res.MissingVars, ", "), verb, res.App)
500 } else {
501 mutedf(w, "credentials are sealed into the app's config. running instances pick them up within seconds")
502 }
503 return
504 }
505 if !res.Entitlement.Approved() {
506 // The four-state printer, not the one-line "go apply": a PENDING
507 // applicant told to apply again overwrites their own Applied.At, and
508 // the queue sorts oldest first, so following that instruction sends
509 // them to the back of the line they are already standing in.
510 printStoreEntitlement(w, res.Entitlement)
511 }
512 if res.Backend == "" {
513 warnf(w, "this deployment has no object storage backend configured yet (the operator sets "+
514 "CARLOS_STORE_BACKEND) — the declaration will simply wait until one is")
515 }
516}
517
518// printStoreGrant answers `carlos store grant`: the walk already ran inline
519// (the console's handleAPIStoreGrant does not return 200 until it has),
520// which is why this can name the bucket/region/user rather than hedging like
521// create does. What it does NOT claim is that a running instance has the new
522// credential yet — that is still a converge a box does on its own poll, spec
523// unchanged by grant having succeeded here.
524func printStoreGrant(w io.Writer, res apiStoreReply) {
525 c := colorFor(w)
526 if res.Provisioned == nil {
527 // Defensive only: handleAPIStoreGrant never answers 200 without a
528 // provisioned block. If a future console did, this is still an
529 // honest thing to print — the record's own state, nothing invented.
530 fmt.Fprintf(w, "%s %s\n", c.ok("store granted for"), res.App)
531 return
532 }
533 p := res.Provisioned
534 fmt.Fprintf(w, "%s %s — bucket %s in %s, user %s\n",
535 c.ok("store granted for"), res.App, p.Bucket, p.Region, p.IAMUser)
536 mutedf(w, "credentials are sealed into the app's config. running instances pick them up within seconds")
537}
538
539// printStoreRotate answers both `carlos store rotate` and `carlos store
540// rotate --finish` — two different confirmations of two different walks, kept
541// in one function because they share the app name and the ok() styling and
542// nothing else worth factoring further.
543func printStoreRotate(w io.Writer, res apiStoreReply, finish bool) {
544 c := colorFor(w)
545 if finish {
546 // The walk deletes the previous key; the reply's own Provisioned
547 // block (PrevKeyID now empty, PendingFinish now false) is the
548 // record of that, not a claim this line re-derives.
549 fmt.Fprintf(w, "%s for %s\n", c.ok("previous key deleted"), res.App)
550 return
551 }
552 key := ""
553 if res.Provisioned != nil {
554 key = res.Provisioned.KeyID
555 }
556 fmt.Fprintf(w, "%s %s — new key %s\n", c.ok("rotated the store credential for"), res.App, key)
557 mutedf(w, "the old key stays active until `carlos store rotate --finish --app %s`", res.App)
558}
559
560// printStoreStatus answers `carlos store status` — the one sub-verb a member
561// with no box access has for seeing all three of a record's blocks, plus the
562// two facts nothing else in the CLI surfaces: config-delivery drift
563// (missing_vars) and a rotation stuck mid-window (pending_finish).
564//
565// The provisioned timestamp is labelled "last credential change", never
566// "provisioned at" — review finding from the walk design: it MOVES on every
567// rotate and every drift repair, so a fixed-sounding label would misdescribe
568// it the very first time either happens.
569// printStoreSize prints the bucket's size from the console's daily storage
570// sweep, dated, because it is up to a day old: status reads the last sweep
571// rather than listing the bucket on every call.
572func printStoreSize(w io.Writer, sz *apiStoreSize) {
573 switch {
574 case sz == nil:
575 mutedf(w, " size: not measured yet — the console sizes stores once a day")
576 case sz.Unswept:
577 warnf(w, "size: the %s sweep could not read this bucket (credential or endpoint) — no size known", sz.AsOf)
578 default:
579 n := sz.StoreBytes
580 fmt.Fprintf(w, " size: %s (as of %s)\n", humanBytes(&n), sz.AsOf)
581 }
582}
583
584func printStoreStatus(w io.Writer, res apiStoreReply) {
585 c := colorFor(w)
586 fmt.Fprintf(w, "%s %s\n", c.head("store for"), res.App)
587 if res.Environment != "" {
588 fmt.Fprintf(w, " environment: %s\n", res.Environment)
589 }
590 if res.Backend == "" {
591 warnf(w, "this deployment has no object storage backend configured yet (the operator sets CARLOS_STORE_BACKEND)")
592 }
593 printStoreEntitlement(w, res.Entitlement)
594 // Before the not-declared exit: an app whose storage is entirely
595 // per-environment has no store of its own, and stopping here would print
596 // "not declared" over a set of live buckets.
597 printStoreEnvironments(w, res)
598 if res.Declared == nil {
599 mutedf(w, "not declared — carlos store create --app %s", res.App)
600 return
601 }
602 fmt.Fprintf(w, " declared: prefix %s, by %s at %s\n",
603 res.Declared.EnvPrefix, res.Declared.By, res.Declared.At.Format(time.RFC3339))
604
605 if res.Grant != nil {
606 fmt.Fprintf(w, " grant: by %s at %s", res.Grant.By, res.Grant.At.Format(time.RFC3339))
607 if res.Grant.Bucket != "" {
608 // The walk always records the resolved bucket here — minted or
609 // adopted, the record does not distinguish — so this is not a
610 // claim about provenance, just the bucket the grant resolved to.
611 fmt.Fprintf(w, " (bucket %s)", res.Grant.Bucket)
612 }
613 fmt.Fprintln(w)
614 }
615
616 if res.Provisioned != nil {
617 p := res.Provisioned
618 fmt.Fprintf(w, " provisioned: bucket %s in %s, user %s, key %s — last credential change %s\n",
619 p.Bucket, p.Region, p.IAMUser, p.KeyID, p.At.Format(time.RFC3339))
620 printStoreSize(w, res.Size)
621 } else {
622 if res.Entitlement.Approved() {
623 mutedf(w, " nothing is provisioned yet. re-run carlos store create --app %s", res.App)
624 } else {
625 mutedf(w, " nothing is provisioned yet. waiting on a deployment operator (carlos store grant --app %s)", res.App)
626 }
627 }
628
629 if len(res.EnvNames) > 0 {
630 fmt.Fprintf(w, " delivers: %s\n", strings.Join(res.EnvNames, ", "))
631 }
632 if res.PendingFinish {
633 warnf(w, "a rotation is pending finish — the previous key is still live: carlos store rotate --finish --app %s", res.App)
634 }
635 // Scanning, once it exists. Deliberately three distinct states rather
636 // than a boolean: "requested" is a member waiting on an operator,
637 // "enabled" is an engine looking at objects, and "enforcing" is infected
638 // objects actually being blocked. Collapsing those would let someone read
639 // "scanning: on" and believe reads were denied when they were only
640 // recorded.
641 printStoreScanLine(w, res)
642
643 if len(res.MissingVars) > 0 {
644 // Named as drift, not as a fresh failure: this is the same state a
645 // healthy store passes through for a few seconds after every grant
646 // or rotate, until the config reaches the app. It only means
647 // something is actually wrong once it persists — which this CLI has
648 // no way to know from one snapshot, so the line names the repair
649 // rather than pronouncing a verdict.
650 verb := "grant"
651 if res.Entitlement.Approved() {
652 verb = "create"
653 }
654 warnf(w, "drift: %s not yet delivered to this app's config. re-run `carlos store %s --app %s` to "+
655 "repair (this rotates the credential)", strings.Join(res.MissingVars, ", "), verb, res.App)
656 }
657}
658
659// printStoreEnvironments names every OTHER store this app has. Each one is a
660// bucket of its own with a credential of its own — a store nobody remembers
661// is a bill nobody remembers, and teardown is explicit, so this line is where
662// an abandoned one becomes visible.
663//
664// Printed BEFORE the not-declared exit, because an app whose storage is
665// entirely per-environment has no store of its own: stopping at "not
666// declared" would say the app has no storage while it owns several buckets.
667// Only an app-store status carries these; a status aimed at one environment
668// answers about that environment and lists nothing.
669func printStoreEnvironments(w io.Writer, res apiStoreReply) {
670 if len(res.Environments) == 0 {
671 return
672 }
673 fmt.Fprintf(w, " environments with their own store: %s\n", strings.Join(res.Environments, ", "))
674 mutedf(w, " read one with carlos store status --app %s --environment %s", res.App, res.Environments[0])
675}
676
677func printStoreEntitlement(w io.Writer, entitlement *objstore.Entitlement) {
678 switch {
679 case entitlement.Approved():
680 case entitlement.Declined():
681 warnf(w, "object storage was declined: %s. the account owner may apply again",
682 entitlement.Decision.Reason)
683 case entitlement.Pending():
684 mutedf(w, "applied by %s on %s, waiting for a deployment operator",
685 entitlement.Applied.By, entitlement.Applied.At.Format(time.RFC3339))
686 default:
687 mutedf(w, "this account is not approved for object storage. run `carlos store apply` to ask for it")
688 }
689}
690
691func printStoreApplication(w io.Writer, res apiStoreAccountReply) {
692 e := res.Entitlement
693 switch {
694 case e != nil && e.Approved():
695 fmt.Fprintf(w, "%s %s for object storage\n", colorFor(w).ok("approved account"), res.Account)
696 case e != nil && e.Declined():
697 fmt.Fprintf(w, "%s %s for object storage — %s\n", colorFor(w).ok("declined account"), res.Account, e.Decision.Reason)
698 default:
699 fmt.Fprintf(w, "%s %s — waiting for a deployment operator\n", colorFor(w).ok("applied for object storage for account"), res.Account)
700 }
701}
702
703func printStoreApprovals(w io.Writer, res apiStoreApprovalsReply) {
704 if len(res.Applications) == 0 {
705 mutedf(w, "no pending object storage applications")
706 return
707 }
708 for _, e := range res.Applications {
709 // The server sends only Pending() entitlements, so Applied is always
710 // there — but this printer is fed by whatever console it is pointed
711 // at, and a CLI should not panic because a server changed its mind.
712 if e == nil || e.Applied == nil {
713 continue
714 }
715 fmt.Fprintf(w, "%s — applied by %s on %s\n", e.Account, e.Applied.By, e.Applied.At.Format(time.RFC3339))
716 if e.Applied.Note != "" {
717 fmt.Fprintf(w, " note: %s\n", e.Applied.Note)
718 }
719 mutedf(w, " carlos store approve --account %s", e.Account)
720 mutedf(w, " carlos store decline --account %s --reason <text>", e.Account)
721 }
722}
723
724// printStoreScanLine is store status's one-line summary of the scan family.
725// The detail lives behind `carlos store scan status`; this exists so a member
726// reading store status is never left believing nothing is looking at their
727// objects when something is, or the reverse.
728func printStoreScanLine(w io.Writer, res apiStoreReply) {
729 sc := res.Scanning
730 if sc == nil || (sc.Requested == nil && sc.Enabled == nil) {
731 return
732 }
733 switch {
734 case sc.Enabled != nil && sc.Enforce != nil:
735 fmt.Fprintf(w, " scanning: %s, enforcing — infected objects cannot be read by this app\n", sc.Enabled.Engine)
736 case sc.Enabled != nil:
737 fmt.Fprintf(w, " scanning: %s, recording only — infected objects are NOT blocked\n", sc.Enabled.Engine)
738 case sc.Requested != nil && sc.Requested.Encrypted:
739 mutedf(w, " scanning: requested, attested client-encrypted — no coverage will be claimed")
740 default:
741 mutedf(w, " scanning: requested, no engine enabled yet (carlos store scan status --app %s)", res.App)
742 }
743}
744