| 1 | // The `carlos apps` verbs: create an app, and delete (trash) / restore one. |
| 2 | // |
| 3 | // create is the CLI door onto the same app-name claim the console's New app |
| 4 | // form makes, console API ONLY — the claims table lives in the console's |
| 5 | // database, which direct bucket mode never opens; in direct mode an app |
| 6 | // comes into existence at its first ship, or box-side via `console grant` |
| 7 | // (docs/superpowers/specs/2026-08-04-cli-app-create-design.md). |
| 8 | // |
| 9 | // delete/restore run in the same two modes every other write verb does — |
| 10 | // signed-in through the console API, or headless straight against the |
| 11 | // deployment bucket. See |
| 12 | // docs/superpowers/specs/2026-08-04-deletion-policy-design.md. |
| 13 | // |
| 14 | // Deleting is reversible for 30 days (release.TrashCooloff) and restoring is |
| 15 | // the undo button — that asymmetry is why this file has two verbs and no |
| 16 | // third: there is no purge here. "Delete now" is console-only, gated behind |
| 17 | // a passkey step-up this CLI cannot perform; a headless deployment either |
| 18 | // waits out the cooloff or an operator deletes bucket keys by hand, which is |
| 19 | // outside the platform's promises (spec §6). |
| 20 | package main |
| 21 | |
| 22 | import ( |
| 23 | "context" |
| 24 | "crypto/ed25519" |
| 25 | "errors" |
| 26 | "flag" |
| 27 | "fmt" |
| 28 | "log" |
| 29 | "os" |
| 30 | "strings" |
| 31 | "time" |
| 32 | |
| 33 | "github.com/carlosframework/platform/internal/registry" |
| 34 | "github.com/carlosframework/platform/internal/release" |
| 35 | ) |
| 36 | |
| 37 | const appsUsage = `usage: carlos apps <create|place|build-probe|set|delete|restore|ttl|keep|list> [flags] <app> |
| 38 | |
| 39 | create --app <name> [flags] claim a new app name in an account, through |
| 40 | the console API — the same claim the console's New app |
| 41 | form makes, so a name the form would refuse is refused |
| 42 | here too. Rate limited per address per day; the console |
| 43 | form is not, and is the fallback. Signed-in mode only: |
| 44 | in direct bucket mode the first ship brings an app into |
| 45 | existence, and "console grant" is the box-side path. |
| 46 | --place fleet/<f> place the app on a customer fleet at the same |
| 47 | instant it is created — equivalent to running "apps place" |
| 48 | right after, but works even though the fresh app has zero |
| 49 | routes yet (the console stores placement on the app record |
| 50 | itself, not only on its routes). Validated the same way |
| 51 | --target below is: the grammar here, ownership server-side; |
| 52 | an invalid or foreign fleet refuses the WHOLE create. |
| 53 | --control-plane mark the app as the control plane: it then |
| 54 | refuses trash, purge, rename and transfer, and refuses a |
| 55 | service credential's promote — owner only. |
| 56 | --build-probe /path the path whose response body names the running |
| 57 | build, e.g. "/healthz" serving "ok abc1234". "carlos deploy" |
| 58 | polls it instead of the X-Carlos-Version header, which the |
| 59 | edge stamps from the route row and which therefore reports |
| 60 | the new build before the process has actually restarted. |
| 61 | Both flags are stamped at CLAIM time and verified against |
| 62 | what the console echoes back, so a store write that silently |
| 63 | failed is a loud error rather than a quiet lie. |
| 64 | |
| 65 | place --app <name> --target fleet/<f>|"" repoint where an app's routes — |
| 66 | and its already-provisioned instances — should run: onto a |
| 67 | customer fleet ("fleet/<name>", a fleet your account owns), |
| 68 | or "" to clear back to platform-served. Every NEW instance |
| 69 | created afterwards inherits this target automatically; |
| 70 | every instance that already exists is repointed by this |
| 71 | call itself. --target is MANDATORY, not optional despite the |
| 72 | "" spelling — leaving it off is a usage error, never a |
| 73 | silent clear (a fleet box retires an app's routes on its |
| 74 | next reconcile the moment placement reads empty). |
| 75 | |
| 76 | build-probe --app <name> </path|-> set (or clear, with "-") the path whose |
| 77 | response body names the RUNNING build, on an app that |
| 78 | already exists. "carlos deploy" polls it instead of the |
| 79 | X-Carlos-Version header, which the edge stamps from the |
| 80 | route row and which therefore reports the new build before |
| 81 | the process has actually restarted — the whole reason a |
| 82 | deploy can print "live" over an old binary. Settable only |
| 83 | at claim time until now, which meant the fix was out of |
| 84 | reach of every app that already had the problem. |
| 85 | |
| 86 | set <app> [--swap overlap|hold] [--swap-concurrency <n>] how the app's |
| 87 | running instances take a new build: see carlos apps set -h. |
| 88 | |
| 89 | delete [flags] <app> move an app to the Trash: every route drops |
| 90 | (custom domains included), its name stays reserved, and it |
| 91 | is restorable for 30 days, or 3 for a temporary app. Refused |
| 92 | while the app is flagged production — turn production off |
| 93 | first, then delete — or when that flag cannot be read at |
| 94 | all: an unreadable flag is treated as production, same |
| 95 | direction "bake" fails. |
| 96 | |
| 97 | restore [flags] <app> undo a delete restorable for 30 days, or 3 for a |
| 98 | temporary app. Releases, channels and the production flag |
| 99 | are exactly as they were — but NO routes come back, not |
| 100 | even the platform apex: run "carlos add <host>" afterwards |
| 101 | to serve it again. |
| 102 | A tmp- app needs none of that: its instance records |
| 103 | are kept in the Trash, so hosts bring its routes back |
| 104 | on their next pass. |
| 105 | |
| 106 | ttl --app <name> <1h to 30d> move a temporary app's expiry to that long |
| 107 | from now, e.g. 5d or 36h. Temporary apps only. |
| 108 | |
| 109 | keep --app <name> make a temporary app permanent. It keeps its tmp- name, |
| 110 | and everything a temporary app cannot do (custom domains, |
| 111 | binaries, storage, email) opens up. |
| 112 | |
| 113 | list every app in the account, the Trash included: live or |
| 114 | trashed, and when it expires or is purged. |
| 115 | |
| 116 | --account <sqid|name> account the app belongs to (default: your only |
| 117 | account, or the configured/chosen one) — resolved |
| 118 | by the console against your own memberships; signed-in |
| 119 | mode only. |
| 120 | --account-id <sqid> account sqid, matched as a sqid ONLY — the only |
| 121 | reference headless mode accepts (a name has no |
| 122 | console here to resolve it against), and supersedes |
| 123 | --account in signed-in mode too. |
| 124 | --console <origin> which logged-in console to use (only needed with |
| 125 | several) |
| 126 | |
| 127 | Signed-in (carlos auth login): both verbs go through the console API — any |
| 128 | member of the account, bearer token alone, POSTing to |
| 129 | /api/cli/apps/<account>/<app>/delete|restore. |
| 130 | |
| 131 | Headless (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR set, no login): |
| 132 | both verbs write or remove the trash marker bucket-direct. --account-id is |
| 133 | required — there is no console here to resolve a name against. delete also |
| 134 | needs CARLOS_RELEASE_PUBKEY, to prove the app is not flagged production. |
| 135 | |
| 136 | There is no purge verb here: "Delete now" is console-only, behind its own |
| 137 | passkey step-up. Headless deployments wait out the window: 30 days, or 3 for a |
| 138 | temporary app. |
| 139 | ` |
| 140 | |
| 141 | // RunApps dispatches the apps sub-verbs. |
| 142 | func RunApps(args []string) { |
| 143 | if len(args) == 0 { |
| 144 | fmt.Fprint(os.Stderr, appsUsage) |
| 145 | os.Exit(1) |
| 146 | } |
| 147 | switch args[0] { |
| 148 | case "create": |
| 149 | runAppsCreate(args[1:]) |
| 150 | case "place": |
| 151 | runAppsPlace(args[1:]) |
| 152 | case "build-probe": |
| 153 | runAppsBuildProbe(args[1:]) |
| 154 | case "set": |
| 155 | runAppsSet(args[1:]) |
| 156 | case "delete": |
| 157 | runAppsDelete(args[1:]) |
| 158 | case "restore": |
| 159 | runAppsRestore(args[1:]) |
| 160 | case "ttl": |
| 161 | runAppsTTL(args[1:]) |
| 162 | case "keep": |
| 163 | runAppsKeep(args[1:]) |
| 164 | case "list": |
| 165 | runAppsList(args[1:]) |
| 166 | default: |
| 167 | fmt.Fprint(os.Stderr, appsUsage) |
| 168 | os.Exit(1) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // validAppName is release.ValidApp's CLI-facing gate, checked BEFORE mode |
| 173 | // selection in both verbs (review finding: the --account-id "ops" phantom |
| 174 | // trap, one level down). Neither TrashApp nor RestoreApp validates their app |
| 175 | // argument — they trust the caller — and the signed-in path's 404 |
| 176 | // (apiAccountApp's HasAppIn) only catches a bad name because the CONSOLE |
| 177 | // happens to keep a row per real app. Headless mode has no such row: nothing |
| 178 | // stops `carlos apps delete --account-id bab MyApp` from writing |
| 179 | // apps/bab/MyApp/deleted.json — a path release.ValidApp accepts as a bucket |
| 180 | // key but that no real app can ever have (Ship/SetProduction both require |
| 181 | // ValidApp, so a real app's own name always passes) — printing "trashed" |
| 182 | // while the actual "myapp" keeps serving, untouched, forever. Refusing here, |
| 183 | // before either mode runs, keeps both paths refusing the same typo instead |
| 184 | // of only the lucky one. |
| 185 | func validAppName(app string) error { |
| 186 | if release.ValidApp(app) { |
| 187 | return nil |
| 188 | } |
| 189 | return fmt.Errorf("%q is not a usable app name — 1-32 characters, lowercase a-z, 0-9 and hyphen, "+ |
| 190 | "starting with a letter", app) |
| 191 | } |
| 192 | |
| 193 | // appsMode is the pure decision behind both apps verbs' mode selection, |
| 194 | // extracted so it can be table-tested without a credentials file or a |
| 195 | // bucket on disk. loggedIn and bucketConfigured come straight off |
| 196 | // resolveMode's own result — sess != nil, and sess == nil && err == nil, |
| 197 | // respectively — which resolveMode's own env check already keeps mutually |
| 198 | // exclusive whenever it returns no error at all. accountID is the trimmed |
| 199 | // --account-id value, already shape-checked by checkAccountID (accounts.go), |
| 200 | // so a malshaped one (a name, or "ops") never reaches here at all. |
| 201 | // |
| 202 | // - signed-in wins outright: it needs no local account-id, since the |
| 203 | // console resolves --account (sqid or name) against the caller's own |
| 204 | // memberships server-side. |
| 205 | // - headless requires an explicit --account-id: a name has no console here |
| 206 | // to resolve it against, and the flag that never guesses is the only |
| 207 | // safe reference on a box (accounts.go's checkAccountID doc comment). |
| 208 | // - neither configured is fatal, with both fixes named — the same |
| 209 | // two-way guidance accounts.go:112-118 uses for an ownerless account. |
| 210 | func appsMode(loggedIn, bucketConfigured bool, accountID string) (mode string, err error) { |
| 211 | switch { |
| 212 | case loggedIn: |
| 213 | return "signed-in", nil |
| 214 | case bucketConfigured: |
| 215 | if strings.TrimSpace(accountID) == "" { |
| 216 | return "", errors.New("headless mode (CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR, no login) needs " + |
| 217 | "--account-id <sqid> — a name has no console here to resolve it against") |
| 218 | } |
| 219 | return "headless", nil |
| 220 | default: |
| 221 | return "", errors.New("neither a console login nor a deployment store is configured:\n\n" + |
| 222 | " carlos auth login --console <url> log this terminal in; use the console API\n" + |
| 223 | " CARLOS_DEPLOYMENT_BUCKET=... (or CARLOS_DEPLOYMENT_DIR=...) work bucket-direct instead\n\n" + |
| 224 | "apps delete/restore need one or the other") |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | func runAppsDelete(args []string) { |
| 229 | fs := flag.NewFlagSet("apps delete", flag.ExitOnError) |
| 230 | acctRaw, acctIDRaw, _ := accountFlags(fs) |
| 231 | consoleFlag := consoleOption(fs, "console origin to delete through (signed-in mode only; default: the only logged-in one)") |
| 232 | fs.Usage = flagUsage(fs, "") |
| 233 | fs.Parse(args) |
| 234 | if fs.NArg() != 1 { |
| 235 | log.Fatal("usage: carlos apps delete [--account <sqid|name>] [--account-id <sqid>] [--console <origin>] <app>") |
| 236 | } |
| 237 | app := fs.Arg(0) |
| 238 | if err := validAppName(app); err != nil { |
| 239 | log.Fatalf("apps delete: %v", err) |
| 240 | } |
| 241 | acctID := checkAccountID(*acctIDRaw) |
| 242 | |
| 243 | sess, err := resolveMode(*consoleFlag) |
| 244 | if err != nil { |
| 245 | log.Fatal(err) |
| 246 | } |
| 247 | mode, err := appsMode(sess != nil, sess == nil, acctID) |
| 248 | if err != nil { |
| 249 | log.Fatal(err) |
| 250 | } |
| 251 | |
| 252 | if mode == "signed-in" { |
| 253 | printUsing(stdout, sess) |
| 254 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 255 | res, err := sess.trashApp(context.Background(), acct, app) |
| 256 | if err != nil { |
| 257 | log.Fatal(err) |
| 258 | } |
| 259 | printTrashed(app, res.RestorableUntil, time.Now()) |
| 260 | return |
| 261 | } |
| 262 | |
| 263 | // Headless: this box has no console to enforce the production gate, so |
| 264 | // headlessTrash enforces it itself — pulled out into its own function |
| 265 | // (review finding: the destructive gate order needs direct test |
| 266 | // coverage, not only exercise-through-the-CLI). |
| 267 | store := openStore() |
| 268 | var pub ed25519.PublicKey |
| 269 | if pubRaw := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")); pubRaw != "" { |
| 270 | p, perr := release.ParsePublicKey(pubRaw, "CARLOS_RELEASE_PUBKEY") |
| 271 | if perr != nil { |
| 272 | log.Fatalf("apps delete: %v", perr) |
| 273 | } |
| 274 | pub = p |
| 275 | } |
| 276 | deletedAt, err := headlessTrash(context.Background(), store, pub, acctID, app, time.Now()) |
| 277 | if err != nil { |
| 278 | log.Fatalf("apps delete: %v", err) |
| 279 | } |
| 280 | fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("trashed"), app) |
| 281 | for _, line := range headlessTrashLines(app, deletedAt, time.Now()) { |
| 282 | fmt.Fprintln(stdout, line) |
| 283 | } |
| 284 | mutedf(stdout, "%s", restoreHint(app)) |
| 285 | } |
| 286 | |
| 287 | // headlessTrash is the destructive headless-mode gate: no console in the |
| 288 | // loop to enforce anything, so this is the whole check, pulled out of |
| 289 | // runAppsDelete so it can be driven directly against a real store in tests |
| 290 | // rather than only through the CLI's os.Exit-on-refusal plumbing (review |
| 291 | // finding — this gate had zero test coverage). Gate order matches |
| 292 | // handleAPITrashApp's (internal/console/apicli.go) exactly: |
| 293 | // |
| 294 | // - a malshaped app name refuses before anything else (validAppName) — |
| 295 | // defense in depth, since runAppsDelete already checks this before mode |
| 296 | // selection, but this function must not trust its caller either. |
| 297 | // - no usable public key refuses before the store is even asked anything: |
| 298 | // with nothing to verify a signature against, an unreadable flag and an |
| 299 | // absent one are indistinguishable, so this is the same "unreadable |
| 300 | // fails toward refusal" rule one step earlier. |
| 301 | // - the production flag ON, or genuinely UNREADABLE, refuses — NOTHING is |
| 302 | // written on this path. |
| 303 | // - only then TrashApp, followed by the SAME re-read handleAPITrashApp |
| 304 | // does: TrashApp is create-only, so an app already in the trash (a |
| 305 | // racing headless caller, or a previous run) keeps its ORIGINAL clock, |
| 306 | // and printing this call's own `now` would over-promise a deadline |
| 307 | // nothing will honour. The function returns the marker's own DeletedAt |
| 308 | // (not DeletedAt + TrashCooloff): there is no console row here to say |
| 309 | // whether the app was temporary, so the caller (headlessTrashLines) |
| 310 | // works out both possible windows from that one clock. |
| 311 | func headlessTrash(ctx context.Context, store release.Store, pub ed25519.PublicKey, acct, app string, now time.Time) (time.Time, error) { |
| 312 | if err := validAppName(app); err != nil { |
| 313 | return time.Time{}, err |
| 314 | } |
| 315 | if len(pub) != ed25519.PublicKeySize { |
| 316 | return time.Time{}, errors.New("CARLOS_RELEASE_PUBKEY is not set — deleting must be able to prove the app " + |
| 317 | "is not flagged production, and a flag that cannot be proven is treated as production and refused") |
| 318 | } |
| 319 | prod, err := release.GetProduction(ctx, store, pub, acct, app) |
| 320 | if err != nil { |
| 321 | return time.Time{}, fmt.Errorf("could not read the production flag for %s/%s — an unreadable flag is "+ |
| 322 | "treated as production, refusing: %w", acct, app, err) |
| 323 | } |
| 324 | if prod != nil && prod.Production { |
| 325 | return time.Time{}, fmt.Errorf("%s/%s is flagged production, so deleting is refused — turn production off "+ |
| 326 | "first, then try again", acct, app) |
| 327 | } |
| 328 | if err := release.TrashApp(ctx, store, acct, app, defaultLabel(), now); err != nil { |
| 329 | return time.Time{}, err |
| 330 | } |
| 331 | tomb, err := release.ReadAppTombstone(ctx, store, acct, app) |
| 332 | if err != nil { |
| 333 | return time.Time{}, fmt.Errorf("%s/%s is trashed, but the restore window could not be confirmed — retry is "+ |
| 334 | "safe (the marker is create-only): %w", acct, app, err) |
| 335 | } |
| 336 | if tomb == nil { |
| 337 | return time.Time{}, fmt.Errorf("%s/%s is trashed, but its marker vanished before the window could be "+ |
| 338 | "confirmed (a restore may have raced this) — retry is safe", acct, app) |
| 339 | } |
| 340 | return tomb.DeletedAt, nil |
| 341 | } |
| 342 | |
| 343 | // printTrashed is the signed-in delete's output: the console's own deadline |
| 344 | // (effectivePurgeAt), how far off it is, and the restore command. |
| 345 | func printTrashed(app, restorableUntil string, now time.Time) { |
| 346 | when, rel := restorableUntil, "" |
| 347 | if t, err := time.Parse(time.RFC3339, restorableUntil); err == nil { |
| 348 | when, rel = t.UTC().Format("2006-01-02 15:04 UTC"), inDuration(t.Sub(now)) |
| 349 | } |
| 350 | fmt.Fprintf(stdout, "%s %s. It is purged %s, at %s. Until then, carlos apps restore %s brings it back.\n", |
| 351 | colorFor(stdout).ok("trashed"), app, rel, when, app) |
| 352 | mutedf(stdout, "%s", restoreHint(app)) |
| 353 | } |
| 354 | |
| 355 | // headlessTrashLines is direct mode's delete output (spec §5, astra 7.4 and |
| 356 | // 8.2). There is no console row here, so it cannot know whether the app is |
| 357 | // temporary, and the tmp- prefix does not say (a kept app keeps it). It names |
| 358 | // both windows from the marker's own clock, and a date already passed as |
| 359 | // "may be purged at any time". |
| 360 | func headlessTrashLines(app string, deletedAt, now time.Time) []string { |
| 361 | when := func(t time.Time) string { |
| 362 | if !t.After(now) { |
| 363 | return "may be purged at any time" |
| 364 | } |
| 365 | return "is purged at " + t.UTC().Format("2006-01-02 15:04 UTC") |
| 366 | } |
| 367 | return []string{ |
| 368 | fmt.Sprintf("%s has been in the Trash since %s.", app, deletedAt.UTC().Format("2006-01-02 15:04 UTC")), |
| 369 | fmt.Sprintf("If it is a temporary app it %s. If not, it %s.", |
| 370 | when(deletedAt.Add(release.TemporaryCooloff)), when(deletedAt.Add(release.TrashCooloff))), |
| 371 | "carlos apps list against the console shows which.", |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | func runAppsRestore(args []string) { |
| 376 | fs := flag.NewFlagSet("apps restore", flag.ExitOnError) |
| 377 | acctRaw, acctIDRaw, _ := accountFlags(fs) |
| 378 | consoleFlag := consoleOption(fs, "console origin to restore through (signed-in mode only; default: the only logged-in one)") |
| 379 | fs.Usage = flagUsage(fs, "") |
| 380 | fs.Parse(args) |
| 381 | if fs.NArg() != 1 { |
| 382 | log.Fatal("usage: carlos apps restore [--account <sqid|name>] [--account-id <sqid>] [--console <origin>] <app>") |
| 383 | } |
| 384 | app := fs.Arg(0) |
| 385 | if err := validAppName(app); err != nil { |
| 386 | log.Fatalf("apps restore: %v", err) |
| 387 | } |
| 388 | acctID := checkAccountID(*acctIDRaw) |
| 389 | |
| 390 | sess, err := resolveMode(*consoleFlag) |
| 391 | if err != nil { |
| 392 | log.Fatal(err) |
| 393 | } |
| 394 | mode, err := appsMode(sess != nil, sess == nil, acctID) |
| 395 | if err != nil { |
| 396 | log.Fatal(err) |
| 397 | } |
| 398 | |
| 399 | if mode == "signed-in" { |
| 400 | printUsing(stdout, sess) |
| 401 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 402 | res, err := sess.restoreApp(context.Background(), acct, app) |
| 403 | if err != nil { |
| 404 | log.Fatal(err) |
| 405 | } |
| 406 | fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("restored"), app) |
| 407 | if res.Note != "" { |
| 408 | // The server's own wording (handleAPIRestoreApp) — printed |
| 409 | // verbatim rather than reworded here, so the two modes cannot |
| 410 | // drift apart on what they tell someone about routing. |
| 411 | fmt.Fprintln(stdout, res.Note) |
| 412 | } else { |
| 413 | mutedf(stdout, "%s has NO routes — carlos add <host> to serve it again\n", app) |
| 414 | } |
| 415 | return |
| 416 | } |
| 417 | |
| 418 | // Headless: no production gate to re-check — an app in the trash cannot |
| 419 | // be flagged production (ship/promote/SetProduction all refuse a |
| 420 | // trashed app), and refusing to undo a deletion protects nothing. |
| 421 | // release.RestoreApp is Store.Delete under the hood, which tolerates an |
| 422 | // already-gone marker, so restoring an app that was never trashed is a |
| 423 | // harmless no-op rather than an error. |
| 424 | store := openStore() |
| 425 | if err := release.RestoreApp(context.Background(), store, acctID, app); err != nil { |
| 426 | log.Fatalf("apps restore: %v", err) |
| 427 | } |
| 428 | fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("restored"), app) |
| 429 | if release.IsTemporaryName(app) { |
| 430 | mutedf(stdout, "%s is restored with its releases and channels intact. Its instance records were kept, "+ |
| 431 | "so hosts bring its routes back on their next pass", app) |
| 432 | return |
| 433 | } |
| 434 | mutedf(stdout, "%s is restored with its releases and channels intact, and with NO routes — "+ |
| 435 | "carlos add <host> to serve it again", app) |
| 436 | } |
| 437 | |
| 438 | // validPlaceTarget is the client-side grammar gate for a fleet placement |
| 439 | // target, shared by --place (apps create) and --target (apps place): checked |
| 440 | // BEFORE any API call, on the same "a shape the server is certain to refuse |
| 441 | // should cost nothing" principle validAppName's own doc comment states — |
| 442 | // for --place at create time this matters doubly, since a wasted round trip |
| 443 | // there would also spend the create verb's own scarce per-day budget on a |
| 444 | // typo. "" is always valid (no placement, or clearing one); anything else |
| 445 | // must parse as "fleet/<name>" per registry.FleetPlacement — the exact |
| 446 | // grammar the server re-checks itself. This is belt only, never the buckle: |
| 447 | // whether the named fleet exists and is owned by the caller's account is a |
| 448 | // check only the server can make, and both server doors (handleAPICreateApp, |
| 449 | // handleAPIPlace) still make it themselves. |
| 450 | func validPlaceTarget(target string) error { |
| 451 | if target == "" { |
| 452 | return nil |
| 453 | } |
| 454 | if _, ok := registry.FleetPlacement(target); !ok { |
| 455 | return fmt.Errorf(`%q is not a usable placement target — want "" or "fleet/<name>" `+ |
| 456 | "(1-32 characters of a-z, 0-9 and hyphen)", target) |
| 457 | } |
| 458 | return nil |
| 459 | } |
| 460 | |
| 461 | // validBuildProbe is the client-side grammar gate for --build-probe, checked |
| 462 | // BEFORE the API call on the same principle validAppName and |
| 463 | // validPlaceTarget already state: a shape the server is certain to refuse |
| 464 | // should cost nothing against the create verb's own scarce per-day budget. |
| 465 | // "" is always valid (no probe). The grammar must stay BYTE-IDENTICAL to the |
| 466 | // server's own validBuildProbe (internal/console/server.go): an absolute |
| 467 | // path, not protocol-relative ("//host/..."), no query, no fragment, and |
| 468 | // printable ASCII only (no control characters) — the same rule, checked |
| 469 | // twice, never merely "similar" rules that could quietly drift apart. |
| 470 | // |
| 471 | // It is no longer only a create-time gate: this is also what keeps the deploy |
| 472 | // watch on-host. runDeploy re-checks the probe the console ECHOES with this |
| 473 | // function before dialing it (deploy.go), so loosening the grammar "just for |
| 474 | // creates" silently loosens the watch too — and the shape that matters there |
| 475 | // is a value not starting with "/": "https://" + host + "@evil.example/x" |
| 476 | // parses host as USERINFO and evil.example as the HOST, which is how a watch |
| 477 | // ends up reporting a DIFFERENT deployment's build as this one's. |
| 478 | func validBuildProbe(probe string) error { |
| 479 | if probe == "" { |
| 480 | return nil |
| 481 | } |
| 482 | bad := !strings.HasPrefix(probe, "/") || strings.HasPrefix(probe, "//") || |
| 483 | strings.ContainsAny(probe, "?#") || len(probe) > 256 |
| 484 | if !bad { |
| 485 | for i := 0; i < len(probe); i++ { |
| 486 | if probe[i] < 0x21 || probe[i] > 0x7e { |
| 487 | bad = true |
| 488 | break |
| 489 | } |
| 490 | } |
| 491 | } |
| 492 | if bad { |
| 493 | return fmt.Errorf(`%q is not a usable build probe — want an absolute URL path with no query, `+ |
| 494 | `fragment, or protocol-relative prefix, e.g. "/healthz"`, probe) |
| 495 | } |
| 496 | return nil |
| 497 | } |
| 498 | |
| 499 | func runAppsCreate(args []string) { |
| 500 | fs := flag.NewFlagSet("apps create", flag.ExitOnError) |
| 501 | app := appFlag(fs, "app name to claim (lowercase letters, digits, dashes)") |
| 502 | place := fs.String("place", "", `place the app on a customer fleet at create time — "" (default) or "fleet/<name>"`) |
| 503 | controlPlane := fs.Bool("control-plane", false, |
| 504 | "mark the app as the control plane: it refuses trash, purge, rename and transfer, "+ |
| 505 | "and refuses a service credential's promote (owner only)") |
| 506 | buildProbe := fs.String("build-probe", "", |
| 507 | "`path` whose response body carries the running build, e.g. \"/healthz\" — "+ |
| 508 | "carlos deploy polls it instead of the X-Carlos-Version header, which the edge "+ |
| 509 | "stamps from the route row and which therefore lies until the process restarts; "+ |
| 510 | `the build must appear as a whole whitespace-delimited word in the body, e.g. "ok abc1234"`) |
| 511 | region := fs.String("region", "", flagRegionAppsCreate) |
| 512 | // Not accountFlags: that helper also registers --registry for direct-mode |
| 513 | // resolution, and this verb never resolves locally — the console resolves |
| 514 | // the account against the caller's own memberships. |
| 515 | acctRaw := fs.String("account", "", "account (sqid or name) to claim the app in (default: your only account, or the configured/chosen one)") |
| 516 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account; this is the flag an ambiguity error tells you to use") |
| 517 | consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)") |
| 518 | fs.Usage = flagUsage(fs, "") |
| 519 | fs.Parse(args) |
| 520 | if strings.TrimSpace(*app) == "" { |
| 521 | log.Fatal("usage: carlos apps create --app <name> [--place fleet/<f>] [--control-plane] " + |
| 522 | "[--build-probe /path] [--region <code>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>]") |
| 523 | } |
| 524 | // Client-side gates before the server's: the create limiter charges |
| 525 | // ATTEMPTS, so a name — or a place or build-probe value — the server is |
| 526 | // certain to refuse should cost a typo nothing. |
| 527 | if err := validAppName(strings.TrimSpace(*app)); err != nil { |
| 528 | log.Fatalf("apps create: %v", err) |
| 529 | } |
| 530 | if release.IsTemporaryName(strings.TrimSpace(*app)) { |
| 531 | log.Fatal("apps create: names starting with tmp- are kept for temporary sites. Use carlos deploy --temporary, or choose another name") |
| 532 | } |
| 533 | place2 := strings.TrimSpace(*place) |
| 534 | if err := validPlaceTarget(place2); err != nil { |
| 535 | log.Fatalf("apps create: %v", err) |
| 536 | } |
| 537 | probe2 := strings.TrimSpace(*buildProbe) |
| 538 | if err := validBuildProbe(probe2); err != nil { |
| 539 | log.Fatalf("apps create: %v", err) |
| 540 | } |
| 541 | |
| 542 | sess, err := resolveMode(*consoleFlag) |
| 543 | if err != nil { |
| 544 | log.Fatal(err) |
| 545 | } |
| 546 | if sess == nil { |
| 547 | // Direct mode. The claim lives in the console's database — there is |
| 548 | // nothing in the bucket to write it to, and pretending otherwise |
| 549 | // re-creates the ownerless-resource failure accounts create solved. |
| 550 | log.Fatal("apps create works through a console, and this shell is in direct bucket mode\n" + |
| 551 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Direct mode needs no claim:\n" + |
| 552 | "the first `carlos ship` brings the app into existence, and `console grant` is the\n" + |
| 553 | "box-side way to hand a name to an address. To claim through a console instead,\n" + |
| 554 | "unset the store env and run: carlos auth login --console <url>") |
| 555 | } |
| 556 | printUsing(stdout, sess) |
| 557 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 558 | region2 := strings.TrimSpace(*region) |
| 559 | res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2, *controlPlane, probe2, |
| 560 | region2, detectTimezone()) |
| 561 | if err != nil { |
| 562 | log.Fatalf("apps create: %v", err) |
| 563 | } |
| 564 | c := colorFor(stdout) |
| 565 | fmt.Fprintf(stdout, "%s %s in account %s — it's yours\n", c.ok("created app"), res.App, res.Account) |
| 566 | fmt.Fprintf(stdout, " open: %s\n", c.link(res.URL)) |
| 567 | fmt.Fprintf(stdout, " ship: carlos ship --account %s --app %s ...\n", res.Account, res.App) |
| 568 | printAppRegion(stdout, res, region2, place2 != "") |
| 569 | // The app WAS created (everything above already printed and is true) — |
| 570 | // what's still unverified is whether --place actually stuck. The server |
| 571 | // echoes back what it really stored (handleAPICreateApp's "placement" |
| 572 | // field), and this is the one place that gets checked: silently |
| 573 | // trusting the request would let a stolen best-effort store write |
| 574 | // (claimApp's, internal/console/server.go) OR an older console version |
| 575 | // (whose readJSON ignores an unknown "place" key and returns 200 with |
| 576 | // no such field at all — which unmarshals to the same "" zero value) |
| 577 | // report success for an app that is NOT actually placed. |
| 578 | if place2 != "" { |
| 579 | if err := placementMismatchErr(res.App, place2, res.Placement); err != nil { |
| 580 | log.Fatalf("apps create: %v", err) |
| 581 | } |
| 582 | fmt.Fprintf(stdout, " placed on %s\n", place2) |
| 583 | } |
| 584 | // Same principle for the two claim-time flags: print what the console |
| 585 | // actually stamped (res.ControlPlane / res.BuildProbe), not what was |
| 586 | // asked for, so a silently-swallowed store-write error is visible here |
| 587 | // rather than trusted away. |
| 588 | if *controlPlane { |
| 589 | if !res.ControlPlane { |
| 590 | log.Fatalf("apps create: --control-plane did not stick (the console did not confirm it) — "+ |
| 591 | "the app %s was created, but is NOT flagged as the control plane", res.App) |
| 592 | } |
| 593 | fmt.Fprintf(stdout, " control plane: yes\n") |
| 594 | } |
| 595 | if probe2 != "" { |
| 596 | if res.BuildProbe != probe2 { |
| 597 | log.Fatalf("apps create: --build-probe did not stick (console echoed %q, want %q) — "+ |
| 598 | "the app %s was created, but has no build probe set", res.BuildProbe, probe2, res.App) |
| 599 | } |
| 600 | fmt.Fprintf(stdout, " build probe: %s\n", res.BuildProbe) |
| 601 | } |
| 602 | } |
| 603 | |
| 604 | // placementMismatchErr compares what --place asked for (requested) against |
| 605 | // what the console's create response actually echoed back (echoed), and |
| 606 | // builds the loud, remedy-naming error runAppsCreate exits on when they |
| 607 | // disagree. Pulled out as its own pure function — no log.Fatal, no I/O — so |
| 608 | // both the ordinary "the write silently failed" case and the "an OLDER |
| 609 | // console never echoes placement at all" case (readJSON there ignores the |
| 610 | // unknown request field and its response JSON simply has no "placement" |
| 611 | // key, which encoding/json leaves at the same "" zero value an explicit |
| 612 | // empty echo would) are covered by ONE string comparison and are directly |
| 613 | // testable without a subprocess. |
| 614 | func placementMismatchErr(app, requested, echoed string) error { |
| 615 | if requested == echoed { |
| 616 | return nil |
| 617 | } |
| 618 | return fmt.Errorf("the app was created, but its placement did NOT stick (the console echoed placement=%q, "+ |
| 619 | "want %q — either the store write silently failed, or this console version is too old to echo "+ |
| 620 | "placement at all). Run this to place it now:\n\n carlos apps place --app %s --target %s\n", |
| 621 | echoed, requested, app, requested) |
| 622 | } |
| 623 | |
| 624 | // runAppsPlace repoints where app's routes and already-provisioned |
| 625 | // instances should run: `carlos apps place --app <name> --target |
| 626 | // fleet/<f>|""`. Every field is client-side validated before the API call |
| 627 | // exactly the way the server itself validates them, so a typo's cost is a |
| 628 | // local refusal, not a spent write-rate-limit slot. |
| 629 | func runAppsPlace(args []string) { |
| 630 | fs := flag.NewFlagSet("apps place", flag.ExitOnError) |
| 631 | app := appFlag(fs, "app to place") |
| 632 | target := fs.String("target", "", `fleet target — "" clears placement, or "fleet/<name>"`) |
| 633 | // Not accountFlags: that helper also registers --registry for direct-mode |
| 634 | // resolution, and this verb never resolves locally — placement is |
| 635 | // console-tracked state, API mode only, same as apps create above. |
| 636 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 637 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account; this is the flag an ambiguity error tells you to use") |
| 638 | consoleFlag := consoleOption(fs, "console to place through (default: the only logged-in one)") |
| 639 | const placeUsage = `usage: carlos apps place --app <name> --target fleet/<f>|"" [--account <sqid|name>] ` + |
| 640 | "[--account-id <sqid>] [--console <origin>]" |
| 641 | fs.Usage = flagUsage(fs, placeUsage) |
| 642 | fs.Parse(args) |
| 643 | if strings.TrimSpace(*app) == "" { |
| 644 | log.Fatal(placeUsage) |
| 645 | } |
| 646 | // --target is TRI-state, the same reason route's --backing is (cli.go's |
| 647 | // --hibernate/--backing precedent, ~line 355): "" is a LEGAL value here — |
| 648 | // it CLEARS placement — so a flag left unmentioned and a flag explicitly |
| 649 | // set to "" cannot be told apart by the value alone. They must not be |
| 650 | // treated the same: an omitted --target silently reading as "clear" would |
| 651 | // retire every route of a fleet-placed app on that fleet box's very next |
| 652 | // reconcile pass. fs.Visit reports only flags actually present on the |
| 653 | // command line, so this is the one reliable way to tell "not named" from |
| 654 | // "named, empty". |
| 655 | targetSet := false |
| 656 | fs.Visit(func(f *flag.Flag) { |
| 657 | if f.Name == "target" { |
| 658 | targetSet = true |
| 659 | } |
| 660 | }) |
| 661 | if !targetSet { |
| 662 | log.Fatal(placeUsage) |
| 663 | } |
| 664 | if err := validAppName(strings.TrimSpace(*app)); err != nil { |
| 665 | log.Fatalf("apps place: %v", err) |
| 666 | } |
| 667 | target2 := strings.TrimSpace(*target) |
| 668 | if err := validPlaceTarget(target2); err != nil { |
| 669 | log.Fatalf("apps place: %v", err) |
| 670 | } |
| 671 | |
| 672 | sess, err := resolveMode(*consoleFlag) |
| 673 | if err != nil { |
| 674 | log.Fatal(err) |
| 675 | } |
| 676 | if sess == nil { |
| 677 | log.Fatal("apps place works through a console, and this shell is in direct bucket mode\n" + |
| 678 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Placement is console-tracked\n" + |
| 679 | "state: log in instead — carlos auth login --console <url>") |
| 680 | } |
| 681 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 682 | fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| 683 | if err := sess.place(context.Background(), acct, strings.TrimSpace(*app), target2); err != nil { |
| 684 | log.Fatalf("apps place: %v", err) |
| 685 | } |
| 686 | if target2 == "" { |
| 687 | fmt.Fprintf(stdout, "cleared placement for %s\n", strings.TrimSpace(*app)) |
| 688 | return |
| 689 | } |
| 690 | fmt.Fprintf(stdout, "placed %s on %s\n", strings.TrimSpace(*app), target2) |
| 691 | } |
| 692 | |