Replace Cirrus CI with GitHub Actions running FreeBSD inside QEMU/KVM
VMs on ubuntu-24.04 runners, testing with both gcc and clang.  VM images
are pre-built from official FreeBSD BASIC-CLOUDINIT releases, provisioned
via nuageinit, and cached between runs to keep build times short.

FreeBSD versions are updated from 13.5/14.3 to 14.4/15.0, which are the
current supported releases and the first to include the nuageinit support
required for automated VM provisioning.

Assisted-by: Claude Sonnet 4.6, OpenCode
Signed-off-by: Eelco Chaudron <[email protected]>
---
 .ci/freebsd-build.sh          | 141 ++++++++++++++++
 .ci/freebsd-prepare-image.sh  | 154 ++++++++++++++++++
 .ci/freebsd-vm.sh             | 291 ++++++++++++++++++++++++++++++++++
 .github/workflows/freebsd.yml | 160 +++++++++++++++++++
 Makefile.am                   |   4 +
 README.rst                    |   4 +-
 utilities/checkpatch_dict.txt |   2 +
 7 files changed, 754 insertions(+), 2 deletions(-)
 create mode 100755 .ci/freebsd-build.sh
 create mode 100755 .ci/freebsd-prepare-image.sh
 create mode 100755 .ci/freebsd-vm.sh
 create mode 100755 .github/workflows/freebsd.yml

