| 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. |
| 31 | package main |
| 32 | |
| 33 | import ( |
| 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 | |
| 48 | const 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 | |
| 97 | Apply, create, status, grant and rotate resolve accounts through your own |
| 98 | memberships. Approve and decline take an explicit sqid and do not require |
| 99 | membership in the target account. |
| 100 | |
| 101 | Signed-in only (carlos auth login): storage records live in the console's |
| 102 | own deployment bucket and its grant/rotate walks touch the deployment's own |
| 103 | cloud account — there is no direct-bucket equivalent for any of these |
| 104 | verbs. |
| 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. |
| 111 | func 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. |
| 153 | func 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 | |
| 168 | func 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. |
| 205 | func 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. |
| 224 | func 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 | |
| 232 | func 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 | |
| 257 | func 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 | |
| 287 | func 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 | |
| 318 | func 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 | |
| 349 | func 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 | |
| 369 | func 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 | |
| 386 | func 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 | |
| 397 | func 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 | |
| 406 | func 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 | |
| 434 | func 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. |
| 476 | func 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. |
| 524 | func 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. |
| 543 | func 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. |
| 572 | func 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 | |
| 584 | func 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. |
| 669 | func 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 | |
| 677 | func 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 | |
| 691 | func 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 | |
| 703 | func 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. |
| 728 | func 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 | |