carlos / platform Public
File too large to show in full — download it above (showing a truncated preview below).
| 1 | # Temporary Static Sites Implementation Plan |
| 2 | |
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | |
| 5 | **Goal:** Let a member create a whole app that is static, platform-hosted, named `tmp-<label>`, expires into the Trash on its own and is purged 3 days later, with `carlos deploy --temporary`, `carlos apps ttl|keep|list`, and a console that alone decides every transition. |
| 6 | |
| 7 | **Architecture:** All lifecycle state lives in four new columns on the console's `apps` table (`expires_at`, `restoring`, `purge_after`, `purge_for`). Nothing new is written to the bucket beyond the existing trash marker, and boxes change nothing. Every transition runs under a per-app mutex in the console process and settles the row against the bucket marker first (`settleApp`). A 5-minute console pass (`expirypass.go`) expires due apps through the ordinary trash path and early-purges through a new `release.PurgeAppIfTrashedAt`, which only acts when the marker's clock matches the decision recorded in the row. The `tmp-` namespace is reserved at every door that can create a name, so a temporary app's expiry can only drop routes a temporary app created. |
| 8 | |
| 9 | **Tech Stack:** Go 1.26, `net/http` 1.22 mux patterns, SQLite via `modernc.org/sqlite`, `release.Store` (S3 or `DirStore`), html/template. |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-09-25-temporary-static-sites-design.md` (rev 9, approved 2026-09-26). §1 rulings are fixed. §§10-17 explain why each mechanism exists. Read the spec before each task; this plan argues from it. |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - **Copy review.** Every user-facing string (CLI output, flag help, usage text, API error text, console HTML, flash messages, audit detail shown on the Activity tab, `docs/site/cli.md` prose) goes through the `copy-review` skill before it is written into code. Each such string is marked `// COPY-REVIEW <id>` in this plan (`<!-- COPY-REVIEW <id> -->` in templates, `(COPY-REVIEW <id>)` in prose for docs and usage text). Task 0 collects them all for one review before any task that ships them. Where the approved text differs from the draft shown here, the approved text wins, verbatim. The markers are plan annotations: do not carry them into the code, templates or docs. Tests assert on command names, app names and dates, never on a whole sentence, so an approved rewording does not break them. Operator log lines (`log.Printf`) are not copy and are not marked. Existing strings a task moves without changing are not new copy. |
| 16 | - **House voice.** No em dashes in new copy. Use a full stop and a new sentence instead, lower case where the sentence continues a CLI line. Plain, direct sentences. Existing strings this plan does not touch keep their current wording. |
| 17 | - **Testing in the sandbox.** Run Go with `GOCACHE=$TMPDIR/gocache` exported on the same command line, every time (the environment does not carry it between commands), e.g. `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestX' -count=1`. Tests that open unix sockets (the root package's `carlos` subprocess tests) also need `TMPDIR=/tmp/g` (create it first with `mkdir -p /tmp/g`), e.g. `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestX' -count=1`. If the toolchain reports "read-only file system", the command needs the sandbox disabled (AGENTS.md "Sandbox note"). |
| 18 | - **Cross-compile gate.** CI builds darwin, windows and linux-arm64 (`.amadan/ci.d/20-cross`). Nothing in this plan is linux-only; do not introduce a syscall or a linux-only type. Before the final commit run `GOOS=darwin GOCACHE=$TMPDIR/gocache go build ./... && GOOS=windows GOCACHE=$TMPDIR/gocache go build ./...`. |
| 19 | - **Full gate before landing.** `GOCACHE=$TMPDIR/gocache go build ./...`, `GOCACHE=$TMPDIR/gocache go vet ./...`, `gofmt -l .` (must print nothing), and `GOCACHE=$TMPDIR/gocache go test ./... -count=1`. |
| 20 | - **Branch and landing.** Work happens on a branch and lands through `amadan branch merge` on the amadan forge (`carlos/platform` on amadan.net), never a direct commit or merge to `main`, never a GitHub PR (AGENTS.md "Every change lands through a reviewed branch on amadan"). Squash to one commit before landing. Every commit message ends with: |
| 21 | `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` |
| 22 | - **Wire-additive.** No change to box code and no change to any bucket object's shape. The only bucket object the feature writes is the existing trash marker (`apps/<sqid>/<app>/deleted.json`, `release.AppTombstone` schema 1), through the existing `release.TrashApp`. JSON responses only gain fields. Spec §2 depends on this: nothing in the bucket can make an app temporary. |
| 23 | - **Fixed numbers (spec §1, §3, §5, §6).** Prefix `tmp-`. TTL default 7 days, minimum 1 hour, maximum 30 days. Cap 20 live temporary apps per account, where live means `expires_at > 0 AND (deleted_at = 0 OR restoring = 1)`. Early purge 3 days (`release.TemporaryCooloff = 72h`) after the marker's `deleted_at`. Restore sets `expires_at = now + 7 days` when `expires_at < now + 1 hour`. Expiry pass every 5 minutes. Feature switch `CARLOS_TEMPORARY_APPS=1` in the console unit's environment; off by default. |
| 24 | - **Lock order.** Account lock before app lock, never the reverse. Only `/new-temporary` and `restoreApp` take the account lock. |
| 25 | - **Security gate order** (AGENTS.md): session, CSRF, owner (byte-identical 404), `requireFreshAuth` last. New browser doors in this plan follow it; the temporary refusals sit after the membership gate so they never confirm an app exists to a stranger. |
| 26 | |
| 27 | ## Review Focus |
| 28 | |
| 29 | 1. **TTL values at and past the bounds, including ones that overflow a `time.Duration`** (`ttl_seconds` of 3599, 3600, 2592000, 2592001, -1, 1<<62; CLI `--ttl 3D`, `1.5d`, `31d`, `90m`). Expected: the bounds are inclusive, everything else is refused with the 1h..30d sentence, nothing wraps, nothing is created. Tests: Task 5 Step 1 (`TestTemporaryTTLBounds`), Task 11 Step 1 (`TestParseTTL`). |
| 30 | 2. **Temporary names at the edge of the grammar**: `TMP-Docs` (normalises to `tmp-docs`), bare `tmp-`, a trailing hyphen `tmp-abc-`, 33 characters, and a CLI `--app` that passes 32 characters only after the prefix is added. Expected: refused with the name rule and nothing claimed, except `TMP-Docs`, which creates `tmp-docs`. Tests: Task 5 Step 1 (`TestTemporaryCreateNameShapes`), Task 11 Step 1 (`TestTemporaryAppName`). |
| 31 | 3. **An expiry exactly equal to the pass's clock.** Expected: `expires_at == now` expires (the rule is `<=`), `expires_at == now + 1s` does not. Test: Task 8 Step 1 (`TestExpiryBoundaryIsInclusive`). |
| 32 | 4. **`ttl` and `keep` aimed at a permanent app, or at a temporary app in the Trash.** Expected: a 409 that names the command that helps (`carlos apps restore` for trashed), the row unchanged. A trashed app is not reachable through the live-app gate at all (404). Test: Task 9 Step 1 (`TestTTLAndKeepRefuseWhatTheyCannotChange`). |
| 33 | 5. **An app-scoped service credential (a CI token bound to one app) calling `/new-temporary`.** Expected: the byte-identical 404 `/new` gives it, and no row, while an account-scoped Publish credential creates. Test: Task 5 Step 1 (`TestAppScopedServiceCannotClaimATemporaryApp`). |
| 34 | |
| 35 | --- |
| 36 | |
| 37 | ## File map |
| 38 | |
| 39 | | File | Task | Responsibility | |
| 40 | |---|---|---| |
| 41 | | `internal/release/temporary.go` (new) | 1 | `tmp-` prefix, `TemporaryCooloff`, `PurgeAppIfTrashedAt` | |
| 42 | | `internal/release/trash.go` | 1 | `purgeReady`/`purgeApp` take an optional tombstone check | |
| 43 | | `internal/console/store.go` | 2, 3, 6 | migration, `TrashAppRow`/`RestoreAppRow`/`AppRows`, name and temporary refusals in `GrantAt`, `BackfillOpsMemberships`, `RenameAppIn`, `MoveApp`, `SetAppControlPlane` | |
| 44 | | `internal/console/tempstore.go` (new) | 2, 10 | `AppLife`, conditional updates, candidate queries, `effectivePurgeAt` | |
| 45 | | `internal/console/server.go` | 3, 4, 6, 9 | `claimAppWith`, Server lock fields, production refusals, `page.ExpiresAt`, `appPage` | |
| 46 | | `internal/console/applocks.go` (new) | 4 | per-key mutex, `lockAccount`, `lockApp` | |
| 47 | | `internal/console/settle.go` (new) | 4 | tripwire helper, `settleApp` | |
| 48 | | `internal/console/materialize.go` | 4 | shared tripwire, temporary window, restoring, restore catch-up | |
| 49 | | `internal/console/apitemporary.go` (new) | 5, 9 | `/new-temporary`, cap, route check, ttl/keep/list, browser keep | |
| 50 | | `internal/console/temprefusal.go` (new) | 6 | `temporaryRefusal`, `refuseTemporary` | |
| 51 | | `internal/console/restore.go` (new) | 7 | `restoreApp` | |
| 52 | | `internal/console/expirypass.go` (new) | 8 | the pass | |
| 53 | | `internal/console/apicli.go`, `ownership.go`, `appsettings.go`, `apiinstances.go`, `instanceprivatestore.go`, `apistore.go`, `apistore_provision.go`, `apidomains.go`, `domainattach.go`, `apimail.go`, `apipipeline.go`, `controlplane.go`, `authcli.go`, `view.go` | 3-10 | door edits | |
| 54 | | `internal/console/templates/appnav.html`, `trash.html` | 9, 10 | expiry header and Keep button, per-app Trash deadline | |
| 55 | | `cmd/console/main.go` | 5, 8 | `CARLOS_TEMPORARY_APPS`, start the pass | |
| 56 | | `cli.go`, `apps.go`, `apimode.go`, `deploy.go`, `temporary.go` (new, package main) | 3, 10, 11 | `carlos add` rail, `apps ttl|keep|list`, deploy flags, display | |
| 57 | | `docs/site/cli.md` | 11 | the flags and the subcommands | |
| 58 | |
| 59 | --- |
| 60 | |
| 61 | ### Task 0: Copy review of every user-facing string |
| 62 | |
| 63 | Nothing in Tasks 3-11 is written until its strings have been through review. One review round covers the whole plan, because the strings share one voice and one vocabulary ("temporary app", "the Trash", "purged", "keep"). |
| 64 | |
| 65 | **Files:** |
| 66 | - Create: `docs/superpowers/plans/2026-09-26-temporary-static-sites-copy.md` (the approved-copy ledger this task produces; each later task reads it) |
| 67 | |
| 68 | **Interfaces:** |
| 69 | - Consumes: this plan's `COPY-REVIEW` markers. |
| 70 | - Produces: the ledger, keyed by the same ids (`C3.1`, `C5.2`, ...). Every later task uses the ledger's text wherever this plan shows a draft. |
| 71 | |
| 72 | - [ ] **Step 1: Collect the strings** |
| 73 | |
| 74 | Run: `grep -n "COPY-REVIEW" docs/superpowers/plans/2026-09-26-temporary-static-sites.md` |
| 75 | Expected: one line per marked string; Appendix A at the end of this plan lists the same ids with their drafts and where each ships. |
| 76 | |
| 77 | - [ ] **Step 2: Review** |
| 78 | |
| 79 | Invoke the `copy-review` skill with Appendix A's table as the extracted strings. Say in the brief: house voice, no em dashes, full stop and a new sentence instead; `carlos ...` commands stay literal; dates print as `2006-01-02 15:04 UTC`; relative times print as `in 3d`, `in 5h`, `in 12m`. Wait for Paul's edits. |
| 80 | |
| 81 | - [ ] **Step 3: Record the approved text** |
| 82 | |
| 83 | Write the ledger file as a table `| id | approved text |`, verbatim from the review, and commit it: |
| 84 | |
| 85 | ```bash |
| 86 | git add docs/superpowers/plans/2026-09-26-temporary-static-sites-copy.md |
| 87 | git commit -m "plan: approved copy for temporary static sites |
| 88 | |
| 89 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 90 | ``` |
| 91 | |
| 92 | --- |
| 93 | |
| 94 | ### Task 1: `release`: the `tmp-` namespace, the 3-day cooloff, and a purge bound to one trash |
| 95 | |
| 96 | **Files:** |
| 97 | - Create: `internal/release/temporary.go` |
| 98 | - Modify: `internal/release/trash.go:137-161` (`PurgeReady`), `internal/release/trash.go:182-266` (`PurgeApp`) |
| 99 | - Test: `internal/release/temporary_test.go` (new) |
| 100 | |
| 101 | **Interfaces:** |
| 102 | - Consumes: `ReadAppTombstone`, `TrashApp`, `ErrNotTrashed` (trash.go); test helpers `seedApp`, `listUnder` (trash_test.go:169, :185), `testKeys` (release_test.go:21), package `ctx` (trash_test.go:16). |
| 103 | - Produces: |
| 104 | - `const TemporaryAppPrefix = "tmp-"` |
| 105 | - `func IsTemporaryName(app string) bool` |
| 106 | - `const TemporaryCooloff = 72 * time.Hour` |
| 107 | - `var ErrTrashClockMismatch error` |
| 108 | - `func PurgeAppIfTrashedAt(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, wantDeletedAt, now time.Time) error` |
| 109 | |
| 110 | - [ ] **Step 1: Write the failing tests** |
| 111 | |
| 112 | ```go |
| 113 | // internal/release/temporary_test.go |
| 114 | package release |
| 115 | |
| 116 | import ( |
| 117 | "errors" |
| 118 | "testing" |
| 119 | "time" |
| 120 | ) |
| 121 | |
| 122 | func TestTemporaryNamespaceAndCooloff(t *testing.T) { |
| 123 | for name, want := range map[string]bool{ |
| 124 | "tmp-docs": true, |
| 125 | "tmp-a1b2c3": true, |
| 126 | "tmp": false, |
| 127 | "tmpdocs": false, |
| 128 | "docs-tmp-": false, |
| 129 | "": false, |
| 130 | } { |
| 131 | if got := IsTemporaryName(name); got != want { |
| 132 | t.Errorf("IsTemporaryName(%q) = %v, want %v", name, got, want) |
| 133 | } |
| 134 | } |
| 135 | if TemporaryCooloff != 72*time.Hour { |
| 136 | t.Fatalf("TemporaryCooloff = %v, want 72h (spec ruling 2)", TemporaryCooloff) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // The row stores whole seconds and the marker stores nanoseconds. The purge |
| 141 | // decision is bound to the second (spec §5), so the two still match. |
| 142 | func TestPurgeAppIfTrashedAtPurgesOnTheSameSecond(t *testing.T) { |
| 143 | s := NewDirStore(t.TempDir()) |
| 144 | _, pub := testKeys(t) |
| 145 | seedApp(t, s, "bab", "tmp-docs") |
| 146 | trashedAt := time.Date(2026, 9, 26, 10, 0, 0, 750_000_000, time.UTC) |
| 147 | if err := TrashApp(ctx, s, "bab", "tmp-docs", "expiry", trashedAt); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | want := time.Unix(trashedAt.Unix(), 0) |
| 151 | if err := PurgeAppIfTrashedAt(ctx, s, pub, "bab", "tmp-docs", want, trashedAt.Add(TemporaryCooloff)); err != nil { |
| 152 | t.Fatalf("PurgeAppIfTrashedAt on the matching clock: %v", err) |
| 153 | } |
| 154 | if got := listUnder(t, s, "apps/bab/tmp-docs/"); len(got) != 0 { |
| 155 | t.Fatalf("purge left objects behind: %v", got) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // A restore, a keep and a fresh trash write a marker with a new clock. The old |
| 160 | // decision must not fire against it (spec §5, astra 4). |
| 161 | func TestPurgeAppIfTrashedAtRefusesAnotherTrash(t *testing.T) { |
| 162 | s := NewDirStore(t.TempDir()) |
| 163 | _, pub := testKeys(t) |
| 164 | keys := seedApp(t, s, "bab", "tmp-docs") |
| 165 | decided := time.Date(2026, 9, 26, 10, 0, 0, 0, time.UTC) |
| 166 | if err := TrashApp(ctx, s, "bab", "tmp-docs", "paul@keymail.dev", decided.Add(time.Hour)); err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | err := PurgeAppIfTrashedAt(ctx, s, pub, "bab", "tmp-docs", decided, decided.Add(TemporaryCooloff)) |
| 170 | if !errors.Is(err, ErrTrashClockMismatch) { |
| 171 | t.Fatalf("PurgeAppIfTrashedAt against a newer trash = %v, want ErrTrashClockMismatch", err) |
| 172 | } |
| 173 | if got := listUnder(t, s, "apps/bab/tmp-docs/"); len(got) != len(keys)+1 { |
| 174 | t.Fatalf("a refused purge destroyed objects: %v", got) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // Everything PurgeApp refuses, this refuses too. |
| 179 | func TestPurgeAppIfTrashedAtKeepsPurgeAppsGuards(t *testing.T) { |
| 180 | priv, pub := testKeys(t) |
| 181 | s := NewDirStore(t.TempDir()) |
| 182 | seedApp(t, s, "bab", "tmp-docs") |
| 183 | at := time.Date(2026, 9, 26, 10, 0, 0, 0, time.UTC) |
| 184 | if err := PurgeAppIfTrashedAt(ctx, s, pub, "bab", "tmp-docs", at, at); !errors.Is(err, ErrNotTrashed) { |
| 185 | t.Fatalf("untrashed app = %v, want ErrNotTrashed", err) |
| 186 | } |
| 187 | if err := SetProduction(ctx, s, priv, "bab", "tmp-docs", true, "paul@keymail.dev"); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | if err := TrashApp(ctx, s, "bab", "tmp-docs", "expiry", at); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | if err := PurgeAppIfTrashedAt(ctx, s, pub, "bab", "tmp-docs", at, at); err == nil { |
| 194 | t.Fatal("purged a production-flagged app") |
| 195 | } |
| 196 | if got := listUnder(t, s, "apps/bab/tmp-docs/"); len(got) == 0 { |
| 197 | t.Fatal("a refused purge destroyed objects") |
| 198 | } |
| 199 | } |
| 200 | ``` |
| 201 | |
| 202 | - [ ] **Step 2: Run the tests to verify they fail** |
| 203 | |
| 204 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/release/ -run 'TestTemporaryNamespaceAndCooloff|TestPurgeAppIfTrashedAt' -count=1` |
| 205 | Expected: FAIL to compile: `undefined: IsTemporaryName`, `undefined: TemporaryCooloff`, `undefined: PurgeAppIfTrashedAt`, `undefined: ErrTrashClockMismatch`. |
| 206 | |
| 207 | - [ ] **Step 3: Implement** |
| 208 | |
| 209 | Create `internal/release/temporary.go`: |
| 210 | |
| 211 | ```go |
| 212 | package release |
| 213 | |
| 214 | import ( |
| 215 | "context" |
| 216 | "crypto/ed25519" |
| 217 | "errors" |
| 218 | "fmt" |
| 219 | "strings" |
| 220 | "time" |
| 221 | ) |
| 222 | |
| 223 | // TemporaryAppPrefix is the reserved namespace every temporary app lives in |
| 224 | // (temporary static sites spec, ruling 7). Nothing permanent may be CREATED |
| 225 | // under it; an app made permanent with `carlos apps keep` keeps its name. |
| 226 | // The reservation is what makes "brand new" provable: no permanent app, row |
| 227 | // or route can hold a tmp- name, so a temporary app's expiry can only drop |
| 228 | // routes a temporary app created (spec §3). |
| 229 | const TemporaryAppPrefix = "tmp-" |
| 230 | |
| 231 | // IsTemporaryName reports whether app is in the reserved namespace. It says |
| 232 | // nothing about whether the app IS temporary: a kept app keeps its tmp- name, |
| 233 | // and only the console's row knows. |
| 234 | func IsTemporaryName(app string) bool { return strings.HasPrefix(app, TemporaryAppPrefix) } |
| 235 | |
| 236 | // TemporaryCooloff is how long a trashed temporary app stays restorable |
| 237 | // before the console purges it (spec ruling 2). The box's own 30-day |
| 238 | // TrashCooloff still applies to every app as the backstop. |
| 239 | const TemporaryCooloff = 72 * time.Hour |
| 240 | |
| 241 | // ErrTrashClockMismatch is PurgeAppIfTrashedAt's refusal: the app is in the |
| 242 | // Trash, but not the trash the purge decision was made about. |
| 243 | var ErrTrashClockMismatch = errors.New("release: the trash marker is not the one the purge was decided for") |
| 244 | |
| 245 | // PurgeAppIfTrashedAt is PurgeApp with one more precondition: the current |
| 246 | // marker's DeletedAt, truncated to whole seconds, equals wantDeletedAt (the |
| 247 | // console row's purge_for). A restore followed by a keep and a fresh trash |
| 248 | // writes a marker with a new clock, so a decision taken about the old trash |
| 249 | // can no longer fire (spec §5, astra 4). |
| 250 | // |
| 251 | // The check runs on the SAME tombstone read PurgeApp's own preconditions use |
| 252 | // (purgeReady), so there is no second read and no window between two. Every |
| 253 | // other guard (production flag, object-store estate) applies unchanged. |
| 254 | func PurgeAppIfTrashedAt(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, wantDeletedAt, now time.Time) error { |
| 255 | return purgeApp(ctx, s, pub, acct, app, func(t *AppTombstone) error { |
| 256 | if t.DeletedAt.Unix() != wantDeletedAt.Unix() { |
| 257 | return fmt.Errorf("release: %s/%s was trashed at %s, not %s: %w", acct, app, |
| 258 | t.DeletedAt.UTC().Format(time.RFC3339), wantDeletedAt.UTC().Format(time.RFC3339), ErrTrashClockMismatch) |
| 259 | } |
| 260 | return nil |
| 261 | }) |
| 262 | } |
| 263 | ``` |
| 264 | |
| 265 | In `internal/release/trash.go`, replace `PurgeReady` (lines 137-161) with a wrapper plus the checked form. Everything from the `len(pub)` check down is the existing body, unchanged: |
| 266 | |
| 267 | ```go |
| 268 | func PurgeReady(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) error { |
| 269 | return purgeReady(ctx, s, pub, acct, app, nil) |
| 270 | } |
| 271 | |
| 272 | // purgeReady is PurgeReady with an optional extra precondition on the marker |
| 273 | // it has just read. check sees the SAME tombstone the rest of this block |
| 274 | // trusts, so a caller binding a purge to one particular trash |
| 275 | // (PurgeAppIfTrashedAt) adds no second read. |
| 276 | func purgeReady(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, check func(*AppTombstone) error) error { |
| 277 | tomb, err := ReadAppTombstone(ctx, s, acct, app) |
| 278 | if err != nil { |
| 279 | return err |
| 280 | } |
| 281 | if tomb == nil { |
| 282 | return ErrNotTrashed |
| 283 | } |
| 284 | if check != nil { |
| 285 | if err := check(tomb); err != nil { |
| 286 | return err |
| 287 | } |
| 288 | } |
| 289 | if len(pub) != ed25519.PublicKeySize { |
| 290 | return fmt.Errorf("release: no release public key — cannot prove %s/%s is not in production, refusing to purge", acct, app) |
| 291 | } |
| 292 | flag, err := GetProduction(ctx, s, pub, acct, app) |
| 293 | if err != nil { |
| 294 | return fmt.Errorf("release: cannot read the production flag for %s/%s — refusing to purge: %w", acct, app, err) |
| 295 | } |
| 296 | if flag != nil && flag.Production { |
| 297 | return fmt.Errorf("release: %s/%s is flagged production — a trashed production app should be impossible; refusing to purge", acct, app) |
| 298 | } |
| 299 | return nil |
| 300 | } |
| 301 | ``` |
| 302 | |
| 303 | Keep the existing comment block above `PurgeReady` and the one inside it about `(nil, nil)` (move it above the `GetProduction` call in `purgeReady`). Then replace `PurgeApp`'s first statement (lines 187-189) so the body becomes `purgeApp`: |
| 304 | |
| 305 | ```go |
| 306 | func PurgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, now time.Time) error { |
| 307 | return purgeApp(ctx, s, pub, acct, app, nil) |
| 308 | } |
| 309 | |
| 310 | // purgeApp is PurgeApp's body with purgeReady's optional check threaded |
| 311 | // through. Everything below the precondition call is PurgeApp as it was. |
| 312 | func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, check func(*AppTombstone) error) error { |
| 313 | if err := purgeReady(ctx, s, pub, acct, app, check); err != nil { |
| 314 | return err |
| 315 | } |
| 316 | prefix := appPrefix(acct, app) |
| 317 | // ... lines 190-265 of the current PurgeApp, unchanged: the store-estate |
| 318 | // guard over store.json and stores/, then the sweep with the marker last. |
| 319 | } |
| 320 | ``` |
| 321 | |
| 322 | Move the existing doc comment of `PurgeApp` (lines 163-181) above the new `PurgeApp` wrapper, and the in-body comments (lines 183-186 and 191-223) into `purgeApp`, unchanged. The existing strings here are not user-facing copy this plan touches. |
| 323 | |
| 324 | - [ ] **Step 4: Run the tests to verify they pass, plus the box regression** |
| 325 | |
| 326 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/release/ -count=1` |
| 327 | Expected: PASS, including every existing `TestPurgeApp*` and `TestTrash*`. |
| 328 | |
| 329 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/domains/ -run 'TestReconcilePurgesAppsPastTheCooloff|TestReconcileDoesNotPurgeInsideTheCooloff|TestReconcileNeverPurgesAProductionFlaggedApp' -count=1` |
| 330 | Expected: PASS. This is spec §9's "Boxes: no change": a trashed app with no row knowledge still purges at 30 days through the refactored `PurgeApp`. |
| 331 | |
| 332 | - [ ] **Step 5: Commit** |
| 333 | |
| 334 | ```bash |
| 335 | gofmt -l internal/release |
| 336 | git add internal/release/temporary.go internal/release/temporary_test.go internal/release/trash.go |
| 337 | git commit -m "release: tmp- namespace, 3-day cooloff, purge bound to one trash |
| 338 | |
| 339 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 340 | ``` |
| 341 | |
| 342 | --- |
| 343 | |
| 344 | ### Task 2: Console store: the four columns, the temporary claim, conditional updates, one deadline |
| 345 | |
| 346 | **Files:** |
| 347 | - Modify: `internal/console/store.go:386` (ALTER list, after `build_probe`), `:481-497` (`migrateAppsKey`'s `apps_new` and copy), `:1456-1476` (`TrashAppRow`, `RestoreAppRow`), `:1570-1606` (`AppRow`, `AppRows`) |
| 348 | - Create: `internal/console/tempstore.go` |
| 349 | - Test: `internal/console/tempstore_test.go` (new). Step 4 also runs the existing tests in `migrate_test.go`, `store_test.go` and `materialize_test.go`, which this task does not edit. |
| 350 | |
| 351 | **Interfaces:** |
| 352 | - Consumes: `release.TemporaryCooloff`, `release.TrashCooloff` (Task 1, trash.go:33). |
| 353 | - Produces: |
| 354 | - `type AppLife struct { AccountID int64; App string; CreatedAt, DeletedAt, ExpiresAt int64; Restoring bool; PurgeAfter, PurgeFor int64; ControlPlane bool }` with `func (l *AppLife) Temporary() bool` and `func (l *AppLife) Trashed() bool`. All times are Unix seconds. |
| 355 | - `func (s *Store) AppLife(accountID int64, app string) (*AppLife, error)` (nil, nil when there is no row) |
| 356 | - `func (s *Store) AppLivesIn(accountID int64) ([]AppLife, error)` (every row, trashed included, by name) |
| 357 | - `func (s *Store) LiveTemporaryApps(accountID int64) ([]AppLife, error)` (the cap's rows) |
| 358 | - `func (s *Store) CreateTemporaryAppIn(accountID int64, app, createdBy string, now, expiresAt time.Time) (bool, error)` |
| 359 | - `func (s *Store) ExpireAppRow(accountID int64, app string, deletedAt time.Time) (bool, error)` |
| 360 | - `func (s *Store) KeepApp(accountID int64, app string) (bool, error)` |
| 361 | - `func (s *Store) SetAppExpiry(accountID int64, app string, expiresAt time.Time) (bool, error)` |
| 362 | - `func (s *Store) BumpTrashedExpiry(accountID int64, app string, now time.Time) (bool, error)` |
| 363 | - `func (s *Store) SetRestoring(accountID int64, app string, on bool) (bool, error)` |
| 364 | - `func (s *Store) FinishRestoreRow(accountID int64, app string, now time.Time) error` |
| 365 | - `func (s *Store) TemporaryAppRows() ([]AppRow, error)` and `func (s *Store) PurgeDueApps(now time.Time) ([]AppRow, error)` |
| 366 | - `AppRow` gains `DeletedAt, ExpiresAt int64; Restoring bool; PurgeAfter, PurgeFor int64` (existing fields unchanged) |
| 367 | - `TrashAppRow` now also sets `purge_for`/`purge_after` when `expires_at > 0`; `RestoreAppRow` also clears `restoring`, `purge_after`, `purge_for` |
| 368 | - `func effectivePurgeAt(deletedAt, purgeAfter int64) time.Time` |
| 369 | - `const temporaryTTLDefault = 7 * 24 * time.Hour`, `const temporaryRestoreFloor = time.Hour`, `const temporaryCooloffSeconds = int64(release.TemporaryCooloff / time.Second)` |
| 370 | |
| 371 | - [ ] **Step 1: Write the failing tests** |
| 372 | |
| 373 | ```go |
| 374 | // internal/console/tempstore_test.go |
| 375 | package console |
| 376 | |
| 377 | import ( |
| 378 | "database/sql" |
| 379 | "path/filepath" |
| 380 | "testing" |
| 381 | "time" |
| 382 | |
| 383 | "github.com/carlosframework/platform/internal/release" |
| 384 | _ "modernc.org/sqlite" |
| 385 | ) |
| 386 | |
| 387 | // tempStoreAccount gives a bare store an account row, which AppRows and the |
| 388 | // candidate queries join against. |
| 389 | func tempStoreAccount(t *testing.T, s *Store) int64 { |
| 390 | t.Helper() |
| 391 | id := testAccountID() |
| 392 | if _, err := s.EnsureAccount(id, "tst", "Test", time.Unix(1700000000, 0)); err != nil { |
| 393 | t.Fatal(err) |
| 394 | } |
| 395 | return id |
| 396 | } |
| 397 | |
| 398 | func mustLife(t *testing.T, s *Store, acct int64, app string) *AppLife { |
| 399 | t.Helper() |
| 400 | l, err := s.AppLife(acct, app) |
| 401 | if err != nil || l == nil { |
| 402 | t.Fatalf("AppLife(%s) = %v, %v", app, l, err) |
| 403 | } |
| 404 | return l |
| 405 | } |
| 406 | |
| 407 | // The pre-accounts table is rebuilt by migrateAppsKey on the FIRST open, after |
| 408 | // the ALTER list has added the new columns to the old table. The rebuild must |
| 409 | // carry them, or the first boot after upgrade runs without them. |
| 410 | func TestTemporaryColumnsSurviveTheAppsRebuild(t *testing.T) { |
| 411 | path := filepath.Join(t.TempDir(), "console.db") |
| 412 | raw, err := sql.Open("sqlite", path) |
| 413 | if err != nil { |
| 414 | t.Fatal(err) |
| 415 | } |
| 416 | if _, err := raw.Exec(`CREATE TABLE apps (app TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL);`); err != nil { |
| 417 | t.Fatal(err) |
| 418 | } |
| 419 | if _, err := raw.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES ('kass', 'paul@keymail.dev', 1700000000)`); err != nil { |
| 420 | t.Fatal(err) |
| 421 | } |
| 422 | raw.Close() |
| 423 | st, err := Open(path) |
| 424 | if err != nil { |
| 425 | t.Fatal(err) |
| 426 | } |
| 427 | defer st.Close() |
| 428 | var exp, restoring, after, forAt int64 |
| 429 | if err := st.db.QueryRow(`SELECT expires_at, restoring, purge_after, purge_for FROM apps WHERE app = 'kass'`). |
| 430 | Scan(&exp, &restoring, &after, &forAt); err != nil { |
| 431 | t.Fatalf("new columns after the first open: %v", err) |
| 432 | } |
| 433 | if exp != 0 || restoring != 0 || after != 0 || forAt != 0 { |
| 434 | t.Fatalf("defaults = %d %d %d %d, want all 0 (permanent, not restoring, no early purge)", exp, restoring, after, forAt) |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | func TestCreateTemporaryAppInClaimsWithTheExpiry(t *testing.T) { |
| 439 | s := openTestStore(t) |
| 440 | acct := tempStoreAccount(t, s) |
| 441 | now := time.Unix(1700000000, 0) |
| 442 | exp := now.Add(7 * 24 * time.Hour) |
| 443 | if ok, err := s.CreateTemporaryAppIn(acct, "tmp-docs", "paul@keymail.dev", now, exp); err != nil || !ok { |
| 444 | t.Fatalf("CreateTemporaryAppIn = %v %v", ok, err) |
| 445 | } |
| 446 | if l := mustLife(t, s, acct, "tmp-docs"); l.ExpiresAt != exp.Unix() || !l.Temporary() || l.Trashed() { |
| 447 | t.Fatalf("row = %+v, want expires_at %d, live", l, exp.Unix()) |
| 448 | } |
| 449 | // A racing permanent create of the same name loses (ON CONFLICT DO NOTHING). |
| 450 | if ok, err := s.CreateAppIn(acct, "tmp-docs", "eve@keymail.dev", now); err != nil || ok { |
| 451 | t.Fatalf("permanent create over a temporary row = %v %v, want false", ok, err) |
| 452 | } |
| 453 | if ok, _ := s.CreateTemporaryAppIn(acct, "tmp-docs", "eve@keymail.dev", now, exp); ok { |
| 454 | t.Fatal("a second temporary claim of the same name succeeded") |
| 455 | } |
| 456 | if _, err := s.CreateTemporaryAppIn(acct, "tmp-zero", "paul@keymail.dev", now, time.Time{}); err == nil { |
| 457 | t.Fatal("a temporary claim with no expiry was accepted") |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | func TestTemporaryConditionalUpdatesLoseCleanly(t *testing.T) { |
| 462 | s := openTestStore(t) |
| 463 | acct := tempStoreAccount(t, s) |
| 464 | now := time.Unix(1700000000, 0) |
| 465 | if _, err := s.CreateAppIn(acct, "perm", "paul@keymail.dev", now); err != nil { |
| 466 | t.Fatal(err) |
| 467 | } |
| 468 | if _, err := s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now, now.Add(time.Hour)); err != nil { |
| 469 | t.Fatal(err) |
| 470 | } |
| 471 | for name, fn := range map[string]func() (bool, error){ |
| 472 | "keep permanent": func() (bool, error) { return s.KeepApp(acct, "perm") }, |
| 473 | "ttl permanent": func() (bool, error) { return s.SetAppExpiry(acct, "perm", now.Add(2*time.Hour)) }, |
| 474 | "expire permanent": func() (bool, error) { return s.ExpireAppRow(acct, "perm", now) }, |
| 475 | "bump live": func() (bool, error) { return s.BumpTrashedExpiry(acct, "tmp-a", now) }, |
| 476 | "restoring on live": func() (bool, error) { return s.SetRestoring(acct, "tmp-a", true) }, |
| 477 | } { |
| 478 | if ok, err := fn(); err != nil || ok { |
| 479 | t.Errorf("%s = %v %v, want a lost update (false, nil)", name, ok, err) |
| 480 | } |
| 481 | } |
| 482 | // Expire wins once, and sets the 3-day window from the given clock. |
| 483 | at := now.Add(90 * time.Minute) |
| 484 | if ok, err := s.ExpireAppRow(acct, "tmp-a", at); err != nil || !ok { |
| 485 | t.Fatalf("ExpireAppRow = %v %v", ok, err) |
| 486 | } |
| 487 | l := mustLife(t, s, acct, "tmp-a") |
| 488 | if l.DeletedAt != at.Unix() || l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 { |
| 489 | t.Fatalf("expired row = %+v, want deleted_at = purge_for = %d, purge_after = +72h", l, at.Unix()) |
| 490 | } |
| 491 | for name, fn := range map[string]func() (bool, error){ |
| 492 | "expire twice": func() (bool, error) { return s.ExpireAppRow(acct, "tmp-a", at.Add(time.Hour)) }, |
| 493 | "keep trashed": func() (bool, error) { return s.KeepApp(acct, "tmp-a") }, |
| 494 | "ttl on trashed": func() (bool, error) { return s.SetAppExpiry(acct, "tmp-a", now.Add(9*time.Hour)) }, |
| 495 | } { |
| 496 | if ok, err := fn(); err != nil || ok { |
| 497 | t.Errorf("%s = %v %v, want a lost update", name, ok, err) |
| 498 | } |
| 499 | } |
| 500 | } |
| 501 | |
| 502 | func TestTrashAppRowSetsTheWindowOnlyForTemporaryRows(t *testing.T) { |
| 503 | s := openTestStore(t) |
| 504 | acct := tempStoreAccount(t, s) |
| 505 | now := time.Unix(1700000000, 0) |
| 506 | s.CreateAppIn(acct, "perm", "paul@keymail.dev", now) |
| 507 | s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now, now.Add(30*24*time.Hour)) |
| 508 | at := now.Add(time.Minute) |
| 509 | for _, app := range []string{"perm", "tmp-a"} { |
| 510 | if err := s.TrashAppRow(acct, app, at); err != nil { |
| 511 | t.Fatal(err) |
| 512 | } |
| 513 | } |
| 514 | if l := mustLife(t, s, acct, "perm"); l.PurgeAfter != 0 || l.PurgeFor != 0 || l.DeletedAt != at.Unix() { |
| 515 | t.Fatalf("permanent trash = %+v, want no early purge", l) |
| 516 | } |
| 517 | if l := mustLife(t, s, acct, "tmp-a"); l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 { |
| 518 | t.Fatalf("temporary trash = %+v, want the 3-day window from %d", l, at.Unix()) |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | func TestLiveTemporaryCountsARestoringRow(t *testing.T) { |
| 523 | s := openTestStore(t) |
| 524 | acct := tempStoreAccount(t, s) |
| 525 | now := time.Unix(1700000000, 0) |
| 526 | for _, app := range []string{"tmp-a", "tmp-b", "tmp-c"} { |
| 527 | s.CreateTemporaryAppIn(acct, app, "paul@keymail.dev", now, now.Add(time.Hour)) |
| 528 | } |
| 529 | s.CreateAppIn(acct, "perm", "paul@keymail.dev", now) |
| 530 | s.TrashAppRow(acct, "tmp-b", now) |
| 531 | s.TrashAppRow(acct, "tmp-c", now) |
| 532 | if ok, err := s.SetRestoring(acct, "tmp-c", true); err != nil || !ok { |
| 533 | t.Fatalf("SetRestoring = %v %v", ok, err) |
| 534 | } |
| 535 | live, err := s.LiveTemporaryApps(acct) |
| 536 | if err != nil { |
| 537 | t.Fatal(err) |
| 538 | } |
| 539 | var names []string |
| 540 | for _, l := range live { |
| 541 | names = append(names, l.App) |
| 542 | } |
| 543 | if len(names) != 2 || names[0] != "tmp-a" || names[1] != "tmp-c" { |
| 544 | t.Fatalf("LiveTemporaryApps = %v, want [tmp-a tmp-c]: live, plus the one mid-restore", names) |
| 545 | } |
| 546 | } |
| 547 | |
| 548 | func TestRestoreStepsOnTheRow(t *testing.T) { |
| 549 | s := openTestStore(t) |
| 550 | acct := tempStoreAccount(t, s) |
| 551 | now := time.Unix(1700000000, 0) |
| 552 | s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now.Add(-10*24*time.Hour), now.Add(-time.Minute)) |
| 553 | s.TrashAppRow(acct, "tmp-a", now.Add(-time.Minute)) |
| 554 | // Step 2: only while trashed, only when the expiry is under an hour away. |
| 555 | if ok, err := s.BumpTrashedExpiry(acct, "tmp-a", now); err != nil || !ok { |
| 556 | t.Fatalf("BumpTrashedExpiry = %v %v", ok, err) |
| 557 | } |
| 558 | if l := mustLife(t, s, acct, "tmp-a"); l.ExpiresAt != now.Add(temporaryTTLDefault).Unix() || !l.Trashed() || l.PurgeAfter == 0 { |
| 559 | t.Fatalf("after step 2 = %+v, want expiry now+7d, still trashed, deadline kept", l) |
| 560 | } |
| 561 | if ok, _ := s.BumpTrashedExpiry(acct, "tmp-a", now); ok { |
| 562 | t.Fatal("step 2 fired again on an expiry a week out") |
| 563 | } |
| 564 | // Step 5. |
| 565 | if err := s.FinishRestoreRow(acct, "tmp-a", now); err != nil { |
| 566 | t.Fatal(err) |
| 567 | } |
| 568 | if l := mustLife(t, s, acct, "tmp-a"); l.Trashed() || l.Restoring || l.PurgeAfter != 0 || l.PurgeFor != 0 { |
| 569 | t.Fatalf("after step 5 = %+v, want live with no deadline", l) |
| 570 | } |
| 571 | } |
| 572 | |
| 573 | func TestEffectivePurgeAt(t *testing.T) { |
| 574 | del := int64(1700000000) |
| 575 | if got, want := effectivePurgeAt(del, 0), time.Unix(del, 0).UTC().Add(release.TrashCooloff); !got.Equal(want) { |
| 576 | t.Errorf("permanent = %v, want %v", got, want) |
| 577 | } |
| 578 | if got, want := effectivePurgeAt(del, del+72*3600), time.Unix(del+72*3600, 0).UTC(); !got.Equal(want) { |
| 579 | t.Errorf("temporary = %v, want %v", got, want) |
| 580 | } |
| 581 | } |
| 582 | |
| 583 | func TestPurgeDueAndTemporaryRows(t *testing.T) { |
| 584 | s := openTestStore(t) |
| 585 | acct := tempStoreAccount(t, s) |
| 586 | now := time.Unix(1700000000, 0) |
| 587 | s.CreateAppIn(acct, "perm", "paul@keymail.dev", now) |
| 588 | s.CreateTemporaryAppIn(acct, "tmp-due", "paul@keymail.dev", now, now.Add(time.Hour)) |
| 589 | s.CreateTemporaryAppIn(acct, "tmp-later", "paul@keymail.dev", now, now.Add(time.Hour)) |
| 590 | s.TrashAppRow(acct, "perm", now.Add(-100*24*time.Hour)) |
| 591 | s.TrashAppRow(acct, "tmp-due", now.Add(-72*time.Hour)) |
| 592 | s.TrashAppRow(acct, "tmp-later", now.Add(-71*time.Hour)) |
| 593 | due, err := s.PurgeDueApps(now) |
| 594 | if err != nil { |
| 595 | t.Fatal(err) |
| 596 | } |
| 597 | if len(due) != 1 || due[0].App != "tmp-due" || due[0].Sqid != "tst" || due[0].PurgeFor != now.Add(-72*time.Hour).Unix() { |
| 598 | t.Fatalf("PurgeDueApps = %+v, want only tmp-due (a permanent row never has purge_after)", due) |
| 599 | } |
| 600 | rows, err := s.TemporaryAppRows() |
| 601 | if err != nil { |
| 602 | t.Fatal(err) |
| 603 | } |
| 604 | if len(rows) != 2 { |
| 605 | t.Fatalf("TemporaryAppRows = %+v, want the two temporary rows", rows) |
| 606 | } |
| 607 | } |
| 608 | ``` |
| 609 | |
| 610 | - [ ] **Step 2: Run the tests to verify they fail** |
| 611 | |
| 612 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryColumnsSurviveTheAppsRebuild|TestCreateTemporaryAppInClaimsWithTheExpiry|TestTemporaryConditionalUpdatesLoseCleanly|TestTrashAppRowSetsTheWindowOnlyForTemporaryRows|TestLiveTemporaryCountsARestoringRow|TestRestoreStepsOnTheRow|TestEffectivePurgeAt|TestPurgeDueAndTemporaryRows' -count=1` |
| 613 | Expected: FAIL to compile: `undefined: AppLife`, `s.CreateTemporaryAppIn undefined`, and so on. |
| 614 | |
| 615 | - [ ] **Step 3: Implement** |
| 616 | |
| 617 | In `internal/console/store.go`, append to the ALTER list after line 386 (`build_probe`): |
| 618 | |
| 619 | ```go |
| 620 | // Temporary apps (temporary static sites spec §2). The lifecycle lives |
| 621 | // HERE, never in the bucket, so bucket write access cannot make an app |
| 622 | // temporary. expires_at 0 = permanent. restoring 1 = between a |
| 623 | // restore's "about to delete the marker" and "row is live" (§6), and |
| 624 | // counts against the cap. purge_after/purge_for are fixed at trash |
| 625 | // time from whether the row was temporary then; nothing re-derives |
| 626 | // them. |
| 627 | `ALTER TABLE apps ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0;`, |
| 628 | `ALTER TABLE apps ADD COLUMN restoring INTEGER NOT NULL DEFAULT 0;`, |
| 629 | `ALTER TABLE apps ADD COLUMN purge_after INTEGER NOT NULL DEFAULT 0;`, |
| 630 | `ALTER TABLE apps ADD COLUMN purge_for INTEGER NOT NULL DEFAULT 0;`, |
| 631 | ``` |
| 632 | |
| 633 | In `migrateAppsKey` (store.go:481-497), add the four columns to `apps_new` and to the copy. The ALTERs above run first, so the old table has them: |
| 634 | |
| 635 | ```go |
| 636 | `CREATE TABLE apps_new ( |
| 637 | app TEXT NOT NULL, |
| 638 | account_id INTEGER NOT NULL DEFAULT 1, |
| 639 | created_by TEXT NOT NULL, |
| 640 | created_at INTEGER NOT NULL, |
| 641 | deleted_at INTEGER NOT NULL DEFAULT 0, |
| 642 | placement TEXT NOT NULL DEFAULT '', |
| 643 | control_plane INTEGER NOT NULL DEFAULT 0, |
| 644 | build_probe TEXT NOT NULL DEFAULT '', |
| 645 | expires_at INTEGER NOT NULL DEFAULT 0, |
| 646 | restoring INTEGER NOT NULL DEFAULT 0, |
| 647 | purge_after INTEGER NOT NULL DEFAULT 0, |
| 648 | purge_for INTEGER NOT NULL DEFAULT 0, |
| 649 | PRIMARY KEY (account_id, app) |
| 650 | );`, |
| 651 | `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, expires_at, restoring, purge_after, purge_for) |
| 652 | SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, expires_at, restoring, purge_after, purge_for FROM apps;`, |
| 653 | ``` |
| 654 | |
| 655 | Replace `TrashAppRow` and `RestoreAppRow` (store.go:1456-1476): |
| 656 | |
| 657 | ```go |
| 658 | // TrashAppRow marks an app trashed. Trashing twice keeps the FIRST timestamp |
| 659 | // (guarded by `deleted_at = 0`) — the cooloff clock starts once, and a second |
| 660 | // trash call (racing UI, a retried request) must not push the purge deadline |
| 661 | // out again. |
| 662 | // |
| 663 | // now is the MARKER's clock at every caller. A temporary row (expires_at > 0) |
| 664 | // also gets its early-purge window in the same statement: purge_for is the |
| 665 | // marker's deleted_at and purge_after is 3 days later (temporary static sites |
| 666 | // spec §5). The window is bound to the marker's clock and to whether the row |
| 667 | // was temporary when the marker was found; a permanent row gets 0 for both. |
| 668 | func (s *Store) TrashAppRow(accountID int64, app string, now time.Time) error { |
| 669 | at := now.Unix() |
| 670 | _, err := s.db.Exec(`UPDATE apps SET deleted_at = ?, |
| 671 | purge_for = CASE WHEN expires_at > 0 THEN ? ELSE 0 END, |
| 672 | purge_after = CASE WHEN expires_at > 0 THEN ? ELSE 0 END |
| 673 | WHERE account_id = ? AND app = ? AND deleted_at = 0`, |
| 674 | at, at, at+temporaryCooloffSeconds, accountID, app) |
| 675 | return err |
| 676 | } |
| 677 | |
| 678 | // RestoreAppRow un-trashes an app and clears everything that only means |
| 679 | // something while trashed (restoring, the early-purge window). Restoring |
| 680 | // something that was never trashed is harmless. |
| 681 | func (s *Store) RestoreAppRow(accountID int64, app string) error { |
| 682 | _, err := s.db.Exec(`UPDATE apps SET deleted_at = 0, restoring = 0, purge_after = 0, purge_for = 0 |
| 683 | WHERE account_id = ? AND app = ?`, accountID, app) |
| 684 | return err |
| 685 | } |
| 686 | ``` |
| 687 | |
| 688 | Replace `AppRow` and `AppRows` (store.go:1570-1606): |
| 689 | |
| 690 | ```go |
| 691 | type AppRow struct { |
| 692 | AccountID int64 |
| 693 | Sqid string |
| 694 | App string |
| 695 | Deleted bool // trashed here (deleted_at != 0), not purged |
| 696 | // The lifecycle columns (temporary static sites spec §2), Unix seconds. |
| 697 | DeletedAt int64 |
| 698 | ExpiresAt int64 // 0 = permanent |
| 699 | Restoring bool |
| 700 | PurgeAfter int64 // 0 = no early purge |
| 701 | PurgeFor int64 |
| 702 | } |
| 703 | |
| 704 | // appRowCols is the SELECT list every AppRow query uses, in scanAppRows' |
| 705 | // order: p is apps, a is accounts. |
| 706 | const appRowCols = `p.account_id, a.sqid, p.app, p.deleted_at, p.expires_at, p.restoring, p.purge_after, p.purge_for` |
| 707 | |
| 708 | func scanAppRows(rows *sql.Rows) ([]AppRow, error) { |
| 709 | defer rows.Close() |
| 710 | var out []AppRow |
| 711 | for rows.Next() { |
| 712 | var r AppRow |
| 713 | if err := rows.Scan(&r.AccountID, &r.Sqid, &r.App, &r.DeletedAt, &r.ExpiresAt, &r.Restoring, &r.PurgeAfter, &r.PurgeFor); err != nil { |
| 714 | return nil, err |
| 715 | } |
| 716 | r.Deleted = r.DeletedAt != 0 |
| 717 | out = append(out, r) |
| 718 | } |
| 719 | return out, rows.Err() |
| 720 | } |
| 721 | |
| 722 | func (s *Store) AppRows() ([]AppRow, error) { |
| 723 | rows, err := s.db.Query(`SELECT ` + appRowCols + ` |
| 724 | FROM apps p JOIN accounts a ON a.id = p.account_id |
| 725 | WHERE a.deleted_at = 0 ORDER BY a.sqid, p.app`) |
| 726 | if err != nil { |
| 727 | return nil, err |
| 728 | } |
| 729 | return scanAppRows(rows) |
| 730 | } |
| 731 | ``` |
| 732 | |
| 733 | (Keep `AppRows`' existing doc comment.) |
| 734 | |
| 735 | Create `internal/console/tempstore.go`: |
| 736 | |
| 737 | ```go |
| 738 | package console |
| 739 | |
| 740 | import ( |
| 741 | "database/sql" |
| 742 | "errors" |
| 743 | "time" |
| 744 | |
| 745 | "github.com/carlosframework/platform/internal/release" |
| 746 | ) |
| 747 | |
| 748 | const ( |
| 749 | // temporaryTTLDefault is a temporary app's life when nobody says |
| 750 | // otherwise, and what a restore gives back to one whose expiry is under |
| 751 | // temporaryRestoreFloor away (spec §1 ruling 3, §6 step 2). |
| 752 | temporaryTTLDefault = 7 * 24 * time.Hour |
| 753 | temporaryRestoreFloor = time.Hour |
| 754 | // temporaryCooloffSeconds is release.TemporaryCooloff as the Unix-second |
| 755 | // offset the row stores. |
| 756 | temporaryCooloffSeconds = int64(release.TemporaryCooloff / time.Second) |
| 757 | ) |
| 758 | |
| 759 | // AppLife is one apps row's lifecycle columns, read together because every |
| 760 | // temporary-app decision needs them together. Times are Unix seconds. |
| 761 | type AppLife struct { |
| 762 | AccountID int64 |
| 763 | App string |
| 764 | CreatedAt int64 |
| 765 | DeletedAt int64 // 0 = live |
| 766 | ExpiresAt int64 // 0 = permanent |
| 767 | Restoring bool |
| 768 | PurgeAfter int64 // 0 = no early purge |
| 769 | PurgeFor int64 // the marker deleted_at purge_after was computed from |
| 770 | ControlPlane bool |
| 771 | } |
| 772 | |
| 773 | func (l *AppLife) Temporary() bool { return l.ExpiresAt > 0 } |
| 774 | func (l *AppLife) Trashed() bool { return l.DeletedAt != 0 } |
| 775 | |
| 776 | const appLifeCols = `account_id, app, created_at, deleted_at, expires_at, restoring, purge_after, purge_for, control_plane` |
| 777 | |
| 778 | func scanAppLife(sc interface{ Scan(...any) error }) (AppLife, error) { |
| 779 | var l AppLife |
| 780 | err := sc.Scan(&l.AccountID, &l.App, &l.CreatedAt, &l.DeletedAt, &l.ExpiresAt, &l.Restoring, |
| 781 | &l.PurgeAfter, &l.PurgeFor, &l.ControlPlane) |
| 782 | return l, err |
| 783 | } |
| 784 | |
| 785 | // AppLife reads one row's lifecycle. (nil, nil) when there is no row. |
| 786 | func (s *Store) AppLife(accountID int64, app string) (*AppLife, error) { |
| 787 | l, err := scanAppLife(s.db.QueryRow(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? AND app = ?`, accountID, app)) |
| 788 | if errors.Is(err, sql.ErrNoRows) { |
| 789 | return nil, nil |
| 790 | } |
| 791 | if err != nil { |
| 792 | return nil, err |
| 793 | } |
| 794 | return &l, nil |
| 795 | } |
| 796 | |
| 797 | func (s *Store) appLives(query string, args ...any) ([]AppLife, error) { |
| 798 | rows, err := s.db.Query(query, args...) |
| 799 | if err != nil { |
| 800 | return nil, err |
| 801 | } |
| 802 | defer rows.Close() |
| 803 | var out []AppLife |
| 804 | for rows.Next() { |
| 805 | l, err := scanAppLife(rows) |
| 806 | if err != nil { |
| 807 | return nil, err |
| 808 | } |
| 809 | out = append(out, l) |
| 810 | } |
| 811 | return out, rows.Err() |
| 812 | } |
| 813 | |
| 814 | // AppLivesIn is every row in the account, trashed included, by name: the |
| 815 | // input to GET /api/cli/apps/{account}/list (spec §7). |
| 816 | func (s *Store) AppLivesIn(accountID int64) ([]AppLife, error) { |
| 817 | return s.appLives(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? ORDER BY app`, accountID) |
| 818 | } |
| 819 | |
| 820 | // liveTemporaryWhere is the cap's definition of a live temporary app (spec |
| 821 | // §6): a row mid-restore holds its slot, so a new app cannot take it. |
| 822 | const liveTemporaryWhere = `expires_at > 0 AND (deleted_at = 0 OR restoring = 1)` |
| 823 | |
| 824 | // LiveTemporaryApps lists the rows the cap counts, soonest expiry first, so |
| 825 | // the refusal can say what each one is and when it goes. |
| 826 | func (s *Store) LiveTemporaryApps(accountID int64) ([]AppLife, error) { |
| 827 | return s.appLives(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? AND `+liveTemporaryWhere+` ORDER BY expires_at, app`, accountID) |
| 828 | } |
| 829 | |
| 830 | // CreateTemporaryAppIn claims a name with its expiry in the SAME insert |
| 831 | // (spec §3): there is no moment where the row exists permanent, and a racing |
| 832 | // permanent create of the same name loses the ON CONFLICT. |
| 833 | func (s *Store) CreateTemporaryAppIn(accountID int64, app, createdBy string, now, expiresAt time.Time) (bool, error) { |
| 834 | if expiresAt.Unix() <= 0 { |
| 835 | return false, errors.New("console: a temporary app needs an expiry") |
| 836 | } |
| 837 | res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, expires_at) |
| 838 | VALUES (?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`, |
| 839 | app, accountID, createdBy, now.Unix(), expiresAt.Unix()) |
| 840 | if err != nil { |
| 841 | return false, err |
| 842 | } |
| 843 | n, err := res.RowsAffected() |
| 844 | return n > 0, err |
| 845 | } |
| 846 | |
| 847 | // changed runs one conditional UPDATE and reports whether it won. Every |
| 848 | // transition below is one of these: a guard in the WHERE clause, and false |
| 849 | // when a concurrent transition got there first. |
| 850 | func (s *Store) changed(query string, args ...any) (bool, error) { |
| 851 | res, err := s.db.Exec(query, args...) |
| 852 | if err != nil { |
| 853 | return false, err |
| 854 | } |
| 855 | n, err := res.RowsAffected() |
| 856 | return n > 0, err |
| 857 | } |
| 858 | |
| 859 | // ExpireAppRow is the expiry pass's step 4 (spec §5): the marker's clock |
| 860 | // becomes deleted_at and purge_for, and purge_after is 3 days later, guarded |
| 861 | // by expires_at > 0 AND deleted_at = 0 so a keep that won the lock first |
| 862 | // (expires_at = 0) makes this a no-op. |
| 863 | func (s *Store) ExpireAppRow(accountID int64, app string, deletedAt time.Time) (bool, error) { |
| 864 | at := deletedAt.Unix() |
| 865 | return s.changed(`UPDATE apps SET deleted_at = ?, purge_for = ?, purge_after = ? |
| 866 | WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0`, |
| 867 | at, at, at+temporaryCooloffSeconds, accountID, app) |
| 868 | } |
| 869 | |
| 870 | // KeepApp makes a live temporary app permanent (`carlos apps keep`). |
| 871 | func (s *Store) KeepApp(accountID int64, app string) (bool, error) { |
| 872 | return s.changed(`UPDATE apps SET expires_at = 0 |
| 873 | WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0 AND restoring = 0`, accountID, app) |
| 874 | } |
| 875 | |
| 876 | // SetAppExpiry moves a live temporary app's expiry (`carlos apps ttl`). It |
| 877 | // only ever moves a non-zero expiry: there is no door that makes an existing |
| 878 | // app temporary (spec §3). |
| 879 | func (s *Store) SetAppExpiry(accountID int64, app string, expiresAt time.Time) (bool, error) { |
| 880 | return s.changed(`UPDATE apps SET expires_at = ? |
| 881 | WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0 AND restoring = 0`, |
| 882 | expiresAt.Unix(), accountID, app) |
| 883 | } |
| 884 | |
| 885 | // BumpTrashedExpiry is restore step 2 (spec §6): while the row is still |
| 886 | // trashed, an expiry under an hour away becomes a week away, so the expiry |
| 887 | // pass never sees an untrashed row with a stale expiry. |
| 888 | func (s *Store) BumpTrashedExpiry(accountID int64, app string, now time.Time) (bool, error) { |
| 889 | return s.changed(`UPDATE apps SET expires_at = ? |
| 890 | WHERE account_id = ? AND app = ? AND expires_at > 0 AND expires_at < ? AND deleted_at != 0`, |
| 891 | now.Add(temporaryTTLDefault).Unix(), accountID, app, now.Add(temporaryRestoreFloor).Unix()) |
| 892 | } |
| 893 | |
| 894 | // SetRestoring sets restore step 3's flag (only on a trashed row) or clears |
| 895 | // it (settleApp, and a failed step 4). |
| 896 | func (s *Store) SetRestoring(accountID int64, app string, on bool) (bool, error) { |
| 897 | if on { |
| 898 | return s.changed(`UPDATE apps SET restoring = 1 WHERE account_id = ? AND app = ? AND deleted_at != 0`, accountID, app) |
| 899 | } |
| 900 | return s.changed(`UPDATE apps SET restoring = 0 WHERE account_id = ? AND app = ?`, accountID, app) |
| 901 | } |
| 902 | |
| 903 | // FinishRestoreRow is restore step 5 in one statement, with step 2 folded in |
| 904 | // for the paths that reach it without having done step 2 (a direct-mode |
| 905 | // restore caught up by materialize or by the expiry pass, spec §5, §6). |
| 906 | func (s *Store) FinishRestoreRow(accountID int64, app string, now time.Time) error { |
| 907 | _, err := s.db.Exec(`UPDATE apps SET deleted_at = 0, restoring = 0, purge_after = 0, purge_for = 0, |
| 908 | expires_at = CASE WHEN expires_at > 0 AND expires_at < ? THEN ? ELSE expires_at END |
| 909 | WHERE account_id = ? AND app = ?`, |
| 910 | now.Add(temporaryRestoreFloor).Unix(), now.Add(temporaryTTLDefault).Unix(), accountID, app) |
| 911 | return err |
| 912 | } |
| 913 | |
| 914 | // TemporaryAppRows is every temporary row of every live account: the expiry |
| 915 | // pass's settle sweep and expire candidates. A list taken before the lock is |
| 916 | // only a hint (spec §2). |
| 917 | func (s *Store) TemporaryAppRows() ([]AppRow, error) { |
| 918 | rows, err := s.db.Query(`SELECT ` + appRowCols + ` |
| 919 | FROM apps p JOIN accounts a ON a.id = p.account_id |
| 920 | WHERE a.deleted_at = 0 AND p.expires_at > 0 ORDER BY a.sqid, p.app`) |
| 921 | if err != nil { |
| 922 | return nil, err |
| 923 | } |
| 924 | return scanAppRows(rows) |
| 925 | } |
| 926 | |
| 927 | // PurgeDueApps is the early-purge candidates: trashed rows whose window has |
| 928 | // passed. Keyed on purge_after alone, because the decision was fixed at trash |
| 929 | // time (spec §2). |
| 930 | func (s *Store) PurgeDueApps(now time.Time) ([]AppRow, error) { |
| 931 | rows, err := s.db.Query(`SELECT `+appRowCols+` |
| 932 | FROM apps p JOIN accounts a ON a.id = p.account_id |
| 933 | WHERE a.deleted_at = 0 AND p.deleted_at != 0 AND p.purge_after > 0 AND p.purge_after <= ? |
| 934 | ORDER BY a.sqid, p.app`, now.Unix()) |
| 935 | if err != nil { |
| 936 | return nil, err |
| 937 | } |
| 938 | return scanAppRows(rows) |
| 939 | } |
| 940 | |
| 941 | // effectivePurgeAt is the one purge deadline every display uses (spec §5, |
| 942 | // astra 4.2): purge_after when set, else deleted_at + TrashCooloff. Nothing |
| 943 | // that shows or returns a purge date computes DeletedAt + TrashCooloff itself. |
| 944 | func effectivePurgeAt(deletedAt, purgeAfter int64) time.Time { |
| 945 | if purgeAfter > 0 { |
| 946 | return time.Unix(purgeAfter, 0).UTC() |
| 947 | } |
| 948 | return time.Unix(deletedAt, 0).UTC().Add(release.TrashCooloff) |
| 949 | } |
| 950 | ``` |
| 951 | |
| 952 | - [ ] **Step 4: Run the tests to verify they pass** |
| 953 | |
| 954 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryColumnsSurviveTheAppsRebuild|TestCreateTemporaryAppInClaimsWithTheExpiry|TestTemporaryConditionalUpdatesLoseCleanly|TestTrashAppRowSetsTheWindowOnlyForTemporaryRows|TestLiveTemporaryCountsARestoringRow|TestRestoreStepsOnTheRow|TestEffectivePurgeAt|TestPurgeDueAndTemporaryRows|TestAppsRebuild|TestReopenMigrationIdempotent|TestTrashAppRowRoundTrip|TestPurgeAppRowRefusesLiveRow|TestMaterializeApps' -count=1` |
| 955 | Expected: PASS. |
| 956 | |
| 957 | - [ ] **Step 5: Commit** |
| 958 | |
| 959 | ```bash |
| 960 | gofmt -l internal/console |
| 961 | git add internal/console/store.go internal/console/tempstore.go internal/console/tempstore_test.go |
| 962 | git commit -m "console: temporary-app columns, claim with expiry, conditional updates |
| 963 | |
| 964 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 965 | ``` |
| 966 | |
| 967 | --- |
| 968 | |
| 969 | ### Task 3: Reserve the `tmp-` namespace at every door that creates a name |
| 970 | |
| 971 | **Files:** |
| 972 | - Modify: `internal/console/server.go:3352-3361` (claim outcomes), `:3460-3591` (`claimApp`), `:3621-3640` (`handleNewApp` switch) |
| 973 | - Modify: `internal/console/apicli.go:415-470` (`handleAPICreateApp` switch) |
| 974 | - Modify: `internal/console/appsettings.go:766-770` (rename destination), `:852` (rename undo), `:867-884` (`renameAdvice`) |
| 975 | - Modify: `internal/console/store.go:515-527` (`Grant`/`GrantAt`), `:658-663` (`BackfillOpsMemberships` insert), `:1751-1806` (`RenameAppIn`) |
| 976 | - Modify: `cli.go:90-92` (`runAdd`), `cli.go:1286` (next to `addRail`), `apps.go:469-471` (`runAppsCreate`) |
| 977 | - Test: `internal/console/tempnames_test.go` (new), `cli_test.go` |
| 978 | |
| 979 | **Interfaces:** |
| 980 | - Consumes: `release.IsTemporaryName`, `release.TemporaryAppPrefix` (Task 1); `Store.CreateTemporaryAppIn` (Task 2). |
| 981 | - Produces: |
| 982 | - `claimTemporaryReserved` (new `claimOutcome`) |
| 983 | - `func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, expiresAt time.Time) (outcome claimOutcome, placed string, held *apexHold)`; `claimApp` is now `claimAppWith(..., time.Time{})` |
| 984 | - `var ErrTemporaryNameReserved error` (console) |
| 985 | - `func (s *Store) UndoRenameAppIn(accountID int64, renamed, original string) error` |
| 986 | - `func appRail(app string) error` (package main) |
| 987 | |
| 988 | - [ ] **Step 1: Write the failing tests** |
| 989 | |
| 990 | ```go |
| 991 | // internal/console/tempnames_test.go |
| 992 | package console |
| 993 | |
| 994 | import ( |
| 995 | "bytes" |
| 996 | "context" |
| 997 | "errors" |
| 998 | "log" |
| 999 | "net/http" |
| 1000 | "net/url" |
| 1001 | "os" |
| 1002 | "strings" |
| 1003 | "testing" |
| 1004 | "time" |
| 1005 | ) |
| 1006 | |
| 1007 | func TestPermanentClaimsRefuseTheTmpNamespace(t *testing.T) { |
| 1008 | fx := newConsoleFixture(t) |
| 1009 | acct := opsAccount(t, fx) |
| 1010 | if got, _, _ := fx.srv.claimApp(context.Background(), acct, "tmp-docs", "paul@keymail.dev", "", true); got != claimTemporaryReserved { |
| 1011 | t.Fatalf("claimApp(tmp-docs) = %v, want claimTemporaryReserved", got) |
| 1012 | } |
| 1013 | // And a temporary claim refuses anything outside it. |
| 1014 | if got, _, _ := fx.srv.claimAppWith(context.Background(), acct, "docs", "paul@keymail.dev", "", false, time.Now().Add(time.Hour)); got != claimInvalid { |
| 1015 | t.Fatalf("temporary claimAppWith(docs) = %v, want claimInvalid", got) |
| 1016 | } |
| 1017 | |
| 1018 | // The CLI door. |
| 1019 | token := cliToken(t, fx, "paul@keymail.dev", "code-tmp-new") |
| 1020 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "tmp-docs"}, token) |
| 1021 | if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "tmp-") { |
| 1022 | t.Fatalf("/new tmp-docs = %d %v, want 409 naming the tmp- reservation", status, out) |
| 1023 | } |
| 1024 | |
| 1025 | // The browser door. |
| 1026 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-tmp-form") |
| 1027 | status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"tmp-docs"}}) |
| 1028 | if status != http.StatusSeeOther || loc != fx.path("/apps/new") { |
| 1029 | t.Fatalf("browser create tmp-docs = %d -> %q, want 303 back to the form", status, loc) |
| 1030 | } |
| 1031 | if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "tmp-") { |
| 1032 | t.Fatal("browser create tmp-docs: the flash does not name the tmp- reservation") |
| 1033 | } |
| 1034 | if accts, _ := fx.st.AppAccounts("tmp-docs"); len(accts) != 0 { |
| 1035 | t.Fatalf("a permanent door created tmp-docs in %v", accts) |
| 1036 | } |
| 1037 | } |
| 1038 | |
| 1039 | func TestRenameRefusesATmpDestination(t *testing.T) { |
| 1040 | fx := newConsoleFixture(t) |
| 1041 | if _, err := fx.st.CreateAppIn(OpsAccountID, "draft", "paul@keymail.dev", time.Now()); err != nil { |
| 1042 | t.Fatal(err) |
| 1043 | } |
| 1044 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-tmp-rename") |
| 1045 | status, _, body := postForm(t, c, fx.u("/apps/draft/rename"), url.Values{"csrf": {csrf}, "to": {"tmp-draft"}}) |
| 1046 | if status != http.StatusUnprocessableEntity || !strings.Contains(body, "tmp-") { |
| 1047 | t.Fatalf("rename to tmp-draft = %d, want 422 naming the reservation", status) |
| 1048 | } |
| 1049 | if err := fx.st.RenameAppIn(OpsAccountID, "draft", "tmp-draft"); !errors.Is(err, ErrTemporaryNameReserved) { |
| 1050 | t.Fatalf("RenameAppIn onto tmp- = %v, want ErrTemporaryNameReserved", err) |
| 1051 | } |
| 1052 | // The undo of a kept tmp- app's rename is the one way back into the |
| 1053 | // namespace, and only onto the name it came from. |
| 1054 | if _, err := fx.st.CreateAppIn(OpsAccountID, "tmp-kept", "paul@keymail.dev", time.Now()); err != nil { |
| 1055 | t.Fatal(err) |
| 1056 | } |
| 1057 | if err := fx.st.RenameAppIn(OpsAccountID, "tmp-kept", "kept"); err != nil { |
| 1058 | t.Fatal(err) |
| 1059 | } |
| 1060 | if err := fx.st.UndoRenameAppIn(OpsAccountID, "kept", "tmp-kept"); err != nil { |
| 1061 | t.Fatalf("UndoRenameAppIn = %v, want the undo to land", err) |
| 1062 | } |
| 1063 | } |
| 1064 | |
| 1065 | func TestGrantAndBackfillRespectTheTmpNamespace(t *testing.T) { |
| 1066 | st := openTestStore(t) |
| 1067 | if err := st.Grant("paul@keymail.dev", "tmp-x"); !errors.Is(err, ErrTemporaryNameReserved) { |
| 1068 | t.Fatalf("Grant(tmp-x) = %v, want ErrTemporaryNameReserved", err) |
| 1069 | } |
| 1070 | // A grant written before the reservation existed. |
| 1071 | if _, err := st.db.Exec(`INSERT INTO owners (address, app, created_at) VALUES ('paul@keymail.dev', 'tmp-old', 1)`); err != nil { |
| 1072 | t.Fatal(err) |
| 1073 | } |
| 1074 | var logged bytes.Buffer |
| 1075 | log.SetOutput(&logged) |
| 1076 | t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| 1077 | if _, err := st.BackfillOpsMemberships(time.Unix(1700000000, 0)); err != nil { |
| 1078 | t.Fatal(err) |
| 1079 | } |
| 1080 | if has, _ := st.HasApp("tmp-old"); has { |
| 1081 | t.Fatal("backfill turned a tmp- grant into a permanent row") |
| 1082 | } |
| 1083 | if !strings.Contains(logged.String(), "tmp-old") { |
| 1084 | t.Fatalf("backfill skipped tmp-old silently; log:\n%s", logged.String()) |
| 1085 | } |
| 1086 | } |
| 1087 | ``` |
| 1088 | |
| 1089 | In `cli_test.go`, next to `TestAddRefusesSidecarSuffixAndKind` (cli_test.go:1151): |
| 1090 | |
| 1091 | ```go |
| 1092 | func TestAddRefusesTheTmpNamespace(t *testing.T) { |
| 1093 | if err := appRail("tmp-docs"); err == nil { |
| 1094 | t.Fatal("carlos add must not route a tmp- app") |
| 1095 | } |
| 1096 | for _, app := range []string{"", "docs", "tmpdocs"} { |
| 1097 | if err := appRail(app); err != nil { |
| 1098 | t.Fatalf("appRail(%q) = %v, want nil", app, err) |
| 1099 | } |
| 1100 | } |
| 1101 | } |
| 1102 | |
| 1103 | // TestAddRefusesTheTmpNamespaceWiredIntoRunAdd proves runAdd calls appRail |
| 1104 | // before any registry write. |
| 1105 | func TestAddRefusesTheTmpNamespaceWiredIntoRunAdd(t *testing.T) { |
| 1106 | bin := buildCarlos(t) |
| 1107 | regPath := filepath.Join(t.TempDir(), "registry.db") |
| 1108 | cmd := exec.Command(bin, "add", "-registry", regPath, "-app", "tmp-docs", "-kind", "service", "-addr", "127.0.0.1:9", "svc.example.com") |
| 1109 | cmd.Env = carlosTestEnv(t) |
| 1110 | cmd.Dir = t.TempDir() |
| 1111 | out, err := cmd.CombinedOutput() |
| 1112 | if err == nil { |
| 1113 | t.Fatalf("carlos add --app tmp-docs: want non-zero exit, got 0\n%s", out) |
| 1114 | } |
| 1115 | if !strings.Contains(string(out), "tmp-") { |
| 1116 | t.Fatalf("refusal does not name the tmp- reservation:\n%s", out) |
| 1117 | } |
| 1118 | reg, rerr := registry.Open(regPath) |
| 1119 | if rerr != nil { |
| 1120 | t.Fatal(rerr) |
| 1121 | } |
| 1122 | defer reg.Close() |
| 1123 | if rt, gerr := reg.Get("svc.example.com"); gerr != nil || rt != nil { |
| 1124 | t.Fatalf("a refused add still wrote a row: %+v, %v", rt, gerr) |
| 1125 | } |
| 1126 | } |
| 1127 | ``` |
| 1128 | |
| 1129 | - [ ] **Step 2: Run the tests to verify they fail** |
| 1130 | |
| 1131 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestPermanentClaimsRefuseTheTmpNamespace|TestRenameRefusesATmpDestination|TestGrantAndBackfillRespectTheTmpNamespace' -count=1` |
| 1132 | Expected: FAIL to compile: `undefined: claimTemporaryReserved`, `undefined: ErrTemporaryNameReserved`, `fx.srv.claimAppWith undefined`, `fx.st.UndoRenameAppIn undefined`. |
| 1133 | |
| 1134 | Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestAddRefusesTheTmpNamespace' -count=1` |
| 1135 | Expected: FAIL to compile: `undefined: appRail`. |
| 1136 | |
| 1137 | - [ ] **Step 3: Implement** |
| 1138 | |
| 1139 | `internal/console/server.go`, append to the `claimOutcome` constants (after `claimApexHeld`, line 3360): |
| 1140 | |
| 1141 | ```go |
| 1142 | claimTemporaryReserved // a tmp- name at a permanent door (temporary static sites spec ruling 7) |
| 1143 | ``` |
| 1144 | |
| 1145 | Rename the body of `claimApp` (server.go:3460) to `claimAppWith` and make `claimApp` the permanent wrapper. Only the lines shown change; the rest of the body is as it is today: |
| 1146 | |
| 1147 | ```go |
| 1148 | func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool) (outcome claimOutcome, placed string, held *apexHold) { |
| 1149 | return s.claimAppWith(ctx, acct, name, addr, placement, ownerClaim, time.Time{}) |
| 1150 | } |
| 1151 | |
| 1152 | // claimAppWith is claimApp with a temporary claim's expiry. A zero expiresAt |
| 1153 | // is the permanent claim every existing door makes, and it refuses a tmp- |
| 1154 | // name: nothing permanent may be created in that namespace (spec ruling 7). |
| 1155 | // A non-zero one is /new-temporary's claim: it refuses any other name, runs |
| 1156 | // with ownerClaim false whatever the caller's role (spec §3, so any object |
| 1157 | // under the prefix refuses), and inserts the row with its expiry in the same |
| 1158 | // statement that claims the name. |
| 1159 | func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, expiresAt time.Time) (outcome claimOutcome, placed string, held *apexHold) { |
| 1160 | if !release.ValidApp(name) { |
| 1161 | return claimInvalid, "", nil |
| 1162 | } |
| 1163 | if name == "new" { |
| 1164 | return claimReserved, "", nil |
| 1165 | } |
| 1166 | temporary := !expiresAt.IsZero() |
| 1167 | if release.IsTemporaryName(name) != temporary { |
| 1168 | if temporary { |
| 1169 | return claimInvalid, "", nil |
| 1170 | } |
| 1171 | return claimTemporaryReserved, "", nil |
| 1172 | } |
| 1173 | if temporary { |
| 1174 | ownerClaim = false |
| 1175 | } |
| 1176 | // ... unchanged from "Taken means taken IN THIS ACCOUNT" (line 3471) |
| 1177 | // down to the insert, which becomes: |
| 1178 | var created bool |
| 1179 | var err error |
| 1180 | if temporary { |
| 1181 | created, err = s.store.CreateTemporaryAppIn(acct.ID, name, addr, time.Now(), expiresAt) |
| 1182 | } else { |
| 1183 | created, err = s.store.CreateAppIn(acct.ID, name, addr, time.Now()) |
| 1184 | } |
| 1185 | if err != nil { |
| 1186 | return claimCreateFailed, "", nil |
| 1187 | } |
| 1188 | // ... unchanged from "if !created" to the end. |
| 1189 | } |
| 1190 | ``` |
| 1191 | |
| 1192 | (Move `claimApp`'s existing doc comment onto `claimAppWith` and keep a one-line comment on the wrapper.) |
| 1193 | |
| 1194 | `handleNewApp` switch (server.go, after `case claimReserved:` at line 3625): |
| 1195 | |
| 1196 | ```go |
| 1197 | case claimTemporaryReserved: |
| 1198 | fail("Names starting with tmp- are kept for temporary sites. Choose another name.") // COPY-REVIEW C3.1 |
| 1199 | ``` |
| 1200 | |
| 1201 | `handleAPICreateApp` switch (apicli.go, after `case claimReserved:` at line 420): |
| 1202 | |
| 1203 | ```go |
| 1204 | case claimTemporaryReserved: |
| 1205 | jsonError(w, http.StatusConflict, |
| 1206 | "names starting with tmp- are kept for temporary sites. Use carlos deploy --temporary, or choose another name") // COPY-REVIEW C3.2 |
| 1207 | ``` |
| 1208 | |
| 1209 | `handleAppRename` (appsettings.go), after the `to == "new"` block at lines 766-770: |
| 1210 | |
| 1211 | ```go |
| 1212 | if release.IsTemporaryName(to) { |
| 1213 | s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", |
| 1214 | "Names starting with tmp- are kept for temporary sites. Choose another name.", // COPY-REVIEW C3.1 |
| 1215 | map[string]string{"rename": r.FormValue("to")}) |
| 1216 | return |
| 1217 | } |
| 1218 | ``` |
| 1219 | |
| 1220 | and the undo at line 852 becomes `s.store.UndoRenameAppIn(acct.ID, to, app)`. `renameAdvice` (line 867) gains a first case: |
| 1221 | |
| 1222 | ```go |
| 1223 | case errors.Is(err, ErrTemporaryNameReserved): |
| 1224 | return "Names starting with tmp- are kept for temporary sites. Choose another name." // COPY-REVIEW C3.1 |
| 1225 | ``` |
| 1226 | |
| 1227 | `internal/console/store.go`: add near the apps section (above `CreateApp`, line 1336): |
| 1228 | |
| 1229 | ```go |
| 1230 | // ErrTemporaryNameReserved refuses a permanent name in the tmp- namespace |
| 1231 | // (temporary static sites spec ruling 7). `console grant` prints it to an |
| 1232 | // operator, so it is written as a sentence. |
| 1233 | var ErrTemporaryNameReserved = errors.New("names starting with tmp- belong to temporary apps. A grant, a rename or a permanent claim cannot create one") // COPY-REVIEW C3.3 |
| 1234 | ``` |
| 1235 | |
| 1236 | `GrantAt` (store.go:522) gains a first statement (`Grant` calls it): |
| 1237 | |
| 1238 | ```go |
| 1239 | if release.IsTemporaryName(app) { |
| 1240 | return ErrTemporaryNameReserved |
| 1241 | } |
| 1242 | ``` |
| 1243 | |
| 1244 | `BackfillOpsMemberships`: the claiming insert (lines 658-663) gets `AND o.app NOT LIKE 'tmp-%'` in its `WHERE`, and just before `tx.Commit()` (line 698) the skipped grants are reported: |
| 1245 | |
| 1246 | ```go |
| 1247 | if _, err := tx.Exec(`INSERT OR IGNORE INTO apps (app, account_id, created_by, created_at) |
| 1248 | SELECT o.app, ?, MIN(o.address), ? FROM owners o |
| 1249 | WHERE o.app NOT IN (SELECT app FROM apps) AND o.app NOT LIKE 'tmp-%' |
| 1250 | GROUP BY o.app`, OpsAccountID, now.Unix()); err != nil { |
| 1251 | return nil, err |
| 1252 | } |
| 1253 | ``` |
| 1254 | |
| 1255 | ```go |
| 1256 | // A tmp- grant never becomes a row: the namespace is reserved for |
| 1257 | // temporary apps, and only /new-temporary may create one (spec §3, |
| 1258 | // astra 7.2). Said at every start, because only an operator can revoke it. |
| 1259 | skipped, err := tx.Query(`SELECT DISTINCT app FROM owners WHERE app LIKE 'tmp-%' AND app NOT IN (SELECT app FROM apps) ORDER BY app`) |
| 1260 | if err != nil { |
| 1261 | return nil, err |
| 1262 | } |
| 1263 | for skipped.Next() { |
| 1264 | var app string |
| 1265 | if err := skipped.Scan(&app); err != nil { |
| 1266 | skipped.Close() |
| 1267 | return nil, err |
| 1268 | } |
| 1269 | log.Printf("console: not claiming %s for ops: names starting with tmp- belong to temporary apps. Revoke the grant with: console revoke <address> %s", app, app) |
| 1270 | } |
| 1271 | skipped.Close() |
| 1272 | if err := skipped.Err(); err != nil { |
| 1273 | return nil, err |
| 1274 | } |
| 1275 | ``` |
| 1276 | |
| 1277 | Add `"log"` and `"github.com/carlosframework/platform/internal/release"` to store.go's imports. |
| 1278 | |
| 1279 | `RenameAppIn` (store.go:1751) becomes a wrapper; the body moves to `renameAppIn` with one new first check: |
| 1280 | |
| 1281 | ```go |
| 1282 | func (s *Store) RenameAppIn(accountID int64, from, to string) error { |
| 1283 | return s.renameAppIn(accountID, from, to, false) |
| 1284 | } |
| 1285 | |
| 1286 | // UndoRenameAppIn reverses a rename that just landed (handleAppRename's |
| 1287 | // post-commit re-check). It is the one path that may rename INTO the tmp- |
| 1288 | // namespace, because it only ever puts back the name the app already had: a |
| 1289 | // kept temporary app keeps its tmp- name, and renaming it away and then |
| 1290 | // undoing must be able to land. |
| 1291 | func (s *Store) UndoRenameAppIn(accountID int64, renamed, original string) error { |
| 1292 | return s.renameAppIn(accountID, renamed, original, true) |
| 1293 | } |
| 1294 | |
| 1295 | func (s *Store) renameAppIn(accountID int64, from, to string, undo bool) error { |
| 1296 | if !undo && release.IsTemporaryName(to) { |
| 1297 | return ErrTemporaryNameReserved |
| 1298 | } |
| 1299 | // ... the current RenameAppIn body, unchanged. |
| 1300 | } |
| 1301 | ``` |
| 1302 | |
| 1303 | `cli.go`: after the `addRail` call in `runAdd` (lines 90-92): |
| 1304 | |
| 1305 | ```go |
| 1306 | if err := appRail(*app); err != nil { |
| 1307 | log.Fatalf("add %s: %v", host, err) |
| 1308 | } |
| 1309 | ``` |
| 1310 | |
| 1311 | and next to `addRail` (cli.go:1286): |
| 1312 | |
| 1313 | ```go |
| 1314 | // appRail is `carlos add`'s side of the tmp- reservation (temporary static |
| 1315 | // sites spec §3): a temporary app's routes come only from its instance |
| 1316 | // records, minted through the console, so a hand-added route may never wear |
| 1317 | // a tmp- name. Upgrading a box's binary upgrades this writer; there is no |
| 1318 | // other copy of it. |
| 1319 | func appRail(app string) error { |
| 1320 | if release.IsTemporaryName(app) { |
| 1321 | return fmt.Errorf("--app %s: names starting with tmp- belong to temporary apps, which get their routes from the console. carlos add cannot route one", app) // COPY-REVIEW C3.4 |
| 1322 | } |
| 1323 | return nil |
| 1324 | } |
| 1325 | ``` |
| 1326 | |
| 1327 | `apps.go`, in `runAppsCreate` after `validAppName` (line 469-471), so a refused name costs no create budget: |
| 1328 | |
| 1329 | ```go |
| 1330 | if release.IsTemporaryName(strings.TrimSpace(*app)) { |
| 1331 | log.Fatal("apps create: names starting with tmp- are kept for temporary sites. Use carlos deploy --temporary, or choose another name") // COPY-REVIEW C3.2 |
| 1332 | } |
| 1333 | ``` |
| 1334 | |
| 1335 | - [ ] **Step 4: Run the tests to verify they pass** |
| 1336 | |
| 1337 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestPermanentClaimsRefuseTheTmpNamespace|TestRenameRefusesATmpDestination|TestGrantAndBackfillRespectTheTmpNamespace|TestOwnerClaim|TestBrowserDoor|TestCreateApp|TestAppRename|TestRename|TestBackfill|TestOwnersRoundTrip' -count=1` |
| 1338 | Expected: PASS. |
| 1339 | |
| 1340 | Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestAddRefuses' -count=1` |
| 1341 | Expected: PASS. |
| 1342 | |
| 1343 | - [ ] **Step 5: Commit** |
| 1344 | |
| 1345 | ```bash |
| 1346 | gofmt -l . |
| 1347 | git add internal/console/server.go internal/console/apicli.go internal/console/appsettings.go internal/console/store.go internal/console/tempnames_test.go cli.go cli_test.go apps.go |
| 1348 | git commit -m "console, cli: reserve the tmp- namespace at every door that creates a name |
| 1349 | |
| 1350 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 1351 | ``` |
| 1352 | |
| 1353 | --- |
| 1354 | ### Task 4: Per-app locks, the shared tripwire, `settleApp`, and materialize's temporary window |
| 1355 | |
| 1356 | **Files:** |
| 1357 | - Create: `internal/console/applocks.go`, `internal/console/settle.go` |
| 1358 | - Modify: `internal/console/server.go:879` (two Server fields after `relayAppLocks`) |
| 1359 | - Modify: `internal/console/materialize.go:311-325` (tripwire), `:352-381` (marker and restore cases) |
| 1360 | - Test: `internal/console/settle_test.go` (new; its materialize tests sit there beside the helpers they use). Step 4 also runs the existing `materialize_test.go`, unedited. |
| 1361 | |
| 1362 | **Interfaces:** |
| 1363 | - Consumes: `Store.AppLife`, `Store.TrashAppRow` (window), `Store.SetRestoring`, `Store.FinishRestoreRow`, `Store.PurgeAppRow`, `Store.CreateTemporaryAppIn`, `AppRow.Restoring` (Task 2); `release.ReadAppTombstone`, `release.AppTombstoneKey`, `release.AppPrefix`. |
| 1364 | - Produces: |
| 1365 | - `type keyedLocks struct` (zero value ready) with `func (l *keyedLocks) lock(key string) func()` |
| 1366 | - Server fields `appLocks keyedLocks`, `acctLocks keyedLocks` |
| 1367 | - `func (s *Server) lockAccount(sqid string) func()` and `func (s *Server) lockApp(sqid, app string) func()` |
| 1368 | - `type evidenceFunc func(ctx context.Context) bool` |
| 1369 | - `func isTrashMarkerKey(key string) bool`, `func bucketEvidence(keys []string) bool` |
| 1370 | - `func (s *Server) listEvidence(ctx context.Context) (keys []string, listed, evidence bool)` |
| 1371 | - `func (s *Server) doorEvidence(ctx context.Context) bool` |
| 1372 | - `func (s *Server) settleApp(ctx context.Context, accountID int64, sqid, app string, now time.Time, evidence evidenceFunc) error` |
| 1373 | - test helpers (settle_test.go, reused by Tasks 5-10): `tempApp(t, fx, app string, expires time.Time)`, `lifeOf(t, fx, app string) *AppLife`, `markerOf(t, fx, app string) *release.AppTombstone`, `always(bool) evidenceFunc` |
| 1374 | |
| 1375 | - [ ] **Step 1: Write the failing tests** |
| 1376 | |
| 1377 | ```go |
| 1378 | // internal/console/settle_test.go |
| 1379 | package console |
| 1380 | |
| 1381 | import ( |
| 1382 | "bytes" |
| 1383 | "context" |
| 1384 | "log" |
| 1385 | "os" |
| 1386 | "strings" |
| 1387 | "sync" |
| 1388 | "testing" |
| 1389 | "time" |
| 1390 | |
| 1391 | "github.com/carlosframework/platform/internal/domains" |
| 1392 | "github.com/carlosframework/platform/internal/release" |
| 1393 | ) |
| 1394 | |
| 1395 | // tempApp seeds a temporary row in the fixture's ops account and one object |
| 1396 | // under its prefix (a shipped site, as far as any listing can tell). |
| 1397 | func tempApp(t *testing.T, fx *consoleFixture, app string, expires time.Time) { |
| 1398 | t.Helper() |
| 1399 | if ok, err := fx.st.CreateTemporaryAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now(), expires); err != nil || !ok { |
| 1400 | t.Fatalf("CreateTemporaryAppIn(%s) = %v %v", app, ok, err) |
| 1401 | } |
| 1402 | if _, err := fx.rel.Put(context.Background(), "apps/"+fx.sqid+"/"+app+"/channels/edge.json", []byte(`{}`), ""); err != nil { |
| 1403 | t.Fatal(err) |
| 1404 | } |
| 1405 | } |
| 1406 | |
| 1407 | func lifeOf(t *testing.T, fx *consoleFixture, app string) *AppLife { |
| 1408 | t.Helper() |
| 1409 | l, err := fx.st.AppLife(OpsAccountID, app) |
| 1410 | if err != nil { |
| 1411 | t.Fatal(err) |
| 1412 | } |
| 1413 | return l |
| 1414 | } |
| 1415 | |
| 1416 | func markerOf(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone { |
| 1417 | t.Helper() |
| 1418 | tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, app) |
| 1419 | if err != nil { |
| 1420 | t.Fatal(err) |
| 1421 | } |
| 1422 | return tomb |
| 1423 | } |
| 1424 | |
| 1425 | func always(v bool) evidenceFunc { return func(context.Context) bool { return v } } |
| 1426 | |
| 1427 | func TestKeyedLocksSerialiseOneKeyOnly(t *testing.T) { |
| 1428 | var l keyedLocks |
| 1429 | unlockA := l.lock("bab/tmp-a") |
| 1430 | other := make(chan struct{}) |
| 1431 | go func() { l.lock("bab/tmp-b")(); close(other) }() |
| 1432 | select { |
| 1433 | case <-other: |
| 1434 | case <-time.After(time.Second): |
| 1435 | t.Fatal("a different key waited on tmp-a's lock") |
| 1436 | } |
| 1437 | var wg sync.WaitGroup |
| 1438 | got := make(chan struct{}) |
| 1439 | wg.Add(1) |
| 1440 | go func() { defer wg.Done(); l.lock("bab/tmp-a")(); close(got) }() |
| 1441 | select { |
| 1442 | case <-got: |
| 1443 | t.Fatal("a second holder of tmp-a's lock did not wait") |
| 1444 | case <-time.After(50 * time.Millisecond): |
| 1445 | } |
| 1446 | unlockA() |
| 1447 | wg.Wait() |
| 1448 | if len(l.m) != 0 { |
| 1449 | t.Fatalf("lock entries left behind: %v", l.m) |
| 1450 | } |
| 1451 | } |
| 1452 | |
| 1453 | func TestBucketEvidenceIgnoresTrashMarkers(t *testing.T) { |
| 1454 | if bucketEvidence(nil) { |
| 1455 | t.Error("an empty listing is not evidence") |
| 1456 | } |
| 1457 | if bucketEvidence([]string{"apps/bab/tmp-a/deleted.json", "apps/bab/x/deleted.json"}) { |
| 1458 | t.Error("a listing of trash markers alone is not evidence (spec §2, astra 5.2)") |
| 1459 | } |
| 1460 | if !bucketEvidence([]string{"apps/bab/tmp-a/deleted.json", "apps/bab/kass/channels/stable.json"}) { |
| 1461 | t.Error("one real key is evidence") |
| 1462 | } |
| 1463 | if isTrashMarkerKey("apps/bab/tmp-a/sub/deleted.json") { |
| 1464 | t.Error("a deleted.json below the app prefix is not the marker") |
| 1465 | } |
| 1466 | } |
| 1467 | |
| 1468 | // Spec §9: settleApp, each marker/row/restoring combination. |
| 1469 | func TestSettleAppEachCombination(t *testing.T) { |
| 1470 | ctx := context.Background() |
| 1471 | now := time.Now().Truncate(time.Second) |
| 1472 | at := now.Add(-time.Hour) |
| 1473 | |
| 1474 | t.Run("live row, marker present: trashed with the marker's clock and the 3-day window", func(t *testing.T) { |
| 1475 | fx := newConsoleFixture(t) |
| 1476 | tempApp(t, fx, "tmp-a", now.Add(29*24*time.Hour)) |
| 1477 | release.TrashApp(ctx, fx.rel, fx.sqid, "tmp-a", "paul@keymail.dev", at) |
| 1478 | if err := fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true)); err != nil { |
| 1479 | t.Fatal(err) |
| 1480 | } |
| 1481 | l := lifeOf(t, fx, "tmp-a") |
| 1482 | if l.DeletedAt != at.Unix() || l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 { |
| 1483 | t.Fatalf("row = %+v", l) |
| 1484 | } |
| 1485 | }) |
| 1486 | t.Run("live permanent row, marker present: trashed, no early purge", func(t *testing.T) { |
| 1487 | fx := newConsoleFixture(t) |
| 1488 | release.TrashApp(ctx, fx.rel, fx.sqid, "kass", "paul@keymail.dev", at) |
| 1489 | fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "kass", now, always(true)) |
| 1490 | if l := lifeOf(t, fx, "kass"); l.DeletedAt != at.Unix() || l.PurgeAfter != 0 { |
| 1491 | t.Fatalf("row = %+v", l) |
| 1492 | } |
| 1493 | }) |
| 1494 | t.Run("restoring, marker present: step 4 failed, slot released, deadline kept", func(t *testing.T) { |
| 1495 | fx := newConsoleFixture(t) |
| 1496 | tempApp(t, fx, "tmp-a", now.Add(time.Hour)) |
| 1497 | release.TrashApp(ctx, fx.rel, fx.sqid, "tmp-a", "expiry", at) |
| 1498 | fx.st.TrashAppRow(OpsAccountID, "tmp-a", at) |
| 1499 | fx.st.SetRestoring(OpsAccountID, "tmp-a", true) |
| 1500 | fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true)) |
| 1501 | if l := lifeOf(t, fx, "tmp-a"); l.Restoring || !l.Trashed() || l.PurgeAfter != at.Unix()+72*3600 { |
| 1502 | t.Fatalf("row = %+v", l) |
| 1503 | } |
| 1504 | }) |
| 1505 | t.Run("restoring, marker gone, objects present: step 5 finished", func(t *testing.T) { |
| 1506 | fx := newConsoleFixture(t) |
| 1507 | tempApp(t, fx, "tmp-a", now.Add(time.Hour)) |
| 1508 | fx.st.TrashAppRow(OpsAccountID, "tmp-a", at) |
| 1509 | fx.st.SetRestoring(OpsAccountID, "tmp-a", true) |
| 1510 | fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true)) |
| 1511 | if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.Restoring || l.PurgeAfter != 0 { |
| 1512 | t.Fatalf("row = %+v", l) |
| 1513 | } |
| 1514 | }) |
| 1515 | t.Run("trashed, marker gone, objects present: restored elsewhere, expiry bumped", func(t *testing.T) { |
| 1516 | fx := newConsoleFixture(t) |
| 1517 | tempApp(t, fx, "tmp-a", now.Add(-time.Minute)) |
| 1518 | fx.st.TrashAppRow(OpsAccountID, "tmp-a", at) |
| 1519 | fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true)) |
| 1520 | l := lifeOf(t, fx, "tmp-a") |
| 1521 | if l.Trashed() || l.ExpiresAt != now.Add(temporaryTTLDefault).Unix() { |
| 1522 | t.Fatalf("row = %+v, want live with expiry now+7d", l) |
| 1523 | } |
| 1524 | }) |
| 1525 | t.Run("trashed, no marker, no objects, evidence: purged elsewhere, row freed", func(t *testing.T) { |
| 1526 | fx := newConsoleFixture(t) |
| 1527 | fx.st.CreateTemporaryAppIn(OpsAccountID, "tmp-a", "paul@keymail.dev", now, now.Add(time.Hour)) |
| 1528 | fx.st.TrashAppRow(OpsAccountID, "tmp-a", at) |
| 1529 | fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true)) |
| 1530 | if l := lifeOf(t, fx, "tmp-a"); l != nil { |
| 1531 | t.Fatalf("row = %+v, want it gone", l) |
| 1532 | } |
| 1533 | }) |
| 1534 | t.Run("trashed, no marker, no objects, no evidence: left exactly as it is", func(t *testing.T) { |
| 1535 | fx := newConsoleFixture(t) |
| 1536 | fx.st.CreateTemporaryAppIn(OpsAccountID, "tmp-a", "paul@keymail.dev", now, now.Add(time.Hour)) |
| 1537 | fx.st.TrashAppRow(OpsAccountID, "tmp-a", at) |
| 1538 | before := *lifeOf(t, fx, "tmp-a") |
| 1539 | fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(false)) |
| 1540 | if l := lifeOf(t, fx, "tmp-a"); l == nil || *l != before { |
| 1541 | t.Fatalf("row = %+v, want unchanged %+v", l, before) |
| 1542 | } |
| 1543 | }) |
| 1544 | t.Run("live, no marker: nothing to settle", func(t *testing.T) { |
| 1545 | fx := newConsoleFixture(t) |
| 1546 | tempApp(t, fx, "tmp-a", now.Add(time.Hour)) |
| 1547 | before := *lifeOf(t, fx, "tmp-a") |
| 1548 | fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true)) |
| 1549 | if l := lifeOf(t, fx, "tmp-a"); *l != before { |
| 1550 | t.Fatalf("row = %+v, want unchanged", l) |
| 1551 | } |
| 1552 | }) |
| 1553 | t.Run("unreadable marker: an error, and nothing changes", func(t *testing.T) { |
| 1554 | fx := newConsoleFixture(t) |
| 1555 | tempApp(t, fx, "tmp-a", now.Add(time.Hour)) |
| 1556 | fx.rel.Put(ctx, release.AppTombstoneKey(fx.sqid, "tmp-a"), []byte("{"), "") |
| 1557 | before := *lifeOf(t, fx, "tmp-a") |
| 1558 | if err := fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true)); err == nil { |
| 1559 | t.Fatal("settleApp believed an unparseable marker") |
| 1560 | } |
| 1561 | if l := lifeOf(t, fx, "tmp-a"); *l != before { |
| 1562 | t.Fatalf("row = %+v, want unchanged", l) |
| 1563 | } |
| 1564 | }) |
| 1565 | } |
| 1566 | |
| 1567 | // Spec §9 "Materialize": a marker found for a temporary row sets the 3-day |
| 1568 | // window from the marker's clock; for a permanent row, leaves it 0. |
| 1569 | func TestMaterializeSetsTheTemporaryWindowFromTheMarker(t *testing.T) { |
| 1570 | st := openTestStore(t) |
| 1571 | rel := release.NewDirStore(t.TempDir()) |
| 1572 | ctx := t.Context() |
| 1573 | now := time.Unix(1700000000, 0).UTC() |
| 1574 | id, sqid, err := domains.MintAccount(ctx, rel, "Testco", "paul@keymail.dev", now) |
| 1575 | if err != nil { |
| 1576 | t.Fatal(err) |
| 1577 | } |
| 1578 | MaterializeAccounts(ctx, rel, st) |
| 1579 | st.CreateTemporaryAppIn(id, "tmp-a", "paul@keymail.dev", now, now.Add(30*24*time.Hour)) |
| 1580 | st.CreateAppIn(id, "perm", "paul@keymail.dev", now) |
| 1581 | at := now.Add(time.Hour) |
| 1582 | for _, app := range []string{"tmp-a", "perm"} { |
| 1583 | rel.Put(ctx, "apps/"+sqid+"/"+app+"/channels/edge.json", []byte(`{}`), "") |
| 1584 | if err := release.TrashApp(ctx, rel, sqid, app, "paul@keymail.dev", at); err != nil { |
| 1585 | t.Fatal(err) |
| 1586 | } |
| 1587 | } |
| 1588 | if err := MaterializeApps(ctx, rel, st); err != nil { |
| 1589 | t.Fatal(err) |
| 1590 | } |
| 1591 | if l, _ := st.AppLife(id, "tmp-a"); l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 { |
| 1592 | t.Fatalf("temporary row = %+v, want the window from the marker's clock", l) |
| 1593 | } |
| 1594 | if l, _ := st.AppLife(id, "perm"); l.PurgeAfter != 0 || l.PurgeFor != 0 { |
| 1595 | t.Fatalf("permanent row = %+v, want no window", l) |
| 1596 | } |
| 1597 | } |
| 1598 | |
| 1599 | // The tripwire ignores trash markers: a bucket holding nothing else is not |
| 1600 | // evidence that anything was purged. |
| 1601 | func TestMaterializeHoldsPurgesOnABucketOfMarkers(t *testing.T) { |
| 1602 | st := openTestStore(t) |
| 1603 | rel := release.NewDirStore(t.TempDir()) |
| 1604 | ctx := t.Context() |
| 1605 | now := time.Unix(1700000000, 0).UTC() |
| 1606 | id, sqid, _ := domains.MintAccount(ctx, rel, "Testco", "paul@keymail.dev", now) |
| 1607 | MaterializeAccounts(ctx, rel, st) |
| 1608 | st.CreateAppIn(id, "gamma", "paul@keymail.dev", now) |
| 1609 | st.TrashAppRow(id, "gamma", now) |
| 1610 | release.TrashApp(ctx, rel, sqid, "somebody-else", "paul@keymail.dev", now) |
| 1611 | var logged bytes.Buffer |
| 1612 | log.SetOutput(&logged) |
| 1613 | t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| 1614 | if err := MaterializeApps(ctx, rel, st); err != nil { |
| 1615 | t.Fatal(err) |
| 1616 | } |
| 1617 | if trashed, _ := st.HasTrashedAppIn(id, "gamma"); !trashed { |
| 1618 | t.Fatal("a bucket holding only trash markers released a held name") |
| 1619 | } |
| 1620 | if !strings.Contains(logged.String(), "CARLOS_DEPLOYMENT_BUCKET") { |
| 1621 | t.Fatalf("the hold was silent:\n%s", logged.String()) |
| 1622 | } |
| 1623 | } |
| 1624 | ``` |
| 1625 | |
| 1626 | - [ ] **Step 2: Run the tests to verify they fail** |
| 1627 | |
| 1628 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestKeyedLocksSerialiseOneKeyOnly|TestBucketEvidenceIgnoresTrashMarkers|TestSettleAppEachCombination|TestMaterializeSetsTheTemporaryWindowFromTheMarker|TestMaterializeHoldsPurgesOnABucketOfMarkers' -count=1` |
| 1629 | Expected: FAIL to compile: `undefined: keyedLocks`, `undefined: evidenceFunc`, `undefined: bucketEvidence`, `fx.srv.settleApp undefined`. |
| 1630 | |
| 1631 | - [ ] **Step 3: Implement** |
| 1632 | |
| 1633 | Create `internal/console/applocks.go`: |
| 1634 | |
| 1635 | ```go |
| 1636 | package console |
| 1637 | |
| 1638 | import "sync" |
| 1639 | |
| 1640 | // keyedLocks is a refcounted mutex per string key: storelock.go's shape with |
| 1641 | // the key built by the caller. Entries drop at zero references, so an app |
| 1642 | // locked once does not hold a mutex for the life of the process. The zero |
| 1643 | // value is ready, which keeps &Server{} literals in tests safe. |
| 1644 | // |
| 1645 | // IN-PROCESS ONLY, like storeWalkLocks: temporary static sites spec §2 assumes |
| 1646 | // one console per deployment bucket, which is how every deployment runs. |
| 1647 | type keyedLocks struct { |
| 1648 | mu sync.Mutex |
| 1649 | m map[string]*keyedLock |
| 1650 | } |
| 1651 | |
| 1652 | type keyedLock struct { |
| 1653 | mu sync.Mutex |
| 1654 | refs int |
| 1655 | } |
| 1656 | |
| 1657 | // lock blocks until key's mutex is held and returns the release: |
| 1658 | // |
| 1659 | // defer s.lockApp(acct.Sqid, app)() |
| 1660 | func (l *keyedLocks) lock(key string) func() { |
| 1661 | l.mu.Lock() |
| 1662 | if l.m == nil { |
| 1663 | l.m = make(map[string]*keyedLock) |
| 1664 | } |
| 1665 | e := l.m[key] |
| 1666 | if e == nil { |
| 1667 | e = &keyedLock{} |
| 1668 | l.m[key] = e |
| 1669 | } |
| 1670 | e.refs++ |
| 1671 | l.mu.Unlock() |
| 1672 | |
| 1673 | e.mu.Lock() |
| 1674 | return func() { |
| 1675 | e.mu.Unlock() |
| 1676 | l.mu.Lock() |
| 1677 | e.refs-- |
| 1678 | if e.refs == 0 { |
| 1679 | delete(l.m, key) |
| 1680 | } |
| 1681 | l.mu.Unlock() |
| 1682 | } |
| 1683 | } |
| 1684 | |
| 1685 | // lockAccount serialises counting temporary apps against creating or |
| 1686 | // restoring one, which is what makes the cap exact (spec §3, §6). |
| 1687 | // |
| 1688 | // LOCK ORDER: account before app, never the other way round. Only |
| 1689 | // /new-temporary and restoreApp take this lock. |
| 1690 | func (s *Server) lockAccount(sqid string) func() { return s.acctLocks.lock(sqid) } |
| 1691 | |
| 1692 | // lockApp is the per-app mutex every lifecycle transition takes: keep, ttl, |
| 1693 | // restore, trash, the purge door and each step of the expiry pass (spec §5). |
| 1694 | func (s *Server) lockApp(sqid, app string) func() { return s.appLocks.lock(sqid + "/" + app) } |
| 1695 | ``` |
| 1696 | |
| 1697 | In `internal/console/server.go`, after `relayAppLocks sync.Map` (line 879): |
| 1698 | |
| 1699 | ```go |
| 1700 | // appLocks and acctLocks are the temporary-app lifecycle locks |
| 1701 | // (applocks.go). Zero values are ready. |
| 1702 | appLocks keyedLocks |
| 1703 | acctLocks keyedLocks |
| 1704 | ``` |
| 1705 | |
| 1706 | Create `internal/console/settle.go`: |
| 1707 | |
| 1708 | ```go |
| 1709 | package console |
| 1710 | |
| 1711 | import ( |
| 1712 | "context" |
| 1713 | "log" |
| 1714 | "strings" |
| 1715 | "time" |
| 1716 | |
| 1717 | "github.com/carlosframework/platform/internal/release" |
| 1718 | ) |
| 1719 | |
| 1720 | // evidenceFunc answers the empty-bucket tripwire (temporary static sites spec |
| 1721 | // §2): does the apps/ listing hold at least one key that is not a trash |
| 1722 | // marker? Every branch that reasons from absence to "the purge finished" asks |
| 1723 | // it first, and an empty or failed listing is "no evidence". |
| 1724 | type evidenceFunc func(ctx context.Context) bool |
| 1725 | |
| 1726 | // isTrashMarkerKey reports whether key is some app's trash marker, |
| 1727 | // apps/<sqid>/<app>/deleted.json exactly. |
| 1728 | func isTrashMarkerKey(key string) bool { |
| 1729 | parts := strings.SplitN(key, "/", 4) |
| 1730 | return len(parts) == 4 && parts[0] == "apps" && key == release.AppTombstoneKey(parts[1], parts[2]) |
| 1731 | } |
| 1732 | |
| 1733 | // bucketEvidence is the tripwire over a listing already taken. Markers do not |
| 1734 | // count: they are the one thing this feature writes into a bucket that holds |
| 1735 | // nothing else, so a wrong, empty bucket would otherwise vouch for itself |
| 1736 | // with the pass's own markers (astra 5.2). |
| 1737 | func bucketEvidence(keys []string) bool { |
| 1738 | for _, key := range keys { |
| 1739 | if !isTrashMarkerKey(key) { |
| 1740 | return true |
| 1741 | } |
| 1742 | } |
| 1743 | return false |
| 1744 | } |
| 1745 | |
| 1746 | // listEvidence lists apps/ once. listed is false when the listing failed; |
| 1747 | // evidence is the tripwire's answer, false for a failed listing too. |
| 1748 | func (s *Server) listEvidence(ctx context.Context) (keys []string, listed, evidence bool) { |
| 1749 | if s.cfg.Releases == nil { |
| 1750 | return nil, false, false |
| 1751 | } |
| 1752 | keys, err := s.cfg.Releases.List(ctx, "apps/") |
| 1753 | if err != nil { |
| 1754 | log.Printf("console: list apps/ for the empty-bucket tripwire: %v", err) |
| 1755 | return nil, false, false |
| 1756 | } |
| 1757 | return keys, true, bucketEvidence(keys) |
| 1758 | } |
| 1759 | |
| 1760 | // doorEvidence is the evidenceFunc a door passes to settleApp: it lists only |
| 1761 | // if settleApp actually reaches an absence branch. |
| 1762 | func (s *Server) doorEvidence(ctx context.Context) bool { |
| 1763 | _, _, ev := s.listEvidence(ctx) |
| 1764 | return ev |
| 1765 | } |
| 1766 | |
| 1767 | // settleApp brings one app's row into line with its trash marker before any |
| 1768 | // transition acts (spec §2 "Every transition settles first", §6). It is |
| 1769 | // MaterializeApps for one app, plus the restoring cases. The CALLER holds |
| 1770 | // s.lockApp(sqid, app), and re-reads the row afterwards: what it listed |
| 1771 | // before the lock is only a hint. |
| 1772 | // |
| 1773 | // marker, row live -> trashed with the marker's clock (and the |
| 1774 | // 3-day window if temporary, TrashAppRow) |
| 1775 | // marker, row restoring -> restore step 4 failed: restoring = 0, the |
| 1776 | // app stays trashed with its deadline |
| 1777 | // no marker, row trashed, keys -> restored elsewhere, or step 5 failed: |
| 1778 | // FinishRestoreRow |
| 1779 | // no marker, row trashed, none -> purged elsewhere: PurgeAppRow, only if |
| 1780 | // the tripwire finds evidence |
| 1781 | // no marker, row live -> nothing |
| 1782 | // |
| 1783 | // An unreadable marker is an error and changes nothing. |
| 1784 | func (s *Server) settleApp(ctx context.Context, accountID int64, sqid, app string, now time.Time, evidence evidenceFunc) error { |
| 1785 | if s.cfg.Releases == nil { |
| 1786 | return nil |
| 1787 | } |
| 1788 | life, err := s.store.AppLife(accountID, app) |
| 1789 | if err != nil || life == nil { |
| 1790 | return err |
| 1791 | } |
| 1792 | tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, sqid, app) |
| 1793 | if err != nil { |
| 1794 | return err |
| 1795 | } |
| 1796 | if tomb != nil { |
| 1797 | switch { |
| 1798 | case !life.Trashed(): |
| 1799 | if err := s.store.TrashAppRow(accountID, app, tomb.DeletedAt); err != nil { |
| 1800 | return err |
| 1801 | } |
| 1802 | log.Printf("console: %s/%s is in the trash in the bucket; marking it trashed here", sqid, app) |
| 1803 | case life.Restoring: |
| 1804 | if _, err := s.store.SetRestoring(accountID, app, false); err != nil { |
| 1805 | return err |
| 1806 | } |
| 1807 | log.Printf("console: %s/%s: a restore did not remove the trash marker; it stays in the trash", sqid, app) |
| 1808 | } |
| 1809 | return nil |
| 1810 | } |
| 1811 | if !life.Trashed() { |
| 1812 | return nil |
| 1813 | } |
| 1814 | keys, err := s.cfg.Releases.List(ctx, release.AppPrefix(sqid, app)) |
| 1815 | if err != nil { |
| 1816 | return err |
| 1817 | } |
| 1818 | if len(keys) > 0 { |
| 1819 | if err := s.store.FinishRestoreRow(accountID, app, now); err != nil { |
| 1820 | return err |
| 1821 | } |
| 1822 | log.Printf("console: %s/%s has no trash marker and still has objects; restoring it here", sqid, app) |
| 1823 | return nil |
| 1824 | } |
| 1825 | if !evidence(ctx) { |
| 1826 | log.Printf("console: %s/%s has no trash marker and no objects, but the apps/ listing holds nothing else either; "+ |
| 1827 | "leaving the row. Is CARLOS_DEPLOYMENT_BUCKET pointing at the right bucket?", sqid, app) |
| 1828 | return nil |
| 1829 | } |
| 1830 | if err := s.store.PurgeAppRow(accountID, app); err != nil { |
| 1831 | return err |
| 1832 | } |
| 1833 | log.Printf("console: %s/%s is gone from the bucket; releasing the name here", sqid, app) |
| 1834 | return nil |
| 1835 | } |
| 1836 | ``` |
| 1837 | |
| 1838 | In `internal/console/materialize.go`, the tripwire (line 311) becomes the shared helper: |
| 1839 | |
| 1840 | ```go |
| 1841 | purgesHeld := !bucketEvidence(keys) |
| 1842 | ``` |
| 1843 | |
| 1844 | and its log line (line 322-323) keeps its text but its comment gains: "Trash markers do not count as evidence (bucketEvidence, settle.go): they are the one thing the temporary-app pass writes into a bucket." The marker case (lines 352-371) handles a restoring row, and the restore case (lines 376-381) uses `FinishRestoreRow`: |
| 1845 | |
| 1846 | ```go |
| 1847 | case bs.marker: |
| 1848 | tomb, err := release.ReadAppTombstone(ctx, s, r.Sqid, r.App) |
| 1849 | if err != nil { |
| 1850 | log.Printf("console: leaving %s/%s alone: %v", r.Sqid, r.App, err) |
| 1851 | continue |
| 1852 | } |
| 1853 | if tomb == nil { |
| 1854 | continue // restored between the listing and the read |
| 1855 | } |
| 1856 | if r.Deleted { |
| 1857 | // A restore that crashed before deleting the marker: release |
| 1858 | // its cap slot; the app stays in the Trash (spec §6). |
| 1859 | if r.Restoring { |
| 1860 | if _, err := st.SetRestoring(r.AccountID, r.App, false); err != nil { |
| 1861 | log.Printf("console: clear restoring on %s/%s: %v", r.Sqid, r.App, err) |
| 1862 | } |
| 1863 | } |
| 1864 | continue |
| 1865 | } |
| 1866 | // The clock comes from the MARKER. TrashAppRow also sets the |
| 1867 | // 3-day window when the row is temporary (spec §5 "Materialize"). |
| 1868 | if err := st.TrashAppRow(r.AccountID, r.App, tomb.DeletedAt); err != nil { |
| 1869 | log.Printf("console: mark %s/%s trashed: %v", r.Sqid, r.App, err) |
| 1870 | continue |
| 1871 | } |
| 1872 | log.Printf("console: %s/%s is in the trash in the bucket — marking it trashed here", r.Sqid, r.App) |
| 1873 | |
| 1874 | case !r.Deleted: |
| 1875 | continue // (d), and the ordinary live app: no marker, nothing to do |
| 1876 | |
| 1877 | case bs.anyKeys: |
| 1878 | // Restored elsewhere (a headless restore), or a console restore |
| 1879 | // that failed its last step. A temporary row gets restore step 2 |
| 1880 | // here too (spec §6). |
| 1881 | if err := st.FinishRestoreRow(r.AccountID, r.App, time.Now()); err != nil { |
| 1882 | log.Printf("console: restore %s/%s: %v", r.Sqid, r.App, err) |
| 1883 | continue |
| 1884 | } |
| 1885 | log.Printf("console: %s/%s has no trash marker and still has objects — restoring it here", r.Sqid, r.App) |
| 1886 | ``` |
| 1887 | |
| 1888 | (The two existing log lines keep their current text; they are operator logs this plan does not rewrite.) |
| 1889 | |
| 1890 | - [ ] **Step 4: Run the tests to verify they pass** |
| 1891 | |
| 1892 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestKeyedLocksSerialiseOneKeyOnly|TestBucketEvidenceIgnoresTrashMarkers|TestSettleAppEachCombination|TestMaterialize' -count=1` |
| 1893 | Expected: PASS, including the existing `TestMaterializeAppsConverges` and `TestMaterializeAppsRefusesAnEmptyBucket`. |
| 1894 | |
| 1895 | - [ ] **Step 5: Commit** |
| 1896 | |
| 1897 | ```bash |
| 1898 | gofmt -l internal/console |
| 1899 | git add internal/console/applocks.go internal/console/settle.go internal/console/server.go internal/console/materialize.go internal/console/settle_test.go |
| 1900 | git commit -m "console: per-app locks, shared tripwire, settleApp, temporary window in materialize |
| 1901 | |
| 1902 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 1903 | ``` |
| 1904 | |
| 1905 | --- |
| 1906 | |
| 1907 | ### Task 5: `POST /api/cli/apps/{account}/new-temporary` |
| 1908 | |
| 1909 | **Files:** |
| 1910 | - Create: `internal/console/apitemporary.go` |
| 1911 | - Modify: `internal/console/server.go:456` (`Config`: add `TemporaryApps`) |
| 1912 | - Modify: `internal/console/authcli.go:482` (register the route after `/new`) |
| 1913 | - Modify: `internal/console/authcli.go:111-135` (budget constants), `:325-331` (`cliRates` fields), `:366-368` (constructors); `internal/console/svcprincipal.go:387` (add `allowTemporaryCreate` beside `allowCreate`) |
| 1914 | - Modify: `cmd/console/main.go:267-300` (Config literal), `:28-68` (doc comment) |
| 1915 | - Test: `internal/console/apitemporary_test.go` (new) |
| 1916 | |
| 1917 | **Interfaces:** |
| 1918 | - Consumes: `claimAppWith`, `claimTemporaryReserved` (Task 3); `lockAccount` (Task 4); `Store.LiveTemporaryApps`, `Store.AppLife`, `temporaryTTLDefault` (Task 2); `release.IsTemporaryName`, `release.TemporaryAppPrefix`, `release.RouteAccount` (adopt.go:862); `provision.ValidHostShape`; `apiPrincipalAccount`, `auditActor`, `readJSONStrictLimit`, `normalizeAppName`, `apexHeldSentence`, `openRegistry`. |
| 1919 | - Produces: |
| 1920 | - `Config.TemporaryApps bool` |
| 1921 | - `const temporaryTTLMin = time.Hour`, `const temporaryTTLMax = 30 * 24 * time.Hour`, `const temporaryAppCap = 20` |
| 1922 | - `func temporaryTTL(seconds int64, allowDefault bool) (time.Duration, error)` and `var errTTLBounds error` |
| 1923 | - `func validTemporaryName(name string) bool` |
| 1924 | - `type temporaryCapError struct{ Live []AppLife }` with `Error() string` |
| 1925 | - `func (s *Server) temporaryCapCheck(accountID int64) error` (caller holds `lockAccount`) |
| 1926 | - `func (s *Server) accountAppHasRoutes(sqid, app string) (bool, error)` |
| 1927 | - `func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Request)` |
| 1928 | - `const cliTemporaryCreateBurst = 50`; `cliRates.tempCreate *rateLimiter`; `func (s *Server) allowTemporaryCreate(p Principal, now time.Time) bool` |
| 1929 | - Wire: request `{"name": string, "ttl_seconds": int}` (unknown fields refused); 200 `{"account", "app", "url", "expires_at"}` (RFC 3339); 409 bodies carry `"code": "name_taken"` or `"code": "cap"`; 404 while `CARLOS_TEMPORARY_APPS` is not `1`. |
| 1930 | |
| 1931 | **Separate create budget (spec ruling 8).** `/new-temporary` does **not** |
| 1932 | spend `allowCreate`'s 10-a-day budget (`authcli.go:119`). It has its own |
| 1933 | budget of 50 per principal per `cliCreateWindow`. It is keyed on |
| 1934 | `principalKey(p)` for persons and service principals alike, so a key |
| 1935 | rotation gets no fresh budget, and it lives in a separate limiter, so |
| 1936 | temporary creates and permanent creates never spend each other's |
| 1937 | budget. The code for Step 3: |
| 1938 | |
| 1939 | ```go |
| 1940 | // authcli.go, beside cliCreateBurst: |
| 1941 | |
| 1942 | // cliTemporaryCreateBurst bounds temporary-app claim ATTEMPTS per |
| 1943 | // principal per day (spec ruling 8, Paul 2026-09-26). Its own budget, |
| 1944 | // not cliCreateBurst's: the scratchpad use is an agent in a loop, and |
| 1945 | // ten a day would refuse it by lunchtime. The live cap (20) bounds |
| 1946 | // storage while apps live; this bounds churn, since a trashed |
| 1947 | // temporary app still holds storage for its 3-day window, so the |
| 1948 | // worst case is about 150 trashed-but-unpurged apps per principal. |
| 1949 | cliTemporaryCreateBurst = 50 |
| 1950 | |
| 1951 | // cliRates, beside svcCreate: |
| 1952 | tempCreate *rateLimiter |
| 1953 | |
| 1954 | // the constructor at :366, beside create: |
| 1955 | tempCreate: newRateLimiter(cliTemporaryCreateBurst, cliCreateWindow), |
| 1956 | |
| 1957 | // svcprincipal.go, after allowCreate: |
| 1958 | |
| 1959 | // allowTemporaryCreate is /new-temporary's budget: one limiter for both |
| 1960 | // principal kinds, keyed like allowCreate (principalKey), separate from it |
| 1961 | // so neither kind of create spends the other's day. |
| 1962 | func (s *Server) allowTemporaryCreate(p Principal, now time.Time) bool { |
| 1963 | return s.cli.tempCreate.allow(principalKey(p), now) |
| 1964 | } |
| 1965 | ``` |
| 1966 | |
| 1967 | Tests, added to Step 1's file: |
| 1968 | |
| 1969 | ```go |
| 1970 | func TestTemporaryCreateHasOwnBudget(t *testing.T) { |
| 1971 | fx, token := temporaryFixture(t) |
| 1972 | // Attempts, not successes, spend the budget (cliCreateBurst's rule): |
| 1973 | // past the live cap of 20 these are 409s, and they still count. |
| 1974 | for i := 0; i < cliTemporaryCreateBurst; i++ { |
| 1975 | status, out := createTemporary(t, fx, token, fmt.Sprintf("tmp-b%d", i), 0) |
| 1976 | if status == http.StatusTooManyRequests { |
| 1977 | t.Fatalf("temporary attempt %d refused by the rate limit: %v", i, out) |
| 1978 | } |
| 1979 | } |
| 1980 | if status, out := createTemporary(t, fx, token, "tmp-over", 0); status != http.StatusTooManyRequests { |
| 1981 | t.Fatalf("attempt %d = %d %v, want 429", cliTemporaryCreateBurst+1, status, out) |
| 1982 | } |
| 1983 | // The permanent budget is untouched by all of that. |
| 1984 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", |
| 1985 | map[string]any{"name": "permanent-one"}, token) |
| 1986 | if status == http.StatusTooManyRequests { |
| 1987 | t.Fatalf("/new refused after temporary creates: %v; the budgets must be separate", out) |
| 1988 | } |
| 1989 | } |
| 1990 | ``` |
| 1991 | |
| 1992 | Add `"fmt"` to the test file's imports if Step 1's list lacks it. |
| 1993 | |
| 1994 | - [ ] **Step 1: Write the failing tests** |
| 1995 | |
| 1996 | ```go |
| 1997 | // internal/console/apitemporary_test.go |
| 1998 | package console |
| 1999 | |
| 2000 | import ( |
| 2001 | "fmt" |
| 2002 | "net/http" |
| 2003 | "os" |
| 2004 | "path/filepath" |
| 2005 | "strings" |
| 2006 | "sync" |
| 2007 | "testing" |
| 2008 | "time" |
| 2009 | |
| 2010 | "github.com/carlosframework/platform/internal/registry" |
| 2011 | "github.com/carlosframework/platform/internal/release" |
| 2012 | "github.com/carlosframework/platform/internal/svcrole" |
| 2013 | ) |
| 2014 | |
| 2015 | func temporaryFixture(t *testing.T) (*consoleFixture, string) { |
| 2016 | t.Helper() |
| 2017 | fx := newConsoleFixture(t) |
| 2018 | fx.srv.cfg.TemporaryApps = true |
| 2019 | return fx, cliToken(t, fx, "paul@keymail.dev", "code-"+strings.ReplaceAll(t.Name(), "/", "-")) |
| 2020 | } |
| 2021 | |
| 2022 | func createTemporary(t *testing.T, fx *consoleFixture, token, name string, ttl int64) (int, map[string]any) { |
| 2023 | t.Helper() |
| 2024 | return postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary", |
| 2025 | map[string]any{"name": name, "ttl_seconds": ttl}, token) |
| 2026 | } |
| 2027 | |
| 2028 | func TestTemporaryRouteIsOffUntilSwitchedOn(t *testing.T) { |
| 2029 | fx := newConsoleFixture(t) |
| 2030 | token := cliToken(t, fx, "paul@keymail.dev", "code-temp-off") |
| 2031 | status, h, body := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary", token, `{"name":"tmp-docs"}`) |
| 2032 | assertNotFound(t, "feature off", canonicalNotFound(t), status, h, body) |
| 2033 | if l := lifeOf(t, fx, "tmp-docs"); l != nil { |
| 2034 | t.Fatal("the switched-off route created a row") |
| 2035 | } |
| 2036 | } |
| 2037 | |
| 2038 | func TestTemporaryCreateSetsTheExpiryInTheClaim(t *testing.T) { |
| 2039 | fx, token := temporaryFixture(t) |
| 2040 | before := time.Now() |
| 2041 | status, out := createTemporary(t, fx, token, "tmp-docs", 0) |
| 2042 | if status != http.StatusOK { |
| 2043 | t.Fatalf("create = %d %v", status, out) |
| 2044 | } |
| 2045 | exp, err := time.Parse(time.RFC3339, str(t, out, "expires_at")) |
| 2046 | if err != nil { |
| 2047 | t.Fatalf("expires_at %q: %v", out["expires_at"], err) |
| 2048 | } |
| 2049 | if d := exp.Sub(before); d < temporaryTTLDefault-time.Minute || d > temporaryTTLDefault+time.Minute { |
| 2050 | t.Fatalf("default expiry is %v away, want 7 days", d) |
| 2051 | } |
| 2052 | if l := lifeOf(t, fx, "tmp-docs"); l == nil || l.ExpiresAt != exp.Unix() { |
| 2053 | t.Fatalf("row = %+v, want expires_at %d", l, exp.Unix()) |
| 2054 | } |
| 2055 | if p, err := release.GetPipeline(t.Context(), fx.rel, fx.pub, fx.sqid, "tmp-docs"); err != nil || p == nil { |
| 2056 | t.Fatalf("a new temporary app gets the default pipeline like any new app: %v %v", p, err) |
| 2057 | } |
| 2058 | } |
| 2059 | |
| 2060 | // Review Focus 1. |
| 2061 | func TestTemporaryTTLBounds(t *testing.T) { |
| 2062 | for secs, ok := range map[int64]bool{ |
| 2063 | 0: true, 3599: false, 3600: true, 2592000: true, 2592001: false, -1: false, 1 << 62: false, |
| 2064 | } { |
| 2065 | if _, err := temporaryTTL(secs, true); (err == nil) != ok { |
| 2066 | t.Errorf("temporaryTTL(%d) = %v, want ok=%v", secs, err, ok) |
| 2067 | } |
| 2068 | } |
| 2069 | if _, err := temporaryTTL(0, false); err == nil { |
| 2070 | t.Error("ttl 0 is only the default at create, never for carlos apps ttl") |
| 2071 | } |
| 2072 | fx, token := temporaryFixture(t) |
| 2073 | if status, _ := createTemporary(t, fx, token, "tmp-short", 3599); status != http.StatusBadRequest { |
| 2074 | t.Fatalf("ttl 3599 = %d, want 400", status) |
| 2075 | } |
| 2076 | if status, _ := createTemporary(t, fx, token, "tmp-huge", 1<<62); status != http.StatusBadRequest { |
| 2077 | t.Fatalf("ttl 1<<62 = %d, want 400", status) |
| 2078 | } |
| 2079 | if l := lifeOf(t, fx, "tmp-huge"); l != nil { |
| 2080 | t.Fatal("a refused ttl created a row") |
| 2081 | } |
| 2082 | } |
| 2083 | |
| 2084 | // Review Focus 2. |
| 2085 | func TestTemporaryCreateNameShapes(t *testing.T) { |
| 2086 | fx, token := temporaryFixture(t) |
| 2087 | for _, name := range []string{"docs", "tmp-", "tmp-abc-", "tmp-" + strings.Repeat("a", 29), "tmp_docs"} { |
| 2088 | if status, out := createTemporary(t, fx, token, name, 0); status != http.StatusBadRequest { |
| 2089 | t.Errorf("name %q = %d %v, want 400", name, status, out) |
| 2090 | } |
| 2091 | } |
| 2092 | if status, out := createTemporary(t, fx, token, " TMP-Docs ", 0); status != http.StatusOK || out["app"] != "tmp-docs" { |
| 2093 | t.Fatalf("TMP-Docs = %d %v, want 200 creating tmp-docs", status, out) |
| 2094 | } |
| 2095 | } |
| 2096 | |
| 2097 | func TestTemporaryCreateRefusesUnknownFields(t *testing.T) { |
| 2098 | fx, token := temporaryFixture(t) |
| 2099 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary", |
| 2100 | map[string]any{"name": "tmp-cp", "control_plane": true}, token) |
| 2101 | if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), "control_plane") { |
| 2102 | t.Fatalf("control_plane on /new-temporary = %d %v, want 400 naming the field", status, out) |
| 2103 | } |
| 2104 | } |
| 2105 | |
| 2106 | // Spec §9: any key under the prefix refuses, including a manifest with no |
| 2107 | // channel pointer, and the owner role does not exempt it (paul owns ops). |
| 2108 | func TestTemporaryCreateRefusesAnyKeyUnderThePrefix(t *testing.T) { |
| 2109 | fx, token := temporaryFixture(t) |
| 2110 | if _, err := fx.rel.Put(t.Context(), "apps/"+fx.sqid+"/tmp-shipped/releases/v1/manifest.json", []byte(`{}`), ""); err != nil { |
| 2111 | t.Fatal(err) |
| 2112 | } |
| 2113 | status, out := createTemporary(t, fx, token, "tmp-shipped", 0) |
| 2114 | if status != http.StatusConflict || out["code"] != "name_taken" { |
| 2115 | t.Fatalf("create over an unpromoted release = %d %v, want 409 name_taken", status, out) |
| 2116 | } |
| 2117 | if l := lifeOf(t, fx, "tmp-shipped"); l != nil { |
| 2118 | t.Fatal("the owner adopted release history into a temporary app") |
| 2119 | } |
| 2120 | } |
| 2121 | |
| 2122 | // Spec §3 (astra 5.1): a route-only service of the same account and app, with |
| 2123 | // no row and no bucket objects, refuses. Another account's route does not. |
| 2124 | func TestTemporaryCreateRefusesARouteOfTheSameAccount(t *testing.T) { |
| 2125 | fx, token := temporaryFixture(t) |
| 2126 | reg, err := registry.Open(fx.regPath) |
| 2127 | if err != nil { |
| 2128 | t.Fatal(err) |
| 2129 | } |
| 2130 | for _, rt := range []registry.Route{ |
| 2131 | {Host: "svc.example.com", Kind: "service", Status: "ready", App: "tmp-svc", Account: fx.sqid, Addr: "127.0.0.1:9"}, |
| 2132 | {Host: "other.example.com", Kind: "service", Status: "ready", App: "tmp-other", Account: "bzz", Addr: "127.0.0.1:9"}, |
| 2133 | } { |
| 2134 | if err := reg.Upsert(rt); err != nil { |
| 2135 | t.Fatal(err) |
| 2136 | } |
| 2137 | } |
| 2138 | if status, out := createTemporary(t, fx, token, "tmp-svc", 0); status != http.StatusConflict { |
| 2139 | t.Fatalf("same-account route = %d %v, want 409", status, out) |
| 2140 | } |
| 2141 | if status, out := createTemporary(t, fx, token, "tmp-other", 0); status != http.StatusOK { |
| 2142 | t.Fatalf("another account's route = %d %v, want 200", status, out) |
| 2143 | } |
| 2144 | } |
| 2145 | |
| 2146 | // "Could not check" is not "no routes". |
| 2147 | func TestTemporaryCreateRefusesWhenTheRegistryCannotBeRead(t *testing.T) { |
| 2148 | fx, token := temporaryFixture(t) |
| 2149 | notADir := filepath.Join(t.TempDir(), "file") |
| 2150 | if err := os.WriteFile(notADir, []byte("x"), 0o600); err != nil { |
| 2151 | t.Fatal(err) |
| 2152 | } |
| 2153 | fx.srv.cfg.Registry = filepath.Join(notADir, "registry.db") |
| 2154 | if status, out := createTemporary(t, fx, token, "tmp-blind", 0); status != http.StatusBadGateway { |
| 2155 | t.Fatalf("unreadable registry = %d %v, want 502", status, out) |
| 2156 | } |
| 2157 | if l := lifeOf(t, fx, "tmp-blind"); l != nil { |
| 2158 | t.Fatal("created without checking the routes") |
| 2159 | } |
| 2160 | } |
| 2161 | |
| 2162 | // Spec §9: the cap refuses the 21st and is exact under concurrent creates. |
| 2163 | func TestTemporaryCapIsExactUnderConcurrentCreates(t *testing.T) { |
| 2164 | fx, token := temporaryFixture(t) |
| 2165 | now := time.Now() |
| 2166 | for i := 0; i < 18; i++ { |
| 2167 | if ok, err := fx.st.CreateTemporaryAppIn(OpsAccountID, fmt.Sprintf("tmp-seed%02d", i), "paul@keymail.dev", now, now.Add(time.Hour)); err != nil || !ok { |
| 2168 | t.Fatal(ok, err) |
| 2169 | } |
| 2170 | } |
| 2171 | var wg sync.WaitGroup |
| 2172 | statuses := make([]int, 5) |
| 2173 | for i := range statuses { |
| 2174 | wg.Add(1) |
| 2175 | go func(i int) { |
| 2176 | defer wg.Done() |
| 2177 | statuses[i], _ = createTemporary(t, fx, token, fmt.Sprintf("tmp-race%d", i), 0) |
| 2178 | }(i) |
| 2179 | } |
| 2180 | wg.Wait() |
| 2181 | won := 0 |
| 2182 | for _, s := range statuses { |
| 2183 | if s == http.StatusOK { |
| 2184 | won++ |
| 2185 | } |
| 2186 | } |
| 2187 | if won != 2 { |
| 2188 | t.Fatalf("statuses = %v, want exactly 2 of 5 to fit under the cap of 20", statuses) |
| 2189 | } |
| 2190 | live, _ := fx.st.LiveTemporaryApps(OpsAccountID) |
| 2191 | if len(live) != temporaryAppCap { |
| 2192 | t.Fatalf("%d live temporary apps, want %d", len(live), temporaryAppCap) |
| 2193 | } |
| 2194 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary", map[string]any{"name": "tmp-21st"}, token) |
| 2195 | msg := str(t, out, "error") |
| 2196 | if status != http.StatusConflict || out["code"] != "cap" || !strings.Contains(msg, "tmp-seed00") { |
| 2197 | t.Fatalf("21st = %d %v, want 409 cap listing each app with its expiry", status, out) |
| 2198 | } |
| 2199 | } |
| 2200 | |
| 2201 | // Review Focus 5. |
| 2202 | func TestAppScopedServiceCannotClaimATemporaryApp(t *testing.T) { |
| 2203 | fx := newConsoleFixture(t) |
| 2204 | fx.srv.cfg.TemporaryApps = true |
| 2205 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/new-temporary" |
| 2206 | appTok := serviceToken(t, fx.st, OpsAccountID, "kass-ci", "kass", svcrole.Publish) |
| 2207 | status, h, body := rawReq(t, http.MethodPost, u, appTok, `{"name":"tmp-ci"}`) |
| 2208 | assertNotFound(t, "app-scoped credential claiming a temporary app", canonicalNotFound(t), status, h, body) |
| 2209 | if l := lifeOf(t, fx, "tmp-ci"); l != nil { |
| 2210 | t.Fatal("an app-scoped credential claimed a temporary app") |
| 2211 | } |
| 2212 | acctTok := serviceToken(t, fx.st, OpsAccountID, "pages", "", svcrole.Publish) |
| 2213 | if status, _, body := rawReq(t, http.MethodPost, u, acctTok, `{"name":"tmp-ci"}`); status != http.StatusOK { |
| 2214 | t.Fatalf("account-scoped Publish credential = %d %s, want 200", status, body) |
| 2215 | } |
| 2216 | } |
| 2217 | ``` |
| 2218 | |
| 2219 | `rawReq(t, method, u, bearer, body) (int, http.Header, string)`, `canonicalNotFound(t) notFound404` and `assertNotFound(t, what, want, status, h, body)` are the existing helpers at `internal/console/apiinstances_test.go:42-96`. |
| 2220 | |
| 2221 | - [ ] **Step 2: Run the tests to verify they fail** |
| 2222 | |
| 2223 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryRouteIsOffUntilSwitchedOn|TestTemporaryCreate|TestTemporaryTTLBounds|TestTemporaryCapIsExactUnderConcurrentCreates|TestAppScopedServiceCannotClaimATemporaryApp' -count=1` |
| 2224 | Expected: FAIL to compile: `fx.srv.cfg.TemporaryApps undefined`, `undefined: temporaryTTL`, `undefined: temporaryAppCap`. |
| 2225 | |
| 2226 | - [ ] **Step 3: Implement** |
| 2227 | |
| 2228 | `internal/console/server.go`, in `Config` (after `StoreMaxPerApp`, line 605): |
| 2229 | |
| 2230 | ```go |
| 2231 | // TemporaryApps turns on POST /api/cli/apps/{account}/new-temporary |
| 2232 | // (temporary static sites spec §3). Off by default, and only switched on |
| 2233 | // after every box runs a binary that carries the tmp- reservation and |
| 2234 | // every registry has been scanned clean (the rollout steps). The expiry |
| 2235 | // pass runs either way, so apps created while it was on still expire. |
| 2236 | TemporaryApps bool |
| 2237 | ``` |
| 2238 | |
| 2239 | `cmd/console/main.go`, in the `console.Config` literal (after `StoreOperators`): |
| 2240 | |
| 2241 | ```go |
| 2242 | TemporaryApps: os.Getenv("CARLOS_TEMPORARY_APPS") == "1", |
| 2243 | ``` |
| 2244 | |
| 2245 | and add to the header doc comment (lines 28-68): `CARLOS_TEMPORARY_APPS=1 turns on temporary apps (carlos deploy --temporary); off by default, see the temporary static sites rollout.` |
| 2246 | |
| 2247 | `internal/console/authcli.go`, after line 482: |
| 2248 | |
| 2249 | ```go |
| 2250 | // Temporary apps (temporary static sites spec §3). A separate route, not a |
| 2251 | // field on /new: /new's decoder accepts unknown fields, so an older |
| 2252 | // console would take "temporary" silently and create a permanent app. An |
| 2253 | // older console answers this route 404 and the CLI stops there. |
| 2254 | s.cliRoute("POST /api/cli/apps/{account}/new-temporary", svcrole.Allow(svcrole.Publish), s.handleAPICreateTemporaryApp) |
| 2255 | ``` |
| 2256 | |
| 2257 | Create `internal/console/apitemporary.go`: |
| 2258 | |
| 2259 | ```go |
| 2260 | package console |
| 2261 | |
| 2262 | import ( |
| 2263 | "errors" |
| 2264 | "fmt" |
| 2265 | "log" |
| 2266 | "net/http" |
| 2267 | "net/url" |
| 2268 | "strings" |
| 2269 | "time" |
| 2270 | |
| 2271 | "github.com/carlosframework/platform/internal/provision" |
| 2272 | "github.com/carlosframework/platform/internal/release" |
| 2273 | ) |
| 2274 | |
| 2275 | // The bounds are spec ruling 3: default 7 days (temporaryTTLDefault, |
| 2276 | // tempstore.go), at least an hour, at most 30 days, and at most 20 live |
| 2277 | // temporary apps in an account. |
| 2278 | const ( |
| 2279 | temporaryTTLMin = time.Hour |
| 2280 | temporaryTTLMax = 30 * 24 * time.Hour |
| 2281 | temporaryAppCap = 20 |
| 2282 | ) |
| 2283 | |
| 2284 | var errTTLBounds = errors.New("ttl_seconds must be between 1 hour (3600) and 30 days (2592000)") // COPY-REVIEW C5.1 |
| 2285 | |
| 2286 | // temporaryTTL reads a request's ttl_seconds. Compared as seconds before any |
| 2287 | // multiplication, so no value can overflow a Duration on its way to being |
| 2288 | // refused. 0 is the default only where allowDefault says so (create). |
| 2289 | func temporaryTTL(seconds int64, allowDefault bool) (time.Duration, error) { |
| 2290 | if seconds == 0 && allowDefault { |
| 2291 | return temporaryTTLDefault, nil |
| 2292 | } |
| 2293 | if seconds < int64(temporaryTTLMin/time.Second) || seconds > int64(temporaryTTLMax/time.Second) { |
| 2294 | return 0, errTTLBounds |
| 2295 | } |
| 2296 | return time.Duration(seconds) * time.Second, nil |
| 2297 | } |
| 2298 | |
| 2299 | // validTemporaryName is the name rule on top of release.ValidApp: in the tmp- |
| 2300 | // namespace, something after the prefix, and a DNS label, because the name |
| 2301 | // becomes the first label of the app's platform host. |
| 2302 | func validTemporaryName(name string) bool { |
| 2303 | return release.IsTemporaryName(name) && name != release.TemporaryAppPrefix && |
| 2304 | release.ValidApp(name) && provision.ValidHostShape(name) |
| 2305 | } |
| 2306 | |
| 2307 | // temporaryCapError is the cap's refusal. It lists each live temporary app |
| 2308 | // with its expiry, so the person refused can see what to keep or delete. |
| 2309 | type temporaryCapError struct{ Live []AppLife } |
| 2310 | |
| 2311 | func (e *temporaryCapError) Error() string { |
| 2312 | var b strings.Builder |
| 2313 | fmt.Fprintf(&b, "this account already has %d temporary apps, the most it can hold. "+ |
| 2314 | "Make one permanent with carlos apps keep, or delete one with carlos apps delete. They are:", len(e.Live)) // COPY-REVIEW C5.2 |
| 2315 | for _, l := range e.Live { |
| 2316 | fmt.Fprintf(&b, "\n %s expires %s", l.App, time.Unix(l.ExpiresAt, 0).UTC().Format("2006-01-02 15:04 UTC")) // COPY-REVIEW C5.3 |
| 2317 | } |
| 2318 | return b.String() |
| 2319 | } |
| 2320 | |
| 2321 | // temporaryCapCheck refuses when the account is at the cap. The CALLER holds |
| 2322 | // s.lockAccount(sqid), which is what makes count-then-insert exact (spec §3). |
| 2323 | func (s *Server) temporaryCapCheck(accountID int64) error { |
| 2324 | live, err := s.store.LiveTemporaryApps(accountID) |
| 2325 | if err != nil { |
| 2326 | return err |
| 2327 | } |
| 2328 | if len(live) >= temporaryAppCap { |
| 2329 | return &temporaryCapError{Live: live} |
| 2330 | } |
| 2331 | return nil |
| 2332 | } |
| 2333 | |
| 2334 | // accountAppHasRoutes is appHasRoutes (appsettings.go:711) scoped to one |
| 2335 | // account: any route in THIS console's registry whose account |
| 2336 | // (release.RouteAccount) is sqid and whose app is app, platform apex |
| 2337 | // included. A registry that cannot be opened or listed is an error, never |
| 2338 | // "no routes" (spec §3, astra 5.1). It is a second line behind the tmp- |
| 2339 | // namespace, not the proof: routes hand-added on other boxes are invisible |
| 2340 | // here. |
| 2341 | func (s *Server) accountAppHasRoutes(sqid, app string) (bool, error) { |
| 2342 | if s.cfg.Registry == "" { |
| 2343 | return false, errors.New("console: no registry configured") |
| 2344 | } |
| 2345 | reg, err := openRegistry(s.cfg.Registry) |
| 2346 | if err != nil { |
| 2347 | return false, err |
| 2348 | } |
| 2349 | routes, err := reg.List() |
| 2350 | if err != nil { |
| 2351 | return false, err |
| 2352 | } |
| 2353 | for _, rt := range routes { |
| 2354 | if rt.App == app && release.RouteAccount(rt) == sqid { |
| 2355 | return true, nil |
| 2356 | } |
| 2357 | } |
| 2358 | return false, nil |
| 2359 | } |
| 2360 | |
| 2361 | // handleAPICreateTemporaryApp is POST /api/cli/apps/{account}/new-temporary |
| 2362 | // (spec §3): a FRESH claim of a tmp- name, never an adoption, with the expiry |
| 2363 | // in the same insert. It shares claimApp's checks through claimAppWith and |
| 2364 | // takes no control_plane, place or build_probe: its strict decoder refuses |
| 2365 | // them by name. |
| 2366 | func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Request) { |
| 2367 | if !s.cfg.TemporaryApps { |
| 2368 | http.NotFound(w, r) |
| 2369 | return |
| 2370 | } |
| 2371 | p, acct, ok := s.apiPrincipalAccount(w, r) |
| 2372 | if !ok { |
| 2373 | return |
| 2374 | } |
| 2375 | // An app-scoped credential claiming a new name is account authority it |
| 2376 | // was not given: the byte-identical 404, exactly as /new (apicli.go:345). |
| 2377 | if p.Kind == PrincipalService && p.App != "" { |
| 2378 | http.NotFound(w, r) |
| 2379 | return |
| 2380 | } |
| 2381 | addr := auditActor(p, acct) |
| 2382 | now := time.Now() |
| 2383 | if !s.allowTemporaryCreate(p, now) { |
| 2384 | jsonError(w, http.StatusTooManyRequests, |
| 2385 | "temporary app limit reached for today. Try again tomorrow, or deploy to a temporary app you already have") // COPY-REVIEW C5.4 |
| 2386 | return |
| 2387 | } |
| 2388 | var req struct { |
| 2389 | Name string `json:"name"` |
| 2390 | TTLSeconds int64 `json:"ttl_seconds"` |
| 2391 | } |
| 2392 | if !readJSONStrictLimit(w, r, &req, 8<<10) { |
| 2393 | return |
| 2394 | } |
| 2395 | name := normalizeAppName(req.Name) |
| 2396 | if !validTemporaryName(name) { |
| 2397 | jsonError(w, http.StatusBadRequest, |
| 2398 | "a temporary app's name is tmp- followed by lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit") // COPY-REVIEW C5.5 |
| 2399 | return |
| 2400 | } |
| 2401 | ttl, err := temporaryTTL(req.TTLSeconds, true) |
| 2402 | if err != nil { |
| 2403 | jsonError(w, http.StatusBadRequest, err.Error()) |
| 2404 | return |
| 2405 | } |
| 2406 | if s.cfg.Releases == nil { |
| 2407 | jsonError(w, http.StatusServiceUnavailable, |
| 2408 | "this console has no deployment bucket, so it cannot create a temporary app") // COPY-REVIEW C5.6 |
| 2409 | return |
| 2410 | } |
| 2411 | defer s.lockAccount(acct.Sqid)() |
| 2412 | if err := s.temporaryCapCheck(acct.ID); err != nil { |
| 2413 | var capErr *temporaryCapError |
| 2414 | if errors.As(err, &capErr) { |
| 2415 | writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"}) |
| 2416 | return |
| 2417 | } |
| 2418 | log.Printf("console: temporary create %s: count: %v", acct.Sqid, err) |
| 2419 | jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again") // COPY-REVIEW C5.7 |
| 2420 | return |
| 2421 | } |
| 2422 | switch wired, err := s.accountAppHasRoutes(acct.Sqid, name); { |
| 2423 | case err != nil: |
| 2424 | log.Printf("console: temporary create %s/%s: routes: %v", acct.Sqid, name, err) |
| 2425 | jsonError(w, http.StatusBadGateway, "could not check the routes for that name, so nothing was created. Try again") // COPY-REVIEW C5.8 |
| 2426 | return |
| 2427 | case wired: |
| 2428 | writeJSON(w, http.StatusConflict, map[string]any{ |
| 2429 | "error": "a route in this account already serves " + name + ", so it cannot be a temporary app. Choose another name", // COPY-REVIEW C5.9 |
| 2430 | "code": "name_taken", |
| 2431 | }) |
| 2432 | return |
| 2433 | } |
| 2434 | outcome, _, held := s.claimAppWith(r.Context(), acct, name, addr, "", false, now.Add(ttl)) |
| 2435 | switch outcome { |
| 2436 | case claimOK: |
| 2437 | case claimInvalid: |
| 2438 | jsonError(w, http.StatusBadRequest, |
| 2439 | "a temporary app's name is tmp- followed by lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit") // COPY-REVIEW C5.5 |
| 2440 | return |
| 2441 | case claimReserved: |
| 2442 | writeJSON(w, http.StatusConflict, map[string]any{"error": "that name is reserved", "code": "name_taken"}) |
| 2443 | return |
| 2444 | case claimTaken: |
| 2445 | writeJSON(w, http.StatusConflict, map[string]any{"error": "that name is taken", "code": "name_taken"}) |
| 2446 | return |
| 2447 | case claimApexHeld: |
| 2448 | writeJSON(w, http.StatusConflict, map[string]any{ |
| 2449 | "error": "that name is not free: " + apexHeldSentence(s.apexHost(acct.Sqid, name), held), |
| 2450 | "code": "name_taken", |
| 2451 | }) |
| 2452 | return |
| 2453 | case claimStoreFailed: |
| 2454 | jsonError(w, http.StatusBadGateway, "could not check the release store. Nothing was created. Try again") // COPY-REVIEW C5.10 |
| 2455 | return |
| 2456 | default: |
| 2457 | jsonError(w, http.StatusInternalServerError, "could not create the app. Try again") // COPY-REVIEW C5.11 |
| 2458 | return |
| 2459 | } |
| 2460 | life, err := s.store.AppLife(acct.ID, name) |
| 2461 | if err != nil || life == nil || !life.Temporary() { |
| 2462 | log.Printf("console: temporary create %s/%s: read back expiry: %v %+v", acct.Sqid, name, err, life) |
| 2463 | jsonError(w, http.StatusInternalServerError, |
| 2464 | "the app was created but its expiry could not be read back. Check carlos apps list before you ship to it") // COPY-REVIEW C5.12 |
| 2465 | return |
| 2466 | } |
| 2467 | expires := time.Unix(life.ExpiresAt, 0).UTC() |
| 2468 | s.audit(acct, name, addr, "create", "created temporary app "+name+", expiring "+expires.Format("2006-01-02 15:04 UTC")+" (cli)") // COPY-REVIEW C5.13 |
| 2469 | writeJSON(w, http.StatusOK, map[string]any{ |
| 2470 | "account": acct.Sqid, |
| 2471 | "app": name, |
| 2472 | "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name), |
| 2473 | "expires_at": expires.Format(time.RFC3339), |
| 2474 | }) |
| 2475 | } |
| 2476 | ``` |
| 2477 | |
| 2478 | - [ ] **Step 4: Run the tests to verify they pass** |
| 2479 | |
| 2480 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryRouteIsOffUntilSwitchedOn|TestTemporaryCreate|TestTemporaryTTLBounds|TestTemporaryCapIsExactUnderConcurrentCreates|TestAppScopedServiceCannotClaimATemporaryApp|TestEveryCLIDoorNamesItsRoles|TestAppScopedServiceCannotClaimANewApp' -count=1` |
| 2481 | Expected: PASS. |
| 2482 | |
| 2483 | Run: `GOCACHE=$TMPDIR/gocache go build ./cmd/console/` |
| 2484 | Expected: builds. |
| 2485 | |
| 2486 | - [ ] **Step 5: Commit** |
| 2487 | |
| 2488 | ```bash |
| 2489 | gofmt -l internal/console cmd/console |
| 2490 | git add internal/console/apitemporary.go internal/console/apitemporary_test.go internal/console/server.go internal/console/authcli.go internal/console/svcprincipal.go cmd/console/main.go |
| 2491 | git commit -m "console: /new-temporary behind CARLOS_TEMPORARY_APPS, exact cap, route check |
| 2492 | |
| 2493 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 2494 | ``` |
| 2495 | |
| 2496 | --- |
| 2497 | ### Task 6: Temporary apps stay static, platform-hosted, and unprotected |
| 2498 | |
| 2499 | Spec §4: on a row with `expires_at > 0`, every server mutation path in the table refuses, each refusal names `carlos apps keep`, and keep lifts them all. Rename, move and the control-plane setter refuse in the shared store operation too, under the per-app mutex. |
| 2500 | |
| 2501 | **Files:** |
| 2502 | - Create: `internal/console/temprefusal.go` |
| 2503 | - Modify: `internal/console/apicli.go:1653-1656` (`handleAPIShip`, after the kind check) |
| 2504 | - Modify: `internal/console/apiinstances.go:693-697` (`handleAPIInstanceCreate`, after `ValidHost`), `:1751` (`handleAPIInstanceEnable`, before the private-storage block) |
| 2505 | - Modify: `internal/console/instanceprivatestore.go:176` (`instanceStoreProvision`, first statement) |
| 2506 | - Modify: `internal/console/apistore.go:363-367` (`handleAPIStoreCreate`), `:764-768` (`handleAPIStoreGrant`); `internal/console/apistore_provision.go:15-19` (`handleAPIStoreProvision`) |
| 2507 | - Modify: `internal/console/apidomains.go:258-262` (`handleAPIDomainAttach`); `internal/console/domainattach.go:102-104` (`attachDomain`, after `bucketRefusal`) |
| 2508 | - Modify: `internal/console/apimail.go:994-998` (`handleAPIEmailDomainEnsure`), `:1239-1243` (`handleAPIEmailEnable`), `:1374-1378` (`handleAPIEmailCredentialsCreate`) |
| 2509 | - Modify: `internal/console/server.go:3819-3838` (`handlePromote`'s `enable_bake`), `:4041-4085` (`handleProduction`) |
| 2510 | - Modify: `internal/console/apipipeline.go:249-251` (`ensureProductionForBakes`) |
| 2511 | - Modify: `internal/console/appsettings.go:750-753` (`handleAppRename`), `:867` (`renameAdvice`), `:1266-1271` (`handleAppMoveAccount`), `:1364` (`moveAdvice`) |
| 2512 | - Modify: `internal/console/controlplane.go:261-292` (`handleAppControlPlaneSet`) |
| 2513 | - Modify: `internal/console/store.go` (`renameAppIn` from Task 3, `MoveApp` at :1633, `SetAppControlPlane` at :1424) |
| 2514 | - Test: `internal/console/temprefusal_test.go` (new) |
| 2515 | |
| 2516 | **Interfaces:** |
| 2517 | - Consumes: `Store.AppLife`, `Store.KeepApp`, `AppLife.Temporary` (Task 2); `lockApp` (Task 4); `s.apexHost` (apex.go:28). |
| 2518 | - Produces: |
| 2519 | - `var ErrTemporaryApp error` (console; returned by `RenameAppIn`, `MoveApp`, `SetAppControlPlane(…, true)`) |
| 2520 | - `func (s *Server) temporaryRefusal(acct *Account, app, action string) (msg string, status int)`: msg "" when the app is not temporary; status 409, or 503 when the row cannot be read (fails closed) |
| 2521 | - `func (s *Server) refuseTemporary(w http.ResponseWriter, acct *Account, app, action string) bool` |
| 2522 | |
| 2523 | - [ ] **Step 1: Write the failing tests** |
| 2524 | |
| 2525 | ```go |
| 2526 | // internal/console/temprefusal_test.go |
| 2527 | package console |
| 2528 | |
| 2529 | import ( |
| 2530 | "context" |
| 2531 | "errors" |
| 2532 | "net/http" |
| 2533 | "net/http/httptest" |
| 2534 | "net/url" |
| 2535 | "strings" |
| 2536 | "testing" |
| 2537 | "time" |
| 2538 | |
| 2539 | "github.com/carlosframework/platform/internal/provision" |
| 2540 | ) |
| 2541 | |
| 2542 | const keepHint = "carlos apps keep" |
| 2543 | |
| 2544 | // refusalFixture is a console with one live temporary app, tmp-site, and a |
| 2545 | // person token and browser for paul, who owns the account. |
| 2546 | func refusalFixture(t *testing.T) (*consoleFixture, string, *http.Client, string) { |
| 2547 | t.Helper() |
| 2548 | fx := newConsoleFixture(t) |
| 2549 | fx.srv.cfg.AppsDomain = "carlosapp.test" |
| 2550 | tempApp(t, fx, "tmp-site", time.Now().Add(24*time.Hour)) |
| 2551 | token := cliToken(t, fx, "paul@keymail.dev", "code-refuse-cli") |
| 2552 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-refuse-web") |
| 2553 | return fx, token, c, csrf |
| 2554 | } |
| 2555 | |
| 2556 | func keep(t *testing.T, fx *consoleFixture) { |
| 2557 | t.Helper() |
| 2558 | if ok, err := fx.st.KeepApp(OpsAccountID, "tmp-site"); err != nil || !ok { |
| 2559 | t.Fatalf("KeepApp = %v %v", ok, err) |
| 2560 | } |
| 2561 | } |
| 2562 | |
| 2563 | // flashAfter follows a browser door's 303 and returns the page it lands on. |
| 2564 | func flashAfter(t *testing.T, fx *consoleFixture, c *http.Client, status int, loc, body string) string { |
| 2565 | t.Helper() |
| 2566 | if status != http.StatusSeeOther { |
| 2567 | return body |
| 2568 | } |
| 2569 | _, _, page := get(t, c, fx.ts.URL+loc) |
| 2570 | return page |
| 2571 | } |
| 2572 | |
| 2573 | // Spec §9: the server paths in §4 each refuse on a temporary row and pass |
| 2574 | // after keep. "Pass" means the temporary refusal no longer fires; the door |
| 2575 | // may still refuse for its own reasons (no mail backend, no store backend). |
| 2576 | func TestTemporaryRefusalsAtTheAPIDoors(t *testing.T) { |
| 2577 | fx, token, _, _ := refusalFixture(t) |
| 2578 | base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site" |
| 2579 | doors := []struct { |
| 2580 | name string |
| 2581 | call func() (int, string) |
| 2582 | }{ |
| 2583 | {"ship a binary", func() (int, string) { |
| 2584 | st, out := postShip(t, base+"/releases", token, []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) |
| 2585 | return st, str(t, out, "error") |
| 2586 | }}, |
| 2587 | {"store create", func() (int, string) { |
| 2588 | st, out := postJSON(t, base+"/store", map[string]string{"env_prefix": "S3"}, token) |
| 2589 | return st, str(t, out, "error") |
| 2590 | }}, |
| 2591 | {"store provision", func() (int, string) { |
| 2592 | st, out := postJSON(t, base+"/store/provision", map[string]string{}, token) |
| 2593 | return st, str(t, out, "error") |
| 2594 | }}, |
| 2595 | {"domain attach", func() (int, string) { |
| 2596 | st, out := postJSON(t, base+"/domains", map[string]string{"host": "www.example.com"}, token) |
| 2597 | return st, str(t, out, "error") |
| 2598 | }}, |
| 2599 | {"email domains add", func() (int, string) { |
| 2600 | st, out := postJSON(t, base+"/email/domains", map[string]string{"domain": "example.com"}, token) |
| 2601 | return st, str(t, out, "error") |
| 2602 | }}, |
| 2603 | {"email enable", func() (int, string) { |
| 2604 | st, out := postJSON(t, base+"/email/enable", map[string]string{}, token) |
| 2605 | return st, str(t, out, "error") |
| 2606 | }}, |
| 2607 | {"email credentials", func() (int, string) { |
| 2608 | st, out := postJSON(t, base+"/email/credentials", map[string]string{"name": "ci"}, token) |
| 2609 | return st, str(t, out, "error") |
| 2610 | }}, |
| 2611 | {"private storage", func() (int, string) { |
| 2612 | st, out := putJSON(t, base+"/instances-manifest", map[string]any{"private_storage": true}, token) |
| 2613 | return st, str(t, out, "error") |
| 2614 | }}, |
| 2615 | } |
| 2616 | for _, d := range doors { |
| 2617 | if st, msg := d.call(); st != http.StatusConflict || !strings.Contains(msg, keepHint) { |
| 2618 | t.Errorf("%s on a temporary app = %d %q, want 409 naming %s", d.name, st, msg, keepHint) |
| 2619 | } |
| 2620 | } |
| 2621 | keep(t, fx) |
| 2622 | for _, d := range doors { |
| 2623 | if _, msg := d.call(); strings.Contains(msg, keepHint) { |
| 2624 | t.Errorf("%s after keep still refuses as temporary: %q", d.name, msg) |
| 2625 | } |
| 2626 | } |
| 2627 | } |
| 2628 | |
| 2629 | // Instance create: static at the platform host only. |
| 2630 | func TestTemporaryInstanceCreateIsStaticAtThePlatformHost(t *testing.T) { |
| 2631 | fx, token, _, _ := refusalFixture(t) |
| 2632 | base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site" |
| 2633 | if st, out := putJSON(t, base+"/instances-manifest", map[string]any{}, token); st != http.StatusOK { |
| 2634 | t.Fatalf("enable = %d %v", st, out) |
| 2635 | } |
| 2636 | apex := "tmp-site." + fx.sqid + ".carlosapp.test" |
| 2637 | for _, body := range []map[string]any{ |
| 2638 | {"host": apex, "kind": "instance"}, |
| 2639 | {"host": apex}, |
| 2640 | {"host": "www." + fx.sqid + ".carlosapp.test", "kind": "static"}, |
| 2641 | } { |
| 2642 | if st, out := postJSON(t, base+"/instances", body, token); st != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { |
| 2643 | t.Errorf("instance %v on a temporary app = %d %v, want 409 naming %s", body, st, out, keepHint) |
| 2644 | } |
| 2645 | } |
| 2646 | // The one shape a temporary app may have must actually be created, not |
| 2647 | // merely escape the temporary refusal: 201, and a record in the bucket at |
| 2648 | // the platform host, for this account and app, kind static. The shape is |
| 2649 | // TestAPIInstanceCreateStaticAtOwnApex's (apiinstances_test.go:2044). |
| 2650 | status, _, out := rawReq(t, http.MethodPost, base+"/instances", token, |
| 2651 | `{"host":"`+apex+`","kind":"static","channel":"edge"}`) |
| 2652 | if status != http.StatusCreated { |
| 2653 | t.Fatalf("static instance at the platform host = %d %s, want 201", status, out) |
| 2654 | } |
| 2655 | stored, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "tmp-site", apex) |
| 2656 | if err != nil { |
| 2657 | t.Fatalf("read back the record: %v", err) |
| 2658 | } |
| 2659 | if stored.Account != fx.sqid || stored.App != "tmp-site" || stored.Host != apex || stored.Kind != "static" { |
| 2660 | t.Fatalf("stored record = %+v, want account %s, app tmp-site, host %s, kind static", stored, fx.sqid, apex) |
| 2661 | } |
| 2662 | } |
| 2663 | |
| 2664 | func TestTemporaryRefusalsAtTheBrowserDoors(t *testing.T) { |
| 2665 | fx, _, c, csrf := refusalFixture(t) |
| 2666 | doors := map[string]url.Values{ |
| 2667 | "/apps/tmp-site/production": {"csrf": {csrf}, "production": {"on"}}, |
| 2668 | "/apps/tmp-site/rename": {"csrf": {csrf}, "to": {"site"}}, |
| 2669 | "/apps/tmp-site/move": {"csrf": {csrf}}, |
| 2670 | "/apps/tmp-site/control-plane/set": {"csrf": {csrf}}, |
| 2671 | "/apps/tmp-site/domains/attach": {"csrf": {csrf}, "host": {"www.example.com"}}, |
| 2672 | } |
| 2673 | for path, form := range doors { |
| 2674 | st, loc, body := postForm(t, c, fx.u(path), form) |
| 2675 | if page := flashAfter(t, fx, c, st, loc, body); !strings.Contains(page, keepHint) { |
| 2676 | t.Errorf("%s on a temporary app: the page does not name %s", path, keepHint) |
| 2677 | } |
| 2678 | } |
| 2679 | keep(t, fx) |
| 2680 | for path, form := range doors { |
| 2681 | st, loc, body := postForm(t, c, fx.u(path), form) |
| 2682 | if page := flashAfter(t, fx, c, st, loc, body); strings.Contains(page, keepHint) { |
| 2683 | t.Errorf("%s after keep still refuses as temporary", path) |
| 2684 | } |
| 2685 | } |
| 2686 | } |
| 2687 | |
| 2688 | // Spec §4: enforced at the store operation as well as the handler. |
| 2689 | func TestTemporaryRefusalsAtTheStore(t *testing.T) { |
| 2690 | fx, _, _, _ := refusalFixture(t) |
| 2691 | if _, err := fx.st.EnsureAccount(2000, "bdz", "Other", time.Now()); err != nil { |
| 2692 | t.Fatal(err) |
| 2693 | } |
| 2694 | if err := fx.st.RenameAppIn(OpsAccountID, "tmp-site", "site"); !errors.Is(err, ErrTemporaryApp) { |
| 2695 | t.Errorf("RenameAppIn = %v, want ErrTemporaryApp", err) |
| 2696 | } |
| 2697 | if err := fx.st.MoveApp("tmp-site", OpsAccountID, 2000); !errors.Is(err, ErrTemporaryApp) { |
| 2698 | t.Errorf("MoveApp = %v, want ErrTemporaryApp", err) |
| 2699 | } |
| 2700 | if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", true); !errors.Is(err, ErrTemporaryApp) { |
| 2701 | t.Errorf("SetAppControlPlane(true) = %v, want ErrTemporaryApp", err) |
| 2702 | } |
| 2703 | if l := lifeOf(t, fx, "tmp-site"); l.ControlPlane || l.AccountID != OpsAccountID { |
| 2704 | t.Fatalf("a refused store operation changed the row: %+v", l) |
| 2705 | } |
| 2706 | keep(t, fx) |
| 2707 | if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", true); err != nil { |
| 2708 | t.Errorf("SetAppControlPlane after keep = %v", err) |
| 2709 | } |
| 2710 | if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", false); err != nil { |
| 2711 | t.Fatal(err) |
| 2712 | } |
| 2713 | if err := fx.st.MoveApp("tmp-site", OpsAccountID, 2000); err != nil { |
| 2714 | t.Errorf("MoveApp after keep = %v", err) |
| 2715 | } |
| 2716 | } |
| 2717 | |
| 2718 | // The shared provisioning path instance create reaches refuses on its own. |
| 2719 | func TestInstanceStoreProvisionRefusesATemporaryApp(t *testing.T) { |
| 2720 | fx, _, _, _ := refusalFixture(t) |
| 2721 | acct := opsAccount(t, fx) |
| 2722 | rec := httptest.NewRecorder() |
| 2723 | if _, ok := fx.srv.instanceStoreProvision(context.Background(), rec, acct, "tmp-site", "", "paul@keymail.dev"); ok { |
| 2724 | t.Fatal("instanceStoreProvision provisioned for a temporary app") |
| 2725 | } |
| 2726 | if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), keepHint) { |
| 2727 | t.Fatalf("refusal = %d %q", rec.Code, rec.Body.String()) |
| 2728 | } |
| 2729 | } |
| 2730 | ``` |
| 2731 | |
| 2732 | - [ ] **Step 2: Run the tests to verify they fail** |
| 2733 | |
| 2734 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryRefusals|TestTemporaryInstanceCreateIsStaticAtThePlatformHost|TestInstanceStoreProvisionRefusesATemporaryApp' -count=1` |
| 2735 | Expected: FAIL to compile: `undefined: ErrTemporaryApp`. With that stubbed, FAIL on every door: status is not 409 / message lacks "carlos apps keep". |
| 2736 | |
| 2737 | - [ ] **Step 3: Implement** |
| 2738 | |
| 2739 | Create `internal/console/temprefusal.go`: |
| 2740 | |
| 2741 | ```go |
| 2742 | package console |
| 2743 | |
| 2744 | import ( |
| 2745 | "errors" |
| 2746 | "log" |
| 2747 | "net/http" |
| 2748 | ) |
| 2749 | |
| 2750 | // ErrTemporaryApp is the store operations' refusal (RenameAppIn, MoveApp, |
| 2751 | // SetAppControlPlane(…, true)) for a row with expires_at > 0. A temporary |
| 2752 | // row cannot change its name, its account, or its protection class |
| 2753 | // (temporary static sites spec §4): rename and move would point it at bucket |
| 2754 | // state it did not create, and the control-plane flag would take it out of |
| 2755 | // automatic destruction's reach. |
| 2756 | var ErrTemporaryApp = errors.New("the app is temporary. Make it permanent first with carlos apps keep") // COPY-REVIEW C6.16 |
| 2757 | |
| 2758 | // temporaryRefusal answers "may this door proceed?" for the §4 table. msg is |
| 2759 | // "" when the app is not temporary; otherwise the sentence to show, naming |
| 2760 | // `carlos apps keep`, and the status a JSON door sends. A row that cannot be |
| 2761 | // read refuses with 503: "could not check" is not "permanent". |
| 2762 | // |
| 2763 | // Callers MUST be past their own membership gate first, for the same reason |
| 2764 | // controlPlaneRefusal gives: consulted earlier, this is an oracle. |
| 2765 | // |
| 2766 | // action is a whole clause ("ship a binary release to it"), so the sentence |
| 2767 | // reads as English for every door. |
| 2768 | func (s *Server) temporaryRefusal(acct *Account, app, action string) (msg string, status int) { |
| 2769 | life, err := s.store.AppLife(acct.ID, app) |
| 2770 | if err != nil { |
| 2771 | log.Printf("console: temporary check %s/%s: %v", acct.Sqid, app, err) |
| 2772 | return "Could not check whether " + app + " is temporary. Nothing was changed. Try again.", // COPY-REVIEW C6.1 |
| 2773 | http.StatusServiceUnavailable |
| 2774 | } |
| 2775 | if life == nil || !life.Temporary() { |
| 2776 | return "", 0 |
| 2777 | } |
| 2778 | return app + " is temporary, so you cannot " + action + ". " + |
| 2779 | "Make it permanent first with: carlos apps keep --app " + app, // COPY-REVIEW C6.2 |
| 2780 | http.StatusConflict |
| 2781 | } |
| 2782 | |
| 2783 | // refuseTemporary is temporaryRefusal for the JSON doors: it writes the |
| 2784 | // refusal and returns true when the caller must stop. |
| 2785 | func (s *Server) refuseTemporary(w http.ResponseWriter, acct *Account, app, action string) bool { |
| 2786 | msg, status := s.temporaryRefusal(acct, app, action) |
| 2787 | if msg == "" { |
| 2788 | return false |
| 2789 | } |
| 2790 | jsonError(w, status, msg) |
| 2791 | return true |
| 2792 | } |
| 2793 | ``` |
| 2794 | |
| 2795 | The door edits. Each `action` clause is marked with its own id, C6.3 through C6.13. |
| 2796 | |
| 2797 | `handleAPIShip` (apicli.go), directly after the `meta.Kind` validity check at lines 1653-1656: |
| 2798 | |
| 2799 | ```go |
| 2800 | if meta.Kind != "static" && s.refuseTemporary(w, acct, app, "ship a binary release to it") { // COPY-REVIEW C6.3 |
| 2801 | return |
| 2802 | } |
| 2803 | ``` |
| 2804 | |
| 2805 | `handleAPIInstanceCreate` (apiinstances.go), directly after the `provision.ValidHost` refusal (lines 693-697): |
| 2806 | |
| 2807 | ```go |
| 2808 | // A temporary app serves one static site at its own platform address |
| 2809 | // (spec §4, ruling 5): anything else is estate an automatic purge would |
| 2810 | // have to tear down, or a host outside what its expiry drops. |
| 2811 | if apex := s.apexHost(acct.Sqid, app); strings.TrimSpace(req.Kind) != "static" || apex == "" || host != apex { |
| 2812 | if s.refuseTemporary(w, acct, app, "serve anything but a static site at "+app+"'s own platform address") { // COPY-REVIEW C6.4 |
| 2813 | return |
| 2814 | } |
| 2815 | } |
| 2816 | ``` |
| 2817 | |
| 2818 | `handleAPIInstanceEnable` (apiinstances.go), immediately before the block at line 1751: |
| 2819 | |
| 2820 | ```go |
| 2821 | if req.PrivateStorage != nil && *req.PrivateStorage && |
| 2822 | s.refuseTemporary(w, acct, app, "give its instances private storage") { // COPY-REVIEW C6.5 |
| 2823 | return |
| 2824 | } |
| 2825 | ``` |
| 2826 | |
| 2827 | `instanceStoreProvision` (instanceprivatestore.go:176), first statement, as the backstop behind the manifest door: |
| 2828 | |
| 2829 | ```go |
| 2830 | if s.refuseTemporary(w, acct, app, "give it object storage") { // COPY-REVIEW C6.6 |
| 2831 | return nil, false |
| 2832 | } |
| 2833 | ``` |
| 2834 | |
| 2835 | `handleAPIStoreCreate` (apistore.go:363), `handleAPIStoreGrant` (apistore.go:764) and `handleAPIStoreProvision` (apistore_provision.go:15), each directly after its `apiAccountApp` gate: |
| 2836 | |
| 2837 | ```go |
| 2838 | if s.refuseTemporary(w, acct, app, "give it object storage") { // COPY-REVIEW C6.6 |
| 2839 | return |
| 2840 | } |
| 2841 | ``` |
| 2842 | |
| 2843 | `handleAPIDomainAttach` (apidomains.go:258), directly after `domainsGate`, before `refuseServiceControlPlane`: |
| 2844 | |
| 2845 | ```go |
| 2846 | if s.refuseTemporary(w, acct, app, "attach a domain to it") { // COPY-REVIEW C6.7 |
| 2847 | return |
| 2848 | } |
| 2849 | ``` |
| 2850 | |
| 2851 | `attachDomain` (domainattach.go), after the `bucketRefusal` check (lines 102-104), which covers the browser form (appdomains.go:47) as well: |
| 2852 | |
| 2853 | ```go |
| 2854 | if msg, status := s.temporaryRefusal(acct, app, "attach a domain to it"); msg != "" { // COPY-REVIEW C6.7 |
| 2855 | return attachResult{}, &doorRefusal{Status: status, Msg: msg} |
| 2856 | } |
| 2857 | ``` |
| 2858 | |
| 2859 | `handleAPIEmailDomainEnsure` (apimail.go:994), `handleAPIEmailEnable` (:1239), `handleAPIEmailCredentialsCreate` (:1374), each directly after `apiAccountApp`, before `mailWriteGates`: |
| 2860 | |
| 2861 | ```go |
| 2862 | if s.refuseTemporary(w, acct, app, "give it email") { // COPY-REVIEW C6.8 |
| 2863 | return |
| 2864 | } |
| 2865 | ``` |
| 2866 | |
| 2867 | `handleProduction` (server.go:4041), directly after the production value is parsed (after `default: back("err", "Invalid production value.")`): |
| 2868 | |
| 2869 | ```go |
| 2870 | if on { |
| 2871 | defer s.lockApp(acct.Sqid, app)() |
| 2872 | if msg, _ := s.temporaryRefusal(acct, app, "mark it as production"); msg != "" { // COPY-REVIEW C6.9 |
| 2873 | back("err", msg) |
| 2874 | return |
| 2875 | } |
| 2876 | } |
| 2877 | ``` |
| 2878 | |
| 2879 | `handlePromote`'s `enable_bake` (server.go:3819-3838): the `default:` arm becomes |
| 2880 | |
| 2881 | ```go |
| 2882 | default: |
| 2883 | if tmsg, _ := s.temporaryRefusal(acct, app, "turn its safety delay on"); tmsg != "" { // COPY-REVIEW C6.10 |
| 2884 | msg += " " + tmsg |
| 2885 | break |
| 2886 | } |
| 2887 | if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, true, addr); err != nil { |
| 2888 | // ... unchanged |
| 2889 | ``` |
| 2890 | |
| 2891 | `ensureProductionForBakes` (apipipeline.go), after `if !bakes { return }` (line 249-251): |
| 2892 | |
| 2893 | ```go |
| 2894 | // A temporary app is never flagged production (spec §4): the flag would |
| 2895 | // take it out of automatic destruction's reach. Its pipeline bakes on |
| 2896 | // boxes new enough to read the pipeline; the old-binary fallback is |
| 2897 | // skipped, and said so in the log. |
| 2898 | if life, err := s.store.AppLife(acct.ID, app); err != nil || (life != nil && life.Temporary()) { |
| 2899 | log.Printf("console: pipeline %s/%s: not setting production for the bake fallback (temporary app, or unreadable row: %v)", acct.Sqid, app, err) |
| 2900 | return |
| 2901 | } |
| 2902 | ``` |
| 2903 | |
| 2904 | `handleAppRename` (appsettings.go), directly after the control-plane refusal (lines 750-753): |
| 2905 | |
| 2906 | ```go |
| 2907 | defer s.lockApp(acct.Sqid, app)() |
| 2908 | if msg, _ := s.temporaryRefusal(acct, app, "rename it"); msg != "" { // COPY-REVIEW C6.11 |
| 2909 | back("err", msg) |
| 2910 | return |
| 2911 | } |
| 2912 | ``` |
| 2913 | |
| 2914 | and `renameAdvice` gains: |
| 2915 | |
| 2916 | ```go |
| 2917 | case errors.Is(err, ErrTemporaryApp): |
| 2918 | return "This app is temporary, so it cannot be renamed. Make it permanent first with carlos apps keep." // COPY-REVIEW C6.14 |
| 2919 | ``` |
| 2920 | |
| 2921 | `handleAppMoveAccount` (appsettings.go), directly after the control-plane refusal (lines 1266-1271): |
| 2922 | |
| 2923 | ```go |
| 2924 | defer s.lockApp(acct.Sqid, app)() |
| 2925 | if msg, _ := s.temporaryRefusal(acct, app, "move it to another account"); msg != "" { // COPY-REVIEW C6.12 |
| 2926 | back("err", msg) |
| 2927 | return |
| 2928 | } |
| 2929 | ``` |
| 2930 | |
| 2931 | and `moveAdvice` (line 1364) gains a first check: |
| 2932 | |
| 2933 | ```go |
| 2934 | if errors.Is(err, ErrTemporaryApp) { |
| 2935 | return "This app is temporary, so it cannot move. Make it permanent first with carlos apps keep." // COPY-REVIEW C6.15 |
| 2936 | } |
| 2937 | ``` |
| 2938 | |
| 2939 | `handleAppControlPlaneSet` (controlplane.go), after the owner check and `back` definition: |
| 2940 | |
| 2941 | ```go |
| 2942 | defer s.lockApp(acct.Sqid, app)() |
| 2943 | if msg, _ := s.temporaryRefusal(acct, app, "mark it as the control plane"); msg != "" { // COPY-REVIEW C6.13 |
| 2944 | back("err", msg) |
| 2945 | return |
| 2946 | } |
| 2947 | ``` |
| 2948 | |
| 2949 | and its `SetAppControlPlane` failure branch maps `ErrTemporaryApp` to the same `temporaryRefusal` message (a keep cannot race it: under the lock, nothing makes a permanent app temporary). |
| 2950 | |
| 2951 | Store operations (`internal/console/store.go`): |
| 2952 | |
| 2953 | `renameAppIn` (Task 3): the source lookup reads `expires_at` too: |
| 2954 | |
| 2955 | ```go |
| 2956 | var createdAt, expiresAt int64 |
| 2957 | err = tx.QueryRow(`SELECT created_at, expires_at FROM apps WHERE account_id = ? AND app = ? AND deleted_at = 0`, |
| 2958 | accountID, from).Scan(&createdAt, &expiresAt) |
| 2959 | if err == sql.ErrNoRows { |
| 2960 | return fmt.Errorf("console: this account does not claim %q", from) |
| 2961 | } else if err != nil { |
| 2962 | return err |
| 2963 | } |
| 2964 | if expiresAt > 0 { |
| 2965 | return ErrTemporaryApp |
| 2966 | } |
| 2967 | ``` |
| 2968 | |
| 2969 | `MoveApp`: after the `atSrc == 0` check: |
| 2970 | |
| 2971 | ```go |
| 2972 | var srcExpires int64 |
| 2973 | if err := tx.QueryRow(`SELECT expires_at FROM apps WHERE app = ? AND account_id = ?`, app, from).Scan(&srcExpires); err != nil { |
| 2974 | return err |
| 2975 | } |
| 2976 | if srcExpires > 0 { |
| 2977 | return ErrTemporaryApp |
| 2978 | } |
| 2979 | ``` |
| 2980 | |
| 2981 | (`console migrate-app` in cmd/console/main.go:225 reaches this and prints the error.) |
| 2982 | |
| 2983 | `SetAppControlPlane` (store.go:1424): |
| 2984 | |
| 2985 | ```go |
| 2986 | // SetAppControlPlane marks (or unmarks) an app as the control plane. Marking |
| 2987 | // refuses a temporary row (ErrTemporaryApp): the flag refuses destruction, |
| 2988 | // and a temporary app exists to be destroyed (temporary static sites §4). |
| 2989 | func (s *Store) SetAppControlPlane(accountID int64, app string, on bool) error { |
| 2990 | if !on { |
| 2991 | _, err := s.db.Exec(`UPDATE apps SET control_plane = 0 WHERE account_id = ? AND app = ?`, accountID, app) |
| 2992 | return err |
| 2993 | } |
| 2994 | res, err := s.db.Exec(`UPDATE apps SET control_plane = 1 WHERE account_id = ? AND app = ? AND expires_at = 0`, accountID, app) |
| 2995 | if err != nil { |
| 2996 | return err |
| 2997 | } |
| 2998 | if n, _ := res.RowsAffected(); n == 0 { |
| 2999 | if l, err := s.AppLife(accountID, app); err == nil && l != nil && l.Temporary() { |
| 3000 | return ErrTemporaryApp |
| 3001 | } |
| 3002 | } |
| 3003 | return nil |
| 3004 | } |
| 3005 | ``` |
| 3006 | |
| 3007 | - [ ] **Step 4: Run the tests to verify they pass** |
| 3008 | |
| 3009 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryRefusals|TestTemporaryInstanceCreateIsStaticAtThePlatformHost|TestInstanceStoreProvisionRefusesATemporaryApp|TestAPIShip|TestAPIInstance|TestAPIStore|TestAPIDomain|TestAPIEmail|TestAppRename|TestAppMove|TestControlPlane|TestProduction|TestPipeline' -count=1` |
| 3010 | Expected: PASS. If a browser door's page renders the refusal without following a redirect (a 4xx page), `flashAfter` already returns the body. |
| 3011 | |
| 3012 | - [ ] **Step 5: Commit** |
| 3013 | |
| 3014 | ```bash |
| 3015 | gofmt -l internal/console |
| 3016 | git add internal/console/temprefusal.go internal/console/temprefusal_test.go internal/console/apicli.go internal/console/apiinstances.go internal/console/instanceprivatestore.go internal/console/apistore.go internal/console/apistore_provision.go internal/console/apidomains.go internal/console/domainattach.go internal/console/apimail.go internal/console/server.go internal/console/apipipeline.go internal/console/appsettings.go internal/console/controlplane.go internal/console/store.go |
| 3017 | git commit -m "console: temporary apps refuse every door that would outlive their expiry |
| 3018 | |
| 3019 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 3020 | ``` |
| 3021 | |
| 3022 | --- |
| 3023 | |
| 3024 | ### Task 7: `restoreApp`, and destructive doors that lock, settle and re-check |
| 3025 | |
| 3026 | **Files:** |
| 3027 | - Create: `internal/console/restore.go` |
| 3028 | - Modify: `internal/console/apicli.go:1196-1296` (`handleAPITrashApp`), `:1313-1356` (`handleAPIRestoreApp`) |
| 3029 | - Modify: `internal/console/ownership.go:226-297` (`handleRestoreApp`), `:313-480` (`handlePurgeApp`: lock, settle, re-check after `requireFreshAuth` at :340 and before `PurgeReady` at :357) |
| 3030 | - Modify: `internal/console/appsettings.go:972-1060` (`handleAppDelete`: lock, settle, re-check after `ctx` at :1008, before `GetProduction` at :1013) |
| 3031 | - Test: `internal/console/restore_test.go` (new), `internal/console/temphook_test.go` (new) |
| 3032 | |
| 3033 | **Interfaces:** |
| 3034 | - Consumes: `lockAccount`, `lockApp`, `settleApp`, `doorEvidence` (Task 4); `temporaryCapCheck`, `temporaryCapError` (Task 5); `Store.AppLife`, `BumpTrashedExpiry`, `SetRestoring`, `FinishRestoreRow` (Task 2); `release.RestoreApp`. |
| 3035 | - Produces: |
| 3036 | - `var errNotInTrash error` |
| 3037 | - `type restoreFailure struct { Marker bool; Err error }` (`Marker` true: the bucket half failed and nothing was restored) |
| 3038 | - `func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now time.Time) error` (returns nil, `errNotInTrash`, `*temporaryCapError` or `*restoreFailure`) |
| 3039 | - `type lockedRefusalKind int` (`lockedUnreadable`, `lockedGone`, `lockedWrongState`, `lockedControlPlane`), `type lockedRefusal struct { Kind lockedRefusalKind; Err error; Msg string; Status int }` and `func (s *Server) recheckUnderLock(ctx context.Context, acct *Account, app, verb string, purge bool, now time.Time) (*AppLife, *lockedRefusal)`: the settle-and-re-validate every destructive door runs after `lockApp`; each door maps the kind onto its own existing wording |
| 3040 | - `func restoredTemporaryNote(app string) string`: the restore note and flash for a `tmp-` app |
| 3041 | - test double `hookStore` (temphook_test.go, with `failPut`, `failDelete`, `onDelete`, `onGet`), used by Tasks 7 and 8; test helper `waitForWaiter(t, l *keyedLocks, key string)` |
| 3042 | |
| 3043 | - [ ] **Step 1: Write the failing tests** |
| 3044 | |
| 3045 | ```go |
| 3046 | // internal/console/temphook_test.go |
| 3047 | package console |
| 3048 | |
| 3049 | import ( |
| 3050 | "context" |
| 3051 | "errors" |
| 3052 | "sync" |
| 3053 | |
| 3054 | "github.com/carlosframework/platform/internal/release" |
| 3055 | ) |
| 3056 | |
| 3057 | // hookStore is the recording store the lifecycle tests drive failures |
| 3058 | // through. failPut and failDelete map a key to how many more times it fails |
| 3059 | // (a negative count fails forever); onDelete runs before each Delete, which is |
| 3060 | // how a test observes the row at the moment a marker is removed; onGet runs |
| 3061 | // before each Get. |
| 3062 | type hookStore struct { |
| 3063 | release.Store |
| 3064 | mu sync.Mutex |
| 3065 | failPut map[string]int |
| 3066 | failDelete map[string]int |
| 3067 | onDelete func(key string) |
| 3068 | // onGet runs before each Get: how a test changes the bucket between two |
| 3069 | // reads of one request (Task 8's ErrNotTrashed test). |
| 3070 | onGet func(key string) |
| 3071 | deletes []string |
| 3072 | } |
| 3073 | |
| 3074 | func (h *hookStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 3075 | if h.onGet != nil { |
| 3076 | h.onGet(key) |
| 3077 | } |
| 3078 | return h.Store.Get(ctx, key) |
| 3079 | } |
| 3080 | |
| 3081 | func (h *hookStore) fails(m map[string]int, key string) bool { |
| 3082 | h.mu.Lock() |
| 3083 | defer h.mu.Unlock() |
| 3084 | n, ok := m[key] |
| 3085 | if !ok || n == 0 { |
| 3086 | return false |
| 3087 | } |
| 3088 | if n > 0 { |
| 3089 | m[key] = n - 1 |
| 3090 | } |
| 3091 | return true |
| 3092 | } |
| 3093 | |
| 3094 | func (h *hookStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 3095 | if h.fails(h.failPut, key) { |
| 3096 | return "", errors.New("hookStore: put refused") |
| 3097 | } |
| 3098 | return h.Store.Put(ctx, key, body, ifMatch) |
| 3099 | } |
| 3100 | |
| 3101 | func (h *hookStore) Delete(ctx context.Context, key string) error { |
| 3102 | if h.onDelete != nil { |
| 3103 | h.onDelete(key) |
| 3104 | } |
| 3105 | if h.fails(h.failDelete, key) { |
| 3106 | return errors.New("hookStore: delete refused") |
| 3107 | } |
| 3108 | h.mu.Lock() |
| 3109 | h.deletes = append(h.deletes, key) |
| 3110 | h.mu.Unlock() |
| 3111 | return h.Store.Delete(ctx, key) |
| 3112 | } |
| 3113 | ``` |
| 3114 | |
| 3115 | ```go |
| 3116 | // internal/console/restore_test.go |
| 3117 | package console |
| 3118 | |
| 3119 | import ( |
| 3120 | "context" |
| 3121 | "errors" |
| 3122 | "fmt" |
| 3123 | "net/http" |
| 3124 | "net/url" |
| 3125 | "strings" |
| 3126 | "testing" |
| 3127 | "time" |
| 3128 | |
| 3129 | "github.com/carlosframework/platform/internal/provision" |
| 3130 | "github.com/carlosframework/platform/internal/release" |
| 3131 | ) |
| 3132 | |
| 3133 | // trashedTemp seeds tmp-a, expired, in the Trash with its 3-day window, the |
| 3134 | // state the expiry pass leaves. |
| 3135 | func trashedTemp(t *testing.T, fx *consoleFixture, app string, at time.Time) { |
| 3136 | t.Helper() |
| 3137 | tempApp(t, fx, app, at) |
| 3138 | if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, app, "expiry", at); err != nil { |
| 3139 | t.Fatal(err) |
| 3140 | } |
| 3141 | if ok, err := fx.st.ExpireAppRow(OpsAccountID, app, at); err != nil || !ok { |
| 3142 | t.Fatal(ok, err) |
| 3143 | } |
| 3144 | } |
| 3145 | |
| 3146 | // Spec §6 and §9: rewrites expiry before deleting the tombstone, and clears |
| 3147 | // the deadline only after. The order is asserted at the moment of the delete. |
| 3148 | func TestRestoreRewritesExpiryBeforeTheMarkerGoes(t *testing.T) { |
| 3149 | fx := newConsoleFixture(t) |
| 3150 | now := time.Now().Truncate(time.Second) |
| 3151 | trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour)) |
| 3152 | h := &hookStore{Store: fx.rel} |
| 3153 | marker := release.AppTombstoneKey(fx.sqid, "tmp-a") |
| 3154 | var atDelete *AppLife |
| 3155 | h.onDelete = func(key string) { |
| 3156 | if key == marker { |
| 3157 | atDelete = lifeOf(t, fx, "tmp-a") |
| 3158 | } |
| 3159 | } |
| 3160 | fx.srv.cfg.Releases = h |
| 3161 | if err := fx.srv.restoreApp(context.Background(), opsAccount(t, fx), "tmp-a", now); err != nil { |
| 3162 | t.Fatal(err) |
| 3163 | } |
| 3164 | if atDelete == nil { |
| 3165 | t.Fatal("the marker was never deleted") |
| 3166 | } |
| 3167 | if atDelete.ExpiresAt != now.Add(temporaryTTLDefault).Unix() || !atDelete.Restoring || atDelete.PurgeAfter == 0 { |
| 3168 | t.Fatalf("row at the marker delete = %+v, want expiry now+7d, restoring, deadline still set", atDelete) |
| 3169 | } |
| 3170 | if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.Restoring || l.PurgeAfter != 0 || l.PurgeFor != 0 { |
| 3171 | t.Fatalf("row after = %+v, want live, no deadline", l) |
| 3172 | } |
| 3173 | } |
| 3174 | |
| 3175 | // Spec §9: a failed tombstone delete keeps the deadline and releases restoring. |
| 3176 | func TestRestoreWhoseMarkerDeleteFailsKeepsTheDeadline(t *testing.T) { |
| 3177 | fx := newConsoleFixture(t) |
| 3178 | now := time.Now().Truncate(time.Second) |
| 3179 | trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour)) |
| 3180 | before := *lifeOf(t, fx, "tmp-a") |
| 3181 | fx.srv.cfg.Releases = &hookStore{Store: fx.rel, failDelete: map[string]int{release.AppTombstoneKey(fx.sqid, "tmp-a"): 1}} |
| 3182 | err := fx.srv.restoreApp(context.Background(), opsAccount(t, fx), "tmp-a", now) |
| 3183 | var rf *restoreFailure |
| 3184 | if !errors.As(err, &rf) || !rf.Marker { |
| 3185 | t.Fatalf("restoreApp = %v, want a marker-half restoreFailure", err) |
| 3186 | } |
| 3187 | l := lifeOf(t, fx, "tmp-a") |
| 3188 | if l.Restoring || !l.Trashed() || l.PurgeAfter != before.PurgeAfter || l.PurgeFor != before.PurgeFor { |
| 3189 | t.Fatalf("row = %+v, want trashed with the same deadline and restoring released", l) |
| 3190 | } |
| 3191 | } |
| 3192 | |
| 3193 | // Spec §9: a failed step 5 holds the cap slot until settled. |
| 3194 | func TestRestoreThatStopsBeforeStepFiveHoldsItsSlot(t *testing.T) { |
| 3195 | fx := newConsoleFixture(t) |
| 3196 | now := time.Now().Truncate(time.Second) |
| 3197 | for i := 0; i < temporaryAppCap-1; i++ { |
| 3198 | tempApp(t, fx, fmt.Sprintf("tmp-live%02d", i), now.Add(time.Hour)) |
| 3199 | } |
| 3200 | trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour)) |
| 3201 | // Steps 3 and 4 done, step 5 never ran. |
| 3202 | fx.st.SetRestoring(OpsAccountID, "tmp-a", true) |
| 3203 | release.RestoreApp(context.Background(), fx.rel, fx.sqid, "tmp-a") |
| 3204 | if err := fx.srv.temporaryCapCheck(OpsAccountID); err == nil { |
| 3205 | t.Fatal("the half-restored app's slot was free for a new app to take") |
| 3206 | } |
| 3207 | if err := fx.srv.settleApp(context.Background(), OpsAccountID, fx.sqid, "tmp-a", now, always(true)); err != nil { |
| 3208 | t.Fatal(err) |
| 3209 | } |
| 3210 | if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.Restoring { |
| 3211 | t.Fatalf("settled row = %+v, want live", l) |
| 3212 | } |
| 3213 | } |
| 3214 | |
| 3215 | // Spec §9: the cap is enforced on every console restore door. |
| 3216 | func TestRestoreDoorsEnforceTheCap(t *testing.T) { |
| 3217 | fx := newConsoleFixture(t) |
| 3218 | now := time.Now().Truncate(time.Second) |
| 3219 | for i := 0; i < temporaryAppCap; i++ { |
| 3220 | tempApp(t, fx, fmt.Sprintf("tmp-live%02d", i), now.Add(time.Hour)) |
| 3221 | } |
| 3222 | trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour)) |
| 3223 | token := cliToken(t, fx, "paul@keymail.dev", "code-restore-cap") |
| 3224 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/tmp-a/restore", map[string]string{}, token) |
| 3225 | if status != http.StatusConflict || out["code"] != "cap" { |
| 3226 | t.Fatalf("API restore over the cap = %d %v, want 409 cap", status, out) |
| 3227 | } |
| 3228 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-restore-cap-web") |
| 3229 | st, loc, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"tmp-a"}}) |
| 3230 | if _, _, page := get(t, c, fx.ts.URL+loc); st != http.StatusSeeOther || !strings.Contains(page, "tmp-live00") { |
| 3231 | t.Fatalf("browser restore over the cap = %d, want the refusal listing the live apps", st) |
| 3232 | } |
| 3233 | if markerOf(t, fx, "tmp-a") == nil { |
| 3234 | t.Fatal("a refused restore removed the marker") |
| 3235 | } |
| 3236 | } |
| 3237 | |
| 3238 | // Spec §5 "Trash by hand": the doors set the window for a temporary row only. |
| 3239 | func TestTrashDoorsSetTheWindowForTemporaryRows(t *testing.T) { |
| 3240 | fx := newConsoleFixture(t) |
| 3241 | tempApp(t, fx, "tmp-a", time.Now().Add(30*24*time.Hour)) |
| 3242 | token := cliToken(t, fx, "paul@keymail.dev", "code-trash-window") |
| 3243 | for _, app := range []string{"tmp-a", "kass"} { |
| 3244 | if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/"+app+"/delete", map[string]string{}, token); status != http.StatusOK { |
| 3245 | t.Fatalf("delete %s = %d %v", app, status, out) |
| 3246 | } |
| 3247 | } |
| 3248 | tmp, kass := lifeOf(t, fx, "tmp-a"), lifeOf(t, fx, "kass") |
| 3249 | if tmp.PurgeFor != tmp.DeletedAt || tmp.PurgeAfter != tmp.DeletedAt+72*3600 { |
| 3250 | t.Fatalf("temporary trash = %+v, want the 3-day window", tmp) |
| 3251 | } |
| 3252 | if kass.PurgeAfter != 0 || kass.PurgeFor != 0 { |
| 3253 | t.Fatalf("permanent trash = %+v, want none", kass) |
| 3254 | } |
| 3255 | } |
| 3256 | ``` |
| 3257 | |
| 3258 | Append to `internal/console/restore_test.go` (the helpers `postGated` (stepup_test.go:56), `postRaw` (apicli_test.go:67) and `appKeys` (deletion_test.go:179) already exist): |
| 3259 | |
| 3260 | ```go |
| 3261 | // waitForWaiter returns once a second holder is queued on key: the request |
| 3262 | // under test has passed every check it makes before the lock and is blocked |
| 3263 | // on it. Deterministic: it waits for the queue, not for a clock. |
| 3264 | func waitForWaiter(t *testing.T, l *keyedLocks, key string) { |
| 3265 | t.Helper() |
| 3266 | deadline := time.Now().Add(10 * time.Second) |
| 3267 | for time.Now().Before(deadline) { |
| 3268 | l.mu.Lock() |
| 3269 | n := 0 |
| 3270 | if e := l.m[key]; e != nil { |
| 3271 | n = e.refs |
| 3272 | } |
| 3273 | l.mu.Unlock() |
| 3274 | if n >= 2 { |
| 3275 | return |
| 3276 | } |
| 3277 | time.Sleep(time.Millisecond) |
| 3278 | } |
| 3279 | t.Fatalf("no request queued on %s", key) |
| 3280 | } |
| 3281 | |
| 3282 | // Astra plan R1 finding 1: a trash door wrote the marker and failed its row |
| 3283 | // update, so the row is live. The manual purge must settle first: without it |
| 3284 | // PurgeApp sweeps the objects and PurgeAppRow (guarded deleted_at != 0) |
| 3285 | // silently keeps a live row that nothing ever repairs. |
| 3286 | func TestManualPurgeAfterAFailedTrashRowUpdate(t *testing.T) { |
| 3287 | fx := newConsoleFixture(t) |
| 3288 | if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "kass", "paul@keymail.dev", time.Now()); err != nil { |
| 3289 | t.Fatal(err) |
| 3290 | } |
| 3291 | if l := lifeOf(t, fx, "kass"); l.Trashed() { |
| 3292 | t.Fatal("setup: the row must still be live") |
| 3293 | } |
| 3294 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-purge-unsettled") |
| 3295 | postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 3296 | if keys := appKeys(t, fx, "kass"); len(keys) != 0 { |
| 3297 | t.Fatalf("purge left objects: %v", keys) |
| 3298 | } |
| 3299 | if l := lifeOf(t, fx, "kass"); l != nil { |
| 3300 | t.Fatalf("row = %+v after a purge, want it gone with the objects", l) |
| 3301 | } |
| 3302 | } |
| 3303 | |
| 3304 | // Astra plan round 4: restoring a provisioned temporary static app must not |
| 3305 | // send anyone to carlos add, which refuses every tmp- name. Its instance |
| 3306 | // record survives trash and restore, and with the marker gone provision's |
| 3307 | // reconcile mints the route again (box side: TestReconcileDeclinesTrashedApp, |
| 3308 | // run in Step 4). Both restore doors are checked. |
| 3309 | func TestRestoreOfAProvisionedTemporaryStaticApp(t *testing.T) { |
| 3310 | fx := newConsoleFixture(t) |
| 3311 | fx.srv.cfg.AppsDomain = "carlosapp.test" |
| 3312 | tempApp(t, fx, "tmp-site", time.Now().Add(24*time.Hour)) |
| 3313 | token := cliToken(t, fx, "paul@keymail.dev", "code-restore-provisioned") |
| 3314 | base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site" |
| 3315 | if st, out := putJSON(t, base+"/instances-manifest", map[string]any{}, token); st != http.StatusOK { |
| 3316 | t.Fatalf("enable = %d %v", st, out) |
| 3317 | } |
| 3318 | apex := "tmp-site." + fx.sqid + ".carlosapp.test" |
| 3319 | if st, _, out := rawReq(t, http.MethodPost, base+"/instances", token, `{"host":"`+apex+`","kind":"static","channel":"edge"}`); st != http.StatusCreated { |
| 3320 | t.Fatalf("instance = %d %s", st, out) |
| 3321 | } |
| 3322 | recordKept := func(when string) { |
| 3323 | t.Helper() |
| 3324 | rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "tmp-site", apex) |
| 3325 | if err != nil || rec == nil || rec.Kind != "static" || rec.Host != apex { |
| 3326 | t.Fatalf("%s: instance record = %+v %v, want the static record at %s kept", when, rec, err, apex) |
| 3327 | } |
| 3328 | } |
| 3329 | |
| 3330 | // API: delete, then restore. |
| 3331 | if st, out := postJSON(t, base+"/delete", map[string]string{}, token); st != http.StatusOK { |
| 3332 | t.Fatalf("delete = %d %v", st, out) |
| 3333 | } |
| 3334 | recordKept("after trash") |
| 3335 | st, out := postJSON(t, base+"/restore", map[string]string{}, token) |
| 3336 | if st != http.StatusOK { |
| 3337 | t.Fatalf("restore = %d %v", st, out) |
| 3338 | } |
| 3339 | if note := str(t, out, "note"); strings.Contains(note, "carlos add") || !strings.Contains(note, "tmp-site") { |
| 3340 | t.Fatalf("restore note = %q, want the instance-record guidance, never carlos add", note) |
| 3341 | } |
| 3342 | recordKept("after API restore") |
| 3343 | if markerOf(t, fx, "tmp-site") != nil || lifeOf(t, fx, "tmp-site").Trashed() { |
| 3344 | t.Fatal("after restore the marker must be gone and the row live, so reconcile mints the route again") |
| 3345 | } |
| 3346 | |
| 3347 | // Browser: delete through the API again, restore from the Trash page. |
| 3348 | if st, out := postJSON(t, base+"/delete", map[string]string{}, token); st != http.StatusOK { |
| 3349 | t.Fatalf("second delete = %d %v", st, out) |
| 3350 | } |
| 3351 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-restore-provisioned-web") |
| 3352 | st, loc, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"tmp-site"}}) |
| 3353 | if st != http.StatusSeeOther { |
| 3354 | t.Fatalf("browser restore = %d", st) |
| 3355 | } |
| 3356 | if _, _, page := get(t, c, fx.ts.URL+loc); strings.Contains(page, "carlos add") || !strings.Contains(page, "tmp-site") { |
| 3357 | t.Fatal("the browser restore flash sends a temporary app to carlos add, or does not name it") |
| 3358 | } |
| 3359 | recordKept("after browser restore") |
| 3360 | } |
| 3361 | |
| 3362 | // Astra plan round 2: settling under the lock must not turn a trash retry into |
| 3363 | // a refusal. A marker already in the bucket (a headless trash) and a live row: |
| 3364 | // the API trash settles the row onto the marker's clock and then answers 200 |
| 3365 | // through its ordinary path, with the marker untouched, the existing deadline, |
| 3366 | // and a serial bump. |
| 3367 | func TestTrashAfterAHeadlessTrashIsIdempotent(t *testing.T) { |
| 3368 | fx := newConsoleFixture(t) |
| 3369 | ctx := context.Background() |
| 3370 | old := time.Now().Add(-5 * 24 * time.Hour).Truncate(time.Second) |
| 3371 | if err := release.TrashApp(ctx, fx.rel, fx.sqid, "kass", "headless", old); err != nil { |
| 3372 | t.Fatal(err) |
| 3373 | } |
| 3374 | serialBefore, _, _ := fx.rel.Get(ctx, release.SerialKey) |
| 3375 | token := cliToken(t, fx, "paul@keymail.dev", "code-trash-idempotent") |
| 3376 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token) |
| 3377 | if status != http.StatusOK { |
| 3378 | t.Fatalf("delete after a headless trash = %d %v, want 200", status, out) |
| 3379 | } |
| 3380 | until, err := time.Parse(time.RFC3339, str(t, out, "restorable_until")) |
| 3381 | if err != nil || !until.Equal(old.Add(release.TrashCooloff)) { |
| 3382 | t.Fatalf("restorable_until = %v %v, want the headless marker's %v", until, err, old.Add(release.TrashCooloff)) |
| 3383 | } |
| 3384 | if tomb := markerOf(t, fx, "kass"); tomb == nil || !tomb.DeletedAt.Equal(old) || tomb.Actor != "headless" { |
| 3385 | t.Fatalf("marker = %+v, want the headless one, untouched", tomb) |
| 3386 | } |
| 3387 | if serialAfter, _, _ := fx.rel.Get(ctx, release.SerialKey); string(serialAfter) == string(serialBefore) { |
| 3388 | t.Fatal("the idempotent success skipped the serial bump") |
| 3389 | } |
| 3390 | } |
| 3391 | |
| 3392 | // Astra plan R1 finding 2 (spec §2: a check made before the lock is a hint). |
| 3393 | // Each case holds the app's lock, lets the destructive request queue on it, |
| 3394 | // changes a precondition, and releases. The door must refuse on what is true |
| 3395 | // once it holds the lock. |
| 3396 | func TestDestructiveDoorsRecheckAfterTheLock(t *testing.T) { |
| 3397 | run := func(t *testing.T, fx *consoleFixture, app string, request func() int, change func()) int { |
| 3398 | t.Helper() |
| 3399 | unlock := fx.srv.lockApp(fx.sqid, app) |
| 3400 | done := make(chan int, 1) |
| 3401 | go func() { done <- request() }() |
| 3402 | waitForWaiter(t, &fx.srv.appLocks, fx.sqid+"/"+app) |
| 3403 | change() |
| 3404 | unlock() |
| 3405 | return <-done |
| 3406 | } |
| 3407 | |
| 3408 | t.Run("API trash: marked control plane while waiting", func(t *testing.T) { |
| 3409 | fx := newConsoleFixture(t) |
| 3410 | token := cliToken(t, fx, "paul@keymail.dev", "code-recheck-api-cp") |
| 3411 | status := run(t, fx, "kass", func() int { |
| 3412 | st, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token) |
| 3413 | return st |
| 3414 | }, func() { |
| 3415 | if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil { |
| 3416 | t.Error(err) |
| 3417 | } |
| 3418 | }) |
| 3419 | if status != http.StatusForbidden || markerOf(t, fx, "kass") != nil || lifeOf(t, fx, "kass").Trashed() { |
| 3420 | t.Fatalf("status %d: the control plane was trashed by a request that checked the flag before waiting", status) |
| 3421 | } |
| 3422 | }) |
| 3423 | |
| 3424 | t.Run("API trash: row purged while waiting", func(t *testing.T) { |
| 3425 | fx := newConsoleFixture(t) |
| 3426 | token := cliToken(t, fx, "paul@keymail.dev", "code-recheck-api-gone") |
| 3427 | status := run(t, fx, "kass", func() int { |
| 3428 | st, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token) |
| 3429 | return st |
| 3430 | }, func() { |
| 3431 | fx.st.TrashAppRow(OpsAccountID, "kass", time.Now()) |
| 3432 | fx.st.PurgeAppRow(OpsAccountID, "kass") |
| 3433 | }) |
| 3434 | if status != http.StatusNotFound || markerOf(t, fx, "kass") != nil { |
| 3435 | t.Fatalf("status %d: a delayed trash wrote a marker for a row that no longer exists", status) |
| 3436 | } |
| 3437 | }) |
| 3438 | |
| 3439 | t.Run("browser delete: marked control plane while waiting", func(t *testing.T) { |
| 3440 | fx := newConsoleFixture(t) |
| 3441 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-recheck-web-cp") |
| 3442 | run(t, fx, "kass", func() int { |
| 3443 | st, _, _ := postGated(t, fx, c, fx.u("/apps/kass/delete"), url.Values{"csrf": {csrf}, "confirm": {"kass"}}) |
| 3444 | return st |
| 3445 | }, func() { |
| 3446 | if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil { |
| 3447 | t.Error(err) |
| 3448 | } |
| 3449 | }) |
| 3450 | if markerOf(t, fx, "kass") != nil || lifeOf(t, fx, "kass").Trashed() { |
| 3451 | t.Fatal("the browser delete trashed the control plane after waiting on the lock") |
| 3452 | } |
| 3453 | }) |
| 3454 | |
| 3455 | t.Run("purge door: marked control plane while waiting", func(t *testing.T) { |
| 3456 | fx := newConsoleFixture(t) |
| 3457 | now := time.Now() |
| 3458 | if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "kass", "paul@keymail.dev", now); err != nil { |
| 3459 | t.Fatal(err) |
| 3460 | } |
| 3461 | fx.st.TrashAppRow(OpsAccountID, "kass", now) |
| 3462 | before := len(appKeys(t, fx, "kass")) |
| 3463 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-recheck-purge-cp") |
| 3464 | run(t, fx, "kass", func() int { |
| 3465 | st, _, _ := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 3466 | return st |
| 3467 | }, func() { |
| 3468 | if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil { |
| 3469 | t.Error(err) |
| 3470 | } |
| 3471 | }) |
| 3472 | if got := len(appKeys(t, fx, "kass")); got != before || lifeOf(t, fx, "kass") == nil { |
| 3473 | t.Fatalf("the purge door destroyed the control plane after waiting on the lock (%d of %d keys left)", got, before) |
| 3474 | } |
| 3475 | }) |
| 3476 | } |
| 3477 | ``` |
| 3478 | |
| 3479 | - [ ] **Step 2: Run the tests to verify they fail** |
| 3480 | |
| 3481 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestRestore|TestTrashDoorsSetTheWindowForTemporaryRows|TestManualPurgeAfterAFailedTrashRowUpdate|TestDestructiveDoorsRecheckAfterTheLock|TestTrashAfterAHeadlessTrashIsIdempotent' -count=1` |
| 3482 | Expected: FAIL to compile: `fx.srv.restoreApp undefined`, `undefined: restoreFailure`. (`TestTrashAfterAHeadlessTrashIsIdempotent` passes before and after; it pins that the re-check keeps the idempotent path.) Once those compile, `TestManualPurgeAfterAFailedTrashRowUpdate` fails with the row still live after the purge, and every `TestDestructiveDoorsRecheckAfterTheLock` case fails: the doors have no lock to queue on (`no request queued`) or act on the pre-lock answer. (`TestTrashDoorsSetTheWindowForTemporaryRows` already passes through Task 2's `TrashAppRow`; it stays as the door-level pin.) |
| 3483 | |
| 3484 | - [ ] **Step 3: Implement** |
| 3485 | |
| 3486 | Create `internal/console/restore.go`: |
| 3487 | |
| 3488 | ```go |
| 3489 | package console |
| 3490 | |
| 3491 | import ( |
| 3492 | "context" |
| 3493 | "errors" |
| 3494 | "log" |
| 3495 | "time" |
| 3496 | |
| 3497 | "github.com/carlosframework/platform/internal/release" |
| 3498 | ) |
| 3499 | |
| 3500 | // errNotInTrash: after settling, the app is not in the Trash (restored or |
| 3501 | // purged elsewhere since the door looked). |
| 3502 | var errNotInTrash = errors.New("console: the app is not in the trash") |
| 3503 | |
| 3504 | // restoreFailure says which half of a restore failed. Marker true means the |
| 3505 | // bucket half (or anything before it) failed and nothing was restored; false |
| 3506 | // means the marker is gone and only this console's row did not follow, which |
| 3507 | // the next settle repairs. |
| 3508 | type restoreFailure struct { |
| 3509 | Marker bool |
| 3510 | Err error |
| 3511 | } |
| 3512 | |
| 3513 | func (e *restoreFailure) Error() string { return e.Err.Error() } |
| 3514 | func (e *restoreFailure) Unwrap() error { return e.Err } |
| 3515 | |
| 3516 | // restoredTemporaryNote is what a restore says about serving a tmp- app again |
| 3517 | // (Astra plan round 4). The usual advice, `carlos add <host>`, cannot help: |
| 3518 | // appRail (cli.go, Task 3) refuses every tmp- name, kept ones included. It |
| 3519 | // does not need to. Trash drops a temporary app's routes but keeps its |
| 3520 | // instance records, restore leaves them alone, and once the marker is gone |
| 3521 | // provision's reconcile mints the routes again from those records |
| 3522 | // (internal/provision/reconcile.go:903, pinned box-side by |
| 3523 | // TestReconcileDeclinesTrashedApp). The serial bump makes that seconds. Keyed |
| 3524 | // on the name, not the row, because appRail is: a kept tmp- app is told the |
| 3525 | // same thing. |
| 3526 | func restoredTemporaryNote(app string) string { |
| 3527 | return app + " is restored with its releases and channels intact. Its instance records were kept, " + |
| 3528 | "so hosts bring its routes back on their next pass." // COPY-REVIEW C7.3 |
| 3529 | } |
| 3530 | |
| 3531 | // restoreApp is the one restore every console door runs (temporary static |
| 3532 | // sites spec §6): the CLI door, the browser door, and nothing else. The |
| 3533 | // expiry pass's catch-up is settleApp's restore branch. For a temporary row: |
| 3534 | // |
| 3535 | // 1. settleApp; cap check counting this app as a new live one |
| 3536 | // 2. expires_at < now+1h: expires_at = now+7d (still trashed, so harmless) |
| 3537 | // 3. restoring = 1: from here the app holds a cap slot |
| 3538 | // 4. release.RestoreApp (delete the marker) |
| 3539 | // 5. one statement: deleted_at = restoring = purge_after = purge_for = 0 |
| 3540 | // |
| 3541 | // A permanent row takes the same path without steps 1's cap check and 2. |
| 3542 | // Locks: account then app, the plan's one lock order. |
| 3543 | func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now time.Time) error { |
| 3544 | if s.cfg.Releases == nil { |
| 3545 | return &restoreFailure{Marker: true, Err: errors.New("console: no deployment bucket on this host")} |
| 3546 | } |
| 3547 | defer s.lockAccount(acct.Sqid)() |
| 3548 | defer s.lockApp(acct.Sqid, app)() |
| 3549 | if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil { |
| 3550 | return &restoreFailure{Marker: true, Err: err} |
| 3551 | } |
| 3552 | life, err := s.store.AppLife(acct.ID, app) |
| 3553 | if err != nil { |
| 3554 | return &restoreFailure{Marker: true, Err: err} |
| 3555 | } |
| 3556 | if life == nil || !life.Trashed() { |
| 3557 | return errNotInTrash |
| 3558 | } |
| 3559 | if life.Temporary() { |
| 3560 | if err := s.temporaryCapCheck(acct.ID); err != nil { |
| 3561 | var capErr *temporaryCapError |
| 3562 | if errors.As(err, &capErr) { |
| 3563 | return capErr |
| 3564 | } |
| 3565 | return &restoreFailure{Marker: true, Err: err} |
| 3566 | } |
| 3567 | if _, err := s.store.BumpTrashedExpiry(acct.ID, app, now); err != nil { |
| 3568 | return &restoreFailure{Marker: true, Err: err} |
| 3569 | } |
| 3570 | } |
| 3571 | if _, err := s.store.SetRestoring(acct.ID, app, true); err != nil { |
| 3572 | return &restoreFailure{Marker: true, Err: err} |
| 3573 | } |
| 3574 | if err := release.RestoreApp(ctx, s.cfg.Releases, acct.Sqid, app); err != nil { |
| 3575 | if _, cerr := s.store.SetRestoring(acct.ID, app, false); cerr != nil { |
| 3576 | log.Printf("console: restore %s/%s: release restoring after a failed marker delete: %v", acct.Sqid, app, cerr) |
| 3577 | } |
| 3578 | return &restoreFailure{Marker: true, Err: err} |
| 3579 | } |
| 3580 | if err := s.store.FinishRestoreRow(acct.ID, app, now); err != nil { |
| 3581 | return &restoreFailure{Marker: false, Err: err} |
| 3582 | } |
| 3583 | return nil |
| 3584 | } |
| 3585 | ``` |
| 3586 | |
| 3587 | `handleAPIRestoreApp` (apicli.go:1313): replace the `release.RestoreApp` call and the `RestoreAppRow` call (lines 1331-1345) with: |
| 3588 | |
| 3589 | ```go |
| 3590 | var capErr *temporaryCapError |
| 3591 | var rf *restoreFailure |
| 3592 | switch err := s.restoreApp(ctx, acct, app, time.Now()); { |
| 3593 | case err == nil: |
| 3594 | case errors.Is(err, errNotInTrash): |
| 3595 | http.NotFound(w, r) |
| 3596 | return |
| 3597 | case errors.As(err, &capErr): |
| 3598 | writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"}) |
| 3599 | return |
| 3600 | case errors.As(err, &rf) && rf.Marker: |
| 3601 | log.Printf("console: api restore %s/%s: %v", acct.Sqid, app, err) |
| 3602 | jsonError(w, http.StatusBadGateway, "could not remove the trash marker — nothing was restored; try again") |
| 3603 | return |
| 3604 | default: |
| 3605 | log.Printf("console: api restore row %s/%s: %v", acct.Sqid, app, err) |
| 3606 | jsonError(w, http.StatusInternalServerError, |
| 3607 | "the trash marker was removed, but this console's own record did not update —"+ |
| 3608 | " it will catch up on the next materialize pass") |
| 3609 | return |
| 3610 | } |
| 3611 | ``` |
| 3612 | |
| 3613 | (The two existing error strings are kept byte for byte: they are not new copy.) |
| 3614 | |
| 3615 | The success response's `note` (apicli.go:1352-1355) stays as it is for every other app, and names the right way back for a `tmp-` one: |
| 3616 | |
| 3617 | ```go |
| 3618 | note := app + " is restored with its releases and channels intact, and with NO routes:" + |
| 3619 | " run `carlos add <host>` to serve it again." |
| 3620 | if release.IsTemporaryName(app) { |
| 3621 | note = restoredTemporaryNote(app) |
| 3622 | } |
| 3623 | writeJSON(w, http.StatusOK, map[string]any{"result": "restored", "note": note}) |
| 3624 | ``` |
| 3625 | |
| 3626 | `handleRestoreApp` (ownership.go:226): replace lines 267-280 (the `release.RestoreApp` and `RestoreAppRow` calls) with: |
| 3627 | |
| 3628 | ```go |
| 3629 | var capErr *temporaryCapError |
| 3630 | var rf *restoreFailure |
| 3631 | switch err := s.restoreApp(r.Context(), acct, app, time.Now()); { |
| 3632 | case err == nil: |
| 3633 | case errors.Is(err, errNotInTrash): |
| 3634 | fail("There is nothing called “" + app + "” in this account's trash.") |
| 3635 | return |
| 3636 | case errors.As(err, &capErr): |
| 3637 | fail(capErr.Error()) |
| 3638 | return |
| 3639 | case errors.As(err, &rf) && rf.Marker: |
| 3640 | log.Printf("console: restore %s/%s: %v", acct.Sqid, app, err) |
| 3641 | fail("Could not remove the trash marker, so nothing was restored. Try again once the bucket is reachable.") |
| 3642 | return |
| 3643 | default: |
| 3644 | log.Printf("console: restore row %s/%s: %v", acct.Sqid, app, err) |
| 3645 | fail("The trash marker was removed, but this console's own record did not update — " + |
| 3646 | "it will catch up on the next restart.") |
| 3647 | return |
| 3648 | } |
| 3649 | ``` |
| 3650 | |
| 3651 | Keep the `s.cfg.Releases == nil` check above it. Add `"errors"` to ownership.go's imports if missing. The success flash (ownership.go:274-275) keeps its wording for every other app and uses the same note for a `tmp-` one: |
| 3652 | |
| 3653 | ```go |
| 3654 | if release.IsTemporaryName(app) { |
| 3655 | s.setFlash(w, "ok", restoredTemporaryNote(app)) |
| 3656 | } else { |
| 3657 | s.setFlash(w, "ok", "Restored “"+app+"”, with its releases and channels intact and with NO routes: "+ |
| 3658 | "run `carlos add <host>` to serve it again.") |
| 3659 | } |
| 3660 | ``` |
| 3661 | |
| 3662 | The destructive doors: every check a door makes before `lockApp` is a hint (spec §2). After it takes the lock, it settles, re-reads the row, and asks again whether the row exists, is in a state the door may act on, and is the control plane. |
| 3663 | |
| 3664 | Two rules keep the doors' existing contracts (Astra plan round 2): |
| 3665 | |
| 3666 | - **A trash that finds the app already trashed is an idempotent success.** If settling finds a marker (a headless trash, or an earlier request whose row update failed), the row is now trashed with the marker's clock. The trash door then runs its ordinary success path: `TrashApp` is create-only and leaves the marker alone, the re-read returns the marker's clock, `TrashAppRow` is a no-op, and the response carries the existing deadline, with the audit row and the serial bump. `TestAPITrashTwiceKeepsTheClock` (apicli_test.go:2155) and the retry in `TestAPITrashUnconfirmedWindowIsRefused` (:2226) depend on this and must pass unchanged. |
| 3667 | - **Every refusal reuses the door's existing wording.** `recheckUnderLock` returns a kind, not a sentence, and each door maps it onto strings it already has: the API trash door's `could not confirm the trash window — try again` (apicli.go:1275; `TestAPITrashUnconfirmedWindowIsRefused` asserts its lowercase "try again" at :2208) and the purge door's `purgeAdvice` (ownership.go:498; `TestPurgeRefusesARestoredAppBeforeTouchingItsStore` asserts its "not in the trash" at deletion_test.go:770). These are existing, already-approved copy, not new strings. Only the browser delete door has no existing sentence for these cases, and gets C7.1 and C7.2. |
| 3668 | |
| 3669 | Add to `internal/console/restore.go`: |
| 3670 | |
| 3671 | ```go |
| 3672 | // lockedRefusalKind is why a destructive door must stop after taking the |
| 3673 | // lock. The door, not this helper, chooses the words: each maps these onto |
| 3674 | // sentences it already uses, so settling under the lock changes no existing |
| 3675 | // copy. |
| 3676 | type lockedRefusalKind int |
| 3677 | |
| 3678 | const ( |
| 3679 | lockedUnreadable lockedRefusalKind = iota + 1 // settle or the re-read failed: nothing was changed |
| 3680 | lockedGone // the row no longer exists |
| 3681 | lockedWrongState // purge: not in the Trash (restored while waiting); trash: trashed with no marker to confirm its clock |
| 3682 | lockedControlPlane // marked as the control plane while waiting |
| 3683 | ) |
| 3684 | |
| 3685 | // lockedRefusal is recheckUnderLock's answer when a door must stop. Err is |
| 3686 | // the settle error for lockedUnreadable (logged, and handed to purgeAdvice by |
| 3687 | // the purge door); Msg and Status are controlPlaneRefusal's, for |
| 3688 | // lockedControlPlane. |
| 3689 | type lockedRefusal struct { |
| 3690 | Kind lockedRefusalKind |
| 3691 | Err error |
| 3692 | Msg string |
| 3693 | Status int |
| 3694 | } |
| 3695 | |
| 3696 | // recheckUnderLock is what every destructive door (API trash, browser trash, |
| 3697 | // the purge door) runs after it takes s.lockApp(acct.Sqid, app) and before it |
| 3698 | // writes or tears down anything. Everything the door checked before the lock |
| 3699 | // is only a hint (temporary static sites spec §2): the control-plane setter |
| 3700 | // takes the same lock, and a row can be trashed, restored or purged while the |
| 3701 | // request waits. So: settle the row against the marker, re-read it, and ask |
| 3702 | // again. |
| 3703 | // |
| 3704 | // purge is true for the purge door, which acts only on a trashed row. Settling |
| 3705 | // is what makes it correct after a trash whose row update failed: the marker |
| 3706 | // is there and the row is live, and without the settle PurgeApp would sweep |
| 3707 | // the objects while PurgeAppRow (guarded deleted_at != 0) kept the live row |
| 3708 | // for ever. |
| 3709 | // |
| 3710 | // For the trash doors (purge false) a trashed row is NOT a refusal when its |
| 3711 | // marker is there: that is a trash already done, and the door's ordinary |
| 3712 | // path answers it idempotently with the marker's own deadline. A trashed row |
| 3713 | // with no marker can only survive settling when the tripwire held it (spec |
| 3714 | // §2); writing a fresh marker there would give the app a second clock, so it |
| 3715 | // refuses as unconfirmed. |
| 3716 | // |
| 3717 | // The production flag and the purge's own preconditions (PurgeReady, the |
| 3718 | // email and store estate) are not re-asked here because each door already |
| 3719 | // reads them after this call, under the lock. Nothing about a temporary row |
| 3720 | // stops a trash or a purge. |
| 3721 | func (s *Server) recheckUnderLock(ctx context.Context, acct *Account, app, verb string, purge bool, now time.Time) (*AppLife, *lockedRefusal) { |
| 3722 | if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil { |
| 3723 | log.Printf("console: %s %s/%s: settle: %v", verb, acct.Sqid, app, err) |
| 3724 | return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err} |
| 3725 | } |
| 3726 | life, err := s.store.AppLife(acct.ID, app) |
| 3727 | if err != nil { |
| 3728 | log.Printf("console: %s %s/%s: re-read: %v", verb, acct.Sqid, app, err) |
| 3729 | return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err} |
| 3730 | } |
| 3731 | if life == nil { |
| 3732 | return nil, &lockedRefusal{Kind: lockedGone} |
| 3733 | } |
| 3734 | switch { |
| 3735 | case purge && (!life.Trashed() || life.Restoring): |
| 3736 | return nil, &lockedRefusal{Kind: lockedWrongState, Err: release.ErrNotTrashed} |
| 3737 | case !purge && life.Restoring: |
| 3738 | return nil, &lockedRefusal{Kind: lockedWrongState} |
| 3739 | case !purge && life.Trashed(): |
| 3740 | tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app) |
| 3741 | if err != nil { |
| 3742 | log.Printf("console: %s %s/%s: re-read marker: %v", verb, acct.Sqid, app, err) |
| 3743 | return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err} |
| 3744 | } |
| 3745 | if tomb == nil { |
| 3746 | return nil, &lockedRefusal{Kind: lockedWrongState} |
| 3747 | } |
| 3748 | // Already trashed, marker and all: the door's success path answers. |
| 3749 | } |
| 3750 | if msg, status := s.controlPlaneRefusal(acct, app, verb); msg != "" { |
| 3751 | return nil, &lockedRefusal{Kind: lockedControlPlane, Msg: msg, Status: status} |
| 3752 | } |
| 3753 | return life, nil |
| 3754 | } |
| 3755 | ``` |
| 3756 | |
| 3757 | restore.go already imports `release`, `log`, `context` and `time`; nothing else is needed. |
| 3758 | |
| 3759 | `handleAPITrashApp` (apicli.go:1196): keep the pre-lock `refuseControlPlane` at :1209 as an early hint. After the `s.cfg.Releases == nil` check and the `ctx` line (lines 1219-1220), before `GetProduction` (so the production flag is read under the lock): |
| 3760 | |
| 3761 | ```go |
| 3762 | // Every transition takes the app's lock and settles first (spec §2, §5). |
| 3763 | // The checks above ran before the lock: ask again. An app already in the |
| 3764 | // Trash with its marker falls through to the idempotent path below. |
| 3765 | defer s.lockApp(acct.Sqid, app)() |
| 3766 | if _, ref := s.recheckUnderLock(ctx, acct, app, "trashed", false, time.Now()); ref != nil { |
| 3767 | switch ref.Kind { |
| 3768 | case lockedGone: |
| 3769 | http.NotFound(w, r) // what apiAccountApp's gate answers for this app now |
| 3770 | case lockedControlPlane: |
| 3771 | jsonError(w, ref.Status, ref.Msg) |
| 3772 | default: |
| 3773 | // Existing copy (apicli.go:1275): the same answer an unreadable |
| 3774 | // marker already gets from this door, and the retry is safe. |
| 3775 | jsonError(w, http.StatusBadGateway, "could not confirm the trash window — try again") |
| 3776 | } |
| 3777 | return |
| 3778 | } |
| 3779 | ``` |
| 3780 | |
| 3781 | `handleAppDelete` (appsettings.go:972): keep the pre-lock `controlPlaneRefusal` at :991 as a hint. After `requireFreshAuth` and the `ctx` line (:1008-1009), before `GetProduction` (:1013): |
| 3782 | |
| 3783 | ```go |
| 3784 | defer s.lockApp(acct.Sqid, app)() |
| 3785 | if _, ref := s.recheckUnderLock(ctx, acct, app, "trashed", false, time.Now()); ref != nil { |
| 3786 | switch ref.Kind { |
| 3787 | case lockedGone: |
| 3788 | back("err", app+" no longer exists.") // COPY-REVIEW C7.2 |
| 3789 | case lockedControlPlane: |
| 3790 | back("err", ref.Msg) |
| 3791 | default: |
| 3792 | back("err", "Could not confirm the trash marker. Nothing was changed. Try again.") // COPY-REVIEW C7.1 |
| 3793 | } |
| 3794 | return |
| 3795 | } |
| 3796 | ``` |
| 3797 | |
| 3798 | `handlePurgeApp` (ownership.go:313): keep the pre-lock `controlPlaneRefusal` at :335 as a hint. After `requireFreshAuth` (:340) and the `s.cfg.Releases == nil` check (:343-346), before `PurgeReady` (:357), so the settle, the re-check, `PurgeReady`, the email and store checks, every teardown and the sweep all run under the one lock: |
| 3799 | |
| 3800 | ```go |
| 3801 | defer s.lockApp(acct.Sqid, app)() |
| 3802 | if _, ref := s.recheckUnderLock(r.Context(), acct, app, "deleted", true, time.Now()); ref != nil { |
| 3803 | switch ref.Kind { |
| 3804 | case lockedControlPlane: |
| 3805 | fail(ref.Msg) |
| 3806 | case lockedGone: |
| 3807 | // Existing copy: purgeAdvice's "not in the trash ... may have been |
| 3808 | // restored, or already deleted" (ownership.go:500). |
| 3809 | fail(purgeAdvice(release.ErrNotTrashed, app)) |
| 3810 | default: |
| 3811 | // lockedWrongState carries ErrNotTrashed; lockedUnreadable carries |
| 3812 | // the read error, which purgeAdvice answers with its existing |
| 3813 | // "still in the trash, try again" sentence. |
| 3814 | fail(purgeAdvice(ref.Err, app)) |
| 3815 | } |
| 3816 | return |
| 3817 | } |
| 3818 | ``` |
| 3819 | |
| 3820 | `TestPurgeRefusesARestoredAppBeforeTouchingItsStore` (deletion_test.go:745) now reaches its refusal one step earlier: the settle finds no marker and objects present, finishes the restore on the row, and `lockedWrongState` maps to the same `purgeAdvice(release.ErrNotTrashed, ...)` sentence the old `PurgeReady` refusal produced. Its store, variables, audit and object assertions are unchanged, and so is its "not in the trash" check. |
| 3821 | |
| 3822 | - [ ] **Step 4: Run the tests to verify they pass** |
| 3823 | |
| 3824 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestRestore|TestTrashDoorsSetTheWindowForTemporaryRows|TestManualPurgeAfterAFailedTrashRowUpdate|TestDestructiveDoorsRecheckAfterTheLock|TestTrashAfterAHeadlessTrashIsIdempotent|TestTrash|TestDeleteNow|TestPurge|TestAPITrash|TestAPIRestore|TestAppDelete|TestControlPlane' -count=1` |
| 3825 | |
| 3826 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/provision/ -run 'TestReconcileDeclinesTrashedApp' -count=1` |
| 3827 | Expected: PASS, unchanged. This is the box-side half of `TestRestoreOfAProvisionedTemporaryStaticApp`: a record whose app's marker is gone is minted again. No box code changes. |
| 3828 | Expected: PASS, with `TestAPITrashTwiceKeepsTheClock`, `TestAPITrashUnconfirmedWindowIsRefused` and `TestPurgeRefusesARestoredAppBeforeTouchingItsStore` passing unchanged: this task edits none of their assertions. |
| 3829 | |
| 3830 | - [ ] **Step 5: Commit** |
| 3831 | |
| 3832 | ```bash |
| 3833 | gofmt -l internal/console |
| 3834 | git add internal/console/restore.go internal/console/restore_test.go internal/console/temphook_test.go internal/console/apicli.go internal/console/ownership.go internal/console/appsettings.go |
| 3835 | git commit -m "console: one restore for every door, trash doors lock and settle |
| 3836 | |
| 3837 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 3838 | ``` |
| 3839 | |
| 3840 | --- |
| 3841 | ### Task 8: The expiry pass |
| 3842 | |
| 3843 | **Files:** |
| 3844 | - Create: `internal/console/expirypass.go` |
| 3845 | - Modify: `cmd/console/main.go:372` (start the pass after `StartScanPass`) |
| 3846 | - Test: `internal/console/expirypass_test.go` (new) |
| 3847 | |
| 3848 | **Interfaces:** |
| 3849 | - Consumes: `Store.TemporaryAppRows`, `Store.PurgeDueApps`, `Store.AppLife`, `Store.ExpireAppRow`, `Store.PurgeAppRow`, `AppRow` (Task 2); `lockApp`, `settleApp`, `listEvidence`, `isTrashMarkerKey`, `evidenceFunc` (Task 4); `release.TrashApp`, `release.ReadAppTombstone`, `release.PurgeAppIfTrashedAt`, `release.ErrNotTrashed` (Task 1); `mail.Get`, `s.mailAppDomains` (apimail.go:308); `s.audit`, `s.bumpSerial`; test helpers `tempApp`, `lifeOf`, `markerOf`, `trashedTemp`, `hookStore`, `deletableStore` (view_test.go:20), `putMailRecord` (appsettings_test.go:1164). |
| 3850 | - Produces: |
| 3851 | - `const expiryPassInterval = 5 * time.Minute`, `const expiryActor = "expiry"` |
| 3852 | - `func (s *Server) StartExpiryPass(ctx context.Context)` |
| 3853 | - `func (s *Server) expiryPass(ctx context.Context, now time.Time)` (the clock is a parameter: tests drive it directly) |
| 3854 | - `func (s *Server) settleSweep(ctx context.Context, rows []AppRow, keys []string, now time.Time, ev evidenceFunc)` |
| 3855 | - `func (s *Server) expireOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc)` |
| 3856 | - `func (s *Server) purgeOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc)` |
| 3857 | - `func (s *Server) mailEstate(ctx context.Context, acct *Account, app string) (bool, error)` |
| 3858 | |
| 3859 | - [ ] **Step 1: Write the failing tests** |
| 3860 | |
| 3861 | ```go |
| 3862 | // internal/console/expirypass_test.go |
| 3863 | package console |
| 3864 | |
| 3865 | import ( |
| 3866 | "bytes" |
| 3867 | "context" |
| 3868 | "log" |
| 3869 | "os" |
| 3870 | "path/filepath" |
| 3871 | "strings" |
| 3872 | "testing" |
| 3873 | "time" |
| 3874 | |
| 3875 | "github.com/carlosframework/platform/internal/mail" |
| 3876 | "github.com/carlosframework/platform/internal/release" |
| 3877 | ) |
| 3878 | |
| 3879 | // Spec §9: expire -> trash -> purge at 3 days, on a fake clock. |
| 3880 | func TestExpiryPassExpiresThenPurgesAtThreeDays(t *testing.T) { |
| 3881 | fx := newConsoleFixture(t) |
| 3882 | ctx := context.Background() |
| 3883 | now := time.Now().Truncate(time.Second).Add(time.Hour) |
| 3884 | tempApp(t, fx, "tmp-a", now.Add(-time.Minute)) |
| 3885 | fx.srv.expiryPass(ctx, now) |
| 3886 | tomb := markerOf(t, fx, "tmp-a") |
| 3887 | if tomb == nil || !tomb.DeletedAt.Equal(now) || tomb.Actor != expiryActor { |
| 3888 | t.Fatalf("marker = %+v, want one written by the pass at %v", tomb, now) |
| 3889 | } |
| 3890 | if l := lifeOf(t, fx, "tmp-a"); l.DeletedAt != now.Unix() || l.PurgeAfter != now.Unix()+72*3600 { |
| 3891 | t.Fatalf("row = %+v, want trashed with the 3-day window", l) |
| 3892 | } |
| 3893 | fx.srv.expiryPass(ctx, now.Add(72*time.Hour-time.Second)) |
| 3894 | if markerOf(t, fx, "tmp-a") == nil { |
| 3895 | t.Fatal("purged before the 3 days were up") |
| 3896 | } |
| 3897 | fx.srv.expiryPass(ctx, now.Add(72*time.Hour)) |
| 3898 | if keys, _ := fx.rel.List(ctx, "apps/"+fx.sqid+"/tmp-a/"); len(keys) != 0 { |
| 3899 | t.Fatalf("objects left after the early purge: %v", keys) |
| 3900 | } |
| 3901 | if l := lifeOf(t, fx, "tmp-a"); l != nil { |
| 3902 | t.Fatalf("row = %+v, want it gone with the name", l) |
| 3903 | } |
| 3904 | } |
| 3905 | |
| 3906 | // Review Focus 3. |
| 3907 | func TestExpiryBoundaryIsInclusive(t *testing.T) { |
| 3908 | fx := newConsoleFixture(t) |
| 3909 | now := time.Now().Truncate(time.Second) |
| 3910 | tempApp(t, fx, "tmp-now", now) |
| 3911 | tempApp(t, fx, "tmp-later", now.Add(time.Second)) |
| 3912 | fx.srv.expiryPass(context.Background(), now) |
| 3913 | if l := lifeOf(t, fx, "tmp-now"); !l.Trashed() { |
| 3914 | t.Error("expires_at == now did not expire") |
| 3915 | } |
| 3916 | if l := lifeOf(t, fx, "tmp-later"); l.Trashed() { |
| 3917 | t.Error("expires_at == now+1s expired early") |
| 3918 | } |
| 3919 | } |
| 3920 | |
| 3921 | // Spec §9: a keep between candidate listing and lock acquisition; the pass |
| 3922 | // must skip. |
| 3923 | func TestExpirySkipsAKeepThatLandedAfterTheListing(t *testing.T) { |
| 3924 | fx := newConsoleFixture(t) |
| 3925 | now := time.Now().Truncate(time.Second) |
| 3926 | tempApp(t, fx, "tmp-a", now.Add(-time.Minute)) |
| 3927 | rows, _ := fx.st.TemporaryAppRows() |
| 3928 | fx.st.KeepApp(OpsAccountID, "tmp-a") |
| 3929 | for _, r := range rows { |
| 3930 | fx.srv.expireOne(context.Background(), r, now, always(true)) |
| 3931 | } |
| 3932 | if markerOf(t, fx, "tmp-a") != nil { |
| 3933 | t.Fatal("the pass trashed an app a keep had made permanent") |
| 3934 | } |
| 3935 | if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.ExpiresAt != 0 { |
| 3936 | t.Fatalf("row = %+v, want live and permanent", l) |
| 3937 | } |
| 3938 | } |
| 3939 | |
| 3940 | // Spec §9: failure at step 3 retries. |
| 3941 | func TestExpiryRetriesAFailedTrash(t *testing.T) { |
| 3942 | fx := newConsoleFixture(t) |
| 3943 | now := time.Now().Truncate(time.Second) |
| 3944 | tempApp(t, fx, "tmp-a", now.Add(-time.Minute)) |
| 3945 | fx.srv.cfg.Releases = &hookStore{Store: fx.rel, failPut: map[string]int{release.AppTombstoneKey(fx.sqid, "tmp-a"): 1}} |
| 3946 | fx.srv.expiryPass(context.Background(), now) |
| 3947 | if markerOf(t, fx, "tmp-a") != nil || lifeOf(t, fx, "tmp-a").Trashed() { |
| 3948 | t.Fatal("a failed marker write changed something") |
| 3949 | } |
| 3950 | fx.srv.expiryPass(context.Background(), now.Add(5*time.Minute)) |
| 3951 | if l := lifeOf(t, fx, "tmp-a"); !l.Trashed() || l.DeletedAt != now.Add(5*time.Minute).Unix() { |
| 3952 | t.Fatalf("row after the retry = %+v", l) |
| 3953 | } |
| 3954 | } |
| 3955 | |
| 3956 | // Spec §9: restore -> keep -> trash between decision and purge; the stale |
| 3957 | // decision refuses through PurgeAppIfTrashedAt. |
| 3958 | func TestEarlyPurgeRefusesAMarkerFromAnotherTrash(t *testing.T) { |
| 3959 | fx := newConsoleFixture(t) |
| 3960 | ctx := context.Background() |
| 3961 | now := time.Now().Truncate(time.Second) |
| 3962 | decided := now.Add(-72 * time.Hour) |
| 3963 | trashedTemp(t, fx, "tmp-a", decided) |
| 3964 | // The bucket moved on: restored, kept and trashed again by hand. |
| 3965 | release.RestoreApp(ctx, fx.rel, fx.sqid, "tmp-a") |
| 3966 | release.TrashApp(ctx, fx.rel, fx.sqid, "tmp-a", "paul@keymail.dev", now.Add(-time.Hour)) |
| 3967 | rows, _ := fx.st.PurgeDueApps(now) |
| 3968 | for _, r := range rows { |
| 3969 | fx.srv.purgeOne(ctx, r, now, always(true)) |
| 3970 | } |
| 3971 | if keys, _ := fx.rel.List(ctx, "apps/"+fx.sqid+"/tmp-a/"); len(keys) < 2 { |
| 3972 | t.Fatalf("a stale decision destroyed objects: %v", keys) |
| 3973 | } |
| 3974 | if lifeOf(t, fx, "tmp-a") == nil { |
| 3975 | t.Fatal("a stale decision freed the name") |
| 3976 | } |
| 3977 | } |
| 3978 | |
| 3979 | // Spec §9: an interrupted purge resumes. |
| 3980 | func TestEarlyPurgeResumesAfterAnInterruption(t *testing.T) { |
| 3981 | fx := newConsoleFixture(t) |
| 3982 | ctx := context.Background() |
| 3983 | now := time.Now().Truncate(time.Second) |
| 3984 | trashedTemp(t, fx, "tmp-a", now.Add(-72*time.Hour)) |
| 3985 | fx.rel.Put(ctx, "apps/"+fx.sqid+"/tmp-a/releases/v1/site.tar.gz", []byte("x"), "") |
| 3986 | fx.srv.cfg.Releases = &hookStore{Store: fx.rel, failDelete: map[string]int{"apps/" + fx.sqid + "/tmp-a/releases/v1/site.tar.gz": 1}} |
| 3987 | fx.srv.expiryPass(ctx, now) |
| 3988 | if lifeOf(t, fx, "tmp-a") == nil || markerOf(t, fx, "tmp-a") == nil { |
| 3989 | t.Fatal("an interrupted purge lost its marker or its row") |
| 3990 | } |
| 3991 | fx.srv.expiryPass(ctx, now.Add(5*time.Minute)) |
| 3992 | if l := lifeOf(t, fx, "tmp-a"); l != nil { |
| 3993 | t.Fatalf("row = %+v after the resumed purge, want gone", l) |
| 3994 | } |
| 3995 | } |
| 3996 | |
| 3997 | // Spec §9: ErrNotTrashed with an empty prefix finalises; with objects, restores. |
| 3998 | // |
| 3999 | // The branch is only reached when the marker disappears AFTER purgeOne's |
| 4000 | // settle read it and BEFORE PurgeAppIfTrashedAt reads it: a marker gone |
| 4001 | // before the pass is repaired by the settle sweep and never gets that far. |
| 4002 | // So the hook deletes the marker on its second read. Reads of the marker key |
| 4003 | // in one pass over a due, consistent row: purgeOne's settleApp (1), then |
| 4004 | // PurgeAppIfTrashedAt's purgeReady (2). The sweep reads nothing for a row |
| 4005 | // whose trash state already agrees with the listing. |
| 4006 | func TestEarlyPurgeNotTrashedBranches(t *testing.T) { |
| 4007 | for _, objects := range []bool{false, true} { |
| 4008 | t.Run(map[bool]string{false: "no objects finalises", true: "objects restore"}[objects], func(t *testing.T) { |
| 4009 | fx := newConsoleFixture(t) |
| 4010 | ctx := context.Background() |
| 4011 | now := time.Now().Truncate(time.Second) |
| 4012 | trashedTemp(t, fx, "tmp-a", now.Add(-72*time.Hour)) |
| 4013 | if !objects { |
| 4014 | // The marker is the only key left under the prefix. kass's |
| 4015 | // objects keep the tripwire satisfied. |
| 4016 | if err := fx.rel.Delete(ctx, "apps/"+fx.sqid+"/tmp-a/channels/edge.json"); err != nil { |
| 4017 | t.Fatal(err) |
| 4018 | } |
| 4019 | } |
| 4020 | marker := release.AppTombstoneKey(fx.sqid, "tmp-a") |
| 4021 | reads := 0 |
| 4022 | fx.srv.cfg.Releases = &hookStore{Store: fx.rel, onGet: func(key string) { |
| 4023 | if key != marker { |
| 4024 | return |
| 4025 | } |
| 4026 | reads++ |
| 4027 | if reads == 2 { |
| 4028 | fx.rel.Delete(ctx, marker) |
| 4029 | } |
| 4030 | }} |
| 4031 | var logged bytes.Buffer |
| 4032 | log.SetOutput(&logged) |
| 4033 | t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| 4034 | |
| 4035 | fx.srv.expiryPass(ctx, now) |
| 4036 | |
| 4037 | if reads < 2 { |
| 4038 | t.Fatalf("the marker was read %d time(s); the purge never reached its own precondition read", reads) |
| 4039 | } |
| 4040 | if !strings.Contains(logged.String(), "left the trash before its purge") { |
| 4041 | t.Fatalf("the ErrNotTrashed branch did not run; log:\n%s", logged.String()) |
| 4042 | } |
| 4043 | l := lifeOf(t, fx, "tmp-a") |
| 4044 | if !objects { |
| 4045 | if l != nil { |
| 4046 | t.Fatalf("row = %+v, want finalised (no marker, no objects, evidence)", l) |
| 4047 | } |
| 4048 | return |
| 4049 | } |
| 4050 | if l == nil || l.Trashed() || l.Restoring || l.PurgeAfter != 0 || l.ExpiresAt != now.Add(temporaryTTLDefault).Unix() { |
| 4051 | t.Fatalf("row = %+v, want restored: live, no deadline, expiry now+7d", l) |
| 4052 | } |
| 4053 | if keys, _ := fx.rel.List(ctx, "apps/"+fx.sqid+"/tmp-a/"); len(keys) == 0 { |
| 4054 | t.Fatal("the restore branch destroyed objects") |
| 4055 | } |
| 4056 | }) |
| 4057 | } |
| 4058 | } |
| 4059 | |
| 4060 | // Spec §9: control-plane and email-estate backstops refuse without tearing down. |
| 4061 | func TestEarlyPurgeBackstops(t *testing.T) { |
| 4062 | ctx := context.Background() |
| 4063 | now := time.Now().Truncate(time.Second) |
| 4064 | t.Run("control plane", func(t *testing.T) { |
| 4065 | fx := newConsoleFixture(t) |
| 4066 | trashedTemp(t, fx, "tmp-a", now.Add(-72*time.Hour)) |
| 4067 | // Unreachable through any door (Task 6); set by hand to prove the backstop. |
| 4068 | fx.st.db.Exec(`UPDATE apps SET control_plane = 1 WHERE app = 'tmp-a'`) |
| 4069 | fx.srv.expiryPass(ctx, now) |
| 4070 | if markerOf(t, fx, "tmp-a") == nil || lifeOf(t, fx, "tmp-a") == nil { |
| 4071 | t.Fatal("the pass purged the control plane") |
| 4072 | } |
| 4073 | }) |
| 4074 | t.Run("email estate", func(t *testing.T) { |
| 4075 | fx := newConsoleFixture(t) |
| 4076 | trashedTemp(t, fx, "tmp-a", now.Add(-72*time.Hour)) |
| 4077 | putMailRecord(t, fx, "tmp-a", &mail.Record{ |
| 4078 | Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, |
| 4079 | Provisioned: &mail.Provisioned{KeyID: "AKIA1", Active: true, At: now}, |
| 4080 | }) |
| 4081 | fx.srv.expiryPass(ctx, now) |
| 4082 | if markerOf(t, fx, "tmp-a") == nil || lifeOf(t, fx, "tmp-a") == nil { |
| 4083 | t.Fatal("the pass purged an app with email estate") |
| 4084 | } |
| 4085 | }) |
| 4086 | } |
| 4087 | |
| 4088 | // Spec §9 "Empty bucket": startup materialize and several passes against an |
| 4089 | // empty replacement bucket, then one holding only trash markers. No marker is |
| 4090 | // written and both rows survive unchanged. |
| 4091 | func TestExpiryPassAgainstTheWrongBucket(t *testing.T) { |
| 4092 | for _, markersOnly := range []bool{false, true} { |
| 4093 | fx := newConsoleFixture(t) |
| 4094 | ctx := context.Background() |
| 4095 | now := time.Now().Truncate(time.Second) |
| 4096 | trashedTemp(t, fx, "tmp-due", now.Add(-72*time.Hour)) |
| 4097 | tempApp(t, fx, "tmp-expired", now.Add(-time.Hour)) |
| 4098 | before := map[string]AppLife{"tmp-due": *lifeOf(t, fx, "tmp-due"), "tmp-expired": *lifeOf(t, fx, "tmp-expired")} |
| 4099 | dir := filepath.Join(t.TempDir(), "wrong") |
| 4100 | wrong := &deletableStore{Store: release.NewDirStore(dir), root: dir} |
| 4101 | if markersOnly { |
| 4102 | release.TrashApp(ctx, wrong, fx.sqid, "someone-else", "paul@keymail.dev", now) |
| 4103 | } |
| 4104 | fx.srv.cfg.Releases = wrong |
| 4105 | materializeQuietly(ctx, wrong, fx.st) |
| 4106 | for i := 0; i < 3; i++ { |
| 4107 | fx.srv.expiryPass(ctx, now.Add(time.Duration(i)*5*time.Minute)) |
| 4108 | } |
| 4109 | keys, _ := wrong.List(ctx, "apps/") |
| 4110 | if want := map[bool]int{false: 0, true: 1}[markersOnly]; len(keys) != want { |
| 4111 | t.Fatalf("markersOnly=%v: the wrong bucket now holds %v", markersOnly, keys) |
| 4112 | } |
| 4113 | for app, b := range before { |
| 4114 | if l := lifeOf(t, fx, app); l == nil || *l != b { |
| 4115 | t.Fatalf("markersOnly=%v: %s = %+v, want unchanged %+v", markersOnly, app, l, b) |
| 4116 | } |
| 4117 | } |
| 4118 | } |
| 4119 | } |
| 4120 | |
| 4121 | // Spec §9 "Settle sweep": a manual trash of a temporary app with 30 days left |
| 4122 | // whose row update failed. No restart, no user action: the next pass settles |
| 4123 | // it, and the early purge runs at 3 days. |
| 4124 | func TestSettleSweepRepairsAFailedManualTrash(t *testing.T) { |
| 4125 | fx := newConsoleFixture(t) |
| 4126 | ctx := context.Background() |
| 4127 | now := time.Now().Truncate(time.Second) |
| 4128 | tempApp(t, fx, "tmp-a", now.Add(30*24*time.Hour)) |
| 4129 | release.TrashApp(ctx, fx.rel, fx.sqid, "tmp-a", "paul@keymail.dev", now) |
| 4130 | fx.srv.expiryPass(ctx, now.Add(time.Minute)) |
| 4131 | if l := lifeOf(t, fx, "tmp-a"); l.DeletedAt != now.Unix() || l.PurgeAfter != now.Unix()+72*3600 { |
| 4132 | t.Fatalf("row after the sweep = %+v, want trashed at the marker's clock with the 3-day window", l) |
| 4133 | } |
| 4134 | fx.srv.expiryPass(ctx, now.Add(72*time.Hour)) |
| 4135 | if l := lifeOf(t, fx, "tmp-a"); l != nil { |
| 4136 | t.Fatalf("row = %+v, want purged at 3 days", l) |
| 4137 | } |
| 4138 | } |
| 4139 | |
| 4140 | // Spec §9: an early purge whose final marker delete fails, with no other keys |
| 4141 | // in the bucket. The next pass trips the tripwire, still retries the purge, |
| 4142 | // finishes, and removes the row. |
| 4143 | func TestEarlyPurgeOfTheLastAppRetriesUnderTheTripwire(t *testing.T) { |
| 4144 | fx := newConsoleFixture(t) |
| 4145 | ctx := context.Background() |
| 4146 | now := time.Now().Truncate(time.Second) |
| 4147 | dir := filepath.Join(t.TempDir(), "only") |
| 4148 | only := &deletableStore{Store: release.NewDirStore(dir), root: dir} |
| 4149 | fx.srv.cfg.Releases = only |
| 4150 | fx.rel = only |
| 4151 | trashedTemp(t, fx, "tmp-a", now.Add(-72*time.Hour)) |
| 4152 | marker := release.AppTombstoneKey(fx.sqid, "tmp-a") |
| 4153 | fx.srv.cfg.Releases = &hookStore{Store: only, failDelete: map[string]int{marker: 1}} |
| 4154 | fx.srv.expiryPass(ctx, now) |
| 4155 | if keys, _ := only.List(ctx, "apps/"); len(keys) != 1 || keys[0] != marker { |
| 4156 | t.Fatalf("after the first pass the bucket holds %v, want only the marker", keys) |
| 4157 | } |
| 4158 | if lifeOf(t, fx, "tmp-a") == nil { |
| 4159 | t.Fatal("the row went before the marker") |
| 4160 | } |
| 4161 | fx.srv.expiryPass(ctx, now.Add(5*time.Minute)) |
| 4162 | if keys, _ := only.List(ctx, "apps/"); len(keys) != 0 { |
| 4163 | t.Fatalf("the retry left %v", keys) |
| 4164 | } |
| 4165 | if l := lifeOf(t, fx, "tmp-a"); l != nil { |
| 4166 | t.Fatalf("row = %+v, want gone", l) |
| 4167 | } |
| 4168 | } |
| 4169 | ``` |
| 4170 | |
| 4171 | `fx.rel = only` in the last test repoints `tempApp`/`markerOf` at the one-app bucket; `trashedTemp` seeds through `fx.rel`. The fixture's `kass` row has no objects in that bucket and stays live: a live row with no marker is never touched. |
| 4172 | |
| 4173 | - [ ] **Step 2: Run the tests to verify they fail** |
| 4174 | |
| 4175 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestExpiry|TestEarlyPurge|TestSettleSweep' -count=1` |
| 4176 | Expected: FAIL to compile: `fx.srv.expiryPass undefined`, `undefined: expiryActor`. |
| 4177 | |
| 4178 | - [ ] **Step 3: Implement** |
| 4179 | |
| 4180 | Create `internal/console/expirypass.go`: |
| 4181 | |
| 4182 | ```go |
| 4183 | // The expiry pass (temporary static sites spec §5): the only actor that |
| 4184 | // expires or early-purges anything. Its own ticker, beside economicspass.go |
| 4185 | // and scanpass.go. Every step takes the per-app mutex keep, ttl, restore, |
| 4186 | // trash and the purge door take, settles first, and re-reads the row: the |
| 4187 | // candidate lists taken before the lock are only hints. |
| 4188 | package console |
| 4189 | |
| 4190 | import ( |
| 4191 | "context" |
| 4192 | "errors" |
| 4193 | "log" |
| 4194 | "time" |
| 4195 | |
| 4196 | "github.com/carlosframework/platform/internal/mail" |
| 4197 | "github.com/carlosframework/platform/internal/release" |
| 4198 | ) |
| 4199 | |
| 4200 | const ( |
| 4201 | expiryPassInterval = 5 * time.Minute |
| 4202 | // expiryActor is who the pass's trash markers and audit rows name. |
| 4203 | expiryActor = "expiry" |
| 4204 | ) |
| 4205 | |
| 4206 | // StartExpiryPass runs the pass now and then every five minutes until ctx is |
| 4207 | // done, StartScanPass's shape. It runs whether or not CARLOS_TEMPORARY_APPS is |
| 4208 | // set: apps created while it was on must still expire after it is turned off. |
| 4209 | func (s *Server) StartExpiryPass(ctx context.Context) { |
| 4210 | if s.cfg.Releases == nil { |
| 4211 | return |
| 4212 | } |
| 4213 | t := time.NewTicker(expiryPassInterval) |
| 4214 | defer t.Stop() |
| 4215 | for { |
| 4216 | s.expiryPass(ctx, time.Now()) |
| 4217 | select { |
| 4218 | case <-ctx.Done(): |
| 4219 | return |
| 4220 | case <-t.C: |
| 4221 | } |
| 4222 | } |
| 4223 | } |
| 4224 | |
| 4225 | // expiryPass is one pass at the given clock. |
| 4226 | // |
| 4227 | // It lists apps/ once, for the tripwire and the settle sweep. With no |
| 4228 | // evidence (spec §2, a tripped pass) it expires nothing and finalises nothing |
| 4229 | // from absence, but it still runs the sweep's presence branch and still |
| 4230 | // retries early purges: PurgeAppIfTrashedAt acts only on a marker whose clock |
| 4231 | // matches the row, and a wrong, empty bucket has no such marker. |
| 4232 | func (s *Server) expiryPass(ctx context.Context, now time.Time) { |
| 4233 | rows, err := s.store.TemporaryAppRows() |
| 4234 | if err != nil { |
| 4235 | log.Printf("console: expiry pass: list temporary apps: %v", err) |
| 4236 | return |
| 4237 | } |
| 4238 | due, err := s.store.PurgeDueApps(now) |
| 4239 | if err != nil { |
| 4240 | log.Printf("console: expiry pass: list purges due: %v", err) |
| 4241 | return |
| 4242 | } |
| 4243 | if len(rows) == 0 && len(due) == 0 { |
| 4244 | return |
| 4245 | } |
| 4246 | keys, listed, evidence := s.listEvidence(ctx) |
| 4247 | ev := func(context.Context) bool { return evidence } |
| 4248 | if !evidence { |
| 4249 | log.Printf("console: expiry pass: the apps/ listing holds nothing but trash markers, or could not be read; " + |
| 4250 | "expiring nothing and finalising nothing this pass. Is CARLOS_DEPLOYMENT_BUCKET pointing at the right bucket?") |
| 4251 | } |
| 4252 | if listed { |
| 4253 | s.settleSweep(ctx, rows, keys, now, ev) |
| 4254 | } |
| 4255 | if evidence { |
| 4256 | for _, r := range rows { |
| 4257 | if !r.Deleted && r.ExpiresAt > 0 && r.ExpiresAt <= now.Unix() { |
| 4258 | s.expireOne(ctx, r, now, ev) |
| 4259 | } |
| 4260 | } |
| 4261 | } |
| 4262 | for _, r := range due { |
| 4263 | s.purgeOne(ctx, r, now, ev) |
| 4264 | } |
| 4265 | } |
| 4266 | |
| 4267 | // settleSweep (spec §5, astra 8.1) compares every temporary row with the |
| 4268 | // listing already taken: a row whose trash state disagrees with its marker, |
| 4269 | // or that is mid-restore, is settled under its lock. This repairs a trash door |
| 4270 | // that wrote the marker and failed its row update while the expiry is still |
| 4271 | // far off. No extra listing; a GET only for the rows that disagree. |
| 4272 | func (s *Server) settleSweep(ctx context.Context, rows []AppRow, keys []string, now time.Time, ev evidenceFunc) { |
| 4273 | markers := make(map[string]bool) |
| 4274 | for _, k := range keys { |
| 4275 | if isTrashMarkerKey(k) { |
| 4276 | markers[k] = true |
| 4277 | } |
| 4278 | } |
| 4279 | for _, r := range rows { |
| 4280 | if markers[release.AppTombstoneKey(r.Sqid, r.App)] == r.Deleted && !r.Restoring { |
| 4281 | continue |
| 4282 | } |
| 4283 | func() { |
| 4284 | defer s.lockApp(r.Sqid, r.App)() |
| 4285 | if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil { |
| 4286 | log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err) |
| 4287 | } |
| 4288 | }() |
| 4289 | } |
| 4290 | } |
| 4291 | |
| 4292 | // expireOne is spec §5 "Expire", steps 1-4, for one candidate. |
| 4293 | func (s *Server) expireOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc) { |
| 4294 | defer s.lockApp(r.Sqid, r.App)() |
| 4295 | if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil { |
| 4296 | log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err) |
| 4297 | return |
| 4298 | } |
| 4299 | life, err := s.store.AppLife(r.AccountID, r.App) |
| 4300 | if err != nil { |
| 4301 | log.Printf("console: expiry pass: read %s/%s: %v", r.Sqid, r.App, err) |
| 4302 | return |
| 4303 | } |
| 4304 | // A keep that ran after the listing set expires_at = 0, which fails > 0. |
| 4305 | if life == nil || life.ExpiresAt <= 0 || life.ExpiresAt > now.Unix() || life.Trashed() || life.Restoring { |
| 4306 | return |
| 4307 | } |
| 4308 | acct := &Account{ID: r.AccountID, Sqid: r.Sqid} |
| 4309 | if life.ControlPlane { |
| 4310 | log.Printf("console: expiry pass: %s/%s is marked as the control plane; not expiring it", r.Sqid, r.App) |
| 4311 | s.audit(acct, r.App, expiryActor, "expire-refused", "did not move "+r.App+" to the trash: it is marked as the control plane") // COPY-REVIEW C8.1 |
| 4312 | return |
| 4313 | } |
| 4314 | if err := release.TrashApp(ctx, s.cfg.Releases, r.Sqid, r.App, expiryActor, now); err != nil { |
| 4315 | log.Printf("console: expiry pass: trash %s/%s: %v", r.Sqid, r.App, err) |
| 4316 | return // step 3 wrote nothing; the next pass tries again |
| 4317 | } |
| 4318 | // Create-only: a marker that already existed keeps its clock, the same |
| 4319 | // re-read handleAPITrashApp does. |
| 4320 | tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, r.Sqid, r.App) |
| 4321 | if err != nil || tomb == nil { |
| 4322 | log.Printf("console: expiry pass: re-read marker %s/%s: %v", r.Sqid, r.App, err) |
| 4323 | return |
| 4324 | } |
| 4325 | if ok, err := s.store.ExpireAppRow(r.AccountID, r.App, tomb.DeletedAt); err != nil || !ok { |
| 4326 | // The marker stands and the row is live. The next door or pass to |
| 4327 | // touch the app settles it, with the temporary window (spec §5). |
| 4328 | log.Printf("console: expiry pass: mark %s/%s trashed: changed=%v err=%v", r.Sqid, r.App, ok, err) |
| 4329 | return |
| 4330 | } |
| 4331 | s.audit(acct, r.App, expiryActor, "expire", "moved "+r.App+" to the trash: it reached its expiry") // COPY-REVIEW C8.2 |
| 4332 | s.bumpSerial(ctx) |
| 4333 | } |
| 4334 | |
| 4335 | // purgeOne is spec §5 "Early purge" for one candidate. |
| 4336 | func (s *Server) purgeOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc) { |
| 4337 | defer s.lockApp(r.Sqid, r.App)() |
| 4338 | if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil { |
| 4339 | log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err) |
| 4340 | return |
| 4341 | } |
| 4342 | life, err := s.store.AppLife(r.AccountID, r.App) |
| 4343 | if err != nil { |
| 4344 | log.Printf("console: expiry pass: read %s/%s: %v", r.Sqid, r.App, err) |
| 4345 | return |
| 4346 | } |
| 4347 | if life == nil || !life.Trashed() || life.PurgeAfter <= 0 || life.PurgeAfter > now.Unix() || life.PurgeFor <= 0 { |
| 4348 | return // restored, purged, or never due: settling answered it |
| 4349 | } |
| 4350 | acct := &Account{ID: r.AccountID, Sqid: r.Sqid} |
| 4351 | // The backstops (spec §5). §4 makes both unreachable; if either is ever |
| 4352 | // reached, the app waits for a person at the console's purge door. |
| 4353 | if life.ControlPlane { |
| 4354 | log.Printf("console: expiry pass: %s/%s is marked as the control plane; not purging it", r.Sqid, r.App) |
| 4355 | s.audit(acct, r.App, expiryActor, "purge-refused", "did not delete "+r.App+": it is marked as the control plane") // COPY-REVIEW C8.3 |
| 4356 | return |
| 4357 | } |
| 4358 | switch estate, err := s.mailEstate(ctx, acct, r.App); { |
| 4359 | case err != nil: |
| 4360 | log.Printf("console: expiry pass: read email estate %s/%s: %v; not purging", r.Sqid, r.App, err) |
| 4361 | return |
| 4362 | case estate: |
| 4363 | log.Printf("console: expiry pass: %s/%s has email estate; not purging it", r.Sqid, r.App) |
| 4364 | s.audit(acct, r.App, expiryActor, "purge-refused", "did not delete "+r.App+": it has email set up. Delete it from the Trash page") // COPY-REVIEW C8.4 |
| 4365 | return |
| 4366 | } |
| 4367 | err = release.PurgeAppIfTrashedAt(ctx, s.cfg.Releases, s.cfg.ReleasePub, r.Sqid, r.App, time.Unix(life.PurgeFor, 0), now) |
| 4368 | switch { |
| 4369 | case err == nil: |
| 4370 | if err := s.store.PurgeAppRow(r.AccountID, r.App); err != nil { |
| 4371 | log.Printf("console: expiry pass: release the name %s/%s: %v", r.Sqid, r.App, err) |
| 4372 | return |
| 4373 | } |
| 4374 | s.audit(acct, r.App, expiryActor, "purge", "deleted "+r.App+" permanently, 3 days after it went to the trash") // COPY-REVIEW C8.5 |
| 4375 | case errors.Is(err, release.ErrNotTrashed): |
| 4376 | // Not "restored" (spec §5, astra 2.4): a finished purge whose row |
| 4377 | // write failed, a box purge, a headless restore and a crashed |
| 4378 | // restore all look the same at the marker. settleApp tells them |
| 4379 | // apart by listing the prefix, under the tripwire. |
| 4380 | log.Printf("console: expiry pass: %s/%s left the trash before its purge; settling it", r.Sqid, r.App) |
| 4381 | if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil { |
| 4382 | log.Printf("console: expiry pass: settle %s/%s after ErrNotTrashed: %v", r.Sqid, r.App, err) |
| 4383 | } |
| 4384 | default: |
| 4385 | // Interrupted, a clock mismatch, a production flag, store estate: |
| 4386 | // purge_after stays, and the next pass resumes or refuses again. |
| 4387 | log.Printf("console: expiry pass: early purge %s/%s: %v", r.Sqid, r.App, err) |
| 4388 | } |
| 4389 | } |
| 4390 | |
| 4391 | // mailEstate is the purge door's email test (ownership.go, handlePurgeApp): |
| 4392 | // a provisioned sender, a standalone credential, or any domain claim. Unlike |
| 4393 | // the door, an unreadable domain list is an error here: the pass acts on its |
| 4394 | // own authority and fails toward keeping. |
| 4395 | func (s *Server) mailEstate(ctx context.Context, acct *Account, app string) (bool, error) { |
| 4396 | rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| 4397 | if err != nil { |
| 4398 | return false, err |
| 4399 | } |
| 4400 | if rec != nil && (rec.Provisioned != nil || len(rec.Standalone) > 0) { |
| 4401 | return true, nil |
| 4402 | } |
| 4403 | doms, err := s.mailAppDomains(ctx, acct, app) |
| 4404 | if err != nil { |
| 4405 | return false, err |
| 4406 | } |
| 4407 | return len(doms) > 0, nil |
| 4408 | } |
| 4409 | ``` |
| 4410 | |
| 4411 | `cmd/console/main.go`, after `go srv.StartScanPass(...)` (line 372): |
| 4412 | |
| 4413 | ```go |
| 4414 | // The temporary-app expiry pass (internal/console/expirypass.go). Same |
| 4415 | // placement rule as every pass above: started here, never in NewServer. |
| 4416 | go srv.StartExpiryPass(context.Background()) |
| 4417 | ``` |
| 4418 | |
| 4419 | - [ ] **Step 4: Run the tests to verify they pass** |
| 4420 | |
| 4421 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestExpiry|TestEarlyPurge|TestSettleSweep|TestSettleApp|TestMaterialize' -count=1` |
| 4422 | Expected: PASS. |
| 4423 | |
| 4424 | Run: `GOCACHE=$TMPDIR/gocache go build ./cmd/console/` |
| 4425 | Expected: builds. |
| 4426 | |
| 4427 | - [ ] **Step 5: Commit** |
| 4428 | |
| 4429 | ```bash |
| 4430 | gofmt -l internal/console cmd/console |
| 4431 | git add internal/console/expirypass.go internal/console/expirypass_test.go cmd/console/main.go |
| 4432 | git commit -m "console: the expiry pass expires, sweeps, and purges temporary apps at 3 days |
| 4433 | |
| 4434 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 4435 | ``` |
| 4436 | |
| 4437 | --- |
| 4438 | ### Task 9: `ttl`, `keep` and `list` doors, and the Keep button |
| 4439 | |
| 4440 | **Files:** |
| 4441 | - Modify: `internal/console/apitemporary.go` (Task 5's file: helpers and handlers) |
| 4442 | - Modify: `internal/console/authcli.go` (three routes after `/new-temporary`) |
| 4443 | - Modify: `internal/console/server.go:1202` (browser route after `/rename`), `:1441` (`page`: `ExpiresAt`), `:2685-2702` (`appPage`) |
| 4444 | - Modify: `internal/console/templates/appnav.html:2-5` |
| 4445 | - Test: `internal/console/apitemporary_test.go` |
| 4446 | |
| 4447 | **Interfaces:** |
| 4448 | - Consumes: `lockApp`, `settleApp`, `doorEvidence` (Task 4); `temporaryTTL` (Task 5); `Store.AppLife`, `Store.KeepApp`, `Store.SetAppExpiry`, `Store.AppLivesIn`, `effectivePurgeAt` (Task 2); `apiAccountApp`, `apiPrincipalAccount`, `allowWrite`, `accountApp`. |
| 4449 | - Produces: |
| 4450 | - `var errAppGone, errTemporaryInTrash, errNotTemporary error` |
| 4451 | - `func (s *Server) keepApp(ctx context.Context, acct *Account, app string, now time.Time) error` |
| 4452 | - `func (s *Server) setAppTTL(ctx context.Context, acct *Account, app string, ttl time.Duration, now time.Time) (time.Time, error)` |
| 4453 | - `func (s *Server) handleAPIAppKeep(w http.ResponseWriter, r *http.Request)`, `handleAPIAppTTL`, `handleAPIAppList`, `handleAppKeep` (browser) |
| 4454 | - `page.ExpiresAt time.Time` (zero for a permanent app) |
| 4455 | - Wire: `POST /api/cli/apps/{account}/{app}/ttl {"ttl_seconds": N}` → 200 `{"app", "expires_at"}`; `POST /api/cli/apps/{account}/{app}/keep` → 200 `{"app", "expires_at": null}`; `GET /api/cli/apps/{account}/list` → 200 JSON array of `{"app", "deleted_at", "expires_at", "purge_after"}`, RFC 3339 or null, trashed apps included, `purge_after` = `effectivePurgeAt` for trashed rows; `POST /a/{sqid}/apps/{app}/keep` (browser, any member, CSRF, no passkey) |
| 4456 | |
| 4457 | - [ ] **Step 1: Write the failing tests** |
| 4458 | |
| 4459 | Append to `internal/console/apitemporary_test.go` (add `"context"`, `"encoding/json"`, `"net/url"` and `release` to its imports if missing). `postJSON`/`getJSON` are only used where the door answers JSON; a door expected to answer `http.NotFound`'s plain text is driven with `rawReq` and checked with `assertNotFound` (apiinstances_test.go:42-96). |
| 4460 | |
| 4461 | ```go |
| 4462 | func appURL(fx *consoleFixture, app, tail string) string { |
| 4463 | return fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/" + app + tail |
| 4464 | } |
| 4465 | |
| 4466 | func TestKeepMakesATemporaryAppPermanent(t *testing.T) { |
| 4467 | fx, token := temporaryFixture(t) |
| 4468 | tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour)) |
| 4469 | status, out := postJSON(t, appURL(fx, "tmp-a", "/keep"), map[string]string{}, token) |
| 4470 | if status != http.StatusOK || out["app"] != "tmp-a" || out["expires_at"] != nil { |
| 4471 | t.Fatalf("keep = %d %v, want 200 with expires_at null", status, out) |
| 4472 | } |
| 4473 | if l := lifeOf(t, fx, "tmp-a"); l.Temporary() { |
| 4474 | t.Fatalf("row = %+v, want permanent", l) |
| 4475 | } |
| 4476 | } |
| 4477 | |
| 4478 | func TestTTLMovesTheExpiry(t *testing.T) { |
| 4479 | fx, token := temporaryFixture(t) |
| 4480 | tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour)) |
| 4481 | before := time.Now() |
| 4482 | status, out := postJSON(t, appURL(fx, "tmp-a", "/ttl"), map[string]int64{"ttl_seconds": 5 * 86400}, token) |
| 4483 | if status != http.StatusOK { |
| 4484 | t.Fatalf("ttl = %d %v", status, out) |
| 4485 | } |
| 4486 | exp, _ := time.Parse(time.RFC3339, str(t, out, "expires_at")) |
| 4487 | if d := exp.Sub(before); d < 5*24*time.Hour-time.Minute || d > 5*24*time.Hour+time.Minute { |
| 4488 | t.Fatalf("expiry is %v away, want 5 days", d) |
| 4489 | } |
| 4490 | if l := lifeOf(t, fx, "tmp-a"); l.ExpiresAt != exp.Unix() { |
| 4491 | t.Fatalf("row = %+v", l) |
| 4492 | } |
| 4493 | } |
| 4494 | |
| 4495 | // Review Focus 4. |
| 4496 | func TestTTLAndKeepRefuseWhatTheyCannotChange(t *testing.T) { |
| 4497 | fx, token := temporaryFixture(t) |
| 4498 | for _, tail := range []string{"/keep", "/ttl"} { |
| 4499 | status, out := postJSON(t, appURL(fx, "kass", tail), map[string]int64{"ttl_seconds": 86400}, token) |
| 4500 | if status != http.StatusConflict { |
| 4501 | t.Errorf("%s on a permanent app = %d %v, want 409", tail, status, out) |
| 4502 | } |
| 4503 | } |
| 4504 | if l := lifeOf(t, fx, "kass"); l.Temporary() { |
| 4505 | t.Fatal("a door made a permanent app temporary") |
| 4506 | } |
| 4507 | tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour)) |
| 4508 | if status, _ := postJSON(t, appURL(fx, "tmp-a", "/ttl"), map[string]int64{"ttl_seconds": 3599}, token); status != http.StatusBadRequest { |
| 4509 | t.Errorf("ttl 3599 = %d, want 400", status) |
| 4510 | } |
| 4511 | if status, _ := postJSON(t, appURL(fx, "tmp-a", "/ttl"), map[string]int64{}, token); status != http.StatusBadRequest { |
| 4512 | t.Errorf("ttl with no ttl_seconds = %d, want 400", status) |
| 4513 | } |
| 4514 | fx.st.TrashAppRow(OpsAccountID, "tmp-a", time.Now()) |
| 4515 | trashed := *lifeOf(t, fx, "tmp-a") |
| 4516 | // The live-app gate's answer is http.NotFound's plain text, not JSON, so |
| 4517 | // these go through rawReq (apiinstances_test.go:42): postJSON's doJSON |
| 4518 | // fails the test on any body that does not decode (authcli_test.go:62). |
| 4519 | for _, tail := range []string{"/keep", "/ttl"} { |
| 4520 | status, h, body := rawReq(t, http.MethodPost, appURL(fx, "tmp-a", tail), token, `{"ttl_seconds":86400}`) |
| 4521 | assertNotFound(t, tail+" on a trashed app", canonicalNotFound(t), status, h, body) |
| 4522 | } |
| 4523 | if l := lifeOf(t, fx, "tmp-a"); l == nil || *l != trashed { |
| 4524 | t.Fatalf("row = %+v after refused keep/ttl, want unchanged %+v", l, trashed) |
| 4525 | } |
| 4526 | } |
| 4527 | |
| 4528 | // Spec §9 "failure at step 4 followed by a keep": the marker is written and |
| 4529 | // the row is live and expired. Keep settles first, finds the app in the |
| 4530 | // Trash, and refuses; the row is left trashed with the 3-day window. |
| 4531 | func TestKeepAfterAFailedExpiryStepFourIsRefused(t *testing.T) { |
| 4532 | fx, token := temporaryFixture(t) |
| 4533 | at := time.Now().Truncate(time.Second).Add(-time.Minute) |
| 4534 | tempApp(t, fx, "tmp-a", at) |
| 4535 | if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "tmp-a", expiryActor, at); err != nil { |
| 4536 | t.Fatal(err) |
| 4537 | } |
| 4538 | status, out := postJSON(t, appURL(fx, "tmp-a", "/keep"), map[string]string{}, token) |
| 4539 | if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "carlos apps restore") { |
| 4540 | t.Fatalf("keep = %d %v, want 409 pointing at carlos apps restore", status, out) |
| 4541 | } |
| 4542 | if l := lifeOf(t, fx, "tmp-a"); l.DeletedAt != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 || !l.Temporary() { |
| 4543 | t.Fatalf("row = %+v, want trashed at the marker's clock with the 3-day window, still temporary", l) |
| 4544 | } |
| 4545 | } |
| 4546 | |
| 4547 | // Spec §9 "/list": trashed apps shown; the accounts listing's []string |
| 4548 | // unchanged; service-principal filtering matches. |
| 4549 | func TestListShowsTheTrashAndFiltersServices(t *testing.T) { |
| 4550 | fx, token := temporaryFixture(t) |
| 4551 | now := time.Now().Truncate(time.Second) |
| 4552 | tempApp(t, fx, "tmp-live", now.Add(3*24*time.Hour)) |
| 4553 | tempApp(t, fx, "tmp-gone", now.Add(time.Hour)) |
| 4554 | fx.st.TrashAppRow(OpsAccountID, "tmp-gone", now) |
| 4555 | fx.st.TrashAppRow(OpsAccountID, "kass", now) |
| 4556 | |
| 4557 | list := func(tok string) map[string]map[string]any { |
| 4558 | t.Helper() |
| 4559 | status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/list", tok) |
| 4560 | if status != http.StatusOK { |
| 4561 | t.Fatalf("list = %d %s", status, body) |
| 4562 | } |
| 4563 | var rows []map[string]any |
| 4564 | if err := json.Unmarshal([]byte(body), &rows); err != nil { |
| 4565 | t.Fatalf("list is not a JSON array: %v\n%s", err, body) |
| 4566 | } |
| 4567 | out := map[string]map[string]any{} |
| 4568 | for _, r := range rows { |
| 4569 | out[r["app"].(string)] = r |
| 4570 | } |
| 4571 | return out |
| 4572 | } |
| 4573 | rows := list(token) |
| 4574 | if len(rows) != 3 { |
| 4575 | t.Fatalf("list = %v, want tmp-live, tmp-gone and kass", rows) |
| 4576 | } |
| 4577 | if r := rows["tmp-live"]; r["deleted_at"] != nil || r["purge_after"] != nil || r["expires_at"] != now.Add(3*24*time.Hour).UTC().Format(time.RFC3339) { |
| 4578 | t.Errorf("tmp-live = %v", r) |
| 4579 | } |
| 4580 | if r := rows["tmp-gone"]; r["purge_after"] != now.Add(72*time.Hour).UTC().Format(time.RFC3339) { |
| 4581 | t.Errorf("tmp-gone = %v, want purge_after 3 days after the trash", r) |
| 4582 | } |
| 4583 | if r := rows["kass"]; r["purge_after"] != now.Add(30*24*time.Hour).UTC().Format(time.RFC3339) || r["expires_at"] != nil { |
| 4584 | t.Errorf("kass = %v, want purge_after 30 days after the trash", r) |
| 4585 | } |
| 4586 | // The accounts listing keeps its []string of LIVE names. |
| 4587 | _, accts := getJSON(t, fx.ts.URL+"/api/cli/apps", token) |
| 4588 | for _, a := range accts["accounts"].([]any) { |
| 4589 | for _, name := range a.(map[string]any)["apps"].([]any) { |
| 4590 | if _, ok := name.(string); !ok || name == "kass" || name == "tmp-gone" { |
| 4591 | t.Errorf("accounts listing apps = %v, want live names only, as strings", a) |
| 4592 | } |
| 4593 | } |
| 4594 | } |
| 4595 | // An app-scoped service sees only its app. |
| 4596 | svc := serviceToken(t, fx.st, OpsAccountID, "live-ci", "tmp-live", svcrole.Publish) |
| 4597 | if rows := list(svc); len(rows) != 1 || rows["tmp-live"] == nil { |
| 4598 | t.Errorf("app-scoped list = %v, want only tmp-live", rows) |
| 4599 | } |
| 4600 | } |
| 4601 | |
| 4602 | func TestBrowserKeepButton(t *testing.T) { |
| 4603 | fx := newConsoleFixture(t) |
| 4604 | tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour)) |
| 4605 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-keep-button") |
| 4606 | if _, _, page := get(t, c, fx.u("/apps/tmp-a")); !strings.Contains(page, fx.path("/apps/tmp-a/keep")) { |
| 4607 | t.Fatal("the app page of a temporary app has no Keep form") |
| 4608 | } |
| 4609 | if _, _, page := get(t, c, fx.u("/apps/kass")); strings.Contains(page, "/apps/kass/keep") { |
| 4610 | t.Fatal("a permanent app shows a Keep form") |
| 4611 | } |
| 4612 | status, _, _ := postForm(t, c, fx.u("/apps/tmp-a/keep"), url.Values{"csrf": {csrf}}) |
| 4613 | if status != http.StatusSeeOther { |
| 4614 | t.Fatalf("keep = %d, want 303", status) |
| 4615 | } |
| 4616 | if l := lifeOf(t, fx, "tmp-a"); l.Temporary() { |
| 4617 | t.Fatal("the Keep button did not keep") |
| 4618 | } |
| 4619 | } |
| 4620 | ``` |
| 4621 | |
| 4622 | - [ ] **Step 2: Run the tests to verify they fail** |
| 4623 | |
| 4624 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestKeep|TestTTL|TestListShowsTheTrashAndFiltersServices|TestBrowserKeepButton' -count=1` |
| 4625 | Expected: FAIL: the `/keep`, `/ttl` and `/list` requests answer 404 or 405, and the app page has no Keep form. |
| 4626 | |
| 4627 | - [ ] **Step 3: Implement** |
| 4628 | |
| 4629 | Append to `internal/console/apitemporary.go` (add `"context"` and `"crypto/subtle"` to its imports): |
| 4630 | |
| 4631 | ```go |
| 4632 | var ( |
| 4633 | errAppGone = errors.New("console: no such app") |
| 4634 | errTemporaryInTrash = errors.New("console: the app is in the trash") |
| 4635 | errNotTemporary = errors.New("console: the app is permanent") |
| 4636 | ) |
| 4637 | |
| 4638 | // temporaryLive takes the app's lock, settles, and returns the row if it is |
| 4639 | // a live temporary app. The caller runs its update under the same lock. |
| 4640 | // Settling first is what makes a keep after a failed expiry step 4 find the |
| 4641 | // app in the Trash and refuse ("restore it first"), rather than succeed on a |
| 4642 | // row the bucket has already trashed (spec §5). |
| 4643 | func (s *Server) temporaryLive(ctx context.Context, acct *Account, app string, now time.Time) (*AppLife, error) { |
| 4644 | if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil { |
| 4645 | return nil, err |
| 4646 | } |
| 4647 | life, err := s.store.AppLife(acct.ID, app) |
| 4648 | switch { |
| 4649 | case err != nil: |
| 4650 | return nil, err |
| 4651 | case life == nil: |
| 4652 | return nil, errAppGone |
| 4653 | case life.Trashed() || life.Restoring: |
| 4654 | return nil, errTemporaryInTrash |
| 4655 | case !life.Temporary(): |
| 4656 | return nil, errNotTemporary |
| 4657 | } |
| 4658 | return life, nil |
| 4659 | } |
| 4660 | |
| 4661 | // keepApp is `carlos apps keep` and the Keep button: expires_at = 0. |
| 4662 | func (s *Server) keepApp(ctx context.Context, acct *Account, app string, now time.Time) error { |
| 4663 | defer s.lockApp(acct.Sqid, app)() |
| 4664 | if _, err := s.temporaryLive(ctx, acct, app, now); err != nil { |
| 4665 | return err |
| 4666 | } |
| 4667 | ok, err := s.store.KeepApp(acct.ID, app) |
| 4668 | if err != nil { |
| 4669 | return err |
| 4670 | } |
| 4671 | if !ok { |
| 4672 | return errTemporaryInTrash |
| 4673 | } |
| 4674 | return nil |
| 4675 | } |
| 4676 | |
| 4677 | // setAppTTL is `carlos apps ttl`: expires_at = now + ttl, live temporary |
| 4678 | // apps only (spec §7). It never sets an expiry on a permanent row. |
| 4679 | func (s *Server) setAppTTL(ctx context.Context, acct *Account, app string, ttl time.Duration, now time.Time) (time.Time, error) { |
| 4680 | defer s.lockApp(acct.Sqid, app)() |
| 4681 | if _, err := s.temporaryLive(ctx, acct, app, now); err != nil { |
| 4682 | return time.Time{}, err |
| 4683 | } |
| 4684 | exp := now.Add(ttl).Truncate(time.Second) |
| 4685 | ok, err := s.store.SetAppExpiry(acct.ID, app, exp) |
| 4686 | if err != nil { |
| 4687 | return time.Time{}, err |
| 4688 | } |
| 4689 | if !ok { |
| 4690 | return time.Time{}, errTemporaryInTrash |
| 4691 | } |
| 4692 | return exp, nil |
| 4693 | } |
| 4694 | |
| 4695 | // lifecycleRefusal maps keepApp/setAppTTL's errors onto the JSON answer. |
| 4696 | // It reports whether it wrote one. |
| 4697 | func lifecycleRefusal(w http.ResponseWriter, r *http.Request, app string, err error) bool { |
| 4698 | switch { |
| 4699 | case err == nil: |
| 4700 | return false |
| 4701 | case errors.Is(err, errAppGone): |
| 4702 | http.NotFound(w, r) |
| 4703 | case errors.Is(err, errTemporaryInTrash): |
| 4704 | jsonError(w, http.StatusConflict, app+" is in the Trash. Restore it first with: carlos apps restore "+app) // COPY-REVIEW C9.1 |
| 4705 | case errors.Is(err, errNotTemporary): |
| 4706 | jsonError(w, http.StatusConflict, app+" is permanent. It has no expiry to move or keep") // COPY-REVIEW C9.2 |
| 4707 | default: |
| 4708 | log.Printf("console: lifecycle %s: %v", app, err) |
| 4709 | jsonError(w, http.StatusInternalServerError, "could not change "+app+". Nothing was changed. Try again") // COPY-REVIEW C9.3 |
| 4710 | } |
| 4711 | return true |
| 4712 | } |
| 4713 | |
| 4714 | func (s *Server) handleAPIAppKeep(w http.ResponseWriter, r *http.Request) { |
| 4715 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 4716 | if !ok { |
| 4717 | return |
| 4718 | } |
| 4719 | p, _ := principalFrom(r.Context()) |
| 4720 | if !s.allowWrite(p, time.Now()) { |
| 4721 | jsonError(w, http.StatusTooManyRequests, "too many requests. Wait a minute and try again") // COPY-REVIEW C9.4 |
| 4722 | return |
| 4723 | } |
| 4724 | ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| 4725 | defer cancel() |
| 4726 | if lifecycleRefusal(w, r, app, s.keepApp(ctx, acct, app, time.Now())) { |
| 4727 | return |
| 4728 | } |
| 4729 | s.audit(acct, app, addr, "keep", "made "+app+" permanent (cli)") // COPY-REVIEW C9.5 |
| 4730 | writeJSON(w, http.StatusOK, map[string]any{"app": app, "expires_at": nil}) |
| 4731 | } |
| 4732 | |
| 4733 | func (s *Server) handleAPIAppTTL(w http.ResponseWriter, r *http.Request) { |
| 4734 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 4735 | if !ok { |
| 4736 | return |
| 4737 | } |
| 4738 | p, _ := principalFrom(r.Context()) |
| 4739 | if !s.allowWrite(p, time.Now()) { |
| 4740 | jsonError(w, http.StatusTooManyRequests, "too many requests. Wait a minute and try again") // COPY-REVIEW C9.4 |
| 4741 | return |
| 4742 | } |
| 4743 | var req struct { |
| 4744 | TTLSeconds int64 `json:"ttl_seconds"` |
| 4745 | } |
| 4746 | if !readJSONStrictLimit(w, r, &req, 8<<10) { |
| 4747 | return |
| 4748 | } |
| 4749 | ttl, err := temporaryTTL(req.TTLSeconds, false) |
| 4750 | if err != nil { |
| 4751 | jsonError(w, http.StatusBadRequest, err.Error()) |
| 4752 | return |
| 4753 | } |
| 4754 | ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| 4755 | defer cancel() |
| 4756 | exp, err := s.setAppTTL(ctx, acct, app, ttl, time.Now()) |
| 4757 | if lifecycleRefusal(w, r, app, err) { |
| 4758 | return |
| 4759 | } |
| 4760 | s.audit(acct, app, addr, "ttl", "set "+app+" to expire "+exp.UTC().Format("2006-01-02 15:04 UTC")+" (cli)") // COPY-REVIEW C9.6 |
| 4761 | writeJSON(w, http.StatusOK, map[string]any{"app": app, "expires_at": exp.UTC().Format(time.RFC3339)}) |
| 4762 | } |
| 4763 | |
| 4764 | // handleAPIAppList is GET /api/cli/apps/{account}/list (spec §7): every app |
| 4765 | // the caller can see in the account, trashed included, with its lifecycle |
| 4766 | // times. New rather than a change to GET /api/cli/apps, whose "apps" field is |
| 4767 | // a []string that clients decode as such. An app-scoped service sees its own |
| 4768 | // app only, the filter handleAPIApps applies (apicli.go:532). |
| 4769 | func (s *Server) handleAPIAppList(w http.ResponseWriter, r *http.Request) { |
| 4770 | p, acct, ok := s.apiPrincipalAccount(w, r) |
| 4771 | if !ok { |
| 4772 | return |
| 4773 | } |
| 4774 | lives, err := s.store.AppLivesIn(acct.ID) |
| 4775 | if err != nil { |
| 4776 | log.Printf("console: api list %s: %v", acct.Sqid, err) |
| 4777 | jsonError(w, http.StatusInternalServerError, "could not load apps") // COPY-REVIEW C9.7 |
| 4778 | return |
| 4779 | } |
| 4780 | stamp := func(unix int64) any { |
| 4781 | if unix == 0 { |
| 4782 | return nil |
| 4783 | } |
| 4784 | return time.Unix(unix, 0).UTC().Format(time.RFC3339) |
| 4785 | } |
| 4786 | out := make([]map[string]any, 0, len(lives)) |
| 4787 | for _, l := range lives { |
| 4788 | if p.Kind == PrincipalService && p.App != "" && l.App != p.App { |
| 4789 | continue |
| 4790 | } |
| 4791 | entry := map[string]any{"app": l.App, "deleted_at": stamp(l.DeletedAt), "expires_at": stamp(l.ExpiresAt), "purge_after": nil} |
| 4792 | if l.Trashed() { |
| 4793 | entry["purge_after"] = effectivePurgeAt(l.DeletedAt, l.PurgeAfter).Format(time.RFC3339) |
| 4794 | } |
| 4795 | out = append(out, entry) |
| 4796 | } |
| 4797 | writeJSON(w, http.StatusOK, out) |
| 4798 | } |
| 4799 | |
| 4800 | // handleAppKeep is the app page's Keep button (spec §7): any member, CSRF, |
| 4801 | // no passkey, because keep only makes the app safer. |
| 4802 | func (s *Server) handleAppKeep(w http.ResponseWriter, r *http.Request) { |
| 4803 | addr, raw, acct, _, app, ok := s.accountApp(w, r) |
| 4804 | if !ok { |
| 4805 | return |
| 4806 | } |
| 4807 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 4808 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 4809 | return |
| 4810 | } |
| 4811 | back := func(kind, msg string) { |
| 4812 | s.setFlash(w, kind, msg) |
| 4813 | http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| 4814 | } |
| 4815 | ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| 4816 | defer cancel() |
| 4817 | switch err := s.keepApp(ctx, acct, app, time.Now()); { |
| 4818 | case err == nil: |
| 4819 | case errors.Is(err, errNotTemporary): |
| 4820 | back("ok", app+" is already permanent.") // COPY-REVIEW C9.8 |
| 4821 | return |
| 4822 | case errors.Is(err, errTemporaryInTrash), errors.Is(err, errAppGone): |
| 4823 | back("err", app+" is in the Trash. Restore it from the Trash page first.") // COPY-REVIEW C9.9 |
| 4824 | return |
| 4825 | default: |
| 4826 | log.Printf("console: keep %s/%s: %v", acct.Sqid, app, err) |
| 4827 | back("err", "Could not keep "+app+". Nothing was changed. Try again.") // COPY-REVIEW C9.10 |
| 4828 | return |
| 4829 | } |
| 4830 | s.audit(acct, app, addr, "keep", "made "+app+" permanent") // COPY-REVIEW C9.5 |
| 4831 | back("ok", app+" is permanent now. It will not expire.") // COPY-REVIEW C9.11 |
| 4832 | } |
| 4833 | ``` |
| 4834 | |
| 4835 | `internal/console/authcli.go`, after the `/new-temporary` route: |
| 4836 | |
| 4837 | ```go |
| 4838 | s.cliRoute("GET /api/cli/apps/{account}/list", svcrole.Allow(svcrole.Publish), s.handleAPIAppList) |
| 4839 | s.cliRoute("POST /api/cli/apps/{account}/{app}/ttl", svcrole.Allow(svcrole.Publish), s.handleAPIAppTTL) |
| 4840 | s.cliRoute("POST /api/cli/apps/{account}/{app}/keep", svcrole.Allow(svcrole.Publish), s.handleAPIAppKeep) |
| 4841 | ``` |
| 4842 | |
| 4843 | `internal/console/server.go`, after `POST /a/{sqid}/apps/{app}/rename` (line 1201): |
| 4844 | |
| 4845 | ```go |
| 4846 | s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/keep", s.handleAppKeep) |
| 4847 | ``` |
| 4848 | |
| 4849 | `page` struct, next to `Trashed` (line 1441): |
| 4850 | |
| 4851 | ```go |
| 4852 | // ExpiresAt is when a temporary app moves to the Trash; zero for a |
| 4853 | // permanent one. appnav shows it with the Keep button. |
| 4854 | ExpiresAt time.Time |
| 4855 | ``` |
| 4856 | |
| 4857 | `appPage` (server.go), after `p.Detail = &views[0]` (line 2685): |
| 4858 | |
| 4859 | ```go |
| 4860 | if life, err := s.store.AppLife(acct.ID, app); err != nil { |
| 4861 | log.Printf("console: app lifecycle %s/%s: %v", acct.Sqid, app, err) |
| 4862 | } else if life != nil && life.Temporary() { |
| 4863 | p.ExpiresAt = time.Unix(life.ExpiresAt, 0).UTC() |
| 4864 | } |
| 4865 | ``` |
| 4866 | |
| 4867 | `internal/console/templates/appnav.html`, the `pagehead` block becomes: |
| 4868 | |
| 4869 | ```html |
| 4870 | <div class="pagehead"> |
| 4871 | <p class="eyebrow">App · {{.Account.Name}}</p> |
| 4872 | <h1>{{.AppName}}</h1> |
| 4873 | {{if not .ExpiresAt.IsZero}} |
| 4874 | <!-- COPY-REVIEW C9.12 --> |
| 4875 | <p class="small">Temporary. It moves to the Trash on {{fmtTime .ExpiresAt}}, and is deleted 3 days after that.</p> |
| 4876 | <form method="post" action="{{.Base}}/apps/{{.AppName}}/keep"> |
| 4877 | <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| 4878 | <!-- COPY-REVIEW C9.13 --> |
| 4879 | <button type="submit">Keep this app</button> |
| 4880 | </form> |
| 4881 | {{end}} |
| 4882 | </div> |
| 4883 | ``` |
| 4884 | |
| 4885 | (`.Base` is what every form posts under, server.go:1432; `.CSRF` is on every account page.) |
| 4886 | |
| 4887 | - [ ] **Step 4: Run the tests to verify they pass** |
| 4888 | |
| 4889 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestKeep|TestTTL|TestListShowsTheTrashAndFiltersServices|TestBrowserKeepButton|TestEveryCLIDoorNamesItsRoles|TestAPIApps|TestAppsListForAService' -count=1` |
| 4890 | Expected: PASS. |
| 4891 | |
| 4892 | - [ ] **Step 5: Commit** |
| 4893 | |
| 4894 | ```bash |
| 4895 | gofmt -l internal/console |
| 4896 | git add internal/console/apitemporary.go internal/console/apitemporary_test.go internal/console/authcli.go internal/console/server.go internal/console/templates/appnav.html |
| 4897 | git commit -m "console: ttl, keep and list doors, and a Keep button on temporary apps |
| 4898 | |
| 4899 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 4900 | ``` |
| 4901 | |
| 4902 | --- |
| 4903 | |
| 4904 | ### Task 10: One deadline, everywhere it is shown |
| 4905 | |
| 4906 | Spec §5 "One deadline, everywhere": the Trash view, the trash API's `restorable_until`, the browser delete confirmation and the CLI all show `effectivePurgeAt`, and direct-mode delete stops promising a window it cannot know. |
| 4907 | |
| 4908 | **Files:** |
| 4909 | - Modify: `internal/console/store.go:1509-1540` (`TrashedApp`, `TrashedAppsIn`) |
| 4910 | - Modify: `internal/console/view.go:475-494` (`TrashedApps`) |
| 4911 | - Modify: `internal/console/templates/trash.html:10-11, :35-39, :51-52` |
| 4912 | - Modify: `internal/console/templates/app_settings.html:488-493` (the delete confirmation's restore window), `internal/console/templates/apps.html:24-25` (the account page's Trash link) |
| 4913 | - Modify: `internal/console/server.go:71-73` (template funcs: add `temporaryCooloffDays` beside `trashCooloffDays`) |
| 4914 | - Modify: `internal/console/apicli.go:1279-1295` (`handleAPITrashApp` response) |
| 4915 | - Modify: `internal/console/appsettings.go:1052-1054` (`handleAppDelete` flash) |
| 4916 | - Modify: `apps.go:84-120` (`appsUsage`: lines 88, 94 and 120 promise 30 days), `:222-243` (`runAppsDelete`), `:262-294` (`headlessTrash`), `:302-308` (`printTrashed`), `:384-385` (`runAppsRestore`'s headless line) |
| 4917 | - Create: `temporary.go` (package main: `inDuration`) |
| 4918 | - Test: `internal/console/deletion_test.go`, `apps_test.go` |
| 4919 | |
| 4920 | **Interfaces:** |
| 4921 | - Consumes: `effectivePurgeAt`, `Store.AppLife` (Task 2); `release.TemporaryCooloff` (Task 1). |
| 4922 | - Produces: |
| 4923 | - `TrashedApp.PurgeAfter int64` |
| 4924 | - `func inDuration(d time.Duration) string` (package main): `in 3d`, `in 5h`, `in 12m`, `soon` for d <= 0 |
| 4925 | - `func restoreHint(app string) string` (package main): delete's line about getting the app back, which never names `carlos add` for a `tmp-` app |
| 4926 | - `func printTrashed(app, restorableUntil string, now time.Time)` (was `printTrashed(app, restorableUntil string)`) |
| 4927 | - `func headlessTrash(ctx context.Context, store release.Store, pub ed25519.PublicKey, acct, app string, now time.Time) (time.Time, error)` now returns the marker's `DeletedAt` (was `DeletedAt + TrashCooloff`) |
| 4928 | - `func headlessTrashLines(app string, deletedAt, now time.Time) []string` |
| 4929 | - template func `temporaryCooloffDays` (3, from `release.TemporaryCooloff`); the delete confirmation picks it when `.ExpiresAt` (Task 9's `page` field, set by `appPage`) is non-zero |
| 4930 | |
| 4931 | Every surface that promised 30 days was found with `grep -rn "trashCooloffDays\|TrashCooloff\|30 days\|30-day\|thirty" internal/console/*.go internal/console/templates/ *.go docs/site/cli.md` (excluding tests and the unrelated mail, analytics and session hits). The user-facing ones and where each is handled: `trash.html:10, :35, :52` (this task), `app_settings.html:492` (this task), `apps.html:25` (this task), `appsettings.go:1058` browser delete flash (this task), `apicli.go:1295` `restorable_until` (this task), `view.go:492` Trash view (this task), `apps.go:88, :94, :120` usage and `apps.go:307` headless (this task), `docs/site/cli.md:130` (Task 11). The rest are code comments. |
| 4932 | |
| 4933 | - [ ] **Step 1: Write the failing tests** |
| 4934 | |
| 4935 | Append to `internal/console/deletion_test.go`: |
| 4936 | |
| 4937 | ```go |
| 4938 | // Spec §9 "Deadline display": a temporary and a permanent app trashed |
| 4939 | // together show 3 and 30 days, on the Trash page and in the trash API. |
| 4940 | func TestTrashShowsEachAppsOwnDeadline(t *testing.T) { |
| 4941 | fx := newConsoleFixture(t) |
| 4942 | tempApp(t, fx, "tmp-a", time.Now().Add(24*time.Hour)) |
| 4943 | token := cliToken(t, fx, "paul@keymail.dev", "code-deadlines") |
| 4944 | until := map[string]time.Time{} |
| 4945 | for _, app := range []string{"tmp-a", "kass"} { |
| 4946 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/"+app+"/delete", map[string]string{}, token) |
| 4947 | if status != http.StatusOK { |
| 4948 | t.Fatalf("delete %s = %d %v", app, status, out) |
| 4949 | } |
| 4950 | u, err := time.Parse(time.RFC3339, str(t, out, "restorable_until")) |
| 4951 | if err != nil { |
| 4952 | t.Fatal(err) |
| 4953 | } |
| 4954 | until[app] = u |
| 4955 | } |
| 4956 | tmp, kass := lifeOf(t, fx, "tmp-a"), lifeOf(t, fx, "kass") |
| 4957 | if want := time.Unix(tmp.DeletedAt, 0).Add(72 * time.Hour); !until["tmp-a"].Equal(want) { |
| 4958 | t.Errorf("tmp-a restorable_until = %v, want %v", until["tmp-a"], want) |
| 4959 | } |
| 4960 | if want := time.Unix(kass.DeletedAt, 0).Add(30 * 24 * time.Hour); !until["kass"].Equal(want) { |
| 4961 | t.Errorf("kass restorable_until = %v, want %v", until["kass"], want) |
| 4962 | } |
| 4963 | c, _ := signInAs(t, fx, "paul@keymail.dev", "code-deadlines-web") |
| 4964 | _, _, page := get(t, c, fx.u("/trash")) |
| 4965 | for app, u := range until { |
| 4966 | if !strings.Contains(page, u.UTC().Format("2006-01-02 15:04 UTC")) { |
| 4967 | t.Errorf("the Trash page does not show %s's deadline %v", app, u) |
| 4968 | } |
| 4969 | } |
| 4970 | } |
| 4971 | ``` |
| 4972 | |
| 4973 | Also append to `internal/console/deletion_test.go`: |
| 4974 | |
| 4975 | ```go |
| 4976 | // Astra plan round 3: the delete confirmation on Settings promises the window |
| 4977 | // the app will actually get, before anything is deleted: 3 days for a |
| 4978 | // temporary app, 30 for a permanent one. The number sits in its own element |
| 4979 | // so the assertion survives any rewording of the sentence around it. |
| 4980 | func TestDeleteConfirmationNamesEachAppsWindow(t *testing.T) { |
| 4981 | fx := newConsoleFixture(t) |
| 4982 | tempApp(t, fx, "tmp-a", time.Now().Add(24*time.Hour)) |
| 4983 | c, _ := signInAs(t, fx, "paul@keymail.dev", "code-delete-window") |
| 4984 | for app, days := range map[string]string{"tmp-a": "3", "kass": "30"} { |
| 4985 | status, _, page := get(t, c, fx.u("/apps/"+app+"/settings/delete")) |
| 4986 | if status != http.StatusOK { |
| 4987 | t.Fatalf("%s delete settings = %d", app, status) |
| 4988 | } |
| 4989 | if want := `class="restore-days">` + days + ` days<`; !strings.Contains(page, want) { |
| 4990 | t.Errorf("%s: the delete confirmation does not promise %s days (want %q in the page)", app, days, want) |
| 4991 | } |
| 4992 | } |
| 4993 | } |
| 4994 | ``` |
| 4995 | |
| 4996 | Replace `TestHeadlessTrashHappyPathWritesMarkerAndReturnsItsWindow` (apps_test.go:426) and add the display tests: |
| 4997 | |
| 4998 | ```go |
| 4999 | func TestHeadlessTrashReturnsTheMarkersClock(t *testing.T) { |
| 5000 | store, _, pub := newHeadlessTestStore(t) |
| 5001 | ctx := context.Background() |
| 5002 | now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) |
| 5003 | deletedAt, err := headlessTrash(ctx, store, pub, "bab", "myapp", now) |
| 5004 | if err != nil { |
| 5005 | t.Fatalf("headlessTrash: %v", err) |
| 5006 | } |
| 5007 | if !deletedAt.Equal(now) { |
| 5008 | t.Fatalf("deletedAt = %v, want the marker's %v", deletedAt, now) |
| 5009 | } |
| 5010 | // A retry two days in re-reads the marker: the clock never restarts. |
| 5011 | again, err := headlessTrash(ctx, store, pub, "bab", "myapp", now.Add(48*time.Hour)) |
| 5012 | if err != nil || !again.Equal(now) { |
| 5013 | t.Fatalf("retry = %v %v, want the first trash's clock %v", again, err, now) |
| 5014 | } |
| 5015 | } |
| 5016 | |
| 5017 | // Spec §5 "Direct mode" and §9: both dates from the marker, and a date |
| 5018 | // already passed is said as such. |
| 5019 | func TestHeadlessTrashLinesNameBothWindows(t *testing.T) { |
| 5020 | at := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) |
| 5021 | tmp, perm := "2026-08-07 12:00 UTC", "2026-09-03 12:00 UTC" |
| 5022 | for _, now := range []time.Time{at, at.Add(48 * time.Hour)} { |
| 5023 | out := strings.Join(headlessTrashLines("myapp", at, now), "\n") |
| 5024 | if !strings.Contains(out, tmp) || !strings.Contains(out, perm) || !strings.Contains(out, "carlos apps list") { |
| 5025 | t.Fatalf("at %v:\n%s\nwant both %s and %s, and carlos apps list", now, out, tmp, perm) |
| 5026 | } |
| 5027 | } |
| 5028 | out := strings.Join(headlessTrashLines("myapp", at, at.Add(4*24*time.Hour)), "\n") |
| 5029 | if strings.Contains(out, tmp) || !strings.Contains(out, perm) { |
| 5030 | t.Fatalf("after 3 days:\n%s\nwant the temporary date replaced by 'may be purged at any time'", out) |
| 5031 | } |
| 5032 | } |
| 5033 | |
| 5034 | func TestPrintTrashedSaysHowLong(t *testing.T) { |
| 5035 | buf := captureStdout(t) |
| 5036 | now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) |
| 5037 | printTrashed("tmp-a", now.Add(72*time.Hour).Format(time.RFC3339), now) |
| 5038 | if out := buf.String(); !strings.Contains(out, "in 3d") || !strings.Contains(out, "2026-09-29 12:00 UTC") || !strings.Contains(out, "carlos apps restore tmp-a") { |
| 5039 | t.Fatalf("printTrashed:\n%s", out) |
| 5040 | } |
| 5041 | } |
| 5042 | |
| 5043 | func TestRestoreHintNeverSendsATmpAppToCarlosAdd(t *testing.T) { |
| 5044 | if h := restoreHint("tmp-a"); strings.Contains(h, "carlos add") { |
| 5045 | t.Errorf("restoreHint(tmp-a) = %q: carlos add refuses every tmp- name", h) |
| 5046 | } |
| 5047 | if h := restoreHint("docs"); !strings.Contains(h, "carlos add") { |
| 5048 | t.Errorf("restoreHint(docs) = %q, want the carlos add advice kept for ordinary apps", h) |
| 5049 | } |
| 5050 | buf := captureStdout(t) |
| 5051 | printTrashed("tmp-a", "2026-09-29T12:00:00Z", time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)) |
| 5052 | if strings.Contains(buf.String(), "carlos add") { |
| 5053 | t.Errorf("delete of a temporary app points at carlos add:\n%s", buf.String()) |
| 5054 | } |
| 5055 | } |
| 5056 | |
| 5057 | func TestInDuration(t *testing.T) { |
| 5058 | for d, want := range map[time.Duration]string{ |
| 5059 | 72 * time.Hour: "in 3d", 71 * time.Hour: "in 2d", 5 * time.Hour: "in 5h", |
| 5060 | 12 * time.Minute: "in 12m", 10 * time.Second: "in 1m", 0: "soon", -time.Hour: "soon", |
| 5061 | } { |
| 5062 | if got := inDuration(d); got != want { |
| 5063 | t.Errorf("inDuration(%v) = %q, want %q", d, got, want) |
| 5064 | } |
| 5065 | } |
| 5066 | } |
| 5067 | ``` |
| 5068 | |
| 5069 | `captureStdout(t)` (apimode_test.go:35) swaps the package's `stdout` (cli.go:45) for a buffer and restores it on cleanup. |
| 5070 | |
| 5071 | - [ ] **Step 2: Run the tests to verify they fail** |
| 5072 | |
| 5073 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTrashShowsEachAppsOwnDeadline|TestDeleteConfirmationNamesEachAppsWindow' -count=1` |
| 5074 | Expected: FAIL: `tmp-a restorable_until = <30 days>, want <3 days>`, the Trash page lacks the 3-day date, and neither delete confirmation has a `restore-days` element. |
| 5075 | |
| 5076 | Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestHeadlessTrash|TestPrintTrashedSaysHowLong|TestInDuration|TestRestoreHintNeverSendsATmpAppToCarlosAdd' -count=1` |
| 5077 | Expected: FAIL to compile: `undefined: headlessTrashLines`, `undefined: inDuration`, `printTrashed` has too many arguments. |
| 5078 | |
| 5079 | - [ ] **Step 3: Implement** |
| 5080 | |
| 5081 | `internal/console/store.go`, `TrashedApp` and `TrashedAppsIn` (lines 1509-1540): |
| 5082 | |
| 5083 | ```go |
| 5084 | type TrashedApp struct { |
| 5085 | App string |
| 5086 | DeletedAt time.Time |
| 5087 | PurgeAfter int64 // Unix seconds; 0 = the ordinary 30 days (effectivePurgeAt) |
| 5088 | } |
| 5089 | |
| 5090 | func (s *Store) TrashedAppsIn(accountID int64) ([]TrashedApp, error) { |
| 5091 | rows, err := s.db.Query(`SELECT app, deleted_at, purge_after FROM apps WHERE account_id = ? AND deleted_at != 0 ORDER BY app`, |
| 5092 | accountID) |
| 5093 | if err != nil { |
| 5094 | return nil, err |
| 5095 | } |
| 5096 | defer rows.Close() |
| 5097 | var out []TrashedApp |
| 5098 | for rows.Next() { |
| 5099 | var t TrashedApp |
| 5100 | var deletedAt int64 |
| 5101 | if err := rows.Scan(&t.App, &deletedAt, &t.PurgeAfter); err != nil { |
| 5102 | return nil, err |
| 5103 | } |
| 5104 | t.DeletedAt = time.Unix(deletedAt, 0).UTC() |
| 5105 | out = append(out, t) |
| 5106 | } |
| 5107 | return out, rows.Err() |
| 5108 | } |
| 5109 | ``` |
| 5110 | |
| 5111 | `internal/console/view.go`, in `TrashedApps` (line 491), replace `v.PurgesAt = v.DeletedAt.Add(release.TrashCooloff)` with: |
| 5112 | |
| 5113 | ```go |
| 5114 | // One deadline, everywhere (temporary static sites spec §5): the |
| 5115 | // row's purge_after when the app was temporary at trash time, else |
| 5116 | // the marker's clock plus the 30-day cooloff. |
| 5117 | v.PurgesAt = effectivePurgeAt(v.DeletedAt.Unix(), row.PurgeAfter) |
| 5118 | ``` |
| 5119 | |
| 5120 | `internal/console/templates/trash.html`: the opening paragraph (lines 6-9) keeps its text, which is true for an ordinary app, and is followed by one new sentence for temporary ones, which carlos add refuses: |
| 5121 | |
| 5122 | ```html |
| 5123 | <!-- COPY-REVIEW C10.17 --> |
| 5124 | <p class="small">A temporary app (named tmp-...) needs no carlos add: its instance records are kept |
| 5125 | in the Trash, so hosts serve it again on their own once it is restored.</p> |
| 5126 | ``` |
| 5127 | |
| 5128 | Then the line 10-11 paragraph, the line 35 sentence, and the empty-state paragraph at 51-52 become: |
| 5129 | |
| 5130 | ```html |
| 5131 | <!-- COPY-REVIEW C10.1 --> |
| 5132 | <p class="small">Left alone, the platform destroys a trashed app on its own on the date in the last column: |
| 5133 | 30 days after it was deleted, or 3 days for a temporary app. That is permanent. The objects go, and the |
| 5134 | name is free for anyone in this account to claim again.</p> |
| 5135 | ``` |
| 5136 | |
| 5137 | ```html |
| 5138 | <!-- COPY-REVIEW C10.2 --> |
| 5139 | <p class="small">Deleting now does what the wait would do on its own, immediately and |
| 5140 | permanently: every object the app owns is destroyed and its name is freed. There is no undo |
| 5141 | and no copy anywhere. Type the app's name to confirm, then confirm with your passkey. A |
| 5142 | signed-in browser on its own is not enough. An app still marked as production cannot be |
| 5143 | permanently deleted, whatever you type. Turn its safety delay off first.</p> |
| 5144 | ``` |
| 5145 | |
| 5146 | ```html |
| 5147 | <!-- COPY-REVIEW C10.3 --> |
| 5148 | <p>Nothing here. An app deleted with <code>carlos apps delete</code> waits in this Trash, |
| 5149 | restorable the whole time, until the platform destroys it: 30 days for most apps, 3 days for a |
| 5150 | temporary one.</p> |
| 5151 | ``` |
| 5152 | |
| 5153 | `internal/console/server.go`, template funcs, beside `trashCooloffDays` (line 73): |
| 5154 | |
| 5155 | ```go |
| 5156 | // temporaryCooloffDays is a temporary app's restore window, in days: the |
| 5157 | // delete confirmation shows it for an app with an expiry (temporary static |
| 5158 | // sites spec §5, "the delete confirmation in the browser"). |
| 5159 | "temporaryCooloffDays": func() int { return int(release.TemporaryCooloff.Hours() / 24) }, |
| 5160 | ``` |
| 5161 | |
| 5162 | `internal/console/templates/app_settings.html:488-493`, the delete paragraph becomes (`.ExpiresAt` is non-zero exactly when the app is temporary, set by `appPage` in Task 9): |
| 5163 | |
| 5164 | ```html |
| 5165 | <!-- COPY-REVIEW C10.14 --> |
| 5166 | <p class="small">Deleting puts {{.Detail.App}} in the account's |
| 5167 | <a href="{{.Nav}}/trash">trash</a>: hosts stop serving it on their next pass |
| 5168 | (custom domains included), and it is restorable for |
| 5169 | <strong class="restore-days">{{if .ExpiresAt.IsZero}}{{trashCooloffDays}}{{else}}{{temporaryCooloffDays}}{{end}} days</strong> |
| 5170 | before the platform destroys its releases for good.{{if not .ExpiresAt.IsZero}} It is a temporary app, so its |
| 5171 | window is shorter than usual.{{end}}{{if .Detail.Production.On}} It is marked as production right now, which |
| 5172 | blocks deletion — turn the safety delay off on the <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{.DeploymentsCopy.History}}</a> tab first.{{end}}</p> |
| 5173 | ``` |
| 5174 | |
| 5175 | (The production clause is existing copy, kept byte for byte.) |
| 5176 | |
| 5177 | `internal/console/templates/apps.html:24-25`, the account page's Trash link, which cannot know a single window for a mixed Trash: |
| 5178 | |
| 5179 | ```html |
| 5180 | <!-- COPY-REVIEW C10.15 --> |
| 5181 | {{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a>: deleted apps you can still |
| 5182 | restore, each until the date shown there.</p>{{end}} |
| 5183 | ``` |
| 5184 | |
| 5185 | `handleAPITrashApp` (apicli.go), after `TrashAppRow` succeeds (line 1285), compute the deadline from the row and use it in the response: |
| 5186 | |
| 5187 | ```go |
| 5188 | // One deadline, everywhere (spec §5). If the row cannot be read back, |
| 5189 | // promise the SHORTER window: under-promising a restore deadline is |
| 5190 | // harmless, over-promising is the one outcome a deletion must not produce. |
| 5191 | until := deletedAt.Add(release.TemporaryCooloff) |
| 5192 | if life, lerr := s.store.AppLife(acct.ID, app); lerr == nil && life != nil { |
| 5193 | until = effectivePurgeAt(life.DeletedAt, life.PurgeAfter) |
| 5194 | } else if lerr != nil { |
| 5195 | log.Printf("console: api trash %s/%s: read back the deadline: %v", acct.Sqid, app, lerr) |
| 5196 | } |
| 5197 | ``` |
| 5198 | |
| 5199 | and the response's `"restorable_until"` becomes `until.UTC().Format(time.RFC3339)`. |
| 5200 | |
| 5201 | `handleAppDelete` (appsettings.go:1052-1054): the same `until` computation after `TrashAppRow`, and the flash becomes: |
| 5202 | |
| 5203 | ```go |
| 5204 | s.setFlash(w, "ok", "Moved "+app+" to the trash. You can restore it there until "+ |
| 5205 | until.UTC().Format("2006-01-02 15:04 UTC")+". Hosts stop serving it on their next pass.") // COPY-REVIEW C10.4 |
| 5206 | ``` |
| 5207 | |
| 5208 | Create `temporary.go` (package main) with the relative-time helper the CLI shares and delete's restore hint (Task 11 adds to this file): |
| 5209 | |
| 5210 | ```go |
| 5211 | // temporary.go: the CLI side of temporary apps (temporary static sites spec |
| 5212 | // §7) and the one relative-time format every lifecycle line prints. |
| 5213 | package main |
| 5214 | |
| 5215 | import ( |
| 5216 | "fmt" |
| 5217 | "time" |
| 5218 | |
| 5219 | "github.com/carlosframework/platform/internal/release" |
| 5220 | ) |
| 5221 | |
| 5222 | // restoreHint is what `carlos apps delete` says about getting the app back. |
| 5223 | // carlos add refuses every tmp- name (appRail, cli.go), kept ones included, |
| 5224 | // so a tmp- app is told what actually happens: its instance records are kept |
| 5225 | // through the trash, and hosts mint its routes again once it is restored |
| 5226 | // (internal/provision/reconcile.go:903). |
| 5227 | func restoreHint(app string) string { |
| 5228 | if release.IsTemporaryName(app) { |
| 5229 | return "restoring brings back its releases and channels, and hosts bring its routes back from its instance records" // COPY-REVIEW C10.16 |
| 5230 | } |
| 5231 | return "restoring brings back its releases and channels, but no routes. carlos add <host> serves it again" // COPY-REVIEW C10.7 |
| 5232 | } |
| 5233 | |
| 5234 | // inDuration says how far away d is, in the largest whole unit: "in 3d", |
| 5235 | // "in 5h", "in 12m". Floors, so it never promises more time than there is. |
| 5236 | // Anything already due is "soon". |
| 5237 | func inDuration(d time.Duration) string { |
| 5238 | switch { |
| 5239 | case d <= 0: |
| 5240 | return "soon" // COPY-REVIEW C10.5 |
| 5241 | case d >= 24*time.Hour: |
| 5242 | return fmt.Sprintf("in %dd", int(d/(24*time.Hour))) |
| 5243 | case d >= time.Hour: |
| 5244 | return fmt.Sprintf("in %dh", int(d/time.Hour)) |
| 5245 | default: |
| 5246 | return fmt.Sprintf("in %dm", max(int(d/time.Minute), 1)) |
| 5247 | } |
| 5248 | } |
| 5249 | ``` |
| 5250 | |
| 5251 | `apps.go`: |
| 5252 | |
| 5253 | `headlessTrash`'s last line (line 293) returns the marker's clock: |
| 5254 | |
| 5255 | ```go |
| 5256 | return tomb.DeletedAt, nil |
| 5257 | ``` |
| 5258 | |
| 5259 | (and its doc comment's last bullet now says it returns the marker's own `DeletedAt`, so a retry never restarts the clock). |
| 5260 | |
| 5261 | `printTrashed` (line 302) and the headless lines: |
| 5262 | |
| 5263 | ```go |
| 5264 | // printTrashed is the signed-in delete's output: the console's own deadline |
| 5265 | // (effectivePurgeAt), how far off it is, and the restore command. |
| 5266 | func printTrashed(app, restorableUntil string, now time.Time) { |
| 5267 | when, rel := restorableUntil, "" |
| 5268 | if t, err := time.Parse(time.RFC3339, restorableUntil); err == nil { |
| 5269 | when, rel = t.UTC().Format("2006-01-02 15:04 UTC"), inDuration(t.Sub(now)) |
| 5270 | } |
| 5271 | fmt.Fprintf(stdout, "%s %s. It is purged %s, at %s. Until then, carlos apps restore %s brings it back.\n", // COPY-REVIEW C10.6 |
| 5272 | colorFor(stdout).ok("trashed"), app, rel, when, app) |
| 5273 | mutedf(stdout, "%s", restoreHint(app)) |
| 5274 | } |
| 5275 | |
| 5276 | // headlessTrashLines is direct mode's delete output (spec §5, astra 7.4 and |
| 5277 | // 8.2). There is no console row here, so it cannot know whether the app is |
| 5278 | // temporary, and the tmp- prefix does not say (a kept app keeps it). It names |
| 5279 | // both windows from the marker's own clock, and a date already passed as |
| 5280 | // "may be purged at any time". |
| 5281 | func headlessTrashLines(app string, deletedAt, now time.Time) []string { |
| 5282 | when := func(t time.Time) string { |
| 5283 | if !t.After(now) { |
| 5284 | return "may be purged at any time" // COPY-REVIEW C10.8 |
| 5285 | } |
| 5286 | return "is purged at " + t.UTC().Format("2006-01-02 15:04 UTC") // COPY-REVIEW C10.9 |
| 5287 | } |
| 5288 | return []string{ |
| 5289 | fmt.Sprintf("%s has been in the Trash since %s.", app, deletedAt.UTC().Format("2006-01-02 15:04 UTC")), // COPY-REVIEW C10.10 |
| 5290 | fmt.Sprintf("If it is a temporary app it %s. If not, it %s.", // COPY-REVIEW C10.11 |
| 5291 | when(deletedAt.Add(release.TemporaryCooloff)), when(deletedAt.Add(release.TrashCooloff))), |
| 5292 | "carlos apps list against the console shows which.", // COPY-REVIEW C10.12 |
| 5293 | } |
| 5294 | } |
| 5295 | ``` |
| 5296 | |
| 5297 | `runAppsDelete`: the signed-in branch calls `printTrashed(app, res.RestorableUntil, time.Now())`; the headless branch (lines 238-242) becomes: |
| 5298 | |
| 5299 | ```go |
| 5300 | deletedAt, err := headlessTrash(context.Background(), store, pub, acctID, app, time.Now()) |
| 5301 | if err != nil { |
| 5302 | log.Fatalf("apps delete: %v", err) |
| 5303 | } |
| 5304 | fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("trashed"), app) |
| 5305 | for _, line := range headlessTrashLines(app, deletedAt, time.Now()) { |
| 5306 | fmt.Fprintln(stdout, line) |
| 5307 | } |
| 5308 | mutedf(stdout, "%s", restoreHint(app)) |
| 5309 | ``` |
| 5310 | |
| 5311 | `runAppsRestore`'s headless branch (apps.go:384-385) keeps its line for every other app and says the true thing for a `tmp-` one, placed after the `restored` line and before the existing `mutedf`: |
| 5312 | |
| 5313 | ```go |
| 5314 | if release.IsTemporaryName(app) { |
| 5315 | mutedf(stdout, "%s is restored with its releases and channels intact. Its instance records were kept, "+ |
| 5316 | "so hosts bring its routes back on their next pass", app) // COPY-REVIEW C10.18 |
| 5317 | return |
| 5318 | } |
| 5319 | ``` |
| 5320 | |
| 5321 | The signed-in branch prints the console's own note, which Task 7 already makes right. |
| 5322 | |
| 5323 | `appsUsage`'s `delete` and `restore` paragraphs (apps.go:88, :94) say "restorable for 30 days" and "within the 30-day window", and its last paragraph (apps.go:120) says "Headless deployments wait out the 30 days". Change the first two to "restorable for 30 days, or 3 for a temporary app" and the third to "Headless deployments wait out the window: 30 days, or 3 for a temporary app" (`COPY-REVIEW C10.13`; the usage text is a const, so the marker is a plan annotation only). |
| 5324 | |
| 5325 | The `restore` paragraph (apps.go:94-97) also tells every app to run `carlos add <host>` afterwards, which Task 3 refuses for every `tmp-` name, kept ones included. Append to that paragraph, after "to serve it again.", this sentence (also `COPY-REVIEW C10.13`): |
| 5326 | |
| 5327 | ```text |
| 5328 | A tmp- app needs none of that: its instance records |
| 5329 | are kept in the Trash, so hosts bring its routes back |
| 5330 | on their next pass. |
| 5331 | ``` |
| 5332 | |
| 5333 | Regression, added to Step 1's test file (`apps_test.go`): |
| 5334 | |
| 5335 | ```go |
| 5336 | func TestAppsUsageNeverSendsATmpAppToCarlosAdd(t *testing.T) { |
| 5337 | i := strings.Index(appsUsage, "restore [flags] <app>") |
| 5338 | if i < 0 { |
| 5339 | t.Fatal("appsUsage has no restore paragraph") |
| 5340 | } |
| 5341 | para := appsUsage[i:] |
| 5342 | if j := strings.Index(para, "\n\n"); j >= 0 { |
| 5343 | para = para[:j] |
| 5344 | } |
| 5345 | if !strings.Contains(para, "tmp- app needs none of that") { |
| 5346 | t.Errorf("restore usage does not tell a tmp- app its routes come back on their own:\n%s", para) |
| 5347 | } |
| 5348 | } |
| 5349 | ``` |
| 5350 | |
| 5351 | Add `TestAppsUsageNeverSendsATmpAppToCarlosAdd` to Step 4's `-run` pattern for package `.`. |
| 5352 | |
| 5353 | - [ ] **Step 4: Run the tests to verify they pass** |
| 5354 | |
| 5355 | Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTrashShowsEachAppsOwnDeadline|TestDeleteConfirmationNamesEachAppsWindow|TestTrashPage|TestAPITrash|TestDeleteNow|TestAppDelete|TestAppSettings' -count=1` |
| 5356 | Expected: PASS. |
| 5357 | |
| 5358 | Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestHeadlessTrash|TestPrintTrashedSaysHowLong|TestInDuration|TestRestoreHintNeverSendsATmpAppToCarlosAdd|TestAppsUsageNeverSendsATmpAppToCarlosAdd|TestAPISessionTrashApp' -count=1` |
| 5359 | Expected: PASS. |
| 5360 | |
| 5361 | - [ ] **Step 5: Commit** |
| 5362 | |
| 5363 | ```bash |
| 5364 | gofmt -l . |
| 5365 | git add internal/console/store.go internal/console/view.go internal/console/server.go internal/console/templates/trash.html internal/console/templates/app_settings.html internal/console/templates/apps.html internal/console/apicli.go internal/console/appsettings.go internal/console/deletion_test.go apps.go apps_test.go temporary.go |
| 5366 | git commit -m "console, cli: one purge deadline everywhere; direct mode names both windows |
| 5367 | |
| 5368 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 5369 | ``` |
| 5370 | |
| 5371 | --- |
| 5372 | ### Task 11: The CLI: `carlos deploy --temporary`, `carlos apps ttl|keep|list`, and the docs |
| 5373 | |
| 5374 | **Files:** |
| 5375 | - Modify: `temporary.go` (package main, created in Task 10) |
| 5376 | - Modify: `apimode.go` (after `restoreApp`, line 1622) |
| 5377 | - Modify: `deploy.go:37-66` (flags, usage), `:84-93` (create before app resolution), `:121` (expiry line for an existing temporary app), imports |
| 5378 | - Modify: `apps.go:36-106` (`appsUsage`), `:109-128` (`RunApps`) |
| 5379 | - Modify: `docs/site/cli.md:112-160` (`### carlos apps`), `:211-245` (`### carlos deploy`) |
| 5380 | - Test: `temporary_test.go` (new, package main) |
| 5381 | |
| 5382 | **Interfaces:** |
| 5383 | - Consumes: `inDuration` (Task 10); `release.IsTemporaryName`, `release.TemporaryAppPrefix`, `release.ValidApp` (Task 1); the four console doors and their wire shapes (Tasks 5, 9); `apiSession.postJSON`, `getJSON`, `apiPath`, `apiErrorFromBody`, `resolveMode`, `resolvedAccount`, `printUsing`, `appFlag`, `consoleOption`, `flagUsage`, `flagWasSet`, `mutedf`, `colorFor`, `stdout`; test helper `newTestSession` (apimode_test.go:209). |
| 5384 | - Produces: |
| 5385 | - `const ttlFloor = time.Hour`, `const ttlCeiling = 30 * 24 * time.Hour`, `const ttlDefault = 7 * 24 * time.Hour`, `const temporaryNameAttempts = 5` |
| 5386 | - `func parseTTL(s string) (time.Duration, error)` |
| 5387 | - `func temporaryAppName(app string, random func() string) (string, error)`, `func randomTemporaryLabel() string` |
| 5388 | - `func temporaryDeployFlags(temporary, ttlSet bool, ttlRaw, host string, kindSet bool, kind string, nargs int) (bool, time.Duration, error)` (`ttlSet` is `flagWasSet(fs, "ttl")`, flags.go:98) |
| 5389 | - `func createTemporary(ctx context.Context, sess *apiSession, acct apiAcct, appFlag string, ttl time.Duration, random func() string) (apiCreateTemporaryResult, error)` |
| 5390 | - `func printTemporaryExpiry(ctx context.Context, sess *apiSession, acct apiAcct, app string)` |
| 5391 | - `func appLifeLine(l apiAppLife, now time.Time) string` |
| 5392 | - `func requireSignedInApps(verb, consoleFlag string) *apiSession` |
| 5393 | - `func runAppsTTL(args []string)`, `func runAppsKeep(args []string)`, `func runAppsList(args []string)` |
| 5394 | - apimode.go: `type apiCreateTemporaryResult struct { Account, App, URL string; ExpiresAt time.Time }`, `type apiAppLife struct { App string; DeletedAt, ExpiresAt, PurgeAfter *time.Time }`, `var errTemporaryUnavailable, errTemporaryNameTaken, errTemporaryNotConfirmed, errListUnavailable error`, `func (s *apiSession) createTemporaryApp(ctx context.Context, a apiAcct, app string, ttl time.Duration) (apiCreateTemporaryResult, error)`, `func (s *apiSession) appTTL(ctx context.Context, a apiAcct, app string, ttl time.Duration) (time.Time, error)`, `func (s *apiSession) appKeep(ctx context.Context, a apiAcct, app string) error`, `func (s *apiSession) appList(ctx context.Context, a apiAcct) ([]apiAppLife, error)` |
| 5395 | |
| 5396 | - [ ] **Step 1: Write the failing tests** |
| 5397 | |
| 5398 | ```go |
| 5399 | // temporary_test.go |
| 5400 | package main |
| 5401 | |
| 5402 | import ( |
| 5403 | "context" |
| 5404 | "encoding/json" |
| 5405 | "errors" |
| 5406 | "net/http" |
| 5407 | "net/http/httptest" |
| 5408 | "os/exec" |
| 5409 | "strings" |
| 5410 | "sync" |
| 5411 | "sync/atomic" |
| 5412 | "testing" |
| 5413 | "time" |
| 5414 | ) |
| 5415 | |
| 5416 | // Review Focus 1. |
| 5417 | func TestParseTTL(t *testing.T) { |
| 5418 | for in, want := range map[string]time.Duration{ |
| 5419 | "3d": 72 * time.Hour, "3D": 72 * time.Hour, " 30d ": 30 * 24 * time.Hour, |
| 5420 | "1h": time.Hour, "90m": 90 * time.Minute, "72h30m": 72*time.Hour + 30*time.Minute, |
| 5421 | } { |
| 5422 | if got, err := parseTTL(in); err != nil || got != want { |
| 5423 | t.Errorf("parseTTL(%q) = %v %v, want %v", in, got, err, want) |
| 5424 | } |
| 5425 | } |
| 5426 | for _, in := range []string{"", "d", "1.5d", "-1d", "31d", "59m", "0", "7", "9999999999999d", "3 days"} { |
| 5427 | if _, err := parseTTL(in); err == nil { |
| 5428 | t.Errorf("parseTTL(%q) accepted", in) |
| 5429 | } |
| 5430 | } |
| 5431 | } |
| 5432 | |
| 5433 | // Review Focus 2. |
| 5434 | func TestTemporaryAppName(t *testing.T) { |
| 5435 | gen := func() string { return "a1b2c3" } |
| 5436 | for in, want := range map[string]string{ |
| 5437 | "": "tmp-a1b2c3", "acme-docs": "tmp-acme-docs", "tmp-acme": "tmp-acme", " TMP-Acme ": "tmp-acme", |
| 5438 | } { |
| 5439 | if got, err := temporaryAppName(in, gen); err != nil || got != want { |
| 5440 | t.Errorf("temporaryAppName(%q) = %q %v, want %q", in, got, err, want) |
| 5441 | } |
| 5442 | } |
| 5443 | for _, in := range []string{strings.Repeat("a", 29), "acme_docs", "acme-", "tmp-", "tmp-abc-"} { |
| 5444 | if got, err := temporaryAppName(in, gen); err == nil { |
| 5445 | t.Errorf("temporaryAppName(%q) = %q, want a refusal", in, got) |
| 5446 | } |
| 5447 | } |
| 5448 | for i := 0; i < 50; i++ { |
| 5449 | label := randomTemporaryLabel() |
| 5450 | if len(label) != 6 || strings.Trim(label, "abcdefghijklmnopqrstuvwxyz0123456789") != "" { |
| 5451 | t.Fatalf("randomTemporaryLabel() = %q, want 6 lowercase letters and digits", label) |
| 5452 | } |
| 5453 | } |
| 5454 | } |
| 5455 | |
| 5456 | // Spec §9 "CLI": --ttl implies --temporary; local refusals. |
| 5457 | func TestTemporaryDeployFlags(t *testing.T) { |
| 5458 | if temp, ttl, err := temporaryDeployFlags(false, true, "3d", "", false, "binary", 1); err != nil || !temp || ttl != 72*time.Hour { |
| 5459 | t.Errorf("--ttl 3d alone = %v %v %v, want temporary for 3d", temp, ttl, err) |
| 5460 | } |
| 5461 | if temp, ttl, err := temporaryDeployFlags(true, false, "", "", false, "binary", 1); err != nil || !temp || ttl != ttlDefault { |
| 5462 | t.Errorf("--temporary alone = %v %v %v, want 7d", temp, ttl, err) |
| 5463 | } |
| 5464 | if temp, _, err := temporaryDeployFlags(false, false, "", "www.example.com", true, "binary", 1); err != nil || temp { |
| 5465 | t.Errorf("an ordinary deploy = %v %v, want untouched", temp, err) |
| 5466 | } |
| 5467 | // Astra plan R1 finding 5: --ttl given is --ttl given, whatever its value. |
| 5468 | // An empty or blank value is an invalid TTL, never an ordinary deploy. |
| 5469 | for _, raw := range []string{"", " ", "\t"} { |
| 5470 | if temp, _, err := temporaryDeployFlags(false, true, raw, "", false, "binary", 1); err == nil || temp { |
| 5471 | t.Errorf("--ttl %q = %v %v, want a refusal", raw, temp, err) |
| 5472 | } |
| 5473 | } |
| 5474 | for name, c := range map[string]struct { |
| 5475 | host string |
| 5476 | kindSet bool |
| 5477 | kind string |
| 5478 | nargs int |
| 5479 | }{ |
| 5480 | "--host": {"www.example.com", false, "binary", 1}, |
| 5481 | "--kind binary": {"", true, "binary", 1}, |
| 5482 | "no site dir": {"", false, "binary", 0}, |
| 5483 | } { |
| 5484 | if _, _, err := temporaryDeployFlags(true, false, "", c.host, c.kindSet, c.kind, c.nargs); err == nil { |
| 5485 | t.Errorf("--temporary with %s was accepted", name) |
| 5486 | } |
| 5487 | } |
| 5488 | } |
| 5489 | |
| 5490 | // TestDeployRefusesABlankTTL drives the real entry point: flag parsing, the |
| 5491 | // flagWasSet presence check and the refusal, before any session is resolved |
| 5492 | // or anything is created. The binary runs with carlosTestEnv (no login) in a |
| 5493 | // scratch dir, so reaching the ordinary deploy path would fail differently |
| 5494 | // (no console) and not name --ttl. |
| 5495 | func TestDeployRefusesABlankTTL(t *testing.T) { |
| 5496 | bin := buildCarlos(t) |
| 5497 | for _, args := range [][]string{ |
| 5498 | {"deploy", "--ttl", "", "./site"}, |
| 5499 | {"deploy", "--ttl=", "./site"}, |
| 5500 | {"deploy", "--ttl", " ", "./site"}, |
| 5501 | } { |
| 5502 | cmd := exec.Command(bin, args...) |
| 5503 | cmd.Env = carlosTestEnv(t) |
| 5504 | cmd.Dir = t.TempDir() |
| 5505 | out, err := cmd.CombinedOutput() |
| 5506 | if err == nil { |
| 5507 | t.Fatalf("carlos %q: want a non-zero exit, got 0\n%s", args, out) |
| 5508 | } |
| 5509 | if !strings.Contains(string(out), "--ttl") { |
| 5510 | t.Fatalf("carlos %q: the refusal does not name --ttl:\n%s", args, out) |
| 5511 | } |
| 5512 | } |
| 5513 | } |
| 5514 | |
| 5515 | // fakeTemporaryConsole answers /new-temporary with the statuses given, in |
| 5516 | // order, and counts every request it sees. |
| 5517 | func fakeTemporaryConsole(t *testing.T, statuses ...int) (*apiSession, *atomic.Int32, *[]string) { |
| 5518 | t.Helper() |
| 5519 | var n atomic.Int32 |
| 5520 | var mu sync.Mutex |
| 5521 | var names []string |
| 5522 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 5523 | i := int(n.Add(1)) - 1 |
| 5524 | var in struct { |
| 5525 | Name string `json:"name"` |
| 5526 | } |
| 5527 | json.NewDecoder(r.Body).Decode(&in) |
| 5528 | mu.Lock() |
| 5529 | names = append(names, in.Name) |
| 5530 | mu.Unlock() |
| 5531 | st := statuses[min(i, len(statuses)-1)] |
| 5532 | switch st { |
| 5533 | case http.StatusOK: |
| 5534 | json.NewEncoder(w).Encode(map[string]string{"account": "bab", "app": in.Name, "expires_at": "2026-10-03T12:00:00Z"}) |
| 5535 | case http.StatusConflict: |
| 5536 | w.WriteHeader(st) |
| 5537 | json.NewEncoder(w).Encode(map[string]string{"error": "that name is taken", "code": "name_taken"}) |
| 5538 | default: |
| 5539 | http.NotFound(w, r) |
| 5540 | } |
| 5541 | })) |
| 5542 | t.Cleanup(ts.Close) |
| 5543 | return newTestSession(ts.URL), &n, &names |
| 5544 | } |
| 5545 | |
| 5546 | // Spec §9 "CLI": generated name shape and retry. |
| 5547 | func TestCreateTemporaryRetriesGeneratedNames(t *testing.T) { |
| 5548 | labels := []string{"aaaaaa", "bbbbbb", "cccccc"} |
| 5549 | i := 0 |
| 5550 | gen := func() string { l := labels[i]; i++; return l } |
| 5551 | sess, n, names := fakeTemporaryConsole(t, http.StatusConflict, http.StatusConflict, http.StatusOK) |
| 5552 | res, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, gen) |
| 5553 | if err != nil || res.App != "tmp-cccccc" || n.Load() != 3 { |
| 5554 | t.Fatalf("createTemporary = %+v %v after %d requests (%v), want tmp-cccccc on the third", res, err, n.Load(), *names) |
| 5555 | } |
| 5556 | // A name the caller chose is never retried under another name. |
| 5557 | sess, n, _ = fakeTemporaryConsole(t, http.StatusConflict) |
| 5558 | if _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "acme-docs", ttlDefault, gen); !errors.Is(err, errTemporaryNameTaken) || n.Load() != 1 { |
| 5559 | t.Fatalf("taken --app = %v after %d requests, want errTemporaryNameTaken after 1", err, n.Load()) |
| 5560 | } |
| 5561 | // Generated names give up after temporaryNameAttempts. |
| 5562 | sess, n, _ = fakeTemporaryConsole(t, http.StatusConflict) |
| 5563 | if _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, func() string { return "zzzzzz" }); err == nil || n.Load() != temporaryNameAttempts { |
| 5564 | t.Fatalf("all taken = %v after %d requests, want an error after %d", err, n.Load(), temporaryNameAttempts) |
| 5565 | } |
| 5566 | } |
| 5567 | |
| 5568 | // Spec §9: the CLI against a console without the route creates nothing. |
| 5569 | func TestCreateTemporaryAgainstAConsoleWithoutTheRoute(t *testing.T) { |
| 5570 | sess, n, _ := fakeTemporaryConsole(t, http.StatusNotFound) |
| 5571 | _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, func() string { return "aaaaaa" }) |
| 5572 | if !errors.Is(err, errTemporaryUnavailable) || n.Load() != 1 { |
| 5573 | t.Fatalf("older console = %v after %d requests, want errTemporaryUnavailable after exactly 1", err, n.Load()) |
| 5574 | } |
| 5575 | } |
| 5576 | |
| 5577 | // Spec §3: the CLI checks the response carries a non-zero expires_at before |
| 5578 | // it ships. |
| 5579 | func TestCreateTemporaryRequiresTheExpiryEcho(t *testing.T) { |
| 5580 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 5581 | json.NewEncoder(w).Encode(map[string]string{"account": "bab", "app": "tmp-docs"}) |
| 5582 | })) |
| 5583 | defer ts.Close() |
| 5584 | _, err := newTestSession(ts.URL).createTemporaryApp(context.Background(), apiAcct{Seg: "bab"}, "tmp-docs", ttlDefault) |
| 5585 | if !errors.Is(err, errTemporaryNotConfirmed) || !strings.Contains(err.Error(), "carlos apps delete tmp-docs") { |
| 5586 | t.Fatalf("no expires_at = %v, want errTemporaryNotConfirmed naming the cleanup", err) |
| 5587 | } |
| 5588 | } |
| 5589 | |
| 5590 | func TestAPISessionLifecycleDoors(t *testing.T) { |
| 5591 | var got []string |
| 5592 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 5593 | got = append(got, r.Method+" "+r.URL.Path) |
| 5594 | switch { |
| 5595 | case strings.HasSuffix(r.URL.Path, "/ttl"): |
| 5596 | var in struct { |
| 5597 | TTLSeconds int64 `json:"ttl_seconds"` |
| 5598 | } |
| 5599 | json.NewDecoder(r.Body).Decode(&in) |
| 5600 | if in.TTLSeconds != 5*86400 { |
| 5601 | t.Errorf("ttl_seconds = %d, want %d", in.TTLSeconds, 5*86400) |
| 5602 | } |
| 5603 | json.NewEncoder(w).Encode(map[string]string{"app": "tmp-a", "expires_at": "2026-10-01T12:00:00Z"}) |
| 5604 | case strings.HasSuffix(r.URL.Path, "/keep"): |
| 5605 | json.NewEncoder(w).Encode(map[string]any{"app": "tmp-a", "expires_at": nil}) |
| 5606 | default: |
| 5607 | w.Write([]byte(`[{"app":"tmp-a","deleted_at":null,"expires_at":"2026-10-01T12:00:00Z","purge_after":null},` + |
| 5608 | `{"app":"kass","deleted_at":"2026-09-26T12:00:00Z","expires_at":null,"purge_after":"2026-10-26T12:00:00Z"}]`)) |
| 5609 | } |
| 5610 | })) |
| 5611 | defer ts.Close() |
| 5612 | s := newTestSession(ts.URL) |
| 5613 | ctx, acct := context.Background(), apiAcct{Seg: "bab"} |
| 5614 | if exp, err := s.appTTL(ctx, acct, "tmp-a", 5*24*time.Hour); err != nil || exp.Format(time.RFC3339) != "2026-10-01T12:00:00Z" { |
| 5615 | t.Fatalf("appTTL = %v %v", exp, err) |
| 5616 | } |
| 5617 | if err := s.appKeep(ctx, acct, "tmp-a"); err != nil { |
| 5618 | t.Fatalf("appKeep = %v", err) |
| 5619 | } |
| 5620 | lives, err := s.appList(ctx, acct) |
| 5621 | if err != nil || len(lives) != 2 || lives[0].ExpiresAt == nil || lives[1].DeletedAt == nil || lives[1].PurgeAfter == nil { |
| 5622 | t.Fatalf("appList = %+v %v", lives, err) |
| 5623 | } |
| 5624 | want := []string{"POST /api/cli/apps/bab/tmp-a/ttl", "POST /api/cli/apps/bab/tmp-a/keep", "GET /api/cli/apps/bab/list"} |
| 5625 | if strings.Join(got, "|") != strings.Join(want, "|") { |
| 5626 | t.Fatalf("requests = %v, want %v", got, want) |
| 5627 | } |
| 5628 | } |
| 5629 | |
| 5630 | func TestAppLifeLine(t *testing.T) { |
| 5631 | now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) |
| 5632 | at := func(d time.Duration) *time.Time { v := now.Add(d); return &v } |
| 5633 | if line := appLifeLine(apiAppLife{App: "tmp-a", ExpiresAt: at(72 * time.Hour)}, now); !strings.Contains(line, "live") || !strings.Contains(line, "expires in 3d") { |
| 5634 | t.Errorf("live temporary = %q", line) |
| 5635 | } |
| 5636 | if line := appLifeLine(apiAppLife{App: "tmp-b", ExpiresAt: at(-time.Hour), DeletedAt: at(-time.Hour), PurgeAfter: at(49 * time.Hour)}, now); !strings.Contains(line, "trashed") || !strings.Contains(line, "purged in 2d") { |
| 5637 | t.Errorf("trashed temporary = %q", line) |
| 5638 | } |
| 5639 | if line := appLifeLine(apiAppLife{App: "kass"}, now); strings.Contains(line, "expires") || strings.Contains(line, "purged") { |
| 5640 | t.Errorf("live permanent = %q, want no time", line) |
| 5641 | } |
| 5642 | } |
| 5643 | ``` |
| 5644 | |
| 5645 | - [ ] **Step 2: Run the tests to verify they fail** |
| 5646 | |
| 5647 | Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestParseTTL|TestTemporaryAppName|TestTemporaryDeployFlags|TestDeployRefusesABlankTTL|TestCreateTemporary|TestAPISessionLifecycleDoors|TestAppLifeLine' -count=1` |
| 5648 | Expected: FAIL to compile: `undefined: parseTTL`, `undefined: temporaryAppName`, `undefined: createTemporary`, `s.appTTL undefined`. |
| 5649 | |
| 5650 | - [ ] **Step 3: Implement** |
| 5651 | |
| 5652 | Append to `temporary.go` (its imports become `context`, `crypto/rand`, `errors`, `flag`, `fmt`, `log`, `strconv`, `strings`, `time`, and `internal/release`): |
| 5653 | |
| 5654 | ```go |
| 5655 | // The bounds match the console's (temporary static sites spec ruling 3). |
| 5656 | // Checked here too so a typo costs no create budget. |
| 5657 | const ( |
| 5658 | ttlFloor = time.Hour |
| 5659 | ttlCeiling = 30 * 24 * time.Hour |
| 5660 | ttlDefault = 7 * 24 * time.Hour |
| 5661 | // temporaryNameAttempts bounds retries of a GENERATED name on "taken". |
| 5662 | temporaryNameAttempts = 5 |
| 5663 | ) |
| 5664 | |
| 5665 | var errTTL = errors.New("a temporary app lives from 1h to 30d. Give a whole number of days like 3d, or a duration like 36h or 90m") // COPY-REVIEW C11.1 |
| 5666 | |
| 5667 | // parseTTL reads --ttl and `carlos apps ttl`'s argument: Go durations plus |
| 5668 | // whole days ("3d"). Days are bounded before they are multiplied, so no |
| 5669 | // input overflows. |
| 5670 | func parseTTL(s string) (time.Duration, error) { |
| 5671 | s = strings.ToLower(strings.TrimSpace(s)) |
| 5672 | var d time.Duration |
| 5673 | if n, ok := strings.CutSuffix(s, "d"); ok { |
| 5674 | days, err := strconv.Atoi(n) |
| 5675 | if err != nil || days < 0 || days > int(ttlCeiling/(24*time.Hour)) { |
| 5676 | return 0, errTTL |
| 5677 | } |
| 5678 | d = time.Duration(days) * 24 * time.Hour |
| 5679 | } else { |
| 5680 | var err error |
| 5681 | if d, err = time.ParseDuration(s); err != nil { |
| 5682 | return 0, errTTL |
| 5683 | } |
| 5684 | } |
| 5685 | if d < ttlFloor || d > ttlCeiling { |
| 5686 | return 0, errTTL |
| 5687 | } |
| 5688 | return d, nil |
| 5689 | } |
| 5690 | |
| 5691 | const temporaryLabelAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" |
| 5692 | |
| 5693 | // randomTemporaryLabel is six random lowercase letters and digits (spec §7). |
| 5694 | func randomTemporaryLabel() string { |
| 5695 | b := make([]byte, 6) |
| 5696 | if _, err := rand.Read(b); err != nil { |
| 5697 | log.Fatalf("deploy: no randomness for a temporary app name: %v", err) |
| 5698 | } |
| 5699 | for i := range b { |
| 5700 | b[i] = temporaryLabelAlphabet[int(b[i])%len(temporaryLabelAlphabet)] |
| 5701 | } |
| 5702 | return string(b) |
| 5703 | } |
| 5704 | |
| 5705 | // temporaryAppName is spec §7's naming: no --app gives tmp- plus a random |
| 5706 | // label; --app acme-docs gives tmp-acme-docs; an --app already starting tmp- |
| 5707 | // is used as given. The result must be a valid app name and a DNS label (it |
| 5708 | // becomes the first label of the platform host), so a trailing hyphen or a |
| 5709 | // name past 32 characters is refused here, before any request. |
| 5710 | func temporaryAppName(app string, random func() string) (string, error) { |
| 5711 | name := strings.ToLower(strings.TrimSpace(app)) |
| 5712 | switch { |
| 5713 | case name == "": |
| 5714 | name = release.TemporaryAppPrefix + random() |
| 5715 | case !release.IsTemporaryName(name): |
| 5716 | name = release.TemporaryAppPrefix + name |
| 5717 | } |
| 5718 | if name == release.TemporaryAppPrefix || !release.ValidApp(name) || strings.HasSuffix(name, "-") { |
| 5719 | return "", fmt.Errorf("%q cannot name a temporary app: it would be %s, and a temporary app's name is tmp- followed by "+ |
| 5720 | "lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit", app, name) // COPY-REVIEW C11.2 |
| 5721 | } |
| 5722 | return name, nil |
| 5723 | } |
| 5724 | |
| 5725 | // temporaryDeployFlags applies spec §7's rules to deploy's flags: --ttl alone |
| 5726 | // implies --temporary, and --host, --kind binary and a missing site |
| 5727 | // directory are refused locally, before anything is created. |
| 5728 | // |
| 5729 | // ttlSet is whether --ttl was GIVEN (flagWasSet), not whether its value is |
| 5730 | // blank: `--ttl ""` is a TTL the caller asked for and got wrong, and must be |
| 5731 | // refused, never read as "no --ttl" and turned into an ordinary deploy. |
| 5732 | func temporaryDeployFlags(temporary, ttlSet bool, ttlRaw, host string, kindSet bool, kind string, nargs int) (bool, time.Duration, error) { |
| 5733 | if ttlSet { |
| 5734 | temporary = true |
| 5735 | } |
| 5736 | if !temporary { |
| 5737 | return false, 0, nil |
| 5738 | } |
| 5739 | ttl := ttlDefault |
| 5740 | if ttlSet { |
| 5741 | d, err := parseTTL(ttlRaw) |
| 5742 | if err != nil { |
| 5743 | return false, 0, fmt.Errorf("--ttl %q: %w", ttlRaw, err) |
| 5744 | } |
| 5745 | ttl = d |
| 5746 | } |
| 5747 | switch { |
| 5748 | case host != "": |
| 5749 | return false, 0, errors.New("--temporary cannot be used with --host. A temporary app is served only at its own platform address") // COPY-REVIEW C11.3 |
| 5750 | case kindSet && kind != "static": |
| 5751 | return false, 0, errors.New("--temporary deploys a static site, so it cannot be used with --kind binary") // COPY-REVIEW C11.4 |
| 5752 | case nargs == 0: |
| 5753 | return false, 0, errors.New("--temporary needs the site directory to deploy, for example: carlos deploy --temporary ./dist") // COPY-REVIEW C11.5 |
| 5754 | } |
| 5755 | return true, ttl, nil |
| 5756 | } |
| 5757 | |
| 5758 | // createTemporary creates the app through /new-temporary, retrying a |
| 5759 | // GENERATED name that is taken. A name the caller chose is tried once. |
| 5760 | func createTemporary(ctx context.Context, sess *apiSession, acct apiAcct, appFlag string, ttl time.Duration, random func() string) (apiCreateTemporaryResult, error) { |
| 5761 | attempts := 1 |
| 5762 | if strings.TrimSpace(appFlag) == "" { |
| 5763 | attempts = temporaryNameAttempts |
| 5764 | } |
| 5765 | var last error |
| 5766 | for i := 0; i < attempts; i++ { |
| 5767 | name, err := temporaryAppName(appFlag, random) |
| 5768 | if err != nil { |
| 5769 | return apiCreateTemporaryResult{}, err |
| 5770 | } |
| 5771 | res, err := sess.createTemporaryApp(ctx, acct, name, ttl) |
| 5772 | if errors.Is(err, errTemporaryNameTaken) && attempts > 1 { |
| 5773 | last = err |
| 5774 | continue |
| 5775 | } |
| 5776 | return res, err |
| 5777 | } |
| 5778 | return apiCreateTemporaryResult{}, fmt.Errorf("%d generated names in a row were taken. Try again, or name the app with --app: %w", attempts, last) // COPY-REVIEW C11.6 |
| 5779 | } |
| 5780 | |
| 5781 | // printTemporaryExpiry is spec §7's "carlos deploy to an existing temporary |
| 5782 | // app: expiry unchanged, printed". Best effort: a console without /list says |
| 5783 | // nothing rather than something it cannot back up. |
| 5784 | func printTemporaryExpiry(ctx context.Context, sess *apiSession, acct apiAcct, app string) { |
| 5785 | lives, err := sess.appList(ctx, acct) |
| 5786 | if err != nil { |
| 5787 | return |
| 5788 | } |
| 5789 | for _, l := range lives { |
| 5790 | if l.App == app && l.ExpiresAt != nil && l.DeletedAt == nil { |
| 5791 | mutedf(stdout, "%s is temporary. It moves to the Trash %s, at %s. This deploy does not change that", // COPY-REVIEW C11.7 |
| 5792 | app, inDuration(time.Until(*l.ExpiresAt)), l.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")) |
| 5793 | } |
| 5794 | } |
| 5795 | } |
| 5796 | |
| 5797 | // appLifeLine is one line of `carlos apps list` (spec §7): name, live or |
| 5798 | // trashed, and "expires in 3d", "purged in 2d" or nothing. |
| 5799 | func appLifeLine(l apiAppLife, now time.Time) string { |
| 5800 | state, note := "live", "" // COPY-REVIEW C11.8 |
| 5801 | switch { |
| 5802 | case l.DeletedAt != nil: |
| 5803 | state = "trashed" // COPY-REVIEW C11.8 |
| 5804 | if l.PurgeAfter != nil { |
| 5805 | note = "purged " + inDuration(l.PurgeAfter.Sub(now)) // COPY-REVIEW C11.8 |
| 5806 | } |
| 5807 | case l.ExpiresAt != nil: |
| 5808 | note = "expires " + inDuration(l.ExpiresAt.Sub(now)) // COPY-REVIEW C11.8 |
| 5809 | } |
| 5810 | return strings.TrimRight(fmt.Sprintf("%-32s %-7s %s", l.App, state, note), " ") |
| 5811 | } |
| 5812 | |
| 5813 | // requireSignedInApps refuses direct mode by name for the apps verbs that |
| 5814 | // only the console can answer. |
| 5815 | func requireSignedInApps(verb, consoleFlag string) *apiSession { |
| 5816 | sess, err := resolveMode(consoleFlag) |
| 5817 | if err != nil { |
| 5818 | log.Fatal(err) |
| 5819 | } |
| 5820 | if sess == nil { |
| 5821 | log.Fatalf("carlos apps %s works through a console, and this shell is in direct bucket mode "+ |
| 5822 | "(CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Temporary apps are recorded by the console, "+ |
| 5823 | "which direct mode never reads. Unset the store variables and run: carlos auth login --console <url>", verb) // COPY-REVIEW C11.9 |
| 5824 | } |
| 5825 | return sess |
| 5826 | } |
| 5827 | |
| 5828 | // appsLifecycleFlags is the flag set the three verbs share. Not accountFlags: |
| 5829 | // that also registers --registry for direct mode, which these never use. |
| 5830 | func appsLifecycleFlags(name string) (fs *flag.FlagSet, app, acctRaw, acctIDRaw, consoleFlag *string) { |
| 5831 | fs = flag.NewFlagSet(name, flag.ExitOnError) |
| 5832 | app = appFlag(fs, "app name") |
| 5833 | acctRaw = fs.String("account", "", "account (sqid or name) the app is in (default: your only account, or the configured/chosen one)") |
| 5834 | acctIDRaw = fs.String("account-id", "", "account sqid, matched as a sqid only; supersedes --account") |
| 5835 | consoleFlag = consoleOption(fs, "console to use (default: the only logged-in one)") |
| 5836 | fs.Usage = flagUsage(fs, "") |
| 5837 | return fs, app, acctRaw, acctIDRaw, consoleFlag |
| 5838 | } |
| 5839 | |
| 5840 | func runAppsTTL(args []string) { |
| 5841 | fs, app, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps ttl") |
| 5842 | fs.Parse(args) |
| 5843 | if strings.TrimSpace(*app) == "" || fs.NArg() != 1 { |
| 5844 | log.Fatal("usage: carlos apps ttl --app <name> <1h to 30d, e.g. 5d> [--account <sqid|name>] [--console <origin>]") // COPY-REVIEW C11.10 |
| 5845 | } |
| 5846 | ttl, err := parseTTL(fs.Arg(0)) |
| 5847 | if err != nil { |
| 5848 | log.Fatalf("apps ttl: %v", err) |
| 5849 | } |
| 5850 | sess := requireSignedInApps("ttl", *consoleFlag) |
| 5851 | printUsing(stdout, sess) |
| 5852 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 5853 | exp, err := sess.appTTL(context.Background(), acct, *app, ttl) |
| 5854 | if err != nil { |
| 5855 | log.Fatalf("apps ttl: %v", err) |
| 5856 | } |
| 5857 | fmt.Fprintf(stdout, "%s %s moves to the Trash %s, at %s\n", colorFor(stdout).ok("expiry set"), // COPY-REVIEW C11.11 |
| 5858 | *app, inDuration(time.Until(exp)), exp.UTC().Format("2006-01-02 15:04 UTC")) |
| 5859 | } |
| 5860 | |
| 5861 | func runAppsKeep(args []string) { |
| 5862 | fs, app, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps keep") |
| 5863 | fs.Parse(args) |
| 5864 | if strings.TrimSpace(*app) == "" || fs.NArg() != 0 { |
| 5865 | log.Fatal("usage: carlos apps keep --app <name> [--account <sqid|name>] [--console <origin>]") // COPY-REVIEW C11.12 |
| 5866 | } |
| 5867 | sess := requireSignedInApps("keep", *consoleFlag) |
| 5868 | printUsing(stdout, sess) |
| 5869 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 5870 | if err := sess.appKeep(context.Background(), acct, *app); err != nil { |
| 5871 | log.Fatalf("apps keep: %v", err) |
| 5872 | } |
| 5873 | fmt.Fprintf(stdout, "%s %s is permanent now. It keeps its name\n", colorFor(stdout).ok("kept"), *app) // COPY-REVIEW C11.13 |
| 5874 | } |
| 5875 | |
| 5876 | func runAppsList(args []string) { |
| 5877 | fs, _, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps list") |
| 5878 | fs.Parse(args) |
| 5879 | if fs.NArg() != 0 { |
| 5880 | log.Fatal("usage: carlos apps list [--account <sqid|name>] [--console <origin>]") // COPY-REVIEW C11.14 |
| 5881 | } |
| 5882 | sess := requireSignedInApps("list", *consoleFlag) |
| 5883 | printUsing(stdout, sess) |
| 5884 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 5885 | lives, err := sess.appList(context.Background(), acct) |
| 5886 | if err != nil { |
| 5887 | log.Fatalf("apps list: %v", err) |
| 5888 | } |
| 5889 | if len(lives) == 0 { |
| 5890 | mutedf(stdout, "no apps in this account") // COPY-REVIEW C11.15 |
| 5891 | return |
| 5892 | } |
| 5893 | now := time.Now() |
| 5894 | for _, l := range lives { |
| 5895 | fmt.Fprintln(stdout, appLifeLine(l, now)) |
| 5896 | } |
| 5897 | } |
| 5898 | ``` |
| 5899 | |
| 5900 | Append to `apimode.go` after `restoreApp` (line 1622): |
| 5901 | |
| 5902 | ```go |
| 5903 | // ---- temporary apps (temporary static sites spec §3, §7) -------------------- |
| 5904 | |
| 5905 | // apiCreateTemporaryResult is handleAPICreateTemporaryApp's success shape, |
| 5906 | // with expires_at parsed. It is only returned once the expiry is confirmed. |
| 5907 | type apiCreateTemporaryResult struct { |
| 5908 | Account string |
| 5909 | App string |
| 5910 | URL string |
| 5911 | ExpiresAt time.Time |
| 5912 | } |
| 5913 | |
| 5914 | // apiAppLife is one row of GET /api/cli/apps/{account}/list. |
| 5915 | type apiAppLife struct { |
| 5916 | App string `json:"app"` |
| 5917 | DeletedAt *time.Time `json:"deleted_at"` |
| 5918 | ExpiresAt *time.Time `json:"expires_at"` |
| 5919 | PurgeAfter *time.Time `json:"purge_after"` |
| 5920 | } |
| 5921 | |
| 5922 | var ( |
| 5923 | errTemporaryUnavailable = errors.New("this console does not offer temporary apps, or the account was not found (it answered 404). Nothing was created") // COPY-REVIEW C11.16 |
| 5924 | errTemporaryNameTaken = errors.New("that name is taken") // COPY-REVIEW C11.17 |
| 5925 | errTemporaryNotConfirmed = errors.New("the console did not confirm an expiry") |
| 5926 | errListUnavailable = errors.New("this console does not offer carlos apps list yet (it answered 404). The console needs upgrading first") // COPY-REVIEW C11.18 |
| 5927 | ) |
| 5928 | |
| 5929 | // createTemporaryApp is POST /new-temporary. A 404 is an older console (or a |
| 5930 | // switched-off one, or a wrong account) and nothing was created: the caller |
| 5931 | // stops there (spec §3, astra 2.7). A 409 with code name_taken is |
| 5932 | // errTemporaryNameTaken, which a generated name retries. A 200 without a |
| 5933 | // usable expires_at is a console that did not make the app temporary, and |
| 5934 | // the caller must not ship. |
| 5935 | func (s *apiSession) createTemporaryApp(ctx context.Context, a apiAcct, app string, ttl time.Duration) (apiCreateTemporaryResult, error) { |
| 5936 | in := struct { |
| 5937 | Name string `json:"name"` |
| 5938 | TTLSeconds int64 `json:"ttl_seconds"` |
| 5939 | }{app, int64(ttl / time.Second)} |
| 5940 | status, body, err := s.postJSON(ctx, apiPath(a, "new-temporary", ""), in) |
| 5941 | if err != nil { |
| 5942 | return apiCreateTemporaryResult{}, err |
| 5943 | } |
| 5944 | switch status { |
| 5945 | case http.StatusOK: |
| 5946 | case http.StatusNotFound: |
| 5947 | return apiCreateTemporaryResult{}, errTemporaryUnavailable |
| 5948 | case http.StatusConflict: |
| 5949 | var e struct { |
| 5950 | Code string `json:"code"` |
| 5951 | } |
| 5952 | if json.Unmarshal(body, &e) == nil && e.Code == "name_taken" { |
| 5953 | return apiCreateTemporaryResult{}, fmt.Errorf("%w: %s", errTemporaryNameTaken, app) |
| 5954 | } |
| 5955 | return apiCreateTemporaryResult{}, apiErrorFromBody(status, body) |
| 5956 | default: |
| 5957 | return apiCreateTemporaryResult{}, apiErrorFromBody(status, body) |
| 5958 | } |
| 5959 | var res struct { |
| 5960 | Account string `json:"account"` |
| 5961 | App string `json:"app"` |
| 5962 | URL string `json:"url"` |
| 5963 | ExpiresAt string `json:"expires_at"` |
| 5964 | } |
| 5965 | if err := json.Unmarshal(body, &res); err != nil { |
| 5966 | return apiCreateTemporaryResult{}, fmt.Errorf("deploy: unreadable response from the console: %w", err) |
| 5967 | } |
| 5968 | if res.App == "" { |
| 5969 | res.App = app |
| 5970 | } |
| 5971 | exp, perr := time.Parse(time.RFC3339, res.ExpiresAt) |
| 5972 | if perr != nil || exp.Unix() <= 0 { |
| 5973 | return apiCreateTemporaryResult{App: res.App}, fmt.Errorf("the console created %s but did not confirm it is temporary, so nothing was shipped. "+ |
| 5974 | "If you do not want it, delete it with: carlos apps delete %s (%w)", res.App, res.App, errTemporaryNotConfirmed) // COPY-REVIEW C11.19 |
| 5975 | } |
| 5976 | return apiCreateTemporaryResult{Account: res.Account, App: res.App, URL: res.URL, ExpiresAt: exp}, nil |
| 5977 | } |
| 5978 | |
| 5979 | // appTTL is POST {app}/ttl: the new expiry, as the console set it. |
| 5980 | func (s *apiSession) appTTL(ctx context.Context, a apiAcct, app string, ttl time.Duration) (time.Time, error) { |
| 5981 | in := struct { |
| 5982 | TTLSeconds int64 `json:"ttl_seconds"` |
| 5983 | }{int64(ttl / time.Second)} |
| 5984 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/ttl"), in) |
| 5985 | if err != nil { |
| 5986 | return time.Time{}, err |
| 5987 | } |
| 5988 | if status != http.StatusOK { |
| 5989 | return time.Time{}, apiErrorFromBody(status, body) |
| 5990 | } |
| 5991 | var res struct { |
| 5992 | ExpiresAt string `json:"expires_at"` |
| 5993 | } |
| 5994 | if err := json.Unmarshal(body, &res); err != nil { |
| 5995 | return time.Time{}, fmt.Errorf("apps ttl: unreadable response: %w", err) |
| 5996 | } |
| 5997 | return time.Parse(time.RFC3339, res.ExpiresAt) |
| 5998 | } |
| 5999 | |
| 6000 | // appKeep is POST {app}/keep. |
| 6001 | func (s *apiSession) appKeep(ctx context.Context, a apiAcct, app string) error { |
| 6002 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/keep"), struct{}{}) |
| 6003 | if err != nil { |
| 6004 | return err |
| 6005 | } |
| 6006 | if status != http.StatusOK { |
| 6007 | return apiErrorFromBody(status, body) |
| 6008 | } |
| 6009 | return nil |
| 6010 | } |
| 6011 | |
| 6012 | // appList is GET /api/cli/apps/{account}/list. |
| 6013 | func (s *apiSession) appList(ctx context.Context, a apiAcct) ([]apiAppLife, error) { |
| 6014 | status, body, err := s.getJSON(ctx, apiPath(a, "list", "")) |
| 6015 | if err != nil { |
| 6016 | return nil, err |
| 6017 | } |
| 6018 | if status == http.StatusNotFound { |
| 6019 | return nil, errListUnavailable |
| 6020 | } |
| 6021 | if status != http.StatusOK { |
| 6022 | return nil, apiErrorFromBody(status, body) |
| 6023 | } |
| 6024 | var out []apiAppLife |
| 6025 | if err := json.Unmarshal(body, &out); err != nil { |
| 6026 | return nil, fmt.Errorf("apps list: unreadable response: %w", err) |
| 6027 | } |
| 6028 | return out, nil |
| 6029 | } |
| 6030 | ``` |
| 6031 | |
| 6032 | `apps.go`, `RunApps` (lines 115-126) gains three cases: |
| 6033 | |
| 6034 | ```go |
| 6035 | case "ttl": |
| 6036 | runAppsTTL(args[1:]) |
| 6037 | case "keep": |
| 6038 | runA |