| index e963fb353..a2a1a37e4 100644 |
| --- a/apps.go |
| +++ b/apps.go |
| @@ -17,6 +17,7 @@ package main |
| |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "errors" |
| "flag" |
| "fmt" |
| @@ -28,16 +29,17 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| -const appsUsage = `usage: carlos apps <delete|restore> <app> [flags] |
| +const appsUsage = `usage: carlos apps <delete|restore> [flags] <app> |
| |
| - delete <app> move an app to the Trash: every route drops (custom |
| - domains included), its name stays reserved, and it is |
| - restorable for 30 days. Refused while the app is flagged |
| - production — turn production off first, then delete — or |
| - when that flag cannot be read at all: an unreadable flag |
| - is treated as production, same direction "bake" fails. |
| + delete [flags] <app> move an app to the Trash: every route drops |
| + (custom domains included), its name stays reserved, and it |
| + is restorable for 30 days. Refused while the app is |
| + flagged production — turn production off first, then |
| + delete — or when that flag cannot be read at all: an |
| + unreadable flag is treated as production, same direction |
| + "bake" fails. |
| |
| - restore <app> undo a delete within the 30-day window. Releases, |
| + restore [flags] <app> undo a delete within the 30-day window. Releases, |
| channels and the production flag are exactly as they were |
| — but NO routes come back, not even the platform apex: |
| run "carlos add <host>" afterwards to serve it again. |
| @@ -83,6 +85,27 @@ func RunApps(args []string) { |
| } |
| } |
| |
| +// validAppName is release.ValidApp's CLI-facing gate, checked BEFORE mode |
| +// selection in both verbs (review finding: the -account-id "ops" phantom |
| +// trap, one level down). Neither TrashApp nor RestoreApp validates their app |
| +// argument — they trust the caller — and the signed-in path's 404 |
| +// (apiAccountApp's HasAppIn) only catches a bad name because the CONSOLE |
| +// happens to keep a row per real app. Headless mode has no such row: nothing |
| +// stops `carlos apps delete -account-id bab MyApp` from writing |
| +// apps/bab/MyApp/deleted.json — a path release.ValidApp accepts as a bucket |
| +// key but that no real app can ever have (Ship/SetProduction both require |
| +// ValidApp, so a real app's own name always passes) — printing "trashed" |
| +// while the actual "myapp" keeps serving, untouched, forever. Refusing here, |
| +// before either mode runs, keeps both paths refusing the same typo instead |
| +// of only the lucky one. |
| +func validAppName(app string) error { |
| + if release.ValidApp(app) { |
| + return nil |
| + } |
| + return fmt.Errorf("%q is not a usable app name — 1-32 characters, lowercase a-z, 0-9 and hyphen, "+ |
| + "starting with a letter", app) |
| +} |
| + |
| // appsMode is the pure decision behind both apps verbs' mode selection, |
| // extracted so it can be table-tested without a credentials file or a |
| // bucket on disk. loggedIn and bucketConfigured come straight off |
| @@ -124,9 +147,12 @@ func runAppsDelete(args []string) { |
| consoleFlag := fs.String("console", "", "console origin to delete through (signed-in mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
| - log.Fatal("usage: carlos apps delete <app> [-account <sqid|name>] [-account-id <sqid>] [-console <origin>]") |
| + log.Fatal("usage: carlos apps delete [-account <sqid|name>] [-account-id <sqid>] [-console <origin>] <app>") |
| } |
| app := fs.Arg(0) |
| + if err := validAppName(app); err != nil { |
| + log.Fatalf("apps delete: %v", err) |
| + } |
| acctID := checkAccountID(*acctIDRaw) |
| |
| sess, err := resolveMode(*consoleFlag) |
| @@ -150,47 +176,76 @@ func runAppsDelete(args []string) { |
| } |
| |
| // Headless: this box has no console to enforce the production gate, so |
| - // this verb enforces it itself, the same direction and the same |
| - // wording as handleAPITrashApp — an unreadable flag is treated as |
| - // production and refused, never as "probably fine". |
| + // headlessTrash enforces it itself — pulled out into its own function |
| + // (review finding: the destructive gate order needs direct test |
| + // coverage, not only exercise-through-the-CLI). |
| store := openStore() |
| - pubRaw := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")) |
| - if pubRaw == "" { |
| - log.Fatal("apps delete (headless): CARLOS_RELEASE_PUBKEY is not set — deleting must be able to prove the " + |
| - "app is not flagged production, and a flag that cannot be proven is treated as production and refused") |
| + var pub ed25519.PublicKey |
| + if pubRaw := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")); pubRaw != "" { |
| + p, perr := release.ParsePublicKey(pubRaw, "CARLOS_RELEASE_PUBKEY") |
| + if perr != nil { |
| + log.Fatalf("apps delete: %v", perr) |
| + } |
| + pub = p |
| } |
| - pub, err := release.ParsePublicKey(pubRaw, "CARLOS_RELEASE_PUBKEY") |
| + restorableUntil, err := headlessTrash(context.Background(), store, pub, acctID, app, time.Now()) |
| if err != nil { |
| log.Fatalf("apps delete: %v", err) |
| } |
| - ctx := context.Background() |
| - prod, err := release.GetProduction(ctx, store, pub, acctID, app) |
| + printTrashed(app, restorableUntil.UTC().Format(time.RFC3339)) |
| +} |
| + |
| +// headlessTrash is the destructive headless-mode gate: no console in the |
| +// loop to enforce anything, so this is the whole check, pulled out of |
| +// runAppsDelete so it can be driven directly against a real store in tests |
| +// rather than only through the CLI's os.Exit-on-refusal plumbing (review |
| +// finding — this gate had zero test coverage). Gate order matches |
| +// handleAPITrashApp's (internal/console/apicli.go) exactly: |
| +// |
| +// - a malshaped app name refuses before anything else (validAppName) — |
| +// defense in depth, since runAppsDelete already checks this before mode |
| +// selection, but this function must not trust its caller either. |
| +// - no usable public key refuses before the store is even asked anything: |
| +// with nothing to verify a signature against, an unreadable flag and an |
| +// absent one are indistinguishable, so this is the same "unreadable |
| +// fails toward refusal" rule one step earlier. |
| +// - the production flag ON, or genuinely UNREADABLE, refuses — NOTHING is |
| +// written on this path. |
| +// - only then TrashApp, followed by the SAME re-read handleAPITrashApp |
| +// does: TrashApp is create-only, so an app already in the trash (a |
| +// racing headless caller, or a previous run) keeps its ORIGINAL clock, |
| +// and printing this call's own `now` would over-promise a deadline |
| +// nothing will honour. |
| +func headlessTrash(ctx context.Context, store release.Store, pub ed25519.PublicKey, acct, app string, now time.Time) (time.Time, error) { |
| + if err := validAppName(app); err != nil { |
| + return time.Time{}, err |
| + } |
| + if len(pub) != ed25519.PublicKeySize { |
| + return time.Time{}, errors.New("CARLOS_RELEASE_PUBKEY is not set — deleting must be able to prove the app " + |
| + "is not flagged production, and a flag that cannot be proven is treated as production and refused") |
| + } |
| + prod, err := release.GetProduction(ctx, store, pub, acct, app) |
| if err != nil { |
| - log.Fatalf("apps delete: could not read the production flag for %s/%s — an unreadable flag is treated as "+ |
| - "production, refusing: %v", acctID, app, err) |
| + return time.Time{}, fmt.Errorf("could not read the production flag for %s/%s — an unreadable flag is "+ |
| + "treated as production, refusing: %w", acct, app, err) |
| } |
| if prod != nil && prod.Production { |
| - log.Fatalf("apps delete: %s/%s is flagged production, so deleting is refused — turn production off first, "+ |
| - "then try again", acctID, app) |
| + return time.Time{}, fmt.Errorf("%s/%s is flagged production, so deleting is refused — turn production off "+ |
| + "first, then try again", acct, app) |
| } |
| - if err := release.TrashApp(ctx, store, acctID, app, defaultLabel(), time.Now()); err != nil { |
| - log.Fatalf("apps delete: %v", err) |
| + if err := release.TrashApp(ctx, store, acct, app, defaultLabel(), now); err != nil { |
| + return time.Time{}, err |
| } |
| - // Re-read for the SAME reason handleAPITrashApp does: TrashApp is |
| - // create-only, so an app already in the trash (trashed by a racing |
| - // headless caller, or previously) keeps its ORIGINAL clock — the marker |
| - // is the only clock this feature has, and printing this call's own |
| - // time would over-promise a deadline nothing will honour. |
| - tomb, terr := release.ReadAppTombstone(ctx, store, acctID, app) |
| - if terr != nil { |
| - log.Fatalf("apps delete: %s/%s is trashed, but the restore window could not be confirmed — retry is safe "+ |
| - "(the marker is create-only): %v", acctID, app, terr) |
| + tomb, err := release.ReadAppTombstone(ctx, store, acct, app) |
| + if err != nil { |
| + return time.Time{}, fmt.Errorf("%s/%s is trashed, but the restore window could not be confirmed — retry is "+ |
| + "safe (the marker is create-only): %w", acct, app, err) |
| } |
| if tomb == nil { |
| - log.Fatalf("apps delete: %s/%s is trashed, but its marker vanished before the window could be confirmed "+ |
| - "(a restore may have raced this) — retry is safe", acctID, app) |
| + return time.Time{}, fmt.Errorf("%s/%s is trashed, but its marker vanished before the window could be "+ |
| + "confirmed (a restore may have raced this) — retry is safe", acct, app) |
| } |
| - printTrashed(app, tomb.DeletedAt.Add(release.TrashCooloff).UTC().Format(time.RFC3339)) |
| + return tomb.DeletedAt.Add(release.TrashCooloff), nil |
| } |
| |
| // printTrashed is the one shape both delete paths print in, so signed-in and |
| @@ -211,9 +266,12 @@ func runAppsRestore(args []string) { |
| consoleFlag := fs.String("console", "", "console origin to restore through (signed-in mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
| - log.Fatal("usage: carlos apps restore <app> [-account <sqid|name>] [-account-id <sqid>] [-console <origin>]") |
| + log.Fatal("usage: carlos apps restore [-account <sqid|name>] [-account-id <sqid>] [-console <origin>] <app>") |
| } |
| app := fs.Arg(0) |
| + if err := validAppName(app); err != nil { |
| + log.Fatalf("apps restore: %v", err) |
| + } |
| acctID := checkAccountID(*acctIDRaw) |
| |
| sess, err := resolveMode(*consoleFlag) |