steps/ per ladder step: gate rule 4, writer, backfill (8); R-653, R-656; decoy suite reads live pins (R-663)
gates / gates (push) Successful in 2s
gates / gates (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
+55
-1
@@ -465,12 +465,50 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
|
||||
tight = [n for n, p in per.items() if _tight_pct(p) is not None and _tight_pct(p) > MEMORY_TIGHT]
|
||||
rec = {"soak_s": round(time.time() - t0, 1), "requested_s": seconds, "requests": hits["n"],
|
||||
"codes": hits["codes"], "first_kill": first_bad, "containers": per,
|
||||
"unmeasured": [n for n, p in per.items() if not p["measured"]]}
|
||||
"unmeasured": [n for n, p in per.items() if not p["measured"]],
|
||||
"load": load_verdict(hits["n"], hits["codes"])}
|
||||
marks = ["memory_tight"] if tight and not killed else []
|
||||
say(f"memory watch: killed={killed} tight={tight} requests={hits['n']} codes={hits['codes']}")
|
||||
return rec, killed, marks
|
||||
|
||||
|
||||
def load_verdict(requests: int, codes: dict) -> str:
|
||||
"""R-653: did the watch's load REACH the app? `reached` when at least half its requests got any HTTP
|
||||
answer (a 401 or a 30x is the app answering); `inconclusive` otherwise. Measured 2026-09-23 night:
|
||||
ghost's and nextcloud's first ten-minute watches sent 11 797 and 9 427 requests and EVERY one was
|
||||
`err` — memory measured, app idle — and the harness wrote `proven` over it."""
|
||||
if requests <= 0:
|
||||
return "inconclusive"
|
||||
answered = sum(n for c, n in codes.items() if c != "err")
|
||||
return "reached" if answered * 2 >= requests else "inconclusive"
|
||||
|
||||
|
||||
def clear_scratch_folders(compose_text: str, env: dict, say) -> list:
|
||||
"""R-656: before FROM, remove the app's OWN scratch drive folders — every `${HDD_PATH}/…`,
|
||||
`${USERDATA_PATH}/…` and `${IMPORT_PATH}/…` bind the compose names — so a re-run of the same app does
|
||||
not start on the last run's files (nextcloud's second run never installed, 2026-09-23 night: `occ
|
||||
status: installed: false` over the first run's config/). `compose down -v` removes named volumes, not
|
||||
bind-mounted host folders. NEVER a bare root, never a path outside the scratch roots. Returns what was
|
||||
removed, and says so either way."""
|
||||
roots = {k: env.get(k) for k in ("HDD_PATH", "USERDATA_PATH", "IMPORT_PATH") if env.get(k)}
|
||||
removed = []
|
||||
for var, rel in re.findall(r"\$\{(HDD_PATH|USERDATA_PATH|IMPORT_PATH)\}(/[^:\s\"']*)", compose_text):
|
||||
root = roots.get(var)
|
||||
if not root:
|
||||
continue
|
||||
rel = rel.strip("/")
|
||||
target = os.path.realpath(os.path.join(root, rel))
|
||||
safe_root = os.path.realpath(root)
|
||||
if not rel or target == safe_root or not target.startswith(safe_root + os.sep):
|
||||
say(f"scratch folder NOT cleared (outside or equal to {var}={root}): {rel!r}")
|
||||
continue
|
||||
if os.path.exists(target):
|
||||
shutil.rmtree(target, ignore_errors=False)
|
||||
removed.append(target)
|
||||
say(f"scratch drive folders cleared before FROM (R-656): {removed or 'none existed'}")
|
||||
return removed
|
||||
|
||||
|
||||
MIGRATION_RE = re.compile(
|
||||
r"migrat|upgrad|schema|alter table|CREATE TABLE|InnoDB: Upgrad|mysql_upgrade|"
|
||||
r"mariadb-upgrade|Running .* migration|Applying|db:migrate",
|
||||
@@ -558,6 +596,7 @@ def run_edge(edge_id: str) -> dict:
|
||||
|
||||
try:
|
||||
# --- 1. FROM ---
|
||||
rec["scratch_cleared"] = clear_scratch_folders(compose_text, env, say)
|
||||
say(f"{edge_id}: deploying {app} at FROM {e['frm']}")
|
||||
render(app, e["frm"], workdir, env, e.get("template"))
|
||||
up = compose(workdir, project, "up", "-d")
|
||||
@@ -652,6 +691,11 @@ def run_edge(edge_id: str) -> dict:
|
||||
if killed:
|
||||
rec["verdict"] = "failed"
|
||||
say("VERDICT -> failed: the new version was OOM-killed or restarted under light load")
|
||||
elif mem.get("load") != "reached":
|
||||
rec["verdict"] = "inconclusive"
|
||||
rec["abort_detail"] = (f"memory watch: fewer than half of its {mem.get('requests')} requests reached "
|
||||
f"the app ({mem.get('codes')}) — memory measured on an idle app (R-653)")
|
||||
say("VERDICT -> inconclusive: " + rec["abort_detail"])
|
||||
|
||||
# --- 6. the ABORT ---
|
||||
say(f"{edge_id}: ABORT — putting the FROM images back")
|
||||
@@ -778,6 +822,16 @@ def write_ladder(argv) -> int:
|
||||
if probs:
|
||||
print(f"REFUSED {app}: the entry would not be well-formed: {probs}")
|
||||
return 1
|
||||
# `09` §6.4 part 5: the step being SUPERSEDED keeps its own definition. When the ladder's head is the
|
||||
# compose as it stands, that compose — the head's images with every fix that flowed since — becomes
|
||||
# steps/<step_key(head.to)>.yml, the file a box one step behind will pin (check-test-record.py rule 4).
|
||||
prior, _, _ = ladder.parse(fy_p.read_text())
|
||||
if prior and prior[-1].get("to") == cur:
|
||||
sp = tdir / ladder.step_file(cur)
|
||||
if not sp.exists():
|
||||
sp.parent.mkdir(parents=True, exist_ok=True)
|
||||
sp.write_text(comp)
|
||||
print(f"STEP {app}: the superseded step {cur} keeps its definition at {ladder.step_file(cur)}")
|
||||
# move the compose, per service, on that service's own image: line
|
||||
out, svc = [], None
|
||||
for line in comp.splitlines():
|
||||
|
||||
Reference in New Issue
Block a user