fix(preview-create-restart): fix 3 root causes behind new-preview 502 - #82
fix(preview-create-restart): fix 3 root causes behind new-preview 502#82samo-agent wants to merge 4 commits into
Conversation
Add failing tests that pin the expected behaviour for the three root causes behind the confirmed production preview-502 bug: 1. env-script: unit phase must emit systemctl restart (not just enable --now which is a no-op on already-active units). Also: host-prep sudoers must include the restart grant. 2. env-command: a failed re-create must NOT clobber prNumber on an existing PR-managed env record. The current always-upsert path spreads a record without prNumber, silently clearing it and breaking the closed-PR reaper guard. 3. trigger: runTriggerRun must forward its err sink to deps.prPreview so ensurePreview failure reasons (e.g. HTTP 502) reach journalctl instead of being noop-swallowed in the closure. All three tests FAIL on current origin/main (b308f87) as required for the RED→GREEN TDD protocol. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1 — unit restart (src/env/script.ts): The unit phase emitted `systemctl enable --now` which is a systemd no-op when the unit is already active. The running process kept listening on the OLD port even after `.env` was rewritten with a new one. Caddy routed to the new port; nothing was listening → HTTP 502. Fix: replace `enable --now` with `enable && restart`. `restart` always brings a fresh process that re-reads the current EnvironmentFile (.env). Also adds the required `systemctl restart` sudoers grant to host-prep, and replaces the old `enable --now` grant with separate `enable` + `restart`. Bug 2 — upsert-on-failure clobbers prNumber (src/commands/env.ts): `envStore.upsert(record)` ran unconditionally on both success and failure. On a re-create, the new `record` is constructed without `prNumber` (it is not in EnvScriptTarget). Upserting on re-create failure spread a record without prNumber → cleared the field → broke the closed-PR reaper guard (`env.prNumber !== undefined`) → stranded broken envs permanently. Fix: skip the upsert when `outcome !== "ok" AND existing !== undefined`. Fresh creates (no existing record) still upsert on failure for idempotent retry. The success path is unchanged. Bug 3 — err sink noop in ensurePreviewImpl (src/commands/trigger.ts): The trigger's `ensurePreviewImpl` closure passed `noop` as the err sink to `runEnvCreate`. When the create failed (e.g. external probe returned 502), the diagnostic message "samohost: external probe failed …" was silently swallowed — journalctl -u samohost-trigger only showed action:"failed" with no reason. Also: `lastDeployedSha` was stamped unconditionally even on failure, so the next trigger cycle saw needDeploy=false and never retried the broken preview. Fix: wire a real err sink (writes to process.stderr AND forwards to the caller's err sink). Stamp `lastDeployedSha` only on outcome="ok". Update `TriggerDeps.prPreview` signature to accept an optional err parameter so runTriggerRun can forward its own err sink. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ackward compat The initial fix used bare `systemctl enable` + `systemctl restart` requiring two new sudoers grants on existing hosts. Switch to the conditional form: is-active → restart (re-reads .env on re-create) !is-active → enable --now (initial create, existing grant) This keeps `restart` as the only new sudoers grant needed. Update host-prep to emit `enable --now` (kept) + `restart` (new), dropping the unused bare `enable`. Tests and comments updated to match. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TS2300 duplicate identifier on the second module-level import type. The first import at line 1133 is already in scope for all subsequent describe blocks; the second (added by the RED test commit) is redundant and breaks tsc --noEmit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
VERDICT: REQUEST CHANGES Gate status
FindingsBLOCKING1. Operator prerequisite not documented — operator-prereq-or-degraded-gate rule The fix emits The MR body says "adds the required sudoers grant to Failure mode if merged before sudoers is applied:
This is not silently worse than today (today: no-op → 502 → sha stamped → never retried; after merge without sudoers: hard fail → no sha stamp → trigger retries every 3 min). But it is still a broken state until host-prep is run, and the MR gives no operator guidance on ordering. Per Required fix: add an "Operator prerequisite" section to the MR description (or a DEPLOYMENT.md note) stating: "Run NON-BLOCKING (nits, no hold)2. Bash conditional has an edge case on FAILED-state units If the unit is in systemd 3. TDD sequence has a post-GREEN fixup commit Commit Diff correctness summary (for reference)Fix 1 (script.ts): Logic is correct. Fix 2 (env.ts): Fix 3 (trigger.ts): No secrets, no fake-UX strings, no wall-clock LLM timeouts, no unrelated churn. The two Required merge/deploy ordering
Merging without step 1 leaves re-creates hard-failing (retriable) rather than 502-ing silently — not catastrophic, but the blocking finding above requires the MR to document this ordering before merge. |
Root cause analysis
New PR previews returned 502 for
samo-we-field-record. Three compounding root causes were identified inb308f87:Bug 1 — unit restart (
src/env/script.ts:~878)systemctl enable --now "$UNIT"is a systemd no-op when the unit is already active — systemd returns 0 without touching the running process. The process started onOLD_PORTkeeps listening onOLD_PORTeven after.envis rewritten withNEW_PORT. Caddy routes toNEW_PORT, nothing listens → external probe sees HTTP 502.Fix: replace
enable --nowwithenable "$UNIT" && restart "$UNIT".restartalways brings a fresh process that re-reads the currentEnvironmentFile(.env). Also adds the requiredsystemctl restartsudoers grant tobuildHostPrepScript(and replaces the old combinedenable --nowgrant with separateenable+restartgrants).Bug 2 — upsert-on-failure clobbers
prNumber(src/commands/env.ts:~717–731)envStore.upsert(record)ran unconditionally on both success and failure. Therecordconstruction does not includeprNumber(it is not inEnvScriptTarget). On a re-create failure, upserting spread a record withoutprNumber→ silently cleared the field from an existing PR-managed env record → broke the closed-PR reaper guard (env.prNumber !== undefined) → the broken env was never reaped, stranded permanently.Fix: skip the upsert when
outcome !== "ok"ANDexisting !== undefined. Fresh creates (no existing record) still upsert on failure for idempotent retry. The success path is unchanged.Bug 3 —
nooperr sink + unconditionallastDeployedShastamp (src/commands/trigger.ts:~795)ensurePreviewImplpassednoopas theerrsink torunEnvCreate. When create failed, the diagnostic message (e.g."external probe failed … HTTP 502") was silently swallowed —journalctl -u samohost-triggeronly showedaction:"failed"with no reason. Additionally,lastDeployedShawas stamped onto the env record unconditionally even on failure. The next trigger cycle sawexisting.lastDeployedSha === pr.headSha→needDeploy = false→ never retried the broken preview. The env was permanently stuck.Fix: wire a real err sink that writes to
process.stderrAND forwards to the caller'serrsink (mirrorsreapPreviewImpl~line 904). StamplastDeployedShaonly whenoutcome === "ok". UpdateTriggerDeps.prPreviewsignature to accept an optionalerrparameter sorunTriggerRuncan forward its own err sink — all existing tests are backward-compatible.TDD evidence
d7c019a— 3 new failing tests, 231 passing65d9d52— 234 passing, 0 failingbun testoutput: 1021 pass, 0 failTest plan
bun testpasses (1021 tests)env create samo-we-field-record field-record --branch preview/bright-green-background --db dblab --jsonreturnsoutcome:"ok"https://field-record-preview-bright-green-background.samo.cat/api/versionreturns HTTP 200~/bin/dev-story-previews.shshows the PR passingIntentionally deferred
origin/mainevery 3 min and may re-break the env until this MR is merged. The E2E run proves the fix works; durable resolution needs merge.lastDeployedShais not currently stamped at the right level in therunPrPreviewPassalgorithm (it delegates toensurePreview). A follow-up could move stamping intorunPrPreviewPassitself for cleaner separation of concerns.🤖 Generated with Claude Code
This fix calls
systemctl restarton active preview units, which needs a sudoers grant that only lands when host-prep is re-run as root on the VM. Safe sequence (do NOT merge first):samo-we-field-record→ applies therestartsudoers grant.sudo -u agent sudo /usr/bin/systemctl restart 'field-record@*.service'has no permission-denied.outcome: "ok"and~/bin/dev-story-previews.shgoes green.If merged before step 1: fresh creates are fine; re-creates of active units hard-fail (retriable each cycle, not a silent 502).