diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9f9ef28d4..a15a3cd1c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -224,3 +224,68 @@ jobs: name: ${{ matrix.arch }}-artifacts path: ${{ steps.artifact.outputs.artifact_path }} + run_compose: + # Compose's mcast plumbing is arch-independent, so this runs once outside + # the per-arch matrix instead of multiplying CI cost by 10. + needs: [changes, build_container] + runs-on: rehosting-arc + if: ${{ !github.event.pull_request.draft }} + steps: + - name: Set up Python + if: ${{ needs.changes.outputs.run_tests == 'true' }} + uses: actions/setup-python@v6 + with: + python-version: "3.10.12" + - name: Install dependencies + if: ${{ needs.changes.outputs.run_tests == 'true' }} + run: pip install click pyyaml + - name: Checkout code + if: ${{ needs.changes.outputs.run_tests == 'true' }} + uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Trust Harbor's self-signed certificate + if: ${{ needs.changes.outputs.run_tests == 'true' }} + run: | + echo "Fetching certificate from ${{ env.REGISTRY }}" + openssl s_client -showcerts -connect ${{ env.REGISTRY }}:443 < /dev/null 2>/dev/null | openssl x509 -outform PEM | sudo tee /usr/local/share/ca-certificates/harbor.crt > /dev/null + sudo update-ca-certificates + + - name: Set up Docker Buildx + if: ${{ needs.changes.outputs.run_tests == 'true' }} + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:master + network=host + buildkitd-config-inline: | + [registry."${{ env.REGISTRY }}"] + insecure = true + http = true + + - name: Log in to Rehosting Arc Registry + if: ${{ needs.changes.outputs.run_tests == 'true' }} + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ env.USER }} + password: ${{ secrets.REHOSTING_ARC_REGISTRY_PASSWORD || env.EXTERNAL_REGISTRY_PASS }} + + - name: Pull image via Buildx and Load to Daemon + if: ${{ needs.changes.outputs.run_tests == 'true' }} + run: | + echo "FROM ${{ env.TARGET }}/rehosting/penguin:${{ github.sha }}" | \ + docker buildx build -t rehosting/penguin:latest --load - + + - name: Compose test (armel) + if: ${{ needs.changes.outputs.run_tests == 'true' }} + run: timeout 8m python3 $GITHUB_WORKSPACE/tests/unit_tests/compose/test.py --arch armel + + - name: Compose debug info + if: ${{ needs.changes.outputs.run_tests == 'true' && failure() }} + uses: actions/upload-artifact@v4 + with: + name: compose-artifacts + path: ${{ github.workspace }}/tests/unit_tests/compose/compose_projects/ diff --git a/.gitignore b/.gitignore index 590d725cc..6bf1984dd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ fws/ results/ projects/ +compose_projects/ tests/unit_tests/test_target/config.yaml penguin.sif fw diff --git a/docs/compose.md b/docs/compose.md index 1920cf140..f0602fd07 100644 --- a/docs/compose.md +++ b/docs/compose.md @@ -1,195 +1,252 @@ -# `penguin compose` — Multi-System Firmware Rehosting +# `penguin compose` — Multi-Device Firmware Rehosting -`penguin compose compose.yaml` brings up a network of rehosted firmware systems -that communicate at L2 — think docker-compose but for emulated firmware devices. +`penguin compose` runs multiple firmware guests in parallel and wires them +together over a shared L2 broadcast domain. Each device runs as its own +PANDA-QEMU guest with its own project config; the only thing compose adds is +the virtual network glue. -## Networking backend: QEMU socket/mcast +The networking backend is QEMU's `socket,mcast=...` netdev. No tap devices, +no Linux bridge, no `CAP_NET_ADMIN` — everything works inside an ordinary +unprivileged container. -Each compose network is a UDP multicast group. QEMU wraps raw Ethernet frames in -UDP, so ARP, DHCP, and NDP all work. Multiple QEMU instances that join the same -`mcast=:` share a full L2 broadcast domain. +## Invocation -No tap devices, no Linux bridge, no `CAP_NET_ADMIN` — works inside an ordinary -unprivileged Docker container. No new Python dependencies either; the args are -injected via the existing `core.extra_qemu_args` config field. +### 1. Create a compose project -QEMU args injected per NIC: +Pass two or more device project directories. Compose generates a fresh +compose project: + +```sh +./penguin compose init ./projects/fw1 ./projects/fw2 +./penguin compose init ./projects/fw1 ./projects/fw2 ./projects/fw3 +./penguin compose init --name three_node ./projects/fw1 ./projects/fw2 ./projects/fw3 ``` --netdev socket,id=compose.0,mcast=230.0.0.1:11000 --device virtio-net-pci,netdev=compose.0,mac=52:54:00:aa:01:01 + +The scaffolded directory is placed alongside the device projects' parent — +if the device projects live in `./projects/`, compose writes the new +project under `./compose_projects//`. The default `` is the +device basenames joined with `_` (so `fw1` + `fw2` → `fw1_fw2`); pass +`--name` to override. Names must match `[A-Za-z0-9._-]+` and be at most +64 characters. If the default join would exceed the cap (many devices or +long basenames), `init` refuses and asks for an explicit `--name`. + +Each device is attached to a single `lan` network (`192.168.1.0/24`) on +`eth0`, with IPs assigned in argument order: the first project gets +`192.168.1.1`, the second `192.168.1.2`, and so on. + +If the target directory already exists, `compose init` refuses rather than +overwriting. To re-run an existing setup, pass it to `compose run`; to +scaffold a separate copy with the same device set, use `--name`. + +After `init`, inspect or edit the generated `compose.yaml` if you need +different IPs, interfaces, networks, or config overrides. + +### 2. Run a compose project + +Pass a directory containing a `compose.yaml`, or the YAML file directly: + +```sh +./penguin compose run ./compose_projects/my_setup +./penguin compose run ./compose_projects/my_setup/compose.yaml ``` -Port = `11000 + network_index`. MAC is deterministically derived from -`(compose_file_path, device_name, network_name)` so it is stable across re-runs. +Results land in `/results//`, with `results/latest` updated +to point at the most recent numbered run. -## compose.yaml format +### Shortcut: scaffold and run -Place `compose.yaml` alongside your project directories (e.g., next to `projects/`). +For quick smoke runs, pass two or more device project directories directly +to `compose`. This is equivalent to `compose init` followed immediately by +`compose run`: + +```sh +./penguin compose ./projects/fw1 ./projects/fw2 +./penguin compose ./projects/fw1 ./projects/fw2 ./projects/fw3 +``` + +Re-running the shortcut with the same arguments produces a different +timestamp, so the second invocation creates a new scaffolded directory +rather than overwriting the first. To re-run an already-scaffolded setup +(and keep its IP assignments and numbered results history), use +`penguin compose run` against that directory. + +## YAML schema ```yaml version: 1 networks: lan: - subnet: 192.168.1.0/24 # optional — used only for startup_script IP config + subnet: 192.168.1.0/24 # optional; informational only devices: - router: - project: ./projects/router_fw # path to penguin project dir (required) + server: + project: ./projects/fw1 # path to a penguin project (required) networks: lan: - iface: eth0 # guest interface name - ip: 192.168.1.1/24 # optional: static IP via startup_script - mac: "52:54:00:aa:01:01" # optional: auto-generated if absent - config_overrides: # optional: merged as a final patch layer + iface: eth0 # guest interface name (required) + ip: 192.168.1.1/24 # optional: static IP via startup script + mac: "52:54:00:aa:01:01" # optional: auto-generated if omitted + config_overrides: # optional: merged as a final patch layer core: guest_cmd_timeout: 120 client: - project: ./projects/client_fw + project: ./projects/fw2 networks: lan: iface: eth0 - ip: 192.168.1.100/24 + ip: 192.168.1.2/24 output: - base_dir: ./compose_results # optional; default ./compose_results + base_dir: ./results # optional; default ./results next to compose.yaml ``` -**`iface`** — the guest Linux interface name. The compose-generated virtio-net -device is assigned the specified MAC so the firmware can identify it. Ensure -your firmware names the interface as expected (normally `eth0`, `eth1`, etc.). - -**`ip`** — if set, writes `ip link set up; ip addr add dev ` -to the compose-generated `/igloo/init.d/zz_compose_net` startup script. - -**`config_overrides`** — merged as the final patch layer on top of the project's -own `config.yaml` and any `patch_*.yaml` files. - -## Usage - -```sh -# Run all devices in parallel -./penguin compose compose.yaml - -# Specify output directory and timeout -./penguin compose compose.yaml --output ./my_results --timeout 120 +Field notes: + +- **`project`** — path to a device project directory containing `config.yaml`, + resolved relative to `compose.yaml`. +- **`iface`** — the Linux interface name the guest is expected to use for + this attachment. The compose-generated NIC's MAC is set so the firmware + can pin the interface; verify your firmware actually names it as + declared (typically `eth0` for a single attachment). +- **`ip`** — if set, compose writes `ip link set up; ip addr add + dev ` into `/igloo/init.d/zz_compose_net` so traffic flows + at boot. +- **`mac`** — if omitted, a stable locally-administered MAC is derived + from `(compose path, device name, network name)`. +- **`config_overrides`** — merged as the final patch layer on top of the + device project's own `config.yaml` and any `patch_*.yaml` files. +- **`output.base_dir`** — base directory for numbered runs, resolved + relative to `compose.yaml`. + +The authoritative schema lives in `src/penguin/compose.py` (`load_compose`, +`DeviceConfig`, `NetworkSpec`, `DeviceNetAttachment`). + +## Output layout -# Force-overwrite existing results -./penguin compose compose.yaml --force +``` +/ + compose.yaml + results/ + latest -> ./0 + 0/ + compose.yaml # copy of the input for reproducibility + compose_summary.yaml # per-device scores and errors + server/ # runner's output dir for this device + console.log + netbinds.csv + ... + client/ + console.log + ... + .compose/ + server/ + derived_config.yaml # device config + injected compose patch + patch_compose_net.yaml # auto-generated net patch + instance.yaml # planned metadata (endpoints, networks) + runtime.yaml # actual runtime metadata from the runner + qemu_stdout.txt + qemu_stderr.txt + client/ + ... ``` -## Results structure +Per-device analysis output (`console.log`, `netbinds.csv`, `health_final.yaml`, +etc.) sits directly under `/`, exactly as a single-device +`penguin run` would write it. Compose-specific bookkeeping (derived +configs, generated patches, QEMU stdio, runtime metadata) is tucked under +`.compose//` so it doesn't clutter the analysis tree. -``` -compose_results/ - compose.yaml # copy of input for reproducibility - router/ - derived_config.yaml # config actually used (with compose patch applied) - patch_compose_net.yaml # auto-generated network patch - instance.yaml # planned metadata: name, endpoint block, networks - runtime.yaml # actual runner metadata: shell port, vsock CID, sockets - output/ # PandaRunner output (console.log, health_final.yaml, ...) - score.txt - client/ - ... - compose_summary.yaml # aggregated scores across all devices -``` +`compose_summary.yaml` aggregates each device's score dict and any +top-level error from its run. ## Inspecting a running compose session -`penguin utils list` walks `compose_results/` and reports each device's -connection info: PID, root-shell port, vsock CID, mcast endpoint, output -directory, and status (`running`, `ok`, or `failed`): +`penguin utils list` prints a table of the devices in a compose run. With +no `--dir`, it searches the current directory for a `results/latest` (or +numbered run) layout. -```sh -./penguin utils list -# or, if compose_results/ lives somewhere unusual: -./penguin utils list --dir /path/to/compose_results ``` - -When run from the host, the wrapper execs the command into the active container -for the current workspace when exactly one is running. That lets `list` see -live QEMU processes as well as the result files. If no compose container is -running, it starts a short-lived utility container and reports what is available -from `compose_results/`. - -For devices whose firmware exposes a guest root shell, the command also prints -ready-to-paste `telnet ` commands. Compose reserves a -bounded root-shell port block per device, using these defaults: - -```sh -PENGUIN_COMPOSE_TELNET_BASE=20000 -PENGUIN_COMPOSE_TELNET_BLOCK_SIZE=100 +$ penguin utils list +DEVICE STATUS PID SHELL CID NETWORKS OUTPUT +device_a running 12345 20000 16 lan(192.168.1.2) results/latest/device_a +device_b running 12346 20100 17 lan(192.168.1.3) results/latest/device_b ``` -So device `idx` searches `base + idx*block_size` through the end of that block. -The runner records the actual selected port in `runtime.yaml`, and `list` -prefers live `/proc` data while the guest is running. +Columns are: device name, runner status, runner PID, per-device telnet port +on the docker container, vsock CID for `guest_cmd`, attached networks with +the assigned guest IP, and the device's output directory. When devices are +running, the command also prints ready-to-paste telnet and +`penguin utils guest-cmd` lines. -Compose also assigns a unique vsock CID per device so multiple guests can use -`plugins.vpn.enabled: true` or `core.guest_cmd: true` in the same container: +If `core.core_shell` is enabled, each device exposes a root shell on its +telnet port. From the docker host: -```sh -PENGUIN_COMPOSE_VSOCK_CID_BASE=16 ``` - -For devices with `core.guest_cmd: true`, `penguin utils list` prints a -`penguin utils guest-cmd ...` command, and the command can be run directly: - -```sh -./penguin utils guest-cmd router -- 'ip addr' +telnet 192.168.0.2 20000 ``` -## Known limitations (multi-device in one container) - -All compose devices run as parallel QEMU subprocesses inside a single Docker -container. Host-facing firmware services exposed by the VPN plugin still share -that container namespace, so two devices that both expose the same guest service -may race for the same host port. The VPN plugin will usually pick a free -alternate port, but explicit fixed maps must still be unique. - -## Layout convention - -All project directories must share a common parent with `compose.yaml`: +or, from inside the penguin container: ``` -workspace/ - compose.yaml - projects/ - router_fw/ - config.yaml - base/ - fs.tar.gz - client_fw/ - config.yaml - base/ - fs.tar.gz +docker exec -it telnet localhost 20000 ``` -Run from `workspace/`: `./penguin compose compose.yaml` +Endpoint allocation is controlled by three env vars (read at compose-start +time): + +| Var | Default | Effect | +|------------------------------------|---------|-------------------------------------| +| `PENGUIN_COMPOSE_TELNET_BASE` | `20000` | Telnet port for the first device. | +| `PENGUIN_COMPOSE_TELNET_BLOCK_SIZE`| `100` | Port stride between devices. | +| `PENGUIN_COMPOSE_VSOCK_CID_BASE` | `16` | vsock CID for the first device. | -The wrapper mounts the `compose.yaml` parent directory into the container, so -all relative `project:` paths resolve correctly. +Bump these when running multiple compose sessions on the same host (so +their port/CID blocks don't collide) or when the default block runs into +the 65535 cap with very large device counts. -## Internals +The source-of-truth files behind `penguin utils list` are +`.compose//instance.yaml` (planned endpoints + networks, written +before the runner starts) and `.compose//runtime.yaml` (actual +PID, status, and endpoints from the runner). If a session crashes, those +two files plus `qemu_stdout.txt` / `qemu_stderr.txt` next to them are the +right place to start. -`penguin compose` is implemented in `src/penguin/compose.py`. It: +## How it works -1. Parses and validates `compose.yaml` -2. Assigns a unique UDP multicast port per network (`11000 + index`) -3. Generates or uses explicit MAC addresses per device/network pair -4. For each device, writes a `patch_compose_net.yaml` with the socket netdev args -5. Writes a `derived_config.yaml` that extends the project config with that patch -6. Assigns each device a root-shell port block and vsock CID -7. Fans out via `ThreadPoolExecutor` — all devices start in parallel -8. Each device calls `PandaRunner().run(derived_config, proj_dir, out_dir, ...)` -9. Collects results into `compose_summary.yaml` +Each entry under `networks:` is assigned a UDP multicast port, and every +device attached to that network gets matching QEMU args injected via +`core.extra_qemu_args`: -## Future: TAP backend +``` +-netdev socket,id=compose.0,mcast=230.0.0.1: +-device virtio-net-pci,netdev=compose.0,mac= +``` -For higher throughput or firmware that probes link carrier state, a `backend: tap` -option can be added. This requires `CAP_NET_ADMIN` (the wrapper would add -`--cap-add=NET_ADMIN` for that case) and creates Linux tap devices bridged via -`ip link type bridge`. The socket/mcast backend covers the common research case -without privilege. +All QEMU processes that join the same `mcast=:` share one L2 +broadcast domain — ARP, DHCP, and link-local discovery all work because +QEMU just wraps raw Ethernet frames in UDP. The kernel inside each guest +enumerates the injected NIC normally (typically as `eth0` for a single +attachment), and the compose-generated `/igloo/init.d/zz_compose_net` +script brings it up and assigns the static IP. The multicast plumbing is +internal to the QEMU processes; the host does not need any privileges or +network configuration. + +## Caveats + +- **No boot ordering.** All devices start in parallel. If a client probes + a server's port before the server has bound it, the probe will fail — + application-level retry is on you. +- **L2 only.** Compose provides a shared broadcast domain and (optionally) + static IPs. There is no DHCP server, no default gateway, no NAT, and no + routing between compose networks. Devices that need DHCP must serve it + themselves from one of the guests. +- **Single container, shared host ports.** All compose devices run as + parallel QEMU subprocesses inside one Penguin container. Plugins that + expose host-facing ports (e.g. the VPN plugin) share that namespace, so + fixed host-port mappings must not collide across devices. +- **Scaffolded directories refuse to overwrite.** `compose init` and the + scaffold-and-run shortcut both fail if the target `compose_projects//` + already exists. Use `compose run` against it to re-run, delete it to start + fresh, or pass `--name` to scaffold a separate copy. diff --git a/penguin b/penguin index 3ac0201ce..ab2f17c2e 100755 --- a/penguin +++ b/penguin @@ -193,6 +193,91 @@ penguin_run() { cmd+=("/host_projects") fi + # Handle "compose init" and the legacy "compose " + # shortcut. When args are device-project dirs, scaffold_compose writes to + # /compose_projects//, so we need the grandparent + # mounted too. Pre-rewrite project args to that container path so + # dirname-walks resolve to a mounted dir. + if [[ ${#cmd[@]} -gt 1 && "${cmd[0]}" == "compose" ]]; then + local compose_form2=true + local compose_grandparent="" + local compose_project_count=0 + local compose_arg_start=1 + if [[ ${#cmd[@]} -gt 2 && "${cmd[1]}" == "init" ]]; then + compose_arg_start=2 + elif [[ "${cmd[1]}" == "run" ]]; then + compose_form2=false + fi + for ((i=compose_arg_start; i<${#cmd[@]}; i++)); do + local _arg="${cmd[$i]}" + # Skip flags and flag values + if [[ "$_arg" =~ ^-- ]]; then continue; fi + if [[ $i -gt 1 && "${cmd[$((i-1))]}" =~ ^--(timeout|output|name)$ ]]; then continue; fi + # Compose init requires each positional arg to be a dir with + # config.yaml and no compose.yaml. Any non-conforming arg drops us + # out of this special-case mode and lets the in-container CLI + # handle dispatch/errors. + if [[ -d "$_arg" && -f "$_arg/config.yaml" && ! -f "$_arg/compose.yaml" ]]; then + compose_project_count=$((compose_project_count + 1)) + local _gp=$(dirname $(dirname $(realpath "$_arg"))) + if [[ -z "$compose_grandparent" ]]; then + compose_grandparent="$_gp" + elif [[ "$compose_grandparent" != "$_gp" ]]; then + # Mixed grandparents -- can't satisfy all with one mount. + # Bail to the generic per-arg-parent mapping below. + compose_form2=false + break + fi + else + compose_form2=false + break + fi + done + if $compose_form2 && [[ -n "$compose_grandparent" && $compose_project_count -ge 2 ]]; then + local _guest_root="/host_$(basename "$compose_grandparent")" + maps+=("$compose_grandparent:$_guest_root") + for ((i=compose_arg_start; i<${#cmd[@]}; i++)); do + local _arg="${cmd[$i]}" + if [[ "$_arg" =~ ^-- ]]; then continue; fi + if [[ $i -gt 1 && "${cmd[$((i-1))]}" =~ ^--(timeout|output|name)$ ]]; then continue; fi + if [[ -d "$_arg" && -f "$_arg/config.yaml" ]]; then + local _arg_real=$(realpath "$_arg") + cmd[$i]="${_arg_real//$compose_grandparent/$_guest_root}" + fi + done + fi + + # Running a scaffolded compose project needs the scaffold base mounted, + # not just compose_projects//, because generated project: + # paths point back to ../../projects/. + local compose_run_target_index="" + if [[ ${#cmd[@]} -gt 2 && "${cmd[1]}" == "run" ]]; then + for ((i=2; i<${#cmd[@]}; i++)); do + local _arg="${cmd[$i]}" + if [[ "$_arg" =~ ^-- ]]; then continue; fi + if [[ $i -gt 2 && "${cmd[$((i-1))]}" =~ ^--(timeout|output)$ ]]; then continue; fi + compose_run_target_index="$i" + break + done + elif [[ ${#cmd[@]} -eq 2 && "${cmd[1]}" != "init" ]]; then + compose_run_target_index=1 + fi + if [[ -n "$compose_run_target_index" && -e "${cmd[$compose_run_target_index]}" ]]; then + local _target_real=$(realpath "${cmd[$compose_run_target_index]}") + local _compose_dir="$_target_real" + if [[ -f "$_compose_dir" ]]; then + _compose_dir=$(dirname "$_compose_dir") + fi + local _compose_parent=$(dirname "$_compose_dir") + if [[ -f "$_compose_dir/compose.yaml" && "$(basename "$_compose_parent")" == "compose_projects" ]]; then + local _base=$(dirname "$_compose_parent") + local _guest_root="/host_$(basename "$_base")" + maps+=("$_base:$_guest_root") + cmd[$compose_run_target_index]="${_target_real//$_base/$_guest_root}" + fi + fi + fi + if [[ ${#cmd[@]} -gt 1 && ("${cmd[0]}" == "reproduce" || "${cmd[0]}" == "repro" )]]; then if [[ ${#cmd[@]} -lt 2 ]]; then echo "Reproduce command requires a specific image to be passed as the first argument. Please set --image to the image you want to use." diff --git a/pyplugins/apis/send_hypercall.py b/pyplugins/apis/send_hypercall.py index 908cc23fd..9000e6cbc 100644 --- a/pyplugins/apis/send_hypercall.py +++ b/pyplugins/apis/send_hypercall.py @@ -141,7 +141,7 @@ def on_send_hypercall(self, cpu: Any, buf_addr: int, # Unpack list of pointers word_char = "I" if arch_bytes == 4 else "Q" - endianness = ">" if self.panda.arch_name in ["mips", "mips64"] else "<" + endianness = ">" if getattr(self.panda, "endianness", "little") == "big" else "<" ptrs = struct.unpack_from( f"{endianness}{buf_num_ptrs}{word_char}", buf) str_ptrs, out_addr = ptrs[:-1], ptrs[-1] diff --git a/src/penguin/__main__.py b/src/penguin/__main__.py index eb72ba26f..274aef582 100644 --- a/src/penguin/__main__.py +++ b/src/penguin/__main__.py @@ -25,7 +25,7 @@ from .patch_search import patch_search from .patch_minimizer import minimize as patch_minimize from .plugin_manager import find_local_plugins -from .compose import run_compose +from .compose import run_compose, scaffold_compose from .utils_cli import utils as _utils_group logger = getColoredLogger("penguin") @@ -430,6 +430,19 @@ def callback(ctx, param, value): return value return click.option("-v", "--verbose", is_flag=True, help="Set log level to debug", expose_value=False, callback=callback)(f) + +class ComposeGroup(click.Group): + """Click group that preserves `penguin compose ` shortcuts.""" + + def resolve_command(self, ctx, args): + try: + return super().resolve_command(ctx, args) + except click.UsageError: + if args: + cmd = self.get_command(ctx, "_shortcut") + return "_shortcut", cmd, args + raise + # --- Click Commands --- @@ -1026,25 +1039,171 @@ def import_cmd(ctx, archive, output, force): ctx.invoke(unpack, archive=archive, output=output, force=force) -@cli.command() -@click.argument("compose_file", type=click.Path(exists=True)) -@click.option("--output", type=str, default=None, help="Output directory. Defaults to compose_results/ next to compose.yaml.") -@click.option("--force", is_flag=True, default=False, help="Delete existing output directory before running.") +def _looks_like_compose_project_dir(path: str) -> bool: + return os.path.isdir(path) and os.path.isfile(os.path.join(path, "compose.yaml")) + + +def _looks_like_compose_device_project_dir(path: str) -> bool: + return ( + os.path.isdir(path) + and os.path.isfile(os.path.join(path, "config.yaml")) + and not os.path.isfile(os.path.join(path, "compose.yaml")) + ) + + +def _compose_file_from_target(target: str) -> str: + if os.path.isfile(target): + return target + if _looks_like_compose_project_dir(target): + return os.path.join(target, "compose.yaml") + if _looks_like_compose_device_project_dir(target): + raise click.ClickException( + f"'{target}' is a single-device project, not a compose project. " + "Use `penguin run` for one project, or `penguin compose init` " + "with two-or-more project directories." + ) + raise click.ClickException( + f"Cannot interpret '{target}' as a compose target. Expected a " + "compose.yaml file or a directory containing compose.yaml." + ) + + +def _scaffold_compose_from_project_dirs( + project_dirs: tuple[str, ...] | list[str], + name: str | None = None, +) -> str: + if len(project_dirs) < 2: + raise click.ClickException( + "Compose init requires two-or-more project directories, each " + "containing config.yaml and no compose.yaml." + ) + bad = [p for p in project_dirs if not _looks_like_compose_device_project_dir(p)] + if bad: + joined = ", ".join(repr(p) for p in bad) + raise click.ClickException( + "Compose init expects only project directories containing config.yaml " + f"and no compose.yaml. Cannot use: {joined}" + ) + try: + return scaffold_compose(list(project_dirs), name=name) + except (ValueError, RuntimeError) as e: + raise click.ClickException(str(e)) + + +def _run_compose_target(ctx, target: str, output: str | None, force: bool, timeout: int | None) -> None: + compose_file = _compose_file_from_target(target) + run_compose( + compose_file, + output, + timeout=timeout, + force=force, + verbose=ctx.obj['VERBOSE'], + ) + + +def _run_compose_shortcut(ctx, targets, output, force, timeout) -> None: + project_dirs = [t for t in targets if _looks_like_compose_device_project_dir(t)] + + if len(project_dirs) == len(targets) and len(targets) >= 2: + try: + compose_file = scaffold_compose(list(targets)) + except (ValueError, RuntimeError) as e: + raise click.ClickException(str(e)) + run_compose( + compose_file, + output, + timeout=timeout, + force=force, + verbose=ctx.obj['VERBOSE'], + ) + elif len(targets) == 1: + _run_compose_target(ctx, targets[0], output, force, timeout) + else: + raise click.ClickException( + "Cannot interpret compose arguments. Expected one of: " + "(a) `penguin compose run `, " + "(b) `penguin compose init [...]`, " + "or (c) the shortcut `penguin compose [...]`." + ) + + +@cli.group( + cls=ComposeGroup, + invoke_without_command=True, + context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, +) +@verbose_option +@click.pass_context +def compose(ctx): + """ + Manage multi-device firmware rehosting. + + Common workflow: + + \b + * `penguin compose init ./projects/a ./projects/b` + * inspect or edit the generated compose.yaml + * `penguin compose run ./compose_projects/` + + For quick experiments, `penguin compose ./projects/a ./projects/b` + remains a scaffold-and-run shortcut. + """ + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + ctx.exit() + + +@compose.command("init") +@click.argument("project_dirs", nargs=-1, required=True, type=click.Path(exists=True, file_okay=False)) +@click.option("--name", "name", type=str, default=None, + help="Name of the scaffolded compose project directory. " + "Defaults to the device basenames joined with '_' " + "(e.g. projects 'foo' + 'bar' → 'foo_bar').") +@verbose_option +@click.pass_context +def compose_init(ctx, project_dirs, name): + """ + Create a compose project from two or more device projects. + + PROJECT_DIRS must each contain a config.yaml. The generated compose + project is written under compose_projects// alongside the + projects' parent directory. + """ + _startup_checks(ctx.obj['VERBOSE']) + compose_file = _scaffold_compose_from_project_dirs(project_dirs, name=name) + click.echo(compose_file) + + +@compose.command("run") +@click.argument("target", type=click.Path(exists=True)) +@click.option("--output", type=str, default=None, help="Exact output directory. Defaults to results/ next to compose.yaml with latest symlink.") +@click.option("--force", is_flag=True, default=False, help="Delete existing explicit output directory before running.") @click.option("--timeout", type=int, default=None, help="Per-device timeout in seconds.") @verbose_option @click.pass_context -def compose(ctx, compose_file, output, force, timeout): +def compose_run(ctx, target, output, force, timeout): """ - Bring up a network of rehosted firmware systems. + Run an existing compose project. + + TARGET is a compose.yaml file or a directory containing compose.yaml. + """ + _startup_checks(ctx.obj['VERBOSE']) + _run_compose_target(ctx, target, output, force, timeout) - COMPOSE_FILE is the path to a compose.yaml describing the multi-device - topology. All devices are started in parallel and connected via QEMU - socket/mcast virtual L2 networks. - See docs/compose.md for the compose.yaml format. +@compose.command("_shortcut", hidden=True, context_settings={"ignore_unknown_options": True}) +@click.argument("targets", nargs=-1, required=True, type=click.Path(exists=True)) +@click.option("--output", type=str, default=None, help="Exact output directory. Defaults to results/ next to compose.yaml with latest symlink.") +@click.option("--force", is_flag=True, default=False, help="Delete existing explicit output directory before running.") +@click.option("--timeout", type=int, default=None, help="Per-device timeout in seconds.") +@verbose_option +@click.pass_context +def compose_shortcut(ctx, targets, output, force, timeout): + """ + Compatibility path for `penguin compose `. """ _startup_checks(ctx.obj['VERBOSE']) - run_compose(compose_file, output, timeout=timeout, force=force, verbose=ctx.obj['VERBOSE']) + _run_compose_shortcut(ctx, targets, output, force, timeout) cli.add_command(_utils_group) diff --git a/src/penguin/compose.py b/src/penguin/compose.py index cb053a28d..a31df8439 100644 --- a/src/penguin/compose.py +++ b/src/penguin/compose.py @@ -9,6 +9,7 @@ import hashlib import logging import os +import re import shutil from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field @@ -153,6 +154,119 @@ def load_compose(compose_path: str) -> ComposeConfig: ) +# --------------------------------------------------------------------------- +# Scaffolding — auto-generate a compose.yaml from a list of project dirs +# --------------------------------------------------------------------------- + +_VALID_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") +MAX_COMPOSE_NAME_LEN = 64 + + +def _validate_compose_name(name: str) -> None: + if not name or name in (".", "..") or not _VALID_NAME_RE.match(name): + raise ValueError( + f"Invalid compose project name {name!r}: must match [A-Za-z0-9._-]+ " + "and not be '.' or '..'" + ) + if len(name) > MAX_COMPOSE_NAME_LEN: + raise ValueError( + f"Compose project name {name!r} is {len(name)} chars; " + f"limit is {MAX_COMPOSE_NAME_LEN}." + ) + + +def scaffold_compose(device_projects: list[str], name: str | None = None) -> str: + """ + Generate a fresh compose.yaml from a list of project directories. + + Each entry of ``device_projects`` must be a path to a directory containing + a ``config.yaml``. The scaffold creates a single ``lan`` network on + ``192.168.1.0/24`` and assigns each device a sequential IP starting at + ``192.168.1.1/24``. + + The scaffold directory is ``/compose_projects//``. + If ``name`` is omitted it defaults to the device basenames joined with ``_`` + (e.g. projects ``foo`` + ``bar`` → ``foo_bar``). Refuses if the directory + already exists. Returns the absolute path to the new ``compose.yaml``. + """ + if not device_projects: + raise ValueError("scaffold_compose requires at least one project directory") + + resolved: list[str] = [] + seen_names: dict[str, str] = {} + for raw in device_projects: + proj = os.path.realpath(raw) + if not os.path.isdir(proj): + raise ValueError(f"Project directory not found: {raw}") + if not os.path.isfile(os.path.join(proj, "config.yaml")): + raise ValueError(f"Project directory missing config.yaml: {raw}") + dev_name = os.path.basename(proj) + if dev_name in seen_names: + raise ValueError( + f"Duplicate device basename '{dev_name}': {seen_names[dev_name]} and {proj}" + ) + seen_names[dev_name] = proj + resolved.append(proj) + + if name is None: + default_name = "_".join(os.path.basename(p) for p in resolved) + if len(default_name) > MAX_COMPOSE_NAME_LEN: + raise ValueError( + f"Default compose project name {default_name!r} is {len(default_name)} " + f"chars; limit is {MAX_COMPOSE_NAME_LEN}. Pass --name to override." + ) + name = default_name + _validate_compose_name(name) + + # Scaffold base is the parent of the first project's parent dir. + # e.g. first_proj = ./projects/fw1 -> scaffold base = ./ + first_proj = resolved[0] + scaffold_base = os.path.realpath(os.path.dirname(os.path.dirname(first_proj))) + + scaffold_dir = os.path.join(scaffold_base, "compose_projects", name) + + if os.path.exists(scaffold_dir): + raise RuntimeError( + f"Scaffold directory already exists: {scaffold_dir}. " + "Pass that directory to `penguin compose run` to re-run it, " + "delete it, or use --name to scaffold a separate copy." + ) + + os.makedirs(scaffold_dir) + + compose_data: dict = { + "version": 1, + "networks": { + "lan": {"subnet": "192.168.1.0/24"}, + }, + "devices": {}, + } + for idx, proj in enumerate(resolved): + name = os.path.basename(proj) + compose_data["devices"][name] = { + "project": os.path.relpath(proj, scaffold_dir), + "networks": { + "lan": { + "iface": "eth0", + "ip": f"192.168.1.{idx + 1}/24", + }, + }, + } + + compose_path = os.path.join(scaffold_dir, "compose.yaml") + with open(compose_path, "w") as f: + yaml.dump(compose_data, f, default_flow_style=False, sort_keys=False) + + logger.info(f"Scaffolded compose.yaml at {compose_path}") + for idx, proj in enumerate(resolved): + name = os.path.basename(proj) + logger.info( + f" device '{name}': project={proj} ip=192.168.1.{idx + 1}/24" + ) + + return compose_path + + # --------------------------------------------------------------------------- # Patch generation — pure logic, no penguin deps # --------------------------------------------------------------------------- @@ -233,7 +347,7 @@ def _prepare_device_run( device: DeviceConfig, networks: dict[str, NetworkSpec], compose_path: str, - device_out_dir: str, + device_meta_dir: str, ) -> str: """ Write the compose patch and a derived config for this device. @@ -241,12 +355,15 @@ def _prepare_device_run( patch appended to its patches list (using an absolute path so load_config can find it regardless of proj_dir). Returns the path to the derived config. + + All bookkeeping artifacts (patch, derived_config) are written to + device_meta_dir, which lives under `/.compose//`. """ - os.makedirs(device_out_dir, exist_ok=True) + os.makedirs(device_meta_dir, exist_ok=True) # Write the compose networking patch patch = _build_compose_patch(device, networks, compose_path) - patch_path = os.path.join(device_out_dir, "patch_compose_net.yaml") + patch_path = os.path.join(device_meta_dir, "patch_compose_net.yaml") with open(patch_path, "w") as f: yaml.dump(patch, f, default_flow_style=False) @@ -261,7 +378,7 @@ def _prepare_device_run( patches_list.append(patch_path) original["patches"] = patches_list - derived_path = os.path.join(device_out_dir, "derived_config.yaml") + derived_path = os.path.join(device_meta_dir, "derived_config.yaml") with open(derived_path, "w") as f: yaml.dump(original, f, default_flow_style=False) @@ -273,6 +390,46 @@ def _prepare_device_run( DEFAULT_VSOCK_CID_BASE = 16 +def _next_numbered_output_dir(base_dir: str) -> str: + """ + Allocate base_dir/ and update base_dir/latest -> ./. + + This mirrors `penguin run`'s default results layout: user-provided + --output paths are exact destinations, while default/configured compose + output bases get monotonically increasing numeric children. + """ + if os.path.exists(base_dir) and not os.path.isdir(base_dir): + raise RuntimeError(f"Output base exists and is not a directory: {base_dir}") + + os.makedirs(base_dir, exist_ok=True) + + def getint(name: str) -> int: + try: + return int(name) + except ValueError: + return -1 + + existing = [ + getint(name) + for name in os.listdir(base_dir) + if os.path.isdir(os.path.join(base_dir, name)) + ] + idx = max(existing) + 1 if existing else 0 + output_dir = os.path.join(base_dir, str(idx)) + os.makedirs(output_dir) + + latest_dir = os.path.join(base_dir, "latest") + if os.path.lexists(latest_dir): + if not os.path.islink(latest_dir): + raise RuntimeError( + f"Cannot update latest symlink because path exists and is not a symlink: {latest_dir}" + ) + os.unlink(latest_dir) + os.symlink(f"./{idx}", latest_dir) + + return output_dir + + def _env_int(name: str, default: int, min_value: int = 0) -> int: raw = os.environ.get(name) if raw is None: @@ -320,6 +477,7 @@ def _write_instance_yaml( device: DeviceConfig, networks: dict[str, NetworkSpec], device_out_dir: str, + device_meta_dir: str, endpoints: RuntimeEndpointSpec, runtime_metadata_path: str, ) -> None: @@ -337,7 +495,7 @@ def _write_instance_yaml( data = { "name": device.name, "project": device.proj_dir, - "output": os.path.join(device_out_dir, "output"), + "output": device_out_dir, "telnet_port": endpoints.telnet_port_base, "telnet_port_range": [ endpoints.telnet_port_base, @@ -348,7 +506,7 @@ def _write_instance_yaml( "networks": nets, "compose_pid": os.getpid(), } - with open(os.path.join(device_out_dir, "instance.yaml"), "w") as f: + with open(os.path.join(device_meta_dir, "instance.yaml"), "w") as f: yaml.dump(data, f, default_flow_style=False) @@ -358,21 +516,28 @@ def _run_device( networks: dict[str, NetworkSpec], compose_path: str, device_out_dir: str, + device_meta_dir: str, timeout: int | None, verbose: bool, ) -> dict: - """Prepare, run, and score a single device. Returns score dict.""" + """Prepare, run, and score a single device. Returns score dict. + + device_out_dir is PandaRunner's out_dir for this device (the flat + `//` directory). device_meta_dir is the bookkeeping + sibling at `/.compose//` where derived config, patches, + runtime.yaml, instance.yaml, qemu_stdout/err live. + """ from penguin.penguin_config import load_config from .manager import PandaRunner, calculate_score from .common import get_inits_from_proj + os.makedirs(device_meta_dir, exist_ok=True) + os.makedirs(device_out_dir, exist_ok=True) + derived_config_path = _prepare_device_run( - device, networks, compose_path, device_out_dir + device, networks, compose_path, device_meta_dir ) - out_dir = os.path.join(device_out_dir, "output") - os.makedirs(out_dir, exist_ok=True) - config = load_config(device.proj_dir, derived_config_path, verbose=verbose) specified_init = None @@ -387,16 +552,17 @@ def _run_device( ) endpoints = _runtime_endpoint_spec(idx) - runtime_metadata_path = os.path.join(device_out_dir, "runtime.yaml") + runtime_metadata_path = os.path.join(device_meta_dir, "runtime.yaml") _write_instance_yaml( - device, networks, device_out_dir, endpoints, runtime_metadata_path + device, networks, device_out_dir, device_meta_dir, + endpoints, runtime_metadata_path, ) try: PandaRunner().run( derived_config_path, device.proj_dir, - out_dir, + device_out_dir, init=specified_init, timeout=timeout, show_output=False, # each device writes to its own console.log @@ -407,6 +573,7 @@ def _run_device( "PENGUIN_TELNET_PORT_RANGE": endpoints.telnet_port_count, "PENGUIN_VSOCK_CID": endpoints.vsock_cid, "PENGUIN_RUNTIME_METADATA": runtime_metadata_path, + "PENGUIN_QEMU_LOG_DIR": device_meta_dir, }, ) except RuntimeError as e: @@ -414,7 +581,7 @@ def _run_device( return {} try: - return calculate_score(out_dir) + return calculate_score(device_out_dir) except Exception as e: logger.warning(f"Device '{device.name}' score calculation failed: {e}") return {} @@ -448,22 +615,23 @@ def run_compose( # Resolve output directory if output_dir is None: if cfg.output_base_dir: - output_dir = os.path.realpath( + output_base_dir = os.path.realpath( os.path.join(os.path.dirname(compose_path), cfg.output_base_dir) ) else: - output_dir = os.path.join(os.path.dirname(compose_path), "compose_results") - - if os.path.exists(output_dir): - if force: - shutil.rmtree(output_dir) - else: - raise RuntimeError( - f"Output directory already exists: {output_dir}. " - "Use --force to overwrite." - ) + output_base_dir = os.path.join(os.path.dirname(compose_path), "results") + output_dir = _next_numbered_output_dir(output_base_dir) + else: + if os.path.exists(output_dir): + if force: + shutil.rmtree(output_dir) + else: + raise RuntimeError( + f"Output directory already exists: {output_dir}. " + "Use --force to overwrite." + ) + os.makedirs(output_dir) - os.makedirs(output_dir) shutil.copy(compose_path, os.path.join(output_dir, "compose.yaml")) logger.info(f"Compose: starting {len(cfg.devices)} device(s)") @@ -475,6 +643,9 @@ def run_compose( results: dict[str, dict] = {} errors: dict[str, str] = {} + meta_base_dir = os.path.join(output_dir, ".compose") + os.makedirs(meta_base_dir, exist_ok=True) + with ThreadPoolExecutor(max_workers=len(cfg.devices)) as executor: futures = { executor.submit( @@ -484,6 +655,7 @@ def run_compose( cfg.networks, compose_path, os.path.join(output_dir, name), + os.path.join(meta_base_dir, name), timeout, verbose, ): name diff --git a/src/penguin/penguin_run.py b/src/penguin/penguin_run.py index fe1248a12..3a0a4499b 100755 --- a/src/penguin/penguin_run.py +++ b/src/penguin/penguin_run.py @@ -497,10 +497,14 @@ def run_config( # Disable audio (allegedly speeds up emulation by avoiding running another thread) os.environ["QEMU_AUDIO_DRV"] = "none" - # Setup PANDA or KVM. Do not let it print - parent_outdir = os.path.dirname(out_dir) - stdout_path = os.path.join(parent_outdir, "qemu_stdout.txt") - stderr_path = os.path.join(parent_outdir, "qemu_stderr.txt") + # Setup PANDA or KVM. Do not let it print. + # qemu stdout/stderr default to out_dir's parent. Compose runs override + # this via PENGUIN_QEMU_LOG_DIR so logs land in the per-device meta dir + # rather than alongside other devices' out_dirs. + log_dir = os.environ.get("PENGUIN_QEMU_LOG_DIR") or os.path.dirname(out_dir) + os.makedirs(log_dir, exist_ok=True) + stdout_path = os.path.join(log_dir, "qemu_stdout.txt") + stderr_path = os.path.join(log_dir, "qemu_stderr.txt") sys.path.append("/pyplugins") from compat.qemu_compat import KVMQemu diff --git a/src/penguin/utils_cli.py b/src/penguin/utils_cli.py index 3773e2d92..e2eccaa34 100644 --- a/src/penguin/utils_cli.py +++ b/src/penguin/utils_cli.py @@ -1,7 +1,7 @@ """ Implementation of `penguin utils `. -The compose commands inspect compose_results metadata and, when run inside the +The compose commands inspect compose results metadata and, when run inside the active compose container, /proc. They are intentionally filesystem based so the same command still gives useful connection details after a compose run exits. """ @@ -38,25 +38,90 @@ def _container_ip() -> str | None: return None +def _is_compose_run_dir(path: str) -> bool: + if not os.path.isdir(path) or not os.path.isfile(os.path.join(path, "compose.yaml")): + return False + if os.path.isfile(os.path.join(path, "compose_summary.yaml")): + return True + if os.path.isdir(os.path.join(path, ".compose")): + return True + try: + entries = os.listdir(path) + except OSError: + return False + return any( + os.path.isfile(os.path.join(path, entry, "derived_config.yaml")) + for entry in entries + ) + + +def _highest_numbered_run_dir(path: str) -> str | None: + runs = [] + for name in os.listdir(path): + run_dir = os.path.join(path, name) + if not os.path.isdir(run_dir): + continue + try: + idx = int(name) + except ValueError: + continue + if _is_compose_run_dir(run_dir): + runs.append((idx, run_dir)) + if not runs: + return None + return max(runs)[1] + + +def _resolve_compose_run_dir_candidate(path: str) -> str | None: + if not os.path.isdir(path): + return None + + for child in ("results", "compose_results"): + child_path = os.path.join(path, child) + resolved = _resolve_compose_results_base(child_path) + if resolved: + return resolved + + resolved = _resolve_compose_results_base(path) + if resolved: + return resolved + + if _is_compose_run_dir(path): + return os.path.realpath(path) + + return None + + +def _resolve_compose_results_base(path: str) -> str | None: + if not os.path.isdir(path): + return None + + latest = os.path.join(path, "latest") + if _is_compose_run_dir(latest): + return os.path.realpath(latest) + + numbered = _highest_numbered_run_dir(path) + if numbered: + return os.path.realpath(numbered) + + return None + + def _find_compose_results(start: str) -> str | None: - """Search cwd, the mapped workspace, and obvious parents for compose_results.""" + """Search cwd, the mapped workspace, and obvious parents for a compose run.""" candidates = [ - os.path.join(start, "compose_results"), - os.path.join(start, "../compose_results"), + start, + os.path.join(start, ".."), ] project_dir = os.environ.get("PENGUIN_PROJECT_DIR") if project_dir: - candidates.extend([ - os.path.join(project_dir, "compose_results"), - ]) - candidates.append(start) + candidates.append(project_dir) for candidate in candidates: path = os.path.realpath(candidate) - if not os.path.isdir(path) or not os.path.isfile(os.path.join(path, "compose.yaml")): - continue - if os.path.basename(path) == "compose_results" or os.path.isfile(os.path.join(path, "compose_summary.yaml")): - return path + resolved = _resolve_compose_run_dir_candidate(path) + if resolved: + return resolved return None @@ -162,19 +227,43 @@ def _device_status(output_dir: str, live_procs: list[dict]) -> tuple[str, int | return "failed", None, [] -def list_instances(compose_dir: str) -> list[dict]: - """Inventory devices under a compose_results directory.""" - live = _scan_qemu_processes() - devices = [] +def _compose_device_dirs(compose_dir: str) -> list[tuple[str, str]]: + """Return (metadata dir, output dir) pairs for old and current layouts.""" + meta_root = os.path.join(compose_dir, ".compose") + if os.path.isdir(meta_root): + pairs = [] + for entry in sorted(os.listdir(meta_root)): + meta_dir = os.path.join(meta_root, entry) + if not os.path.isdir(meta_dir): + continue + if not ( + os.path.isfile(os.path.join(meta_dir, "derived_config.yaml")) + or os.path.isfile(os.path.join(meta_dir, "instance.yaml")) + ): + continue + pairs.append((meta_dir, os.path.join(compose_dir, entry))) + return pairs + + pairs = [] for entry in sorted(os.listdir(compose_dir)): device_dir = os.path.join(compose_dir, entry) if not os.path.isdir(device_dir): continue if not os.path.isfile(os.path.join(device_dir, "derived_config.yaml")): continue + pairs.append((device_dir, os.path.join(device_dir, "output"))) + return pairs - info = _merge_runtime(_load_instance(device_dir), device_dir) - output_dir = info.get("output") or os.path.join(device_dir, "output") + +def list_instances(compose_dir: str) -> list[dict]: + """Inventory devices under a compose results run directory.""" + live = _scan_qemu_processes() + devices = [] + for meta_dir, fallback_output_dir in _compose_device_dirs(compose_dir): + info = _merge_runtime(_load_instance(meta_dir), meta_dir) + output_dir = info.get("output") or fallback_output_dir + if not os.path.exists(output_dir) and os.path.exists(fallback_output_dir): + output_dir = fallback_output_dir status, pid, procs = _device_status(output_dir, live) qemu = next((p for p in procs if p["kind"] == "qemu"), None) @@ -191,10 +280,13 @@ def list_instances(compose_dir: str) -> list[dict]: def _resolve_compose_dir(compose_dir: str | None) -> str: if compose_dir is not None: + resolved = _resolve_compose_run_dir_candidate(os.path.realpath(compose_dir)) + if resolved: + return resolved return os.path.realpath(compose_dir) found = _find_compose_results(os.getcwd()) if found is None: - raise click.ClickException("No compose_results/ found under cwd. Pass --dir.") + raise click.ClickException("No compose results run found under cwd. Pass --dir.") return found @@ -234,7 +326,7 @@ def utils(): @click.option( "--dir", "compose_dir", type=click.Path(), default=None, - help="Path to compose_results/ (default: search cwd).", + help="Path to results/, compose_results/, a numbered run, or latest (default: search cwd).", ) def list_cmd(compose_dir): """List compose devices with PID, shell port, vsock CID, networks, and status.""" @@ -294,7 +386,7 @@ def list_cmd(compose_dir): @click.option( "--dir", "compose_dir", type=click.Path(), default=None, - help="Path to compose_results/ (default: search cwd).", + help="Path to results/, compose_results/, a numbered run, or latest (default: search cwd).", ) @click.argument("device_name") @click.argument("command", nargs=-1, type=click.UNPROCESSED) diff --git a/tests/unit_tests/compose/test.py b/tests/unit_tests/compose/test.py new file mode 100644 index 000000000..afaf8ccdd --- /dev/null +++ b/tests/unit_tests/compose/test.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +End-to-end smoke test for `penguin compose`. + +Stands up two minimal guests sharing one `lan` compose network. The server runs +busybox httpd serving a static marker; the client wgets it and writes the body +into its core.shared_dir. The test asserts the marker landed. + +Designed to be runnable from CI without arch matrix expansion -- compose's +plumbing is arch-independent, so one arch is enough signal. + +Invokes `penguin compose init` followed by `penguin compose run` — per-device +init scripts are baked into each project's config via a patch.yaml file, and +the compose.yaml itself is generated by penguin. +""" +import logging +import shutil +import subprocess +from pathlib import Path + +import click +import yaml + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(name)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("penguin.tests.compose") + +TEST_DIR = Path(__file__).resolve().parent +REPO_ROOT = TEST_DIR.parents[2] +PENGUIN = REPO_ROOT / "penguin" +FS_DIR = TEST_DIR / "fs" +EMPTY_FS = TEST_DIR / "empty_fs.tar.gz" +PROJECTS = TEST_DIR / "projects" +COMPOSE_PROJECTS = TEST_DIR / "compose_projects" + +MARKER = "penguin-compose-ok" + +# Basic-target FS has no init binary; supply a minimal one that just waits so +# the guest stays alive while our init.d/ probe scripts do their work. +SLEEPER_INIT = """#!/igloo/utils/sh +/busybox sleep 600 +""" + +SERVER_INIT = f"""#!/igloo/utils/sh +# Smoke-test server: serve a one-line marker on tcp/4242. +export PATH="/igloo/utils:/igloo/boot:$PATH" +/busybox mkdir -p /tmp/www /igloo/shared +echo "{MARKER}" > /tmp/www/marker +/busybox ip link set eth0 up +/busybox ip addr add 192.168.1.1/24 dev eth0 +echo "[compose-smoke srv] starting httpd" > /dev/console +( + while :; do + /busybox httpd -p 4242 -f -h /tmp/www > /dev/null 2>&1 + /busybox sleep 1 + done +) & +echo "started" > /igloo/shared/server_started +""" + +CLIENT_INIT = """#!/igloo/utils/sh +# Smoke-test client: fetch the marker from the server. +export PATH="/igloo/utils:/igloo/boot:$PATH" +/busybox mkdir -p /igloo/shared +/busybox ip link set eth0 up +/busybox ip addr add 192.168.1.100/24 dev eth0 +echo "[compose-smoke cli] starting probe" > /dev/console +( + for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do + /busybox sleep 5 + body=$(/busybox wget -q -T 5 -O - http://192.168.1.1:4242/marker 2>/dev/null) + rc=$? + echo "[compose-smoke cli] try $i rc=$rc body=$body" > /dev/console + if [ "$rc" = "0" ] && [ -n "$body" ]; then + echo "$body" > /igloo/shared/compose_ok + echo "[compose-smoke cli] SUCCESS try=$i" > /dev/console + break + fi + done +) & +""" + + +def run(cmd, **kw): + logger.info(f"$ {cmd}") + subprocess.run(cmd, shell=True, check=True, **kw) + + +def stage_busybox(image: str, arch: str) -> None: + """Pull busybox. out of the penguin image into FS_DIR/busybox.""" + if FS_DIR.exists(): + shutil.rmtree(FS_DIR) + FS_DIR.mkdir() + (FS_DIR / "bin").mkdir() + cid = subprocess.check_output( + f"docker create {image}", shell=True + ).decode().strip() + try: + run(f"docker cp -L {cid}:/igloo_static/utils.bin/busybox.{arch} {FS_DIR}/busybox") + finally: + run(f"docker rm -v {cid}") + run(f"tar -czf {EMPTY_FS} -C {FS_DIR} .") + + +def penguin_init(image: str) -> Path: + """Bootstrap an empty project from EMPTY_FS; returns the project dir.""" + if PROJECTS.exists(): + shutil.rmtree(PROJECTS) + run( + f"{PENGUIN} --image {image} init {EMPTY_FS} --force", + cwd=TEST_DIR, + ) + return PROJECTS / "empty_fs" + + +def clone_project(src: Path, dst: Path) -> None: + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + + +def pin_kernel(project: Path, kernel: str) -> None: + cfg_path = project / "config.yaml" + with open(cfg_path) as f: + cfg = yaml.safe_load(f) + cfg["core"]["kernel"] = kernel + with open(cfg_path, "w") as f: + yaml.safe_dump(cfg, f, sort_keys=False) + + +def write_project_patch(project: Path, init_script: str) -> None: + """Drop a patch.yaml into the project that wires up /init.sh + init.d probe.""" + patch = { + "core": {"shared_dir": "./shared"}, + "env": {"igloo_init": "/init.sh"}, + "static_files": { + "/init.sh": { + "type": "inline_file", + "mode": 0o755, + "contents": SLEEPER_INIT, + }, + f"/igloo/init.d/zzz_smoke_{project.name}": { + "type": "inline_file", + "mode": 0o755, + "contents": init_script, + }, + }, + } + with open(project / "patch_smoke.yaml", "w") as f: + yaml.safe_dump(patch, f, sort_keys=False) + + +def latest_scaffold_dir() -> Path: + """Resolve the newest auto-scaffolded compose_projects// dir.""" + if not COMPOSE_PROJECTS.exists(): + raise AssertionError(f"compose_projects/ not created at {COMPOSE_PROJECTS}") + candidates = [p for p in COMPOSE_PROJECTS.iterdir() if p.is_dir()] + if not candidates: + raise AssertionError(f"no scaffold dirs under {COMPOSE_PROJECTS}") + return max(candidates, key=lambda p: p.stat().st_mtime) + + +def dump_logs_on_failure(out_root: Path) -> None: + for dev in ("server", "client"): + clog = out_root / dev / "console.log" + logger.error(f"--- {dev} console.log tail ---") + if clog.exists(): + for line in clog.read_text(errors="replace").splitlines()[-100:]: + logger.error(line) + else: + logger.error("(missing)") + + +def assert_marker(out_root: Path) -> None: + marker = out_root / "client" / "shared" / "compose_ok" + if not marker.exists(): + dump_logs_on_failure(out_root) + raise AssertionError(f"compose marker missing: {marker}") + body = marker.read_text().strip() + if MARKER not in body: + dump_logs_on_failure(out_root) + raise AssertionError(f"marker has unexpected contents: {body!r}") + logger.info(f"OK: client received {body!r} from server over compose network") + + +@click.command() +@click.option("--arch", default="armel", + help="Guest arch for both devices (compose's plumbing is arch-independent).") +@click.option("--image", default="rehosting/penguin:latest") +@click.option("--kernel", default="4.10") +@click.option("--timeout", default=180, type=int, + help="Per-device emulation timeout, seconds.") +def main(arch: str, image: str, kernel: str, timeout: int) -> None: + if COMPOSE_PROJECTS.exists(): + shutil.rmtree(COMPOSE_PROJECTS) + + stage_busybox(image, arch) + base_proj = penguin_init(image) + + server_proj = PROJECTS / "server" + client_proj = PROJECTS / "client" + clone_project(base_proj, server_proj) + clone_project(base_proj, client_proj) + pin_kernel(server_proj, kernel) + pin_kernel(client_proj, kernel) + write_project_patch(server_proj, SERVER_INIT) + write_project_patch(client_proj, CLIENT_INIT) + + run(f"{PENGUIN} --image {image} compose init {server_proj} {client_proj}", + cwd=TEST_DIR) + scaffold_dir = latest_scaffold_dir() + logger.info(f"Resolved scaffold dir: {scaffold_dir}") + run( + f"{PENGUIN} --image {image} compose run {scaffold_dir} --timeout {timeout}", + cwd=TEST_DIR, + ) + assert_marker(scaffold_dir / "results" / "latest") + + +if __name__ == "__main__": + main() diff --git a/tests/unit_tests/test_compose.py b/tests/unit_tests/test_compose.py index f74a716eb..7bbd02abe 100644 --- a/tests/unit_tests/test_compose.py +++ b/tests/unit_tests/test_compose.py @@ -19,9 +19,13 @@ _build_compose_patch, _deep_merge, _generate_mac, + _prepare_device_run, _runtime_endpoint_spec, load_compose, + run_compose, + scaffold_compose, ) +from penguin.utils_cli import _resolve_compose_dir, list_instances MINIMAL_COMPOSE = """\ @@ -359,5 +363,425 @@ def test_relative_project_path(self): self.assertEqual(cfg.devices["router"].proj_dir, self.router_dir) +class TestComposeOutputDirs(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.router_dir = _make_project_dir(self.tmpdir, "router") + self.client_dir = _make_project_dir(self.tmpdir, "client") + self.compose_path = os.path.join(self.tmpdir, "compose.yaml") + + def tearDown(self): + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _write_compose(self, extra=""): + with open(self.compose_path, "w") as f: + f.write(MINIMAL_COMPOSE.format( + router_dir=self.router_dir, + client_dir=self.client_dir, + )) + if extra: + f.write(extra) + + def _run_compose(self, output=None, force=False): + with patch("penguin.compose._run_device", return_value={"nopanic": 1}): + run_compose( + self.compose_path, + output, + timeout=1, + force=force, + verbose=False, + ) + + def test_default_output_rotates_and_updates_latest(self): + self._write_compose() + + self._run_compose() + base = os.path.join(self.tmpdir, "results") + self.assertTrue(os.path.isdir(os.path.join(base, "0"))) + self.assertTrue(os.path.isfile(os.path.join(base, "0", "compose_summary.yaml"))) + self.assertTrue(os.path.islink(os.path.join(base, "latest"))) + self.assertEqual(os.readlink(os.path.join(base, "latest")), "./0") + + self._run_compose() + self.assertTrue(os.path.isdir(os.path.join(base, "1"))) + self.assertTrue(os.path.isfile(os.path.join(base, "1", "compose_summary.yaml"))) + self.assertEqual(os.readlink(os.path.join(base, "latest")), "./1") + + def test_configured_output_base_rotates(self): + self._write_compose("""\ + +output: + base_dir: ./custom_compose_results +""") + + self._run_compose() + base = os.path.join(self.tmpdir, "custom_compose_results") + self.assertTrue(os.path.isdir(os.path.join(base, "0"))) + self.assertEqual(os.readlink(os.path.join(base, "latest")), "./0") + + def test_explicit_output_is_exact_path(self): + self._write_compose() + output = os.path.join(self.tmpdir, "my_results") + + self._run_compose(output=output) + + self.assertTrue(os.path.isfile(os.path.join(output, "compose_summary.yaml"))) + self.assertFalse(os.path.exists(os.path.join(output, "0"))) + + def test_explicit_output_requires_force_when_existing(self): + self._write_compose() + output = os.path.join(self.tmpdir, "my_results") + os.makedirs(output) + marker = os.path.join(output, "marker") + with open(marker, "w") as f: + f.write("old") + + with self.assertRaises(RuntimeError): + self._run_compose(output=output) + + self._run_compose(output=output, force=True) + self.assertTrue(os.path.isfile(os.path.join(output, "compose_summary.yaml"))) + self.assertFalse(os.path.exists(marker)) + + def test_utils_resolve_base_dir_uses_latest(self): + self._write_compose() + self._run_compose() + self._run_compose() + + base = os.path.join(self.tmpdir, "results") + + self.assertEqual( + _resolve_compose_dir(base), + os.path.realpath(os.path.join(base, "1")), + ) + + def test_utils_resolve_project_root_uses_results_latest(self): + self._write_compose() + self._run_compose() + self._run_compose() + + with patch("penguin.utils_cli.os.getcwd", return_value=self.tmpdir): + self.assertEqual( + _resolve_compose_dir(None), + os.path.realpath(os.path.join(self.tmpdir, "results", "1")), + ) + + +class TestComposeCli(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.router_dir = _make_project_dir(self.tmpdir, "router") + self.client_dir = _make_project_dir(self.tmpdir, "client") + + def tearDown(self): + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_single_project_dir_is_rejected(self): + from click.testing import CliRunner + from penguin.__main__ import cli + + with patch("penguin.__main__._startup_checks"), \ + patch("penguin.__main__.scaffold_compose") as scaffold, \ + patch("penguin.__main__.run_compose") as run: + result = CliRunner().invoke(cli, ["compose", self.router_dir]) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn("single-device project", result.output) + self.assertIn("penguin run", result.output) + scaffold.assert_not_called() + run.assert_not_called() + + def test_compose_init_scaffolds_without_running(self): + from click.testing import CliRunner + from penguin.__main__ import cli + + compose_path = os.path.join(self.tmpdir, "compose.yaml") + with patch("penguin.__main__._startup_checks"), \ + patch("penguin.__main__.scaffold_compose", return_value=compose_path) as scaffold, \ + patch("penguin.__main__.run_compose") as run: + result = CliRunner().invoke( + cli, ["compose", "init", self.router_dir, self.client_dir] + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn(compose_path, result.output) + scaffold.assert_called_once_with( + [self.router_dir, self.client_dir], name=None + ) + run.assert_not_called() + + def test_compose_init_requires_two_projects(self): + from click.testing import CliRunner + from penguin.__main__ import cli + + with patch("penguin.__main__._startup_checks"), \ + patch("penguin.__main__.scaffold_compose") as scaffold, \ + patch("penguin.__main__.run_compose") as run: + result = CliRunner().invoke( + cli, ["compose", "init", self.router_dir] + ) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn("two-or-more project directories", result.output) + scaffold.assert_not_called() + run.assert_not_called() + + def test_compose_run_uses_existing_compose_project(self): + from click.testing import CliRunner + from penguin.__main__ import cli + + compose_project = os.path.join(self.tmpdir, "compose_project") + os.makedirs(compose_project) + compose_path = os.path.join(compose_project, "compose.yaml") + with open(compose_path, "w") as f: + f.write("version: 1\n") + + output = os.path.join(self.tmpdir, "out") + with patch("penguin.__main__._startup_checks"), \ + patch("penguin.__main__.scaffold_compose") as scaffold, \ + patch("penguin.__main__.run_compose") as run: + result = CliRunner().invoke( + cli, + [ + "compose", "run", compose_project, + "--timeout", "10", "--output", output, "--force", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + scaffold.assert_not_called() + run.assert_called_once_with( + compose_path, output, timeout=10, force=True, verbose=False + ) + + def test_shortcut_scaffolds_and_runs_project_dirs(self): + from click.testing import CliRunner + from penguin.__main__ import cli + + compose_path = os.path.join(self.tmpdir, "compose.yaml") + with patch("penguin.__main__._startup_checks"), \ + patch("penguin.__main__.scaffold_compose", return_value=compose_path) as scaffold, \ + patch("penguin.__main__.run_compose") as run: + result = CliRunner().invoke( + cli, ["compose", self.router_dir, self.client_dir, "--timeout", "10"] + ) + + self.assertEqual(result.exit_code, 0, result.output) + scaffold.assert_called_once_with([self.router_dir, self.client_dir]) + run.assert_called_once_with( + compose_path, None, timeout=10, force=False, verbose=False + ) + + +class TestScaffoldCompose(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + # Layout: tmpdir/projects// — scaffold base = tmpdir + self.projects_dir = os.path.join(self.tmpdir, "projects") + os.makedirs(self.projects_dir) + self.router_dir = _make_project_dir(self.projects_dir, "router") + self.client_dir = _make_project_dir(self.projects_dir, "client") + + def tearDown(self): + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_happy_path(self): + compose_path = scaffold_compose([self.router_dir, self.client_dir]) + self.assertTrue(os.path.isabs(compose_path)) + self.assertTrue(os.path.isfile(compose_path)) + + scaffold_dir = os.path.dirname(compose_path) + # Default name is the device basenames joined with '_'. + self.assertEqual( + scaffold_dir, + os.path.join(self.tmpdir, "compose_projects", "router_client"), + ) + + import yaml as _yaml + with open(compose_path) as f: + data = _yaml.safe_load(f) + + self.assertEqual(data["version"], 1) + self.assertIn("lan", data["networks"]) + self.assertEqual(data["networks"]["lan"]["subnet"], "192.168.1.0/24") + self.assertEqual(set(data["devices"].keys()), {"router", "client"}) + + router = data["devices"]["router"] + client = data["devices"]["client"] + self.assertEqual(router["networks"]["lan"]["ip"], "192.168.1.1/24") + self.assertEqual(router["networks"]["lan"]["iface"], "eth0") + self.assertEqual(client["networks"]["lan"]["ip"], "192.168.1.2/24") + self.assertEqual(client["networks"]["lan"]["iface"], "eth0") + + # Project paths should be relative + self.assertFalse(os.path.isabs(router["project"])) + self.assertFalse(os.path.isabs(client["project"])) + # And resolve back to the real project dirs + self.assertEqual( + os.path.realpath(os.path.join(scaffold_dir, router["project"])), + self.router_dir, + ) + self.assertEqual( + os.path.realpath(os.path.join(scaffold_dir, client["project"])), + self.client_dir, + ) + + def test_custom_name(self): + compose_path = scaffold_compose( + [self.router_dir, self.client_dir], name="my_setup" + ) + self.assertEqual( + os.path.dirname(compose_path), + os.path.join(self.tmpdir, "compose_projects", "my_setup"), + ) + + def test_rejects_invalid_name(self): + for bad in ("", ".", "..", "../etc", "has space", "with/slash", "back\\slash"): + with self.assertRaises(ValueError, msg=f"should reject {bad!r}"): + scaffold_compose([self.router_dir, self.client_dir], name=bad) + + def test_rejects_too_long_name(self): + from penguin.compose import MAX_COMPOSE_NAME_LEN + # exactly at the limit succeeds; one over fails. + ok_name = "a" * MAX_COMPOSE_NAME_LEN + compose_path = scaffold_compose( + [self.router_dir, self.client_dir], name=ok_name + ) + self.assertEqual(os.path.basename(os.path.dirname(compose_path)), ok_name) + + too_long = "a" * (MAX_COMPOSE_NAME_LEN + 1) + with self.assertRaises(ValueError) as cm: + scaffold_compose([self.router_dir, self.client_dir], name=too_long) + self.assertIn(str(MAX_COMPOSE_NAME_LEN), str(cm.exception)) + + def test_rejects_too_long_default_name(self): + from penguin.compose import MAX_COMPOSE_NAME_LEN + # Build enough projects that the joined default name exceeds the cap. + # Each basename is "longproj_NN" plus underscore separators. + many = [] + for i in range(20): + many.append(_make_project_dir(self.projects_dir, f"longproj_{i:02d}")) + with self.assertRaises(ValueError) as cm: + scaffold_compose(many) + msg = str(cm.exception) + # Hint that --name is the way out, and surface the cap. + self.assertIn("--name", msg) + self.assertIn(str(MAX_COMPOSE_NAME_LEN), msg) + + def test_refuses_existing_scaffold_dir(self): + os.makedirs(os.path.join(self.tmpdir, "compose_projects", "router_client")) + with self.assertRaises(RuntimeError): + scaffold_compose([self.router_dir, self.client_dir]) + + def test_duplicate_basenames_error(self): + # Two different project paths with the same basename "router" + other = os.path.join(self.tmpdir, "elsewhere") + os.makedirs(other) + dup = _make_project_dir(other, "router") + with self.assertRaises(ValueError): + scaffold_compose([self.router_dir, dup]) + + def test_missing_config_yaml_errors(self): + bare = os.path.join(self.tmpdir, "bare_proj") + os.makedirs(bare) + with self.assertRaises(ValueError): + scaffold_compose([self.router_dir, bare]) + + +class TestFlattenedDeviceLayout(unittest.TestCase): + """Compose bookkeeping must land under .compose//, not /.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.proj_dir = _make_project_dir(self.tmpdir, "router") + + def tearDown(self): + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_prepare_device_run_writes_into_meta_dir(self): + device = DeviceConfig( + name="router", + proj_dir=self.proj_dir, + config_path=os.path.join(self.proj_dir, "config.yaml"), + networks=[DeviceNetAttachment( + network_name="lan", + iface="eth0", + ip="192.168.1.1/24", + mac="52:54:00:aa:01:01", + )], + ) + networks = {"lan": NetworkSpec(name="lan", port=MCAST_BASE_PORT)} + + run_dir = os.path.join(self.tmpdir, "run0") + device_meta_dir = os.path.join(run_dir, ".compose", "router") + device_out_dir = os.path.join(run_dir, "router") + os.makedirs(device_out_dir) + + derived = _prepare_device_run( + device, networks, "/compose.yaml", device_meta_dir, + ) + + # Bookkeeping must be in .compose/router/, not in router/ + self.assertTrue(os.path.isfile( + os.path.join(device_meta_dir, "patch_compose_net.yaml") + )) + self.assertTrue(os.path.isfile( + os.path.join(device_meta_dir, "derived_config.yaml") + )) + self.assertEqual( + derived, os.path.join(device_meta_dir, "derived_config.yaml"), + ) + # Device out dir stays clean of compose metadata + self.assertFalse(os.path.exists( + os.path.join(device_out_dir, "patch_compose_net.yaml") + )) + self.assertFalse(os.path.exists( + os.path.join(device_out_dir, "derived_config.yaml") + )) + + def test_utils_list_instances_reads_flattened_meta_dir(self): + import yaml as _yaml + + run_dir = os.path.join(self.tmpdir, "results", "0") + device_meta_dir = os.path.join(run_dir, ".compose", "router") + device_out_dir = os.path.join(run_dir, "router") + os.makedirs(device_meta_dir) + os.makedirs(device_out_dir) + with open(os.path.join(run_dir, "compose.yaml"), "w") as f: + f.write("version: 1\n") + with open(os.path.join(run_dir, "compose_summary.yaml"), "w") as f: + f.write("devices: {}\n") + with open(os.path.join(device_out_dir, ".ran"), "w") as f: + f.write("") + with open(os.path.join(device_meta_dir, "derived_config.yaml"), "w") as f: + _yaml.safe_dump({"core": {"extra_qemu_args": ""}}, f) + with open(os.path.join(device_meta_dir, "instance.yaml"), "w") as f: + _yaml.safe_dump({ + "name": "router", + "output": "/stale/container/path/router", + "telnet_port": 20000, + "vsock_cid": 16, + "networks": [{ + "name": "lan", + "iface": "eth0", + "ip": "192.168.1.1/24", + "mcast": "230.0.0.1:11000", + }], + }, f) + + with patch("penguin.utils_cli._scan_qemu_processes", return_value=[]): + devices = list_instances(run_dir) + + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0]["name"], "router") + self.assertEqual(devices[0]["status"], "ok") + self.assertEqual(devices[0]["output"], device_out_dir) + self.assertEqual(devices[0]["telnet_port"], 20000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit_tests/test_penguin_run_ports.py b/tests/unit_tests/test_penguin_run_ports.py new file mode 100644 index 000000000..8a4a7aeb8 --- /dev/null +++ b/tests/unit_tests/test_penguin_run_ports.py @@ -0,0 +1,48 @@ +import os +import socket +import unittest +from contextlib import closing +from unittest.mock import patch + +from penguin.penguin_run import find_free_port + + +def _reserve_port_with_free_successor(): + for base in range(30000, 65000): + reserved = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + reserved.bind(("0.0.0.0", base)) + except OSError: + reserved.close() + continue + + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as probe: + try: + probe.bind(("0.0.0.0", base + 1)) + except OSError: + reserved.close() + continue + return base, reserved + + raise RuntimeError("could not find a two-port free block for test") + + +class TestFindFreePort(unittest.TestCase): + def test_compose_range_searches_within_reserved_block(self): + base, reserved = _reserve_port_with_free_successor() + self.addCleanup(reserved.close) + env = { + "PENGUIN_TELNET_PORT_BASE": str(base), + "PENGUIN_TELNET_PORT_RANGE": "2", + } + with patch.dict(os.environ, env, clear=False): + self.assertEqual(find_free_port(), base + 1) + + def test_invalid_env_fails_clearly(self): + with patch.dict(os.environ, {"PENGUIN_TELNET_PORT_BASE": "bad"}, clear=False): + with self.assertRaisesRegex(ValueError, "PENGUIN_TELNET_PORT_BASE"): + find_free_port() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/test_target/patches/tests/core_pattern_guard.yaml b/tests/unit_tests/test_target/patches/tests/core_pattern_guard.yaml new file mode 100644 index 000000000..c5ac185f9 --- /dev/null +++ b/tests/unit_tests/test_target/patches/tests/core_pattern_guard.yaml @@ -0,0 +1,36 @@ +plugins: + core_pattern_guard: {} + verifier: + conditions: + core_pattern_guard: + type: file_contains + file: console.log + string: "/tests/core_pattern_guard.sh PASS" + +static_files: + /tests/core_pattern_guard.sh: + type: inline_file + contents: | + #!/igloo/utils/sh + set -eux + bb=/igloo/utils/busybox + + expected='/igloo/shared/core_dumps/core_%e.%p' + + initial=$($bb cat /proc/sys/kernel/core_pattern) + if [ "$initial" != "$expected" ]; then + echo "Error: initial core_pattern is '$initial', expected '$expected'" + exit 1 + fi + + # Guest attempts to redirect core dumps -- the guard must eat the write. + $bb echo '/tmp/pwned_%e.%p' > /proc/sys/kernel/core_pattern + + after=$($bb cat /proc/sys/kernel/core_pattern) + if [ "$after" != "$expected" ]; then + echo "Error: core_pattern was overwritten to '$after'" + exit 1 + fi + + exit 0 + mode: 73