carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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).
20package main
21
22import (
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
37const 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
127Signed-in (carlos auth login): both verbs go through the console API — any
128member of the account, bearer token alone, POSTing to
129/api/cli/apps/<account>/<app>/delete|restore.
130
131Headless (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR set, no login):
132both verbs write or remove the trash marker bucket-direct. --account-id is
133required — there is no console here to resolve a name against. delete also
134needs CARLOS_RELEASE_PUBKEY, to prove the app is not flagged production.
135
136There is no purge verb here: "Delete now" is console-only, behind its own
137passkey step-up. Headless deployments wait out the window: 30 days, or 3 for a
138temporary app.
139`
140
141// RunApps dispatches the apps sub-verbs.
142func 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.
185func 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.
210func 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
228func 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.
311func 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.
345func 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".
360func 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
375func 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.
450func 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.
478func 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
499func 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.
614func 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.
629func 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