"""Build, lint, test and launch a generated ROS 2 package, the way a developer would on their machine. Runs inside the ROS 2 image (worker/ros/Dockerfile) as a sandbox: the Modal function has no network and no secrets, and every ROS command gets a scrubbed environment. Standard library only, so it runs with the image's own Python as well as Modal's. python3 -m worker.ros_runner PACKAGE_DIR_OR_JSON --distro jazzy [--out OUT_DIR] Steps (each one's output is kept as a log file): 1. colcon build build.log 2. ament linters on the sources lint.log (flake8/pep257 for Python, cpplint/cppcheck for C++, xmllint) 3. colcon test, if it has tests test.log (launch_testing, pytest, gtest, ament_lint_auto) 4. launch it for a few seconds launch.log (ros2 launch, or ros2 run for each executable without one) while ros2 bag records every topic to MCAP bag.mcap """ from __future__ import annotations import argparse import json import os import random import re import shutil import signal import subprocess import tempfile import time import xml.etree.ElementTree as ET from pathlib import Path, PurePosixPath DISTROS = ("humble", "jazzy") BUILD_TIMEOUT = 600 TEST_TIMEOUT = 300 LINT_TIMEOUT = 120 LAUNCH_SECONDS = 10 MAX_BAG_BYTES = 20_000_000 MAX_LOG_BYTES = 400_000 # DDS discovery over unicast on loopback only. Containers (and Modal's no-network sandbox) have no multicast on # `lo`, which the default localhost-only discovery needs, so nodes would never see each other. CYCLONE_CONFIG = """ false auto 60 """ LAUNCH_SUFFIXES = (".launch.py", "_launch.py", ".launch.xml", "_launch.xml", ".launch.yaml", "_launch.yaml") def _env(distro: str, home: Path) -> dict[str, str]: """A clean environment: nothing from the host (no credentials), ROS traffic kept on localhost.""" (home / "cyclonedds.xml").write_text(CYCLONE_CONFIG) env = { "PATH": "/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": str(home), "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "ROS_DISTRO": distro, "ROS_DOMAIN_ID": str(random.randint(1, 100)), "RMW_IMPLEMENTATION": "rmw_cyclonedds_cpp", "CYCLONEDDS_URI": f"file://{home / 'cyclonedds.xml'}", "PYTHONUNBUFFERED": "1", "RCUTILS_COLORIZED_OUTPUT": "0", "QT_QPA_PLATFORM": "offscreen", "MAKEFLAGS": f"-j{os.cpu_count() or 2}", } return env def _bash(script: str, ws: Path, env: dict, timeout: float) -> tuple[int | None, str]: """Run `script` in bash with ROS (and the workspace, once built) sourced. Returns (exit code or None, output).""" distro = env["ROS_DISTRO"] prelude = f"source /opt/ros/{distro}/setup.bash; [ -f install/setup.bash ] && source install/setup.bash; " try: p = subprocess.run( ["bash", "-c", prelude + script], cwd=ws, env=env, capture_output=True, text=True, timeout=timeout, errors="replace", ) return p.returncode, p.stdout + p.stderr except subprocess.TimeoutExpired as e: out = (e.stdout or b"").decode(errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "") return None, out + f"\n[rosdiff] timed out after {timeout:.0f}s\n" def write_workspace(files: dict[str, str], ws: Path) -> list[str]: """Write the generated files under ws/src. Returns the package names found.""" src = ws / "src" src.mkdir(parents=True, exist_ok=True) for rel, content in files.items(): p = PurePosixPath(rel) if p.is_absolute() or ".." in p.parts: raise ValueError(f"unsafe path in package: {rel}") dest = src / p dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(content) names = [] for xml in sorted(src.rglob("package.xml")): try: name = ET.parse(xml).getroot().findtext("name") except ET.ParseError: name = None if name: names.append(name.strip()) return names # --------------------------------------------------------------------------- steps _MISSING = [ re.compile(r'provided by "([\w-]+)"'), # CMake find_package re.compile(r"No module named '([\w.]+)'"), # Python import re.compile(r"Package '([\w-]+)' not found"), # ros2 launch / ament index ] def missing_dependencies(text: str) -> list[str]: found: list[str] = [] for pattern in _MISSING: for name in pattern.findall(text): if name not in found: found.append(name) return found def first_errors(text: str, limit: int = 8) -> list[str]: """The first compiler/CMake/Python errors, each on one line, for the summary.""" lines = text.splitlines() out: list[str] = [] used: set[int] = set() for i, raw in enumerate(lines): ln = raw.strip() if not ln or i in used or ln.startswith("Traceback (most recent call last)"): continue if "CMake Error" in ln: following = [(j, x.strip()) for j, x in enumerate(lines[i + 1 : i + 4], i + 1) if x.strip()] if following: used.add(following[0][0]) ln = f"{ln} {following[0][1]}" elif not ( "error:" in ln or re.match(r"^[\w.]*(Error|Exception)\b.*:", ln) or "SyntaxError" in ln or "ImportError" in ln or "ModuleNotFoundError" in ln ): continue if ln not in out: out.append(ln[:300]) if len(out) >= limit: break return out def build(ws: Path, env: dict) -> dict: code, log = _bash( "colcon build --event-handlers console_direct+ --cmake-args -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release", ws, env, BUILD_TIMEOUT, ) (ws / "build.log").write_text(log) return { "ok": code == 0, "exit_code": code, "errors": first_errors(log) if code != 0 else [], "missing_dependencies": missing_dependencies(log) if code != 0 else [], } def lint(ws: Path, env: dict, packages: dict[str, Path]) -> dict: """ament linters, as `ament_lint_auto` would run them. Reported, never a failure on their own.""" results: dict[str, int] = {} logs = [] for name, pkg in packages.items(): py = any(pkg.rglob("*.py")) cpp = any(p.suffix in (".cpp", ".hpp", ".cc", ".h") for p in pkg.rglob("*")) tools = ["ament_xmllint"] if py: tools += ["ament_flake8", "ament_pep257"] if cpp: tools += ["ament_cpplint", "ament_cppcheck"] for tool in tools: code, out = _bash(f"{tool} {pkg}", ws, env, LINT_TIMEOUT) issues = _count_issues(tool, out) if code else 0 results[tool] = results.get(tool, 0) + issues logs.append(f"===== {name}: {tool} (exit {code})\n{out}") (ws / "lint.log").write_text("\n".join(logs)) return {"issues": results, "total": sum(results.values())} def _count_issues(tool: str, out: str) -> int: m = re.search(r"(?:errors found: |^)(\d+)(?: (?:errors?|problems?))?\s*$", out, re.MULTILINE) if m: return int(m.group(1)) return max(1, sum(1 for ln in out.splitlines() if re.match(r"^\S+:\d+", ln))) def test(ws: Path, env: dict) -> dict: code, log = _bash( "colcon test --event-handlers console_direct+ --return-code-on-test-failure; " "echo '===== colcon test-result'; colcon test-result --all --verbose", ws, env, TEST_TIMEOUT, ) (ws / "test.log").write_text(log) m = re.search(r"Summary: (\d+) tests?, (\d+) errors?, (\d+) failures?, (\d+) skipped", log) if not m: return {"ran": False, "ok": code == 0} total, errors, failures, skipped = map(int, m.groups()) return { "ran": total > 0, "ok": errors == 0 and failures == 0, "tests": total, "errors": errors, "failures": failures, "skipped": skipped, } def _launch_targets(ws: Path, env: dict, packages: dict[str, Path]) -> list[tuple[str, list[str]]]: """What to start: each package's launch file, else each of its executables (at most 3 per package).""" targets = [] for name, pkg in packages.items(): launches = sorted( p for p in pkg.rglob("*") if p.is_file() and p.name.endswith(LAUNCH_SUFFIXES) and "test" not in p.parts ) if launches: targets.append((f"ros2 launch {name} {launches[0].name}", [name, launches[0].name])) continue _, out = _bash(f"ros2 pkg executables {name}", ws, env, 60) for line in out.splitlines()[:3]: parts = line.split() if len(parts) == 2 and parts[0] == name: targets.append((f"ros2 run {name} {parts[1]}", parts)) return targets def _start(cmd: str, ws: Path, env: dict, log: Path) -> subprocess.Popen: distro = env["ROS_DISTRO"] script = f"source /opt/ros/{distro}/setup.bash; source install/setup.bash; exec {cmd}" return subprocess.Popen( ["bash", "-c", script], cwd=ws, env=env, stdout=log.open("w"), stderr=subprocess.STDOUT, start_new_session=True, # its own process group, so every node it spawns can be stopped ) def _stop(proc: subprocess.Popen, grace: float = 8.0) -> int | None: if proc.poll() is not None: return proc.returncode for sig, wait in ((signal.SIGINT, grace), (signal.SIGTERM, 3.0), (signal.SIGKILL, 2.0)): try: os.killpg(proc.pid, sig) except ProcessLookupError: break try: proc.wait(wait) break except subprocess.TimeoutExpired: continue return None # still running when we stopped it: that is what we want def launch(ws: Path, env: dict, packages: dict[str, Path], seconds: float = LAUNCH_SECONDS) -> dict: targets = _launch_targets(ws, env, packages) if not targets: (ws / "launch.log").write_text("nothing to launch: no launch file and no executables\n") return {"ok": False, "started": [], "error": "no launch file and no executables to start"} logs_dir = ws / "launch_logs" logs_dir.mkdir() bag = subprocess.Popen( [ "bash", "-c", f"source /opt/ros/{env['ROS_DISTRO']}/setup.bash; exec ros2 bag record -a -s mcap -o {ws / 'bag'}", ], cwd=ws, env=env, stdout=(logs_dir / "bag.log").open("w"), stderr=subprocess.STDOUT, start_new_session=True, ) time.sleep(2.0) # let the recorder subscribe before the nodes start publishing procs = [(cmd, _start(cmd, ws, env, logs_dir / f"{i}.log")) for i, (cmd, _) in enumerate(targets)] exited_early: list[tuple[str, int]] = [] deadline = time.monotonic() + seconds graph: dict = {} while time.monotonic() < deadline: time.sleep(0.5) for cmd, p in procs: if p.poll() is not None and (cmd, p.returncode) not in exited_early: exited_early.append((cmd, p.returncode)) if not graph and time.monotonic() > deadline - seconds / 2: graph = _graph(ws, env) if not graph: graph = _graph(ws, env) # Crashes are judged on what happened before we stop everything (stopping makes launch report exit -2). before_stop = "\n".join((logs_dir / f"{i}.log").read_text(errors="replace") for i in range(len(procs))) for _, p in procs: _stop(p) _stop(bag, grace=10.0) texts = [] for i, (cmd, _) in enumerate(procs): texts.append(f"===== {cmd}\n" + (logs_dir / f"{i}.log").read_text(errors="replace")) texts.append("===== ros2 bag record\n" + (logs_dir / "bag.log").read_text(errors="replace")) log = "\n".join(texts) (ws / "launch.log").write_text(log) died = re.findall(r"\[ERROR\] \[([^\]]+)\]: process has died \[pid \d+, exit code (-?\d+)", before_stop) crashed = [f"{name} (exit {code})" for name, code in died] crashed += [f"{cmd} (exit {code})" for cmd, code in exited_early if code != 0] tracebacks = first_errors(before_stop) ok = not crashed and bool(graph.get("nodes")) return { "ok": ok, "started": [cmd for cmd, _ in procs], "seconds": seconds, "nodes": graph.get("nodes", []), "topics": graph.get("topics", []), "crashed": crashed, "errors": tracebacks, "missing_dependencies": missing_dependencies(log), "error": None if ok else ("a node crashed" if crashed else "no ROS node came up"), } def _graph(ws: Path, env: dict) -> dict: _, nodes = _bash("ros2 node list --no-daemon --spin-time 2", ws, env, 30) _, topics = _bash("ros2 topic list -t --no-daemon --spin-time 2", ws, env, 30) hidden = ("/rosbag2", "/_ros2cli", "/launch_ros") node_list = [n.strip() for n in nodes.splitlines() if n.startswith("/") and not n.strip().startswith(hidden)] topic_list = [t.strip() for t in topics.splitlines() if t.startswith("/")] return {"nodes": node_list, "topics": topic_list} # --------------------------------------------------------------------------- job def check(files: dict[str, str], distro: str, out: Path, launch_seconds: float = LAUNCH_SECONDS) -> dict: if distro not in DISTROS: raise ValueError(f"distro must be one of {DISTROS}") started = time.monotonic() root = Path(tempfile.mkdtemp(prefix="ros_check_")) ws, home = root / "ws", root / "home" home.mkdir(parents=True) ws.mkdir() env = _env(distro, home) try: names = write_workspace(files, ws) if not names: raise ValueError("no package.xml with a in the generated files") packages = {} for xml in (ws / "src").rglob("package.xml"): n = ET.parse(xml).getroot().findtext("name") if n: packages[n.strip()] = xml.parent result: dict = {"distro": distro, "packages": names} result["build"] = build(ws, env) result["lint"] = lint(ws, env, packages) if result["build"]["ok"]: result["test"] = test(ws, env) result["launch"] = launch(ws, env, packages, launch_seconds) result["headline"] = headline(result) result["passed"] = bool(result["build"]["ok"] and result.get("launch", {}).get("ok")) out.mkdir(parents=True, exist_ok=True) for name in ("build.log", "lint.log", "test.log", "launch.log"): if (ws / name).is_file(): data = (ws / name).read_bytes() (out / name).write_bytes(data[-MAX_LOG_BYTES:]) bags = sorted((ws / "bag").glob("*.mcap")) if (ws / "bag").is_dir() else [] if bags and bags[0].stat().st_size <= MAX_BAG_BYTES: shutil.copy(bags[0], out / "bag.mcap") result["bag_bytes"] = bags[0].stat().st_size result["worker_seconds"] = round(time.monotonic() - started, 1) (out / "summary.json").write_text(json.dumps(result, indent=2)) return result finally: shutil.rmtree(root, ignore_errors=True) def headline(r: dict) -> str: if not r["build"]["ok"]: missing = r["build"].get("missing_dependencies") return "does not build" + (f" (missing: {', '.join(missing[:4])})" if missing else "") parts = ["builds"] la = r.get("launch") or {} if la.get("ok"): parts.append(f"{len(la['nodes'])} node(s) up, {len(la['topics'])} topic(s)") else: parts.append(f"launch failed: {la.get('error')}") t = r.get("test") or {} if t.get("ran"): parts.append(f"tests {t['tests'] - t['failures'] - t['errors']}/{t['tests']} passed") lint_total = (r.get("lint") or {}).get("total", 0) parts.append(f"lint: {lint_total} issue(s)" if lint_total else "lint clean") return " ยท ".join(parts) def run_check_job(job_input: dict, distro: str) -> dict: """Worker entry point: returns the summary plus the logs/bag as inline artifacts (the API stores them).""" files = job_input["files"] if not isinstance(files, dict) or not files: raise ValueError("files must be a non-empty {path: content} mapping") with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) summary = check(files, distro, out) artifacts = {p.name: p.read_bytes() for p in sorted(out.iterdir()) if p.is_file()} return {"summary": summary, "artifacts": artifacts, "worker_seconds": summary["worker_seconds"]} def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("source", type=Path, help="a directory containing package(s), or a JSON {path: content} file") parser.add_argument("--distro", default=os.environ.get("ROS_DISTRO", "jazzy"), choices=DISTROS) parser.add_argument("--out", type=Path, default=Path("ros_check_out")) parser.add_argument("--launch-seconds", type=float, default=LAUNCH_SECONDS) args = parser.parse_args(argv) if args.source.is_dir(): files = {p.relative_to(args.source).as_posix(): p.read_text() for p in args.source.rglob("*") if p.is_file()} else: files = json.loads(args.source.read_text()) result = check(files, args.distro, args.out, args.launch_seconds) print(json.dumps(result, indent=2)) return 0 if result["passed"] else 1 if __name__ == "__main__": raise SystemExit(main())