v0.144.0 code: R8 measures the real download (R-865); an OS pass's report survives a killed agent (R-868); the debug pass runs from the saved block when the hub is away (R-866)
gates / gates (push) Successful in 19s
gates / gates (push) Successful in 19s
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:
+48
-1
@@ -200,6 +200,32 @@ class Runner:
|
||||
if rc != 0:
|
||||
raise Refused("R7", f"could not write {path} in the guest")
|
||||
|
||||
def save_report(self, plan_path, report):
|
||||
"""R-868: keep an apply pass's report on disk until the agent has sent it (the agent deletes it). Written
|
||||
INTO the agent's own plan dir as root, so: the dir is opened with O_NOFOLLOW and must be a real directory
|
||||
owned by the agent (a symlink swapped in for it is refused); the file is created O_EXCL|O_NOFOLLOW after
|
||||
removing an old one, then handed to the agent (0600). Any failure only loses the copy — never the run."""
|
||||
base = os.path.basename(plan_path)
|
||||
name = "report-" + base[len("plan-"):]
|
||||
dfd = os.open(PLAN_DIR, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
st = os.fstat(dfd)
|
||||
if not stat.S_ISDIR(st.st_mode) or st.st_uid != self.agent_uid():
|
||||
raise OSError(f"{PLAN_DIR} is not the agent's own directory")
|
||||
try:
|
||||
os.unlink(name, dir_fd=dfd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=dfd)
|
||||
try:
|
||||
os.write(fd, (json.dumps(report, sort_keys=True) + "\n").encode())
|
||||
os.fchown(fd, self.agent_uid(), -1)
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
os.close(dfd)
|
||||
return os.path.join(PLAN_DIR, name)
|
||||
|
||||
def now(self):
|
||||
return time.time()
|
||||
|
||||
@@ -808,6 +834,16 @@ class Apply:
|
||||
plan = self.load_plan()
|
||||
self.mode, self.layer, self.vmid, self.select = self.check_plan(plan)
|
||||
self.report.update(mode=self.mode, layer=self.layer, release_id=plan.get("release_id"), vmid=self.vmid)
|
||||
# R-868 (v0.144.0): the agent's run id, trigger and ring travel in the report, so a report the agent never
|
||||
# received (it was killed mid-pass) can be sent later from the saved copy. Plain ids only; anything else
|
||||
# is dropped, never refused (the plan's other checks decide).
|
||||
rid, trig, ring = plan.get("run_id"), plan.get("trigger"), plan.get("ring")
|
||||
if isinstance(rid, str) and re.match(r"^[A-Za-z0-9._-]{1,80}$", rid):
|
||||
self.report["run_id"] = rid
|
||||
if isinstance(trig, str) and re.match(r"^[a-z0-9_-]{1,20}$", trig):
|
||||
self.report["trigger"] = trig
|
||||
if ring in (0, 1) and not isinstance(ring, bool):
|
||||
self.report["ring"] = ring
|
||||
if self.mode == "facts":
|
||||
return self.facts()
|
||||
if self.mode == "bundle":
|
||||
@@ -1027,7 +1063,11 @@ class Apply:
|
||||
self.remove_snapshot_sources()
|
||||
|
||||
def download_bytes(self, args):
|
||||
rc, out, _ = self.x(APT_ENV + ["apt-get", "-s", "-o", "Debug::NoLocking=1", "--print-uris", "-q"] + args)
|
||||
# R-865 (v0.144.0): NO `-s`. With `-s` apt prints the simulation ("Inst …") and no URI list, so this summed
|
||||
# 0 B and R8 only ever applied its 500 MB floor (measured 2026-10-04: 0 URIs with -s, 3 URIs without).
|
||||
# `--print-uris` alone downloads nothing — measured on 9202 2026-10-05: the archive cache and the versions
|
||||
# unchanged. Pinned by test_R8_counts_the_real_download / test_download_bytes_never_simulates.
|
||||
rc, out, _ = self.x(APT_ENV + ["apt-get", "-o", "Debug::NoLocking=1", "--print-uris", "-q"] + args)
|
||||
total = 0
|
||||
for l in out.splitlines():
|
||||
m = re.match(r"^'[^']+' \S+ ([0-9]+) ", l)
|
||||
@@ -1406,6 +1446,13 @@ def main(argv, runner=None, environ=None):
|
||||
a.report["failed"] = {"rc": 124, "timeout": str(e.cmd)[:200]}
|
||||
rc = 3
|
||||
a.report["pass_seconds"] = round(time.time() - t0, 1)
|
||||
if a.report.get("mode") == "apply":
|
||||
# R-868: BEFORE the stdout line — a killed agent never reads stdout, and this copy is how its report still
|
||||
# reaches the hub (the agent sends an unsent copy when it starts, and deletes it once sent).
|
||||
try:
|
||||
r.save_report(argv[2], a.report)
|
||||
except Exception as e:
|
||||
r.log(f"os-apply: the report copy could not be saved (the run is unaffected): {e}")
|
||||
print("OSAPPLY-REPORT " + json.dumps(a.report, sort_keys=True))
|
||||
return rc
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ class Fake:
|
||||
self.sig_rc = 0
|
||||
self.nonces = {}
|
||||
self.clock = 1791115200.0 # 2026-10-04T12:00:00Z
|
||||
self.saved_reports = [] # R-868: (plan path, report) the wrapper kept on disk
|
||||
self.files[osapply.TRUST_FILE] = json.dumps({"host_id": "demo-hp-bb76ea", "ring0_slow_lane": False})
|
||||
self.stats[osapply.TRUST_FILE] = St(mode=statmod.S_IFREG | 0o644, uid=0)
|
||||
self.files[osapply.TRUST_SIGNERS] = 'felhom-op-1 namespaces="felhom-op-v1" ssh-ed25519 AAAA\n'
|
||||
@@ -113,6 +114,10 @@ class Fake:
|
||||
def log(self, line):
|
||||
self.logs.append(line)
|
||||
|
||||
def save_report(self, plan_path, report):
|
||||
self.saved_reports.append((plan_path, json.loads(json.dumps(report))))
|
||||
return plan_path.replace("/plan-", "/report-")
|
||||
|
||||
def host(self, argv, timeout=600, stdin=None):
|
||||
self.calls.append(("host", argv))
|
||||
if argv[0] == "/usr/sbin/pct" and argv[1] == "status":
|
||||
@@ -168,8 +173,8 @@ class Fake:
|
||||
if "-f" in a:
|
||||
self.dpkg_audit = ""
|
||||
return 0, "Setting up x (1) ...\n" if getattr(self, "repaired", False) else "", ""
|
||||
if "-s" in a:
|
||||
return self.sim(a)
|
||||
if "--print-uris" in a or "-s" in a:
|
||||
return self.sim(a) # --print-uris prints and installs nothing, with or without -s (9202, 2026-10-05)
|
||||
if "install" in a:
|
||||
if self.install_rc:
|
||||
return self.install_rc, "", "E: boom"
|
||||
@@ -232,7 +237,14 @@ class Fake:
|
||||
|
||||
def sim(self, a):
|
||||
if "--print-uris" in a:
|
||||
return 0, "'http://x/libc6.deb' libc6.deb 4000000 SHA256:x\n", ""
|
||||
if "-s" in a:
|
||||
# real apt (measured 9202 2026-10-05): with -s it prints the SIMULATION, no URI list
|
||||
return 0, "Inst libc6 [2.41-12+deb13u4] (2.41-12+deb13u4 Debian:13.7/stable [amd64])\n", ""
|
||||
# real apt's line shape, verbatim from 9202 2026-10-05 (audits/night-fixes-2026-10-05/partC/)
|
||||
return 0, ("Need to get 4347 kB of archives.\n"
|
||||
"'http://deb.debian.org/debian/pool/main/b/bash/bash_5.2.37-2%2bb10_amd64.deb' bash_5.2.37-2+b10_amd64.deb 1500792 MD5Sum:27b11721fea83d73b96e0f7023863771\n"
|
||||
"'http://deb.debian.org/debian/pool/main/g/glibc/libc6_2.41-12%2bdeb13u4_amd64.deb' libc6_2.41-12+deb13u4_amd64.deb 2846580 MD5Sum:5559581916477ef1f57ea9f82cecf22e\n"
|
||||
+ getattr(self, "extra_uris", "")), ""
|
||||
if "dist-upgrade" in a:
|
||||
if getattr(self, "pending_sim", None) is not None and not getattr(self, "_pending_used", False):
|
||||
self._pending_used = True
|
||||
@@ -273,7 +285,7 @@ class Happy(unittest.TestCase):
|
||||
self.assertEqual(rep["pending"][0]["name"], "bash")
|
||||
self.assertTrue(any(l.startswith("os-apply: REPAIR ") for l in f.logs), "the repair line must always print")
|
||||
self.assertTrue(any(l.startswith("os-apply: DONE rc=0") for l in f.logs))
|
||||
inst = [c for c in f.calls if c[0] == "guest" and "install" in c[2] and "-s" not in c[2] and "-f" not in c[2]]
|
||||
inst = [c for c in f.calls if c[0] == "guest" and "install" in c[2] and "-s" not in c[2] and "-f" not in c[2] and "--print-uris" not in c[2]]
|
||||
self.assertTrue(inst and "Dpkg::Options::=--force-confold" in inst[0][2], "must keep existing config files")
|
||||
|
||||
def test_already_current_is_a_no_op(self):
|
||||
@@ -343,7 +355,7 @@ class Refusals(unittest.TestCase):
|
||||
self.assertEqual(rc, 2, rep)
|
||||
self.assertEqual(rep["refused"]["code"], code, rep)
|
||||
self.assertTrue(any(l.startswith(f"os-apply: REFUSED: {code} ") for l in f.logs), f.logs)
|
||||
inst = [c for c in f.calls if c[0] == "guest" and "install" in c[2] and "-s" not in c[2] and "-f" not in c[2]]
|
||||
inst = [c for c in f.calls if c[0] == "guest" and "install" in c[2] and "-s" not in c[2] and "-f" not in c[2] and "--print-uris" not in c[2]]
|
||||
self.assertEqual(inst, [], "a refusal must install nothing")
|
||||
return rep
|
||||
|
||||
@@ -454,6 +466,24 @@ class Refusals(unittest.TestCase):
|
||||
f.free = 100 * 1024 * 1024
|
||||
self.refused(f, "R8")
|
||||
|
||||
# R-865: the download is the real one. 2 GB of URIs, 5 GB free: 5 GB < 3 x 2 GB -> R8, with the size in the line.
|
||||
# COMPANION RED-PROOF: put "-s" back into download_bytes -> 0 B -> no refusal -> this test fails.
|
||||
def test_R8_counts_the_real_download(self):
|
||||
f = Fake()
|
||||
f.free = 5 * 1024 ** 3
|
||||
f.extra_uris = "'http://deb.debian.org/debian/pool/main/b/big/big_1_amd64.deb' big_1_amd64.deb 2000000000 MD5Sum:x\n"
|
||||
rep = self.refused(f, "R8")
|
||||
self.assertIn("download 2004347372 B", str(rep))
|
||||
|
||||
def test_download_bytes_never_simulates(self):
|
||||
f = Fake()
|
||||
rc, rep = run(f)
|
||||
self.assertEqual(rc, 0, rep)
|
||||
calls = [c[2] for c in f.calls if c[0] == "guest" and "--print-uris" in c[2]]
|
||||
self.assertTrue(calls, "download_bytes was never called")
|
||||
for c in calls:
|
||||
self.assertNotIn("-s", c, f"--print-uris with -s prints no URIs: {c}")
|
||||
|
||||
def test_R9_guest_locked_by_a_backup(self):
|
||||
f = Fake()
|
||||
f.files["/etc/pve/lxc/9201.conf"] = CONF_OK + "lock: backup\n"
|
||||
@@ -962,3 +992,88 @@ class RealSignatureCheck(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class UnsentReport(unittest.TestCase):
|
||||
"""R-868 (v0.144.0): an apply pass keeps its report on disk until the agent has sent it.
|
||||
COMPANION RED-PROOF: drop the r.save_report call in main() -> test_apply_keeps_a_copy fails."""
|
||||
|
||||
def test_apply_keeps_a_copy_with_the_agents_ids(self):
|
||||
f = Fake()
|
||||
f.plan.update(run_id="20261005T0257-ab12", trigger="debug", ring=0)
|
||||
rc, rep = run(f)
|
||||
self.assertEqual(rc, 0, rep)
|
||||
self.assertEqual(len(f.saved_reports), 1, "an apply pass must keep its report on disk")
|
||||
path, saved = f.saved_reports[0]
|
||||
self.assertEqual(path, PLAN)
|
||||
self.assertEqual(saved, rep, "the copy is the report the agent would have read")
|
||||
self.assertEqual((saved["run_id"], saved["trigger"], saved["ring"]), ("20261005T0257-ab12", "debug", 0))
|
||||
|
||||
def test_a_refusal_is_kept_too(self):
|
||||
f = Fake()
|
||||
f.free = 1
|
||||
rc, rep = run(f)
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertEqual(f.saved_reports[0][1]["refused"]["code"], "R8")
|
||||
|
||||
def test_other_modes_keep_nothing(self):
|
||||
f = Fake()
|
||||
f.plan["mode"] = "health"
|
||||
run(f)
|
||||
self.assertEqual(f.saved_reports, [])
|
||||
|
||||
def test_odd_ids_are_dropped_not_trusted(self):
|
||||
f = Fake()
|
||||
f.plan.update(run_id="../../etc/x", trigger="Night; rm", ring=True)
|
||||
rc, rep = run(f)
|
||||
self.assertEqual(rc, 0, rep)
|
||||
for k in ("run_id", "trigger", "ring"):
|
||||
self.assertNotIn(k, rep)
|
||||
|
||||
|
||||
class SaveReportOnDisk(unittest.TestCase):
|
||||
"""The real Runner.save_report on a temp dir: root writes into the AGENT's directory, so a symlink must never
|
||||
be followed — neither for the directory nor for the file name."""
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self.dir = os.path.join(self.tmp, "os")
|
||||
os.mkdir(self.dir)
|
||||
self.prev = osapply.PLAN_DIR
|
||||
osapply.PLAN_DIR = self.dir
|
||||
self.r = osapply.Runner()
|
||||
self.r.agent_uid = lambda: os.getuid()
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
osapply.PLAN_DIR = self.prev
|
||||
shutil.rmtree(self.tmp)
|
||||
|
||||
def test_writes_0600_next_to_the_plan(self):
|
||||
p = self.r.save_report(os.path.join(self.dir, "plan-r1-guest-apply.json"), {"mode": "apply"})
|
||||
self.assertEqual(p, os.path.join(self.dir, "report-r1-guest-apply.json"))
|
||||
st = os.stat(p)
|
||||
self.assertEqual(statmod.S_IMODE(st.st_mode), 0o600)
|
||||
with open(p) as fh:
|
||||
self.assertEqual(json.load(fh), {"mode": "apply"})
|
||||
|
||||
def test_a_symlink_at_the_name_is_replaced_not_followed(self):
|
||||
victim = os.path.join(self.tmp, "victim")
|
||||
with open(victim, "w") as fh:
|
||||
fh.write("untouched")
|
||||
os.symlink(victim, os.path.join(self.dir, "report-r2-guest-apply.json"))
|
||||
self.r.save_report(os.path.join(self.dir, "plan-r2-guest-apply.json"), {"mode": "apply"})
|
||||
with open(victim) as fh:
|
||||
self.assertEqual(fh.read(), "untouched")
|
||||
self.assertFalse(os.path.islink(os.path.join(self.dir, "report-r2-guest-apply.json")))
|
||||
|
||||
def test_a_symlinked_directory_is_refused(self):
|
||||
real = os.path.join(self.tmp, "elsewhere")
|
||||
os.mkdir(real)
|
||||
link = os.path.join(self.tmp, "linked")
|
||||
os.symlink(real, link)
|
||||
osapply.PLAN_DIR = link
|
||||
with self.assertRaises(OSError):
|
||||
self.r.save_report(os.path.join(link, "plan-r3-guest-apply.json"), {"mode": "apply"})
|
||||
self.assertEqual(os.listdir(real), [])
|
||||
|
||||
Reference in New Issue
Block a user