carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "net/url"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/provision"
14 "github.com/carlosframework/platform/internal/release"
15)
16
17// trashedTemp seeds tmp-a, expired, in the Trash with its 3-day window, the
18// state the expiry pass leaves.
19func trashedTemp(t *testing.T, fx *consoleFixture, app string, at time.Time) {
20 t.Helper()
21 tempApp(t, fx, app, at)
22 if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, app, "expiry", at); err != nil {
23 t.Fatal(err)
24 }
25 if ok, err := fx.st.ExpireAppRow(OpsAccountID, app, at); err != nil || !ok {
26 t.Fatal(ok, err)
27 }
28}
29
30// Spec §6 and §9: rewrites expiry before deleting the tombstone, and clears
31// the deadline only after. The order is asserted at the moment of the delete.
32func TestRestoreRewritesExpiryBeforeTheMarkerGoes(t *testing.T) {
33 fx := newConsoleFixture(t)
34 now := time.Now().Truncate(time.Second)
35 trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour))
36 h := &hookStore{Store: fx.rel}
37 marker := release.AppTombstoneKey(fx.sqid, "tmp-a")
38 var atDelete *AppLife
39 h.onDelete = func(key string) {
40 if key == marker {
41 atDelete = lifeOf(t, fx, "tmp-a")
42 }
43 }
44 fx.srv.cfg.Releases = h
45 if err := fx.srv.restoreApp(context.Background(), opsAccount(t, fx), "tmp-a", now); err != nil {
46 t.Fatal(err)
47 }
48 if atDelete == nil {
49 t.Fatal("the marker was never deleted")
50 }
51 if atDelete.ExpiresAt != now.Add(temporaryTTLDefault).Unix() || !atDelete.Restoring || atDelete.PurgeAfter == 0 {
52 t.Fatalf("row at the marker delete = %+v, want expiry now+7d, restoring, deadline still set", atDelete)
53 }
54 if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.Restoring || l.PurgeAfter != 0 || l.PurgeFor != 0 {
55 t.Fatalf("row after = %+v, want live, no deadline", l)
56 }
57}
58
59// Spec §9: a failed tombstone delete keeps the deadline and releases restoring.
60func TestRestoreWhoseMarkerDeleteFailsKeepsTheDeadline(t *testing.T) {
61 fx := newConsoleFixture(t)
62 now := time.Now().Truncate(time.Second)
63 trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour))
64 before := *lifeOf(t, fx, "tmp-a")
65 fx.srv.cfg.Releases = &hookStore{Store: fx.rel, failDelete: map[string]int{release.AppTombstoneKey(fx.sqid, "tmp-a"): 1}}
66 err := fx.srv.restoreApp(context.Background(), opsAccount(t, fx), "tmp-a", now)
67 var rf *restoreFailure
68 if !errors.As(err, &rf) || !rf.Marker {
69 t.Fatalf("restoreApp = %v, want a marker-half restoreFailure", err)
70 }
71 l := lifeOf(t, fx, "tmp-a")
72 if l.Restoring || !l.Trashed() || l.PurgeAfter != before.PurgeAfter || l.PurgeFor != before.PurgeFor {
73 t.Fatalf("row = %+v, want trashed with the same deadline and restoring released", l)
74 }
75}
76
77// Spec §9: a failed step 5 holds the cap slot until settled.
78func TestRestoreThatStopsBeforeStepFiveHoldsItsSlot(t *testing.T) {
79 fx := newConsoleFixture(t)
80 now := time.Now().Truncate(time.Second)
81 for i := 0; i < temporaryAppCap-1; i++ {
82 tempApp(t, fx, fmt.Sprintf("tmp-live%02d", i), now.Add(time.Hour))
83 }
84 trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour))
85 // Steps 3 and 4 done, step 5 never ran.
86 fx.st.SetRestoring(OpsAccountID, "tmp-a", true)
87 release.RestoreApp(context.Background(), fx.rel, fx.sqid, "tmp-a")
88 if err := fx.srv.temporaryCapCheck(OpsAccountID); err == nil {
89 t.Fatal("the half-restored app's slot was free for a new app to take")
90 }
91 if err := fx.srv.settleApp(context.Background(), OpsAccountID, fx.sqid, "tmp-a", now, always(true)); err != nil {
92 t.Fatal(err)
93 }
94 if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.Restoring {
95 t.Fatalf("settled row = %+v, want live", l)
96 }
97}
98
99// Spec §9: the cap is enforced on every console restore door.
100func TestRestoreDoorsEnforceTheCap(t *testing.T) {
101 fx := newConsoleFixture(t)
102 now := time.Now().Truncate(time.Second)
103 for i := 0; i < temporaryAppCap; i++ {
104 tempApp(t, fx, fmt.Sprintf("tmp-live%02d", i), now.Add(time.Hour))
105 }
106 trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour))
107 token := cliToken(t, fx, "paul@keymail.dev", "code-restore-cap")
108 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/tmp-a/restore", map[string]string{}, token)
109 if status != http.StatusConflict || out["code"] != "cap" {
110 t.Fatalf("API restore over the cap = %d %v, want 409 cap", status, out)
111 }
112 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-restore-cap-web")
113 st, loc, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"tmp-a"}})
114 if _, _, page := get(t, c, fx.ts.URL+loc); st != http.StatusSeeOther || !strings.Contains(page, "tmp-live00") {
115 t.Fatalf("browser restore over the cap = %d, want the refusal listing the live apps", st)
116 }
117 if markerOf(t, fx, "tmp-a") == nil {
118 t.Fatal("a refused restore removed the marker")
119 }
120}
121
122// Spec §5 "Trash by hand": the doors set the window for a temporary row only.
123func TestTrashDoorsSetTheWindowForTemporaryRows(t *testing.T) {
124 fx := newConsoleFixture(t)
125 tempApp(t, fx, "tmp-a", time.Now().Add(30*24*time.Hour))
126 token := cliToken(t, fx, "paul@keymail.dev", "code-trash-window")
127 for _, app := range []string{"tmp-a", "kass"} {
128 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/"+app+"/delete", map[string]string{}, token); status != http.StatusOK {
129 t.Fatalf("delete %s = %d %v", app, status, out)
130 }
131 }
132 tmp, kass := lifeOf(t, fx, "tmp-a"), lifeOf(t, fx, "kass")
133 if tmp.PurgeFor != tmp.DeletedAt || tmp.PurgeAfter != tmp.DeletedAt+72*3600 {
134 t.Fatalf("temporary trash = %+v, want the 3-day window", tmp)
135 }
136 if kass.PurgeAfter != 0 || kass.PurgeFor != 0 {
137 t.Fatalf("permanent trash = %+v, want none", kass)
138 }
139}
140
141// waitForWaiter returns once a second holder is queued on key: the request
142// under test has passed every check it makes before the lock and is blocked
143// on it. Deterministic: it waits for the queue, not for a clock.
144func waitForWaiter(t *testing.T, l *keyedLocks, key string) {
145 t.Helper()
146 deadline := time.Now().Add(10 * time.Second)
147 for time.Now().Before(deadline) {
148 l.mu.Lock()
149 n := 0
150 if e := l.m[key]; e != nil {
151 n = e.refs
152 }
153 l.mu.Unlock()
154 if n >= 2 {
155 return
156 }
157 time.Sleep(time.Millisecond)
158 }
159 t.Fatalf("no request queued on %s", key)
160}
161
162// Astra plan R1 finding 1: a trash door wrote the marker and failed its row
163// update, so the row is live. The manual purge must settle first: without it
164// PurgeApp sweeps the objects and PurgeAppRow (guarded deleted_at != 0)
165// silently keeps a live row that nothing ever repairs.
166func TestManualPurgeAfterAFailedTrashRowUpdate(t *testing.T) {
167 fx := newConsoleFixture(t)
168 if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "kass", "paul@keymail.dev", time.Now()); err != nil {
169 t.Fatal(err)
170 }
171 if l := lifeOf(t, fx, "kass"); l.Trashed() {
172 t.Fatal("setup: the row must still be live")
173 }
174 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-purge-unsettled")
175 postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
176 if keys := appKeys(t, fx, "kass"); len(keys) != 0 {
177 t.Fatalf("purge left objects: %v", keys)
178 }
179 if l := lifeOf(t, fx, "kass"); l != nil {
180 t.Fatalf("row = %+v after a purge, want it gone with the objects", l)
181 }
182}
183
184// Astra plan round 4: restoring a provisioned temporary static app must not
185// send anyone to carlos add, which refuses every tmp- name. Its instance
186// record survives trash and restore, and with the marker gone provision's
187// reconcile mints the route again (box side: TestReconcileDeclinesTrashedApp,
188// run in Step 4). Both restore doors are checked.
189func TestRestoreOfAProvisionedTemporaryStaticApp(t *testing.T) {
190 fx := newConsoleFixture(t)
191 fx.srv.cfg.AppsDomain = "carlosapp.test"
192 tempApp(t, fx, "tmp-site", time.Now().Add(24*time.Hour))
193 token := cliToken(t, fx, "paul@keymail.dev", "code-restore-provisioned")
194 base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site"
195 if st, out := putJSON(t, base+"/instances-manifest", map[string]any{}, token); st != http.StatusOK {
196 t.Fatalf("enable = %d %v", st, out)
197 }
198 apex := "tmp-site." + fx.sqid + ".carlosapp.test"
199 if st, _, out := rawReq(t, http.MethodPost, base+"/instances", token, `{"host":"`+apex+`","kind":"static","channel":"edge"}`); st != http.StatusCreated {
200 t.Fatalf("instance = %d %s", st, out)
201 }
202 recordKept := func(when string) {
203 t.Helper()
204 rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "tmp-site", apex)
205 if err != nil || rec == nil || rec.Kind != "static" || rec.Host != apex {
206 t.Fatalf("%s: instance record = %+v %v, want the static record at %s kept", when, rec, err, apex)
207 }
208 }
209
210 // API: delete, then restore.
211 if st, out := postJSON(t, base+"/delete", map[string]string{}, token); st != http.StatusOK {
212 t.Fatalf("delete = %d %v", st, out)
213 }
214 recordKept("after trash")
215 st, out := postJSON(t, base+"/restore", map[string]string{}, token)
216 if st != http.StatusOK {
217 t.Fatalf("restore = %d %v", st, out)
218 }
219 if note := str(t, out, "note"); strings.Contains(note, "carlos add") || !strings.Contains(note, "tmp-site") {
220 t.Fatalf("restore note = %q, want the instance-record guidance, never carlos add", note)
221 }
222 recordKept("after API restore")
223 if markerOf(t, fx, "tmp-site") != nil || lifeOf(t, fx, "tmp-site").Trashed() {
224 t.Fatal("after restore the marker must be gone and the row live, so reconcile mints the route again")
225 }
226
227 // Browser: delete through the API again, restore from the Trash page.
228 if st, out := postJSON(t, base+"/delete", map[string]string{}, token); st != http.StatusOK {
229 t.Fatalf("second delete = %d %v", st, out)
230 }
231 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-restore-provisioned-web")
232 st, loc, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"tmp-site"}})
233 if st != http.StatusSeeOther {
234 t.Fatalf("browser restore = %d", st)
235 }
236 if _, _, page := get(t, c, fx.ts.URL+loc); strings.Contains(page, "carlos add") || !strings.Contains(page, "tmp-site") {
237 t.Fatal("the browser restore flash sends a temporary app to carlos add, or does not name it")
238 }
239 recordKept("after browser restore")
240}
241
242// Astra plan round 2: settling under the lock must not turn a trash retry into
243// a refusal. A marker already in the bucket (a headless trash) and a live row:
244// the API trash settles the row onto the marker's clock and then answers 200
245// through its ordinary path, with the marker untouched, the existing deadline,
246// and a serial bump.
247func TestTrashAfterAHeadlessTrashIsIdempotent(t *testing.T) {
248 fx := newConsoleFixture(t)
249 ctx := context.Background()
250 old := time.Now().Add(-5 * 24 * time.Hour).Truncate(time.Second)
251 if err := release.TrashApp(ctx, fx.rel, fx.sqid, "kass", "headless", old); err != nil {
252 t.Fatal(err)
253 }
254 serialBefore, _, _ := fx.rel.Get(ctx, release.SerialKey)
255 token := cliToken(t, fx, "paul@keymail.dev", "code-trash-idempotent")
256 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token)
257 if status != http.StatusOK {
258 t.Fatalf("delete after a headless trash = %d %v, want 200", status, out)
259 }
260 until, err := time.Parse(time.RFC3339, str(t, out, "restorable_until"))
261 if err != nil || !until.Equal(old.Add(release.TrashCooloff)) {
262 t.Fatalf("restorable_until = %v %v, want the headless marker's %v", until, err, old.Add(release.TrashCooloff))
263 }
264 if tomb := markerOf(t, fx, "kass"); tomb == nil || !tomb.DeletedAt.Equal(old) || tomb.Actor != "headless" {
265 t.Fatalf("marker = %+v, want the headless one, untouched", tomb)
266 }
267 if serialAfter, _, _ := fx.rel.Get(ctx, release.SerialKey); string(serialAfter) == string(serialBefore) {
268 t.Fatal("the idempotent success skipped the serial bump")
269 }
270}
271
272// Astra plan R1 finding 2 (spec §2: a check made before the lock is a hint).
273// Each case holds the app's lock, lets the destructive request queue on it,
274// changes a precondition, and releases. The door must refuse on what is true
275// once it holds the lock.
276func TestDestructiveDoorsRecheckAfterTheLock(t *testing.T) {
277 run := func(t *testing.T, fx *consoleFixture, app string, request func() int, change func()) int {
278 t.Helper()
279 unlock := fx.srv.lockApp(fx.sqid, app)
280 done := make(chan int, 1)
281 go func() { done <- request() }()
282 waitForWaiter(t, &fx.srv.appLocks, fx.sqid+"/"+app)
283 change()
284 unlock()
285 return <-done
286 }
287
288 t.Run("API trash: marked control plane while waiting", func(t *testing.T) {
289 fx := newConsoleFixture(t)
290 token := cliToken(t, fx, "paul@keymail.dev", "code-recheck-api-cp")
291 status := run(t, fx, "kass", func() int {
292 st, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token)
293 return st
294 }, func() {
295 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
296 t.Error(err)
297 }
298 })
299 if status != http.StatusForbidden || markerOf(t, fx, "kass") != nil || lifeOf(t, fx, "kass").Trashed() {
300 t.Fatalf("status %d: the control plane was trashed by a request that checked the flag before waiting", status)
301 }
302 })
303
304 t.Run("API trash: row purged while waiting", func(t *testing.T) {
305 fx := newConsoleFixture(t)
306 token := cliToken(t, fx, "paul@keymail.dev", "code-recheck-api-gone")
307 status := run(t, fx, "kass", func() int {
308 st, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token)
309 return st
310 }, func() {
311 fx.st.TrashAppRow(OpsAccountID, "kass", time.Now())
312 fx.st.PurgeAppRow(OpsAccountID, "kass")
313 })
314 if status != http.StatusNotFound || markerOf(t, fx, "kass") != nil {
315 t.Fatalf("status %d: a delayed trash wrote a marker for a row that no longer exists", status)
316 }
317 })
318
319 t.Run("browser delete: marked control plane while waiting", func(t *testing.T) {
320 fx := newConsoleFixture(t)
321 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-recheck-web-cp")
322 run(t, fx, "kass", func() int {
323 st, _, _ := postGated(t, fx, c, fx.u("/apps/kass/delete"), url.Values{"csrf": {csrf}, "confirm": {"kass"}})
324 return st
325 }, func() {
326 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
327 t.Error(err)
328 }
329 })
330 if markerOf(t, fx, "kass") != nil || lifeOf(t, fx, "kass").Trashed() {
331 t.Fatal("the browser delete trashed the control plane after waiting on the lock")
332 }
333 })
334
335 t.Run("purge door: marked control plane while waiting", func(t *testing.T) {
336 fx := newConsoleFixture(t)
337 now := time.Now()
338 if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "kass", "paul@keymail.dev", now); err != nil {
339 t.Fatal(err)
340 }
341 fx.st.TrashAppRow(OpsAccountID, "kass", now)
342 before := len(appKeys(t, fx, "kass"))
343 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-recheck-purge-cp")
344 run(t, fx, "kass", func() int {
345 st, _, _ := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
346 return st
347 }, func() {
348 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
349 t.Error(err)
350 }
351 })
352 if got := len(appKeys(t, fx, "kass")); got != before || lifeOf(t, fx, "kass") == nil {
353 t.Fatalf("the purge door destroyed the control plane after waiting on the lock (%d of %d keys left)", got, before)
354 }
355 })
356}
357
358// Task 7 review, fix round 1: a restore whose step 5 failed has already
359// removed the marker. The retry must answer as a restore, with the audit row
360// and the serial bump, not "not in the trash". The failed attempt bumps the
361// serial too, because the bucket has already changed. Both doors.
362func TestRestoreRetryAfterAFailedRowUpdate(t *testing.T) {
363 failStepFive := func(t *testing.T, fx *consoleFixture) func() {
364 t.Helper()
365 if _, err := fx.st.db.Exec(`CREATE TRIGGER fail_step_five BEFORE UPDATE ON apps
366WHEN OLD.deleted_at != 0 AND NEW.deleted_at = 0
367BEGIN SELECT RAISE(ABORT, 'test: step 5 refused'); END`); err != nil {
368 t.Fatal(err)
369 }
370 return func() {
371 t.Helper()
372 if _, err := fx.st.db.Exec(`DROP TRIGGER fail_step_five`); err != nil {
373 t.Fatal(err)
374 }
375 }
376 }
377 serial := func(t *testing.T, fx *consoleFixture) string {
378 t.Helper()
379 b, _, _ := fx.rel.Get(context.Background(), release.SerialKey)
380 return string(b)
381 }
382
383 t.Run("API", func(t *testing.T) {
384 fx := newConsoleFixture(t)
385 token := cliToken(t, fx, "paul@keymail.dev", "code-restore-retry-api")
386 base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass"
387 if st, out := postJSON(t, base+"/delete", map[string]string{}, token); st != http.StatusOK {
388 t.Fatalf("delete = %d %v", st, out)
389 }
390 restoreRow := failStepFive(t, fx)
391 before := serial(t, fx)
392 if st, out := postJSON(t, base+"/restore", map[string]string{}, token); st != http.StatusInternalServerError {
393 t.Fatalf("restore with step 5 failing = %d %v, want 500", st, out)
394 }
395 if markerOf(t, fx, "kass") != nil || !lifeOf(t, fx, "kass").Trashed() {
396 t.Fatal("setup: want the marker gone and the row still trashed")
397 }
398 if serial(t, fx) == before {
399 t.Fatal("the failed step 5 skipped the serial bump, though the marker is gone")
400 }
401 restoreRow()
402 before = serial(t, fx)
403 if st, out := postJSON(t, base+"/restore", map[string]string{}, token); st != http.StatusOK || out["result"] != "restored" {
404 t.Fatalf("retry = %d %v, want 200 restored", st, out)
405 }
406 if l := lifeOf(t, fx, "kass"); l.Trashed() || l.Restoring {
407 t.Fatalf("row after the retry = %+v, want live", l)
408 }
409 auditedBy(t, fx, "kass", "restore", "paul@keymail.dev", "kass")
410 if serial(t, fx) == before {
411 t.Fatal("the retry skipped the serial bump")
412 }
413 })
414
415 t.Run("browser", func(t *testing.T) {
416 fx := newConsoleFixture(t)
417 token := cliToken(t, fx, "paul@keymail.dev", "code-restore-retry-web")
418 if st, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token); st != http.StatusOK {
419 t.Fatalf("delete = %d %v", st, out)
420 }
421 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-restore-retry-web-in")
422 restoreRow := failStepFive(t, fx)
423 before := serial(t, fx)
424 postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}})
425 if markerOf(t, fx, "kass") != nil || !lifeOf(t, fx, "kass").Trashed() {
426 t.Fatal("setup: want the marker gone and the row still trashed")
427 }
428 if serial(t, fx) == before {
429 t.Fatal("the failed step 5 skipped the serial bump, though the marker is gone")
430 }
431 restoreRow()
432 before = serial(t, fx)
433 st, loc, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}})
434 if _, _, page := get(t, c, fx.ts.URL+loc); st != http.StatusSeeOther || strings.Contains(page, "nothing called") {
435 t.Fatalf("retry = %d, want the restored flash, not the not-in-trash refusal", st)
436 }
437 if l := lifeOf(t, fx, "kass"); l.Trashed() || l.Restoring {
438 t.Fatalf("row after the retry = %+v, want live", l)
439 }
440 auditedBy(t, fx, "kass", "restore", "paul@keymail.dev", "kass")
441 if serial(t, fx) == before {
442 t.Fatal("the retry skipped the serial bump")
443 }
444 })
445}
446