#!/usr/bin/env python3 """Exercise board Makefiles with fake Cargo, image creation, and DFU tools.""" import json import os from pathlib import Path import shutil import subprocess import tempfile import unittest FIRMWARE = Path(__file__).resolve().parents[1] BOARDS = ("geprc-f722-v2", "kakute-f7-mini-v3") TARGET = "thumbv7em-none-eabihf" FAKE_TOOL = """#!/usr/bin/env python3 import json import os from pathlib import Path import sys tool = Path(sys.argv[0]).name args = sys.argv[1:] with open(os.environ["WORKFLOW_LOG"], "a") as log: log.write(json.dumps({"tool": tool, "args": args}) + "\\n") if os.environ.get("FAIL_TOOL") == tool: sys.exit(1) if tool == "cargo": if args[0] != "build" or not all( flag in args for flag in ("--release", "--locked", "--offline") ): raise ValueError("expected locked offline release build") directory = Path(args[args.index("--target-dir") + 1]) directory /= args[args.index("--target") + 1] directory /= "release" directory.mkdir(parents=True, exist_ok=True) for index, arg in enumerate(args): if arg == "--bin": binary = args[index + 1] (directory / binary).write_text("fresh " + binary) elif tool == "create-dfu-image.sh": if len(args) != 1: raise ValueError("expected one ELF") elf = Path(args[0]) if elf.read_text() != "fresh " + elf.name: raise ValueError("stale ELF") Path(str(elf) + ".bin").write_text(elf.read_text()) elif tool == "dfu-util": image = Path(args[args.index("--download") + 1]) if image.read_text() != "fresh " + image.stem: raise ValueError("stale image") else: raise AssertionError(tool) """ class BoardWorkflowTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory(prefix="kaptan-workflow-") self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) self.boards = self.root / "firmware" / "boards" self.boards.mkdir(parents=True) for relative in ("Makefile", "board.mk", *(f"{b}/Makefile" for b in BOARDS)): destination = self.boards / relative destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(FIRMWARE / "boards" / relative, destination) for relative in ( "bin/cargo", "bin/dfu-util", "firmware/scripts/create-dfu-image.sh", ): tool = self.root / relative tool.parent.mkdir(parents=True, exist_ok=True) tool.write_text(FAKE_TOOL) tool.chmod(0o755) self.log = self.root / "events.jsonl" self.environment = dict(os.environ) for name in ("CARGO_TARGET_DIR", "MAKEFLAGS", "MFLAGS", "MAKEOVERRIDES"): self.environment.pop(name, None) self.environment.update( PATH=f"{self.root / 'bin'}{os.pathsep}{os.environ['PATH']}", WORKFLOW_LOG=str(self.log), ) # A wrong packaging path must fail even when old build outputs exist. self.seed_stale(self.root / "firmware" / "target") def seed_stale(self, target_dir): release = target_dir / TARGET / "release" release.mkdir(parents=True, exist_ok=True) for board in BOARDS: for suffix in ("", ".bin"): (release / f"kaptan-{board}{suffix}").write_text("stale") def run_make(self, directory, goal, target_dir=None, fail="", variables=()): self.log.unlink(missing_ok=True) environment = dict(self.environment, FAIL_TOOL=fail) if target_dir is not None: environment["CARGO_TARGET_DIR"] = target_dir result = subprocess.run( [ "make", "--no-print-directory", "-j2", "-C", str(directory), goal, *variables, ], env=environment, capture_output=True, text=True, timeout=30, ) events = ( [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else [] ) return result, events def test_selected_board_artifact_directory_and_identity(self): for board in BOARDS: directory = self.boards / board for target_dir in ( None, "local artifacts", str(self.root / "external artifacts"), ): with self.subTest(board=board, target_dir=target_dir): result, events = self.run_make( directory, "flash", target_dir, variables=( "BOARD=wrong", "PACKAGE=wrong", "EMBEDDED_TARGET=wrong", ), ) self.assertEqual( result.returncode, 0, result.stdout + result.stderr ) self.assertEqual( [event["tool"] for event in events], ["cargo", "create-dfu-image.sh", "dfu-util"], ) args = events[0]["args"] self.assertEqual(args[args.index("-p") + 1], f"kaptan-{board}") self.assertEqual(args[args.index("--bin") + 1], f"kaptan-{board}") self.assertEqual(args[args.index("--target") + 1], TARGET) expected = directory / (target_dir or "../../target") self.assertEqual( (directory / args[args.index("--target-dir") + 1]).resolve(), expected.resolve(), ) elf = expected / TARGET / "release" / f"kaptan-{board}" self.assertEqual( (directory / events[1]["args"][0]).resolve(), elf.resolve() ) self.assertEqual( (directory / events[2]["args"][-1]).resolve(), Path(str(elf) + ".bin").resolve(), ) def test_all_boards_use_one_artifact_directory(self): for target_dir in ( None, "local artifacts", str(self.root / "external artifacts"), ): with self.subTest(target_dir=target_dir): result, events = self.run_make(self.boards, "build", target_dir) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual( [event["tool"] for event in events], ["cargo", "create-dfu-image.sh", "create-dfu-image.sh"], ) expected = self.boards / (target_dir or "../target") args = events[0]["args"] self.assertEqual( (self.boards / args[args.index("--target-dir") + 1]).resolve(), expected.resolve(), ) self.assertEqual( [ (self.boards / event["args"][0]).resolve() for event in events[1:] ], [ (expected / TARGET / "release" / f"kaptan-{b}").resolve() for b in BOARDS ], ) def test_failures_stop_packaging_and_flashing_even_with_stale_outputs(self): target_dir = self.root / "external artifacts" for directory, goal in ( (self.boards, "build"), *((self.boards / board, "flash") for board in BOARDS), ): for failed_tool in ("cargo", "create-dfu-image.sh"): with self.subTest(directory=directory, failed_tool=failed_tool): self.seed_stale(target_dir) result, events = self.run_make( directory, goal, str(target_dir), failed_tool ) self.assertNotEqual(result.returncode, 0) self.assertEqual( [event["tool"] for event in events], ["cargo"] if failed_tool == "cargo" else ["cargo", failed_tool], ) for board in BOARDS: image = target_dir / TARGET / "release" / f"kaptan-{board}.bin" self.assertEqual(image.read_text(), "stale") class PackagingValidationTests(unittest.TestCase): def test_real_packager_validates_sram_and_bounds_before_objcopy(self): fake = """#!/usr/bin/env python3 import os from pathlib import Path import sys name = Path(sys.argv[0]).name if name == "rustc": print("host: test-host" if sys.argv[1] == "-vV" else os.environ["FAKE_SYSROOT"]) elif name == "llvm-readobj": print(os.environ["ELF_METADATA"]) elif name == "llvm-objdump": print("20000000: bx lr") elif name == "llvm-objcopy": Path(os.environ["OBJCOPY_LOG"]).touch() Path(sys.argv[-1]).write_bytes(b"validated image") else: raise AssertionError(name) """ with tempfile.TemporaryDirectory(prefix="kaptan-package-") as temporary: root = Path(temporary) tools = root / "lib/rustlib/test-host/bin" tools.mkdir(parents=True) for path in ( root / "rustc", *(tools / f"llvm-{tool}" for tool in ("readobj", "objdump", "objcopy")), ): path.write_text(fake) path.chmod(0o755) elf, memory, log = ( root / "arbitrary-name.elf", root / "memory.x", root / "objcopy-called", ) elf.touch() image = elf.with_suffix(".bin") memory.write_text( "FLASH : ORIGIN = 0x08000000, LENGTH = 256\nRAM : ORIGIN = 0x20000000, LENGTH = 128\n" ) for executable, size, accepted in ( (True, 16, True), (False, 16, False), (True, 129, False), ): with self.subTest(executable=executable, size=size): image.write_bytes(b"previous image") log.unlink(missing_ok=True) flags = [{"Name": "SHF_ALLOC"}] if executable: flags.append({"Name": "SHF_EXECINSTR"}) metadata = [ { "Sections": [ { "Section": { "Name": {"Name": ".data"}, "Size": size, "Address": 0x20000000, "Flags": {"Flags": flags}, } } ], "ProgramHeaders": [ { "ProgramHeader": { "Type": {"Name": "PT_LOAD"}, "VirtualAddress": 0x20000000, "PhysicalAddress": 0x08000000, "FileSize": size, "MemSize": size, } } ], } ] result = subprocess.run( [ str(FIRMWARE / "scripts/create-dfu-image.sh"), str(elf), str(memory), ], env=dict( os.environ, PATH=f"{root}{os.pathsep}{os.environ['PATH']}", FAKE_SYSROOT=str(root), ELF_METADATA=json.dumps(metadata), OBJCOPY_LOG=str(log), ), capture_output=True, text=True, timeout=10, ) self.assertEqual( result.returncode == 0, accepted, result.stdout + result.stderr ) self.assertEqual(log.exists(), accepted) self.assertEqual( image.read_bytes(), b"validated image" if accepted else b"previous image", ) if __name__ == "__main__": unittest.main()