// 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. // // Five 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. // scan the virus-scanning family (scan.go): request, status, grant, // revoke, sweep, enforce. Off by default and opt-in — a // deployment that sets nothing has no engine and no sweep. // // 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" "errors" "flag" "fmt" "io" "log" "os" "strings" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/objstore" ) const storeUsage = `usage: carlos store [flags] create --app [--env-prefix ] [--environment ] declare that this app wants object storage, under an env prefix (default CARLOS_STORE). For an approved account, this also provisions the store. --environment declares a store belonging to one named config bundle, with its own bucket and its own credential, delivered into that bundle. status --app [--environment ] 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. Without --environment it also names every environment this app has a store for. apply [--note ] apply for object storage for an account you own. approvals list pending account applications (operator only). approve --account approve an account for object storage (operator only; an explicit account sqid is required). decline --account --reason decline an account application (operator only; an explicit account sqid and reason are required). grant --app [--bucket ] [--environment ] 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 [--finish] [--environment ] 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. scan --app virus scanning over the store's objects — off by default. Run carlos store scan on its own to list the sub-verbs. A clean result on a client-encrypted store means nothing, and the CLI says so rather than claiming coverage it cannot have. --account account the app belongs to (default: your only account, or the configured/chosen one) — resolved by the console against your own memberships; signed-in mode only. --account-id account sqid, matched as a sqid ONLY. --console which logged-in console to use (only needed with several) Apply, create, status, grant and rotate resolve accounts through your own memberships. Approve and decline take an explicit sqid and do not require membership in the target account. Signed-in only (carlos auth login): storage records live 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 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:]) case "apply": runStoreApply(args[1:]) case "approvals": runStoreApprovals(args[1:]) case "approve": runStoreApprove(args[1:]) case "decline": runStoreDecline(args[1:]) case "scan": runStoreScan(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 .") } return sess } func runStoreCreate(args []string) { fs := flag.NewFlagSet("store create", flag.ExitOnError) app := appFlag(fs, "app to declare a store for") envPrefix := fs.String("env-prefix", "", "env var prefix to deliver under (default CARLOS_STORE)") environment := storeEnvironmentFlag(fs, "declare a store for this config environment instead of the app itself") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to declare through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos store create --app [--env-prefix ] [--environment ] [--account ] [--account-id ] [--console ]" // 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 name != "" { if err := validAppName(name); err != nil { log.Fatalf("store create: %v", err) } } sess := requireStoreAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) name = resolvedApp(sess, acct, name, usage) if err := storeCreateAndProvision(context.Background(), sess, acct, name, strings.TrimSpace(*environment), strings.TrimSpace(*envPrefix), stdout); err != nil { log.Fatalf("store create: %v", err) } } // storeEnvironmentFlag is the --environment flag every store verb takes: the // config environment whose store to act on, or none for the app's own. // // Declared in one place so the four verbs cannot drift on its spelling or on // what it means. The name matches `carlos env --environment` and // `carlos instances create --environment` deliberately: it is the same named // bundle in all three, and a store delivers INTO that bundle. func storeEnvironmentFlag(fs *flag.FlagSet, usage string) *string { return fs.String("environment", "", usage) } // storeEnvMismatch refuses a reply that does not name the environment we // asked about. // // A console older than per-environment stores does not read ?environment= at // all: it answers about the app's OWN store, with a 200 and a perfectly // ordinary body. So `carlos store create --app x --environment beta` against // an un-upgraded console would declare — or grant, or rotate — the WRONG // store and report success. The env prefix is fixed once granted, and a // rotation mints a credential, so that mistake is not always reversible. // // Every console that knows about environment stores echoes the environment // on every store reply, including "" for the app's own. An older one omits // the key, which decodes as "" — so a reply that does not say what we asked // about is the tell, and it is a refusal rather than a warning because the // caller cannot inspect what happened without the very verb that just lied. func storeEnvMismatch(res apiStoreReply, env string) error { if env == "" || res.Environment == env { return nil } return fmt.Errorf("this console answered about the app's own store, not the %q environment — "+ "it is older than per-environment object storage. Upgrade the console, or drop --environment", env) } func storeCreateAndProvision(ctx context.Context, sess *apiSession, acct apiAcct, app, env, envPrefix string, out io.Writer) error { res, err := sess.storeCreate(ctx, acct, app, env, envPrefix) if err != nil { return err } // Before printing: a reply about the wrong store must not be narrated as // if it were the right one. if err := storeEnvMismatch(res, env); err != nil { return err } printStoreCreate(out, res) if res.Backend == "" || !res.Entitlement.Approved() { return nil } res, err = sess.storeProvision(ctx, acct, app, env) if err != nil { return err } if err := storeEnvMismatch(res, env); err != nil { return err } printStoreCreate(out, res) return nil } func runStoreStatus(args []string) { fs := flag.NewFlagSet("store status", flag.ExitOnError) app := appFlag(fs, "app whose store to show") environment := storeEnvironmentFlag(fs, "show this config environment's store instead of the app's own") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos store status --app [--environment ] [--account ] [--account-id ] [--console ]" if name != "" { if err := validAppName(name); err != nil { log.Fatalf("store status: %v", err) } } sess := requireStoreAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) name = resolvedApp(sess, acct, name, usage) res, err := sess.storeGet(context.Background(), acct, name, strings.TrimSpace(*environment)) if err != nil { log.Fatalf("store status: %v", err) } if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil { log.Fatalf("store status: %v", err) } printStoreStatus(stdout, res) } func runStoreGrant(args []string) { fs := flag.NewFlagSet("store grant", flag.ExitOnError) app := appFlag(fs, "app to grant a store for") bucket := fs.String("bucket", "", "adopt this existing bucket instead of minting one") environment := storeEnvironmentFlag(fs, "grant this config environment's store instead of the app's own") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to grant through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos store grant --app [--bucket ] [--environment ] [--account ] [--account-id ] [--console ]" if name != "" { if err := validAppName(name); err != nil { log.Fatalf("store grant: %v", err) } } sess := requireStoreAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) name = resolvedApp(sess, acct, name, usage) res, err := sess.storeGrant(context.Background(), acct, name, strings.TrimSpace(*environment), strings.TrimSpace(*bucket)) if err != nil { log.Fatalf("store grant: %v", err) } if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil { log.Fatalf("store grant: %v", err) } printStoreGrant(stdout, res) } func runStoreRotate(args []string) { fs := flag.NewFlagSet("store rotate", flag.ExitOnError) app := appFlag(fs, "app whose store credential to rotate") finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one") environment := storeEnvironmentFlag(fs, "rotate this config environment's store instead of the app's own") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to rotate through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos store rotate --app [--finish] [--environment ] [--account ] [--account-id ] [--console ]" if name != "" { if err := validAppName(name); err != nil { log.Fatalf("store rotate: %v", err) } } sess := requireStoreAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) name = resolvedApp(sess, acct, name, usage) res, err := sess.storeRotate(context.Background(), acct, name, strings.TrimSpace(*environment), *finish) if err != nil { log.Fatalf("store rotate: %v", err) } if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil { log.Fatalf("store rotate: %v", err) } printStoreRotate(stdout, res, *finish) } func runStoreApply(args []string) { fs := flag.NewFlagSet("store apply", flag.ExitOnError) note := fs.String("note", "", "what you plan to use object storage for") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to apply through") fs.Usage = flagUsage(fs, "") fs.Parse(args) if fs.NArg() != 0 { log.Fatalf("store apply: unexpected argument %q", fs.Arg(0)) } sess := requireStoreAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) res, err := sess.storeApply(context.Background(), acct, strings.TrimSpace(*note)) if err != nil { log.Fatalf("store apply: %v", err) } printStoreApplication(stdout, res) } func runStoreApprovals(args []string) { fs := flag.NewFlagSet("store approvals", flag.ExitOnError) consoleFlag := consoleOption(fs, "console origin to read through") fs.Usage = flagUsage(fs, "") fs.Parse(args) if fs.NArg() != 0 { log.Fatalf("store approvals: unexpected argument %q", fs.Arg(0)) } sess := requireStoreAPISession(*consoleFlag) printUsing(stdout, sess) res, err := sess.storeApprovals(context.Background()) if err != nil { log.Fatalf("store approvals: %v", err) } printStoreApprovals(stdout, res) } func explicitStoreAccount(raw string) (string, error) { account := strings.TrimSpace(raw) if account == "" { return "", fmt.Errorf("--account requires an account sqid") } if !domains.ValidSqid(account) { return "", fmt.Errorf("--account %q is not an account sqid", account) } return account, nil } func runStoreApprove(args []string) { if err := runStoreApproveWithSession(context.Background(), nil, args, stdout); err != nil { if errors.Is(err, flag.ErrHelp) { return } log.Fatalf("store approve: %v", err) } } func runStoreApproveWithSession(ctx context.Context, sess *apiSession, args []string, out io.Writer) error { fs := flag.NewFlagSet("store approve", flag.ContinueOnError) fs.SetOutput(out) accountFlag := fs.String("account", "", "account sqid to approve") consoleFlag := consoleOption(fs, "console origin to approve through") fs.Usage = flagUsage(fs, "") if err := fs.Parse(args); err != nil { return err } if fs.NArg() != 0 { return fmt.Errorf("unexpected argument %q", fs.Arg(0)) } account, err := explicitStoreAccount(*accountFlag) if err != nil { return err } if sess == nil { sess = requireStoreAPISession(*consoleFlag) printUsing(out, sess) } res, err := sess.storeApprove(ctx, account) if err != nil { return err } printStoreApplication(out, res) return nil } func runStoreDecline(args []string) { fs := flag.NewFlagSet("store decline", flag.ExitOnError) accountFlag := fs.String("account", "", "account sqid to decline") reason := fs.String("reason", "", "reason for declining object storage") consoleFlag := consoleOption(fs, "console origin to decline through") fs.Usage = flagUsage(fs, "") fs.Parse(args) if fs.NArg() != 0 { log.Fatalf("store decline: unexpected argument %q", fs.Arg(0)) } account, err := explicitStoreAccount(*accountFlag) if err != nil { log.Fatalf("store decline: %v", err) } if strings.TrimSpace(*reason) == "" { log.Fatal("store decline: --reason is required") } sess := requireStoreAPISession(*consoleFlag) printUsing(stdout, sess) res, err := sess.storeDecline(context.Background(), account, strings.TrimSpace(*reason)) if err != nil { log.Fatalf("store decline: %v", err) } printStoreApplication(stdout, res) } // ---- 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 (AGENTS.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, ", ")) if res.Environment != "" { // Named on its own line rather than folded into the sentence above: // the variables are delivered into THAT bundle, so only the // instances bound to it read them. fmt.Fprintf(w, " environment: %s — delivered into that bundle, not the app's default one\n", res.Environment) } if res.Provisioned != nil { p := res.Provisioned fmt.Fprintf(w, " provisioned: bucket %s in %s, user %s\n", p.Bucket, p.Region, p.IAMUser) if len(res.MissingVars) > 0 { verb := "grant" if res.Entitlement.Approved() { verb = "create" } warnf(w, "credentials are not fully delivered: %s. re-run `carlos store %s --app %s` to repair", strings.Join(res.MissingVars, ", "), verb, res.App) } else { mutedf(w, "credentials are sealed into the app's config. running instances pick them up within seconds") } return } if !res.Entitlement.Approved() { // The four-state printer, not the one-line "go apply": a PENDING // applicant told to apply again overwrites their own Applied.At, and // the queue sorts oldest first, so following that instruction sends // them to the back of the line they are already standing in. printStoreEntitlement(w, res.Entitlement) } 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. // printStoreSize prints the bucket's size from the console's daily storage // sweep, dated, because it is up to a day old: status reads the last sweep // rather than listing the bucket on every call. func printStoreSize(w io.Writer, sz *apiStoreSize) { switch { case sz == nil: mutedf(w, " size: not measured yet — the console sizes stores once a day") case sz.Unswept: warnf(w, "size: the %s sweep could not read this bucket (credential or endpoint) — no size known", sz.AsOf) default: n := sz.StoreBytes fmt.Fprintf(w, " size: %s (as of %s)\n", humanBytes(&n), sz.AsOf) } } func printStoreStatus(w io.Writer, res apiStoreReply) { c := colorFor(w) fmt.Fprintf(w, "%s %s\n", c.head("store for"), res.App) if res.Environment != "" { fmt.Fprintf(w, " environment: %s\n", res.Environment) } if res.Backend == "" { warnf(w, "this deployment has no object storage backend configured yet (the operator sets CARLOS_STORE_BACKEND)") } printStoreEntitlement(w, res.Entitlement) // Before the not-declared exit: an app whose storage is entirely // per-environment has no store of its own, and stopping here would print // "not declared" over a set of live buckets. printStoreEnvironments(w, res) 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 != "" { // The walk always records the resolved bucket here — minted or // adopted, the record does not distinguish — so this is not a // claim about provenance, just the bucket the grant resolved to. fmt.Fprintf(w, " (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)) printStoreSize(w, res.Size) } else { if res.Entitlement.Approved() { mutedf(w, " nothing is provisioned yet. re-run carlos store create --app %s", res.App) } 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) } // Scanning, once it exists. Deliberately three distinct states rather // than a boolean: "requested" is a member waiting on an operator, // "enabled" is an engine looking at objects, and "enforcing" is infected // objects actually being blocked. Collapsing those would let someone read // "scanning: on" and believe reads were denied when they were only // recorded. printStoreScanLine(w, res) 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. verb := "grant" if res.Entitlement.Approved() { verb = "create" } warnf(w, "drift: %s not yet delivered to this app's config. re-run `carlos store %s --app %s` to "+ "repair (this rotates the credential)", strings.Join(res.MissingVars, ", "), verb, res.App) } } // printStoreEnvironments names every OTHER store this app has. Each one is a // bucket of its own with a credential of its own — a store nobody remembers // is a bill nobody remembers, and teardown is explicit, so this line is where // an abandoned one becomes visible. // // Printed BEFORE the not-declared exit, because an app whose storage is // entirely per-environment has no store of its own: stopping at "not // declared" would say the app has no storage while it owns several buckets. // Only an app-store status carries these; a status aimed at one environment // answers about that environment and lists nothing. func printStoreEnvironments(w io.Writer, res apiStoreReply) { if len(res.Environments) == 0 { return } fmt.Fprintf(w, " environments with their own store: %s\n", strings.Join(res.Environments, ", ")) mutedf(w, " read one with carlos store status --app %s --environment %s", res.App, res.Environments[0]) } func printStoreEntitlement(w io.Writer, entitlement *objstore.Entitlement) { switch { case entitlement.Approved(): case entitlement.Declined(): warnf(w, "object storage was declined: %s. the account owner may apply again", entitlement.Decision.Reason) case entitlement.Pending(): mutedf(w, "applied by %s on %s, waiting for a deployment operator", entitlement.Applied.By, entitlement.Applied.At.Format(time.RFC3339)) default: mutedf(w, "this account is not approved for object storage. run `carlos store apply` to ask for it") } } func printStoreApplication(w io.Writer, res apiStoreAccountReply) { e := res.Entitlement switch { case e != nil && e.Approved(): fmt.Fprintf(w, "%s %s for object storage\n", colorFor(w).ok("approved account"), res.Account) case e != nil && e.Declined(): fmt.Fprintf(w, "%s %s for object storage — %s\n", colorFor(w).ok("declined account"), res.Account, e.Decision.Reason) default: fmt.Fprintf(w, "%s %s — waiting for a deployment operator\n", colorFor(w).ok("applied for object storage for account"), res.Account) } } func printStoreApprovals(w io.Writer, res apiStoreApprovalsReply) { if len(res.Applications) == 0 { mutedf(w, "no pending object storage applications") return } for _, e := range res.Applications { // The server sends only Pending() entitlements, so Applied is always // there — but this printer is fed by whatever console it is pointed // at, and a CLI should not panic because a server changed its mind. if e == nil || e.Applied == nil { continue } fmt.Fprintf(w, "%s — applied by %s on %s\n", e.Account, e.Applied.By, e.Applied.At.Format(time.RFC3339)) if e.Applied.Note != "" { fmt.Fprintf(w, " note: %s\n", e.Applied.Note) } mutedf(w, " carlos store approve --account %s", e.Account) mutedf(w, " carlos store decline --account %s --reason ", e.Account) } } // printStoreScanLine is store status's one-line summary of the scan family. // The detail lives behind `carlos store scan status`; this exists so a member // reading store status is never left believing nothing is looking at their // objects when something is, or the reverse. func printStoreScanLine(w io.Writer, res apiStoreReply) { sc := res.Scanning if sc == nil || (sc.Requested == nil && sc.Enabled == nil) { return } switch { case sc.Enabled != nil && sc.Enforce != nil: fmt.Fprintf(w, " scanning: %s, enforcing — infected objects cannot be read by this app\n", sc.Enabled.Engine) case sc.Enabled != nil: fmt.Fprintf(w, " scanning: %s, recording only — infected objects are NOT blocked\n", sc.Enabled.Engine) case sc.Requested != nil && sc.Requested.Encrypted: mutedf(w, " scanning: requested, attested client-encrypted — no coverage will be claimed") default: mutedf(w, " scanning: requested, no engine enabled yet (carlos store scan status --app %s)", res.App) } }