diff --git a/Makefile b/Makefile index 9d643fa25..a17a805c3 100644 --- a/Makefile +++ b/Makefile @@ -167,7 +167,7 @@ preflight: # reason: between them they install the software, wire OVN, erase the state and # remove the install, so a shellcheck warning in one is an outage or a wrong # deletion, not a style note. -NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh +NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh scripts/lib/distro.sh test-build-scripts: @echo -e "\n....Running build/scripts/**/*_test.sh...." @@ -175,8 +175,8 @@ test-build-scripts: echo "-- $$t"; \ sh "$$t" || exit 1; \ done - @echo -e "\n....Running scripts/*_test.sh...." - @for t in $$(find scripts -maxdepth 1 -name '*_test.sh' | sort); do \ + @echo -e "\n....Running scripts/*_test.sh and scripts/lib/*_test.sh...." + @for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib -name '*_test.sh') | sort); do \ echo "-- $$t"; \ sh "$$t" || exit 1; \ done diff --git a/build/Dockerfile.distro b/build/Dockerfile.distro index 53687de2b..8085e84cb 100644 --- a/build/Dockerfile.distro +++ b/build/Dockerfile.distro @@ -45,6 +45,7 @@ RUN cd viperblock && GOFIPS140=v1.0.0 go build -o /out/nbdkit-viperblock-plugin. # Copy setup scripts and systemd units RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true RUN cp spinifex/scripts/setup-ovn.sh /out/setup-ovn.sh +RUN mkdir -p /out/lib && cp spinifex/scripts/lib/distro.sh /out/lib/distro.sh RUN mkdir -p /out/systemd && cp spinifex/build/systemd/* /out/systemd/ RUN mkdir -p /out/scripts && cp spinifex/build/scripts/* /out/scripts/ RUN cp spinifex/build/logrotate/spinifex /out/logrotate-spinifex diff --git a/scripts/README.md b/scripts/README.md index 99b673bb8..f5ee02575 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,6 +12,24 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati | `dev-install.sh` | Full local dev setup through the production installer: builds from source, packs a tarball, runs `setup.sh`, initialises the cluster, and starts services under systemd. | | `clone-deps.sh` | Clones or updates the viperblock and predastore checkouts alongside spinifex for cross-repo development. | +### `lib/distro.sh`: host-OS capability probes + +The node scripts select host-OS behaviour by probing for the capability, not by reading `/etc/os-release`; only `setup.sh`'s supported-OS gate keys on the OS ID. +Each probe prints its answer, or names every candidate it tried on stderr and returns 1, which it also does when it finds both candidates of a pair. + +| Probe | Answers | +|---|---| +| `distro_pkg_manager` | `apt` or `dnf` | +| `distro_ovs_unit` | `openvswitch-switch.service` (Debian, Ubuntu) or `openvswitch.service` (EL) | +| `distro_ovn_central_layout` | `split` (Debian's `ovn-central` plus separate NB and SB units) or `lone-northd` (EL's single `ovn-northd.service`) | +| `distro_ca_trust` | the trust-store update command and its anchor directory | +| `distro_ovn_logrotate` | `/etc/logrotate.d/ovn-common` or `/etc/logrotate.d/ovn` | + +`setup-ovn.sh`, `uninstall-spx.sh` and `dev-install.sh` source it from `lib/` beside themselves, then from `/usr/local/share/spinifex/lib/distro.sh`, where `setup.sh` installs it, and stop naming both paths when neither exists. +`setup.sh` is piped from the install endpoint before the tarball is downloaded, and `install-node.sh --wipe` copies `node-reset.sh` alone to each host, so those two carry inline copies of the probes they need between `# --- BEGIN distro.sh probes ---` markers. +`lib/distro_test.sh` fails when an inline copy differs from the library. +Go code uses `spinifex/runtime/host/hostos` for the same purpose. + ## Teardown Both ship to `/usr/local/share/spinifex/` on every node, so they are available on a host installed with `curl … | bash` and not only from a checkout. They differ in what they take away: diff --git a/scripts/dev-install.sh b/scripts/dev-install.sh index 72b8b961a..d5ca8e90d 100755 --- a/scripts/dev-install.sh +++ b/scripts/dev-install.sh @@ -19,6 +19,19 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" MULGA_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)" +# Host-OS capability probes: beside this script in a checkout and in the +# installed share dir, else where setup.sh installs them. +DISTRO_LIB="" +for candidate in "$(dirname "${BASH_SOURCE[0]}")/lib/distro.sh" /usr/local/share/spinifex/lib/distro.sh; do + if [ -f "$candidate" ]; then DISTRO_LIB="$candidate"; break; fi +done +if [ -z "$DISTRO_LIB" ]; then + echo "ERROR: distro.sh not found at $(dirname "${BASH_SOURCE[0]}")/lib/distro.sh or /usr/local/share/spinifex/lib/distro.sh; re-run setup.sh to install it" >&2 + exit 1 +fi +# shellcheck source=lib/distro.sh +. "$DISTRO_LIB" + echo "=== Building ===" cd "$PROJECT_ROOT" && make build cd "$MULGA_ROOT/viperblock" && make go_build_nbd @@ -31,6 +44,8 @@ cp "$MULGA_ROOT/viperblock/lib/nbdkit-viperblock-plugin.so" "$STAGING/" cp "$PROJECT_ROOT/scripts/setup-ovn.sh" "$STAGING/" cp "$PROJECT_ROOT/scripts/node-reset.sh" "$STAGING/" cp "$PROJECT_ROOT/scripts/uninstall-spx.sh" "$STAGING/" +mkdir -p "$STAGING/lib" +cp "$PROJECT_ROOT/scripts/lib/distro.sh" "$STAGING/lib/" mkdir -p "$STAGING/systemd" cp "$PROJECT_ROOT/build/systemd/"* "$STAGING/systemd/" mkdir -p "$STAGING/scripts" diff --git a/scripts/lib/distro.sh b/scripts/lib/distro.sh new file mode 100644 index 000000000..398b4b12c --- /dev/null +++ b/scripts/lib/distro.sh @@ -0,0 +1,105 @@ +# shellcheck shell=bash +# distro.sh — host-OS capability probes. Each prints its answer, or names every +# candidate it tried on stderr and returns 1, also when it finds both of a pair. +# setup.sh and node-reset.sh carry inline copies; distro_test.sh keeps them equal. + +# Reads unit files off disk, not through systemctl, so it answers in a chroot. +# DISTRO_ROOT prefixes every host path the probes read; tests set it. +distro_unit_exists() { + local dir + for dir in /etc/systemd/system /run/systemd/system /usr/lib/systemd/system /lib/systemd/system; do + if [ -e "${DISTRO_ROOT:-}$dir/$1" ] || [ -L "${DISTRO_ROOT:-}$dir/$1" ]; then + return 0 + fi + done + return 1 +} + +distro_pkg_manager() { + local apt=false dnf=false + command -v apt-get >/dev/null 2>&1 && apt=true + command -v dnf >/dev/null 2>&1 && dnf=true + if $apt && $dnf; then + echo "distro.sh: package manager: found both apt-get and dnf; cannot tell which one manages this host" >&2 + return 1 + fi + if $apt; then echo apt; return 0; fi + if $dnf; then echo dnf; return 0; fi + echo "distro.sh: package manager: found neither apt-get nor dnf on PATH" >&2 + return 1 +} + +# The OVS umbrella unit: openvswitch-switch.service on Debian, openvswitch.service on EL. +distro_ovs_unit() { + local deb=false el=false + distro_unit_exists openvswitch-switch.service && deb=true + distro_unit_exists openvswitch.service && el=true + if $deb && $el; then + echo "distro.sh: OVS unit: found both openvswitch-switch.service and openvswitch.service; cannot tell which one owns OVS" >&2 + return 1 + fi + if $deb; then echo openvswitch-switch.service; return 0; fi + if $el; then echo openvswitch.service; return 0; fi + echo "distro.sh: OVS unit: found neither openvswitch-switch.service nor openvswitch.service; is Open vSwitch installed?" >&2 + return 1 +} + +# "split": Debian's ovn-central plus separate NB and SB DB units. +# "lone-northd": EL's single ovn-northd.service, which starts both DB servers. +distro_ovn_central_layout() { + local split=false lone=false unit missing="" + for unit in ovn-central.service ovn-ovsdb-server-nb.service ovn-ovsdb-server-sb.service; do + if distro_unit_exists "$unit"; then + split=true + else + missing="${missing:+$missing, }$unit" + fi + done + if distro_unit_exists ovn-northd.service && [ -f "${DISTRO_ROOT:-}/etc/sysconfig/ovn" ]; then + lone=true + fi + if $split && $lone; then + echo "distro.sh: OVN central layout: found both the split units (ovn-central.service) and a lone ovn-northd.service with /etc/sysconfig/ovn; cannot tell which one owns the OVN databases" >&2 + return 1 + fi + if $split; then + if [ -n "$missing" ]; then + echo "distro.sh: OVN central layout: ovn-central is installed but $missing is missing" >&2 + return 1 + fi + echo split + return 0 + fi + if $lone; then echo lone-northd; return 0; fi + echo "distro.sh: OVN central layout: found neither ovn-central.service nor ovn-northd.service with /etc/sysconfig/ovn; is OVN central installed?" >&2 + return 1 +} + +# The trust-store update command and its local anchor directory, space-separated. +distro_ca_trust() { + local deb=false el=false + command -v update-ca-certificates >/dev/null 2>&1 && deb=true + command -v update-ca-trust >/dev/null 2>&1 && el=true + if $deb && $el; then + echo "distro.sh: CA trust: found both update-ca-certificates and update-ca-trust; cannot tell which trust store TLS clients read" >&2 + return 1 + fi + if $deb; then echo "update-ca-certificates /usr/local/share/ca-certificates"; return 0; fi + if $el; then echo "update-ca-trust /etc/pki/ca-trust/source/anchors"; return 0; fi + echo "distro.sh: CA trust: found neither update-ca-certificates nor update-ca-trust on PATH" >&2 + return 1 +} + +distro_ovn_logrotate() { + local deb=false el=false + [ -f "${DISTRO_ROOT:-}/etc/logrotate.d/ovn-common" ] && deb=true + [ -f "${DISTRO_ROOT:-}/etc/logrotate.d/ovn" ] && el=true + if $deb && $el; then + echo "distro.sh: OVN logrotate: found both /etc/logrotate.d/ovn-common and /etc/logrotate.d/ovn; cannot tell which one the OVN packages own" >&2 + return 1 + fi + if $deb; then echo /etc/logrotate.d/ovn-common; return 0; fi + if $el; then echo /etc/logrotate.d/ovn; return 0; fi + echo "distro.sh: OVN logrotate: found neither /etc/logrotate.d/ovn-common nor /etc/logrotate.d/ovn; is OVN installed?" >&2 + return 1 +} diff --git a/scripts/lib/distro_test.sh b/scripts/lib/distro_test.sh new file mode 100644 index 000000000..bf14ff620 --- /dev/null +++ b/scripts/lib/distro_test.sh @@ -0,0 +1,201 @@ +#!/bin/sh +# distro_test.sh — tests for the host-OS probes and the inline copies of them. +# +# Every probe runs against a fixture tree (DISTRO_ROOT) and a PATH holding only +# stub commands, so the result never depends on the distro running the test. +set -eu + +LIB_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +SCRIPTS_DIR=$(dirname -- "$LIB_DIR") +DISTRO="$LIB_DIR/distro.sh" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +fails=0 +pass() { echo " ok - $1"; } +fail() { echo " FAIL - $1"; fails=$((fails + 1)); } + +echo "-- lib/distro.sh" + +# fixture — a fresh empty host root and stub bin dir. +fixture() { + ROOT="$TMP/$1/root" + BIN="$TMP/$1/bin" + mkdir -p "$ROOT" "$BIN" +} +unit() { mkdir -p "$ROOT/usr/lib/systemd/system" && : > "$ROOT/usr/lib/systemd/system/$1"; } +file() { mkdir -p "$ROOT$(dirname "$1")" && : > "$ROOT$1"; } +cmd() { printf '#!/bin/sh\nexit 0\n' > "$BIN/$1" && chmod +x "$BIN/$1"; } + +# probe — runs one probe in bash against the current fixture, with only +# the stub dir on PATH. Sets OUT, ERR and RC. +probe() { + RC=0 + OUT=$(DISTRO_ROOT="$ROOT" PATH="$BIN" /bin/bash -c '. "$1"; "$2"' _ "$DISTRO" "$1" 2>"$TMP/err") || RC=$? + ERR=$(cat "$TMP/err") +} + +# expect_ok ; expect_fail ... — every word must appear +# in the error, which is how "names every candidate" is checked. +expect_ok() { + if [ "$RC" = 0 ] && [ "$OUT" = "$2" ]; then pass "$1"; else fail "$1 (rc=$RC out='$OUT' err='$ERR')"; fi +} +expect_fail() { + desc=$1; shift + if [ "$RC" = 0 ]; then fail "$desc (succeeded with '$OUT')"; return; fi + for word in "$@"; do + case "$ERR" in + *"$word"*) ;; + *) fail "$desc (error does not name $word: '$ERR')"; return ;; + esac + done + pass "$desc" +} + +# -------------------------------------------------------------------------- +# Package manager. +# -------------------------------------------------------------------------- +fixture pm-apt; cmd apt-get; probe distro_pkg_manager +expect_ok "package manager: apt-get alone is apt" apt +fixture pm-dnf; cmd dnf; probe distro_pkg_manager +expect_ok "package manager: dnf alone is dnf" dnf +fixture pm-both; cmd apt-get; cmd dnf; probe distro_pkg_manager +expect_fail "package manager: both fails naming both" apt-get dnf +fixture pm-none; probe distro_pkg_manager +expect_fail "package manager: neither fails naming both" apt-get dnf + +# -------------------------------------------------------------------------- +# OVS umbrella unit. Unit files are found under any systemd unit directory, and +# a masked unit (a symlink to /dev/null) still counts as installed. +# -------------------------------------------------------------------------- +fixture ovs-deb; unit openvswitch-switch.service; probe distro_ovs_unit +expect_ok "OVS unit: Debian name" openvswitch-switch.service +fixture ovs-el; unit openvswitch.service; probe distro_ovs_unit +expect_ok "OVS unit: EL name" openvswitch.service +fixture ovs-masked; mkdir -p "$ROOT/etc/systemd/system" +ln -s /dev/null "$ROOT/etc/systemd/system/openvswitch.service"; probe distro_ovs_unit +expect_ok "OVS unit: a masked unit in /etc counts" openvswitch.service +fixture ovs-lib; mkdir -p "$ROOT/lib/systemd/system" +: > "$ROOT/lib/systemd/system/openvswitch-switch.service"; probe distro_ovs_unit +expect_ok "OVS unit: found under /lib on an unmerged-/usr host" openvswitch-switch.service +fixture ovs-both; unit openvswitch-switch.service; unit openvswitch.service; probe distro_ovs_unit +expect_fail "OVS unit: both fails naming both" openvswitch-switch.service openvswitch.service +fixture ovs-none; probe distro_ovs_unit +expect_fail "OVS unit: neither fails naming both" openvswitch-switch.service openvswitch.service + +# -------------------------------------------------------------------------- +# OVN central layout. Both distros ship ovn-northd.service, so the unit alone +# must not decide it: Debian's split layout carries one too. +# -------------------------------------------------------------------------- +split_units() { unit ovn-central.service; unit ovn-ovsdb-server-nb.service; unit ovn-ovsdb-server-sb.service; unit ovn-northd.service; } + +fixture ovn-split; split_units; probe distro_ovn_central_layout +expect_ok "OVN layout: Debian split units" split +fixture ovn-lone; unit ovn-northd.service; file /etc/sysconfig/ovn; probe distro_ovn_central_layout +expect_ok "OVN layout: EL lone ovn-northd" lone-northd +fixture ovn-both; split_units; file /etc/sysconfig/ovn; probe distro_ovn_central_layout +expect_fail "OVN layout: both fails naming both" ovn-central.service ovn-northd.service /etc/sysconfig/ovn +fixture ovn-partial; unit ovn-central.service; unit ovn-ovsdb-server-nb.service; probe distro_ovn_central_layout +expect_fail "OVN layout: a partial split install names the missing unit" ovn-ovsdb-server-sb.service +fixture ovn-northd-only; unit ovn-northd.service; probe distro_ovn_central_layout +expect_fail "OVN layout: ovn-northd without /etc/sysconfig/ovn is not EL" ovn-central.service ovn-northd.service /etc/sysconfig/ovn +fixture ovn-none; probe distro_ovn_central_layout +expect_fail "OVN layout: neither fails naming both" ovn-central.service ovn-northd.service + +# -------------------------------------------------------------------------- +# CA trust. +# -------------------------------------------------------------------------- +fixture ca-deb; cmd update-ca-certificates; probe distro_ca_trust +expect_ok "CA trust: Debian command and anchor dir" "update-ca-certificates /usr/local/share/ca-certificates" +fixture ca-el; cmd update-ca-trust; probe distro_ca_trust +expect_ok "CA trust: EL command and anchor dir" "update-ca-trust /etc/pki/ca-trust/source/anchors" +fixture ca-both; cmd update-ca-certificates; cmd update-ca-trust; probe distro_ca_trust +expect_fail "CA trust: both fails naming both" update-ca-certificates update-ca-trust +fixture ca-none; probe distro_ca_trust +expect_fail "CA trust: neither fails naming both" update-ca-certificates update-ca-trust + +# -------------------------------------------------------------------------- +# OVN logrotate file. +# -------------------------------------------------------------------------- +fixture lr-deb; file /etc/logrotate.d/ovn-common; probe distro_ovn_logrotate +expect_ok "OVN logrotate: Debian file" /etc/logrotate.d/ovn-common +fixture lr-el; file /etc/logrotate.d/ovn; probe distro_ovn_logrotate +expect_ok "OVN logrotate: EL file" /etc/logrotate.d/ovn +fixture lr-both; file /etc/logrotate.d/ovn-common; file /etc/logrotate.d/ovn; probe distro_ovn_logrotate +expect_fail "OVN logrotate: both fails naming both" /etc/logrotate.d/ovn-common /etc/logrotate.d/ovn +fixture lr-none; probe distro_ovn_logrotate +expect_fail "OVN logrotate: neither fails naming both" /etc/logrotate.d/ovn-common /etc/logrotate.d/ovn + +# -------------------------------------------------------------------------- +# Inline copies. setup.sh and node-reset.sh cannot source the library, so they +# carry copies between markers; each copied function must match the library's +# byte for byte, or a fix to one silently misses the other. +# -------------------------------------------------------------------------- +# fn_body — the function from "name() {" to the first "}" line. +fn_body() { + awk -v name="$2" '$0 == name "() {" { on = 1 } on { print } on && $0 == "}" { exit }' "$1" +} + +for copy in setup.sh node-reset.sh; do + path="$SCRIPTS_DIR/$copy" + block=$(sed -n '/^# --- BEGIN distro.sh probes ---$/,/^# --- END distro.sh probes ---$/p' "$path") + if [ -z "$block" ]; then + fail "$copy: no distro.sh probe block" + continue + fi + names=$(printf '%s\n' "$block" | sed -n 's/^\([a-z_][a-z0-9_]*\)() {$/\1/p') + if [ -z "$names" ]; then + fail "$copy: the distro.sh probe block holds no functions" + continue + fi + for name in $names; do + want=$(fn_body "$DISTRO" "$name") + got=$(fn_body "$path" "$name") + if [ -z "$want" ]; then + fail "$copy: $name is not in lib/distro.sh" + elif [ "$got" = "$want" ]; then + pass "$copy: $name matches lib/distro.sh" + else + fail "$copy: $name differs from lib/distro.sh" + fi + done + + # A copied probe calling a probe helper the block lacks fails only when run. + for called in $(printf '%s\n' "$block" | grep -v '^ *#' | grep -o 'distro_[a-z_]*' | sort -u); do + case " $(echo $names) " in + *" $called "*) ;; + *) fail "$copy: copies a probe that calls $called without copying it" ;; + esac + done +done + +# -------------------------------------------------------------------------- +# A consumer with no library beside it and none installed must stop naming +# both paths, not run on with the probes undefined. Skipped on a host where +# setup.sh has installed the library, since the fallback then finds it. +# -------------------------------------------------------------------------- +if [ -e /usr/local/share/spinifex/lib/distro.sh ]; then + echo " skip - consumers without distro.sh: /usr/local/share/spinifex/lib/distro.sh is installed here" +else + # uninstall-spx.sh sources the library after its --help, so it is run into + # its multi-node refusal, which exits 1 without touching the host either way. + mkdir -p "$TMP/alone/bin" "$TMP/alone/etc" + printf '#!/bin/sh\nexec "$@"\n' > "$TMP/alone/bin/sudo" + chmod +x "$TMP/alone/bin/sudo" + printf '[nodes.node1]\n[nodes.node2]\n' > "$TMP/alone/etc/spinifex.toml" + for consumer in "setup-ovn.sh --help" "uninstall-spx.sh --dry-run"; do + script=${consumer%% *} + cp "$SCRIPTS_DIR/$script" "$TMP/alone/" + PATH="$TMP/alone/bin:$PATH" SPX_ETC_DIR="$TMP/alone/etc" "$TMP/alone/$script" "${consumer#* }" >"$TMP/out" 2>&1 && rc=0 || rc=$? + if [ "$rc" != 0 ] && grep -q "distro.sh not found at $TMP/alone/lib/distro.sh or /usr/local/share/spinifex/lib/distro.sh" "$TMP/out"; then + pass "$script: stops naming both paths when distro.sh is missing" + else + fail "$script: without distro.sh exited $rc: $(head -3 "$TMP/out")" + fi + done +fi + +if [ "$fails" -gt 0 ]; then + echo "$fails failure(s)" + exit 1 +fi diff --git a/scripts/node-reset.sh b/scripts/node-reset.sh index 6266d52b7..a817a79ed 100755 --- a/scripts/node-reset.sh +++ b/scripts/node-reset.sh @@ -57,6 +57,77 @@ run() { "$@" } +# --- BEGIN distro.sh probes --- +# Copied from scripts/lib/distro.sh: install-node.sh --wipe copies this script +# alone to each host, so it cannot source the library. distro_test.sh keeps them equal. +distro_unit_exists() { + local dir + for dir in /etc/systemd/system /run/systemd/system /usr/lib/systemd/system /lib/systemd/system; do + if [ -e "${DISTRO_ROOT:-}$dir/$1" ] || [ -L "${DISTRO_ROOT:-}$dir/$1" ]; then + return 0 + fi + done + return 1 +} + +distro_ovs_unit() { + local deb=false el=false + distro_unit_exists openvswitch-switch.service && deb=true + distro_unit_exists openvswitch.service && el=true + if $deb && $el; then + echo "distro.sh: OVS unit: found both openvswitch-switch.service and openvswitch.service; cannot tell which one owns OVS" >&2 + return 1 + fi + if $deb; then echo openvswitch-switch.service; return 0; fi + if $el; then echo openvswitch.service; return 0; fi + echo "distro.sh: OVS unit: found neither openvswitch-switch.service nor openvswitch.service; is Open vSwitch installed?" >&2 + return 1 +} + +distro_ovn_central_layout() { + local split=false lone=false unit missing="" + for unit in ovn-central.service ovn-ovsdb-server-nb.service ovn-ovsdb-server-sb.service; do + if distro_unit_exists "$unit"; then + split=true + else + missing="${missing:+$missing, }$unit" + fi + done + if distro_unit_exists ovn-northd.service && [ -f "${DISTRO_ROOT:-}/etc/sysconfig/ovn" ]; then + lone=true + fi + if $split && $lone; then + echo "distro.sh: OVN central layout: found both the split units (ovn-central.service) and a lone ovn-northd.service with /etc/sysconfig/ovn; cannot tell which one owns the OVN databases" >&2 + return 1 + fi + if $split; then + if [ -n "$missing" ]; then + echo "distro.sh: OVN central layout: ovn-central is installed but $missing is missing" >&2 + return 1 + fi + echo split + return 0 + fi + if $lone; then echo lone-northd; return 0; fi + echo "distro.sh: OVN central layout: found neither ovn-central.service nor ovn-northd.service with /etc/sysconfig/ovn; is OVN central installed?" >&2 + return 1 +} + +distro_ca_trust() { + local deb=false el=false + command -v update-ca-certificates >/dev/null 2>&1 && deb=true + command -v update-ca-trust >/dev/null 2>&1 && el=true + if $deb && $el; then + echo "distro.sh: CA trust: found both update-ca-certificates and update-ca-trust; cannot tell which trust store TLS clients read" >&2 + return 1 + fi + if $deb; then echo "update-ca-certificates /usr/local/share/ca-certificates"; return 0; fi + if $el; then echo "update-ca-trust /etc/pki/ca-trust/source/anchors"; return 0; fi + echo "distro.sh: CA trust: found neither update-ca-certificates nor update-ca-trust on PATH" >&2 + return 1 +} +# --- END distro.sh probes --- + WIPE_DIRS=("$ETC_DIR" "$LOG_DIR" "$RUN_DIR") $KEEP_DATA || WIPE_DIRS+=("$DATA_DIR") diff --git a/scripts/setup-ovn.sh b/scripts/setup-ovn.sh index 3bd0735c2..f65784bc0 100755 --- a/scripts/setup-ovn.sh +++ b/scripts/setup-ovn.sh @@ -121,6 +121,19 @@ set -e +# Host-OS capability probes: beside this script in a checkout and in the +# installed share dir, else where setup.sh installs them. +DISTRO_LIB="" +for candidate in "$(dirname "${BASH_SOURCE[0]}")/lib/distro.sh" /usr/local/share/spinifex/lib/distro.sh; do + if [ -f "$candidate" ]; then DISTRO_LIB="$candidate"; break; fi +done +if [ -z "$DISTRO_LIB" ]; then + echo "ERROR: distro.sh not found at $(dirname "${BASH_SOURCE[0]}")/lib/distro.sh or /usr/local/share/spinifex/lib/distro.sh; re-run setup.sh to install it" >&2 + exit 1 +fi +# shellcheck source=lib/distro.sh +. "$DISTRO_LIB" + # Defaults MANAGEMENT=false WAN_BRIDGE="" diff --git a/scripts/setup.sh b/scripts/setup.sh index 1bd592ca7..44fe89b51 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -58,6 +58,24 @@ info() { echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } fatal() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } +# --- BEGIN distro.sh probes --- +# Copied from scripts/lib/distro.sh, which this script installs but cannot source +# when piped from the install endpoint. distro_test.sh keeps the copies equal. +distro_pkg_manager() { + local apt=false dnf=false + command -v apt-get >/dev/null 2>&1 && apt=true + command -v dnf >/dev/null 2>&1 && dnf=true + if $apt && $dnf; then + echo "distro.sh: package manager: found both apt-get and dnf; cannot tell which one manages this host" >&2 + return 1 + fi + if $apt; then echo apt; return 0; fi + if $dnf; then echo dnf; return 0; fi + echo "distro.sh: package manager: found neither apt-get nor dnf on PATH" >&2 + return 1 +} +# --- END distro.sh probes --- + stage() { [ "${VERBOSE:-0}" = "1" ] && echo "[setup] $*" return 0 @@ -92,14 +110,25 @@ setup_sudo() { } # --- OS detection --- +# os_release_field — the value of KEY=value, with one level of quotes removed. +os_release_field() { + sed -n "s/^$1=//p" "$2" | head -n 1 | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/" +} + +# The only place that keys on the OS ID: the gate and, later, the package table. +# Everything else probes for the capability (scripts/lib/distro.sh). detect_os() { - if [ ! -f /etc/os-release ]; then - fatal "Cannot detect OS: /etc/os-release not found" + local os_release="${SPINIFEX_OS_RELEASE:-/etc/os-release}" + if [ ! -f "$os_release" ]; then + fatal "Cannot detect OS: $os_release not found" fi - . /etc/os-release + # Parsed, not sourced, so a malformed file is refused instead of executed. + ID=$(os_release_field ID "$os_release") + VERSION_ID=$(os_release_field VERSION_ID "$os_release") + PRETTY_NAME=$(os_release_field PRETTY_NAME "$os_release") - case "$ID" in + case "${ID:-}" in debian) if [ "${VERSION_ID%%.*}" -lt 13 ] 2>/dev/null; then fatal "Debian $VERSION_ID is not supported. Minimum: Debian 13" @@ -111,12 +140,58 @@ detect_os() { fatal "Ubuntu $VERSION_ID is not supported. Minimum: Ubuntu 24.04" fi ;; + ol) + check_oracle_linux + ;; *) - fatal "Unsupported OS: $ID $VERSION_ID. Spinifex requires Debian 13+ or Ubuntu 24.04+" + fatal "Unsupported OS: ${ID:-unknown} ${VERSION_ID:-}. Spinifex requires Debian 13+, Ubuntu 24.04+ or Oracle Linux 10.2+" ;; esac - info "Detected OS: $PRETTY_NAME" + info "Detected OS: ${PRETTY_NAME:-$ID $VERSION_ID}" +} + +# Oracle Linux 10.2+ on x86_64 only, running UEK R8: the KVM Utilities QEMU +# needs UEK, and Oracle ships kernel modules per kernel, so installing a newer +# kernel-uek alone leaves the running one without openvswitch.ko. +check_oracle_linux() { + local unsupported="Oracle Linux ${VERSION_ID:-} is not supported. Spinifex requires Oracle Linux 10.2 or a later 10.x" + local major="${VERSION_ID%%.*}" minor="${VERSION_ID#*.}" kernel + minor="${minor%%.*}" + case "${VERSION_ID:-}" in *.*) ;; *) fatal "$unsupported" ;; esac + case "$major" in "" | *[!0-9]*) fatal "$unsupported" ;; esac + case "$minor" in "" | *[!0-9]*) fatal "$unsupported" ;; esac + if [ "$major" -ne 10 ] || [ "$minor" -lt 2 ]; then + fatal "$unsupported" + fi + + if [ "$(uname -m)" != "x86_64" ]; then + fatal "Oracle Linux on $(uname -m) is not supported. Spinifex supports Oracle Linux on x86_64 only" + fi + + kernel=$(uname -r) + case "$kernel" in + 6.12.0-*uek*) ;; + *) fatal "The running kernel $kernel is not UEK R8 (6.12.0-*uek). Install kernel-uek, reboot into it, then re-run this installer" ;; + esac + + check_x86_64_v3 +} + +# The NFV SIG OVS/OVN and the KVM Utilities QEMU are built for x86-64-v3, so an +# older CPU installs cleanly and then dies on an illegal instruction. +check_x86_64_v3() { + local cpuinfo="${SPINIFEX_CPUINFO:-/proc/cpuinfo}" flags flag missing="" + flags=" $(grep -m1 '^flags' "$cpuinfo" 2>/dev/null | cut -d: -f2) " + for flag in cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 avx avx2 bmi1 bmi2 f16c fma abm movbe xsave; do + case "$flags" in + *" $flag "*) ;; + *) missing="${missing:+$missing }$flag" ;; + esac + done + if [ -n "$missing" ]; then + fatal "This CPU is below x86-64-v3 (missing: $missing). Oracle Linux's OVS, OVN and QEMU builds require x86-64-v3" + fi } # --- Architecture detection --- @@ -891,6 +966,13 @@ install_files() { info " /usr/local/share/spinifex/setup-ovn.sh" fi + # Sourced, not run, by setup-ovn.sh and uninstall-spx.sh wherever they are + # installed, including the ISO's /usr/local/bin/setup-ovn.sh. + if [ -f "$EXTRACT_DIR/lib/distro.sh" ]; then + $SUDO install -D -m 0644 "$EXTRACT_DIR/lib/distro.sh" /usr/local/share/spinifex/lib/distro.sh + info " /usr/local/share/spinifex/lib/distro.sh" + fi + # Install setup.sh itself so firstboot and future re-runs can find it at a # stable path on both ISO and curl|bash installs. if [ -f "$EXTRACT_DIR/setup.sh" ]; then diff --git a/scripts/setup_test.sh b/scripts/setup_test.sh new file mode 100644 index 000000000..e493027e4 --- /dev/null +++ b/scripts/setup_test.sh @@ -0,0 +1,138 @@ +#!/bin/sh +# setup_test.sh — tests for setup.sh's supported-OS gate. +# +# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs, and +# detect_os reads a fixture os-release, a fixture cpuinfo and a stub uname. +set -eu + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +SETUP="$SCRIPT_DIR/setup.sh" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +fails=0 +pass() { echo " ok - $1"; } +fail() { echo " FAIL - $1"; fails=$((fails + 1)); } + +echo "-- setup.sh OS gate" + +V3_FLAGS="fpu cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 avx avx2 bmi1 bmi2 f16c fma abm movbe xsave" +V2_FLAGS="fpu cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3" +UEK_R8="6.12.0-207.111.5.4.el10uek.x86_64" + +# gate [machine] [kernel] [cpu flags] — runs detect_os and +# sets RC and OUT (stdout and stderr together). +gate() { + printf '%s\n' "$1" > "$TMP/os-release" + printf 'processor\t: 0\nflags\t\t: %s\n' "${4:-$V3_FLAGS}" > "$TMP/cpuinfo" + mkdir -p "$TMP/bin" + cat > "$TMP/bin/uname" <&1) || RC=$? +} +accepts() { + if [ "$RC" = 0 ]; then pass "accepts $1"; else fail "rejects $1: $OUT"; fi +} +# rejects — the refusal must say why, not just exit. +rejects() { + if [ "$RC" = 0 ]; then + fail "accepts $1" + else + case "$OUT" in + *"$2"*) pass "rejects $1" ;; + *) fail "rejects $1 without naming '$2': $OUT" ;; + esac + fi +} + +OL='ID="ol" +NAME="Oracle Linux Server" +PRETTY_NAME="Oracle Linux Server 10.2"' + +gate 'ID=debian +VERSION_ID="13" +PRETTY_NAME="Debian GNU/Linux 13 (trixie)"' +accepts "Debian 13" + +# Debian testing and sid carry no VERSION_ID. The gate has always let them +# through, and Debian behaviour is not this gate's to change. +gate 'ID=debian +PRETTY_NAME="Debian GNU/Linux forky/sid"' +accepts "Debian testing with no VERSION_ID" + +gate 'ID=debian +VERSION_ID="12"' +rejects "Debian 12" "Minimum: Debian 13" + +gate 'ID=ubuntu +VERSION_ID="26.04" +PRETTY_NAME="Ubuntu 26.04 LTS"' +accepts "Ubuntu 26.04" + +gate 'ID=ubuntu +VERSION_ID="22.04"' +rejects "Ubuntu 22.04" "Minimum: Ubuntu 24.04" + +gate "$OL +VERSION_ID=\"10.2\"" +accepts "Oracle Linux 10.2 on UEK R8, x86_64, x86-64-v3" + +gate "$OL +VERSION_ID=\"10.4\"" +accepts "Oracle Linux 10.4" + +gate "$OL +VERSION_ID=\"10.1\"" +rejects "Oracle Linux 10.1" "Oracle Linux 10.2 or a later 10.x" + +gate "$OL +VERSION_ID=\"10\"" +rejects "Oracle Linux with no minor version" "Oracle Linux 10.2 or a later 10.x" + +gate "$OL +VERSION_ID=\"11.0\"" +rejects "Oracle Linux 11" "Oracle Linux 10.2 or a later 10.x" + +gate "$OL +VERSION_ID=\"9.6\"" x86_64 "5.15.0-310.184.5.2.el9uek.x86_64" +rejects "Oracle Linux 9" "Oracle Linux 10.2 or a later 10.x" + +gate "$OL" x86_64 +rejects "Oracle Linux with no VERSION_ID" "Oracle Linux 10.2 or a later 10.x" + +gate "$OL +VERSION_ID=\"10.2\"" aarch64 "6.12.0-207.111.5.4.el10uek.aarch64" +rejects "Oracle Linux on aarch64" "x86_64 only" + +gate "$OL +VERSION_ID=\"10.2\"" x86_64 "6.12.0-55.9.1.el10_0.x86_64" +rejects "Oracle Linux on the Red Hat compatible kernel" "is not UEK R8" + +gate "$OL +VERSION_ID=\"10.2\"" x86_64 "5.15.0-310.184.5.2.el9uek.x86_64" +rejects "Oracle Linux on a UEK older than R8" "is not UEK R8" + +gate "$OL +VERSION_ID=\"10.2\"" x86_64 "$UEK_R8" "$V2_FLAGS" +rejects "Oracle Linux on an x86-64-v2 CPU" "missing: avx avx2 bmi1 bmi2 f16c fma abm movbe xsave" + +gate 'ID="rocky" +VERSION_ID="10.0"' +rejects "Rocky Linux 10" "Unsupported OS: rocky" + +gate 'this is not an os-release file' +rejects "a malformed os-release" "Unsupported OS" + +if [ "$fails" -gt 0 ]; then + echo "$fails failure(s)" + exit 1 +fi diff --git a/scripts/uninstall-spx.sh b/scripts/uninstall-spx.sh index 40e2ca788..da9574e47 100755 --- a/scripts/uninstall-spx.sh +++ b/scripts/uninstall-spx.sh @@ -73,6 +73,19 @@ while [[ $# -gt 0 ]]; do shift done +# Host-OS capability probes: beside this script in a checkout and in the +# installed share dir, else where setup.sh installs them. +DISTRO_LIB="" +for candidate in "$(dirname "${BASH_SOURCE[0]}")/lib/distro.sh" /usr/local/share/spinifex/lib/distro.sh; do + if [ -f "$candidate" ]; then DISTRO_LIB="$candidate"; break; fi +done +if [ -z "$DISTRO_LIB" ]; then + echo "ERROR: distro.sh not found at $(dirname "${BASH_SOURCE[0]}")/lib/distro.sh or /usr/local/share/spinifex/lib/distro.sh; re-run setup.sh to install it" >&2 + exit 1 +fi +# shellcheck source=lib/distro.sh +. "$DISTRO_LIB" + log() { echo "[uninstall-spx] $*"; } run() { if $DRY_RUN; then diff --git a/spinifex/runtime/host/hostos/hostos.go b/spinifex/runtime/host/hostos/hostos.go new file mode 100644 index 000000000..c1e489ac0 --- /dev/null +++ b/spinifex/runtime/host/hostos/hostos.go @@ -0,0 +1,205 @@ +// Package hostos resolves the host tools whose names or paths differ between +// host operating systems. Each resolver probes for the tool itself, never reads +// /etc/os-release, and names every candidate it tried when nothing matches. +package hostos + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" +) + +// QEMUKVMPath is where EL-family distributions install QEMU. It only runs +// guests of the host's own architecture. +const QEMUKVMPath = "/usr/libexec/qemu-kvm" + +// Tool is a host command found by a resolver. +type Tool struct { + // Name is the candidate that matched, such as "grubby" or "update-grub". + Name string + // Path is where it was found on PATH. + Path string +} + +// CATrust is the host's system trust store. +type CATrust struct { + // Update rebuilds the trust bundle from the anchor directory. + Update Tool + // AnchorDir is where locally added CA certificates go. + AnchorDir string +} + +// BootTools are the commands that change the kernel command line and rebuild +// the initramfs. +type BootTools struct { + Bootloader Tool + Initramfs Tool +} + +type qemuArch struct { + binary string + pkg string +} + +// qemuArches maps the guest architectures the VM config uses to the upstream +// QEMU binary and the Debian package that ships it. +var qemuArches = map[string]qemuArch{ + "x86_64": {binary: "qemu-system-x86_64", pkg: "qemu-system-x86"}, + "arm64": {binary: "qemu-system-aarch64", pkg: "qemu-system-arm"}, +} + +var caTrustAnchorDirs = map[string]string{ + "update-ca-certificates": "/usr/local/share/ca-certificates", + "update-ca-trust": "/etc/pki/ca-trust/source/anchors", +} + +var std = newResolver() + +// QEMUBinary returns the QEMU system emulator for guests of arch ("x86_64" or +// "arm64"). QEMUKVMPath wins for host-arch guests, so a stray qemu-system-* on +// PATH never replaces the distro's packaged build. +func QEMUBinary(arch string) (string, error) { + return std.qemuBinary(arch) +} + +// SystemCATrust returns the host's trust-store update command and anchor directory. +func SystemCATrust() (CATrust, error) { + return std.caTrust() +} + +// SystemBootTools returns the host's bootloader and initramfs tools. +func SystemBootTools() (BootTools, error) { + return std.bootTools() +} + +// resolver holds the host lookups behind fields so tests run against a temp +// root and a stubbed PATH. Only successes are cached, so a tool installed +// after a failed lookup is found on the next call. +type resolver struct { + root string + hostArch string + lookPath func(string) (string, error) + + mu sync.Mutex + qemu map[string]string + ca *CATrust + boots *BootTools +} + +func newResolver() *resolver { + return &resolver{ + hostArch: hostArch(runtime.GOARCH), + lookPath: exec.LookPath, + qemu: map[string]string{}, + } +} + +// hostArch maps a GOARCH to the architecture names the VM config uses. +func hostArch(goarch string) string { + switch goarch { + case "amd64": + return "x86_64" + case "arm64": + return "arm64" + default: + return goarch + } +} + +func (r *resolver) qemuBinary(arch string) (string, error) { + want, ok := qemuArches[arch] + if !ok { + return "", fmt.Errorf("no QEMU for guest architecture %q: supported architectures are x86_64 and arm64", arch) + } + + r.mu.Lock() + defer r.mu.Unlock() + if path, ok := r.qemu[arch]; ok { + return path, nil + } + + haveKVM := r.isExecutable(QEMUKVMPath) + if haveKVM && arch == r.hostArch { + r.qemu[arch] = QEMUKVMPath + return QEMUKVMPath, nil + } + + path, err := r.lookPath(want.binary) + if err == nil { + r.qemu[arch] = path + return path, nil + } + if haveKVM { + return "", fmt.Errorf("no QEMU for %s guests: %s (package qemu-kvm) runs only %s guests, and %s (package %s) is not on PATH", + arch, QEMUKVMPath, r.hostArch, want.binary, want.pkg) + } + if arch != r.hostArch { + return "", fmt.Errorf("no QEMU for %s guests: %s (package %s) is not on PATH", arch, want.binary, want.pkg) + } + return "", fmt.Errorf("no QEMU for %s guests: found neither %s (package qemu-kvm) nor %s (package %s) on PATH", + arch, QEMUKVMPath, want.binary, want.pkg) +} + +func (r *resolver) caTrust() (CATrust, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.ca != nil { + return *r.ca, nil + } + update, err := r.pickOne("CA trust store update command", "update-ca-certificates", "update-ca-trust") + if err != nil { + return CATrust{}, err + } + r.ca = &CATrust{Update: update, AnchorDir: caTrustAnchorDirs[update.Name]} + return *r.ca, nil +} + +func (r *resolver) bootTools() (BootTools, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.boots != nil { + return *r.boots, nil + } + bootloader, err := r.pickOne("bootloader tool", "update-grub", "grubby") + if err != nil { + return BootTools{}, err + } + initramfs, err := r.pickOne("initramfs tool", "update-initramfs", "dracut") + if err != nil { + return BootTools{}, err + } + r.boots = &BootTools{Bootloader: bootloader, Initramfs: initramfs} + return *r.boots, nil +} + +// pickOne returns the one candidate on PATH. Finding several is an error too: +// a host carrying both tools of a pair cannot tell us which one it uses. +func (r *resolver) pickOne(what string, candidates ...string) (Tool, error) { + var found []Tool + for _, name := range candidates { + if path, err := r.lookPath(name); err == nil { + found = append(found, Tool{Name: name, Path: path}) + } + } + switch len(found) { + case 1: + return found[0], nil + case 0: + return Tool{}, fmt.Errorf("no %s: found none of %s on PATH", what, strings.Join(candidates, ", ")) + default: + names := make([]string, len(found)) + for i, t := range found { + names[i] = fmt.Sprintf("%s (%s)", t.Name, t.Path) + } + return Tool{}, fmt.Errorf("ambiguous %s: found %s; cannot tell which one this host uses", what, strings.Join(names, " and ")) + } +} + +func (r *resolver) isExecutable(path string) bool { + info, err := os.Stat(filepath.Join(r.root, path)) + return err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 +} diff --git a/spinifex/runtime/host/hostos/hostos_test.go b/spinifex/runtime/host/hostos/hostos_test.go new file mode 100644 index 000000000..2bb0199c5 --- /dev/null +++ b/spinifex/runtime/host/hostos/hostos_test.go @@ -0,0 +1,296 @@ +package hostos + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// fakeHost is a temp root plus a PATH made of a name-to-path map, counting +// lookups so the caching tests can see whether the host was probed again. +type fakeHost struct { + root string + path map[string]string + lookups int +} + +func newFakeHost(t *testing.T) *fakeHost { + t.Helper() + return &fakeHost{root: t.TempDir(), path: map[string]string{}} +} + +func (h *fakeHost) resolver(hostArch string) *resolver { + r := newResolver() + r.root = h.root + r.hostArch = hostArch + r.lookPath = func(name string) (string, error) { + h.lookups++ + if p, ok := h.path[name]; ok { + return p, nil + } + return "", &exec.Error{Name: name, Err: exec.ErrNotFound} + } + return r +} + +func (h *fakeHost) onPath(names ...string) { + for _, n := range names { + h.path[n] = "/usr/bin/" + n + } +} + +func (h *fakeHost) installQEMUKVM(t *testing.T, mode os.FileMode) { + t.Helper() + p := filepath.Join(h.root, QEMUKVMPath) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, nil, mode); err != nil { + t.Fatal(err) + } +} + +// requireErrNames fails unless err is non-nil and mentions every word. +func requireErrNames(t *testing.T, err error, words ...string) { + t.Helper() + if err == nil { + t.Fatalf("want an error naming %v, got nil", words) + } + for _, w := range words { + if !strings.Contains(err.Error(), w) { + t.Errorf("error does not name %q: %v", w, err) + } + } +} + +func TestQEMUBinary_QEMUKVMWinsOverQEMUSystemOnPath(t *testing.T) { + h := newFakeHost(t) + h.installQEMUKVM(t, 0o755) + h.onPath("qemu-system-x86_64") + + got, err := h.resolver("x86_64").qemuBinary("x86_64") + if err != nil { + t.Fatal(err) + } + if got != QEMUKVMPath { + t.Fatalf("got %q, want %q", got, QEMUKVMPath) + } +} + +func TestQEMUBinary_QEMUSystemWithoutQEMUKVM(t *testing.T) { + h := newFakeHost(t) + h.onPath("qemu-system-x86_64") + + got, err := h.resolver("x86_64").qemuBinary("x86_64") + if err != nil { + t.Fatal(err) + } + if got != "/usr/bin/qemu-system-x86_64" { + t.Fatalf("got %q, want the qemu-system-x86_64 on PATH", got) + } +} + +func TestQEMUBinary_NonExecutableQEMUKVMIsIgnored(t *testing.T) { + h := newFakeHost(t) + h.installQEMUKVM(t, 0o644) + h.onPath("qemu-system-x86_64") + + got, err := h.resolver("x86_64").qemuBinary("x86_64") + if err != nil { + t.Fatal(err) + } + if got != "/usr/bin/qemu-system-x86_64" { + t.Fatalf("got %q, want the qemu-system-x86_64 on PATH", got) + } +} + +func TestQEMUBinary_NeitherNamesBothCandidatesAndPackages(t *testing.T) { + h := newFakeHost(t) + + _, err := h.resolver("x86_64").qemuBinary("x86_64") + requireErrNames(t, err, QEMUKVMPath, "qemu-kvm", "qemu-system-x86_64", "qemu-system-x86") +} + +func TestQEMUBinary_ForeignArchIsNeverQEMUKVM(t *testing.T) { + h := newFakeHost(t) + h.installQEMUKVM(t, 0o755) + + _, err := h.resolver("x86_64").qemuBinary("arm64") + requireErrNames(t, err, QEMUKVMPath, "runs only x86_64 guests", "qemu-system-aarch64", "qemu-system-arm") +} + +func TestQEMUBinary_ForeignArchUsesQEMUSystemBesideQEMUKVM(t *testing.T) { + h := newFakeHost(t) + h.installQEMUKVM(t, 0o755) + h.onPath("qemu-system-aarch64") + + got, err := h.resolver("x86_64").qemuBinary("arm64") + if err != nil { + t.Fatal(err) + } + if got != "/usr/bin/qemu-system-aarch64" { + t.Fatalf("got %q, want the qemu-system-aarch64 on PATH", got) + } +} + +func TestQEMUBinary_ForeignArchWithoutQEMUKVMNamesOnlyQEMUSystem(t *testing.T) { + h := newFakeHost(t) + + _, err := h.resolver("x86_64").qemuBinary("arm64") + requireErrNames(t, err, "qemu-system-aarch64", "qemu-system-arm") + if strings.Contains(err.Error(), QEMUKVMPath) { + t.Errorf("error offers qemu-kvm, which cannot run arm64 guests on x86_64: %v", err) + } +} + +func TestQEMUBinary_UnknownArch(t *testing.T) { + h := newFakeHost(t) + h.onPath("qemu-system-riscv64") + + _, err := h.resolver("x86_64").qemuBinary("riscv64") + requireErrNames(t, err, `"riscv64"`, "x86_64", "arm64") +} + +func TestQEMUBinary_CachesSuccessPerArch(t *testing.T) { + h := newFakeHost(t) + h.onPath("qemu-system-x86_64", "qemu-system-aarch64") + r := h.resolver("x86_64") + + first, err := r.qemuBinary("x86_64") + if err != nil { + t.Fatal(err) + } + lookups := h.lookups + h.installQEMUKVM(t, 0o755) + + again, err := r.qemuBinary("x86_64") + if err != nil { + t.Fatal(err) + } + if again != first || h.lookups != lookups { + t.Fatalf("second call re-probed: got %q (first %q), lookups %d -> %d", again, first, lookups, h.lookups) + } + + other, err := r.qemuBinary("arm64") + if err != nil { + t.Fatal(err) + } + if other != "/usr/bin/qemu-system-aarch64" { + t.Fatalf("arm64 got %q from the x86_64 cache entry", other) + } +} + +func TestQEMUBinary_FailureIsNotCached(t *testing.T) { + h := newFakeHost(t) + r := h.resolver("x86_64") + + if _, err := r.qemuBinary("x86_64"); err == nil { + t.Fatal("want an error before QEMU is installed") + } + h.installQEMUKVM(t, 0o755) + + got, err := r.qemuBinary("x86_64") + if err != nil { + t.Fatalf("QEMU installed after a failed lookup is not found: %v", err) + } + if got != QEMUKVMPath { + t.Fatalf("got %q, want %q", got, QEMUKVMPath) + } +} + +func TestHostArch(t *testing.T) { + for goarch, want := range map[string]string{"amd64": "x86_64", "arm64": "arm64"} { + if got := hostArch(goarch); got != want { + t.Errorf("hostArch(%q) = %q, want %q", goarch, got, want) + } + } +} + +func TestSystemCATrust(t *testing.T) { + cases := []struct { + name string + onPath []string + wantTool string + wantDir string + wantError []string + }{ + {name: "debian", onPath: []string{"update-ca-certificates"}, wantTool: "update-ca-certificates", wantDir: "/usr/local/share/ca-certificates"}, + {name: "el", onPath: []string{"update-ca-trust"}, wantTool: "update-ca-trust", wantDir: "/etc/pki/ca-trust/source/anchors"}, + {name: "both", onPath: []string{"update-ca-certificates", "update-ca-trust"}, wantError: []string{"/usr/bin/update-ca-certificates", "/usr/bin/update-ca-trust"}}, + {name: "neither", wantError: []string{"update-ca-certificates", "update-ca-trust"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newFakeHost(t) + h.onPath(tc.onPath...) + + got, err := h.resolver("x86_64").caTrust() + if tc.wantError != nil { + requireErrNames(t, err, tc.wantError...) + return + } + if err != nil { + t.Fatal(err) + } + if got.Update.Name != tc.wantTool || got.Update.Path != "/usr/bin/"+tc.wantTool || got.AnchorDir != tc.wantDir { + t.Fatalf("got %+v, want %s in %s", got, tc.wantTool, tc.wantDir) + } + }) + } +} + +func TestSystemCATrust_CachesSuccess(t *testing.T) { + h := newFakeHost(t) + h.onPath("update-ca-certificates") + r := h.resolver("x86_64") + + if _, err := r.caTrust(); err != nil { + t.Fatal(err) + } + h.onPath("update-ca-trust") + + got, err := r.caTrust() + if err != nil { + t.Fatalf("cached result was re-probed: %v", err) + } + if got.Update.Name != "update-ca-certificates" { + t.Fatalf("got %q, want the cached update-ca-certificates", got.Update.Name) + } +} + +func TestSystemBootTools(t *testing.T) { + cases := []struct { + name string + onPath []string + wantBootloader string + wantInitramfs string + wantError []string + }{ + {name: "debian", onPath: []string{"update-grub", "update-initramfs"}, wantBootloader: "update-grub", wantInitramfs: "update-initramfs"}, + {name: "el", onPath: []string{"grubby", "dracut"}, wantBootloader: "grubby", wantInitramfs: "dracut"}, + {name: "both bootloader tools", onPath: []string{"update-grub", "grubby", "dracut"}, wantError: []string{"update-grub", "grubby"}}, + {name: "both initramfs tools", onPath: []string{"grubby", "update-initramfs", "dracut"}, wantError: []string{"update-initramfs", "dracut"}}, + {name: "no bootloader tool", onPath: []string{"dracut"}, wantError: []string{"update-grub", "grubby"}}, + {name: "no initramfs tool", onPath: []string{"grubby"}, wantError: []string{"update-initramfs", "dracut"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newFakeHost(t) + h.onPath(tc.onPath...) + + got, err := h.resolver("x86_64").bootTools() + if tc.wantError != nil { + requireErrNames(t, err, tc.wantError...) + return + } + if err != nil { + t.Fatal(err) + } + if got.Bootloader.Name != tc.wantBootloader || got.Initramfs.Name != tc.wantInitramfs { + t.Fatalf("got %+v, want %s and %s", got, tc.wantBootloader, tc.wantInitramfs) + } + }) + } +}