From e9105abc37c0f06deac8c6c29f2380ea6c334459 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:29:51 +0000 Subject: [PATCH] Renode test: check the network - DHCP, ping both ways, telnet New step 6 of run_renode_tests.sh. Renode connects the board's Ethernet (DMBOOT_RENODE_ETHERNET, sysbus.ethernet on stm32f746g-disco) through a switch to a TAP interface on the host (new DMBOOT_RENODE_TAP option), and scripts/test_renode_network.py - standard library only, no ip/ping/telnet tools needed - then: - gives the host end of the TAP 192.168.100.1/24 and serves DHCP on it - waits (via the console UART) for eth0 to lease 192.168.100.2 - pings the host from the board (shell `ping`) - pings the board from the host (ICMP echo) - logs in over telnet and runs `echo ` The board's flash.dmd gets telnetd (TCP port 23, one console@/dmell per connection) for the latter. Opening a TAP needs root and /dev/net/tun: the step is skipped without them, unless DMBOOT_TEST_NETWORK=1 makes it mandatory. CI runs the Renode job with --cap-add=NET_ADMIN --device=/dev/net/tun and sets it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JWVYTSBnZLbpDqp7X1sknv --- .github/workflows/build.yml | 5 + configs/board/stm32f746g-disco/board.cmake | 4 + configs/board/stm32f746g-disco/flash.dmd | 5 + configs/renode/renode.resc.in | 2 + docs/cmake-options.md | 1 + scripts/run_renode_tests.sh | 71 +++++-- scripts/targets.cmake | 10 + scripts/test_renode_network.py | 230 +++++++++++++++++++++ 8 files changed, 312 insertions(+), 16 deletions(-) create mode 100755 scripts/test_renode_network.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 40689c2..069afa5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -144,6 +144,8 @@ jobs: runs-on: ubuntu-latest container: image: chocotechnologies/dmboot:1.0.0 + # The network test connects the emulated Ethernet to a TAP interface + options: --cap-add=NET_ADMIN --device=/dev/net/tun permissions: contents: read @@ -166,6 +168,9 @@ jobs: git submodule update --recursive - name: Run Renode emulation tests + env: + # Fail instead of skipping the network test if the TAP can't be opened + DMBOOT_TEST_NETWORK: "1" run: | ./scripts/run_renode_tests.sh diff --git a/configs/board/stm32f746g-disco/board.cmake b/configs/board/stm32f746g-disco/board.cmake index 1dcf9a5..1452f14 100644 --- a/configs/board/stm32f746g-disco/board.cmake +++ b/configs/board/stm32f746g-disco/board.cmake @@ -6,3 +6,7 @@ set(TARGET "STM32F746xG" CACHE STRING "Target microcontroller" FORCE) # virtual COM port) runs on, as named in the Renode platform - emulation mode # exposes it as a TCP terminal (see DMBOOT_RENODE_UART_PORT) set(DMBOOT_RENODE_CONSOLE_UART "sysbus.usart1" CACHE STRING "Renode peripheral of the console UART") + +# Ethernet controller (eth0) as named in the Renode platform - emulation mode +# can connect it to a TAP interface on the host (see DMBOOT_RENODE_TAP) +set(DMBOOT_RENODE_ETHERNET "sysbus.ethernet" CACHE STRING "Renode peripheral of the Ethernet controller") diff --git a/configs/board/stm32f746g-disco/flash.dmd b/configs/board/stm32f746g-disco/flash.dmd index 0773ffd..7a8da62 100644 --- a/configs/board/stm32f746g-disco/flash.dmd +++ b/configs/board/stm32f746g-disco/flash.dmd @@ -31,6 +31,11 @@ dhcpc service=dhcp@.ini dhcpc rules=dhcp.rules test_dmeth # +# Telnet: telnetd (dmtcp + dmtelnet) listens on TCP port 23; every connection +# becomes a tty node with its own console@ / dmell, like a UART does. +# +telnetd driver=telnetd.ini +# # SD card: on-board microSD slot (CN3) on SDMMC1. dmdevfs creates the host # node /dev/dmsdio0, the dmdevmon service (common flash.dmd) monitors the # card detect pin and announces /dev/dmsdio0/0 when a card is inserted. diff --git a/configs/renode/renode.resc.in b/configs/renode/renode.resc.in index 469d5f8..661a15e 100644 --- a/configs/renode/renode.resc.in +++ b/configs/renode/renode.resc.in @@ -8,4 +8,6 @@ machine StartGdbServer 3333 @DMBOOT_RENODE_UART_TERMINAL@ +@DMBOOT_RENODE_NETWORK@ + @DMBOOT_RENODE_START@ \ No newline at end of file diff --git a/docs/cmake-options.md b/docs/cmake-options.md index 1a0ade0..a6d840c 100644 --- a/docs/cmake-options.md +++ b/docs/cmake-options.md @@ -21,6 +21,7 @@ These options are defined in the top-level `CMakeLists.txt` and control the over | `DMBOOT_EMULATION` | BOOL | `OFF` | Enable Renode emulation mode instead of hardware mode (OpenOCD). | | `DMBOOT_RENODE_WAIT_FOR_GDB` | BOOL | `OFF` | In emulation mode, keep the Renode machine paused until a GDB client (e.g. `monitor-gdb`) continues it, so no log output is produced before the monitor attaches. | | `DMBOOT_RENODE_UART_PORT` | STRING | `3456` | In emulation mode, TCP port of the raw Renode terminal attached to the board's console UART (`DMBOOT_RENODE_CONSOLE_UART`, set by the board config, e.g. `sysbus.usart1` for `stm32f746g-disco`). Connect to it (e.g. `nc localhost 3456`) to use the shell on that UART. Empty disables it. | +| `DMBOOT_RENODE_TAP` | STRING | *(empty)* | In emulation mode, host TAP interface (e.g. `tap0`) that the board's Ethernet controller (`DMBOOT_RENODE_ETHERNET`, set by the board config, e.g. `sysbus.ethernet` for `stm32f746g-disco`) is connected to through a Renode switch, so the host can reach the board over IP. Needs root and `/dev/net/tun`; empty leaves the Ethernet unconnected. | ## VFS Options diff --git a/scripts/run_renode_tests.sh b/scripts/run_renode_tests.sh index 11028a8..bf3aa1c 100755 --- a/scripts/run_renode_tests.sh +++ b/scripts/run_renode_tests.sh @@ -3,8 +3,13 @@ # # This script reproduces the Renode CI tests locally. # It builds the firmware with emulation mode, starts Renode, runs monitor-gdb -# to capture firmware logs, verifies the expected log messages, and then -# checks that the shell answers on the console UART. +# to capture firmware logs, verifies the expected log messages, checks that +# the shell answers on the console UART and - when it can open a TAP +# interface - checks the network (DHCP, ping both ways, telnet). +# +# The network step needs root and /dev/net/tun (in Docker: +# --cap-add=NET_ADMIN --device=/dev/net/tun). Without them it is skipped, +# unless DMBOOT_TEST_NETWORK=1 makes it mandatory (as CI does). # # Usage: # ./scripts/run_renode_tests.sh [SOURCE_DIR [BUILD_DIR]] @@ -25,13 +30,29 @@ EXPECTED_LOGS="$SOURCE_DIR/configs/renode/expected_logs.txt" VERIFY_SCRIPT="$SOURCE_DIR/scripts/verify_renode_logs.sh" UART_TEST_SCRIPT="$SOURCE_DIR/scripts/test_renode_uart.py" UART_PORT=3456 +NETWORK_TEST_SCRIPT="$SOURCE_DIR/scripts/test_renode_network.py" +TAP_INTERFACE=tap0 # Timeouts (seconds) # Upper bound only: step 4 stops as soon as every expected line is captured MONITOR_TIMEOUT=300 UART_TIMEOUT=90 -# Renode has to outlive both the monitor and the UART test -CONNECT_TIMEOUT=$((MONITOR_TIMEOUT + UART_TIMEOUT + 60)) +NETWORK_TIMEOUT=120 +# Renode has to outlive the monitor and the UART and network tests +CONNECT_TIMEOUT=$((MONITOR_TIMEOUT + UART_TIMEOUT + NETWORK_TIMEOUT + 60)) + +# The network test needs a TAP interface: root and /dev/net/tun +NETWORK_TEST=0 +if [ "$(id -u)" -eq 0 ] && [ -c /dev/net/tun ]; then + NETWORK_TEST=1 +elif [ "${DMBOOT_TEST_NETWORK:-0}" = "1" ]; then + echo "✗ DMBOOT_TEST_NETWORK=1, but the network test needs root and /dev/net/tun" + exit 1 +fi +RENODE_TAP="" +if [ "$NETWORK_TEST" -eq 1 ]; then + RENODE_TAP="$TAP_INTERFACE" +fi echo "==============================================" echo " dmod-boot Renode emulation tests" @@ -44,12 +65,13 @@ echo "" # ------------------------------------------------------- # Step 1 – Build firmware with emulation mode enabled # ------------------------------------------------------- -echo "[1/5] Building firmware with emulation mode enabled..." +echo "[1/6] Building firmware with emulation mode enabled..." cmake -DCMAKE_BUILD_TYPE=Debug \ -DBOARD="$BOARD" \ -DDMBOOT_EMULATION=ON \ -DDMBOOT_RENODE_WAIT_FOR_GDB=ON \ -DDMBOOT_RENODE_UART_PORT="$UART_PORT" \ + -DDMBOOT_RENODE_TAP="$RENODE_TAP" \ -S "$SOURCE_DIR" \ -B "$BUILD_DIR" cmake --build "$BUILD_DIR" --config Debug @@ -63,7 +85,7 @@ echo "" # ------------------------------------------------------- # Step 2 – Verify install-firmware target # ------------------------------------------------------- -echo "[2/5] Testing install-firmware target..." +echo "[2/6] Testing install-firmware target..." cmake --build "$BUILD_DIR" --target install-firmware if [ ! -f "$BUILD_DIR/renode_firmware.elf" ]; then echo "✗ renode_firmware.elf not found after install-firmware" @@ -76,7 +98,7 @@ echo "" # ------------------------------------------------------- # Step 3 – Start Renode in the background # ------------------------------------------------------- -echo "[3/5] Starting Renode emulation..." +echo "[3/6] Starting Renode emulation..." CONNECT_LOG="$BUILD_DIR/connect.log" timeout "$CONNECT_TIMEOUT" cmake --build "$BUILD_DIR" --target connect > "$CONNECT_LOG" 2>&1 & CONNECT_PID=$! @@ -96,11 +118,12 @@ echo "" # ------------------------------------------------------- # Step 4 – Run monitor-gdb and verify firmware logs # ------------------------------------------------------- -echo "[4/5] Running monitor-gdb to capture firmware logs..." +echo "[4/6] Running monitor-gdb to capture firmware logs..." MONITOR_LOG="$BUILD_DIR/monitor.log" -# The monitor stays attached through step 5: it keeps resuming the target -# between its reads, whereas stopping it mid-read could leave the target halted -timeout "$((MONITOR_TIMEOUT + UART_TIMEOUT))" cmake --build "$BUILD_DIR" --target monitor-gdb > "$MONITOR_LOG" 2>&1 & +# The monitor stays attached through steps 5 and 6: it keeps resuming the +# target between its reads, whereas stopping it mid-read could leave the +# target halted +timeout "$((MONITOR_TIMEOUT + UART_TIMEOUT + NETWORK_TIMEOUT))" cmake --build "$BUILD_DIR" --target monitor-gdb > "$MONITOR_LOG" 2>&1 & MONITOR_PID=$! # Wait until every expected line has been captured, or MONITOR_TIMEOUT @@ -123,19 +146,35 @@ echo "" # ------------------------------------------------------- # Step 5 – Check that the shell answers on the console UART # ------------------------------------------------------- -echo "[5/5] Checking the shell on the console UART..." +echo "[5/6] Checking the shell on the console UART..." UART_STATUS=0 python3 "$UART_TEST_SCRIPT" --port "$UART_PORT" --timeout "$UART_TIMEOUT" || UART_STATUS=$? +echo "" + +# ------------------------------------------------------- +# Step 6 – Check the network: DHCP, ping both ways, telnet +# ------------------------------------------------------- +echo "[6/6] Checking the network..." +NETWORK_STATUS=0 +if [ "$UART_STATUS" -ne 0 ]; then + echo "Skipped: the shell on the console UART does not work" +elif [ "$NETWORK_TEST" -eq 1 ]; then + python3 "$NETWORK_TEST_SCRIPT" --tap "$TAP_INTERFACE" --uart-port "$UART_PORT" \ + --timeout "$NETWORK_TIMEOUT" || NETWORK_STATUS=$? +else + echo "Skipped: needs root and /dev/net/tun (set DMBOOT_TEST_NETWORK=1 to require it)" +fi pkill -f dmlog_monitor 2>/dev/null || true +TEST_STATUS=$((UART_STATUS | NETWORK_STATUS)) echo "" echo "==============================================" -if [ "$UART_STATUS" -eq 0 ]; then +if [ "$TEST_STATUS" -eq 0 ]; then echo " Renode emulation tests PASSED" else echo " Renode emulation tests FAILED" - # The monitor kept logging during step 5 - whatever stopped the shell - # (e.g. a stack overflow) is at the end of its log + # The monitor kept logging during steps 5 and 6 - whatever stopped the + # shell or the network (e.g. a stack overflow) is at the end of its log echo "--- monitor.log (tail) ---" tail -n 40 "$MONITOR_LOG" echo "--- connect.log (tail) ---" @@ -143,4 +182,4 @@ else fi echo "==============================================" -exit "$UART_STATUS" +exit "$TEST_STATUS" diff --git a/scripts/targets.cmake b/scripts/targets.cmake index 120adc5..4e096b3 100644 --- a/scripts/targets.cmake +++ b/scripts/targets.cmake @@ -38,6 +38,16 @@ if(DMBOOT_RENODE_CONSOLE_UART AND DMBOOT_RENODE_UART_PORT) else() set(DMBOOT_RENODE_UART_TERMINAL "") endif() +# Connect the board's Ethernet (if it names one) to a TAP interface on the +# host through a Renode switch, so the host can talk to the board over IP. +# Opening a TAP needs root (CAP_NET_ADMIN) and /dev/net/tun, so it is opt-in. +set(DMBOOT_RENODE_TAP "" CACHE STRING "Host TAP interface the board's Ethernet is connected to in emulation mode (needs root and /dev/net/tun); empty: not connected") +if(DMBOOT_RENODE_ETHERNET AND DMBOOT_RENODE_TAP) + set(DMBOOT_RENODE_NETWORK + "emulation CreateSwitch \"switch\"\nconnector Connect ${DMBOOT_RENODE_ETHERNET} switch\nemulation CreateTap \"${DMBOOT_RENODE_TAP}\" \"tap\"\nconnector Connect host.tap switch") +else() + set(DMBOOT_RENODE_NETWORK "") +endif() set(RENODE_SCRIPT_PATH "${CMAKE_BINARY_DIR}/renode.resc") configure_file( ${CMAKE_SOURCE_DIR}/configs/renode/renode.resc.in diff --git a/scripts/test_renode_network.py b/scripts/test_renode_network.py new file mode 100755 index 0000000..f702406 --- /dev/null +++ b/scripts/test_renode_network.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Check the network of the Renode machine from the host. + +Renode connects the board's Ethernet to a TAP interface on the host +(DMBOOT_RENODE_TAP). This script gives the host end of it an address, serves +DHCP on it and then checks, driving the shell on the console UART where the +board side is needed: + +- the board's DHCP client gets its lease +- the board pings the host (`ping` in the shell) +- the host pings the board (ICMP echo) +- a telnet session to the board gets a shell that runs `echo ` + +Needs root (TAP address, DHCP port, raw ICMP socket) - only the standard +library, no ip/ping/telnet tools. + +Usage: test_renode_network.py [--tap TAP] [--uart-port PORT] [--timeout SECONDS] +""" +import argparse +import fcntl +import os +import re +import socket +import struct +import sys +import threading +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from test_renode_uart import Terminal, check, connect, wait_for_prompt # noqa: E402 + +HOST_ADDR = "192.168.100.1" +BOARD_ADDR = "192.168.100.2" +NETMASK = "255.255.255.0" +PREFIX_LEN = 24 +TELNET_PORT = 23 + +# Linux ioctls / socket options used to configure the TAP without iproute2 +SIOCSIFADDR, SIOCSIFNETMASK, SIOCGIFFLAGS, SIOCSIFFLAGS = 0x8916, 0x891C, 0x8913, 0x8914 +IFF_UP = 0x1 +SO_BINDTODEVICE = 25 + + +def configure_interface(name, addr, netmask): + """Give the interface an IPv4 address and bring it up.""" + def ifreq_addr(value): + return struct.pack("16sH2s4s8s", name.encode(), socket.AF_INET, b"\0\0", socket.inet_aton(value), b"\0" * 8) + + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + fcntl.ioctl(sock, SIOCSIFADDR, ifreq_addr(addr)) + fcntl.ioctl(sock, SIOCSIFNETMASK, ifreq_addr(netmask)) + flags = struct.unpack("16sH", fcntl.ioctl(sock, SIOCGIFFLAGS, struct.pack("16sH", name.encode(), 0))[:18])[1] + fcntl.ioctl(sock, SIOCSIFFLAGS, struct.pack("16sH", name.encode(), flags | IFF_UP)) + + +def wait_for_interface(name, deadline): + while not os.path.exists(f"/sys/class/net/{name}"): + if time.monotonic() > deadline: + raise TimeoutError(f"interface {name} did not appear - is Renode connected to it?") + time.sleep(0.5) + + +class DhcpServer(threading.Thread): + """Answers DHCP DISCOVER/REQUEST on one interface with a single fixed lease.""" + + def __init__(self, interface, server_addr, client_addr, netmask): + super().__init__(daemon=True) + self.server_addr, self.client_addr, self.netmask = server_addr, client_addr, netmask + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + self.sock.setsockopt(socket.SOL_SOCKET, SO_BINDTODEVICE, interface.encode() + b"\0") + self.sock.bind(("0.0.0.0", 67)) + self.sock.settimeout(0.5) + self.acked = threading.Event() + self.running = True + + def reply(self, request, message_type): + xid, chaddr = request[4:8], request[28:44] + packet = (struct.pack("!BBBB", 2, 1, 6, 0) + xid + b"\0" * 8 + socket.inet_aton(self.client_addr) + + socket.inet_aton(self.server_addr) + b"\0" * 4 + chaddr + b"\0" * 192 + b"\x63\x82\x53\x63") + options = [ + (53, bytes([message_type])), + (54, socket.inet_aton(self.server_addr)), + (51, struct.pack("!I", 3600)), + (1, socket.inet_aton(self.netmask)), + (3, socket.inet_aton(self.server_addr)), + ] + for code, value in options: + packet += bytes([code, len(value)]) + value + self.sock.sendto(packet + b"\xff", ("255.255.255.255", 68)) + + def run(self): + while self.running: + try: + data, _ = self.sock.recvfrom(1500) + except socket.timeout: + continue + if len(data) < 240 or data[0] != 1: + continue + message_type = self.message_type(data[240:]) + if message_type == 1: # DISCOVER -> OFFER + self.reply(data, 2) + elif message_type == 3: # REQUEST -> ACK + self.reply(data, 5) + self.acked.set() + + @staticmethod + def message_type(options): + i = 0 + while i + 1 < len(options) and options[i] != 255: + if options[i] == 0: + i += 1 + continue + if options[i] == 53: + return options[i + 2] + i += 2 + options[i + 1] + return None + + +def icmp_checksum(data): + if len(data) % 2: + data += b"\0" + total = sum(struct.unpack(f"!{len(data) // 2}H", data)) + total = (total >> 16) + (total & 0xFFFF) + total += total >> 16 + return ~total & 0xFFFF + + +def host_ping(addr, count, timeout=3): + """Send ICMP echo requests from the host, return how many were answered.""" + ident, payload, answered = os.getpid() & 0xFFFF, b"dmod-boot-renode-test", 0 + with socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) as sock: + sock.settimeout(timeout) + for seq in range(count): + header = struct.pack("!BBHHH", 8, 0, 0, ident, seq) + packet = struct.pack("!BBHHH", 8, 0, icmp_checksum(header + payload), ident, seq) + payload + sock.sendto(packet, (addr, 0)) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + data, _ = sock.recvfrom(1024) + except socket.timeout: + break + icmp = data[(data[0] & 0x0F) * 4:] + if icmp[0] == 0 and struct.unpack("!HH", icmp[4:8]) == (ident, seq): + answered += 1 + break + return answered + + +class TelnetTerminal(Terminal): + """Terminal over a telnet connection - option negotiation is left unanswered and stripped.""" + + IAC_SEQUENCE = re.compile(rb"\xff[\xfb-\xfe].|\xff\xfa.*?\xff\xf0|\xff[\xf0-\xfa]", re.DOTALL) + + def text(self): + return self.IAC_SEQUENCE.sub(b"", super().text()) + + +def wait_for_lease(term, deadline): + """Poll `ip addr` on the board until eth0 has the leased address.""" + expected = f"eth0: {BOARD_ADDR}/{PREFIX_LEN}" + while time.monotonic() < deadline: + output = term.run("ip addr") + if output is not None and expected in output: + return True + time.sleep(2) + return False + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--tap", default="tap0") + parser.add_argument("--uart-port", type=int, default=3456) + parser.add_argument("--timeout", type=float, default=90, + help="seconds to wait for the interface, the shell and the DHCP lease") + args = parser.parse_args() + deadline = time.monotonic() + args.timeout + + wait_for_interface(args.tap, deadline) + configure_interface(args.tap, HOST_ADDR, NETMASK) + print(f"✓ Host end of {args.tap}: {HOST_ADDR}/{PREFIX_LEN}") + dhcp = DhcpServer(args.tap, HOST_ADDR, BOARD_ADDR, NETMASK) + dhcp.start() + + uart = Terminal(connect("127.0.0.1", args.uart_port, deadline)) + if not wait_for_prompt(uart, deadline): + print("✗ No shell prompt on the console UART") + return 1 + + if not wait_for_lease(uart, deadline): + print(f"✗ eth0 did not get {BOARD_ADDR} over DHCP (server {'acked' if dhcp.acked.is_set() else 'got no request'})") + return 1 + print(f"✓ DHCP: eth0 leased {BOARD_ADDR}/{PREFIX_LEN}") + + def all_received(out): + match = re.search(r"(\d+) packets transmitted, (\d+) packets received", "\n".join(out)) + return match is not None and int(match.group(1)) > 0 and match.group(1) == match.group(2) + + if not check(uart, f"ping {HOST_ADDR}", all_received, f"board pings the host ({HOST_ADDR})"): + return 1 + + answered = host_ping(BOARD_ADDR, 3) + if answered != 3: + print(f"✗ host pings the board ({BOARD_ADDR}): {answered}/3 answered") + return 1 + print(f"✓ host pings the board ({BOARD_ADDR}): 3/3 answered") + + telnet = TelnetTerminal(socket.create_connection((BOARD_ADDR, TELNET_PORT), timeout=5)) + if not wait_for_prompt(telnet, time.monotonic() + 30): + print(f"✗ No shell prompt over telnet ({BOARD_ADDR}:{TELNET_PORT})") + print(f"Received: {telnet.text()[-500:]!r}") + return 1 + print(f"✓ telnet {BOARD_ADDR}:{TELNET_PORT} gives a shell prompt") + token = f"telnet-test-{int(time.time())}" + if not check(telnet, f"echo {token}", lambda out: token in out, "echo over telnet prints its argument"): + return 1 + telnet.sock.close() + + dhcp.running = False + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (TimeoutError, ConnectionError, OSError) as error: + print(f"✗ {error}") + sys.exit(1)