diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..648f0e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: python -m pip install build twine + - run: python -m build + - run: python -m twine check dist/* + - run: python -m pip install dist/*.whl + - run: python -c "import freebsd_sysctl; print(freebsd_sysctl.__version__)" + + test-freebsd: + name: Test on FreeBSD ${{ matrix.release }} + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + release: ["14.4", "15.1"] + steps: + - uses: actions/checkout@v7 + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Install QEMU + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + qemu-system-x86 qemu-utils ovmf genisoimage + - name: Cache FreeBSD VM image + uses: actions/cache@v6 + with: + path: ~/.cache/freebsd_sysctl-vm/*.xz + key: freebsd-vm-${{ matrix.release }}-v1 + - name: Run the test suite in a FreeBSD VM + run: tests/vm/run.sh --release "${{ matrix.release }}" + - name: Upload VM console log + if: failure() + uses: actions/upload-artifact@v7 + with: + name: vm-console-${{ matrix.release }} + path: /tmp/freebsd_sysctl-vm-*/console.log + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 4e413f8..bee6f37 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ build/ dist/ *.egg-info/ .eggs/ +*.qcow2 +*.iso diff --git a/freebsd_sysctl/__init__.py b/freebsd_sysctl/__init__.py index 859d06d..c8d6f69 100644 --- a/freebsd_sysctl/__init__.py +++ b/freebsd_sysctl/__init__.py @@ -23,6 +23,7 @@ # POSSIBILITY OF SUCH DAMAGE. import typing import ctypes +import errno import struct import enum import freebsd_sysctl.libc @@ -31,9 +32,7 @@ from freebsd_sysctl.__version__ import __version__ -NULL_BYTES = b"\x00" -CTL_MAXNAME = ctypes.c_uint(24) -T_OID = (ctypes.c_int * 2) +CTL_MAXNAME = 24 BUFSIZ = 1024 # see /include/stdio.h#L209 @@ -117,91 +116,54 @@ def next(self): def children(self) -> typing.Iterator['Sysctl']: if self.ctl_type != freebsd_sysctl.types.NODE: return - current = self.next - while self.oid == current.oid[:len(self.oid)]: - yield current - current = current.next + try: + current = self.next + while self.oid == current.oid[:len(self.oid)]: + yield current + current = current.next + except OSError as err: + # the last OID of the sysctl tree has no successor + if err.errno != errno.ENOENT: + raise def __query_kind_and_fmt(self) -> None: self._kind, self._fmt = self.query_fmt(self.oid) @staticmethod def name2oid(name: str) -> typing.List[int]: - p_name = ctypes.c_char_p(name.encode() + NULL_BYTES) - oid = T_OID(0, 3) - p_oid = ctypes.POINTER(T_OID)(oid) - - length = ctypes.c_int(CTL_MAXNAME.value * ctypes.sizeof(ctypes.c_int)) - p_length = ctypes.POINTER(ctypes.c_int)(length) - - Res = ctypes.c_int*length.value - res = (Res)() - - freebsd_sysctl.libc.dll.sysctl( - p_oid, - 2, - ctypes.POINTER(Res)(res), - p_length, - p_name, - len(p_name.value) + encoded_name = name.encode() + res = (ctypes.c_int * CTL_MAXNAME)() + oldlen = ctypes.c_size_t(ctypes.sizeof(res)) + + freebsd_sysctl.libc.sysctl( + [0, 3], + res, + oldlen, + ctypes.c_char_p(encoded_name), + len(encoded_name) ) - oid_length = int(length.value / ctypes.sizeof(ctypes.c_int)) + oid_length = int(oldlen.value / ctypes.sizeof(ctypes.c_int)) return res[:oid_length] @staticmethod def oid2name(oid: typing.List[int]) -> str: - qoid_len = (2 + len(oid)) - qoid_type = ctypes.c_int * qoid_len - qoid = (qoid_type)(*([0, 1] + oid)) - p_qoid = ctypes.POINTER(qoid_type)(qoid) - buf = ctypes.create_string_buffer(BUFSIZ) - buf_void = ctypes.cast(buf, ctypes.c_void_p) - - buf_length = ctypes.sizeof(buf) - p_buf_length = ctypes.POINTER(ctypes.c_int)(ctypes.c_int(buf_length)) - - freebsd_sysctl.libc.dll.sysctl( - p_qoid, - qoid_len, - buf_void, - p_buf_length, - 0, - 0 - ) + oldlen = ctypes.c_size_t(ctypes.sizeof(buf)) + freebsd_sysctl.libc.sysctl([0, 1] + oid, buf, oldlen) return buf.value.decode() @staticmethod def query_fmt(oid: typing.List[int]) -> typing.Tuple[int, str]: + buf = ctypes.create_string_buffer(BUFSIZ) + oldlen = ctypes.c_size_t(ctypes.sizeof(buf)) + freebsd_sysctl.libc.sysctl([0, 4] + oid, buf, oldlen) - qoid_len = (2 + len(oid)) - qoid_type = ctypes.c_int * qoid_len - qoid = (qoid_type)(*([0, 4] + oid)) - p_qoid = ctypes.POINTER(qoid_type)(qoid) - - buf_type = ctypes.c_char * BUFSIZ - buf = buf_type() - p_buf = ctypes.POINTER(buf_type)(buf) - buf_void = ctypes.cast(p_buf, ctypes.c_void_p) - - buf_length = ctypes.sizeof(buf) - p_buf_length = ctypes.POINTER(ctypes.c_int)(ctypes.c_int(buf_length)) - - freebsd_sysctl.libc.dll.sysctl( - p_qoid, - qoid_len, - buf_void, - p_buf_length, - 0, - 0 - ) - - if len(buf) < 4: + if oldlen.value < 4: raise Exception("response buffer too small") - result = buf[:buf_length] + result = buf.raw[:oldlen.value] kind, = struct.unpack("I", result[:4]) - null_pos = result.find(b'\x00',4) # buf is large and string is small + null_pos = result.find(b"\x00", 4) fmt = result[4:null_pos].decode() return (kind, fmt) @@ -209,24 +171,10 @@ def query_fmt(oid: typing.List[int]) -> typing.Tuple[int, str]: def query_size( oid: typing.List[int], ctl_type: freebsd_sysctl.types.CtlType - ) -> bytes: - - oid_type = ctypes.c_int * len(oid) - _oid = (oid_type)(*oid) - p_oid = ctypes.POINTER(oid_type)(_oid) - - length = ctypes.c_int() - p_length = ctypes.POINTER(ctypes.c_int)(length) - - freebsd_sysctl.libc.dll.sysctl( - p_oid, - len(oid), - None, - p_length, - 0 - ) - - return max(length.value, ctl_type.min_size) + ) -> int: + oldlen = ctypes.c_size_t() + freebsd_sysctl.libc.sysctl(oid, None, oldlen) + return max(oldlen.value, ctl_type.min_size) @staticmethod def query_value( @@ -234,89 +182,37 @@ def query_value( size: int, ctl_type: freebsd_sysctl.types.CtlType ) -> bytes: - - # ToDo: check if value is readable - - oid_type = ctypes.c_int * len(oid) - _oid = (oid_type)(*oid) - p_oid = ctypes.POINTER(oid_type)(_oid) - - buf_type = ctypes.c_char * size - buf = buf_type() - p_buf = ctypes.POINTER(buf_type)(buf) - p_buf_void = ctypes.cast(p_buf, ctypes.c_void_p) - - buf_length = ctypes.sizeof(buf) - p_buf_length = ctypes.POINTER(ctypes.c_int)(ctypes.c_int(buf_length)) - - freebsd_sysctl.libc.dll.sysctl( - p_oid, - ctypes.c_uint32(len(oid)), - p_buf_void, - p_buf_length, - None, - 0 - ) - - return ctl_type(buf, size) + # the value can grow between the size query and the read + attempts = 4 + while True: + buf = ctypes.create_string_buffer(size) + oldlen = ctypes.c_size_t(size) + try: + freebsd_sysctl.libc.sysctl(oid, buf, oldlen) + except OSError as err: + attempts -= 1 + if (err.errno != errno.ENOMEM) or (attempts == 0): + raise + size *= 2 + continue + return ctl_type(buf, size) @staticmethod def query_description( oid: typing.List[int] ) -> str: - - qoid_len = (2 + len(oid)) - qoid_type = ctypes.c_int * qoid_len - qoid = (qoid_type)(*([0, 5] + oid)) - p_qoid = ctypes.POINTER(qoid_type)(qoid) - - buf_type = ctypes.c_char * BUFSIZ - buf = buf_type() - p_buf = ctypes.POINTER(buf_type)(buf) - buf_void = ctypes.cast(p_buf, ctypes.c_void_p) - - buf_length = ctypes.sizeof(buf) - p_buf_length = ctypes.POINTER(ctypes.c_int)(ctypes.c_int(buf_length)) - - freebsd_sysctl.libc.dll.sysctl( - p_qoid, - qoid_len, - buf_void, - p_buf_length, - 0, - 0 - ) - + buf = ctypes.create_string_buffer(BUFSIZ) + oldlen = ctypes.c_size_t(ctypes.sizeof(buf)) + freebsd_sysctl.libc.sysctl([0, 5] + oid, buf, oldlen) return buf.value.decode() @staticmethod def query_next(oid: typing.List[int]) -> bytes: + buf = (ctypes.c_int * CTL_MAXNAME)() + oldlen = ctypes.c_size_t(ctypes.sizeof(buf)) + freebsd_sysctl.libc.sysctl([0, 2] + oid, buf, oldlen) - qoid_len = (2 + len(oid)) - qoid_type = ctypes.c_int * qoid_len - qoid = (qoid_type)(*([0, 2] + oid)) - p_qoid = ctypes.POINTER(qoid_type)(qoid) - - buf_type = ctypes.c_int * CTL_MAXNAME.value - buf = buf_type() - p_buf = ctypes.POINTER(buf_type)(buf) - buf_void = ctypes.cast(p_buf, ctypes.c_void_p) - - buf_length = ctypes.sizeof(buf) - p_buf_length = ctypes.POINTER(ctypes.c_int)(ctypes.c_int(buf_length)) - - freebsd_sysctl.libc.dll.sysctl( - p_qoid, - qoid_len, - buf_void, - p_buf_length, - 0, - 0 - ) - - oid_length = int( - p_buf_length.contents.value / ctypes.sizeof(ctypes.c_int) - ) + oid_length = int(oldlen.value / ctypes.sizeof(ctypes.c_int)) return buf[0:oid_length] @property diff --git a/freebsd_sysctl/libc.py b/freebsd_sysctl/libc.py index bdedd0b..6e024d2 100644 --- a/freebsd_sysctl/libc.py +++ b/freebsd_sysctl/libc.py @@ -22,8 +22,54 @@ # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import ctypes +import os +import typing + try: - dll = ctypes.CDLL("libc.so.7") + dll = ctypes.CDLL("libc.so.7", use_errno=True) except OSError: import ctypes.util - dll = ctypes.CDLL(str(ctypes.util.find_library("c")), use_errno=True) + dll = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True) + +_sysctl_prototype = None + + +def _sysctl() -> typing.Any: + # sysctl(3) only exists in FreeBSD's libc, so the symbol is resolved on + # first use to keep the module importable on other platforms. + global _sysctl_prototype + if _sysctl_prototype is None: + prototype = dll.sysctl + prototype.argtypes = [ + ctypes.POINTER(ctypes.c_int), + ctypes.c_uint, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_size_t), + ctypes.c_void_p, + ctypes.c_size_t + ] + prototype.restype = ctypes.c_int + _sysctl_prototype = prototype + return _sysctl_prototype + + +def sysctl( + oid: typing.List[int], + oldp: typing.Any=None, + oldlen: typing.Optional[ctypes.c_size_t]=None, + newp: typing.Any=None, + newlen: int=0 +) -> None: + """Call sysctl(3) and raise OSError when it fails.""" + name = (ctypes.c_int * len(oid))(*oid) + res = _sysctl()( + name, + len(oid), + None if oldp is None else ctypes.cast(oldp, ctypes.c_void_p), + None if oldlen is None else ctypes.byref(oldlen), + None if newp is None else ctypes.cast(newp, ctypes.c_void_p), + newlen + ) + if res != 0: + code = ctypes.get_errno() + raise OSError(code, os.strerror(code)) diff --git a/tests/freebsd_sysctl_test.py b/tests/freebsd_sysctl_test.py index d6615a4..a9a13fd 100644 --- a/tests/freebsd_sysctl_test.py +++ b/tests/freebsd_sysctl_test.py @@ -21,6 +21,7 @@ # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +import errno import os.path import pytest import subprocess @@ -158,12 +159,19 @@ def test_sysctl_values(benchmark, sysctl_types): def lookup_values(sysctl_types): for sysctl_name, sysctl_type in sysctl_types.items(): - yield sysctl_name, freebsd_sysctl.Sysctl(sysctl_name).raw_value + try: + yield sysctl_name, freebsd_sysctl.Sysctl(sysctl_name).raw_value + except OSError: + # the kernel refuses to read some sysctls, for example + # NODEs without a handler; sysctl(8) cannot read them either + continue raw_values = dict(benchmark(lookup_values, sysctl_types)) mismatches = [] for sysctl_name, sysctl_type in sysctl_types.items(): + if sysctl_name not in raw_values: + continue raw_value = raw_values[sysctl_name] if any([ @@ -202,7 +210,12 @@ def test_sysctl_descriptions(benchmark, sysctl_types): def lookup_descriptions(sysctl_types): for sysctl_name, sysctl_type in sysctl_types.items(): - yield sysctl_name, freebsd_sysctl.Sysctl(sysctl_name).description + try: + yield sysctl_name, freebsd_sysctl.Sysctl(sysctl_name).description + except OSError as err: + # dynamic sysctls can vanish between listing and lookup + if err.errno != errno.ENOENT: + raise descriptions = dict(benchmark(lookup_descriptions, sysctl_types)) @@ -210,6 +223,8 @@ def lookup_descriptions(sysctl_types): if sysctl_type.upper() == "NODE": # skip NODE types because /sbin/sysctl returns multiple results continue + if sysctl_name not in descriptions: + continue stdout = subprocess.check_output([ "/sbin/sysctl", "-d", @@ -255,3 +270,17 @@ def test_sysctl_refresh(): list.append(bytearray(10 * 1024 * 1024)) assert last != sysctl.value last = sysctl.value + + +def test_unknown_sysctl_name_raises(): + with pytest.raises(OSError) as excinfo: + freebsd_sysctl.Sysctl("kern.does.not.exist").oid + assert excinfo.value.errno == errno.ENOENT + + +def test_unknown_sysctl_oid_raises(): + # the kernel echoes unknown OIDs numerically in name lookups, + # so only the value query reports the missing sysctl + with pytest.raises(OSError) as excinfo: + freebsd_sysctl.Sysctl(oid=[0x7fffffff]).value + assert excinfo.value.errno == errno.ENOENT diff --git a/tests/vm/README.md b/tests/vm/README.md new file mode 100644 index 0000000..78b4b0b --- /dev/null +++ b/tests/vm/README.md @@ -0,0 +1,52 @@ +# FreeBSD VM test harness + +`run.sh` boots an official FreeBSD VM image in QEMU and runs the test suite inside it. +The same script drives local test runs on a Linux machine and the GitHub Actions workflow. + +## Host requirements + +Linux with `qemu-system-x86`, `qemu-utils`, `ovmf`, `genisoimage`, `curl`, `xz-utils` and an ssh client. +KVM is used when `/dev/kvm` is accessible; without it QEMU falls back to TCG emulation, which works but is roughly an order of magnitude slower. + +## Usage + +``` +tests/vm/run.sh --release 14.4 +tests/vm/run.sh --release 15.1 --ssh-port 2223 --pytest-args '-k test_sysctl_types' +``` + +Options: + +| Option | Description | +| --- | --- | +| `--release` | FreeBSD release to test against (`14.4` or `15.1`). | +| `--ssh-port` | Host port forwarded to the VM's sshd (default 2222); pick distinct ports for parallel runs. | +| `--pytest-args` | Extra arguments appended to the pytest invocation. | +| `--workdir` | Working directory for disk overlay, seed ISO, keys and console log (default: a fresh temp dir). | +| `--keep` | Keep the working directory after a successful run. | +| `--provision-only` | Stop after provisioning and leave the VM running for interactive ssh. | + +The environment variable `SSH_TIMEOUT` bounds the wait for sshd in seconds (default 1800). +On failure the working directory is kept and the tail of the VM console log is printed. + +## How it works + +1. Downloads `FreeBSD--RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz` from download.freebsd.org into `~/.cache/freebsd_sysctl-vm/` and verifies it against a checksum pinned in the script. +2. Creates a qcow2 overlay, an ephemeral ssh key and a NoCloud cloud-init seed ISO that authorizes the key for root and enables sshd. +3. Boots the image headless under UEFI (OVMF) with virtio disk and network and a forwarded ssh port. +4. Installs Python and pytest from FreeBSD packages, copies the tracked files of the working tree into the VM and runs `pytest`. + +## Design notes + +Images are downloaded on demand and cached in `~/.cache/freebsd_sysctl-vm/` outside the repository; VM disks and seed images never enter version control. + +The 14.x images run freebsd-update on first boot and apply base-system patches before sshd becomes reachable. +This delays the first ssh connection by a few minutes with KVM and considerably longer under TCG; the seed cannot prevent it because nuageinit executes shell user-data only at rc.local time. +Expect 15.x images to become reachable faster. + +## Updating to a new FreeBSD release + +1. Add the release and its checksum to the `case` block in `run.sh`. + The checksum comes from `CHECKSUM.SHA256` in `https://download.freebsd.org/releases/VM-IMAGES/-RELEASE/amd64/Latest/`. +2. Update the matrix in `.github/workflows/ci.yml`. +3. Drop releases that reached end of life. diff --git a/tests/vm/run.sh b/tests/vm/run.sh new file mode 100755 index 0000000..3829546 --- /dev/null +++ b/tests/vm/run.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# Boot a FreeBSD VM in QEMU and run the test suite inside it. +# Works on any Linux host with qemu-system-x86, qemu-utils, ovmf and genisoimage installed. +# KVM is used when /dev/kvm is available and falls back to TCG emulation otherwise. +set -euo pipefail + +RELEASE="" +SSH_PORT=2222 +PYTEST_ARGS="" +WORKDIR="" +KEEP=0 +PROVISION_ONLY=0 + +usage() { + echo "usage: $0 --release <14.4|15.1> [--ssh-port PORT] [--pytest-args ARGS] [--workdir DIR] [--keep]" >&2 + exit 2 +} + +while [ $# -gt 0 ]; do + case "$1" in + --release) RELEASE="$2"; shift 2 ;; + --ssh-port) SSH_PORT="$2"; shift 2 ;; + --pytest-args) PYTEST_ARGS="$2"; shift 2 ;; + --workdir) WORKDIR="$2"; shift 2 ;; + --keep) KEEP=1; shift ;; + --provision-only) PROVISION_ONLY=1; shift ;; + *) usage ;; + esac +done +[ -n "${RELEASE}" ] || usage + +# Pinned images. Update the checksums from CHECKSUM.SHA256 in the same +# directory when bumping to a new release. +case "${RELEASE}" in + 14.4) IMAGE_SHA256="d957fe56fb596280428328e78e9e4feae00dd361701caa79a061f9bd289eff67" ;; + 15.1) IMAGE_SHA256="e4ca4db889f8559c9b9dfcacc70405c038476f4b6d41649b152d3809a2ed9e1f" ;; + *) echo "unsupported release: ${RELEASE}" >&2; exit 2 ;; +esac +IMAGE_NAME="FreeBSD-${RELEASE}-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2" +IMAGE_URL="https://download.freebsd.org/releases/VM-IMAGES/${RELEASE}-RELEASE/amd64/Latest/${IMAGE_NAME}.xz" + +REPO_ROOT="$(git rev-parse --show-toplevel)" +CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/freebsd_sysctl-vm" +mkdir -p "${CACHE_DIR}" + +if [ -z "${WORKDIR}" ]; then + WORKDIR="$(mktemp -d -t freebsd_sysctl-vm-XXXXXX)" +else + mkdir -p "${WORKDIR}" +fi + +QEMU_PIDFILE="${WORKDIR}/qemu.pid" + +cleanup() { + status=$? + if [ -f "${QEMU_PIDFILE}" ] && kill -0 "$(cat "${QEMU_PIDFILE}")" 2>/dev/null; then + kill "$(cat "${QEMU_PIDFILE}")" 2>/dev/null || true + fi + if [ ${status} -ne 0 ] && [ -f "${WORKDIR}/console.log" ]; then + echo "=== last lines of VM console ===" >&2 + tail -25 "${WORKDIR}/console.log" >&2 || true + fi + if [ ${KEEP} -eq 0 ] && [ ${status} -eq 0 ]; then + rm -rf "${WORKDIR}" + else + echo "workdir kept at ${WORKDIR}" >&2 + fi + exit ${status} +} +trap cleanup EXIT INT TERM + +log() { + echo "[$(date +%H:%M:%S)] $*" >&2 +} + +# --- image download and verification ----------------------------------------- +if [ ! -f "${CACHE_DIR}/${IMAGE_NAME}" ]; then + if [ ! -f "${CACHE_DIR}/${IMAGE_NAME}.xz" ]; then + log "downloading ${IMAGE_NAME}.xz" + curl -SsL --retry 3 -o "${CACHE_DIR}/${IMAGE_NAME}.xz.part" "${IMAGE_URL}" + mv "${CACHE_DIR}/${IMAGE_NAME}.xz.part" "${CACHE_DIR}/${IMAGE_NAME}.xz" + fi + echo "${IMAGE_SHA256} ${CACHE_DIR}/${IMAGE_NAME}.xz" | sha256sum -c - >&2 + log "extracting ${IMAGE_NAME}" + unxz -k "${CACHE_DIR}/${IMAGE_NAME}.xz" +fi + +# --- per-run disk, ssh key and cloud-init seed -------------------------------- +qemu-img create -q -f qcow2 -b "${CACHE_DIR}/${IMAGE_NAME}" -F qcow2 "${WORKDIR}/disk.qcow2" +qemu-img resize -q "${WORKDIR}/disk.qcow2" +4G + +ssh-keygen -q -t ed25519 -N "" -f "${WORKDIR}/id" + +SEED_DIR="${WORKDIR}/seed" +mkdir -p "${SEED_DIR}" +cat > "${SEED_DIR}/meta-data" < "${SEED_DIR}/user-data" < /root/.ssh/authorized_keys +chmod 600 /root/.ssh/authorized_keys +echo 'PermitRootLogin prohibit-password' >> /etc/ssh/sshd_config +sysrc sshd_enable=YES +sysrc firstboot_freebsd_update_enable=NO +service sshd restart || service sshd start +EOF +genisoimage -quiet -output "${WORKDIR}/seed.iso" -volid cidata -joliet -rock \ + "${SEED_DIR}/user-data" "${SEED_DIR}/meta-data" + +# --- firmware ----------------------------------------------------------------- +OVMF_CODE="" +for candidate in /usr/share/OVMF/OVMF_CODE_4M.fd /usr/share/OVMF/OVMF_CODE.fd; do + [ -f "${candidate}" ] && OVMF_CODE="${candidate}" && break +done +[ -n "${OVMF_CODE}" ] || { echo "no OVMF firmware found" >&2; exit 1; } +cp "${OVMF_CODE%CODE*}VARS${OVMF_CODE##*CODE}" "${WORKDIR}/OVMF_VARS.fd" + +# --- boot ---------------------------------------------------------------------- +ACCEL="tcg" +[ -w /dev/kvm ] && ACCEL="kvm" +log "booting FreeBSD ${RELEASE} (accel=${ACCEL}, ssh port ${SSH_PORT})" +qemu-system-x86_64 \ + -machine q35,accel=${ACCEL} \ + -cpu max \ + -smp 2 \ + -m 4096 \ + -drive if=pflash,format=raw,readonly=on,file="${OVMF_CODE}" \ + -drive if=pflash,format=raw,file="${WORKDIR}/OVMF_VARS.fd" \ + -drive if=virtio,format=qcow2,file="${WORKDIR}/disk.qcow2" \ + -drive file="${WORKDIR}/seed.iso",media=cdrom \ + -netdev user,id=n0,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22 \ + -device virtio-net-pci,netdev=n0 \ + -display none \ + -vga none \ + -serial "file:${WORKDIR}/console.log" \ + -daemonize \ + -pidfile "${QEMU_PIDFILE}" + +SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null + -o ConnectTimeout=5 -o IdentitiesOnly=yes -o LogLevel=ERROR + -i "${WORKDIR}/id" -p "${SSH_PORT}") +run_ssh() { + ssh "${SSH_OPTS[@]}" root@127.0.0.1 "$@" +} + +# Retry wrapper for idempotent commands: the VM reboots at the end of the +# firstboot sequence, and pkg needs the network and remote repositories. +run_ssh_retry() { + for attempt in 1 2 3 4 5; do + if run_ssh "$@"; then + return 0 + fi + log "ssh command failed, retrying (${attempt}/5)" + sleep 15 + done + return 1 +} + +SSH_TIMEOUT="${SSH_TIMEOUT:-1800}" +log "waiting for ssh (timeout ${SSH_TIMEOUT}s)" +deadline=$(( $(date +%s) + SSH_TIMEOUT )) +# the firstboot sentinel is gone once growfs and firstboot scripts finished, +# so this does not race against the reboot at the end of the first boot +until run_ssh "test ! -e /firstboot" 2>/dev/null; do + if [ "$(date +%s)" -ge "${deadline}" ]; then + echo "timed out waiting for ssh" >&2 + exit 1 + fi + sleep 10 +done +log "ssh is up" + +# --- provision ------------------------------------------------------------------ +log "installing python and pytest" +run_ssh_retry "env ASSUME_ALWAYS_YES=yes pkg bootstrap -f >/dev/null && pkg install -qy python3" >&2 +PY_SUFFIX=$(run_ssh "python3 -c 'import sys; print(f\"py{sys.version_info[0]}{sys.version_info[1]}\")'") +run_ssh_retry "pkg install -qy ${PY_SUFFIX}-pytest ${PY_SUFFIX}-pytest-benchmark" >&2 + +if [ ${PROVISION_ONLY} -eq 1 ]; then + log "VM provisioned; connect with: ssh ${SSH_OPTS[*]} root@127.0.0.1" + trap - EXIT INT TERM + exit 0 +fi + +# --- ship the working tree and run the tests ------------------------------------- +log "copying source tree" +(cd "${REPO_ROOT}" && git ls-files -z | tar -czf - --null -T -) \ + | run_ssh "rm -rf /root/src && mkdir -p /root/src && tar -xzf - -C /root/src" + +log "running pytest" +set +e +run_ssh "cd /root/src && python3 -m pytest tests -v --benchmark-disable ${PYTEST_ARGS}" +result=$? +set -e +log "pytest exited with ${result}" +exit ${result}