fix(1d): custom-html backup/restore overlay reads marker via exec (volume-direct)

http_fetch raced the serving layer right after backup-bot cycled the app container (served '' for a
moment). Backup/restore preserve the VOLUME, so read the marker in-container via exec_in_app — correct
and race-free. Serving is proven separately by install/upgrade assert_serving.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:10:35 +01:00
co-authored by Claude Opus 4.7
parent feb6f80d50
commit b758767830
2 changed files with 22 additions and 9 deletions
+14 -5
View File
@@ -1,6 +1,11 @@
"""custom-html — BACKUP overlay (Phase 1d, DG4): seed a known state, back it up (assert artifact), """custom-html — BACKUP overlay (Phase 1d, DG4): seed a known state, back it up (assert artifact),
then mutate so the RESTORE overlay (test_restore.py) can prove the backed-up state returns. Runs on then mutate so the RESTORE overlay (test_restore.py) can prove the backed-up state returns. Runs on
the shared deployment; the marker it leaves ("mutated") persists for the restore tier.""" the shared deployment; the marker it leaves ("mutated") persists for the restore tier.
Reads the marker via `exec_in_app` (the file in the volume), NOT http: backup/restore preserve the
VOLUME, and reading it directly is immune to the serving/container-routing race right after
backup-bot-two cycles the app container (HTTP briefly served empty). Serving is proven separately by
the install/upgrade tiers' assert_serving."""
import os import os
import sys import sys
@@ -11,15 +16,19 @@ from harness import generic, lifecycle # noqa: E402
MARKER_PATH = "/usr/share/nginx/html/ci-marker.txt" MARKER_PATH = "/usr/share/nginx/html/ci-marker.txt"
def _marker(domain: str) -> str:
return lifecycle.exec_in_app(domain, ["cat", MARKER_PATH]).strip()
def test_backup_captures_state(live_app, meta): def test_backup_captures_state(live_app, meta):
domain = live_app domain = live_app
# 1) establish a known original state, then back it up (reuse the generic op: backup + assert # 1) establish a known original state, then back it up (reuse the generic op: backup + assert a
# a snapshot artifact was produced) # snapshot artifact was produced)
lifecycle.exec_in_app(domain, ["sh", "-c", f"echo original > {MARKER_PATH}"]) lifecycle.exec_in_app(domain, ["sh", "-c", f"echo original > {MARKER_PATH}"])
assert lifecycle.http_fetch(domain, "/ci-marker.txt")[1].strip() == "original" assert _marker(domain) == "original"
snap = generic.do_backup(domain) snap = generic.do_backup(domain)
assert snap, "backup produced no snapshot artifact" assert snap, "backup produced no snapshot artifact"
# 2) mutate state so a successful restore is observable (diverge from the backup) # 2) mutate state so a successful restore is observable (diverge from the backup)
lifecycle.exec_in_app(domain, ["sh", "-c", f"echo mutated > {MARKER_PATH}"]) lifecycle.exec_in_app(domain, ["sh", "-c", f"echo mutated > {MARKER_PATH}"])
assert lifecycle.http_fetch(domain, "/ci-marker.txt")[1].strip() == "mutated" assert _marker(domain) == "mutated"
+8 -4
View File
@@ -3,8 +3,9 @@
Runs after the backup overlay (test_backup.py) on the SAME shared deployment, which left state Runs after the backup overlay (test_backup.py) on the SAME shared deployment, which left state
mutated to "mutated" after backing up "original". This restores the snapshot via the shared op mutated to "mutated" after backing up "original". This restores the snapshot via the shared op
helper (`generic.do_restore`, which also asserts the app is healthy + serving afterwards), then helper (`generic.do_restore`, which also asserts the app is healthy + serving afterwards), then
asserts the served data returned to the pre-mutation "original" — the app-specific data integrity the asserts the VOLUME data returned to the pre-mutation "original" — the app-specific data integrity the
generic restore cannot check. Assertion-only (no deploy/teardown).""" generic restore cannot check. Reads the marker via exec_in_app (volume-direct, robust to the
post-restore serving race). Assertion-only (no deploy/teardown)."""
import os import os
import sys import sys
@@ -12,10 +13,13 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import generic, lifecycle # noqa: E402 from harness import generic, lifecycle # noqa: E402
MARKER_PATH = "/usr/share/nginx/html/ci-marker.txt"
def test_restore_returns_state(live_app, meta): def test_restore_returns_state(live_app, meta):
domain = live_app domain = live_app
generic.do_restore(domain, meta) # restore + assert healthy/serving generic.do_restore(domain, meta) # restore + assert healthy/serving
restored = lifecycle.exec_in_app(domain, ["cat", MARKER_PATH]).strip()
assert ( assert (
lifecycle.http_fetch(domain, "/ci-marker.txt")[1].strip() == "original" restored == "original"
), "restore did not return the pre-mutation (backed-up) state" ), f"restore did not return the pre-mutation (backed-up) state: got {restored!r}"