diff --git a/.ci/freebsd-build.sh b/.ci/freebsd-build.sh
new file mode 100755
index 000000000..f1cb41650
--- /dev/null
+++ b/.ci/freebsd-build.sh
@@ -0,0 +1,141 @@
+#!/bin/bash
+# Builds and tests Open vSwitch inside a FreeBSD QEMU VM.
+#
+# Steps mirror the original Cirrus CI configuration:
+#   configure -> ./boot.sh && ./configure CC=<compiler> ...
+#   build     -> gmake -j8
+#   check     -> gmake -j8 check TESTSUITEFLAGS=-j8 RECHECK=yes
+#
+# All CI dependencies are pre-installed in the cached image by nuageinit
+# during the prepare phase (see freebsd-prepare-image.sh).
+#
+# Required environment variables:
+#   FREEBSD_VER   - FreeBSD version string, e.g. "14.4" or "15.0"
+#   COMPILER      - compiler to use: "gcc" or "clang"
+#
+# The cached image freebsd-<FREEBSD_VER>.qcow2 must exist in the current
+# directory before this script is called (restored from actions/cache by the
+# workflow).
+
+set -o errexit
+set -o xtrace
+
+FREEBSD_VER="${FREEBSD_VER:?Must set FREEBSD_VER (e.g. 14.4)}"
+COMPILER="${COMPILER:?Must set COMPILER (gcc or clang)}"
+
+BASE_IMG="freebsd-${FREEBSD_VER}.qcow2"
+RUN_IMG="freebsd-run.qcow2"
+
+if [ ! -f "${BASE_IMG}" ]; then
+    echo "ERROR: ${BASE_IMG} not found." \
+         "Ensure the cache was restored before calling this script." >&2
+    exit 1
+fi
+
+# ---------------------------------------------------------------------------
+# 1. Install host-side tools
+# ---------------------------------------------------------------------------
+sudo apt-get update -qq
+sudo apt-get install -y \
+    qemu-system-x86 qemu-utils \
+    genisoimage \
+    rsync openssh-client \
+    ovmf
+
+# ---------------------------------------------------------------------------
+# 2. Source VM utilities
+# ---------------------------------------------------------------------------
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=freebsd-vm.sh
+. "${SCRIPT_DIR}/freebsd-vm.sh"
+
+# ---------------------------------------------------------------------------
+# 3. Generate an ephemeral SSH key for this CI run
+# ---------------------------------------------------------------------------
+KEY_DIR="$(mktemp -d)"
+SSH_KEY="${KEY_DIR}/id_ed25519"
+ssh-keygen -t ed25519 -f "${SSH_KEY}" -N "" -q
+export FREEBSD_SSH_KEY="${SSH_KEY}"
+
+# ---------------------------------------------------------------------------
+# 4. Create a COW overlay on top of the cached base image
+# ---------------------------------------------------------------------------
+# COW overlay keeps the cached base image unmodified.
+echo "==> Creating COW overlay image ..."
+qemu-img create -f qcow2 -F qcow2 -b "$(realpath "${BASE_IMG}")" "${RUN_IMG}"
+
+# ---------------------------------------------------------------------------
+# 5. Create a seed ISO with a fresh instance-id
+# ---------------------------------------------------------------------------
+SEED_INSTANCE_ID="freebsd-build-${FREEBSD_VER}-${COMPILER}-$(date +%s%N)"
+freebsd_create_seed \
+    "${SSH_KEY}.pub" \
+    "${SEED_INSTANCE_ID}" \
+    /tmp/freebsd-seed-build \
+    /tmp/freebsd-seed-build.iso
+
+# ---------------------------------------------------------------------------
+# 6. Boot the VM
+# ---------------------------------------------------------------------------
+echo "==> Booting FreeBSD ${FREEBSD_VER} (compiler: ${COMPILER}) ..."
+
+BUILD_OVMF_VARS="/tmp/freebsd-ovmf-vars-build.fd"
+cp "${FREEBSD_OVMF_VARS}" "${BUILD_OVMF_VARS}"
+
+freebsd_start_vm \
+    "${RUN_IMG}" /tmp/freebsd-seed-build.iso 4096 4 "${BUILD_OVMF_VARS}"
+
+cleanup() {
+    # Retrieve logs before stopping the VM (|| true handles boot failures).
+    echo "==> Retrieving logs ..."
+    mkdir -p tests
+    freebsd_rsync_from /root/ovs/config.log        ./      2>/dev/null || true
+    freebsd_rsync_from /root/ovs/tests/testsuite.log tests/ 2>/dev/null || true
+    freebsd_rsync_from /root/ovs/tests/testsuite.dir tests/ 2>/dev/null || true
+    freebsd_stop_vm
+    rm -rf "${KEY_DIR}" "${RUN_IMG}" "${BUILD_OVMF_VARS}" \
+           /tmp/freebsd-seed-build /tmp/freebsd-seed-build.iso
+}
+trap cleanup EXIT
+
+# ---------------------------------------------------------------------------
+# 7. Wait for SSH, then wait for firstboot to complete
+# ---------------------------------------------------------------------------
+# Wait for SSH, then ensure all firstboot rc scripts (including the sshd
+# restart runcmd) have completed before proceeding.
+# SSH wait: 20 x 10s = 200s.  Firstboot wait: 30 x 5s = 150s.
+freebsd_wait_ssh 20 10
+freebsd_wait_firstboot_complete 30 5
+
+# ---------------------------------------------------------------------------
+# 8. Sync source tree into the VM
+# ---------------------------------------------------------------------------
+echo "==> Syncing source tree to VM ..."
+freebsd_ssh "mkdir -p /root/ovs"
+freebsd_rsync_to "$(pwd)/" /root/ovs/
+
+# ---------------------------------------------------------------------------
+# 9. Configure
+# ---------------------------------------------------------------------------
+echo "==> Configuring (CC=${COMPILER}) ..."
+freebsd_ssh "cd /root/ovs && \
+    ./boot.sh && \
+    ./configure CC=${COMPILER} CFLAGS='-g -O2 -Wall' MAKE=gmake \
+        --enable-Werror \
+    || { cat config.log; exit 1; }"
+
+# ---------------------------------------------------------------------------
+# 10. Build
+# ---------------------------------------------------------------------------
+echo "==> Building ..."
+freebsd_ssh "cd /root/ovs && gmake -j8"
+
+# ---------------------------------------------------------------------------
+# 11. Test
+# ---------------------------------------------------------------------------
+echo "==> Running test suite ..."
+freebsd_ssh "cd /root/ovs && \
+    gmake -j8 check TESTSUITEFLAGS=-j8 RECHECK=yes \
+    || { cat ./tests/testsuite.log; exit 1; }"
+
+echo "==> FreeBSD ${FREEBSD_VER} ${COMPILER} build complete."
diff --git a/.ci/freebsd-prepare-image.sh b/.ci/freebsd-prepare-image.sh
new file mode 100755
index 000000000..5cbbb7960
--- /dev/null
+++ b/.ci/freebsd-prepare-image.sh
@@ -0,0 +1,154 @@
+#!/bin/bash
+# Prepares a FreeBSD QEMU VM image with all CI dependencies pre-installed.
+#
+# Usage: freebsd-prepare-image.sh <freebsd_version>
+#   freebsd_version  e.g. "14.4" or "15.0"
+#
+# Downloads the FreeBSD BASIC-CLOUDINIT qcow2 image, verifies its SHA256,
+# grows the disk, runs the two-boot nuageinit sequence (SSH key injection,
+# package install, PermitRootLogin), restores /firstboot for build job boots,
+# and compresses the result for caching.  Boot sequence details: freebsd-vm.sh.
+#
+# Output file: freebsd-<version>.qcow2  (in the current directory)
+
+set -o errexit
+set -o xtrace
+
+# Capture the exact command that triggers errexit so the EXIT trap can print
+# a clear "FAILED: <cmd>" line.  Without this the failure is buried in
+# xtrace noise.
+_FAILED_CMD=""
+trap '_FAILED_CMD="${BASH_COMMAND}"' ERR
+
+FREEBSD_VER="${1:?Usage: $0 <version>  (e.g. 14.4)}"
+
+RELEASE="${FREEBSD_VER}-RELEASE"
+ARCH="amd64"
+BASE_URL="https://download.freebsd.org/releases/VM-IMAGES";
+BASE_URL="${BASE_URL}/${RELEASE}/${ARCH}/Latest"
+
+IMG_NAME="FreeBSD-${RELEASE}-${ARCH}-BASIC-CLOUDINIT-ufs.qcow2"
+IMG_XZ="${IMG_NAME}.xz"
+OUT_IMG="freebsd-${FREEBSD_VER}.qcow2"
+
+# ---------------------------------------------------------------------------
+# 1. Install host-side tools
+# ---------------------------------------------------------------------------
+sudo apt-get update -qq
+sudo apt-get install -y \
+    qemu-system-x86 qemu-utils \
+    genisoimage \
+    wget xz-utils \
+    ovmf
+
+# ---------------------------------------------------------------------------
+# 2. Download and verify the FreeBSD image
+# ---------------------------------------------------------------------------
+echo "==> Fetching CHECKSUM.SHA256 ..."
+wget -q "${BASE_URL}/CHECKSUM.SHA256" -O freebsd-checksum.txt
+
+echo "==> Downloading ${IMG_XZ} ..."
+wget -q "${BASE_URL}/${IMG_XZ}" -O "${IMG_XZ}"
+
+echo "==> Verifying image integrity ..."
+expected_sha=$(grep "(${IMG_XZ})" freebsd-checksum.txt | awk '{print $NF}')
+actual_sha=$(sha256sum "${IMG_XZ}" | awk '{print $1}')
+if [ "${expected_sha}" != "${actual_sha}" ]; then
+    echo "ERROR: SHA256 mismatch for ${IMG_XZ}" >&2
+    echo "  expected: ${expected_sha}" >&2
+    echo "  actual:   ${actual_sha}" >&2
+    exit 1
+fi
+
+echo "==> Extracting image ..."
+xz --decompress --keep "${IMG_XZ}"
+mv "${IMG_NAME}" "${OUT_IMG}"
+rm -f "${IMG_XZ}"
+
+# ---------------------------------------------------------------------------
+# 3. Grow the disk to make room for packages
+# ---------------------------------------------------------------------------
+echo "==> Resizing disk to +8G ..."
+qemu-img resize "${OUT_IMG}" +8G
+
+# ---------------------------------------------------------------------------
+# 4. Generate an ephemeral SSH key for this preparation run
+# ---------------------------------------------------------------------------
+PREP_KEY_DIR="$(mktemp -d)"
+PREP_KEY="${PREP_KEY_DIR}/id_ed25519"
+ssh-keygen -t ed25519 -f "${PREP_KEY}" -N "" -q
+
+# ---------------------------------------------------------------------------
+# 5. Source VM utilities
+# ---------------------------------------------------------------------------
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=freebsd-vm.sh
+. "${SCRIPT_DIR}/freebsd-vm.sh"
+export FREEBSD_SSH_KEY="${PREP_KEY}"
+
+# ---------------------------------------------------------------------------
+# 6. Boot the VM with a seed ISO
+# ---------------------------------------------------------------------------
+SEED_INSTANCE_ID="freebsd-prepare-${FREEBSD_VER}-$(date +%s)"
+freebsd_create_seed \
+    "${PREP_KEY}.pub" \
+    "${SEED_INSTANCE_ID}" \
+    /tmp/freebsd-seed-prepare \
+    /tmp/freebsd-seed-prepare.iso
+
+echo "==> Booting FreeBSD ${FREEBSD_VER} for image preparation ..."
+
+PREP_OVMF_VARS="/tmp/freebsd-ovmf-vars-prepare.fd"
+cp "${FREEBSD_OVMF_VARS}" "${PREP_OVMF_VARS}"
+
+freebsd_start_vm \
+    "${OUT_IMG}" /tmp/freebsd-seed-prepare.iso 4096 4 "${PREP_OVMF_VARS}"
+
+_prep_cleanup() {
+    local rc=$?
+    if [ "${rc}" -ne 0 ]; then
+        echo "### PREPARE FAILED (rc=${rc}): ${_FAILED_CMD} ###" >&2
+    fi
+    freebsd_stop_vm
+    rm -rf "${PREP_KEY_DIR}" "${PREP_OVMF_VARS}" \
+           /tmp/freebsd-seed-prepare /tmp/freebsd-seed-prepare.iso
+}
+trap _prep_cleanup EXIT
+
+# ---------------------------------------------------------------------------
+# 7. Wait for SSH, then wait for firstboot to complete
+# ---------------------------------------------------------------------------
+# Covers two boots: Boot 1 (freebsd-update + reboot) + Boot 2 (packages +
+# runcmds).  Must finish before "touch /firstboot" (step 8).
+# SSH wait: 90 x 10s = 900s.  Firstboot wait: 12 x 10s = 120s.
+freebsd_wait_ssh 90 10
+freebsd_wait_firstboot_complete 12 10
+
+# ---------------------------------------------------------------------------
+# 8. Restore /firstboot so build job boots re-run nuageinit
+# ---------------------------------------------------------------------------
+# Restore /firstboot so nuageinit runs on build job boots to inject
+# per-job SSH keys.
+echo "==> Restoring /firstboot for build job boots ..."
+freebsd_ssh "touch /firstboot"
+
+echo "==> Shutting down FreeBSD ${FREEBSD_VER} ..."
+freebsd_stop_vm
+_prep_cleanup() {
+    rm -rf "${PREP_KEY_DIR}" "${PREP_OVMF_VARS}" \
+           /tmp/freebsd-seed-prepare /tmp/freebsd-seed-prepare.iso
+}
+trap _prep_cleanup EXIT
+
+# ---------------------------------------------------------------------------
+# 9. Compress the prepared image for caching
+# ---------------------------------------------------------------------------
+echo "==> Compressing prepared image ..."
+COMPRESSED="${OUT_IMG}.compressed"
+qemu-img convert -c -O qcow2 "${OUT_IMG}" "${COMPRESSED}"
+mv "${COMPRESSED}" "${OUT_IMG}"
+
+FINAL_SIZE=$(du -sh "${OUT_IMG}" | cut -f1)
+echo "==> Image preparation complete."
+echo "    Output : ${OUT_IMG}"
+echo "    Size   : ${FINAL_SIZE}"
diff --git a/.ci/freebsd-vm.sh b/.ci/freebsd-vm.sh
new file mode 100755
index 000000000..13394efba
--- /dev/null
+++ b/.ci/freebsd-vm.sh
@@ -0,0 +1,291 @@
+#!/bin/bash
+# FreeBSD QEMU VM management utilities.
+#
+# Source this file from other scripts; do NOT execute it directly.
+#
+# Required environment variables (set before sourcing or calling functions):
+#   FREEBSD_SSH_KEY   - path to the private SSH key for VM access
+#
+# Optional environment variables:
+#   FREEBSD_SSH_PORT  - host port forwarded to VM's SSH (default: 2222)
+#   FREEBSD_VM_PIDFILE - path for the QEMU pid file
+#                        (default: /tmp/freebsd-vm.pid)
+
+FREEBSD_SSH_PORT="${FREEBSD_SSH_PORT:-2222}"
+FREEBSD_VM_PIDFILE="${FREEBSD_VM_PIDFILE:-/tmp/freebsd-vm.pid}"
+
+# OVMF firmware paths (Ubuntu 24.04, package: ovmf).
+FREEBSD_OVMF_CODE="/usr/share/OVMF/OVMF_CODE_4M.fd"
+FREEBSD_OVMF_VARS="/usr/share/OVMF/OVMF_VARS_4M.fd"
+
+# SSH options for VM communication.  Host key checking disabled (ephemeral VM).
+_freebsd_ssh_opts() {
+    echo "-p ${FREEBSD_SSH_PORT}"
+    echo "-i ${FREEBSD_SSH_KEY}"
+    echo "-o StrictHostKeyChecking=no"
+    echo "-o UserKnownHostsFile=/dev/null"
+    echo "-o ConnectTimeout=5"
+    echo "-o BatchMode=yes"
+    echo "-o ServerAliveInterval=15"
+    echo "-o ServerAliveCountMax=4"
+    echo "-o LogLevel=ERROR"
+}
+
+# freebsd_kvm_opts
+# Emits KVM flags if /dev/kvm is available, falls back to software emulation.
+freebsd_kvm_opts() {
+    if [ -e /dev/kvm ] && [ -r /dev/kvm ]; then
+        echo "-enable-kvm -cpu host"
+    else
+        echo "WARNING: /dev/kvm not available; falling back to software" \
+             "emulation.  Build times will be significantly longer." >&2
+        echo "-cpu qemu64"
+    fi
+}
+
+# freebsd_start_vm <image.qcow2> [seed.iso] [mem_mb] [cpus] [ovmf_vars.fd]
+# Boots a FreeBSD VM in the background.
+#   image.qcow2  - disk image (may be a COW overlay)
+#   seed.iso     - optional cloud-init NoCloud seed ISO
+#   mem_mb       - RAM in megabytes (default: 4096)
+#   cpus         - vCPU count (default: 4)
+#   ovmf_vars.fd - path to a *writable* copy of FREEBSD_OVMF_VARS for UEFI
+#                  boot.  FreeBSD BASIC-CLOUDINIT images use GPT/EFI and
+#                  require UEFI firmware.  When absent the VM falls back to
+#                  SeaBIOS (legacy BIOS).
+freebsd_start_vm() {
+    local img_file="${1:?freebsd_start_vm: image file required}"
+    local seed_iso="${2:-}"
+    local mem="${3:-4096}"
+    local cpus="${4:-4}"
+    local ovmf_vars="${5:-}"
+
+    local seed_args=""
+    if [ -n "${seed_iso}" ]; then
+        # Attach seed ISO via AHCI controller — AHCI is probed during PCI
+        # bus scan, early enough for nuageinit.  FreeBSD creates
+        # /dev/iso9660/cidata from the volume ID.
+        seed_args="-device ahci,id=ahci0"
+        seed_args="${seed_args} -drive"
+        seed_args="${seed_args} if=none,id=seed_drive"
+        seed_args="${seed_args},file=${seed_iso}"
+        seed_args="${seed_args},format=raw,media=cdrom,readonly=on"
+        seed_args="${seed_args} -device ide-cd,bus=ahci0.0,drive=seed_drive"
+    fi
+
+    # UEFI firmware (OVMF).  FreeBSD BASIC-CLOUDINIT images require GPT/EFI
+    # boot.  The vars file must be a writable per-VM copy.
+    local ovmf_args=""
+    if [ -n "${ovmf_vars}" ]; then
+        ovmf_args="-drive"
+        ovmf_args="${ovmf_args} if=pflash,format=raw"
+        ovmf_args="${ovmf_args},readonly=on,file=${FREEBSD_OVMF_CODE}"
+        ovmf_args="${ovmf_args} -drive if=pflash,format=raw,file=${ovmf_vars}"
+    fi
+
+    # Build the full QEMU command into an array.
+    # shellcheck disable=SC2046
+    local qemu_cmd
+    qemu_cmd=(
+        qemu-system-x86_64
+        $(freebsd_kvm_opts)
+        -m "${mem}"
+        -smp "${cpus}"
+        -nographic
+        -netdev "user,id=net0,hostfwd=tcp::${FREEBSD_SSH_PORT}-:22"
+        -device virtio-net-pci,netdev=net0
+        -drive "file=${img_file},if=virtio,format=qcow2,cache=unsafe"
+        -device virtio-rng-pci
+        -pidfile "${FREEBSD_VM_PIDFILE}"
+    )
+
+    if [ -n "${seed_args}" ]; then
+        # shellcheck disable=SC2206
+        qemu_cmd+=( ${seed_args} )
+    fi
+    if [ -n "${ovmf_args}" ]; then
+        # shellcheck disable=SC2206
+        qemu_cmd+=( ${ovmf_args} )
+    fi
+
+    "${qemu_cmd[@]}" > /tmp/freebsd-vm.log 2>&1 &
+
+    echo "FreeBSD VM launched (PID $!); log: /tmp/freebsd-vm.log"
+}
+
+# freebsd_wait_ssh [max_attempts] [delay_seconds]
+# Polls SSH port until the VM responds or we exhaust attempts.
+# Returns 0 on success, 1 on timeout.
+freebsd_wait_ssh() {
+    local max_attempts="${1:-20}"
+    local delay="${2:-10}"
+
+    echo "Waiting for SSH on localhost:${FREEBSD_SSH_PORT} ..."
+    local i
+    for i in $(seq 1 "${max_attempts}"); do
+        # shellcheck disable=SC2046
+        if ssh $(tr '\n' ' ' < <(_freebsd_ssh_opts)) \
+               root@localhost true 2>/dev/null; then
+            echo "SSH ready after attempt ${i}."
+            return 0
+        fi
+
+        echo "  attempt ${i}/${max_attempts}, retrying in ${delay}s ..."
+
+        if [ "${i}" != "${max_attempts}" ]; then
+            sleep "${delay}"
+        fi
+    done
+
+    echo "ERROR: SSH did not become available after" \
+         "$((max_attempts * delay))s." >&2
+    return 1
+}
+
+# freebsd_wait_firstboot_complete [max_attempts] [delay_seconds]
+# Polls until /firstboot is absent, which means rc.d/firstboot has finished all
+# firstboot-keyed rc scripts — including nuageinit_user_data_script and its
+# "sshd onerestart" runcmd.  Call after freebsd_wait_ssh; without this, SSH
+# commands can be killed mid-flight by that sshd restart.
+freebsd_wait_firstboot_complete() {
+    local max_attempts="${1:-30}"
+    local delay="${2:-5}"
+
+    echo "Waiting for firstboot to complete (/firstboot to be removed) ..."
+    local i
+    for i in $(seq 1 "${max_attempts}"); do
+        if freebsd_ssh "test ! -f /firstboot" 2>/dev/null; then
+            echo "Firstboot sequence complete (attempt ${i})."
+            return 0
+        fi
+        echo "  /firstboot still present (attempt ${i}/${max_attempts})," \
+             "waiting ${delay}s ..."
+        sleep "${delay}"
+    done
+
+    echo "ERROR: /firstboot still present after" \
+         "$((max_attempts * delay))s." >&2
+    return 1
+}
+
+# freebsd_ssh <command ...>
+# Runs a command inside the VM as root.
+freebsd_ssh() {
+    # shellcheck disable=SC2046
+    ssh $(tr '\n' ' ' < <(_freebsd_ssh_opts)) root@localhost "$@"
+}
+
+# freebsd_rsync_to <local_src> <vm_dst>
+# Rsyncs from the host into the VM.
+freebsd_rsync_to() {
+    local src="${1:?freebsd_rsync_to: source required}"
+    local dst="${2:?freebsd_rsync_to: destination required}"
+    # shellcheck disable=SC2046
+    rsync -az --delete \
+        -e "ssh $(tr '\n' ' ' < <(_freebsd_ssh_opts))" \
+        "${src}" "root@localhost:${dst}"
+}
+
+# freebsd_rsync_from <vm_src> <local_dst>
+# Rsyncs from the VM back to the host.
+freebsd_rsync_from() {
+    local src="${1:?freebsd_rsync_from: source required}"
+    local dst="${2:?freebsd_rsync_from: destination required}"
+    # shellcheck disable=SC2046
+    rsync -az \
+        -e "ssh $(tr '\n' ' ' < <(_freebsd_ssh_opts))" \
+        "root@localhost:${src}" "${dst}"
+}
+
+# freebsd_stop_vm
+# Gracefully shuts down the VM; kills QEMU if it does not exit in time.
+freebsd_stop_vm() {
+    if [ ! -f "${FREEBSD_VM_PIDFILE}" ]; then
+        return 0
+    fi
+
+    local pid
+    pid=$(cat "${FREEBSD_VM_PIDFILE}" 2>/dev/null) || return 0
+
+    echo "Shutting down FreeBSD VM (PID ${pid}) ..."
+    freebsd_ssh "shutdown -p now" 2>/dev/null || true
+
+    local i
+    for i in $(seq 1 30); do
+        kill -0 "${pid}" 2>/dev/null || {
+            echo "VM exited cleanly."
+            rm -f "${FREEBSD_VM_PIDFILE}"
+            return 0
+        }
+        sleep 2
+    done
+
+    echo "VM did not stop in time; killing QEMU."
+    kill "${pid}" 2>/dev/null || true
+    rm -f "${FREEBSD_VM_PIDFILE}"
+}
+
+# freebsd_create_seed <pubkey_file> <instance_id> <work_dir> <output_iso>
+#                     [label]
+# Creates a NoCloud seed ISO that injects <pubkey_file> into root
+# authorized_keys
+# and installs CI dependencies via nuageinit.
+#
+# label (optional, default: cidata):
+#   ISO 9660 volume ID passed to genisoimage/mkisofs.  nuageinit on FreeBSD
+#   14.x+ BASIC-CLOUDINIT images recognises the "cidata" NoCloud label.
+freebsd_create_seed() {
+    local pub_key_file="${1:?freebsd_create_seed: public key file required}"
+    local instance_id="${2:?freebsd_create_seed: instance-id required}"
+    local work_dir="${3:-/tmp/freebsd-seed}"
+    local out_iso="${4:-/tmp/freebsd-seed.iso}"
+    local label="${5:-cidata}"
+
+    local pub_key
+    pub_key=$(cat "${pub_key_file}")
+
+    mkdir -p "${work_dir}"
+
+    cat > "${work_dir}/meta-data" <<EOF
+instance-id: ${instance_id}
+local-hostname: freebsd-ci
+EOF
+
+    # py311-* names match FreeBSD 14/15 default Python 3.11.
+    cat > "${work_dir}/user-data" <<EOF
+#cloud-config
+users:
+  - name: root
+    ssh_authorized_keys:
+      - ${pub_key}
+package_update: true
+packages:
+  - automake
+  - libtool
+  - gmake
+  - gcc
+  - openssl
+  - python3
+  - rsync
+  - py311-sphinx
+  - py311-netaddr
+  - py311-pyparsing
+runcmd:
+  - printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config
+  - grep -q kern.coredump /etc/sysctl.conf || echo 'kern.coredump=0' >> 
/etc/sysctl.conf
+  - sysctl -w kern.coredump=0 || true
+  - service sshd onerestart || true
+EOF
+
+    if command -v genisoimage >/dev/null 2>&1; then
+        genisoimage -output "${out_iso}" \
+            -volid "${label}" -rational-rock -joliet \
+            "${work_dir}/user-data" "${work_dir}/meta-data" 2>/dev/null
+    else
+        mkisofs -output "${out_iso}" \
+            -volid "${label}" -rational-rock -joliet \
+            "${work_dir}/user-data" "${work_dir}/meta-data"
+    fi
+
+    echo "Seed ISO created: ${out_iso} (label: ${label})"
+}
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
new file mode 100755
index 000000000..3d57d9c03
--- /dev/null
+++ b/.github/workflows/freebsd.yml
@@ -0,0 +1,160 @@
+name: FreeBSD Build and Test
+
+# Triggers match the existing build-and-test.yml for consistency.
+on: [push, pull_request]
+
+jobs:
+
+  # ---------------------------------------------------------------------------
+  # build-freebsd-image
+  # ---------------------------------------------------------------------------
+  # Downloads the official FreeBSD BASIC-CLOUDINIT image, boots it with QEMU,
+  # lets nuageinit install all CI dependencies and configure SSH access, then
+  # compresses and caches the result.
+  #
+  # On a cache hit the job completes in under 1 minute.  The actual image
+  # preparation only runs when the cache key changes, i.e. when the FreeBSD
+  # version, the prepare/VM-utility scripts, or the upstream image itself
+  # changes.
+  #
+  # The cache key is re-derived independently in build-freebsd, so no job
+  # output is needed to pass the key between jobs.
+  # ---------------------------------------------------------------------------
+  build-freebsd-image:
+    name: freebsd ${{ matrix.freebsd_version }} image
+    runs-on: ubuntu-24.04
+    timeout-minutes: 45
+
+    strategy:
+      fail-fast: false
+      matrix:
+        freebsd_version: ["14.4", "15.0"]
+
+    steps:
+    - name: checkout
+      uses: actions/checkout@v6
+
+    # The cache key covers three inputs:
+    #   - the FreeBSD version (one image per version)
+    #   - SHA-256 of the scripts that control what ends up in the image
+    #   - SHA-256 of the upstream image file from FreeBSD's CHECKSUM.SHA256
+    # Any change to the scripts or a FreeBSD image re-spin busts the cache.
+    - name: generate image cache key
+      id: gen_key
+      run: |
+        img_xz="FreeBSD-${{ matrix.freebsd_version 
}}-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz"
+        base_url="https://download.freebsd.org/releases/VM-IMAGES/${{ 
matrix.freebsd_version }}-RELEASE/amd64/Latest"
+        wget -q "${base_url}/CHECKSUM.SHA256" -O freebsd-upstream-checksum.txt
+        image_sha=$(grep "(${img_xz})" freebsd-upstream-checksum.txt | awk 
'{print $NF}')
+        scripts_hash=$(cat .ci/freebsd-prepare-image.sh .ci/freebsd-vm.sh \
+                         | sha256sum | cut -d' ' -f1)
+        combined=$(printf '%s-%s' "${image_sha}" "${scripts_hash}" \
+                    | sha256sum | cut -d' ' -f1)
+        echo "key=freebsd-${{ matrix.freebsd_version }}-${combined}" \
+          >> "$GITHUB_OUTPUT"
+
+    # /dev/kvm is owned by group kvm (mode 0660) on GitHub-hosted runners.
+    # The runner user is not in that group, so we make it world-accessible.
+    # QEMU requires this for KVM acceleration.
+    - name: enable KVM access
+      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: restore image cache
+      id: image_cache
+      uses: actions/cache@v5
+      with:
+        path: freebsd-${{ matrix.freebsd_version }}.qcow2
+        key:  ${{ steps.gen_key.outputs.key }}
+
+    - name: prepare FreeBSD ${{ matrix.freebsd_version }} image
+      if: steps.image_cache.outputs.cache-hit != 'true'
+      run: ./.ci/freebsd-prepare-image.sh ${{ matrix.freebsd_version }}
+
+  # ---------------------------------------------------------------------------
+  # build-freebsd
+  # ---------------------------------------------------------------------------
+  # Restores the prepared VM image, boots it with QEMU using a COW overlay so
+  # the cached base is never modified, rsyncs the source tree into the VM, and
+  # runs configure -> gmake -j8 -> gmake check.
+  # ---------------------------------------------------------------------------
+  build-freebsd:
+    name: freebsd ${{ matrix.freebsd_version }} ${{ matrix.compiler }}
+    needs: build-freebsd-image
+    runs-on: ubuntu-24.04
+    timeout-minutes: 60
+
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - freebsd_version: "14.4"
+            compiler: gcc
+          - freebsd_version: "14.4"
+            compiler: clang
+          - freebsd_version: "15.0"
+            compiler: gcc
+          - freebsd_version: "15.0"
+            compiler: clang
+
+    env:
+      FREEBSD_VER: ${{ matrix.freebsd_version }}
+      COMPILER:    ${{ matrix.compiler }}
+
+    steps:
+    - name: checkout
+      uses: actions/checkout@v6
+
+    # Re-derive the same cache key used by build-freebsd-image.
+    - name: generate image cache key
+      id: gen_key
+      run: |
+        img_xz="FreeBSD-${{ matrix.freebsd_version 
}}-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz"
+        base_url="https://download.freebsd.org/releases/VM-IMAGES/${{ 
matrix.freebsd_version }}-RELEASE/amd64/Latest"
+        wget -q "${base_url}/CHECKSUM.SHA256" -O freebsd-upstream-checksum.txt
+        image_sha=$(grep "(${img_xz})" freebsd-upstream-checksum.txt | awk 
'{print $NF}')
+        scripts_hash=$(cat .ci/freebsd-prepare-image.sh .ci/freebsd-vm.sh \
+                         | sha256sum | cut -d' ' -f1)
+        combined=$(printf '%s-%s' "${image_sha}" "${scripts_hash}" \
+                    | sha256sum | cut -d' ' -f1)
+        echo "key=freebsd-${{ matrix.freebsd_version }}-${combined}" \
+          >> "$GITHUB_OUTPUT"
+
+    - name: enable KVM access
+      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
+
+    # Restore the prepared image written by build-freebsd-image.  If the cache
+    # is absent (e.g. eviction) the build step will fail with a clear error
+    # message pointing to the missing image file.
+    - name: restore FreeBSD ${{ matrix.freebsd_version }} image
+      uses: actions/cache/restore@v5
+      with:
+        path: freebsd-${{ matrix.freebsd_version }}.qcow2
+        key:  ${{ steps.gen_key.outputs.key }}
+
+    - name: build and test
+      run: ./.ci/freebsd-build.sh
+
+    - name: collect logs on failure
+      if: failure() || cancelled()
+      run: |
+        mkdir -p logs
+        cp config.log              logs/ 2>/dev/null || true
+        cp tests/testsuite.log     logs/ 2>/dev/null || true
+        # testsuite.dir may contain socket files; tar handles them gracefully.
+        sudo tar -czf logs.tgz logs/ tests/testsuite.dir 2>/dev/null || \
+        tar -czf logs.tgz logs/
+
+    - name: upload logs on failure
+      if: failure() || cancelled()
+      uses: actions/upload-artifact@v7
+      with:
+        name: logs-freebsd-${{ matrix.freebsd_version }}-${{ matrix.compiler }}
+        path: logs.tgz
diff --git a/Makefile.am b/Makefile.am
index a805f21d1..ed57ddac5 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -79,6 +79,9 @@ EXTRA_DIST = \
        NOTICE \
        .ci/dpdk-build.sh \
        .ci/dpdk-prepare.sh \
