Files
MeshCore/test/test_local_release_capacity_archive.py
T

283 lines
16 KiB
Python

#!/usr/bin/env python3
"""Exercise production archival and resume wiring without invoking PlatformIO."""
import fcntl
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
REPO = Path(os.environ.get("CAPACITY_ARCHIVE_REPO", str(Path(__file__).resolve().parents[1])))
sys.path.insert(0, str(REPO / "scripts"))
from firmware_build_recipe import package_occupied
MODULE = Path(os.environ.get("CAPACITY_ARCHIVE_MODULE", str(REPO / "scripts/build_local_release.py")))
SPEC = importlib.util.spec_from_file_location("capacity_archive_release", MODULE)
BUILD = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(BUILD)
VERSION = "v1.17.1.9-halo-keymind-cascade-dev"
SOURCE = "8d31b3932de614ac38d238f1a01010bc5f362fe8"
TARGET = "heltec_v4_repeater_lora_ota_no_external_sensors"
class CapacityArchiveTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="mesh-capacity-archive-test-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.work = self.root / ".releases" / (".build-" + VERSION + "-" + SOURCE[:8])
(self.work / "build-logs").mkdir(parents=True)
def attempt(self, target=TARGET, *, infix="-ota"):
stem = f"{target}{infix}-{VERSION}-{SOURCE[:8]}"
manifest = self.work / (stem + ".capabilities.json")
value = {"schema_version": 2, "target": target, "artifact_target": target,
"platformio_env": "heltec_v4_repeater", "platform": "ESP32_PLATFORM",
"build_profile": "standard", "verified": True,
"ota_update_verified": True,
"ota_update_evidence": "firmware fits both OTA application slots; otadata present",
"verification": [{"capability": "ota.update.lora", "present": True,
"source": "packaged application", "evidence": "OTA: status"}],
"build_recipe": {"schema_version": 1, "sha256": "a" * 64}}
manifest.write_text(json.dumps(value, indent=2) + "\n", encoding="ascii")
log = self.work / "build-logs" / (target + "-standard.log")
log.write_text(
f' -DFIRMWARE_VERSION="{VERSION}-{SOURCE[:8]}"\n'
f"Verified 1 capability marker(s); manifest: {manifest}\n"
"ESP32 app image is 1334184 bytes, exceeding portable LoRa-OTA "
"slot 0x10000..0x150000 (1310720 bytes) by 23464 bytes\n"
f"DEFERRED: {target} (standard) exceeds the portable OTA slot; "
"the expanded FULL pass is required.\n", encoding="ascii")
return manifest, log
def archive(self, **kwargs):
return BUILD.archive_capacity_rejected_attempts(
self.work, VERSION, SOURCE, project_root=self.root, **kwargs)
def mutate(self, manifest, **updates):
value = json.loads(manifest.read_text()); value.update(updates)
manifest.write_text(json.dumps(value) + "\n", encoding="ascii")
def assert_preserved(self, manifest, log):
raw_manifest, raw_log = manifest.read_bytes(), log.read_bytes()
before = sorted(path.name for path in self.work.parent.iterdir())
result = self.archive()
self.assertEqual(result["archived"], [])
self.assertEqual(manifest.read_bytes(), raw_manifest)
self.assertEqual(log.read_bytes(), raw_log)
self.assertEqual(sorted(path.name for path in self.work.parent.iterdir()), before)
self.assertEqual(len(result["preserved"]), 1)
def test_real_log_shape_preserves_raw_bytes_and_leaves_log_in_place(self):
manifest, log = self.attempt()
raw_manifest, raw_log = manifest.read_bytes(), log.read_bytes()
self.assertTrue(package_occupied(manifest.with_name(
manifest.name.removesuffix(".capabilities.json"))))
result = self.archive(); archive = Path(result["archive"])
self.assertEqual(len(result["archived"]), 1)
self.assertFalse(manifest.exists())
self.assertFalse(package_occupied(manifest.with_name(
manifest.name.removesuffix(".capabilities.json"))))
self.assertEqual(log.read_bytes(), raw_log)
self.assertEqual((archive / "manifests" / manifest.name).read_bytes(), raw_manifest)
self.assertEqual((archive / "logs" / log.name).read_bytes(), raw_log)
self.assertEqual(archive.parent, self.work.parent)
self.assertNotIn(archive, self.work.rglob("*"))
index = json.loads((archive / "index.json").read_text(encoding="ascii"))
self.assertEqual(index["state"], "archived")
self.assertEqual(index["source_commit"], SOURCE)
record = index["attempts"][0]
self.assertEqual(record["manifest_sha256"], hashlib.sha256(raw_manifest).hexdigest())
self.assertEqual(record["original_log_sha256"], hashlib.sha256(raw_log).hexdigest())
self.assertEqual(record["image_bytes"] - record["portable_limit_bytes"], 23464)
self.assertEqual(self.archive()["archived"], [])
def test_plain_c6_filename_and_multiple_attempts_receive_unique_archive(self):
self.attempt("Xiao_C6_repeater_", infix="")
self.attempt()
first = self.archive()
self.assertEqual(len(first["archived"]), 2)
self.attempt()
second = self.archive()
self.assertNotEqual(first["archive"], second["archive"])
self.assertTrue(Path(first["archive"]).is_dir())
def test_log_only_precollection_size_deferral_needs_no_archive(self):
manifest, log = self.attempt("Station_G2_repeater_lora_ota_no_external_sensors")
manifest.unlink(); raw_log = log.read_bytes()
result = self.archive()
self.assertEqual(result["archived"], [])
self.assertIsNone(result["archive"])
self.assertEqual(log.read_bytes(), raw_log)
def test_any_firmware_memory_license_unknown_sidecar_or_directory_is_preserved(self):
for suffix in (".bin", "-merged.bin", ".uf2", ".zip", ".hex", ".memory.json",
".font-license.txt", ".unexpected", ".directory", ".bin-link"):
with self.subTest(suffix=suffix):
manifest, log = self.attempt()
sibling = self.work / (manifest.name.removesuffix(".capabilities.json") + suffix)
if suffix == ".directory": sibling.mkdir()
elif suffix == ".bin-link": sibling.symlink_to(self.root / "missing.bin")
else: sibling.write_bytes(b"original evidence or qualified firmware")
self.assert_preserved(manifest, log)
if sibling.is_dir(): sibling.rmdir()
else: sibling.unlink()
def test_unknown_failures_and_incomplete_or_nonstandard_contracts_are_preserved(self):
faults = [{"verified": False}, {"ota_update_verified": False},
{"platform": "NRF52_PLATFORM"}, {"build_profile": "full"},
{"artifact_target": "different"}, {"schema_version": 1},
{"schema_version": 2.0},
{"verification": []}, {"verification": [{"present": False}]},
{"ota_update_evidence": "firmware does not fit both OTA application slots"},
{"build_recipe": None}, {"build_recipe": {"schema_version": 1, "sha256": "x" * 64}},
{"build_recipe": {"schema_version": True, "sha256": "a" * 64}},
{"build_recipe": {"schema_version": 1, "sha256": "a" * 64, "unknown": 1}}]
for fault in faults:
with self.subTest(fault=fault):
manifest, log = self.attempt(); self.mutate(manifest, **fault)
self.assert_preserved(manifest, log)
manifest, log = self.attempt(); manifest.write_bytes(b"{malformed")
self.assert_preserved(manifest, log)
def test_nested_firmware_is_not_hidden_by_flat_manifest_scan(self):
manifest, log = self.attempt()
nested = self.work / "nested"; nested.mkdir()
(nested / (manifest.name.removesuffix(".capabilities.json") + ".bin")).write_bytes(b"firmware")
self.assert_preserved(manifest, log)
def test_numeric_source_identity_and_final_failure_consistency_are_required(self):
replacements = [("23464 bytes", "23465 bytes"), ("1310720 bytes", "1310721 bytes"),
("1334184 bytes", "1310719 bytes"),
("portable LoRa-OTA slot", "ota_0"),
(SOURCE[:8], "deadbeef"), ("(standard)", "(full)"),
("Verified 1 capability", "Failed 1 capability"),
("; the expanded FULL pass is required.", "; unknown failure.")]
for old, new in replacements:
with self.subTest(old=old):
manifest, log = self.attempt()
log.write_text(log.read_text().replace(old, new), encoding="ascii")
self.assert_preserved(manifest, log)
manifest, log = self.attempt()
with log.open("a") as output: output.write("FAILED: unrelated compiler error\n")
self.assert_preserved(manifest, log)
def test_wrong_source_version_or_target_filename_is_not_consumed(self):
for token, replacement in ((SOURCE[:8], "deadbeef"), (VERSION, VERSION.replace("1.9", "1.8")),
(TARGET, "different_target")):
with self.subTest(token=token):
manifest, log = self.attempt()
renamed = manifest.with_name(manifest.name.replace(token, replacement))
manifest.rename(renamed)
self.assert_preserved(renamed, log); renamed.unlink()
def test_symlink_manifest_log_log_directory_or_work_is_never_consumed(self):
manifest, log = self.attempt()
original = self.root / "original.capabilities.json"
manifest.rename(original); manifest.symlink_to(original)
self.assert_preserved(manifest, log)
manifest.unlink(); original.rename(manifest)
real_log = self.root / "original.log"; log.rename(real_log); log.symlink_to(real_log)
self.assert_preserved(manifest, log)
log.unlink(); real_log.rename(log)
real_logs = self.root / "original-logs"
log.parent.rename(real_logs); log.parent.symlink_to(real_logs, target_is_directory=True)
self.assert_preserved(manifest, log)
alias = self.root / "work-alias"; alias.symlink_to(self.work, target_is_directory=True)
with self.assertRaisesRegex(ValueError, "unsafe work/archive"):
BUILD.archive_capacity_rejected_attempts(alias, VERSION, SOURCE, project_root=self.root)
def test_archive_overlap_and_held_build_lock_leave_every_package_byte_untouched(self):
manifest, log = self.attempt(); raw_manifest, raw_log = manifest.read_bytes(), log.read_bytes()
for parent in (self.work, self.work / "nested"):
with self.subTest(parent=parent), self.assertRaisesRegex(ValueError, "unsafe work/archive"):
self.archive(archive_parent=parent)
lock_path = self.root / ".pio" / "build-sh.lock"; lock_path.parent.mkdir()
lock_path.write_bytes(b"original active build lock owner\n")
with lock_path.open("a+") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with self.assertRaisesRegex(RuntimeError, "active build owns"):
self.archive()
self.assertEqual(lock_path.read_bytes(), b"original active build lock owner\n")
self.assertEqual(manifest.read_bytes(), raw_manifest)
self.assertEqual(log.read_bytes(), raw_log)
self.assertEqual(list(self.work.parent.glob(".capacity-rejected-*")), [])
def test_complete_scan_recheck_blocks_changes_before_any_move(self):
first, _ = self.attempt(); second, _ = self.attempt("second_repeater")
original = BUILD.capacity_rejected_attempt; calls = 0
def racing(*args):
nonlocal calls
calls += 1
record = original(*args)
if calls == 2: self.mutate(first, verified=False)
return record
with mock.patch.object(BUILD, "capacity_rejected_attempt", side_effect=racing):
with self.assertRaises(ValueError): self.archive()
self.assertTrue(first.exists()); self.assertTrue(second.exists())
self.assertEqual(list(self.work.parent.glob(".capacity-rejected-*")), [])
def test_late_failure_keeps_moved_raw_evidence_and_marks_partial_archive(self):
first, _ = self.attempt(); second, _ = self.attempt("second_repeater")
raw_first, raw_second = first.read_bytes(), second.read_bytes()
original = BUILD.capacity_rejected_attempt; calls = 0
def racing(*args):
nonlocal calls
calls += 1
if calls == 6: raise ValueError("injected late race")
return original(*args)
with mock.patch.object(BUILD, "capacity_rejected_attempt", side_effect=racing):
with self.assertRaisesRegex(ValueError, "injected late race"): self.archive()
archive, = self.work.parent.glob(".capacity-rejected-*")
self.assertFalse(first.exists()); self.assertTrue(second.exists())
self.assertEqual((archive / "manifests" / first.name).read_bytes(), raw_first)
self.assertEqual(second.read_bytes(), raw_second)
index = json.loads((archive / "index.json").read_text())
self.assertEqual(index["state"], "interrupted")
self.assertEqual(index["moved_manifests"], [first.name])
def test_resume_main_archives_before_matrix_and_nonresume_dryrun_do_not_archive(self):
(self.work / "build-logs").rmdir()
for arguments, expected in ((["--resume"], True), ([], False), (["--resume", "--dry-run"], False)):
with self.subTest(arguments=arguments):
order = []
def archive(*args):
order.append("archive")
return {"archived": [], "preserved": [], "archive": None}
def matrix(*args):
order.append("matrix")
raise RuntimeError("stop before any external build")
with mock.patch.object(BUILD, "ROOT", self.root), \
mock.patch.object(BUILD, "git", side_effect=[SOURCE, ""]), \
mock.patch.object(BUILD, "archive_capacity_rejected_attempts", side_effect=archive), \
mock.patch.object(BUILD, "run_logged", side_effect=matrix), \
mock.patch.object(BUILD, "resolve_pio_build_dir", return_value=self.root / ".pio/build"), \
mock.patch.object(sys, "argv", ["build_local_release.py"] + arguments), \
mock.patch("sys.stdout", new=io.StringIO()), \
mock.patch("sys.stderr", new=io.StringIO()):
if "--dry-run" in arguments: BUILD.main()
else:
with self.assertRaisesRegex(RuntimeError, "stop before"): BUILD.main()
self.assertEqual(order, ["archive", "matrix"] if expected else
([] if "--dry-run" in arguments else ["matrix"]))
def test_explicit_release_ci_step_runs_capacity_archive_regressions(self):
workflow = Path(os.environ.get("CAPACITY_ARCHIVE_WORKFLOW", str(
REPO / ".github/workflows/run-unit-tests.yml"))).read_text(encoding="utf-8")
command = "python3 -B test/test_local_release_capacity_archive.py"
self.assertEqual(workflow.count(command), 1)
# This workflow lists host suites explicitly, rather than discovering
# test files. Keep the new resume boundary in the existing release step.
self.assertIn("python3 -B test/test_release_publication.py\n"
" python3 -B test/test_local_release_full_selection.py\n"
" " + command, workflow)
if __name__ == "__main__":
unittest.main()