Skip to content

Commit d04b26f

Browse files
fix(deploy): guarded CAS on redeploy 'building' flip — close TOCTOU (#14)
Both redeploy entry points (POST /deploy/:id/redeploy and POST /deploy/new redeploy=true) read the deployment row, assert it is non-terminal, then flip it to 'building'. Between the read and the flip the DeploymentExpirer / teardown reconciler can reap the row to 'expired'/'deleted'. The old unconditional UpdateDeploymentStatus would resurrect that reaped, over-TTL / over-cap workload back to 'building'. New models.MarkDeploymentBuilding does the flip as a guarded compare-and-swap (WHERE status IN ('building','deploying','healthy','failed')) and returns rows-affected — mirroring the CAS guards already on MarkDeploymentTornDown and SetDeploymentTTL. Both handlers now: 0 rows -> 409 deployment_not_redeployable (reaped concurrently, do not re-arm); driver error -> log + continue (non-determinate; runRedeployAsync reconciles); 1 row -> proceed. New metric label DeployRedeployInPlaceTotal{outcome="not_redeployable"}. Coverage: Symptom: redeploy resurrects an expired/deleted deploy to 'building' Enumeration: rg -F 'UpdateDeploymentStatus(c.Context(), h.db' + "building" flips in deploy.go Sites found: 2 (in-place /deploy/new redeploy=true; POST /deploy/:id/redeploy) Sites touched: 2 Coverage test: TestMarkDeploymentBuilding_Branches (1-row/0-row/driver), TestDeployNew_Redeploy_CASMiss_Returns409, TestDeployRedeploy_ByID_CASMiss_Returns409, TestDeployRedeploy_ByID_CASSuccess_Returns202, TestDeployRedeploy_ByID_CASDriverError_StillAccepts, TestDeployNew_Redeploy_UpdateStatusError_StillAccepts (updated) Live verified: awaiting post-merge auto-deploy (rule 14 build-SHA gate in CI) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 498f5cf commit d04b26f

5 files changed

Lines changed: 383 additions & 13 deletions

File tree

internal/handlers/deploy.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,10 +1087,20 @@ func (h *DeployHandler) New(c *fiber.Ctx) error {
10871087
"Existing deployment has no provider ID yet — initial build may still be running. Try again in a few seconds.")
10881088
}
10891089

1090-
// Flip the row to 'building' (mirrors POST /deploy/:id/redeploy).
1091-
if err := models.UpdateDeploymentStatus(c.Context(), h.db, existing.ID, "building", ""); err != nil {
1090+
// Flip the row to 'building' as a guarded CAS (mirrors POST
1091+
// /deploy/:id/redeploy). FindActiveDeploymentByTeamEnvName already
1092+
// filters to redeployable statuses, but the reaper can flip this row
1093+
// to expired/deleted in the TOCTOU window between that lookup and
1094+
// here — the CAS reports 0 rows in that case and we 409 rather than
1095+
// resurrecting a dead workload. A driver error is non-determinate;
1096+
// log and continue (runRedeployAsync reconciles the status later).
1097+
if n, err := models.MarkDeploymentBuilding(c.Context(), h.db, existing.ID); err != nil {
10921098
slog.Warn("deploy.new.redeploy_status_update_failed",
10931099
"app_id", existing.AppID, "error", err)
1100+
} else if n == 0 {
1101+
metrics.DeployRedeployInPlaceTotal.WithLabelValues("not_redeployable").Inc()
1102+
return respondError(c, fiber.StatusConflict, errCodeDeploymentNotRedeployable,
1103+
"This deployment is no longer in a redeployable state (it was reaped concurrently). Create a new deployment instead.")
10941104
}
10951105

10961106
// Audit BEFORE the async build (source="deploy_new_in_place" so the
@@ -1905,9 +1915,19 @@ func (h *DeployHandler) Redeploy(c *fiber.Ctx) error {
19051915
"Failed to read tarball bytes")
19061916
}
19071917

