fix(heal-reliability): five surgical fixes for reliable fleet-wide vhost heal - #188
Merged
Conversation
Failing tests that pin the expected behaviour for: - Fix 1: no sudo tee to mktemp comparison file (node + static paths) - Fix 2: node-app sudoers must include four Caddy grants (reload/tee/mv/rm sites.d) - Fix 3: inline-Caddyfile pre-flight guard emits inline-caddyfile finding + early exit - Fix 4: --adopt-provenance flag for byte-identical header adoption - Fix 5: HEAL_VM_CAP env-overridable via SAMOHOST_HEAL_VM_CAP 17/26 tests RED; 9/26 already pass (unrelated assertions on existing behaviour). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ost heal Root causes of the 2026-07-20 favicon rollout requiring hand-writes to each VM: 1. sudo tee to /tmp comparison file (Fix 1) heal-script.ts used `sudo /usr/bin/tee "$_new_main_vhost"` to write the mktemp comparison copy — a path that is NOT covered by the VMs' NOPASSWD grant (`/etc/caddy/sites.d/*.caddy`). The privileged write failed silently. Fix: use plain `cat >` redirect for the user-writable temp file; only the final staged sites.d path keeps `sudo /usr/bin/tee`. 2. Node-app sudoers missing four Caddy grants (Fix 2) bootstrap.ts node-app §6 sudoers omitted the four grants present in the static block (reload caddy, tee/mv/rm sites.d/*.caddy). Without them, the node-app SSH user cannot write to sites.d even after Fix 1. Fix: add all four exact-path NOPASSWD grants to the node-app sudoers block. Re-running bootstrap §6 on existing node-app VMs (idempotent) backfills them. 3. Inline-Caddyfile pre-flight guard (Fix 3) VMs using inline site blocks (not `import sites.d/*.caddy`) would create a duplicate-site conflict if heal wrote to sites.d. The script now checks for the import line at runtime and exits with outcome `inline-caddyfile` (exit 0, writes nothing) when absent or when the app's mainHost appears as an inline site block. 4. --adopt-provenance flag (Fix 4) Hand-written vhosts (favicon rollout, any pre-samohost file) lack the provenance header and were permanently skipped as drift-foreign. New `adoptProvenance` option: when content is byte-identical to the regenerated vhost (modulo the missing header), the script prepends the header via the existing staged+atomic-mv machinery and reports `adopted`. On the second run, the header is present → normal cmp → `no-drift` (idempotent). Default: false (drift-foreign skip preserved; gate behaviour unchanged). 5. HEAL_VM_CAP env-overridable (Fix 5) trigger.ts hardcoded `const HEAL_VM_CAP = 2`. Operators can now raise it via `SAMOHOST_HEAL_VM_CAP` without a code change. Default stays 2 (blast-radius limiter for the initial enable cycle). NOT in scope: SAMOHOST_APP_HEAL gate (unchanged, off by default), /etc/caddy/ Caddyfile automation, rollback/health-probe logic, per-VM state migrations. Merging this PR changes ZERO runtime behaviour on prod (gate is still off). Docs: heal-enable-runbook.md (pre-enable checklist, activation, rollback); migrate-inline-caddyfile.md (field-record step-by-step with rollback). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AppHealResult.app is required (string). The Fix 5 test mock was returning
{ outcome, exitCode } — wrong shape. Fix: return { outcome, app: app.name }.
Also remove the unused AppHealResult import (TS6133).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root-cause analysis
During the 2026-07-20 favicon rollout every VM had to receive hand-written vhosts over SSH because
samohost app heal --applyfailed silently on each one. Five distinct failure modes — all in the code, not in operator config (except Fix 5 which adds a knob):Fix 1 — sudo tee to /tmp comparison file blocked
Root cause:
heal-script.tsline ~366/423 wrote the mktemp comparison copy withsudo /usr/bin/tee "$_new_main_vhost". The VMs' NOPASSWD sudoers grants only cover/etc/caddy/sites.d/*.caddy— not arbitrary/tmp/*paths. The privileged write failed; bash-euo pipefailaborted the script before any vhost was touched.Fix: Replace with plain
cat > "$_new_main_vhost" <<'HEREDOC'(user-writable temp; no sudo). Only the final staged sites.d path retainssudo /usr/bin/tee. Heredoc delimiters unchanged (quoted for node, unquoted for static — preserves variable expansion semantics).Fix 2 — Node-app sudoers missing four Caddy grants
Root cause:
bootstrap.ts§6 node-app sudoers block omitted the four grants present in the static block (reload caddy,tee /etc/caddy/sites.d/*.caddy,mv --,rm -f). Without these, the node-app SSH user cannot write to sites.d even after Fix 1 corrects the temp-file path.Fix: Add all four exact-path NOPASSWD grants to the node-app sudoers block. Re-running the bootstrap §6 section on existing node-app VMs (idempotent — overwrites
/etc/sudoers.d/<app>-agent, visudo-validated) backfills the grants.Fix 3 — Inline-Caddyfile pre-flight guard
Root cause: VMs with inline site blocks (field-record:
http://field-record-1.samo.team { ... }directly in/etc/caddy/Caddyfilerather thanimport sites.d/*.caddy) would get a duplicate-site conflict if heal wrote to sites.d. The old code didn't detect this and would write, caddy validate would fail, rollback would fire.Fix: Generated script now runs a pre-flight grep before any vhost work. If the import line is absent OR the app's mainHost appears as an inline block, the script emits
inline-caddyfileand exits 0 (writes nothing). A separate operator migration (documented indocs/migrate-inline-caddyfile.md) is required for field-record before heal will attempt it.Fix 4 — adopt-provenance flag for header-less vhosts
Root cause: Hand-written vhosts (favicon rollout hand-writes, game-changers, gregg-sites) lack the
# generated by samohost/...provenance header on line 1. The heal script classifies them asdrift-foreignand skips indefinitely, requiring manual re-writes on every generator change.Fix: New
adoptProvenanceoption onbuildConfigHealScript. When set, if the live file content is byte-identical to the regenerated content modulo the missing header, the script prepends the header via the existing staged+atomic-mv+validate+rollback machinery and reportsadopted. On the second run, the header is present → normal cmp →no-drift(idempotent). Default: false; drift-foreign skip preserved for unreviewed cases. CLI:samohost app heal <vm> <app> --apply --adopt-provenance.Fix 5 — HEAL_VM_CAP env-overridable
Root cause:
trigger.tshardcodedconst HEAL_VM_CAP = 2. During a fleet-wide change, 2 VMs/cycle means ceil(N/2) cycles to converge all N apps, causing multi-cycle lag. The default (blast-radius limiter) is correct for initial enable, but operators need a knob to raise it without a code change.Fix:
const HEAL_VM_CAP = Number(process.env["SAMOHOST_HEAL_VM_CAP"] ?? 2). Default behaviour unchanged.Test evidence
f078f9b— 17/26 new tests fail (pinning each fix's behaviour)c5b211b— all 26 pass; full suite 2303 pass / 1 pre-existing fail (auth/pg.test.ts requiresPG_TEST_URLenv, fails on origin/main too)What this PR does NOT do
SAMOHOST_APP_HEALgate or its default (off). Merging activates ZERO new runtime behaviour on prod./etc/caddy/Caddyfilechanges (heal-script.ts scope comment on line 22 preserved).Post-merge per-VM migration steps
See
vmMigrationPlanin the structured output anddocs/migrate-inline-caddyfile.md.🤖 Generated with Claude Code