+       .ci/freebsd-build.sh \
+       .ci/freebsd-prepare-image.sh \
+       .ci/freebsd-vm.sh \
        .ci/linux-build.sh \
        .ci/linux-prepare.sh \
        .ci/osx-build.sh \
@@ -88,6 +91,7 @@ EXTRA_DIST = \
        .cirrus.yml \
        .editorconfig \
        .github/workflows/build-and-test.yml \
+       .github/workflows/freebsd.yml \
        .readthedocs.yaml \
        appveyor.yml \
        boot.sh \
diff --git a/README.rst b/README.rst
index 649dc1d38..97c85bd95 100644
--- a/README.rst
+++ b/README.rst
@@ -10,8 +10,8 @@ Open vSwitch
     :target: https://github.com/openvswitch/ovs/actions
 .. image:: 
https://ci.appveyor.com/api/projects/status/github/openvswitch/ovs?branch=main&svg=true&retina=true
     :target: https://ci.appveyor.com/project/blp/ovs/history
-.. image:: https://api.cirrus-ci.com/github/openvswitch/ovs.svg
-    :target: https://cirrus-ci.com/github/openvswitch/ovs
+.. image:: 
https://github.com/openvswitch/ovs/workflows/FreeBSD%20Build%20and%20Test/badge.svg
+    :target: https://github.com/openvswitch/ovs/actions
 .. image:: https://readthedocs.org/projects/openvswitch/badge/?version=latest
     :target: https://docs.openvswitch.org/en/latest/
 
diff --git a/utilities/checkpatch_dict.txt b/utilities/checkpatch_dict.txt
index c1f43e5af..cfeea6e8b 100644
--- a/utilities/checkpatch_dict.txt
+++ b/utilities/checkpatch_dict.txt
@@ -186,6 +186,7 @@ nicira
 nics
 ns
 nsec
+nuageinit
 num
 numa
 odp
@@ -196,6 +197,7 @@ ofpbufs
 ofport
 ofproto
 onwards
+opencode
 openflow
 openssl
 openvswitch
-- 
2.53.0

_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to