1908-
// Update status to "building" while the redeploy runs.
1909-
if err := models.UpdateDeploymentStatus(c.Context(), h.db, d.ID, "building", ""); err != nil {
1918+
// Flip the row to "building" as a guarded CAS. The IsDeploymentTerminal
1919+
// gate above is a check-then-act with a TOCTOU window: the reaper can
1920+
// flip this row to expired/deleted between that read and here. The CAS
1921+
// only matches a redeployable status, so a row reaped in that window
1922+
// reports 0 rows and we 409 rather than resurrecting a dead workload.
1923+
// A driver error is non-determinate (we can't tell whether the flip
1924+
// landed) — log and continue, since runRedeployAsync will reconcile the
1925+
// status; only the explicit 0-row CAS miss means "reaped, do not re-arm".
1926+
if n, err := models.MarkDeploymentBuilding(c.Context(), h.db, d.ID); err != nil {
19101927
slog.Warn("deploy.redeploy.status_update_failed", "app_id", appID, "error", err)
1928+
} else if n == 0 {
1929+
return respondError(c, fiber.StatusConflict, errCodeDeploymentNotRedeployable,
1930+
"This deployment is no longer in a redeployable state (it was reaped concurrently). Create a new deployment instead.")
19111931
}
19121932

19131933
// Emit audit trail BEFORE the async build runs — same shape as

internal/handlers/deploy_redeploy_inplace_mock_test.go

Lines changed: 283 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -330,11 +330,13 @@ func TestDeployNew_Redeploy_UpdateStatusError_StillAccepts(t *testing.T) {
330330
"", "", "", // git_url, git_ref, git_token_enc (mig 065)
331331
))
332332

333-
// UPDATE deployments SET status = $1 ... → driver error. The handler
334-
// must slog.Warn and CONTINUE (NOT return 5xx) — the redeploy itself
335-
// is still useful because runRedeployAsync will flip the row later.
336-
mock.ExpectExec(`UPDATE deployments\s+SET status = \$1, error_message = \$2, updated_at = now\(\)`).
337-
WithArgs("building", nil, rowID).
333+
// MarkDeploymentBuilding (guarded CAS) → driver error. The handler must
334+
// slog.Warn and CONTINUE (NOT return 5xx) — a driver error is
335+
// non-determinate (we can't tell whether the flip landed), and
336+
// runRedeployAsync will reconcile the row later. Only an explicit 0-row
337+
// CAS miss means "reaped concurrently, return 409".
338+
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
339+
WithArgs(rowID).
338340
WillReturnError(errMockRedeployDriver)
339341

340342
// MatchExpectationsInOrder=false because the audit goroutine
@@ -448,6 +450,282 @@ func TestDeployNew_Redeploy_EmptyProviderID_Returns409(t *testing.T) {
448450
require.NoError(t, mock.ExpectationsWereMet())
449451
}
450452

453+
// TestDeployNew_Redeploy_CASMiss_Returns409 pins the #14 TOCTOU hardening on
454+
// the in-place path. FindActiveDeploymentByTeamEnvName returns a redeployable
455+
// row (status=healthy, provider_id set), but between that lookup and the
456+
// 'building' flip the reaper advanced the row to a terminal status. The
457+
// guarded CAS therefore matches 0 rows. The handler MUST 409
458+
// deployment_not_redeployable (NOT re-arm the dead workload as 'building').
459+
func TestDeployNew_Redeploy_CASMiss_Returns409(t *testing.T) {
460+
db, mock, err := sqlmock.New()
461+
require.NoError(t, err)
462+
defer db.Close()
463+
464+
app, teamID, _ := redeployMockApp(t, db)
465+
rowID := uuid.New()
466+
467+
expectTeamLookupOK(mock, teamID, "pro")
468+
469+
envVarsJSON, _ := json.Marshal(map[string]string{"_name": "raced-reaper"})
470+
mock.ExpectQuery(`FROM deployments\s+WHERE team_id = \$1\s+AND env = \$2\s+AND env_vars->>'_name' = \$3`).
471+
WithArgs(teamID, "development", "raced-reaper").
472+
WillReturnRows(sqlmock.NewRows(deploymentColumnsList).AddRow(
473+
rowID,
474+
teamID,
475+
uuid.NullUUID{},
476+
"racedrpr",
477+
"app-racedrpr", // non-empty provider_id → passes the not_ready guard
478+
"healthy",
479+
"https://racedrpr.deploy.",
480+
envVarsJSON,
481+
8080, "pro", "development",
482+
false, "",
483+
sql.NullString{},
484+
time.Now(), time.Now(),
485+
sql.NullString{}, sql.NullString{}, "unset", 0,
486+
sql.NullTime{}, "permanent", 0, sql.NullTime{},
487+
"tarball", "", "", // source, image_ref, registry_creds_enc (mig 064)
488+
"", "", "", // git_url, git_ref, git_token_enc (mig 065)
489+
))
490+
491+
// Guarded CAS matches 0 rows — the reaper won the race. Handler 409s.
492+
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
493+
WithArgs(rowID).
494+
WillReturnResult(sqlmock.NewResult(0, 0))
495+
496+
body, ct := multipartRedeployMockBody(t, map[string]string{
497+
"name": "raced-reaper",
498+
"redeploy": "true",
499+
"port": "8080",
500+
"env": "development",
501+
})
502+
req := httptest.NewRequest(http.MethodPost, "/deploy/new", body)
503+
req.Header.Set("Content-Type", ct)
504+
505+
resp, err := app.Test(req, 5000)
506+
require.NoError(t, err)
507+
defer resp.Body.Close()
508+
respBody, _ := io.ReadAll(resp.Body)
509+
require.Equal(t, http.StatusConflict, resp.StatusCode,
510+
"a CAS miss (row reaped concurrently) must 409, not resurrect; body: %s", string(respBody))
511+
512+
var errBody struct {
513+
OK bool `json:"ok"`
514+
Error string `json:"error"`
515+
}
516+
require.NoError(t, json.Unmarshal(respBody, &errBody))
517+
assert.False(t, errBody.OK)
518+
assert.Equal(t, errCodeDeploymentNotRedeployable, errBody.Error,
519+
"CAS-miss response code must be deployment_not_redeployable")
520+
521+
require.NoError(t, mock.ExpectationsWereMet())
522+
}
523+
524+
// redeployByIDMockApp wires a Fiber app exposing POST /deploy/:id/redeploy
525+
// against a sqlmock-backed DB, with the same Locals-fake auth as
526+
// redeployMockApp. Used to exercise Redeploy's guarded-CAS branch
527+
// deterministically (the real-DB test in deploy_async_deployasync_test.go
528+
// can't interpose a reaper between GetDeploymentByAppID and the CAS flip).
529+
func redeployByIDMockApp(t *testing.T, db *sql.DB) (*fiber.App, uuid.UUID) {
530+
t.Helper()
531+
teamID := uuid.New()
532+
533+
cfg := &config.Config{
534+
JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!",
535+
AESKey: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
536+
EnabledServices: "deploy",
537+
Environment: "test",
538+
}
539+
h := &DeployHandler{db: db, cfg: cfg, compute: noop.New(), planRegistry: plans.Default()}
540+
541+
app := fiber.New(fiber.Config{
542+
BodyLimit: 50 * 1024 * 1024,
543+
ErrorHandler: func(c *fiber.Ctx, err error) error {
544+
if errors.Is(err, ErrResponseWritten) {
545+
return nil
546+
}
547+
code := fiber.StatusInternalServerError
548+
if e, ok := err.(*fiber.Error); ok {
549+
code = e.Code
550+
}
551+
return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error", "message": err.Error()})
552+
},
553+
})
554+
app.Use(func(c *fiber.Ctx) error {
555+
c.Locals(middleware.LocalKeyTeamID, teamID.String())
556+
c.Locals(middleware.LocalKeyUserID, uuid.NewString())
557+
return c.Next()
558+
})
559+
app.Post("/deploy/:id/redeploy", h.Redeploy)
560+
return app, teamID
561+
}
562+
563+
// TestDeployRedeploy_ByID_CASMiss_Returns409 pins the #14 TOCTOU hardening on
564+
// the POST /deploy/:id/redeploy path. GetDeploymentByAppID returns a
565+
// non-terminal row (status=healthy, provider_id set) so the IsDeploymentTerminal
566+
// gate passes, but the reaper advances the row to a terminal status before the
567+
// 'building' flip. The guarded CAS matches 0 rows, and the handler must 409
568+
// deployment_not_redeployable rather than resurrecting the reaped workload.
569+
func TestDeployRedeploy_ByID_CASMiss_Returns409(t *testing.T) {
570+
db, mock, err := sqlmock.New()
571+
require.NoError(t, err)
572+
defer db.Close()
573+
574+
app, teamID := redeployByIDMockApp(t, db)
575+
rowID := uuid.New()
576+
appID := "racedbyid"
577+
578+
expectTeamLookupOK(mock, teamID, "pro")
579+
580+
envVarsJSON, _ := json.Marshal(map[string]string{"_name": "raced-by-id"})
581+
mock.ExpectQuery(`SELECT .* FROM deployments WHERE app_id = \$1`).
582+
WithArgs(appID).
583+
WillReturnRows(sqlmock.NewRows(deploymentColumnsList).AddRow(
584+
rowID,
585+
teamID,
586+
uuid.NullUUID{},
587+
appID,
588+
"app-racedbyid", // non-empty provider_id → passes the not_ready guard
589+
"healthy", // non-terminal → passes IsDeploymentTerminal gate
590+
"https://racedbyid.deploy.",
591+
envVarsJSON,
592+
8080, "pro", "development",
593+
false, "",
594+
sql.NullString{},
595+
time.Now(), time.Now(),
596+
sql.NullString{}, sql.NullString{}, "unset", 0,
597+
sql.NullTime{}, "permanent", 0, sql.NullTime{},
598+
"tarball", "", "",
599+
"", "", "",
600+
))
601+
602+
// Guarded CAS matches 0 rows — the reaper won the race after the read.
603+
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
604+
WithArgs(rowID).
605+
WillReturnResult(sqlmock.NewResult(0, 0))
606+
607+
body, ct := multipartRedeployMockBody(t, map[string]string{"port": "8080"})
608+
req := httptest.NewRequest(http.MethodPost, "/deploy/"+appID+"/redeploy", body)
609+
req.Header.Set("Content-Type", ct)
610+
611+
resp, err := app.Test(req, 5000)
612+
require.NoError(t, err)
613+
defer resp.Body.Close()
614+
respBody, _ := io.ReadAll(resp.Body)
615+
require.Equal(t, http.StatusConflict, resp.StatusCode,
616+
"a CAS miss on the :id redeploy path must 409, not resurrect; body: %s", string(respBody))
617+
618+
var errBody struct {
619+
OK bool `json:"ok"`
620+
Error string `json:"error"`
621+
}
622+
require.NoError(t, json.Unmarshal(respBody, &errBody))
623+
assert.False(t, errBody.OK)
624+
assert.Equal(t, errCodeDeploymentNotRedeployable, errBody.Error)
625+
626+
require.NoError(t, mock.ExpectationsWereMet())
627+
}
628+
629+
// TestDeployRedeploy_ByID_CASSuccess_Returns202 is the happy-path partner: the
630+
// guarded CAS matches 1 row (the deploy was still redeployable), so the
631+
// handler proceeds to 202 and launches the async build. Pins that the CAS
632+
// guard does NOT break the normal redeploy.
633+
func TestDeployRedeploy_ByID_CASSuccess_Returns202(t *testing.T) {
634+
db, mock, err := sqlmock.New()
635+
require.NoError(t, err)
636+
defer db.Close()
637+
638+
app, teamID := redeployByIDMockApp(t, db)
639+
rowID := uuid.New()
640+
appID := "happybyid"
641+
642+
expectTeamLookupOK(mock, teamID, "pro")
643+
644+
envVarsJSON, _ := json.Marshal(map[string]string{"_name": "happy-by-id"})
645+
mock.ExpectQuery(`SELECT .* FROM deployments WHERE app_id = \$1`).
646+
WithArgs(appID).
647+
WillReturnRows(sqlmock.NewRows(deploymentColumnsList).AddRow(
648+
rowID, teamID, uuid.NullUUID{}, appID, "app-happybyid", "healthy",
649+
"https://happybyid.deploy.", envVarsJSON,
650+
8080, "pro", "development", false, "", sql.NullString{},
651+
time.Now(), time.Now(),
652+
sql.NullString{}, sql.NullString{}, "unset", 0,
653+
sql.NullTime{}, "permanent", 0, sql.NullTime{},
654+
"tarball", "", "", "", "", "",
655+
))
656+
657+
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
658+
WithArgs(rowID).
659+
WillReturnResult(sqlmock.NewResult(0, 1))
660+
661+
// emitDeployAudit + runRedeployAsync race the response; don't pin further.
662+
mock.MatchExpectationsInOrder(false)
663+
664+
body, ct := multipartRedeployMockBody(t, map[string]string{"port": "8080"})
665+
req := httptest.NewRequest(http.MethodPost, "/deploy/"+appID+"/redeploy", body)
666+
req.Header.Set("Content-Type", ct)
667+
668+
resp, err := app.Test(req, 5000)
669+
require.NoError(t, err)
670+
defer resp.Body.Close()
671+
respBody, _ := io.ReadAll(resp.Body)
672+
require.Equal(t, http.StatusAccepted, resp.StatusCode,
673+
"a successful CAS (1 row) must 202 and launch the async build; body: %s", string(respBody))
674+
675+
time.Sleep(50 * time.Millisecond) // let the async goroutines drain
676+
}
677+
678+
// TestDeployRedeploy_ByID_CASDriverError_StillAccepts pins the :id redeploy
679+
// driver-error arm of the guarded CAS. A driver error on MarkDeploymentBuilding
680+
// is non-determinate (we can't tell whether the flip landed), so the handler
681+
// must slog.Warn and CONTINUE to 202 — runRedeployAsync reconciles the status
682+
// later. Only an explicit 0-row CAS miss means "reaped, 409".
683+
func TestDeployRedeploy_ByID_CASDriverError_StillAccepts(t *testing.T) {
684+
db, mock, err := sqlmock.New()
685+
require.NoError(t, err)
686+
defer db.Close()
687+
688+
app, teamID := redeployByIDMockApp(t, db)
689+
rowID := uuid.New()
690+
appID := "drvbyid"
691+
692+
expectTeamLookupOK(mock, teamID, "pro")
693+
694+
envVarsJSON, _ := json.Marshal(map[string]string{"_name": "drv-by-id"})
695+
mock.ExpectQuery(`SELECT .* FROM deployments WHERE app_id = \$1`).
696+
WithArgs(appID).
697+
WillReturnRows(sqlmock.NewRows(deploymentColumnsList).AddRow(
698+
rowID, teamID, uuid.NullUUID{}, appID, "app-drvbyid", "healthy",
699+
"https://drvbyid.deploy.", envVarsJSON,
700+
8080, "pro", "development", false, "", sql.NullString{},
701+
time.Now(), time.Now(),
702+
sql.NullString{}, sql.NullString{}, "unset", 0,
703+
sql.NullTime{}, "permanent", 0, sql.NullTime{},
704+
"tarball", "", "", "", "", "",
705+
))
706+
707+
// Guarded CAS → driver error (non-determinate). Handler logs + continues.
708+
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
709+
WithArgs(rowID).
710+
WillReturnError(errMockRedeployDriver)
711+
712+
// emitDeployAudit + runRedeployAsync race the response; don't pin further.
713+
mock.MatchExpectationsInOrder(false)
714+
715+
body, ct := multipartRedeployMockBody(t, map[string]string{"port": "8080"})
716+
req := httptest.NewRequest(http.MethodPost, "/deploy/"+appID+"/redeploy", body)
717+
req.Header.Set("Content-Type", ct)
718+
719+
resp, err := app.Test(req, 5000)
720+
require.NoError(t, err)
721+
defer resp.Body.Close()
722+
respBody, _ := io.ReadAll(resp.Body)
723+
require.Equal(t, http.StatusAccepted, resp.StatusCode,
724+
"a CAS driver error must NOT block the 202 accept; body: %s", string(respBody))
725+
726+
time.Sleep(50 * time.Millisecond) // let the async goroutines drain
727+
}
728+
451729
// TestDeployNew_Redeploy_MissingName_AfterValidation pins deploy.go:655-661.
452730
// The branch fires when shouldRedeployInPlace is true AND name == "". In
453731
// practice requireName at line 604 fires first on empty/whitespace input,

internal/metrics/metrics.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,13 @@ var (
244244
// DeployRedeployInPlaceTotal counts the POST /deploy/new in-place
245245
// redeploy outcomes (redeploy=true form field). Labels:
246246
//
247-
// outcome = "success" — match found, redeploy compute path invoked
248-
// "not_found" — no live deployment for (team, env, name)
249-
// "wrong_team" — name exists on a different team (404 is
250-
// still returned — we never confirm existence)
247+
// outcome = "success" — match found, redeploy compute path invoked
248+
// "not_found" — no live deployment for (team, env, name)
249+
// "wrong_team" — name exists on a different team (404 is
250+
// still returned — we never confirm existence)
251+
// "not_redeployable" — row was reaped (expired/deleted) in the
252+
// TOCTOU window between lookup and the
253+
// guarded 'building' CAS → 409 (#14)
251254
//
252255
// Closes the agent-UX gap surfaced 2026-05-30 (duplicate-URL incident):
253256
// agents previously called /deploy/new repeatedly, minting a fresh

0 commit comments

Comments
 (0)