Compare commits

...

22 Commits

Author SHA1 Message Date
Julian Sommer bcf28b7af4 fix: read the recorded firewall mode through sudo, test the NFV SIG key and AppStream exclude, drop dev-install's unused distro.sh 2026-10-26 15:43:07 +10:00
Julian Sommer c628ae97c4 docs: drop the Oracle Linux section from the air-gapped install guide 2026-10-26 15:27:53 +10:00
Julian Sommer 48b029bfae docs: add the Oracle Linux hosts guide and Oracle Linux notes across install, GPU and security docs 2026-10-26 15:17:51 +10:00
Julian Sommer 58248c9d28 feat: add Oracle Linux single-node cells to the nightly matrix
Cells 34-36 run the basic single-node subset, reboot-single and
nat-single on the Oracle Linux gold image, selected through
host_image_override and the new host_os tofu variable. The OVN RAFT
test stops ovn-northd where the host has no ovn-central unit.
2026-10-26 15:07:47 +10:00
Julian Sommer 3187764fd1 fix: refuse uninstall --purge-deps on dnf hosts
Its fixed package list cannot tell what setup.sh installed from what the host
already had, and dnf also removes every package that requires them, so on OL it
would have taken chrony, less, ethtool, git and kdump-utils. The apt path is
unchanged.
2026-10-26 14:49:42 +10:00
Julian Sommer 6a2438e7b6 fix: leave no bridge, NAT veth or tmpfiles drop-in behind on reset and uninstall
node-reset.sh deleted the OVS bridges while ovn-controller was running, which
recreated br-int at once; it now stops ovn-controller first. It also deletes
the routed-NAT transit veth and its networkd units, as it already did for
veth-wan. uninstall-spx.sh removes /etc/tmpfiles.d/spinifex.conf, which names
the deleted spinifex group, and /var/lib/spinifex-reset once it is empty.
2026-10-26 14:34:53 +10:00
Julian Sommer 12b09ac296 fix: keep IMDS endpoints loose under EL rp_filter and find the AWS CLI under sudo
EL's 50-redhat.conf sets every interface to strict rp_filter, and both
sysctl --system and udev's per-link systemd-sysctl reapply it, so a
setup-ovn.sh rerun or a racing launch left ime-* endpoints strict and guest
IMDS requests timed out. 99-spinifex-vpc.conf now sets ime-* to 0.

install_aws_cli looked up aws on PATH only; sudo's secure_path on OL leaves
out /usr/local/bin, so every run reinstalled the CLI and printed
"aws: command not found".
2026-10-26 14:34:52 +10:00
Julian Sommer 66313bd342 feat: operator scripts and release contents for Oracle Linux hosts
install-node.sh refuses a cluster that mixes OS families and refuses --ipsec
on libreswan hosts. node-reset.sh and reset-dev-env.sh resolve the OVS unit
and stop every installed OVN central unit. uninstall-spx.sh purges with dnf on
EL, removes the resolved drop-in, keeps firewalld disabled after unmasking and
lists the settings it leaves in place. Smoke and dev scripts run the AWS CLI by
absolute path. The release tarball ships install-node.sh, smoke-test.sh and the
udev rules, and the build fails when a binary needs glibc above 2.34.
2026-10-26 12:22:25 +10:00
Julian Sommer c3b5bb1873 Merge commit '57ea08be2ac4957cd1b931f38a93de8e2da24e8d' into feat/oracle-linux 2026-10-26 12:03:14 +10:00
Julian Sommer 464be8972c feat: keep IPsec off on libreswan hosts
On a host whose IPsec stack is libreswan, setup-ovn.sh stops and masks
openvswitch-ipsec and ipsec.service, the state helper masks ipsec.service
when turning IPsec off, and the daemon's reconciler counts it in the off
state and never turns IPsec on there. spx admin init defaults --ipsec to
false on such a host and refuses --ipsec=true.

libreswan is detected by its IKE daemon, /usr/libexec/ipsec/pluto. On
Debian ipsec.service is an alias of strongswan-starter and is not touched.
2026-10-26 12:00:40 +10:00
Julian Sommer 83cf04f4d8 feat: run standalone OVN central on EL's lone ovn-northd and record the management role
setup-ovn.sh --management writes the ovn-ctl flags to /etc/sysconfig/ovn-northd
as OVN_NORTHD_OPTS where EL's ovn-northd.service runs the OVN databases, and
skips the no-manage-ovsdb override there. Every OS records the role in
/etc/spinifex/ovn-role; the OVN_CTL_OPTS line older Debian nodes carry still
counts, and the refusal names each marker.

The OVS and OVN logrotate files gain maxsize 100M, plus rotate 5 where they
set no count, and rotate as root: EL's OVN file rotated as openvswitch, which
fails once OVN runs as root. node-reset.sh removes the EL options file.
2026-10-26 11:38:42 +10:00
Julian Sommer e739cf3a27 Merge commit '10fc0af2c0e749d9333e2726efbd2990ed358eab' into feat/oracle-linux 2026-10-26 11:25:24 +10:00
Julian Sommer abcbaeb2c2 feat: prepare GPU passthrough hosts with grubby and dracut, and verify every GPU function
spx admin gpu setup resolves its bootloader and initramfs tools before any
change. On grubby hosts it adds the IOMMU arguments to every boot entry and
/etc/kernel/cmdline and checks both. On dracut hosts it writes a force_drivers
config that also installs the modprobe.d files, regenerates every image and
checks each with lsinitrd. The post-reboot run checks every function in each
GPU's slot is bound to vfio-pci.
2026-10-26 11:23:28 +10:00
Julian Sommer 4a065a7546 Merge commit '524c20db8bb1a3170f98d47984ac530ef76741a4' into feat/oracle-linux 2026-10-26 11:02:09 +10:00
Julian Sommer d548535fcf feat: hand host DNS to systemd-resolved, probe CA trust and chrony, link spx for sudo
setup.sh turns on systemd-resolved behind NetworkManager where resolved is
installed but stopped and no real resolvconf exists, saving /etc/resolv.conf
once and stopping unless resolved has upstream servers and the package
repository still resolves. hostdns no longer treats resolvectl's resolvconf
link as a resolvconf backend, and preflight warns when resolved is active
but /etc/resolv.conf is not its stub.

CA trust goes through the hostos probe and a failure stops admin init and
join before they write anything. The chrony drop-in directory is probed,
with a confdir line appended on EL. /usr/bin/spx links to /usr/local/bin/spx
where sudo's PATH leaves it out, and the nbdkit plugin directory comes only
from nbdkit --dump-config.
2026-10-26 10:58:02 +10:00
Julian Sommer a54ea1beb5 feat: mask firewalld, fail preflight on an active firewalld or ufw, and load OVS kernel modules at boot
setup.sh masks firewalld wherever it is installed, after recording the
firewall mode, so a retry still arms the Spinifex policy. An unset
--firewall keeps the recorded mode, or arms the policy when firewalld was
active. A new modules stage writes /etc/modules-load.d/spinifex.conf and
loads openvswitch, geneve, vhost_net and tun on every OS.

spx admin preflight fails, and the daemon logs an error, while firewalld
or an enabled ufw is active. uninstall-spx.sh unmasks firewalld and
leaves it disabled.
2026-10-26 10:39:33 +10:00
Julian Sommer 44df3096ac Merge commit 'ac403f57bc46624e2055c13cdf4102f967415ea0' into feat/oracle-linux 2026-10-26 10:29:26 +10:00
Julian Sommer 98b864249c feat: persist host network wiring without systemd-networkd
setup-ovn.sh records a persistence backend on its first run: networkd where
systemd-networkd is enabled or Spinifex networkd files exist, hostnet
otherwise. On hostnet it writes /etc/spinifex/hostnet.json under a flock,
pending until the run finishes, and spinifex-hostnet.service replays it with
spx admin hostnet apply at boot and on every ovs-vswitchd start. The daemon
and vpcd run spx admin hostnet check before starting.

Where NetworkManager runs, setup-ovn.sh leaves the links Spinifex owns
unmanaged (a keyfile, plus a udev rule for management TAPs, since NM globs
have no character classes) and disconnects NICs before moving them into OVS,
refusing the default-route or SSH NIC.
2026-10-21 15:34:47 +10:00
Julian Sommer aebc2f0efb feat: order Spinifex units against OVS names every distro ships, and probe OVS/OVN units in setup-ovn
spinifex.target, the daemon and vpcd now order against ovsdb-server,
ovs-vswitchd and ovn-controller instead of Debian's openvswitch-switch,
which EL does not have, and want spinifex-hostnet without requiring it.
A unit test fails when a unit names an OVS or OVN unit outside that set.

setup-ovn.sh takes the OVS umbrella and the OVN central units from the
distro.sh probes, runs OVS and OVN as root where /etc/sysconfig sets their
users, and hooks the socket-permission helper onto ovs-vswitchd (and EL's
ovn-northd), so a standalone restart of either OVS daemon keeps the sockets
usable by the service users. It stops when an OVS or OVN unit the
installed Spinifex units name is not installed.
2026-10-21 15:09:40 +10:00
Julian Sommer 0c488d9470 feat: resolve QEMU per host, match qemu-kvm, and boot microvms on a shipped qboot
QEMU is now run from the hostos resolver, so an Oracle Linux host uses
/usr/libexec/qemu-kvm. The orphan scan, node-reset.sh, reset-dev-env.sh,
run-bench.sh and the e2e harness all treat qemu-kvm as a guest process.

System instances boot qboot passed with -bios. build-qboot.sh builds it from
a pinned upstream commit in a digest-pinned container; the output matches
Debian 13's qboot.rom byte for byte. The tarball ships it with its licence
and releases publish the source. The daemon refuses a launch without it, and
spx admin preflight flags a QEMU with no microvm machine or a missing qboot.

reset-dev-env.sh's pkill -x qemu-system-x86_64 never matched a truncated
comm; it now uses node-reset.sh's owner-scoped match.
2026-10-21 14:46:18 +10:00
Julian Sommer 9a3ab0468d feat: install Oracle Linux dependencies with dnf
setup.sh picks apt or dnf with distro_pkg_manager. On Oracle Linux it enables
ol10_UEKR8 and ol10_kvm_utils, excludes QEMU from AppStream, adds the CentOS
NFV SIG repo with a fingerprint-checked key and an includepkgs allowlist,
installs the OL package list and the running kernel's kernel-uek-modules, and
loads the OVS and tap modules. The OL gate refuses SELinux enforcing now or at
next boot. make install-system takes the same path, and lint-dnf-packages.sh
resolves the list against oraclelinux:10 in CI. The apt list is unchanged.
2026-10-21 14:25:17 +10:00
Julian Sommer 520478dc37 feat: Oracle Linux supported-OS gate and host capability probes
setup.sh accepts Oracle Linux 10.2+ (<11) on x86_64 with a running UEK R8
kernel and an x86-64-v3 CPU, beside Debian 13+ and Ubuntu 24.04+, and parses
os-release instead of sourcing it. New scripts/lib/distro.sh probes the
package manager, OVS umbrella unit, OVN central layout, CA trust store and
OVN logrotate file; it ships in the release tarball, is installed to
/usr/local/share/spinifex/lib/, and is sourced by setup-ovn.sh,
uninstall-spx.sh and dev-install.sh. setup.sh and node-reset.sh carry inline
copies, kept identical by scripts/lib/distro_test.sh.

New runtime/host/hostos resolves the QEMU binary, CA trust store and boot
tools by probing, naming every candidate on failure. Callers are wired in
later stages.
2026-10-21 14:11:54 +10:00
94 changed files with 8386 additions and 551 deletions
+33
View File
@@ -190,5 +190,38 @@
"extra": "",
"suite": "kvquorum",
"timeout_minutes": 55
},
{
"cell": 34,
"install": "binary",
"topo": "single",
"net": "dhcp",
"host": "oracle-10",
"arch": "x86_64",
"runner": "ci-single",
"extra": "",
"suite": ""
},
{
"cell": 35,
"install": "binary",
"topo": "single",
"net": "static",
"host": "oracle-10",
"arch": "x86_64",
"runner": "ci-single",
"extra": "reboot-single",
"suite": ""
},
{
"cell": 36,
"install": "binary",
"topo": "single",
"net": "nat",
"host": "oracle-10",
"arch": "x86_64",
"runner": "ci-single",
"extra": "nat-single",
"suite": ""
}
]
+15 -1
View File
@@ -2,7 +2,8 @@ name: Apt Packages
# Path-filtered like image-scripts.yml: this resolves setup.sh's runtime package
# list against each supported suite's real indices, so a renamed, dropped, or
# ambiguously virtual package fails here rather than on a user's machine.
# ambiguously virtual package fails here rather than on a user's machine. The
# dnf job does the same for Oracle Linux, through setup.sh's own repo setup.
on:
push:
branches:
@@ -11,6 +12,7 @@ on:
paths:
- scripts/setup.sh
- scripts/lint-apt-packages.sh
- scripts/lint-dnf-packages.sh
- .github/workflows/apt-packages.yml
pull_request:
branches:
@@ -19,6 +21,7 @@ on:
paths:
- scripts/setup.sh
- scripts/lint-apt-packages.sh
- scripts/lint-dnf-packages.sh
- .github/workflows/apt-packages.yml
workflow_dispatch:
@@ -44,3 +47,14 @@ jobs:
- name: Resolve runtime package list
run: ./scripts/lint-apt-packages.sh ${{ matrix.image }}
dnf_packages:
name: Resolve dnf list (oraclelinux:10)
runs-on: ubuntu-26.04
steps:
- name: Checkout Code
uses: actions/checkout@v7.0.1
- name: Resolve runtime package list
run: ./scripts/lint-dnf-packages.sh oraclelinux:10
+2 -1
View File
@@ -102,7 +102,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
if: steps.version.outputs.exists != 'true'
run: cd spinifex && make build-microvm-image
@@ -144,6 +144,7 @@ jobs:
--notes-file "$RUNNER_TEMP/notes.md" \
"dist/spinifex-$TAG-linux-amd64.tar.gz" \
"dist/spinifex-$TAG-linux-amd64.tar.gz.sha256" \
dist/qboot-*.tar.gz \
"scripts/setup.sh"
# The assertion that matters. A prerelease must never become the latest
+7 -4
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
cells:
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 34-36 Oracle Linux single node (basic, reboot, routed NAT), 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
required: false
permissions:
@@ -33,6 +33,7 @@ concurrency:
env:
GIT_BRANCH: ${{ github.ref_name }}
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-26.04.qcow2
ORACLE_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-oracle-10.qcow2
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
@@ -195,7 +196,8 @@ jobs:
# back onto the debian gold.
TOFU_VAR_ARGS=""
case "$MATRIX_HOST" in
ubuntu-*) TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}" ;;
ubuntu-*) TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE} -var=host_os=ubuntu" ;;
oracle-*) TOFU_VAR_ARGS="-var=host_image_override=${ORACLE_GOLD_IMAGE} -var=host_os=oracle" ;;
esac
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
@@ -687,7 +689,7 @@ jobs:
'echo "host: $(hostname)"; echo "loadavg: $(cat /proc/loadavg)";
grep -E "^(MemTotal|MemAvailable|SwapFree):" /proc/meminfo;
echo "cpu(steal is field 8): $(grep "^cpu " /proc/stat)";
echo "running qemu: $(pgrep -c qemu-system-x86 || echo 0)"' 2>&1
echo "running qemu: $(pgrep -c "qemu-system-x86|qemu-kvm" || echo 0)"' 2>&1
} >> "${ARTIFACT_DIR}/node-load.txt"
exit 0
@@ -858,7 +860,8 @@ jobs:
# different image path than apply did.
TOFU_VAR_ARGS=""
case "$MATRIX_HOST" in
ubuntu-*) TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}" ;;
ubuntu-*) TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE} -var=host_os=ubuntu" ;;
oracle-*) TOFU_VAR_ARGS="-var=host_image_override=${ORACLE_GOLD_IMAGE} -var=host_os=oracle" ;;
esac
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
+2 -1
View File
@@ -57,7 +57,7 @@ jobs:
go-version-file: spinifex/go.mod
cache: false
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
run: cd spinifex && make build-microvm-image
- name: Build amd64 Tarball
@@ -72,6 +72,7 @@ jobs:
path: |
spinifex/dist/spinifex-${{ github.ref_name }}-linux-amd64.tar.gz
spinifex/dist/spinifex-${{ github.ref_name }}-linux-amd64.tar.gz.sha256
spinifex/dist/qboot-*.tar.gz
spinifex/scripts/setup.sh
distro-arm64:
+3
View File
@@ -19,6 +19,9 @@ bin/
dist/
build/microvm/vmlinuz
build/microvm/initramfs.cpio.gz
build/microvm/qboot.rom
build/microvm/qboot.LICENSE
build/qboot/
!spinifex/runtime/roles/spinifexui/frontend/dist/
tmp/
*.test
+45 -23
View File
@@ -140,12 +140,23 @@ $(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
bin/lb-agent:
$(MAKE) build-lb-agent
build-microvm-image: $(MICROVM_ARTIFACTS) ## Build microVM kernel + initramfs (incremental — skips when up to date)
# qboot is the microvm firmware. The source tarball ships with each release
# because qboot is GPL-2.0.
QBOOT_COMMIT := $(shell sed -n 's/^QBOOT_COMMIT="\(.*\)"$$/\1/p' scripts/build-qboot.sh)
QBOOT_SOURCE := build/qboot/qboot-$(QBOOT_COMMIT).tar.gz
QBOOT_ARTIFACTS := $(MICROVM_OUT_DIR)/qboot.rom $(MICROVM_OUT_DIR)/qboot.LICENSE $(QBOOT_SOURCE)
install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share/spinifex/microvm/
$(QBOOT_ARTIFACTS) &: scripts/build-qboot.sh
./scripts/build-qboot.sh
build-microvm-image: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Build microVM kernel, initramfs and qboot firmware (incremental — skips when up to date)
install-microvm: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Install microVM artifacts to /usr/share/spinifex/microvm/
sudo install -d /usr/share/spinifex/microvm
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
sudo install -m 0644 $(MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/initramfs.cpio.gz
sudo install -m 0644 $(MICROVM_OUT_DIR)/qboot.rom /usr/share/spinifex/microvm/qboot.rom
sudo install -m 0644 $(MICROVM_OUT_DIR)/qboot.LICENSE /usr/share/spinifex/microvm/qboot.LICENSE
# the pre-commit gate: manifest checks, lint, vuln, and the unit and e2e-harness tiers.
# integration and race tests skipped to keep quick, they run in CI.
@@ -167,7 +178,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 +186,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
@@ -333,14 +344,20 @@ fuzz:
# Microvm artifacts are reinstalled when they already exist on disk — the rule's
# input timestamps drive a rebuild only if anything actually changed. On a fresh
# checkout (no build/microvm/vmlinuz yet) the install step is skipped; run
# `make install-microvm` explicitly the first time.
# `make install-microvm` explicitly the first time. They go in before the
# preflight, which checks the microvm firmware.
deploy: build
sudo install -m 755 bin/spx /usr/local/bin/spx
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ]; then \
$(MAKE) install-microvm; \
else \
echo "[deploy] microvm artifacts absent — run 'make install-microvm' for first-time setup"; \
fi
@if [ "$${SKIP_ASSET_PREFLIGHT:-}" != "1" ]; then \
if ! sudo /usr/local/bin/spx admin preflight; then \
echo ""; \
echo "[deploy] host-asset preflight failed: this node's helper scripts or sudoers"; \
echo " grants don't match the binary just installed. Remediate with:"; \
echo "[deploy] host-asset preflight failed: this node's helper scripts, sudoers"; \
echo " grants, QEMU or microvm firmware don't match the binary just installed. Remediate with:"; \
echo " scripts/update-nodes.sh (from the mulga root)"; \
echo " or:"; \
echo " make reinstall"; \
@@ -350,11 +367,6 @@ deploy: build
else \
echo "[deploy] SKIP_ASSET_PREFLIGHT=1 — skipping host-asset preflight"; \
fi
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ]; then \
$(MAKE) install-microvm; \
else \
echo "[deploy] microvm artifacts absent — run 'make install-microvm' for first-time setup"; \
fi
sudo systemctl daemon-reload
sudo systemctl restart spinifex.target
@@ -367,16 +379,25 @@ clean:
rm -f ./bin/$(GO_PROJECT_NAME)
rm -rf spinifex/runtime/roles/spinifexui/frontend/dist
# On dnf hosts the runtime packages and repositories come from setup.sh, so the
# two cannot drift; nbdkit-devel, for building the plugin, is only in CodeReady.
install-system:
@echo -e "\n....Installing system dependencies for $(ARCH)...."
sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
-o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" \
nbdkit nbdkit-plugin-dev pkg-config qemu-system-x86 qemu-system-arm qemu-utils \
ovmf qemu-efi-aarch64 \
libvirt-daemon-system libvirt-clients libvirt-dev make gcc jq curl \
iproute2 netcat-openbsd openssh-client wget git unzip sudo xz-utils file \
ovn-central ovn-host openvswitch-switch dhcpcd-base
@pm=$$(INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. scripts/setup.sh && distro_pkg_manager') || exit 1; \
if [ "$$pm" = dnf ]; then \
INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. scripts/setup.sh && setup_sudo && detect_os && install_dnf_deps' && \
sudo dnf config-manager --set-enabled ol10_codeready_builder && \
sudo dnf install -y nbdkit-devel pkgconf-pkg-config make gcc openssh-clients git sudo; \
else \
sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
-o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" \
nbdkit nbdkit-plugin-dev pkg-config qemu-system-x86 qemu-system-arm qemu-utils \
ovmf qemu-efi-aarch64 \
libvirt-daemon-system libvirt-clients libvirt-dev make gcc jq curl \
iproute2 netcat-openbsd openssh-client wget git unzip sudo xz-utils file \
ovn-central ovn-host openvswitch-switch dhcpcd-base; \
fi
install-go:
@echo -e "\n....Installing Go 1.27.3 for $(ARCH) ($(GO_ARCH))...."
@@ -438,10 +459,11 @@ distro-amd64:
-f build/Dockerfile.distro \
--output type=local,dest=dist/amd64/ \
../
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ] && [ -f $(MICROVM_OUT_DIR)/initramfs.cpio.gz ]; then \
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ] && [ -f $(MICROVM_OUT_DIR)/initramfs.cpio.gz ] && [ -f $(MICROVM_OUT_DIR)/qboot.rom ] && [ -f $(QBOOT_SOURCE) ]; then \
echo "[distro-amd64] staging microvm artifacts into tarball"; \
mkdir -p dist/amd64/microvm; \
cp $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz dist/amd64/microvm/; \
cp $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz $(MICROVM_OUT_DIR)/qboot.rom $(MICROVM_OUT_DIR)/qboot.LICENSE dist/amd64/microvm/; \
cp $(QBOOT_SOURCE) dist/; \
else \
echo "[distro-amd64] WARNING: microvm artifacts missing — tarball will not include them"; \
echo "[distro-amd64] run 'make build-microvm-image' before 'make distro-amd64'"; \
+7
View File
@@ -42,9 +42,16 @@ RUN COMMIT=$(cd spinifex && git rev-parse --short HEAD 2>/dev/null || echo "unkn
# Build nbdkit viperblock plugin (.so)
RUN cd viperblock && GOFIPS140=v1.0.0 go build -o /out/nbdkit-viperblock-plugin.so -buildmode=c-shared nbd/viperblock.go
# One tarball ships to every OS. The ceiling is what both binaries need today,
# so a rise is a decision rather than an accident; every supported OS has 2.39+.
RUN spinifex/scripts/check-glibc-ceiling.sh 2.34 /out/spx /out/nbdkit-viperblock-plugin.so
# 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 cd spinifex/scripts && cp install-node.sh smoke-test.sh node-reset.sh uninstall-spx.sh /out/
RUN mkdir -p /out/udev && cp spinifex/build/udev/* /out/udev/
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
+5 -4
View File
@@ -1,10 +1,10 @@
# spinifex-unit-version: 3
# spinifex-unit-version: 5
[Unit]
Description=Spinifex Daemon (VM Orchestration)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service openvswitch-ipsec.service
# Wants (not Requires): stay bootable when openvswitch-ipsec is absent
Wants=openvswitch-ipsec.service
After=spinifex-nats.service spinifex-viperblock.service ovsdb-server.service ovs-vswitchd.service ovn-controller.service openvswitch-ipsec.service spinifex-hostnet.service
# Wants (not Requires): boot without openvswitch-ipsec, survive OVS restarts via hostnet
Wants=openvswitch-ipsec.service spinifex-hostnet.service
[Service]
EnvironmentFile=-/etc/spinifex/telemetry.env
@@ -14,6 +14,7 @@ Type=simple
Slice=spinifex-system.slice
User=spinifex-daemon
Group=spinifex
ExecStartPre=/usr/local/bin/spx admin hostnet check
ExecStart=/usr/local/bin/spx service spinifex start
Restart=on-failure
RestartSec=5
+22
View File
@@ -0,0 +1,22 @@
# spinifex-unit-version: 1
[Unit]
Description=Spinifex host network wiring (veth pairs, mgmt address, encap route)
# PartOf/WantedBy ovs-vswitchd: re-applied whenever OVS starts; daemon and vpcd only Want it
Requires=ovs-vswitchd.service
After=ovs-vswitchd.service NetworkManager-wait-online.service
PartOf=ovs-vswitchd.service
Before=spinifex.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/spx admin hostnet apply
# Covers the 120s wait behind setup-ovn.sh plus the 60s wait for links
TimeoutStartSec=240
TimeoutStopSec=10
ProtectSystem=full
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=spinifex.target ovs-vswitchd.service
+4 -3
View File
@@ -1,9 +1,9 @@
# spinifex-unit-version: 1
# spinifex-unit-version: 3
[Unit]
Description=Spinifex VPC Daemon (OVN Networking)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-daemon.service systemd-networkd.service openvswitch-switch.service ovn-controller.service
Wants=spinifex-nats.service ovn-controller.service
After=spinifex-nats.service spinifex-daemon.service systemd-networkd.service ovsdb-server.service ovs-vswitchd.service ovn-controller.service spinifex-hostnet.service
Wants=spinifex-nats.service ovn-controller.service spinifex-hostnet.service
[Service]
EnvironmentFile=-/etc/spinifex/telemetry.env
@@ -11,6 +11,7 @@ Type=simple
Slice=spinifex-system.slice
User=spinifex-vpcd
Group=spinifex
ExecStartPre=/usr/local/bin/spx admin hostnet check
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service vpcd start
Restart=on-failure
+2 -2
View File
@@ -1,9 +1,9 @@
# spinifex-unit-version: 1
# spinifex-unit-version: 2
[Unit]
Description=Spinifex Infrastructure Platform
After=network-online.target
Wants=network-online.target
Wants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer openvswitch-switch.service ovn-controller.service
Wants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer ovsdb-server.service ovs-vswitchd.service ovn-controller.service
[Install]
WantedBy=multi-user.target
+3 -3
View File
@@ -95,7 +95,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--advertise` (external IP off-host clients dial; auto-detected from the default route), `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--predastore-compaction-interval` (seconds; test clusters set a short one), `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--discard-jetstream` (with `--nodes` >= 2, remove this node's existing JetStream streams once every node has joined; without it a multi-node init over a store that holds streams is refused, and it never proceeds while NATS is running), `--skip-host-dns`, `--email` (operator address for update and security notices), `--no-telemetry`, `--gpu-passthrough`, `--ipsec` (default true; encrypt intra-AZ Geneve via OVN native IPsec cluster-wide — disable only for a trusted single-rack lab), `--host` (leader to join; falls back to multicast discovery), `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode), `--external-iface` (WAN NIC for br-external, auto-detected), `--external-source` (`dhcp` \| `static`), `--external-pool` (`start-end`), `--external-prefix-len` (default 24), `--external-gateway` (auto-detected), `--external-dns` (comma-separated IPv4 addresses, at most 3; the DNS servers written to the external pool's `dns_servers` and handed to guests, verbatim; when omitted they are detected from the host resolver at init time, so set it to give every node of a cluster the same value), `--external-bind-bridge` (default `br-wan` when `--external-source=dhcp`), `--gateway-ip` (OVN gateway router's external IP for SNAT; defaults to the pool's range start, required for `nat` mode without DHCP) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` → points the host resolver at this node's local northstar so the Spinifex zones (ELB/EKS names) resolve from the node itself (opt out with `--skip-host-dns`). `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--advertise` (external IP off-host clients dial; auto-detected from the default route), `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--predastore-compaction-interval` (seconds; test clusters set a short one), `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--discard-jetstream` (with `--nodes` >= 2, remove this node's existing JetStream streams once every node has joined; without it a multi-node init over a store that holds streams is refused, and it never proceeds while NATS is running), `--skip-host-dns`, `--email` (operator address for update and security notices), `--no-telemetry`, `--gpu-passthrough`, `--ipsec` (default true; encrypt intra-AZ Geneve via OVN native IPsec cluster-wide — disable only for a trusted single-rack lab; on a host whose IPsec stack is libreswan it defaults to false and `--ipsec=true` is refused), `--host` (leader to join; falls back to multicast discovery), `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode), `--external-iface` (WAN NIC for br-external, auto-detected), `--external-source` (`dhcp` \| `static`), `--external-pool` (`start-end`), `--external-prefix-len` (default 24), `--external-gateway` (auto-detected), `--external-dns` (comma-separated IPv4 addresses, at most 3; the DNS servers written to the external pool's `dns_servers` and handed to guests, verbatim; when omitted they are detected from the host resolver at init time, so set it to give every node of a cluster the same value), `--external-bind-bridge` (default `br-wan` when `--external-source=dhcp`), `--gateway-ip` (OVN gateway router's external IP for SNAT; defaults to the pool's range start, required for `nat` mode without DHCP) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` → points the host resolver at this node's local northstar so the Spinifex zones (ELB/EKS names) resolve from the node itself (opt out with `--skip-host-dns`). `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--advertise` (defaults to `--bind`, or the auto-detected WAN IP when `--bind` is 0.0.0.0), `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services`, `--skip-host-dns`, `--email`, `--no-telemetry`, `--predastore-compaction-interval`, `--join-timeout` (how long to keep retrying while the formation server is unreachable, default 20m), `--force` (join even though this node is already initialized or holds JetStream streams, **discarding its own CA, master key and JetStream store**; the store is removed only after formation succeeds, and the join is refused while NATS is running even with `--force`) | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations → points the host resolver at this node's local northstar so the Spinifex zones resolve from the node itself (opt out with `--skip-host-dns`) |
### Version
@@ -126,7 +126,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin preflight` | `--quiet` (print only non-OK rows) | Reports whether this host has the privileged helper scripts and sudoers grants the installed build depends on. `spx version` knows the build but not the host: `setup.sh` and `setup-ovn.sh` install those assets, and a binary-swap deploy (`make deploy`, or a manual copy) skips them. It also reports any running service still executing a replaced binary — overwriting `/usr/local/bin/spx` while `spinifex.target` is up does not move services onto it, so each keeps executing the replaced inode until it restarts and a node can serve a mixture of two builds indefinitely. Checks compare content, not existence, so a present-but-stale asset is caught. Exits non-zero if any asset is Missing, Stale or Ungranted. |
| `spx admin preflight` | `--quiet` (print only non-OK rows) | Reports whether this host has the privileged helper scripts and sudoers grants the installed build depends on. `spx version` knows the build but not the host: `setup.sh` and `setup-ovn.sh` install those assets, and a binary-swap deploy (`make deploy`, or a manual copy) skips them. It also reports any running service still executing a replaced binary — overwriting `/usr/local/bin/spx` while `spinifex.target` is up does not move services onto it, so each keeps executing the replaced inode until it restarts and a node can serve a mixture of two builds indefinitely. Checks compare content, not existence, so a present-but-stale asset is caught. On x86_64 it also checks that the QEMU binary the daemon will run lists the `microvm` machine type and that the microvm firmware `/usr/share/spinifex/microvm/qboot.rom` is installed, since load balancers boot on both. It runs the host network check `spinifex-daemon` and `spinifex-vpcd` run before they start, and fails while firewalld or ufw is active, since the Spinifex nftables policy is the only supported node firewall. It reports a Warning, without failing, when systemd-resolved is active but `/etc/resolv.conf` is not its stub, since lookups then bypass the route that sends Spinifex names to northstar. Exits non-zero if any asset is Missing, Stale, Ungranted, Unsupported or Failed. |
| `spx admin node js-probe` | `--timeout` (default: 10s) | Round-trips a canary key through the cluster-state KV bucket and exits non-zero if the write or the read-back fails. JetStream can latch a write error per message block and keep serving reads — nats-server's filestore records the failure on the block and returns it on every later write, and nothing clears it short of a restart. The HTTP health endpoint reports the server-wide JetStream state, which that latch never touches, so only an actual write observes it. |
| `spx admin banner` | `--boot-check` | Writes the node information banner to `/etc/issue` (shown before login on the physical/serial console) and `/etc/motd` (shown after SSH login). With `--boot-check` it also detects a management IP that has changed since last boot and updates `/etc/spinifex/node.conf` accordingly. |
@@ -180,7 +180,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| `spx admin gpu status` | `--node` (default: local node) | Queries `spinifex.node.status` fan-out → finds the target node response → prints Node, GPU hardware (model list or "none detected"), IOMMU state, vfio-pci state, passthrough enabled/disabled, and GPU pool allocation (`allocated/total`). Also lists GPU-capable instance types when passthrough is active. |
| `spx admin gpu enable` | — | Checks current passthrough state via NATS → errors if already enabled or prerequisites not met (directs to `setup`) → writes `gpu_passthrough = true` to `spinifex.toml` via `admin.SetGPUPassthrough` → sends SIGHUP to `spinifex-daemon` via `systemctl kill -s HUP` → polls node status for up to 30 s until daemon confirms new state → prints final `gpu status` output. Must be run directly on the target host. |
| `spx admin gpu disable` | — | Checks current passthrough state via NATS → errors if already disabled or if `AllocGPUs > 0` (must terminate GPU instances first) → writes `gpu_passthrough = false` → sends SIGHUP to `spinifex-daemon` → polls for up to 30 s → prints final `gpu status` output. Must be run directly on the target host. |
| `spx admin gpu setup` | — | Idempotent host configuration for GPU passthrough. Steps (skipped if already applied): detect GPUs via `gpu.Discover` → collect PCI IDs for all IOMMU-group siblings → check/enable IOMMU in GRUB (`intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt`) → write vfio udev rule (`/etc/udev/rules.d/99-spinifex-vfio.rules`) → blacklist nouveau (`/etc/modprobe.d/blacklist-nouveau.conf`) → blacklist amdgpu if AMD GPU present (`/etc/modprobe.d/blacklist-amdgpu.conf`) → write vfio-pci early binding config (`/etc/modprobe.d/vfio-pci.conf`) → add vfio modules to initramfs. If any change requires a reboot: runs `update-initramfs -u` and exits with reboot instructions. After reboot: verifies `vfio_pci` module is loaded → verifies each GPU is bound to `vfio-pci` (binds explicitly via `driver_override` if unbound) → calls `gpu enable` to activate passthrough. |
| `spx admin gpu setup` | — | Idempotent host configuration for GPU passthrough. Resolves its boot tools first (`update-grub` or `grubby`, `update-initramfs` or `dracut`) and stops before any change if either pair has neither or both. Steps (skipped if already applied): detect GPUs via `gpu.Discover` → collect PCI IDs for all IOMMU-group siblings → if IOMMU is inactive, add `intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt` to `GRUB_CMDLINE_LINUX_DEFAULT` and run `update-grub`, or run `grubby --update-kernel=ALL --args=...` and check the default entry and `/etc/kernel/cmdline` carry them → write vfio udev rule (`/etc/udev/rules.d/99-spinifex-vfio.rules`) → blacklist nouveau (`/etc/modprobe.d/blacklist-nouveau.conf`) → blacklist amdgpu if AMD GPU present (`/etc/modprobe.d/blacklist-amdgpu.conf`) → write vfio-pci early binding config (`/etc/modprobe.d/vfio-pci.conf`) → add vfio modules to `/etc/initramfs-tools/modules`, or write `/etc/dracut.conf.d/spinifex-vfio.conf` (`force_drivers` for the vfio modules, `install_items` for the `modprobe.d` files). If any change requires a reboot: runs `update-initramfs -u`, or `dracut -f --regenerate-all` and checks every kernel's image with `lsinitrd`, then exits with reboot instructions. After reboot: verifies `vfio_pci` module is loaded → verifies every PCI function in each GPU's slot is bound to `vfio-pci` (binds explicitly via `driver_override` if unbound, fails naming any function on another driver) → calls `gpu enable` to activate passthrough. |
| `spx admin gpu mig status` | — (must be run on target host) | Runs `gpu.Discover()` locally → for each GPU prints: PCI address, model, MIG capability, MIG mode (enabled/disabled/N/A); for GPUs with MIG enabled lists active MIG slices with GI ID, profile name, and mdev path. |
| `spx admin gpu mig enable` | `--profile <name>` (required, e.g. `1g.10gb`), `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → enables MIG mode on each target (`gpu.EnableMIGMode`) → lists available profiles (`gpu.ListProfiles`) and validates requested profile name → destroys any existing instances (`gpu.DestroyAllInstances`) → creates new instances filling GPU capacity (`gpu.CreateInstances`) → writes `mig_profile` to `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. |
| `spx admin gpu mig disable` | `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → destroys all GPU instances (`gpu.DestroyAllInstances`) → disables MIG mode (`gpu.DisableMIGMode`) → clears `mig_profile` in `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. |
+15 -3
View File
@@ -51,12 +51,24 @@ sudo spx admin gpu setup
What it does:
1. Detects installed GPUs and their PCI IDs.
2. Enables IOMMU in GRUB (`intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt`).
2. Adds the IOMMU kernel arguments (`intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt`): to `GRUB_CMDLINE_LINUX_DEFAULT` with `update-grub`, or with `grubby` to every boot entry and to `/etc/kernel/cmdline`, which kernels installed later inherit.
3. Writes the vfio udev rule (`/etc/udev/rules.d/99-spinifex-vfio.rules`).
4. Blacklists `nouveau` (NVIDIA) and `amdgpu` (AMD) so the host kernel does not claim the device.
5. Configures `vfio-pci` early binding in `/etc/modprobe.d/vfio-pci.conf`.
6. Adds vfio modules to initramfs.
6. Loads the vfio modules from the initramfs: through `/etc/initramfs-tools/modules` and `update-initramfs -u`, or through `/etc/dracut.conf.d/spinifex-vfio.conf` and `dracut -f --regenerate-all`. With dracut, each rebuilt image is checked with `lsinitrd` for `vfio_pci`, its early-load line and the `modprobe.d` files, and setup stops naming whatever is missing.
The command finds its boot tools before changing anything, and stops naming them if the host has neither or both of `update-grub` and `grubby`, or of `update-initramfs` and `dracut`.
**Oracle Linux hosts** take the `grubby` and `dracut` path: Oracle Linux keeps kernel arguments in BLS boot entries and has no `GRUB_CMDLINE_LINUX_DEFAULT`.
`grubby --update-kernel=ALL` writes the IOMMU arguments to every entry and to `/etc/kernel/cmdline`, from which a kernel installed later builds its own entry, and dracut applies `spinifex-vfio.conf` to every initramfs it builds.
After installing a new `kernel-uek`, check its entry before rebooting into it:
```bash
sudo grubby --info=ALL | grep -E '^(kernel|args)'
sudo lsinitrd -k <new-kernel-version> | grep vfio
```
Whole-GPU passthrough is supported on Oracle Linux; NVIDIA MIG is not.
If any change requires a reboot, the command prints instructions and exits:
```
@@ -175,7 +187,7 @@ The command signals the daemon and waits for confirmation. The GPU is released b
### IOMMU not active after reboot
Verify IOMMU is enabled in BIOS/UEFI (`Intel VT-d` or `AMD-Vi`). Then check GRUB picked up the parameters:
Verify IOMMU is enabled in BIOS/UEFI (`Intel VT-d` or `AMD-Vi`). Then check the boot loader picked up the parameters (on Oracle Linux, `sudo grubby --info=DEFAULT` lists them for the default entry):
```bash
cat /proc/cmdline | grep iommu
+1 -1
View File
@@ -207,7 +207,7 @@ Spinifex's own **host** firewall is separate, and already carries this policy
Choose one method and apply it to **every** server in the cluster.
**Option A — existing OS.** On a server already running Ubuntu 26.04 or Debian 13:
**Option A — existing OS.** On a server already running Ubuntu 26.04 or Debian 13 (Oracle Linux runs a single node only for now, see [Oracle Linux Hosts](/docs/install-oracle-linux)):
```bash
curl -fsSL https://install.mulgadc.com | bash
+234
View File
@@ -0,0 +1,234 @@
---
title: "Oracle Linux Hosts"
seoTitle: "Install Spinifex on Oracle Linux 10 — Spinifex Docs"
description: "Prepare an Oracle Linux 10 host for Spinifex: UEK R8, SELinux, a NetworkManager br-wan bridge, the CentOS NFV SIG repository, firewalld handover and updates."
category: "Install"
tags:
- install
- oracle linux
- single node
resources:
- title: "Oracle Linux 10 Documentation"
url: "https://docs.oracle.com/en/operating-systems/oracle-linux/10/"
- title: "CentOS NFV SIG"
url: "https://sigs.centos.org/nfv/"
---
# Oracle Linux Hosts
> What differs when the Spinifex host runs Oracle Linux 10 instead of Debian or Ubuntu.
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex installs on Oracle Linux 10.2 or a later 10.x release, running the UEK R8 kernel, on x86_64.
The installer, the release tarball and the install steps are the same as on Debian; this page covers what the host needs first and what the installer changes on it.
Follow it alongside [Single-Node Install](/docs/install).
**Supported on Oracle Linux:**
- A single node.
- Guests, VPCs, routed NAT, Elastic IPs, and Application and Network Load Balancers.
- Whole-GPU passthrough, on x86_64 GPU hosts (see [GPU Passthrough](/docs/gpu-passthrough)).
**Not supported on Oracle Linux yet:**
- Clusters of more than one node. They wait for IPsec on libreswan, so `spx admin init` turns IPsec off on Oracle Linux and refuses `--ipsec=true`.
- Clusters that mix Oracle Linux with Debian or Ubuntu nodes, on any release.
- SELinux enforcing, aarch64, Oracle Linux 9 or earlier, the Red Hat Compatible Kernel, a ZFS root, and NVIDIA MIG.
Open vSwitch and OVN come from the CentOS NFV SIG, because Oracle ships neither for Oracle Linux 10.
The NFV SIG is a community repository, and Oracle does not support the packages it publishes.
Spinifex installs `openvswitch3.5` and `ovn25.03`, the versions Debian 13 ships, and its repository file lets the SIG supply nothing else.
QEMU comes from Oracle's KVM Utilities repository, because only that build has the `microvm` machine type Spinifex load balancers boot on.
An Oracle Linux node must be a dedicated Spinifex host.
Spinifex claims every `tap*` device and stops libreswan's `ipsec.service`, so VPNs, hand-run QEMU guests and libreswan tunnels of your own cannot share the host.
Crypto policies other than `DEFAULT` and `FIPS` are untested, and so is Secure Boot.
## Prerequisites
### Operating system and kernel
Install Oracle Linux 10 with Oracle's ISO or a KVM template, then update it to the current minor release and reboot:
```bash
sudo dnf update -y
sudo reboot
```
After the reboot, check the release, the kernel and the CPU:
```bash
cat /etc/oracle-release # Oracle Linux Server release 10.2 or later
uname -r # 6.12.0-...el10uek.x86_64
grep -c -w avx2 /proc/cpuinfo # non-zero
```
The kernel must be UEK R8 (`6.12.0-*uek`), and it must be the running kernel: an installed but not booted `kernel-uek` is refused.
The CPU must be x86-64-v3 or newer (Intel Haswell, AMD Excavator or later), since the NFV SIG and KVM Utilities builds target it.
The installer names any missing CPU flag.
The installer adds `kernel-uek-modules` for the running kernel, which carries `openvswitch.ko` and pulls in `linux-firmware`, about 800 MB.
Oracle publishes modules for each kernel separately, so a host running an old kernel may find its modules gone from the repository; the installer then stops and asks for a kernel update and a reboot.
### SELinux
Spinifex ships no SELinux policy yet, so SELinux must be permissive or disabled, both now and at the next boot.
Oracle's images boot enforcing.
```bash
sudo setenforce 0
sudo sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config
getenforce # Permissive
```
The installer and `spx admin preflight` refuse a host that is enforcing in either place.
### WAN bridge with NetworkManager
As on every OS, the host's uplink must already sit on a Linux bridge named `br-wan`, with the host address and default route on the bridge (see [Single-Node Install](/docs/install#prerequisites)).
On Oracle Linux, NetworkManager owns `br-wan`; Spinifex leaves the bridge and its NIC to it and marks the links it creates itself as unmanaged.
Create the bridge from the console, not over SSH on the NIC being moved, since the host loses its address while the profile changes.
Replace `eno1` with the uplink NIC and `"System eno1"` with its current profile (`nmcli connection show`):
```bash
sudo nmcli connection add type bridge ifname br-wan con-name br-wan \
bridge.stp no ipv4.method auto ipv6.method disabled
sudo nmcli connection add type bridge-slave ifname eno1 master br-wan con-name br-wan-port
sudo nmcli connection down "System eno1"
sudo nmcli connection up br-wan
```
For a static address, give the bridge `ipv4.method manual` with `ipv4.addresses`, `ipv4.gateway` and `ipv4.dns` instead of `ipv4.method auto`.
`bridge.stp no` matters: a NetworkManager bridge runs STP by default, which holds the port in its forwarding delay for about 15 seconds after every link up, long enough for DHCP on the bridge to time out.
Then verify, as on Debian:
```bash
ip -br link show br-wan # UP
ip route # default via ... dev br-wan
```
### Network access during install
Besides `install.mulgadc.com` and the AWS CLI download, the install reaches `yum.oracle.com` for the Oracle Linux repositories, `mirror.stream.centos.org` for the NFV SIG packages, and `www.centos.org` for the NFV SIG signing key.
An air-gapped install on Oracle Linux is not documented yet.
## Instructions
## Step 1. Install Spinifex
Run the installer as a user with `sudo`, exactly as on Debian:
```bash
curl -fsSL https://install.mulgadc.com | bash
```
Then continue with [Single-Node Install](/docs/install) from Step 2: `setup-ovn.sh --management`, `spx admin init` and `systemctl start spinifex.target`.
`spx admin init` prints that IPsec is off on this host; on a single node there is no peer to encrypt traffic to.
## Step 2. Know What the Installer Changed
On Oracle Linux the installer makes these changes, which Debian hosts do not need:
- **Repositories.** It enables `ol10_UEKR8` and `ol10_kvm_utils`, and adds `qemu-kvm*` and `qemu-img*` to the `excludepkgs` of `ol10_appstream`, keeping any excludes already set, so a later AppStream build can never replace the KVM Utilities QEMU.
- **NFV SIG.** It writes `/etc/yum.repos.d/spinifex-nfv-sig.repo` with an `includepkgs=` list of the OVS and OVN packages, and imports the SIG key to `/etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-SIG-NFV` only when its fingerprint is `35154228174901BEFA8E69A621465E289D2A76A7`.
- **OVS and OVN user.** Both run as root, as on Debian, through `OVS_USER_ID` and `OVN_USER_ID` in `/etc/sysconfig/openvswitch` and `/etc/sysconfig/ovn`.
- **DNS.** Where systemd-resolved is installed but not running, it saves `/etc/resolv.conf` to `/var/lib/spinifex-reset/resolv.conf.orig`, starts resolved behind NetworkManager and links `/etc/resolv.conf` to the resolved stub. It then checks that names still resolve and stops, printing the commands that put DNS back, if they do not.
- **chrony.** It appends `confdir /etc/chrony.d` to `/etc/chrony.conf` once, and writes its step policy to `/etc/chrony.d/spinifex.conf`.
- **`spx` under `sudo`.** Oracle Linux's `sudo` path leaves out `/usr/local/bin`, so the installer links `/usr/bin/spx` to `/usr/local/bin/spx`.
The kernel modules list (`/etc/modules-load.d/spinifex.conf`) and the host firewall below apply on every OS.
## Step 3. Replace firewalld Rules You Depend On
The installer stops and masks firewalld, because with it running a packet must pass both firewalld's table and the Spinifex one, which silently breaks routed NAT, Elastic IPs and instance metadata.
When firewalld was active, the installer turns on the Spinifex nftables policy in its place; pass `--firewall=on` or `--firewall=off` to choose yourself.
firewalld's rules are not saved or migrated.
The Spinifex policy filters inbound traffic only and opens 53, 443, 3000, 8443 and 9999 to any source.
It accepts SSH from every IPv4 source until `$trusted_ssh_peers` in `/etc/spinifex/firewall/custom.nft` narrows it.
Restate any other restriction you had, such as SSH limited to a management subnet, in that file; [Host Firewall](/docs/host-firewall) shows how.
From the moment firewalld stops until the daemon first loads the Spinifex policy, the host accepts every connection.
Uninstalling Spinifex leaves firewalld unmasked but disabled; it does not turn it back on.
## Step 4. Keep the Host Updated
Do not enable `dnf-automatic`.
An Open vSwitch package upgrade restarts OVS, which interrupts guest networking, so update `openvswitch3.5`, `ovn25.03*` and `qemu-kvm*` in a maintenance window.
Stay on the current Oracle Linux 10 minor release, which is what Spinifex is tested on.
Before rebooting into a new kernel, check that its modules are installed, or OVS cannot start after the reboot:
```bash
rpm -q kernel-uek-core kernel-uek-modules
```
Each `kernel-uek-core` version needs a `kernel-uek-modules` of the same version; install a missing one with `sudo dnf install kernel-uek-modules-<version>`.
Track Oracle Linux security advisories (ELSA) for the host, the kernel and QEMU, for example with `dnf updateinfo list --security`.
Those advisories do not cover the NFV SIG packages, so follow Open vSwitch and OVN security announcements for those.
## Step 5. Uninstall
`/usr/local/share/spinifex/uninstall-spx.sh` removes Spinifex as on Debian, and lists what it leaves in place on Oracle Linux: the repository changes and the SIG key, `OVS_USER_ID` and `OVN_USER_ID`, the OVS and OVN logrotate edits, the chrony include, `/etc/modules-load.d/spinifex.conf`, the SELinux mode, a masked `ipsec.service`, firewalld disabled, and any GPU passthrough configuration.
`--purge-deps` is refused on Oracle Linux, because dnf cannot tell the packages Spinifex installed from the host's own; remove them with `dnf remove` yourself.
## Troubleshooting
### Installer stops: SELinux is enforcing
Set SELinux permissive now and in `/etc/selinux/config`, as in [SELinux](#selinux), then run the installer again.
Fixing only one of the two is refused too.
### Installer stops: the running kernel is not UEK R8
Install `kernel-uek`, reboot into it, check `uname -r`, then run the installer again.
### Installer stops: kernel-uek-modules is no longer in the repositories
The running kernel is older than anything the repository still carries modules for.
```bash
sudo dnf update -y kernel-uek
sudo reboot
```
### Installer stops: a repository is unreachable
The message names the repository.
Check the host can reach `yum.oracle.com` and `mirror.stream.centos.org` over HTTPS, including through any proxy dnf uses, then run the installer again.
### DHCP on br-wan times out
Check STP is off on the bridge:
```bash
nmcli -g bridge.stp connection show br-wan
```
If it prints `yes`, run `sudo nmcli connection modify br-wan bridge.stp no` and `sudo nmcli connection up br-wan`.
### `sudo spx` finds no command
Check that `/usr/bin/spx` links to `/usr/local/bin/spx`; run the installer again if it does not, or call `sudo /usr/local/bin/spx` meanwhile.
### CA certificate not trusted
`sudo spx admin init` installs the CA automatically. To install it again by hand on Oracle Linux:
```bash
sudo cp /etc/spinifex/ca.pem /etc/pki/ca-trust/source/anchors/spinifex-ca.crt
sudo update-ca-trust
```
+2 -1
View File
@@ -1,7 +1,7 @@
---
title: "Source Install"
seoTitle: "Build and Install Spinifex from Source — Spinifex Docs"
description: "Build Spinifex from source on Ubuntu or Debian for development, custom builds, or contributing changes, then install and run the resulting binaries locally."
description: "Build Spinifex from source on Debian, Ubuntu or Oracle Linux for development, custom builds or contributing changes, then install and run the binaries."
category: "Install"
tags:
- install
@@ -34,6 +34,7 @@ This guide builds Spinifex from source. For production deployments, the [binary
- Ubuntu 26.04
- Debian 13
- Oracle Linux 10.2 or a later 10.x, on UEK R8, x86_64 only. Prepare the host with [Oracle Linux Hosts](/docs/install-oracle-linux) first; `make quickinstall` also enables the CodeReady Builder repository for `nbdkit-devel`.
## Instructions
+18 -3
View File
@@ -1,7 +1,7 @@
---
title: "Single-Node Install"
seoTitle: "Install Spinifex on a Single Server — Spinifex Docs"
description: "Install Spinifex on one Ubuntu or Debian server with the binary installer and get an AWS-compatible EC2, S3, and VPC stack running on your own hardware."
description: "Install Spinifex on one Debian, Ubuntu or Oracle Linux server with the binary installer and run an AWS-compatible EC2, S3 and VPC stack on your hardware."
category: "Install"
tags:
- install
@@ -39,6 +39,7 @@ This guide installs Spinifex on a single server using the binary installer. For
- Ubuntu 26.04
- Debian 13
- Oracle Linux 10.2 or a later 10.x, on UEK R8, x86_64 only, as a single node. Open vSwitch and OVN come from the CentOS NFV SIG, a community repository that Oracle does not support. Prepare the host with [Oracle Linux Hosts](/docs/install-oracle-linux) first.
**What Gets Installed:**
@@ -139,19 +140,33 @@ Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import a
### spx Command Not Found
The installer puts `spx` in `/usr/local/bin`.
Where `sudo`'s `secure_path` leaves that directory out, as on Oracle Linux, the installer also links `/usr/bin/spx` to it, so `sudo spx` works; changing `PATH` in your shell does not reach commands run with `sudo`.
Check both:
```bash
export PATH=$PATH:/usr/local/bin
ls -l /usr/local/bin/spx /usr/bin/spx
sudo sh -c 'command -v spx'
```
If `sudo` still finds no `spx`, run the installer again, or call `sudo /usr/local/bin/spx` meanwhile.
### CA Certificate Not Trusted
`sudo spx admin init` installs the CA automatically. If you need to re-install it manually:
`sudo spx admin init` installs the CA automatically. If you need to re-install it manually on Debian or Ubuntu:
```bash
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
On Oracle Linux:
```bash
sudo cp /etc/spinifex/ca.pem /etc/pki/ca-trust/source/anchors/spinifex-ca.crt
sudo update-ca-trust
```
### OVN Services Not Starting
```bash
@@ -127,8 +127,8 @@ Operators own remediation for layers Spinifex does not ship:
| Layer | Responsibility |
|-------|----------------|
| Spinifex releases | Apply released patches within the correct-SLA above. |
| Host OS + kernel | Track distribution advisories (Debian DSA, Ubuntu USN) at matching cadence. |
| OVN / OVS | Patched via the operator's package channel alongside the OS. |
| Host OS + kernel | Track distribution advisories (Debian DSA, Ubuntu USN, Oracle Linux ELSA) at matching cadence. |
| OVN / OVS | Patched via the operator's package channel alongside the OS. On Oracle Linux they come from the CentOS NFV SIG, which ELSA does not cover, so track upstream Open vSwitch and OVN security announcements. |
| QEMU / KVM / libvirt | As above — hypervisor CVEs affect tenant isolation. |
| Host AV / EDR agent | See [Malware Protection §2](/docs/malware-protection#2-update-requirements-sil1-3144). |
+1 -1
View File
@@ -75,7 +75,7 @@ All Spinifex nodes must include the following in their malware protection scope.
| `/usr/local/bin/spx` | Spinifex CLI and service entry point. All `spinifex-*.service` units invoke `spx service <name> start`, so every service binary is reached through this one path. |
| `/usr/bin/ovn-*`, `/usr/bin/ovs-*` | OVN / OVS control-plane and datapath binaries (installed via `apt` from the `ovn-central`, `ovn-host`, `openvswitch-switch` packages). |
| `/usr/lib/<arch>-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so` | Viperblock NBD plugin loaded by `nbdkit` to expose EBS volumes as block devices. The exact path comes from `nbdkit --dump-config` (`plugindir=`); on Debian 13 amd64 it is `/usr/lib/x86_64-linux-gnu/nbdkit/plugins/`. |
| `/usr/local/share/ca-certificates/spinifex-ca.crt` | Cluster CA cert installed into the system trust store |
| `/usr/local/share/ca-certificates/spinifex-ca.crt` | Cluster CA cert installed into the system trust store. On Oracle Linux it is `/etc/pki/ca-trust/source/anchors/spinifex-ca.crt`. |
### 1.2 Configuration and Secrets
+14 -1
View File
@@ -145,6 +145,16 @@ Spinifex nodes initiate a small, fixed set of outbound connections.
| `https://d2yp8ipz5jfqcw.cloudfront.net` | Alpine image for managed HAProxy load-balancer | HTTPS | TLS + checksum verification |
| `https://install.mulgadc.com/install` | One-shot install telemetry POST on `spx admin init` / `join`. | HTTPS | TLS |
**During install and package updates on Oracle Linux hosts:**
| Destination | Purpose | Protocol | Verification |
|-------------|---------|----------|--------------|
| `https://yum.oracle.com` | Oracle Linux BaseOS, AppStream, UEK R8 and KVM Utilities repositories | HTTPS | TLS + Oracle RPM signatures |
| `https://mirror.stream.centos.org/SIGs/10-stream/nfv/` | Open vSwitch and OVN from the CentOS NFV SIG, limited by `includepkgs=` | HTTPS | TLS + NFV SIG RPM signatures |
| `https://www.centos.org/keys/RPM-GPG-KEY-CentOS-SIG-NFV` | NFV SIG signing key, fetched once at install | HTTPS | TLS + fingerprint pinned in `setup.sh` |
Debian and Ubuntu hosts reach their distribution's own mirrors for the same purpose.
**To peer nodes (cluster-internal):** NATS federation (4248), Predastore S3 (8443), OVN NB/SB (6641/6642), northstar DNS forwarding (5300), and the Geneve/IPsec overlay (6081, 500, 4500, ESP) — see [§3](#3-cross-node-internal-connections) for encryption and verification of each. The daemon also polls local NATS monitoring at `127.0.0.1:8222/varz` (loopback HTTP). It opens no connection to Predastore for status: the storage topology it reports comes from reading `predastore.toml`, and no Predastore node serves a status endpoint.
**Update checks and metadata.** Spinifex does not check for updates and does not consume a cloud metadata service (`169.254.169.254` is served *by* the cluster to guest VMs). Node software updates come from the operator's OS package channel. The install-telemetry endpoint above is the only vendor-operated destination contacted by a node; closed-egress deployments should disable it and record the opt-out in the security plan.
@@ -173,7 +183,10 @@ Nodes **must** sit on a network segment that is not routed to tenant/guest VMs o
Default external surface is five listeners — **9999** (AWS API), **3000** (UI), **22** (SSH), **8443** (S3) and **53** (DNS) — plus **4432** transiently during bootstrap. Every other listener is cluster- or encap-scoped and the operator must enforce this with a host firewall or an upstream network ACL.
The nodes ship no firewall policy today. Until one is installed, this is the operator's responsibility and the reference below is the starting point.
Every node ships a host firewall policy, `table inet spinifex_filter` in `/etc/spinifex/firewall/spinifex.nft`, written by `setup.sh`; `spinifex-daemon` loads it once cluster peers resolve, filling the peer and encap sets itself.
It is armed by default on ISO installs; a `setup.sh` install arms it with `--firewall=on`, or when firewalld was active, and otherwise installs it unarmed, so on those nodes the scope split is still the operator's to enforce.
`setup.sh` masks firewalld wherever it is installed, and `spx admin preflight` fails while firewalld or ufw is active, because a second ruleset silently drops routed NAT, elastic IP and metadata traffic.
The shipped policy is the authority; the reference below explains the rules it needs and is looser than the shipped one in places, such as accepting all `ime-*` traffic where the shipped policy accepts only the metadata and DNS endpoints.
> **Read the notes under the ruleset before applying it.** A default-deny input policy that
> omits any of the loopback, conntrack, `ime-*` or Geneve rules will break instance
+2 -2
View File
@@ -30,7 +30,7 @@ The largest suite, 27 top-level tests. Notable entry points:
- **`TestIMDSDatapathSurvivesVswitchdRestart`** restarts `ovs-vswitchd` under a guest with an Elastic IP, once through systemd and once with SIGKILL. After each, the guest must answer over its EIP and fetch its instance ID through IMDSv2 within 60 seconds, and `br-imds` must hold all 7 of the guest's own flows. It is sequential because it takes the whole node's datapath down.
- `TestStopStart`, `TestEarlyRebootLiveness`, `TestGuestChurnDurability`, `TestSnapshotLifecycle`, `TestSnapshotBackedLaunch`, `TestCreateImage`, `TestVolumeLifecycle`, `TestSpotInstanceLifecycle`, `TestLaunchTemplateBoot`.
- `TestNegativeErrorPaths` — the `8a`–`8t` AWS error-shape cases (malformed AMI ID, invalid instance type, volume in use, detach-root forbidden, unknown or malformed instance IDs on the lifecycle verbs, and so on).
- **`TestKVReplication`** — the single-node half of the KV replication policy, and it is not the multi-node assertion with a smaller number in it. On one node one replica per bucket is the correct answer rather than the defect, so what it proves is the opposite: that nothing declared a replica count this deployment cannot place, that `spx admin kv replicas` exits 0 and reports `WANT 1`, and that `--repair` run twice against a cluster with nothing to repair changes no bucket's replica count and leaves every bucket serving. The multi-node suites cannot cover any of that, because on three nodes the unplaceable count is the right one. It lives here rather than in its own suite so it runs in every single-node nightly cell, across all three host and network permutations.
- **`TestKVReplication`** — the single-node half of the KV replication policy, and it is not the multi-node assertion with a smaller number in it. On one node one replica per bucket is the correct answer rather than the defect, so what it proves is the opposite: that nothing declared a replica count this deployment cannot place, that `spx admin kv replicas` exits 0 and reports `WANT 1`, and that `--repair` run twice against a cluster with nothing to repair changes no bucket's replica count and leaves every bucket serving. The multi-node suites cannot cover any of that, because on three nodes the unplaceable count is the right one. It lives here rather than in its own suite so it runs in every single-node nightly cell, across every host and network permutation.
### `natuplink` — routed NAT (`external_mode = "nat"`), one node or three
@@ -40,7 +40,7 @@ Runs **on** a node, shelling out to `ip`/`iptables`/`ovn-nbctl` locally. Skips u
Phases 7 and 8 are gated on a cluster by what the datapath can actually do: the transit segment is a localnet, so host-to-guest Tier 1 ingress only exists where the VPC's gateway router port and the guest are both on this host. The VPC ingress route itself is installed on every node, so phase 8 checks it unconditionally.
Two nightly cells run it: `nat-single` (cell 19) and `nat-multi` (cell 30). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap.
Three nightly cells run it: `nat-single` (cell 19), its Oracle Linux twin (cell 36) and `nat-multi` (cell 30). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap.
### `multinode` — behaviour that only exists on more than one node
+52 -7
View File
@@ -6,22 +6,66 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati
| Script | What it does |
|---|---|
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, files, systemd units, logrotate and udev rules. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. `--management` also starts the OVN central services. |
| `install-node.sh` | Forms a multi-node cluster across hosts that are already running Spinifex: rebuilds the OVN database in clustered form, runs `spx admin init` on the first host and `spx admin join` on the rest, then verifies. The scripted form of [install-multi-node](../docs/install/install-multi-node/README.md). Destroys the joining nodes' logical network state and crypto — see its `--help`. |
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, links `/etc/resolv.conf` to the resolved stub, and stops unless resolved has upstream servers and the package repository host still resolves. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. It runs OVS and OVN as root, setting `OVS_USER_ID` and `OVN_USER_ID` where `/etc/sysconfig` holds them, and hooks the socket-permission helper onto `ovs-vswitchd`, `ovn-controller`, `openvswitch-ipsec` and, on EL, `ovn-northd`. `--management` also starts the OVN central services, writes their `ovn-ctl` flags to `/etc/default/ovn-central` (Debian) or `/etc/sysconfig/ovn-northd` (EL), and records the role in `/etc/spinifex/ovn-role`. A later run without `--management` refuses while that file, or the `OVN_CTL_OPTS` line older nodes carry in `/etc/default/ovn-central`, records the role. It adds `maxsize 100M` to the OVS and OVN logrotate files, plus `rotate 5` where the file sets no count, and rotates as root, matching the daemons. On a host whose IPsec stack is libreswan it stops and masks `openvswitch-ipsec.service` and `ipsec.service`, because OVN IPsec does not run on libreswan yet; elsewhere it enables and starts `openvswitch-ipsec.service`, unless the daemon has masked it to keep IPsec off. The links it creates persist through systemd-networkd where that is enabled, and otherwise through `/etc/spinifex/hostnet.json`, which `spinifex-hostnet.service` (`spx admin hostnet apply`) replays at boot and on every OVS start; the first run records the choice in `/etc/spinifex/hostnet-backend`. Where NetworkManager runs, it leaves the links Spinifex owns unmanaged. |
| `install-node.sh` | Forms a multi-node cluster across hosts that are already running Spinifex: rebuilds the OVN database in clustered form, runs `spx admin init` on the first host and `spx admin join` on the rest, then verifies. Before touching any host it refuses hosts of different OS families (`distro_os_family`) or `spx` versions, and `--ipsec on` when any host uses libreswan. The scripted form of [install-multi-node](../docs/install/install-multi-node/README.md). Destroys the joining nodes' logical network state and crypto — see its `--help`. |
| `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_ovs_logrotate` | `/etc/logrotate.d/openvswitch-switch` or `/etc/logrotate.d/openvswitch` |
| `distro_ovn_logrotate` | `/etc/logrotate.d/ovn-common` or `/etc/logrotate.d/ovn` |
| `distro_os_family` | the OS family of the `/etc/os-release` text on stdin: `debian` for Debian and Ubuntu, otherwise the `ID` |
`distro_has_libreswan` is a predicate rather than a probe: it succeeds when libreswan's IKE daemon, `/usr/libexec/ipsec/pluto`, is installed.
EL's `openvswitch-ipsec` drives libreswan even beside strongSwan, so finding both is not an error.
`distro_missing_ovs_deps <unit-file>...` is a check rather than a probe: it prints each OVS or OVN unit the given unit files depend on or order against that is not installed, and fails only when a file cannot be read.
`setup-ovn.sh` runs it against the installed Spinifex units and stops when one is missing, because systemd silently drops ordering against a unit that does not exist; a missing `openvswitch-ipsec.service` is only a warning.
`setup-ovn.sh`, `uninstall-spx.sh` and `install-node.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.
### System packages
`setup.sh` installs `APT_RUNTIME_PACKAGES` with apt, or on Oracle Linux `OL_RUNTIME_PACKAGES` with dnf, choosing by `distro_pkg_manager`.
Before the dnf install it enables `ol10_UEKR8` and `ol10_kvm_utils`, excludes `qemu-kvm*` and `qemu-img*` from `ol10_appstream` so QEMU always comes from KVM Utilities, and writes `/etc/yum.repos.d/spinifex-nfv-sig.repo` for OVS 3.5 and OVN 25.03 from the CentOS NFV SIG.
That repo carries an `includepkgs=` allowlist, and its key is refused unless its fingerprint matches `NFV_SIG_KEY_FPR`.
It then installs `kernel-uek-modules` for the running kernel, stopping with a reboot instruction if that version has left the repositories, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`.
`make install-system` takes the same dnf path, adding the build packages from CodeReady Builder.
| Script | What it does |
|---|---|
| `lint-apt-packages.sh <image>` | Resolves the apt list against a Debian or Ubuntu image's real indices with `apt-get install -s`. |
| `lint-dnf-packages.sh [image]` | Runs `setup.sh`'s Oracle Linux repository setup in an `oraclelinux:10` container, resolves the dnf list, and fails unless QEMU comes from KVM Utilities and OVS and OVN from the NFV SIG. |
| `check-glibc-ceiling.sh <ceiling> <elf>...` | Fails when an ELF file needs a glibc symbol version newer than the ceiling, naming the symbols. `build/Dockerfile.distro` runs it on `spx` and the nbdkit plugin with 2.34, so the one release tarball keeps loading on every supported OS. |
## 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:
| Script | What it does |
|---|---|
| `node-reset.sh` | Clears **state** and leaves the node installed: stops services, destroys guests, wipes `/etc/spinifex`, `/var/lib/spinifex`, `/var/log/spinifex` and the OVN databases, and removes the OVS bridges. The node is then ready for a fresh `spx admin init`. `--keep-data` preserves `/var/lib/spinifex`; `SPX_RESET_CONFIRM=reset` runs it unattended. |
| `uninstall-spx.sh` | Removes the **software** — the inverse of `setup.sh`. Delegates the state teardown above to `node-reset.sh` rather than reimplementing it, then removes the binary, the nbdkit plugin, the systemd units and slices, the service users, the sudoers grant, the sysctl/logrotate/chrony/udev drop-ins, the `systemd-networkd` units, the host firewall policy and its loaded nft table, and the Spinifex CA from the host trust store. Verifies afterwards and exits non-zero naming anything that survived. |
| `node-reset.sh` | Clears **state** and leaves the node installed: stops services, destroys guests, wipes `/etc/spinifex`, `/var/lib/spinifex`, `/var/log/spinifex` and the OVN databases, and removes the OVS bridges. It stops OVS and OVN central by the unit names the host's packages use. It leaves the NetworkManager unmanaged-device config, which the next `setup-ovn.sh` run rewrites, so NetworkManager cannot take a NIC back in between. The node is then ready for a fresh `spx admin init`. `--keep-data` preserves `/var/lib/spinifex`; `SPX_RESET_CONFIRM=reset` runs it unattended. |
| `uninstall-spx.sh` | Removes the **software** — the inverse of `setup.sh`. Delegates the state teardown above to `node-reset.sh` rather than reimplementing it, then removes the binary, the nbdkit plugin, the systemd units and slices, the service users, the sudoers grant, the sysctl/logrotate/chrony/udev drop-ins, the `systemd-networkd` units, the host firewall policy and its loaded nft table, the Spinifex systemd-resolved drop-in, and the Spinifex CA from the host trust store. It unmasks firewalld and leaves it disabled. Where `setup.sh` handed DNS to systemd-resolved, it restores the saved `/etc/resolv.conf` and turns resolved off. Verifies afterwards and exits non-zero naming anything that survived, then lists the host settings `setup.sh` changed that it leaves in place, such as the dnf repositories, `OVS_USER_ID`, masked IPsec units and the SELinux mode. |
`uninstall-spx.sh` keeps `/var/lib/spinifex` and every apt package by default — uninstalling software should not destroy data, and a `curl … | bash` install lands on hosts that were already using openvswitch, libvirt and chrony. `--purge-data` and `--purge-deps` opt into each, and both confirm separately even under `--yes` (`SPX_PURGE_CONFIRM=destroy` for unattended data removal). `--dry-run` prints the whole plan and touches nothing.
`node-reset.sh` finds guests by process name, `qemu-system-*` or `qemu-kvm`, owned by the daemon's service user, and waits for them to exit before it deletes any state.
`node-reset_test.sh` checks that match against real processes, and that `reset-dev-env.sh` uses the same pattern.
`uninstall-spx.sh` keeps `/var/lib/spinifex` and every package by default — uninstalling software should not destroy data, and a `curl … | bash` install lands on hosts that were already using openvswitch, libvirt and chrony. `--purge-data` and `--purge-deps` opt into each, and both confirm separately even under `--yes` (`SPX_PURGE_CONFIRM=destroy` for unattended data removal). `--dry-run` prints the whole plan and touches nothing.
`--purge-deps` removes with apt and is refused on dnf hosts, where its fixed list cannot tell what `setup.sh` installed from what the host already had, and dnf would also remove every package that requires them.
**It refuses on a multi-node cluster, and `--yes` does not override that.** Uninstalling one node of a running cluster strands its guests and leaves the survivors reconciling against a chassis that will never answer. Run `spx admin cluster shutdown` first, then the uninstaller on each node.
@@ -36,7 +80,7 @@ Both ship to `/usr/local/share/spinifex/` on every node, so they are available o
| Script | What it does |
|---|---|
| `smoke-test.sh` | End-to-end sanity check on a running node: imports an SSH keypair and the Ubuntu AMI, then launches an instance. `--create-vpc` builds the VPC, gateway, subnet and routes first and tears them down after; `--nodes N` launches through a spread placement group so each instance lands on a different physical server. `TEST_GPU=1` adds GPU passthrough checks. |
| `smoke-test.sh` | End-to-end sanity check on a running node: imports an SSH keypair and the Ubuntu AMI, then launches an instance. `--create-vpc` builds the VPC, gateway, subnet and routes first and tears them down after; `--nodes N` launches through a spread placement group so each instance lands on a different physical server. `TEST_GPU=1` adds GPU passthrough checks. It runs the AWS CLI by absolute path, since sudo's `secure_path` leaves out `/usr/local/bin` on Oracle Linux. |
| `verify-network.sh` | Asserts a node's datapath matches its declared planes — EIP reachability, which plane carries Geneve, and which interfaces services bind. Catches what `smoke-test.sh` cannot, since none of its traffic leaves the box. |
| `check-coverage.sh` | Fails the build if total Go coverage falls below the minimum threshold, excluding packages exempt from the requirement. |
| `diff-coverage.sh` | Coverage check scoped to changed lines only, against an auto-detected base ref (`HEAD~1` on main, `origin/main` on dev, otherwise `origin/dev`). |
@@ -52,6 +96,7 @@ Both ship to `/usr/local/share/spinifex/` on every node, so they are available o
| `build-system-image.sh` | Builds a minimal system AMI (Alpine or Ubuntu) from a manifest. All customisation happens inside the libguestfs appliance, so the build touches no host block device, no host mount, and needs no sudo. `--import` also registers it as an AMI. |
| `publish-system-image.sh` | Converts a built raw image to compressed qcow2, writes the `.sha256` sidecar the catalog verifier expects, and uploads both to the R2 bucket behind `iso.mulgadc.com/system-ami/`. |
| `build-microvm-image.sh` | Builds the minimal Alpine kernel + initramfs used by the QEMU microvm machine type and the lb-agent, carrying haproxy (ALB) and nginx-stream (NLB) data planes. |
| `build-qboot.sh` | Builds qboot, the firmware the daemon passes to every microvm with `-bios`, from a pinned upstream commit in a digest-pinned Debian container. Fails unless the output matches the recorded sha256, which is also Debian 13's `qboot.rom`. Writes `qboot.rom` and its GPL-2.0 licence beside the microvm kernel, and the matching source tarball, which releases publish, under `build/qboot/`. `make build-microvm-image` runs it. |
| `mkosi-build.sh` | Runs an mkosi system-image build inside the pinned builder container, so a runner needs only Docker and the toolchain cannot drift. `--image` names an output and expands to the right profile list. |
| `mkosi-publish.sh` | Publishes an mkosi-built image to R2 from inside the same pinned container — qcow2 conversion, `.sha256` sidecar, and upload. |
| `mkosi-common.sh` | Shared plumbing sourced by both mkosi scripts. Builds the toolchain image once, with the builder account matched to the invoking user so bind-mounted output is not written back as root. |
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
# build-qboot.sh — build qboot, the firmware the QEMU microvm machine boots
# system instances (ALB/NLB) with, from a pinned upstream commit.
#
# Outputs:
# $REPO_ROOT/build/microvm/qboot.rom firmware, shipped beside the microvm kernel
# $REPO_ROOT/build/microvm/qboot.LICENSE qboot's GPL-2.0 licence text
# $REPO_ROOT/build/qboot/qboot-<commit>.tar.gz the matching source, published with releases
#
# The build runs in a digest-pinned Debian container, so its output is
# reproducible and is checked against a recorded hash. That hash is also the
# sha256 of Debian 13's /usr/share/qemu/qboot.rom, which is built from the same commit.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT_DIR="$REPO_ROOT/build/microvm"
SRC_DIR="$REPO_ROOT/build/qboot"
QBOOT_REPO="${QBOOT_REPO:-https://gitlab.com/qemu-project/qboot.git}"
QBOOT_COMMIT="8ca302e86d685fa05b16e2b208888243da319941"
QBOOT_SHA256="5c9c4b12c4438e3cf11d087263afe6519c8c05dd5896727b8b169b8f5dbf9877"
BUILD_IMAGE="debian:trixie@sha256:4c25275446ca371766b8b093c696782eabce06263fccacb8d5d813b3dc44b308"
log() { echo "[build-qboot] $*"; }
fail() { echo "ERROR: $*" >&2; exit 1; }
CONTAINER_TOOL=""
for tool in docker podman; do
if command -v "$tool" >/dev/null 2>&1 && "$tool" info >/dev/null 2>&1; then
CONTAINER_TOOL=$tool
break
fi
done
[ -n "$CONTAINER_TOOL" ] || fail "qboot builds in a container; install docker or podman"
command -v git >/dev/null 2>&1 || fail "required tool not found: git"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
log "fetching qboot $QBOOT_COMMIT from $QBOOT_REPO"
git init -q "$WORK/src"
git -C "$WORK/src" fetch -q --depth 1 "$QBOOT_REPO" "$QBOOT_COMMIT"
git -C "$WORK/src" checkout -q FETCH_HEAD
got_commit=$(git -C "$WORK/src" rev-parse HEAD)
[ "$got_commit" = "$QBOOT_COMMIT" ] || fail "fetched qboot $got_commit, not the pinned $QBOOT_COMMIT"
mkdir -p "$SRC_DIR"
src_tarball="$SRC_DIR/qboot-${QBOOT_COMMIT}.tar.gz"
git -C "$WORK/src" archive --format=tar.gz --prefix="qboot-${QBOOT_COMMIT}/" -o "$src_tarball" "$QBOOT_COMMIT"
log "building in $BUILD_IMAGE with $CONTAINER_TOOL"
# The firmware comes back on stdout, so nothing the container writes is left
# root-owned on the host.
"$CONTAINER_TOOL" run --rm --platform linux/amd64 -v "$WORK/src:/src:ro" \
-e DEBIAN_FRONTEND=noninteractive "$BUILD_IMAGE" sh -euc '
{
apt-get update -qq
apt-get install -y -qq --no-install-recommends gcc libc6-dev meson ninja-build binutils
meson setup /tmp/build /src
ninja -C /tmp/build
} >/dev/null
cat /tmp/build/bios.bin
' > "$WORK/qboot.rom"
got_sha=$(sha256sum "$WORK/qboot.rom" | cut -d' ' -f1)
if [ "$got_sha" != "$QBOOT_SHA256" ]; then
fail "qboot.rom has sha256 $got_sha, not the recorded $QBOOT_SHA256.
The pinned commit or build image changed; rebuild, boot an ALB on it, then update QBOOT_SHA256."
fi
mkdir -p "$OUT_DIR"
install -m 0644 "$WORK/qboot.rom" "$OUT_DIR/qboot.rom"
install -m 0644 "$WORK/src/LICENSE" "$OUT_DIR/qboot.LICENSE"
log "qboot.rom sha256 $got_sha"
log "source: $src_tarball"
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# check-glibc-ceiling.sh — fail when an ELF file needs a glibc symbol version
# newer than a ceiling, so the one release tarball keeps loading on the oldest
# glibc of every OS it ships to.
#
# Usage: scripts/check-glibc-ceiling.sh <ceiling, e.g. 2.34> <elf file>...
set -euo pipefail
if [ $# -lt 2 ]; then
echo "usage: $0 <ceiling> <elf file>..." >&2
exit 2
fi
ceiling=$1
shift
failed=0
for f in "$@"; do
if ! syms=$(objdump -T "$f" 2>&1); then
case "$syms" in
*"not a dynamic object"*)
echo "$f: statically linked, needs no glibc"
continue
;;
esac
echo "ERROR: cannot read the dynamic symbols of $f: $syms" >&2
failed=1
continue
fi
need=$(grep -o 'GLIBC_[0-9][0-9.]*' <<<"$syms" | sed 's/^GLIBC_//' | sort -uV | tail -1 || true)
if [ -z "$need" ]; then
echo "$f: needs no versioned glibc symbol"
continue
fi
if [ "$(printf '%s\n%s\n' "$need" "$ceiling" | sort -V | tail -1)" = "$ceiling" ]; then
echo "$f: needs at most GLIBC_$need (ceiling GLIBC_$ceiling)"
continue
fi
echo "ERROR: $f needs GLIBC_$need, newer than the GLIBC_$ceiling ceiling. Symbols above it:" >&2
while read -r ver sym; do
if [ "$(printf '%s\n%s\n' "$ver" "$ceiling" | sort -V | tail -1)" != "$ceiling" ]; then
echo " $sym@GLIBC_$ver" >&2
fi
done < <(awk '{ for (i = 1; i <= NF; i++) if ($i ~ /^\(?GLIBC_[0-9]/) { v = $i; gsub(/[()]|GLIBC_/, "", v); print v, $NF } }' <<<"$syms" | sort -u)
failed=1
done
exit "$failed"
+3
View File
@@ -31,6 +31,9 @@ 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/"
cp "$PROJECT_ROOT/scripts/install-node.sh" "$PROJECT_ROOT/scripts/smoke-test.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"
+46 -8
View File
@@ -7,7 +7,7 @@
# `spx admin join` on the rest, restarts services and verifies the result.
#
# It is the scripted form of docs/install/install-multi-node. It installs no
# software — every host must already be running the version you want. Use
# software — every host must already run the version you want, on one OS. Use
# mulga's scripts/update-nodes.sh, or the release installer, for that first.
#
# Usage:
@@ -69,6 +69,19 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && 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 "$SCRIPT_DIR/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 $SCRIPT_DIR/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"
SSH_USER="${SSH_USER:-spinifex}"
IDENTITY=""
HOSTS=()
@@ -284,6 +297,8 @@ resolve_plane() {
log "preflight on $N host(s)"
versions=""
families=""
libreswan_hosts=""
for i in $(seq 0 $((N - 1))); do
host="${HOSTS[$i]}"
@@ -303,6 +318,16 @@ for i in $(seq 0 $((N - 1))); do
version="${version//[$'\r\n']/}"
versions+="${version}"$'\n'
family=$(out "$host" "cat /etc/os-release" | distro_os_family) ||
fail "$host: cannot tell its OS from /etc/os-release"
families+="${host}: ${family}"$'\n'
# libreswan's pluto: Spinifex cannot run OVN IPsec on it yet, and spx admin
# init refuses --ipsec=true there, which would stop formation part way.
if [ "$(out "$host" "test -x /usr/libexec/ipsec/pluto && echo yes")" = "yes" ]; then
libreswan_hosts+=" $host"
fi
# A node that is already in a multi-node cluster is one whose keys and
# logical network state are load-bearing. Refusing here is deliberate:
# forming a cluster must never be the command that destroys a live one.
@@ -353,6 +378,20 @@ $(sort -u <<<"$versions" | sed 's/^/ /')
Bring them all to the same build before forming a cluster."
fi
# A cluster runs one OS. Debian and Oracle Linux ship different OVN builds and
# IKE daemons, and nothing qualifies them against each other.
if [ "$(cut -d' ' -f2 <<<"$families" | sort -u | grep -c .)" -gt 1 ]; then
fail "hosts run different operating systems:
$(sed '/^$/d; s/^/ /' <<<"$families")
A cluster must run one OS on every host."
fi
if $IPSEC_ENABLED && [ -n "$libreswan_hosts" ]; then
fail "--ipsec on, but these hosts use libreswan for IPsec:$libreswan_hosts
Spinifex cannot run OVN IPsec on libreswan yet. Re-run with --ipsec off
only if the nodes share a trusted private link."
fi
# --- Confirm ---------------------------------------------------------------
echo ""
@@ -489,10 +528,10 @@ fi
# --- Wipe ------------------------------------------------------------------
#
# node-reset.sh is not part of the release tarball, so it is pushed from this
# checkout the same way smoke-test.sh is. After this a node has no CA, master
# key, OVN database or chassis identity left for the formation phase to collide
# with — which is the whole reason --wipe exists.
# Pushed from beside this script, as smoke-test.sh is, so the reset matches this
# script whatever the host has installed; it carries its own probes. After this
# a node has no CA, master key, OVN database or chassis identity left for the
# formation phase to collide with — which is the whole reason --wipe exists.
if $WIPE; then
log "wiping every host back to its pre-install state"
for host in "${HOSTS[@]}"; do
@@ -964,9 +1003,8 @@ out "${HOSTS[0]}" "sudo sed -n '/^\[\[network.external_pools\]\]/,/^$/p' /etc/sp
if $RUN_SMOKE; then
echo ""
log "running the smoke test on ${HOSTS[0]}"
# smoke-test.sh is not part of the release tarball, so it is pushed from
# this checkout — which also guarantees the test and this script are the
# same version.
# Pushed from beside this script, so the test and this script are the same
# version whatever the host has installed.
scp "${SSH_OPTS[@]}" -q "$SCRIPT_DIR/smoke-test.sh" "$SSH_USER@${HOSTS[0]}:/tmp/spx-smoke-test.sh"
on "${HOSTS[0]}" "chmod 0755 /tmp/spx-smoke-test.sh && /tmp/spx-smoke-test.sh --create-vpc --nodes $N" ||
fail "smoke test failed on ${HOSTS[0]}"
+193
View File
@@ -0,0 +1,193 @@
# 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
}
# chrony's main config file: /etc/chrony/chrony.conf on Debian, /etc/chrony.conf on EL.
distro_chrony_conf() {
local deb=false el=false
[ -f "${DISTRO_ROOT:-}/etc/chrony/chrony.conf" ] && deb=true
[ -f "${DISTRO_ROOT:-}/etc/chrony.conf" ] && el=true
if $deb && $el; then
echo "distro.sh: chrony config: found both /etc/chrony/chrony.conf and /etc/chrony.conf; cannot tell which one chronyd reads" >&2
return 1
fi
if $deb; then echo /etc/chrony/chrony.conf; return 0; fi
if $el; then echo /etc/chrony.conf; return 0; fi
echo "distro.sh: chrony config: found neither /etc/chrony/chrony.conf nor /etc/chrony.conf; is chrony installed?" >&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
}
distro_ovs_logrotate() {
local deb=false el=false
[ -f "${DISTRO_ROOT:-}/etc/logrotate.d/openvswitch-switch" ] && deb=true
[ -f "${DISTRO_ROOT:-}/etc/logrotate.d/openvswitch" ] && el=true
if $deb && $el; then
echo "distro.sh: OVS logrotate: found both /etc/logrotate.d/openvswitch-switch and /etc/logrotate.d/openvswitch; cannot tell which one the OVS packages own" >&2
return 1
fi
if $deb; then echo /etc/logrotate.d/openvswitch-switch; return 0; fi
if $el; then echo /etc/logrotate.d/openvswitch; return 0; fi
echo "distro.sh: OVS logrotate: found neither /etc/logrotate.d/openvswitch-switch nor /etc/logrotate.d/openvswitch; is Open vSwitch installed?" >&2
return 1
}
# libreswan's IKE daemon. EL's openvswitch-ipsec drives libreswan even beside
# strongSwan, so this is a presence check rather than one of a pair.
distro_has_libreswan() {
[ -x "${DISTRO_ROOT:-}/usr/libexec/ipsec/pluto" ]
}
# The OS family of the os-release text on stdin, for install-node.sh's one-OS
# cluster check: "debian" for Debian and Ubuntu, so mixing those is not newly
# refused, and the ID itself for anything else.
distro_os_family() {
local line id="" like=""
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
ID=*) id=${line#ID=} ;;
ID_LIKE=*) like=${line#ID_LIKE=} ;;
esac
done
id=${id//[\"\']/}
like=${like//[\"\']/}
if [ -z "$id" ]; then
echo "distro.sh: OS family: /etc/os-release has no ID" >&2
return 1
fi
case " $id $like " in
*" debian "*) echo debian ;;
*) echo "$id" ;;
esac
}
# The OVS and OVN units the given unit files depend on or order against that
# are not installed, one per line; prints nothing when all are. Fails only
# when a unit file cannot be read.
distro_missing_ovs_deps() {
local file line dep missing=""
for file in "$@"; do
if [ ! -r "${DISTRO_ROOT:-}$file" ]; then
echo "distro.sh: OVS dependencies: cannot read $file" >&2
return 1
fi
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
After=* | Before=* | Wants=* | Requires=* | Requisite=* | BindsTo=* | PartOf=* | Upholds=*) ;;
*) continue ;;
esac
for dep in ${line#*=}; do
case "$dep" in
ovs* | ovn* | openvswitch*) ;;
*) continue ;;
esac
case " $missing " in
*" $dep "*) continue ;;
esac
distro_unit_exists "$dep" || missing="${missing:+$missing }$dep"
done
done <"${DISTRO_ROOT:-}$file"
done
for dep in $missing; do echo "$dep"; done
}
+300
View File
@@ -0,0 +1,300 @@
#!/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 <name> — 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 <fn> [arg...] — 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"; shift; "$@"' _ "$DISTRO" "$@" 2>"$TMP/err") || RC=$?
ERR=$(cat "$TMP/err")
}
# expect_ok <desc> <want>; expect_fail <desc> <word>... — 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
# --------------------------------------------------------------------------
# chrony config file.
# --------------------------------------------------------------------------
fixture chrony-deb; file /etc/chrony/chrony.conf; probe distro_chrony_conf
expect_ok "chrony config: Debian file" /etc/chrony/chrony.conf
fixture chrony-el; file /etc/chrony.conf; probe distro_chrony_conf
expect_ok "chrony config: EL file" /etc/chrony.conf
fixture chrony-both; file /etc/chrony/chrony.conf; file /etc/chrony.conf; probe distro_chrony_conf
expect_fail "chrony config: both fails naming both" /etc/chrony/chrony.conf /etc/chrony.conf
fixture chrony-none; probe distro_chrony_conf
expect_fail "chrony config: neither fails naming both" /etc/chrony/chrony.conf /etc/chrony.conf
# --------------------------------------------------------------------------
# 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
# --------------------------------------------------------------------------
# OVS logrotate file.
# --------------------------------------------------------------------------
fixture ovslr-deb; file /etc/logrotate.d/openvswitch-switch; probe distro_ovs_logrotate
expect_ok "OVS logrotate: Debian file" /etc/logrotate.d/openvswitch-switch
fixture ovslr-el; file /etc/logrotate.d/openvswitch; probe distro_ovs_logrotate
expect_ok "OVS logrotate: EL file" /etc/logrotate.d/openvswitch
fixture ovslr-both; file /etc/logrotate.d/openvswitch-switch; file /etc/logrotate.d/openvswitch; probe distro_ovs_logrotate
expect_fail "OVS logrotate: both fails naming both" /etc/logrotate.d/openvswitch-switch /etc/logrotate.d/openvswitch
fixture ovslr-none; probe distro_ovs_logrotate
expect_fail "OVS logrotate: neither fails naming both" /etc/logrotate.d/openvswitch-switch /etc/logrotate.d/openvswitch
# --------------------------------------------------------------------------
# libreswan.
# --------------------------------------------------------------------------
# expect_rc <desc> <want> — for the predicates, which print nothing.
expect_rc() {
if [ "$RC" = "$2" ]; then pass "$1"; else fail "$1 (rc=$RC, want $2)"; fi
}
fixture ls-pluto; file /usr/libexec/ipsec/pluto; chmod +x "$ROOT/usr/libexec/ipsec/pluto"; probe distro_has_libreswan
expect_rc "libreswan: an executable pluto" 0
fixture ls-noexec; file /usr/libexec/ipsec/pluto; probe distro_has_libreswan
expect_rc "libreswan: a pluto that cannot run is not libreswan" 1
fixture ls-charon; file /usr/lib/ipsec/charon; chmod +x "$ROOT/usr/lib/ipsec/charon"; probe distro_has_libreswan
expect_rc "libreswan: strongSwan's charon is not libreswan" 1
# --------------------------------------------------------------------------
# OS family, read from os-release text on stdin.
# --------------------------------------------------------------------------
# family <os-release text> — runs distro_os_family on it. Sets OUT, ERR and RC.
family() {
RC=0
OUT=$(printf '%s\n' "$1" | /bin/bash -c '. "$1"; distro_os_family' _ "$DISTRO" 2>"$TMP/err") || RC=$?
ERR=$(cat "$TMP/err")
}
family 'PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
ID=debian
VERSION_ID="13"'
expect_ok "OS family: Debian" debian
family 'NAME="Ubuntu"
ID=ubuntu
ID_LIKE=debian'
expect_ok "OS family: Ubuntu is Debian's family" debian
family 'NAME="Oracle Linux Server"
ID="ol"
ID_LIKE="fedora"
VERSION_ID="10.2"'
expect_ok "OS family: Oracle Linux, quoted, is its own" ol
family 'ID=rocky
ID_LIKE="rhel centos fedora"'
expect_ok "OS family: another EL is not Oracle Linux" rocky
family 'NAME=Something
VERSION_ID=1'
expect_fail "OS family: no ID fails naming the file" /etc/os-release ID
# --------------------------------------------------------------------------
# OVS and OVN units the shipped Spinifex units name. Run against the real unit
# files, so a unit that names a Debian-only OVS unit fails on the EL fixture.
# --------------------------------------------------------------------------
UNITS_SRC="$(dirname -- "$SCRIPTS_DIR")/build/systemd"
spinifex_units() {
mkdir -p "$ROOT/etc/systemd/system"
cp "$UNITS_SRC"/spinifex.target "$UNITS_SRC"/spinifex-*.service "$ROOT/etc/systemd/system/"
}
deps_probe() {
set --
for f in "$ROOT"/etc/systemd/system/spinifex*; do set -- "$@" "${f#"$ROOT"}"; done
probe distro_missing_ovs_deps "$@"
}
el_ovs_units() {
for u in openvswitch.service ovsdb-server.service ovs-vswitchd.service ovn-controller.service ovn-northd.service openvswitch-ipsec.service; do unit "$u"; done
}
fixture deps-el; spinifex_units; el_ovs_units; deps_probe
expect_ok "OVS dependencies: every one the shipped units name is installed on EL" ""
fixture deps-no-ipsec; spinifex_units; el_ovs_units; rm "$ROOT/usr/lib/systemd/system/openvswitch-ipsec.service"; deps_probe
expect_ok "OVS dependencies: a missing unit is printed once" openvswitch-ipsec.service
fixture deps-debian-only; el_ovs_units
mkdir -p "$ROOT/etc/systemd/system"
printf '[Unit]\n# After=ovs-old.service\nAfter=openvswitch-switch.service ovn-controller.service\nWants=openvswitch-switch.service\n' >"$ROOT/etc/systemd/system/spinifex-x.service"
printf '[Unit]\nRequires=ovn-central.service' >"$ROOT/etc/systemd/system/spinifex-y.service"
deps_probe
expect_ok "OVS dependencies: Debian-only names are missing on EL, comments are not read" "openvswitch-switch.service
ovn-central.service"
fixture deps-unreadable; probe distro_missing_ovs_deps /etc/systemd/system/spinifex.target
expect_fail "OVS dependencies: an unreadable unit file fails naming it" "cannot read /etc/systemd/system/spinifex.target"
# --------------------------------------------------------------------------
# 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 <file> <name> — 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
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Runs setup.sh's Oracle Linux repository setup in a container, then resolves
# its runtime dnf list against the real repositories with dnf install --assumeno.
# Catches a renamed or dropped package, a SIG dependency the includepkgs
# allowlist misses, a changed SIG key, and QEMU or OVS resolving from the wrong
# repository.
set -euo pipefail
IMAGE="${1:-oraclelinux:10}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "Resolving ${IMAGE}..."
# The repository setup and the list both come from setup.sh itself, so the lint
# cannot drift from what the installer does.
docker run --rm -v "${SCRIPT_DIR}/setup.sh:/setup.sh:ro" "${IMAGE}" bash -c '
set -euo pipefail
INSTALL_SPINIFEX_LIB_ONLY=1 . /setup.sh
SUDO=""
ensure_ol_repos
# --assumeno always exits 1, so the transaction is judged from its output.
# shellcheck disable=SC2086
out=$(dnf install --assumeno $OL_RUNTIME_PACKAGES 2>&1) || true
if ! grep -q "^Operation aborted" <<< "$out" || grep -qE "^(Error|Problem)|No match for argument" <<< "$out"; then
echo "$out"
echo "FAIL: the Oracle Linux package list does not resolve" >&2
exit 1
fi
# repo_of <package> — the repository dnf would install the package from.
repo_of() { awk -v p="$1" "\$1 == p { print \$4; exit }" <<< "$out"; }
for want in "qemu-kvm $OL_KVM_UTILS_REPO" "qemu-img $OL_KVM_UTILS_REPO" \
"openvswitch3.5 $NFV_SIG_REPO_ID" "ovn25.03-central $NFV_SIG_REPO_ID"; do
pkg=${want% *} repo=${want#* }
got=$(repo_of "$pkg")
if [ "$got" != "$repo" ]; then
echo "$out"
echo "FAIL: $pkg resolves from ${got:-nowhere}, not $repo" >&2
exit 1
fi
done
'
echo "OK: every package resolves on ${IMAGE}, QEMU from KVM Utilities and OVS/OVN from the NFV SIG"
+92 -14
View File
@@ -57,6 +57,55 @@ 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_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 ---
# Resolved before anything stops, so a host whose OVS unit is ambiguous is
# refused with nothing changed.
OVS_UNIT=""
if command -v ovs-vsctl >/dev/null 2>&1; then
OVS_UNIT=$(distro_ovs_unit) || exit 1
fi
WIPE_DIRS=("$ETC_DIR" "$LOG_DIR" "$RUN_DIR")
$KEEP_DATA || WIPE_DIRS+=("$DATA_DIR")
@@ -102,6 +151,9 @@ fi
log "stopping services"
run sudo systemctl stop spinifex.target 2>/dev/null || true
# spinifex-hostnet starts with OVS, which is started below to delete bridges; it
# would rebuild the old veth pairs. setup-ovn.sh enables it again.
run sudo systemctl disable --now spinifex-hostnet.service 2>/dev/null || true
run sudo systemctl reset-failed 'spinifex-*' 2>/dev/null || true
# Stopping the target is not the same as its services being stopped.
@@ -134,8 +186,9 @@ fi
# `pgrep -x` and `pkill -x` match against comm, which the kernel truncates to 15
# characters, so `qemu-system-x86_64` is stored as `qemu-system-x86` and an -x
# match on the full name can never fire. Match the prefix instead: it is 11
# characters, well under the limit, and covers both x86_64 and aarch64. This is
# the same rule qemuProcessPrefix applies in spinifex/runtime/compute/vm/orphan_scan.go.
# characters, well under the limit, and covers both x86_64 and aarch64. EL-family
# hosts run /usr/libexec/qemu-kvm, whose comm is exactly `qemu-kvm`. This is the
# rule hostos.IsQEMUProcess applies in spinifex/runtime/host/hostos.
#
# Deliberately not -f. That matches the whole command line, where argv[0] may
# carry a path, so an anchored pattern misses the process it was written for.
@@ -144,7 +197,7 @@ fi
# ones has both under the same comm and an unscoped kill would take the wrong
# set down. The daemon runs guests as its own service user on an installed node;
# a dev install runs them as whoever invoked spx.
QEMU_PGREP=('^qemu-system')
QEMU_PGREP=('^(qemu-system|qemu-kvm$)')
for qemu_user in spinifex-daemon spinifex "${SUDO_USER:-$(id -un)}"; do
if id -u "$qemu_user" >/dev/null 2>&1; then
QEMU_PGREP=(-u "$qemu_user" "${QEMU_PGREP[@]}")
@@ -178,8 +231,10 @@ fi
# node keeping its old system-id across a reset re-registers under the old
# chassis name and collides with the new one over the encap IP.
log "removing OVS bridges and identity"
if command -v ovs-vsctl >/dev/null 2>&1; then
run sudo systemctl start openvswitch-switch 2>/dev/null || true
# A running ovn-controller recreates br-int as soon as it is deleted.
run sudo systemctl stop ovn-controller 2>/dev/null || true
if [ -n "$OVS_UNIT" ]; then
run sudo systemctl start "$OVS_UNIT" 2>/dev/null || true
$DRY_RUN || sleep 1
# Listed even under --dry-run: "which bridges" is the question an operator
# actually has here, and br-wan is a Linux bridge so it is never in this set.
@@ -188,7 +243,7 @@ if command -v ovs-vsctl >/dev/null 2>&1; then
run sudo ovs-vsctl --if-exists del-br "$br"
done
run sudo ovs-vsctl --if-exists clear Open_vSwitch . external_ids 2>/dev/null || true
run sudo systemctl stop openvswitch-switch 2>/dev/null || true
run sudo systemctl stop "$OVS_UNIT" 2>/dev/null || true
fi
run sudo rm -f /etc/openvswitch/system-id.conf
@@ -233,14 +288,20 @@ if [ -e "$ETC_DIR/firewall/custom.nft" ]; then
fi
log "removing OVN databases"
run sudo systemctl stop ovn-central 2>/dev/null || true
# Debian's ovn-central takes its split units down with it; EL's ovn-northd runs
# both databases itself. A compute node has neither.
for unit in ovn-central.service ovn-northd.service; do
if distro_unit_exists "$unit"; then
run sudo systemctl stop "$unit" 2>/dev/null || true
fi
done
run sudo systemctl stop ovn-controller 2>/dev/null || true
run sudo rm -f /var/lib/ovn/ovnnb_db.db /var/lib/ovn/ovnsb_db.db
# The RAFT membership flags live in this file, not in the databases just
# deleted. Left behind, ovn-central recreates the DB as a cluster member and
# blocks dialling a peer that has itself been reset.
run sudo rm -f /etc/default/ovn-central
# The RAFT membership flags live in these files (Debian, EL), not in the
# databases just deleted. Left behind, OVN central recreates the DB as a
# cluster member and blocks dialling a peer that has itself been reset.
run sudo rm -f /etc/default/ovn-central /etc/sysconfig/ovn-northd
if ip link show veth-wan-br >/dev/null 2>&1; then
log " deleting veth pair: veth-wan-br <-> veth-wan-ovs"
@@ -259,6 +320,21 @@ if [ -e /etc/systemd/network/15-spinifex-veth-wan.netdev ] ||
run sudo networkctl reload 2>/dev/null || true
fi
# The routed-NAT transit veth keeps 100.127.0.1/24 and the routes behind it.
if ip link show spx-nat-host >/dev/null 2>&1; then
log " deleting veth pair: spx-nat-host <-> spx-nat-ovs"
run sudo ip link del spx-nat-host 2>/dev/null || true
fi
if [ -e /etc/systemd/network/17-spinifex-nat.netdev ] ||
[ -e /etc/systemd/network/17-spinifex-nat.network ] ||
[ -e /etc/systemd/network/18-spinifex-nat-ovs.network ]; then
log " deleting NAT transit persistence units"
run sudo rm -f /etc/systemd/network/17-spinifex-nat.netdev \
/etc/systemd/network/17-spinifex-nat.network \
/etc/systemd/network/18-spinifex-nat-ovs.network
run sudo networkctl reload 2>/dev/null || true
fi
# `rm -rf` is wrong here on two counts. On an ISO-installed node several of
# these are separate ZFS datasets — rpool/log and rpool/data/{nats,viperblock,
# predastore,predastore-db,predastore-nodes} — and a mountpoint cannot be
@@ -374,10 +450,12 @@ fi
# The next init writes a fresh CA. Leaving the old one trusted means the host
# trusts a CA nobody holds the key for.
if [ -f /usr/local/share/ca-certificates/spinifex-ca.crt ]; then
CA_TRUST=$(distro_ca_trust) || { echo "ERROR: cannot tell which trust store holds the stale CA" >&2; exit 1; }
CA_ANCHOR="${CA_TRUST#* }/spinifex-ca.crt"
if [ -f "$CA_ANCHOR" ]; then
log "removing the stale CA from the trust store"
run sudo rm -f /usr/local/share/ca-certificates/spinifex-ca.crt
run sudo update-ca-certificates
run sudo rm -f "$CA_ANCHOR"
run sudo "${CA_TRUST%% *}"
fi
log "done — node is installed but uninitialized, ready to init or join"
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# node-reset_test.sh — tests the QEMU process match node-reset.sh uses to stop
# guests before it deletes Viperblock state, against real processes.
#
# Each fake guest is a copy of sleep named like a QEMU binary, so the kernel
# gives it the same comm, including the 15-character truncation.
set -eu
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
TMP=$(mktemp -d)
pids=""
cleanup() {
for pid in $pids; do kill "$pid" 2>/dev/null || true; done
rm -rf "$TMP"
}
trap cleanup EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- node-reset.sh QEMU process match"
# pattern_in <script> — the pgrep pattern the script assigns to QEMU_PGREP.
pattern_in() {
sed -n "s/^QEMU_PGREP=('\(.*\)')$/\1/p" "$1"
}
PATTERN=$(pattern_in "$SCRIPT_DIR/node-reset.sh")
if [ -z "$PATTERN" ]; then
echo " FAIL - no QEMU_PGREP=('...') line in node-reset.sh"
exit 1
fi
# reset-dev-env.sh stops guests the same way and must not drift from it.
dev_pattern=$(pattern_in "$SCRIPT_DIR/reset-dev-env.sh")
if [ "$dev_pattern" = "$PATTERN" ]; then
pass "reset-dev-env.sh uses node-reset.sh's pattern"
else
fail "reset-dev-env.sh pattern '$dev_pattern' differs from node-reset.sh's '$PATTERN'"
fi
# start <name> — runs a process whose comm is <name>, truncated to 15
# characters. Its PID is left in $!.
start() {
cp "$(command -v sleep)" "$TMP/$1"
"$TMP/$1" 300 &
pids="$pids $!"
}
start qemu-system-x86_64; pid_x86=$!
start qemu-system-aarch64; pid_arm=$!
start qemu-kvm; pid_kvm=$!
start qemu-img; pid_img=$!
start qemu-kvm-helper; pid_helper=$!
me=$(id -un)
found=" $(pgrep -u "$me" "$PATTERN" | tr '\n' ' ' || true) "
check_found() {
case "$found" in
*" $2 "*) pass "finds a $1 guest owned by the service user" ;;
*) fail "misses a $1 guest (pid $2) owned by the service user" ;;
esac
}
check_found qemu-system-x86_64 "$pid_x86"
check_found qemu-system-aarch64 "$pid_arm"
check_found qemu-kvm "$pid_kvm"
check_ignored() {
case "$found" in
*" $2 "*) fail "matches $1 (pid $2), which is not a guest" ;;
*) pass "ignores $1" ;;
esac
}
check_ignored qemu-img "$pid_img"
check_ignored qemu-kvm-helper "$pid_helper"
# The owner scope: the same guests, asked for as another user, are not found.
other=root
[ "$me" = root ] && other=nobody
other_found=" $(pgrep -u "$other" "$PATTERN" | tr '\n' ' ' || true) "
case "$other_found" in
*" $pid_kvm "* | *" $pid_x86 "*) fail "a scope of -u $other still matches guests owned by $me" ;;
*) pass "ignores guests owned by another user" ;;
esac
if [ "$fails" -gt 0 ]; then
echo "$fails failure(s)"
exit 1
fi
+33 -8
View File
@@ -50,6 +50,8 @@ CONFIG_FILE="$ETC_DIR/spinifex.toml"
# --- Script context ---
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# shellcheck source=lib/distro.sh
. "$SCRIPT_DIR/lib/distro.sh"
# Resolve invoking user's HOME. $HOME is unsafe under sudo (resolves to /root).
INVOKING_USER="${SUDO_USER:-$(id -un)}"
@@ -359,17 +361,29 @@ fi
# --- Shutdown services ---
echo "==> Stopping services"
sudo systemctl stop spinifex.target 2>/dev/null || true
# spinifex-hostnet starts with OVS, which is started below to delete bridges; it
# would rebuild the old veth pairs. setup-ovn.sh enables it again.
sudo systemctl disable --now spinifex-hostnet.service 2>/dev/null || true
sudo systemctl reset-failed 'spinifex-*' 2>/dev/null || true
sudo pkill -x qemu-system-x86_64 2>/dev/null || true
sudo pkill -x qemu-system-aarch64 2>/dev/null || true
# comm truncates to 15 characters, so match qemu-system-* by prefix; EL-family
# hosts run qemu-kvm. Scoped to the user the daemon runs guests as, as in
# node-reset.sh, so libvirt guests on the same host are left alone.
QEMU_PGREP=('^(qemu-system|qemu-kvm$)')
for qemu_user in spinifex-daemon spinifex "$INVOKING_USER"; do
if id -u "$qemu_user" >/dev/null 2>&1; then
QEMU_PGREP=(-u "$qemu_user" "${QEMU_PGREP[@]}")
break
fi
done
sudo pkill "${QEMU_PGREP[@]}" 2>/dev/null || true
# Wait for QEMU to fully exit before tearing down viperblock state.
timeout=30
elapsed=0
while pgrep -x 'qemu-system-x86_64|qemu-system-aarch64' > /dev/null 2>&1; do
while pgrep "${QEMU_PGREP[@]}" > /dev/null 2>&1; do
if [ "$elapsed" -ge "$timeout" ]; then
echo "❌ QEMU still running after ${timeout}s:"
pgrep -af 'qemu-system-' || true
pgrep -a "${QEMU_PGREP[@]}" || true
echo " Kill them manually and re-run this script."
exit 1
fi
@@ -380,21 +394,26 @@ done
# --- Clean OVS / OVN ---
echo "==> Cleaning OVS bridges"
if command -v ovs-vsctl >/dev/null 2>&1; then
sudo systemctl start openvswitch-switch 2>/dev/null || true
OVS_UNIT=$(distro_ovs_unit) || exit 1
sudo systemctl start "$OVS_UNIT" 2>/dev/null || true
sleep 1
for br in $(sudo ovs-vsctl list-br 2>/dev/null); do
echo " Deleting bridge: $br"
sudo ovs-vsctl --if-exists del-br "$br"
done
sudo ovs-vsctl --if-exists clear Open_vSwitch . external_ids 2>/dev/null || true
sudo systemctl stop openvswitch-switch 2>/dev/null || true
sudo systemctl stop "$OVS_UNIT" 2>/dev/null || true
fi
# Delete OVN DB files outright — setup-ovn.sh will restart ovn-central with
# fresh empty DBs. Eliminates stale SB state (chassis entries, port bindings)
# that accumulates across resets and triggers ovn-controller commit loops.
echo "==> Removing OVN database files"
sudo systemctl stop ovn-central 2>/dev/null || true
for unit in ovn-central.service ovn-northd.service; do
if distro_unit_exists "$unit"; then
sudo systemctl stop "$unit" 2>/dev/null || true
fi
done
sudo systemctl stop ovn-controller 2>/dev/null || true
if [ -d /var/lib/ovn ]; then
sudo rm -f /var/lib/ovn/ovnnb_db.db /var/lib/ovn/ovnsb_db.db
@@ -516,10 +535,16 @@ sleep 5
echo "==> Building and installing microVM artifacts"
cd "$PROJECT_ROOT" && make build-lb-agent install-microvm
# By absolute path: sudo's secure_path leaves out /usr/local/bin on some hosts,
# Oracle Linux among them, and setup.sh installs the AWS CLI there.
AWS_BIN=$(command -v aws || true)
[ -z "$AWS_BIN" ] && [ -x /usr/local/bin/aws ] && AWS_BIN=/usr/local/bin/aws
[ -n "$AWS_BIN" ] || { echo "ERROR: aws CLI not found on PATH or at /usr/local/bin/aws" >&2; exit 1; }
# --- Authorize SSH ingress on the default security group ---
# Default SG denies all inbound, so smoke-test.sh's SSH probe times out.
# Open port 22 on the default VPC's default SG before the smoke test runs.
aws_as_user() { sudo -u "$INVOKING_USER" env HOME="$INVOKING_HOME" AWS_PROFILE=spinifex aws "$@"; }
aws_as_user() { sudo -u "$INVOKING_USER" env HOME="$INVOKING_HOME" AWS_PROFILE=spinifex "$AWS_BIN" "$@"; }
echo "==> Waiting for EC2 daemon"
DAEMON_TIMEOUT=60
+1 -1
View File
@@ -66,7 +66,7 @@ MAX_QEMU_ATTEMPTS=60
QEMU_CMD=""
while [ $QEMU_ATTEMPTS -lt $MAX_QEMU_ATTEMPTS ]; do
QEMU_CMD=$(ps auxw | grep "$INSTANCE_ID" | grep qemu-system | grep -v grep)
QEMU_CMD=$(ps auxw | grep "$INSTANCE_ID" | grep -E "qemu-system|qemu-kvm" | grep -v grep)
if [ -n "$QEMU_CMD" ]; then
break
+501 -118
View File
@@ -4,12 +4,20 @@
#
# This script bootstraps a compute node for OVN-based VPC networking:
# 1. Installs OVN/OVS packages (if not present)
# 2. Enables required services (openvswitch-switch, ovn-controller)
# 2. Enables required services (Open vSwitch, ovn-controller)
# 3. Creates br-int with secure fail-mode
# 4. Configures WAN bridge for public subnet uplink (auto-detected or manual)
# 5. Configures OVS external_ids for OVN chassis identity
# 6. Applies sysctl tuning for overlay networking
#
# Host network persistence: the links this script creates are kernel-only. On
# a host where systemd-networkd is enabled, or that already has Spinifex
# networkd files, they persist in /etc/systemd/network. Otherwise (for example
# a NetworkManager host) the topology goes to /etc/spinifex/hostnet.json and
# spinifex-hostnet.service re-creates it at boot and on every OVS start. The
# first run records which in /etc/spinifex/hostnet-backend; it never changes.
# Where NetworkManager runs, the links Spinifex owns are made unmanaged.
#
# Usage:
# ./scripts/setup-ovn.sh [options]
#
@@ -121,6 +129,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=""
@@ -145,6 +166,10 @@ DB_CLUSTER_REMOTE_ADDR=""
# Recreating the NB/SB DBs discards all logical network state, so it is opt-in.
RECREATE_DB=false
OVN_DBDIR="${OVN_DBDIR:-/var/lib/ovn}"
# Records that --management set this node up. Nodes set up before it existed
# carry only the legacy marker, OVN_CTL_OPTS in /etc/default/ovn-central.
OVN_ROLE_FILE=/etc/spinifex/ovn-role
LEGACY_OVN_ROLE_FILE=/etc/default/ovn-central
# NODE_NAME is left empty by default. The chassis-id pin block at Step 4
# only runs when --node-name=NAME is explicitly given. Passing nothing
# preserves whatever system-id already lives in OVS (gold-image UUID,
@@ -222,6 +247,152 @@ iface_master() {
printf '%s' "$m"
}
# --- Host network persistence ---
#
# Links this script creates are kernel-only and vanish on reboot. The first run
# picks how they persist and records it: networkd where systemd-networkd is
# enabled, else hostnet (spinifex-hostnet.service replays HOSTNET_STATE at boot
# and on every ovs-vswitchd start). There is no switching; reset the node.
HOSTNET_BACKEND_FILE=/etc/spinifex/hostnet-backend
HOSTNET_STATE=/etc/spinifex/hostnet.json
HOSTNET_LOCK=/run/spinifex/hostnet.lock
NETWORKD_DIR=/etc/systemd/network
NM_UNMANAGED_CONF=/etc/NetworkManager/conf.d/00-spinifex-unmanaged.conf
NM_UNMANAGED_RULE=/etc/udev/rules.d/90-spinifex-nm-unmanaged.rules
# System-instance management TAPs: "mg" plus 13 hex digits of the instance ID.
# NetworkManager globs have no character classes, so this match goes to udev.
MGMT_TAP_GLOB='mg[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
# Prints the recorded backend, or picks one and records it. A node with
# Spinifex networkd files and no record was set up before the record existed.
select_hostnet_backend() {
local backend
if sudo test -e "$HOSTNET_BACKEND_FILE"; then
backend=$(sudo cat "$HOSTNET_BACKEND_FILE" | tr -d '[:space:]')
case "$backend" in
networkd|hostnet) printf '%s\n' "$backend"; return 0 ;;
esac
echo "ERROR: $HOSTNET_BACKEND_FILE says '$backend'; expected networkd or hostnet" >&2
return 1
fi
if compgen -G "$NETWORKD_DIR/*-spinifex-*.network" >/dev/null || compgen -G "$NETWORKD_DIR/*-spinifex-*.netdev" >/dev/null; then
backend=networkd
elif systemctl is-enabled --quiet systemd-networkd.service 2>/dev/null; then
backend=networkd
else
backend=hostnet
fi
sudo mkdir -p "$(dirname "$HOSTNET_BACKEND_FILE")"
printf '%s\n' "$backend" | sudo tee "$HOSTNET_BACKEND_FILE" >/dev/null
# Read by `spx admin hostnet check`, which the daemon and vpcd run unprivileged.
sudo chmod 0644 "$HOSTNET_BACKEND_FILE"
printf '%s\n' "$backend"
}
# write_hostnet_state <true|false> — records the topology for
# `spx admin hostnet apply`, atomically. true marks it pending: apply refuses
# it until the run that wrote it finishes.
write_hostnet_state() {
local pending="$1" body
body=$(printf '{\n "version": 1,\n "pending": %s,\n "int_bridge": "br-int"' "$pending")
case "$WAN_BRIDGE_MODE" in
existing)
body="$body"$(printf ',\n "wan": {"mode": "existing", "ovs_bridge": "%s"}' "$WAN_BRIDGE") ;;
veth)
body="$body"$(printf ',\n "wan": {"mode": "veth", "ovs_bridge": "%s", "linux_bridge": "%s", "host_end": "veth-wan-br", "ovs_end": "veth-wan-ovs"}' "$WAN_BRIDGE" "$LINUX_BRIDGE") ;;
direct)
body="$body"$(printf ',\n "wan": {"mode": "direct", "ovs_bridge": "%s", "nic": "%s"}' "$WAN_BRIDGE" "$WAN_IFACE") ;;
nat)
body="$body"$(printf ',\n "wan": {"mode": "nat", "ovs_bridge": "%s", "host_end": "spx-nat-host", "ovs_end": "spx-nat-ovs", "host_mac": "%s", "host_cidr": "%s"}' "$WAN_BRIDGE" "$NAT_TRANSIT_HOST_MAC" "$NAT_TRANSIT_GW_CIDR") ;;
esac
if [ "$MGMT_BRIDGE_ENABLED" = true ]; then
body="$body"$(printf ',\n "mgmt": {"bridge": "%s", "cidr": "%s", "nic": "%s"}' "$MGMT_BRIDGE" "$MGMT_CIDR" "$MGMT_IFACE")
fi
if [ -n "${ENCAP_ROUTE_DST:-}" ]; then
body="$body"$(printf ',\n "encap_route": {"dst": "%s", "dev": "%s", "src": "%s", "metric": %s}' \
"$ENCAP_ROUTE_DST" "$ENCAP_ROUTE_DEV" "$ENCAP_IP" "$ENCAP_ROUTE_METRIC")
fi
sudo mkdir -p "$(dirname "$HOSTNET_STATE")"
printf '%s\n}\n' "$body" | sudo tee "$HOSTNET_STATE.tmp" >/dev/null
sudo chmod 0644 "$HOSTNET_STATE.tmp"
sudo mv -f "$HOSTNET_STATE.tmp" "$HOSTNET_STATE"
}
# Holds the lock `spx admin hostnet apply` takes, from a root flock process fed
# by a pipe, so an apply started by an OVS restart waits for this run's links.
# Never run systemctl against OVS or spinifex-hostnet while holding it.
hostnet_lock() {
local reply=""
sudo mkdir -p "$(dirname "$HOSTNET_LOCK")"
coproc HOSTNET_LOCK_HOLDER { exec sudo flock -w 120 "$HOSTNET_LOCK" sh -c 'echo locked; read -r _'; }
read -r reply <&"${HOSTNET_LOCK_HOLDER[0]}" || true
if [ "$reply" != locked ]; then
echo "ERROR: $HOSTNET_LOCK still held after 120 s; is spx admin hostnet apply stuck? (systemctl status spinifex-hostnet)" >&2
exit 1
fi
}
hostnet_unlock() {
local fd="${HOSTNET_LOCK_HOLDER[1]}" pid="$HOSTNET_LOCK_HOLDER_PID"
exec {fd}>&-
wait "$pid" 2>/dev/null || true
}
nm_running() {
systemctl is-active --quiet NetworkManager.service 2>/dev/null
}
# nm_release_nic <nic> — before a NIC moves into OVS, refuse the one carrying
# the default route or this SSH session, then take it from NetworkManager. Its
# profiles are left alone: once unmanaged, none can activate on it.
nm_release_nic() {
local nic="$1" ssh_ip state
if sudo ovs-vsctl port-to-br "$nic" >/dev/null 2>&1; then
return 0
fi
if ip -4 route show default 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | grep -qx "$nic"; then
echo "ERROR: $nic carries the default route; moving it into OVS would cut this host off" >&2
exit 1
fi
ssh_ip=$(printf '%s\n' "${SSH_CONNECTION:-}" | awk '{print $3}')
if [ -n "$ssh_ip" ] && ip -o -4 addr show dev "$nic" 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | grep -qxF "$ssh_ip"; then
echo "ERROR: this SSH session arrived on $nic ($ssh_ip); moving it into OVS would drop it" >&2
exit 1
fi
state=$(nmcli -g GENERAL.STATE device show "$nic" 2>/dev/null || true)
case "$state" in
*"(connect"*)
sudo nmcli device disconnect "$nic" >/dev/null
echo " $nic: disconnected from NetworkManager"
;;
esac
}
# Writes the NetworkManager config and udev rule that leave every link Spinifex
# owns unmanaged, then has NetworkManager and udev reread them. The host uplink
# (a Linux bridge such as br-wan, and its NIC) stays NetworkManager's.
write_nm_unmanaged() {
local names=(br-int "$WAN_BRIDGE" 'veth-wan-*' 'spx-nat-*' 'tap*' 'ime-*' 'imi-*' 'vhp-*' br-imds 'genev_sys_*')
local list="" name
if [ "$MGMT_BRIDGE_ENABLED" = true ]; then
names+=("$MGMT_BRIDGE" "$MGMT_IFACE")
fi
if [ "$WAN_BRIDGE_MODE" = direct ]; then
names+=("$WAN_IFACE")
fi
for name in "${names[@]}"; do
[ -n "$name" ] && list="${list:+$list;}interface-name:$name"
done
sudo mkdir -p "$(dirname "$NM_UNMANAGED_CONF")" "$(dirname "$NM_UNMANAGED_RULE")"
printf '# Written by setup-ovn.sh on every run: links Spinifex owns.\n[keyfile]\nunmanaged-devices+=%s\n' "$list" \
| sudo tee "$NM_UNMANAGED_CONF" >/dev/null
printf '# Written by setup-ovn.sh: system-instance management TAPs.\nSUBSYSTEM=="net", KERNEL=="%s", ENV{NM_UNMANAGED}="1"\n' "$MGMT_TAP_GLOB" \
| sudo tee "$NM_UNMANAGED_RULE" >/dev/null
sudo udevadm control --reload
sudo udevadm trigger --action=change --subsystem-match=net --sysname-match='mg*'
sudo nmcli general reload conf
}
teardown() {
echo "Tearing down the OVN host wiring built by this script"
[ "$DRY_RUN" = true ] && echo " (dry run — nothing is changed)"
@@ -469,6 +640,15 @@ else
detect_wan_bridge
fi
# Routed NAT transit, used by nat mode and recorded for spinifex-hostnet.
NAT_TRANSIT_CIDR="100.127.0.0/24"
NAT_TRANSIT_GW_CIDR="100.127.0.1/24"
# Same on every node. OVN keeps one MAC binding for the transit nexthop
# cluster-wide, so a random per-node MAC sends every chassis's egress to
# whichever node seeded it; the pair is node-local, so one address everywhere
# is unambiguous.
NAT_TRANSIT_HOST_MAC="02:00:64:7f:00:01"
# Auto-detect encap IP if not specified
if [ -z "$ENCAP_IP" ]; then
# Prefer br-vpc IP if it exists (dedicated VPC data plane)
@@ -575,19 +755,104 @@ ensure_clustered_db_storage() {
# fresh SB and re-registers under whatever system-id it started with — which
# on a renamed node is the old one, and that row then holds this node's encap
# IP so the correctly-named chassis can never commit. Step 5 restarts it.
sudo systemctl stop ovn-northd ovn-ovsdb-server-nb ovn-ovsdb-server-sb ovn-central ovn-controller 2>/dev/null || true
sudo systemctl stop "${OVN_CENTRAL_UNITS[@]}" ovn-controller 2>/dev/null || true
sudo rm -f "${standalone[@]}"
}
# set_root_service_user <file> <var> — sets <var>="root:root" in <file> when
# the file exists. Returns 0 only when it changed the file.
set_root_service_user() {
local file=$1 var=$2
[ -f "$file" ] || return 1
grep -qx "$var=\"root:root\"" "$file" && return 1
if grep -q "^$var=" "$file"; then
sudo sed -i "s|^$var=.*|$var=\"root:root\"|" "$file"
else
echo "$var=\"root:root\"" | sudo tee -a "$file" >/dev/null
fi
echo " $file: $var=\"root:root\""
}
# write_ovn_central_opts <flags> — writes the ovn-ctl flags to the options
# file the OVN central units read for the probed layout.
write_ovn_central_opts() {
echo "$OVN_CENTRAL_OPTS_VAR=\"$1\"" | sudo tee "$OVN_CENTRAL_OPTS_FILE" >/dev/null
echo " wrote $OVN_CENTRAL_OPTS_FILE"
}
record_management_role() {
sudo mkdir -p "$(dirname "$OVN_ROLE_FILE")"
echo management | sudo tee "$OVN_ROLE_FILE" >/dev/null
}
# management_role_markers — prints each file recording that a previous run set
# this node up with --management, one per line; returns 1 when there is none.
management_role_markers() {
local found=1
if sudo grep -qx management "$OVN_ROLE_FILE" 2>/dev/null; then
echo "$OVN_ROLE_FILE"
found=0
fi
if sudo grep -q '^OVN_CTL_OPTS=' "$LEGACY_OVN_ROLE_FILE" 2>/dev/null; then
echo "$LEGACY_OVN_ROLE_FILE"
found=0
fi
return $found
}
echo ""
echo "Step 2: Enabling services..."
sudo systemctl enable openvswitch-switch
sudo systemctl start openvswitch-switch
echo " openvswitch-switch: started"
# Unit names differ by distro: the OVS umbrella, and whether the OVN databases
# have units of their own (Debian) or are started by ovn-northd.service (EL).
OVS_UNIT=$(distro_ovs_unit) || exit 1
OVN_CENTRAL_LAYOUT=""
if [ "$MANAGEMENT" = true ] || distro_unit_exists ovn-central.service || distro_unit_exists ovn-northd.service; then
OVN_CENTRAL_LAYOUT=$(distro_ovn_central_layout) || exit 1
fi
case "$OVN_CENTRAL_LAYOUT" in
split)
OVN_CENTRAL_UNIT=ovn-central.service
OVN_DB_UNITS=(ovn-ovsdb-server-nb.service ovn-ovsdb-server-sb.service)
OVN_SERVER_UNITS=("${OVN_DB_UNITS[@]}" ovn-northd.service)
OVN_CENTRAL_UNITS=(ovn-central.service "${OVN_SERVER_UNITS[@]}")
OVN_CENTRAL_OPTS_FILE=/etc/default/ovn-central
OVN_CENTRAL_OPTS_VAR=OVN_CTL_OPTS
;;
lone-northd)
OVN_CENTRAL_UNIT=ovn-northd.service
OVN_DB_UNITS=(ovn-northd.service)
OVN_SERVER_UNITS=(ovn-northd.service)
OVN_CENTRAL_UNITS=(ovn-northd.service)
OVN_CENTRAL_OPTS_FILE=/etc/sysconfig/ovn-northd
OVN_CENTRAL_OPTS_VAR=OVN_NORTHD_OPTS
;;
esac
# OVS and OVN run as root, as on Debian: the service users reach the sockets
# through the spinifex group, a model tested only against root-owned daemons.
# EL ships non-root users and re-chowns the run dirs to them on every start.
OVS_USER_CHANGED=false
if set_root_service_user /etc/sysconfig/openvswitch OVS_USER_ID; then
OVS_USER_CHANGED=true
fi
# Takes effect below: ovn-northd and ovn-controller are restarted either way.
set_root_service_user /etc/sysconfig/ovn OVN_USER_ID || true
sudo systemctl enable "$OVS_UNIT"
if [ "$OVS_USER_CHANGED" = true ]; then
# start is a no-op on a running OVS, which would keep its old user.
sudo systemctl restart "$OVS_UNIT"
else
sudo systemctl start "$OVS_UNIT"
fi
echo " ${OVS_UNIT%.service}: started"
if [ "$MANAGEMENT" = true ]; then
sudo systemctl enable ovn-central
sudo systemctl enable "$OVN_CENTRAL_UNIT"
# Recorded before anything else changes, so a run that stops part way
# still makes a later run without --management refuse.
record_management_role
# LAN-plane NB/SB client listener, added to the loopback one set below.
# Guard is load-bearing: --db-nb-addr defaults to 0.0.0.0, so
@@ -600,8 +865,8 @@ if [ "$MANAGEMENT" = true ]; then
if [ -n "$DB_CLUSTER_LOCAL_ADDR" ]; then
ensure_clustered_db_storage
# Clustered NB/SB via native OVSDB RAFT. Both per-DB units source one
# shared OVN_CTL_OPTS from /etc/default/ovn-central; each run_*_ovsdb
# Clustered NB/SB via native OVSDB RAFT. Every OVN central unit reads
# one shared options file (write_ovn_central_opts); each run_*_ovsdb
# consumes only its own --db-{nb,sb}-* flags. RAFT ports default to NB
# 6643 / SB 6644. ovn-ctl creates the cluster when no remote-addr is
# given (and no existing .db file), joins when one is.
@@ -635,26 +900,28 @@ if [ "$MANAGEMENT" = true ]; then
echo " NB/SB client listen: 127.0.0.1"
fi
echo "OVN_CTL_OPTS=\"$OVN_CTL_OPTS\"" | sudo tee /etc/default/ovn-central >/dev/null
echo " wrote /etc/default/ovn-central"
write_ovn_central_opts "$OVN_CTL_OPTS"
# The packaged ovn-northd.service ExecStop runs `ovn-ctl stop_northd`
# without --ovn-manage-ovsdb=no, so restarting northd also tears down the
# NB/SB ovsdb-server units. With the split clustered units those DBs are
# owned by their own units, so override ExecStop to leave them alone —
# otherwise the restart below races and kills the freshly-started DBs.
sudo mkdir -p /etc/systemd/system/ovn-northd.service.d
sudo tee /etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf >/dev/null <<'EOF'
# EL's lone ovn-northd.service owns the DBs, so stopping them is right there.
if [ "$OVN_CENTRAL_LAYOUT" = split ]; then
sudo mkdir -p /etc/systemd/system/ovn-northd.service.d
sudo tee /etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf >/dev/null <<'EOF'
[Service]
ExecStop=
ExecStop=/usr/share/ovn/scripts/ovn-ctl stop_northd --no-monitor --ovn-manage-ovsdb=no
EOF
sudo systemctl daemon-reload
sudo systemctl daemon-reload
fi
# The ovn-central aggregator is ExecStart=/bin/true, so restarting it
# won't restart the children — restart the per-DB units directly to
# pick up the new OVN_CTL_OPTS.
sudo systemctl restart ovn-ovsdb-server-nb ovn-ovsdb-server-sb ovn-northd
# Debian's ovn-central aggregator is ExecStart=/bin/true, so restarting
# it won't restart the children — restart the DB and northd units
# directly to pick up the new OVN_CTL_OPTS.
sudo systemctl restart "${OVN_SERVER_UNITS[@]}"
echo " ovn-central: started clustered (NB DB + SB DB + ovn-northd)"
else
# Always write the file, even when there is nothing to put in it. This
@@ -662,14 +929,13 @@ EOF
# and the RAFT flags live here rather than in the database — leaving a
# stale file in place restarts the DB as a cluster member dialling a
# peer that no longer exists, which looks like a hung leader election.
echo "OVN_CTL_OPTS=\"$OVN_DB_LISTEN_OPTS\"" | sudo tee /etc/default/ovn-central >/dev/null
echo " wrote /etc/default/ovn-central"
sudo systemctl start ovn-central
write_ovn_central_opts "$OVN_DB_LISTEN_OPTS"
sudo systemctl start "$OVN_CENTRAL_UNIT"
# ovn-central is ExecStart=/bin/true; restarting it does not restart
# its children, so a re-run with changed options needs the per-DB units
# restarted directly to pick them up.
sudo systemctl restart ovn-ovsdb-server-nb ovn-ovsdb-server-sb
# Debian's ovn-central is ExecStart=/bin/true; restarting it does not
# restart its children, so a re-run with changed options needs the DB
# units restarted directly to pick them up.
sudo systemctl restart "${OVN_DB_UNITS[@]}"
echo " ovn-central: started (NB DB + SB DB + ovn-northd)"
if [ -n "$LAN_ADDR" ]; then
echo " NB/SB client listen: 127.0.0.1, $LAN_ADDR"
@@ -731,7 +997,7 @@ EOF
done
if [ "$(printf '%s\n' "$NB_MEMBERS" | grep -c .)" -gt 1 ]; then
sudo sed -i "s|\"\$| --ovn-northd-nb-db=$NB_LIST --ovn-northd-sb-db=$SB_LIST\"|" \
/etc/default/ovn-central
"$OVN_CENTRAL_OPTS_FILE"
sudo systemctl restart ovn-northd
echo " ovn-northd: NB=$NB_LIST SB=$SB_LIST (derived from RAFT membership)"
else
@@ -796,26 +1062,27 @@ else
# 6641, and vpcd crash-loops on "connection refused" with no hint that this
# script caused it. Seen on mulga-poc at 15,061 restarts.
#
# The signal is /etc/default/ovn-central, not the presence of a database:
# the ovn-central *package* creates DBs on install, so every compute node
# has them and testing for them would refuse every legitimate compute node.
# Only the --management path below writes OVN_CTL_OPTS to that file.
if [ -f /etc/default/ovn-central ] && grep -q '^OVN_CTL_OPTS=' /etc/default/ovn-central 2>/dev/null; then
# The signal is a role marker, not the presence of a database: the OVN
# central *package* creates DBs on install, so every compute node has them
# and testing for them would refuse every legitimate compute node. Only the
# --management path above writes a marker (management_role_markers).
if ROLE_MARKERS=$(management_role_markers); then
ROLE_MARKERS=${ROLE_MARKERS//$'\n'/ }
echo "ERROR: this node was configured as a management node by a previous run"
echo " (/etc/default/ovn-central holds OVN_CTL_OPTS), but --management was"
echo " not given. Re-running without it stops and disables ovn-central and"
echo " takes the control plane down."
echo " (recorded in $ROLE_MARKERS), but --management was not given."
echo " Re-running without it stops and disables OVN central and takes the"
echo " control plane down."
echo ""
echo " If this is a management node, re-run with --management."
echo " If it is genuinely being demoted to a compute node:"
echo " sudo rm -f /etc/default/ovn-central"
echo " sudo rm -f $ROLE_MARKERS"
exit 1
fi
if systemctl is-enabled ovn-central >/dev/null 2>&1 ||
systemctl is-active ovn-central >/dev/null 2>&1; then
sudo systemctl stop ovn-central ovn-northd ovn-ovsdb-server-nb ovn-ovsdb-server-sb 2>/dev/null || true
sudo systemctl disable ovn-central ovn-northd ovn-ovsdb-server-nb ovn-ovsdb-server-sb 2>/dev/null || true
if [ -n "$OVN_CENTRAL_LAYOUT" ] && { systemctl is-enabled "$OVN_CENTRAL_UNIT" >/dev/null 2>&1 ||
systemctl is-active "$OVN_CENTRAL_UNIT" >/dev/null 2>&1; }; then
sudo systemctl stop "${OVN_CENTRAL_UNITS[@]}" 2>/dev/null || true
sudo systemctl disable "${OVN_CENTRAL_UNITS[@]}" 2>/dev/null || true
echo " ovn-central: stopped and disabled (compute node runs no database)"
fi
for db in "$OVN_DBDIR/ovnnb_db.db" "$OVN_DBDIR/ovnsb_db.db"; do
@@ -825,6 +1092,34 @@ else
done
fi
# --- Step 2b: Host network persistence and NetworkManager ---
echo ""
echo "Step 2b: Host network persistence..."
HOSTNET_BACKEND=$(select_hostnet_backend)
echo " backend: $HOSTNET_BACKEND (recorded in $HOSTNET_BACKEND_FILE)"
if nm_running; then
if [ "$MGMT_BRIDGE_ENABLED" = true ] && [ -n "$MGMT_IFACE" ]; then
nm_release_nic "$MGMT_IFACE"
fi
if [ "$WAN_BRIDGE_MODE" = direct ]; then
nm_release_nic "$WAN_IFACE"
fi
write_nm_unmanaged
echo " NetworkManager: Spinifex links unmanaged ($NM_UNMANAGED_CONF, $NM_UNMANAGED_RULE)"
fi
# Held until the wiring below is complete; a run that dies before then leaves
# the state pending, and spinifex-hostnet refuses it until this script is rerun.
ENCAP_ROUTE_DST=""
ENCAP_ROUTE_DEV=""
ENCAP_ROUTE_METRIC=50
if [ "$HOSTNET_BACKEND" = hostnet ]; then
hostnet_lock
write_hostnet_state true
echo " $HOSTNET_STATE: pending until this run finishes wiring"
fi
# --- Step 3: Create and configure br-int ---
echo ""
echo "Step 3: Configuring br-int..."
@@ -842,7 +1137,7 @@ echo " br-int: created, fail-mode=secure, up"
# still forwards, but ovn-controller flow programming and any tooling that
# probes link state misbehave. Unmanaged=yes keeps OVS in sole control.
OVS_INTERNAL_NET=/etc/systemd/network/05-spinifex-ovs-internal.network
if [ ! -f "$OVS_INTERNAL_NET" ]; then
if [ "$HOSTNET_BACKEND" = networkd ] && [ ! -f "$OVS_INTERNAL_NET" ]; then
sudo tee "$OVS_INTERNAL_NET" >/dev/null <<'NETWORK'
[Match]
Name=br-int br-ext
@@ -956,23 +1251,24 @@ if [ -n "$WAN_BRIDGE" ]; then
# existing networkd config. networkd searches /etc, /run, /usr/lib;
# check all three. Our own file is excluded so idempotent re-runs
# still rewrite when needed.
BR_WAN_NETDEV="/etc/systemd/network/14-spinifex-br-wan.netdev"
VETH_NETDEV="/etc/systemd/network/15-spinifex-veth-wan.netdev"
VETH_NETWORK="/etc/systemd/network/15-spinifex-veth-wan.network"
VETH_OVS_NETWORK="/etc/systemd/network/16-spinifex-veth-wan-ovs.network"
EXISTING_BR_NETDEV=$(grep -rls --include="*.netdev" -E "^\s*Name=$LINUX_BRIDGE\s*$" \
/etc/systemd/network /run/systemd/network /usr/lib/systemd/network 2>/dev/null \
| grep -v "^$BR_WAN_NETDEV$" || true)
if [ -n "$EXISTING_BR_NETDEV" ]; then
echo " skipping $BR_WAN_NETDEV — operator-managed NetDev already declares $LINUX_BRIDGE: $EXISTING_BR_NETDEV"
else
sudo tee "$BR_WAN_NETDEV" >/dev/null <<NETDEV
if [ "$HOSTNET_BACKEND" = networkd ]; then
BR_WAN_NETDEV="/etc/systemd/network/14-spinifex-br-wan.netdev"
VETH_NETDEV="/etc/systemd/network/15-spinifex-veth-wan.netdev"
VETH_NETWORK="/etc/systemd/network/15-spinifex-veth-wan.network"
VETH_OVS_NETWORK="/etc/systemd/network/16-spinifex-veth-wan-ovs.network"
EXISTING_BR_NETDEV=$(grep -rls --include="*.netdev" -E "^\s*Name=$LINUX_BRIDGE\s*$" \
/etc/systemd/network /run/systemd/network /usr/lib/systemd/network 2>/dev/null \
| grep -v "^$BR_WAN_NETDEV$" || true)
if [ -n "$EXISTING_BR_NETDEV" ]; then
echo " skipping $BR_WAN_NETDEV — operator-managed NetDev already declares $LINUX_BRIDGE: $EXISTING_BR_NETDEV"
else
sudo tee "$BR_WAN_NETDEV" >/dev/null <<NETDEV
[NetDev]
Name=$LINUX_BRIDGE
Kind=bridge
NETDEV
fi
sudo tee "$VETH_NETDEV" >/dev/null <<NETDEV
fi
sudo tee "$VETH_NETDEV" >/dev/null <<NETDEV
[NetDev]
Name=veth-wan-br
Kind=veth
@@ -980,7 +1276,7 @@ Kind=veth
[Peer]
Name=veth-wan-ovs
NETDEV
sudo tee "$VETH_NETWORK" >/dev/null <<NETWORK
sudo tee "$VETH_NETWORK" >/dev/null <<NETWORK
[Match]
Name=veth-wan-br
@@ -988,12 +1284,12 @@ Name=veth-wan-br
Bridge=$LINUX_BRIDGE
ConfigureWithoutCarrier=yes
NETWORK
# Second unit admin-ups the OVS end of the pair. OVS owns the port
# (enslaved via ovs-vsctl add-port above) but does not flip admin
# state on external ports — that's networkd's job. Without this,
# veth-wan-ovs stays DOWN after reboot, peer goes LOWERLAYERDOWN,
# br-wan loses carrier.
sudo tee "$VETH_OVS_NETWORK" >/dev/null <<NETWORK
# Second unit admin-ups the OVS end of the pair. OVS owns the port
# (enslaved via ovs-vsctl add-port above) but does not flip admin
# state on external ports — that's networkd's job. Without this,
# veth-wan-ovs stays DOWN after reboot, peer goes LOWERLAYERDOWN,
# br-wan loses carrier.
sudo tee "$VETH_OVS_NETWORK" >/dev/null <<NETWORK
[Match]
Name=veth-wan-ovs
@@ -1003,8 +1299,9 @@ RequiredForOnline=no
[Network]
ConfigureWithoutCarrier=yes
NETWORK
sudo networkctl reload 2>/dev/null || true
echo " wrote $VETH_NETDEV + $VETH_NETWORK + $VETH_OVS_NETWORK (veth persists on reboot)"
sudo networkctl reload 2>/dev/null || true
echo " wrote $VETH_NETDEV + $VETH_NETWORK + $VETH_OVS_NETWORK (veth persists on reboot)"
fi
;;
direct)
@@ -1038,14 +1335,6 @@ NETWORK
# forwards and masquerades the transit /24 out whatever uplink it
# has. OVN SNATs each VPC CIDR to its gateway LRP transit IP, so
# the masquerade rule below is the only host-side NAT state.
NAT_TRANSIT_CIDR="100.127.0.0/24"
NAT_TRANSIT_GW_CIDR="100.127.0.1/24"
# Same on every node. OVN keeps one MAC binding for the transit
# nexthop cluster-wide, so a random per-node MAC sends every
# chassis's egress to whichever node seeded it; the pair is
# node-local, so one address everywhere is unambiguous.
NAT_TRANSIT_HOST_MAC="02:00:64:7f:00:01"
if ! sudo ovs-vsctl br-exists "$WAN_BRIDGE" 2>/dev/null; then
sudo ovs-vsctl --may-exist add-br "$WAN_BRIDGE"
echo " created OVS bridge: $WAN_BRIDGE"
@@ -1073,10 +1362,11 @@ NETWORK
# Persist the veth pair + transit IP across reboot (veths are
# kernel-only; same rationale as veth mode above).
NAT_NETDEV="/etc/systemd/network/17-spinifex-nat.netdev"
NAT_NETWORK="/etc/systemd/network/17-spinifex-nat.network"
NAT_OVS_NETWORK="/etc/systemd/network/18-spinifex-nat-ovs.network"
sudo tee "$NAT_NETDEV" >/dev/null <<NETDEV
if [ "$HOSTNET_BACKEND" = networkd ]; then
NAT_NETDEV="/etc/systemd/network/17-spinifex-nat.netdev"
NAT_NETWORK="/etc/systemd/network/17-spinifex-nat.network"
NAT_OVS_NETWORK="/etc/systemd/network/18-spinifex-nat-ovs.network"
sudo tee "$NAT_NETDEV" >/dev/null <<NETDEV
[NetDev]
Name=spx-nat-host
Kind=veth
@@ -1085,7 +1375,7 @@ MACAddress=$NAT_TRANSIT_HOST_MAC
[Peer]
Name=spx-nat-ovs
NETDEV
sudo tee "$NAT_NETWORK" >/dev/null <<NETWORK
sudo tee "$NAT_NETWORK" >/dev/null <<NETWORK
[Match]
Name=spx-nat-host
@@ -1093,9 +1383,9 @@ Name=spx-nat-host
Address=$NAT_TRANSIT_GW_CIDR
ConfigureWithoutCarrier=yes
NETWORK
# Admin-up the OVS end after reboot (OVS owns the port but does
# not flip admin state on external ports — same as veth mode).
sudo tee "$NAT_OVS_NETWORK" >/dev/null <<NETWORK
# Admin-up the OVS end after reboot (OVS owns the port but does
# not flip admin state on external ports — same as veth mode).
sudo tee "$NAT_OVS_NETWORK" >/dev/null <<NETWORK
[Match]
Name=spx-nat-ovs
@@ -1105,8 +1395,9 @@ RequiredForOnline=no
[Network]
ConfigureWithoutCarrier=yes
NETWORK
sudo networkctl reload 2>/dev/null || true
echo " wrote $NAT_NETDEV + $NAT_NETWORK + $NAT_OVS_NETWORK (transit veth persists on reboot)"
sudo networkctl reload 2>/dev/null || true
echo " wrote $NAT_NETDEV + $NAT_NETWORK + $NAT_OVS_NETWORK (transit veth persists on reboot)"
fi
# Kernel egress: masquerade the transit /24 out any uplink and
# accept forwarded transit traffic. vpcd re-ensures these on every
@@ -1261,8 +1552,9 @@ if [ "$MGMT_BRIDGE_ENABLED" = true ]; then
# Persist the IP across reboots via a systemd-networkd drop-in. The
# OVS bridge definition itself survives in ovsdb; only the L3 IP
# needs re-applying on boot.
MGMT_NETD_UNIT="/etc/systemd/network/10-spinifex-mgmt.network"
sudo tee "$MGMT_NETD_UNIT" >/dev/null <<NETD
if [ "$HOSTNET_BACKEND" = networkd ]; then
MGMT_NETD_UNIT="/etc/systemd/network/10-spinifex-mgmt.network"
sudo tee "$MGMT_NETD_UNIT" >/dev/null <<NETD
[Match]
Name=$MGMT_BRIDGE
@@ -1270,7 +1562,8 @@ Name=$MGMT_BRIDGE
Address=$MGMT_CIDR
ConfigureWithoutCarrier=yes
NETD
echo " wrote $MGMT_NETD_UNIT (IP persists on reboot via systemd-networkd)"
echo " wrote $MGMT_NETD_UNIT (IP persists on reboot via systemd-networkd)"
fi
fi
fi
@@ -1384,6 +1677,9 @@ sudo tee /etc/sysctl.d/99-spinifex-vpc.conf >/dev/null <<'SYSCTL'
net.ipv4.ip_forward=1
net.ipv4.conf.all.rp_filter=0
net.ipv4.conf.default.rp_filter=0
# EL's 50-redhat.conf sets every interface strict, reapplied by udev on each new
# netdev; a strict IMDS endpoint drops guest requests, so keep those loose here.
net.ipv4.conf.ime-*.rp_filter=0
SYSCTL
sudo sysctl --system -q
echo " ip_forward=1, rp_filter=0"
@@ -1401,8 +1697,10 @@ DATA_IFACE=$(ip -o -4 addr show | awk -v ip="$ENCAP_IP" '$0 ~ ip"/" {print $2}')
if [ -n "$DATA_IFACE" ]; then
SUBNET=$(ip -o -4 route show dev "$DATA_IFACE" proto kernel scope link | awk '{print $1}' | head -1)
if [ -n "$SUBNET" ]; then
sudo ip route replace "$SUBNET" dev "$DATA_IFACE" src "$ENCAP_IP" metric 50
echo " data route: $SUBNET via $DATA_IFACE src $ENCAP_IP (metric 50)"
sudo ip route replace "$SUBNET" dev "$DATA_IFACE" src "$ENCAP_IP" metric "$ENCAP_ROUTE_METRIC"
echo " data route: $SUBNET via $DATA_IFACE src $ENCAP_IP (metric $ENCAP_ROUTE_METRIC)"
ENCAP_ROUTE_DST="$SUBNET"
ENCAP_ROUTE_DEV="$DATA_IFACE"
else
echo " skipped: no kernel route found for $DATA_IFACE"
fi
@@ -1410,6 +1708,12 @@ else
echo " skipped: could not find interface for $ENCAP_IP"
fi
if [ "$HOSTNET_BACKEND" = hostnet ]; then
write_hostnet_state false
hostnet_unlock
echo " $HOSTNET_STATE: recorded for spinifex-hostnet"
fi
# --- Step 7: Verify Geneve kernel support ---
echo ""
echo "Step 7: Verifying Geneve kernel module..."
@@ -1509,17 +1813,24 @@ echo " OVS/OVN sockets: 0660 root:spinifex (group-scoped, no world access)"
# file is the old 0666 override, and skipping would leave that exposure in place.
# openvswitch-ipsec is included because Step 10 restarts it after this sweep,
# so its ctl socket would otherwise be the one file left at the shipped mode.
for unit in openvswitch-switch:/var/run/openvswitch/db.sock \
"ovn-controller:/var/run/ovn/*.ctl" \
"openvswitch-ipsec:/var/run/openvswitch/ovs-monitor-ipsec.*.ctl"; do
# ovs-vswitchd starts after ovsdb-server and restarts with it (Requires=), so
# its hook sees both daemons' sockets and fires on a restart of either.
PERMS_UNITS=("ovs-vswitchd:/var/run/openvswitch/ovs-vswitchd.*.ctl"
"ovn-controller:/var/run/ovn/*.ctl"
"openvswitch-ipsec:/var/run/openvswitch/ovs-monitor-ipsec.*.ctl")
# EL's ovn-northd.service starts the NB and SB databases as well as northd.
if [ "$OVN_CENTRAL_LAYOUT" = lone-northd ]; then
PERMS_UNITS+=("ovn-northd:/var/run/ovn/ovn-northd.*.ctl")
fi
for unit in "${PERMS_UNITS[@]}"; do
UNIT="${unit%%:*}"
WAIT_GLOB="${unit#*:}"
OVERRIDE_DIR="/etc/systemd/system/${UNIT}.service.d"
sudo mkdir -p "$OVERRIDE_DIR"
# `ExecStartPost=-` so a failure here cannot stop the unit. This drop-in goes
# into another package's service, and tightening a socket mode is hardening,
# not a liveness requirement — it must never be the reason openvswitch-switch
# or ovn-controller will not start. Without the dash, removing the helper
# not a liveness requirement — it must never be the reason OVS or
# ovn-controller will not start. Without the dash, removing the helper
# (an uninstall, a partial re-run that has not reached this step yet) takes
# OVS down across the whole host with `Failed at step EXEC`, which reads as
# an OVS fault and is nothing of the sort.
@@ -1540,23 +1851,45 @@ else
echo " sudoers rule: not found — run setup.sh first, or install manually"
fi
# --- Step 9: Configure OVN log rotation ---
# The ovn-common package provides /etc/logrotate.d/ovn-common which handles
# rotation and vlog/reopen. We just add maxsize + rotate to cap disk usage.
# --- Step 9: Configure OVS and OVN log rotation ---
# The packages' logrotate files handle rotation and vlog/reopen; this caps
# disk usage and rotates as the user the daemons now run as.
echo ""
echo "Step 9: Configuring OVN log rotation..."
echo "Step 9: Configuring OVS and OVN log rotation..."
OVN_LOGROTATE="/etc/logrotate.d/ovn-common"
if [ -f "$OVN_LOGROTATE" ]; then
if ! grep -q 'maxsize' "$OVN_LOGROTATE"; then
sudo sed -i '/^\/var\/log\/ovn\/\*\.log {/a\ rotate 5\n maxsize 100M' "$OVN_LOGROTATE"
echo " added maxsize 100M + rotate 5 to $OVN_LOGROTATE"
else
echo " $OVN_LOGROTATE already has maxsize configured"
# cap_logrotate <file> — adds maxsize 100M, and rotate 5 where the file keeps
# no rotated logs, to its /var/log/<dir>/*.log stanza. A su line naming another
# user becomes su root root: rotating root-owned logs as that user fails.
cap_logrotate() {
local file=$1 lines=' maxsize 100M' added='maxsize 100M'
if sudo grep -qE '^[[:space:]]*su[[:space:]]' "$file" &&
! sudo grep -qE '^[[:space:]]*su[[:space:]]+root[[:space:]]+root[[:space:]]*$' "$file"; then
sudo sed -i -E 's/^([[:space:]]*)su[[:space:]].*/\1su root root/' "$file"
echo " $file: su root root"
fi
else
echo " WARNING: $OVN_LOGROTATE not found — install ovn-common package"
fi
if sudo grep -q 'maxsize' "$file"; then
echo " $file already has maxsize configured"
return 0
fi
if ! sudo grep -qE '^[[:space:]]*rotate[[:space:]]' "$file"; then
lines=' rotate 5\n maxsize 100M'
added='maxsize 100M + rotate 5'
fi
sudo sed -i "/^\/var\/log\/[a-z]*\/\*\.log {/a\\$lines" "$file"
if ! sudo grep -q 'maxsize' "$file"; then
echo " WARNING: $file has no /var/log/<dir>/*.log stanza; log size left uncapped"
return 0
fi
echo " added $added to $file"
}
for probe in distro_ovs_logrotate distro_ovn_logrotate; do
if LOGROTATE_FILE=$($probe); then
cap_logrotate "$LOGROTATE_FILE"
else
echo " WARNING: log size left uncapped (see above)"
fi
done
# Remove our old custom config if present (superseded by patching ovn-common)
if [ -f /etc/logrotate.d/ovn-spinifex ]; then
@@ -1570,22 +1903,72 @@ fi
# to prevent log spam during those retries.
echo ""
echo "Step 10: Enabling OVN auto-start on boot..."
sudo systemctl enable openvswitch-switch 2>/dev/null || true
sudo systemctl enable "$OVS_UNIT" 2>/dev/null || true
sudo systemctl enable ovn-controller 2>/dev/null || true
# ovs-monitor-ipsec drives strongSwan from OVS DB cert pointers. The daemon's
# enableOVNIPSec() flips ipsec_encapsulation=true at runtime and silently drops
# tunnel traffic if this unit isn't already up — enable at provision time so
# daemon never needs systemd-write capability (only is-active read).
sudo systemctl enable openvswitch-ipsec.service 2>/dev/null || true
# When openvswitch-ipsec is installed before strongswan-starter, the deb
# post-install starts the service against a missing /usr/sbin/ipsec and
# leaves it in 'failed' state. Clear the failure and restart unconditionally
# now that strongswan-starter is present.
sudo systemctl reset-failed openvswitch-ipsec.service 2>/dev/null || true
sudo systemctl restart openvswitch-ipsec.service 2>/dev/null || true
echo " openvswitch-switch: enabled on boot"
echo " ovn-controller: enabled on boot"
echo " openvswitch-ipsec: enabled on boot"
printf ' %-21s enabled on boot\n' "${OVS_UNIT%.service}:" ovn-controller:
if distro_has_libreswan; then
# OVN IPsec does not run on libreswan yet, so it stays off here. Starting
# openvswitch-ipsec would also start pluto, which listens on UDP 500/4500.
IPSEC_UNITS=(ipsec.service)
distro_unit_exists openvswitch-ipsec.service && IPSEC_UNITS+=(openvswitch-ipsec.service)
if ! sudo systemctl mask --now "${IPSEC_UNITS[@]}" >/dev/null; then
echo "ERROR: could not stop and mask ${IPSEC_UNITS[*]}; IPsec must stay off on libreswan" >&2
exit 1
fi
printf ' %-21s %s\n' openvswitch-ipsec: "masked: IPsec stays off on libreswan" ipsec: "masked: IPsec stays off on libreswan"
elif [ "$(systemctl is-enabled openvswitch-ipsec.service 2>/dev/null)" = masked ]; then
# The daemon masks it while IPsec is off and unmasks it to turn IPsec on.
printf ' %-21s %s\n' openvswitch-ipsec: "masked: IPsec is off, left to the daemon"
else
# ovs-monitor-ipsec drives strongSwan from OVS DB cert pointers. The daemon's
# enableOVNIPSec() flips ipsec_encapsulation=true at runtime and silently drops
# tunnel traffic if this unit isn't already up — enable at provision time so
# daemon never needs systemd-write capability (only is-active read).
sudo systemctl enable openvswitch-ipsec.service 2>/dev/null || true
# When openvswitch-ipsec is installed before strongswan-starter, the deb
# post-install starts the service against a missing /usr/sbin/ipsec and
# leaves it in 'failed' state. Clear the failure and restart unconditionally
# now that strongswan-starter is present.
sudo systemctl reset-failed openvswitch-ipsec.service 2>/dev/null || true
sudo systemctl restart openvswitch-ipsec.service 2>/dev/null || true
printf ' %-21s enabled on boot\n' openvswitch-ipsec:
fi
# Applied now, outside the hostnet lock, so the daemon and vpcd find its
# readiness marker; on the networkd backend the apply does nothing.
if [ -f /etc/systemd/system/spinifex-hostnet.service ]; then
sudo systemctl enable spinifex-hostnet.service >/dev/null 2>&1
if ! sudo systemctl restart spinifex-hostnet.service; then
echo "ERROR: spinifex-hostnet could not apply the host network: journalctl -u spinifex-hostnet" >&2
exit 1
fi
printf ' %-21s enabled on boot, applied (%s backend)\n' spinifex-hostnet: "$HOSTNET_BACKEND"
else
echo " spinifex-hostnet: not installed yet — run setup.sh, then re-run this script"
fi
# systemd silently drops ordering against a unit that is not installed, so a
# missing OVS or OVN unit would leave Spinifex starting unordered against OVS.
SPINIFEX_UNITS=()
for f in /etc/systemd/system/spinifex.target /etc/systemd/system/spinifex-*.service; do
[ -f "$f" ] && SPINIFEX_UNITS+=("$f")
done
if [ ${#SPINIFEX_UNITS[@]} -eq 0 ]; then
echo " Spinifex units: not installed yet — run setup.sh, then re-run this script"
else
MISSING_DEPS=$(distro_missing_ovs_deps "${SPINIFEX_UNITS[@]}") || exit 1
DEPS_OK=true
for dep in $MISSING_DEPS; do
if [ "$dep" = openvswitch-ipsec.service ]; then
echo " WARNING: $dep is not installed; IPsec cannot be turned on"
else
echo "ERROR: the Spinifex units order against $dep, which is not installed" >&2
DEPS_OK=false
fi
done
[ "$DEPS_OK" = true ] || exit 1
echo " OVS/OVN units the Spinifex units name: installed"
fi
# --- Step 11: Health check ---
echo ""
+239 -1
View File
@@ -131,7 +131,7 @@ esac
# The script and the Go constant must agree: vpcd repairs a veth made before
# this existed, and a disagreement would have the two fighting every pass.
script_mac=$(printf '%s\n' "$nat_mode_body" | sed -n 's/.*NAT_TRANSIT_HOST_MAC="\([^"]*\)".*/\1/p' | head -1)
script_mac=$(sed -n 's/^NAT_TRANSIT_HOST_MAC="\([^"]*\)".*/\1/p' "$SETUP" | head -1)
go_mac=$(sed -n 's/.*NATTransitHostMAC = "\([^"]*\)".*/\1/p' "$SCRIPT_DIR/../spinifex/domains/network/host/routed.go" | head -1)
if [ -n "$script_mac" ] && [ "$script_mac" = "$go_mac" ]; then
pass "the transit MAC matches host.NATTransitHostMAC"
@@ -139,6 +139,244 @@ else
fail "transit MAC disagrees: script=$script_mac go=$go_mac"
fi
# --------------------------------------------------------------------------
# OVS and OVN service users.
# --------------------------------------------------------------------------
# EL runs OVS and OVN as non-root users and re-chowns their sockets on every
# start. The function is run on its own against scratch files, through a sudo
# that only runs its arguments.
awk '$0 == "set_root_service_user() {" { on = 1 } on { print } on && $0 == "}" { exit }' "$SETUP" >"$TMP/user_fn.sh"
mkdir -p "$TMP/bin"
printf '#!/bin/sh\nexec "$@"\n' >"$TMP/bin/sudo"
chmod +x "$TMP/bin/sudo"
# set_user <file> — runs the function on <file> for OVS_USER_ID; sets RC.
set_user() {
RC=0
PATH="$TMP/bin:$PATH" bash -c '. "$1"; set_root_service_user "$2" OVS_USER_ID' _ "$TMP/user_fn.sh" "$1" >/dev/null || RC=$?
}
set_user "$TMP/absent"
if [ "$RC" != 0 ] && [ ! -e "$TMP/absent" ]; then pass "service user: no file, no change"; else fail "service user: absent file gave rc=$RC or was created"; fi
printf '# OVS_USER_ID="openvswitch:openvswitch"\nOPTIONS=""\nOVS_USER_ID="openvswitch:hugetlbfs"\n' >"$TMP/el"
set_user "$TMP/el"
want='# OVS_USER_ID="openvswitch:openvswitch"
OPTIONS=""
OVS_USER_ID="root:root"'
if [ "$RC" = 0 ] && [ "$(cat "$TMP/el")" = "$want" ]; then pass "service user: EL default replaced in place, comments and other lines kept"; else fail "service user: EL file rc=$RC: $(cat "$TMP/el")"; fi
set_user "$TMP/el"
if [ "$RC" != 0 ] && [ "$(cat "$TMP/el")" = "$want" ]; then pass "service user: already root reports no change"; else fail "service user: second run rc=$RC: $(cat "$TMP/el")"; fi
printf 'OPTIONS=""\n' >"$TMP/unset"
set_user "$TMP/unset"
if [ "$RC" = 0 ] && [ "$(cat "$TMP/unset")" = 'OPTIONS=""
OVS_USER_ID="root:root"' ]; then pass "service user: appended when the file does not set it"; else fail "service user: unset file rc=$RC: $(cat "$TMP/unset")"; fi
# --------------------------------------------------------------------------
# Host network persistence backend.
# --------------------------------------------------------------------------
# The backend is picked once and recorded; every later run reads the record.
# The functions run on their own against scratch paths, with systemctl stubbed.
for fn in select_hostnet_backend write_nm_unmanaged; do
awk -v f="$fn() {" '$0 == f { on = 1 } on { print } on && $0 == "}" { exit }' "$SETUP" >>"$TMP/hostnet_fn.sh"
done
# backend <networkd-enabled:yes|no> — runs select_hostnet_backend; sets RC, OUT.
backend() {
printf '#!/bin/sh
[ "$1 $2" = "is-enabled --quiet" ] && [ "%s" = yes ]
' "$1" >"$TMP/bin/systemctl"
chmod +x "$TMP/bin/systemctl"
RC=0
OUT=$(PATH="$TMP/bin:$PATH" HOSTNET_BACKEND_FILE="$TMP/hn/etc/hostnet-backend" NETWORKD_DIR="$TMP/hn/network" \
bash -c '. "$1"; select_hostnet_backend' _ "$TMP/hostnet_fn.sh" 2>&1) || RC=$?
}
reset_hn() { rm -rf "$TMP/hn"; mkdir -p "$TMP/hn/network"; }
reset_hn
backend no
if [ "$RC" = 0 ] && [ "$OUT" = hostnet ] && [ "$(cat "$TMP/hn/etc/hostnet-backend")" = hostnet ]; then pass "backend: no networkd picks and records hostnet"; else fail "backend: no networkd gave rc=$RC out=$OUT"; fi
reset_hn
backend yes
if [ "$RC" = 0 ] && [ "$OUT" = networkd ] && [ "$(cat "$TMP/hn/etc/hostnet-backend")" = networkd ]; then pass "backend: networkd enabled picks networkd"; else fail "backend: networkd enabled gave rc=$RC out=$OUT"; fi
reset_hn
: >"$TMP/hn/network/10-spinifex-mgmt.network"
backend no
if [ "$RC" = 0 ] && [ "$OUT" = networkd ]; then pass "backend: legacy Spinifex networkd files keep networkd"; else fail "backend: legacy files gave rc=$RC out=$OUT"; fi
reset_hn
mkdir -p "$TMP/hn/etc"
echo hostnet >"$TMP/hn/etc/hostnet-backend"
backend yes
if [ "$RC" = 0 ] && [ "$OUT" = hostnet ]; then pass "backend: the record wins over networkd being enabled"; else fail "backend: recorded hostnet gave rc=$RC out=$OUT"; fi
echo ifupdown >"$TMP/hn/etc/hostnet-backend"
backend yes
case "$RC:$OUT" in
0:*) fail "backend: an unknown record was accepted: $OUT" ;;
*"expected networkd or hostnet"*) pass "backend: an unknown record is refused" ;;
*) fail "backend: an unknown record gave rc=$RC out=$OUT" ;;
esac
# --------------------------------------------------------------------------
# NetworkManager unmanaged config.
# --------------------------------------------------------------------------
# NetworkManager globs have no character classes, so the management TAPs go to
# a udev rule; the keyfile lists the rest. The host uplink bridge stays managed.
printf '#!/bin/sh
exit 0
' >"$TMP/bin/udevadm"
cp "$TMP/bin/udevadm" "$TMP/bin/nmcli"
chmod +x "$TMP/bin/udevadm" "$TMP/bin/nmcli"
glob=$(sed -n "s/^MGMT_TAP_GLOB='\([^']*\)'$/\1/p" "$SETUP")
RC=0
PATH="$TMP/bin:$PATH" NM_UNMANAGED_CONF="$TMP/nm/conf.d/00-spinifex-unmanaged.conf" NM_UNMANAGED_RULE="$TMP/nm/rules.d/90.rules" \
MGMT_TAP_GLOB="$glob" WAN_BRIDGE=br-ext WAN_BRIDGE_MODE=direct WAN_IFACE=eth3 LINUX_BRIDGE=br-wan \
MGMT_BRIDGE_ENABLED=true MGMT_BRIDGE=br-mgmt MGMT_IFACE=eth2 \
bash -c '. "$1"; write_nm_unmanaged' _ "$TMP/hostnet_fn.sh" >/dev/null 2>&1 || RC=$?
conf=$(cat "$TMP/nm/conf.d/00-spinifex-unmanaged.conf" 2>/dev/null || true)
line=$(printf '%s\n' "$conf" | grep '^unmanaged-devices+=' || true)
if [ "$RC" = 0 ] && printf '%s\n' "$conf" | grep -qx '\[keyfile\]' && [ -n "$line" ]; then pass "nm: keyfile section appends to unmanaged-devices"; else fail "nm: rc=$RC conf=$conf"; fi
for want in br-int br-ext 'veth-wan-*' 'spx-nat-*' 'tap*' br-mgmt eth2 eth3; do
case ";${line#unmanaged-devices+=};" in
*";interface-name:$want;"*) pass "nm: $want unmanaged" ;;
*) fail "nm: $want missing from $line" ;;
esac
done
case "$line" in
*br-wan*) fail "nm: the host uplink bridge br-wan was made unmanaged" ;;
*'['*) fail "nm: keyfile uses a character class NetworkManager cannot match: $line" ;;
*) pass "nm: host uplink left managed and no character classes" ;;
esac
rule=$(cat "$TMP/nm/rules.d/90.rules" 2>/dev/null || true)
case "$rule" in
*"KERNEL==\"$glob\", ENV{NM_UNMANAGED}=\"1\""*) pass "nm: udev rule unmanages management TAPs" ;;
*) fail "nm: udev rule is $rule" ;;
esac
# --------------------------------------------------------------------------
# OVN central options file and management role.
# --------------------------------------------------------------------------
# The functions run on their own against scratch paths, through the stub sudo.
for fn in write_ovn_central_opts record_management_role management_role_markers cap_logrotate; do
awk -v f="$fn() {" '$0 == f { on = 1 } on { print } on && $0 == "}" { exit }' "$SETUP" >>"$TMP/ovn_fn.sh"
done
# ovn_fn <cmd>... — runs the functions against scratch role files; sets RC, OUT.
ovn_fn() {
RC=0
OUT=$(PATH="$TMP/bin:$PATH" OVN_ROLE_FILE="$TMP/role/etc/spinifex/ovn-role" \
LEGACY_OVN_ROLE_FILE="$TMP/role/etc/default/ovn-central" \
bash -c '. "$1"; shift; "$@"' _ "$TMP/ovn_fn.sh" "$@" 2>&1) || RC=$?
}
ovn_fn env OVN_CENTRAL_OPTS_FILE="$TMP/sysconfig-ovn-northd" OVN_CENTRAL_OPTS_VAR=OVN_NORTHD_OPTS \
bash -c '. "$1"; write_ovn_central_opts "--db-nb-addr=10.0.0.1 --db-nb-create-insecure-remote=yes"' _ "$TMP/ovn_fn.sh"
if [ "$RC" = 0 ] && [ "$(cat "$TMP/sysconfig-ovn-northd")" = 'OVN_NORTHD_OPTS="--db-nb-addr=10.0.0.1 --db-nb-create-insecure-remote=yes"' ]; then
pass "opts: EL file holds OVN_NORTHD_OPTS"
else
fail "opts: rc=$RC $(cat "$TMP/sysconfig-ovn-northd" 2>/dev/null)"
fi
rm -rf "$TMP/role"
ovn_fn management_role_markers
if [ "$RC" != 0 ] && [ -z "$OUT" ]; then pass "role: a fresh node has no marker"; else fail "role: fresh node rc=$RC out=$OUT"; fi
ovn_fn record_management_role
ovn_fn management_role_markers
if [ "$RC" = 0 ] && [ "$OUT" = "$TMP/role/etc/spinifex/ovn-role" ]; then pass "role: recorded marker is found"; else fail "role: recorded marker rc=$RC out=$OUT"; fi
mkdir -p "$TMP/role/etc/default"
echo 'OVN_CTL_OPTS=""' >"$TMP/role/etc/default/ovn-central"
ovn_fn management_role_markers
if [ "$RC" = 0 ] && [ "$OUT" = "$TMP/role/etc/spinifex/ovn-role
$TMP/role/etc/default/ovn-central" ]; then pass "role: both markers are named, so the demotion hint removes both"; else fail "role: both markers rc=$RC out=$OUT"; fi
rm -f "$TMP/role/etc/spinifex/ovn-role"
ovn_fn management_role_markers
if [ "$RC" = 0 ] && [ "$OUT" = "$TMP/role/etc/default/ovn-central" ]; then pass "role: the legacy Debian marker alone still counts"; else fail "role: legacy marker rc=$RC out=$OUT"; fi
# The package's own file sets only commented defaults; it is not a marker.
printf '# OVN_CTL_OPTS="--db-nb-addr=0.0.0.0"\n' >"$TMP/role/etc/default/ovn-central"
echo compute >"$TMP/role/etc/spinifex/ovn-role"
ovn_fn management_role_markers
if [ "$RC" != 0 ]; then pass "role: a package-default options file and another role are not markers"; else fail "role: non-markers gave rc=$RC out=$OUT"; fi
# --------------------------------------------------------------------------
# OVS and OVN log rotation.
# --------------------------------------------------------------------------
# Fixtures are the stanzas the Debian 13 and NFV SIG el10s packages ship.
mkdir -p "$TMP/lr"
cat >"$TMP/lr/ovn-common" <<'EOF'
/var/log/ovn/*.log {
su root root
daily
compress
sharedscripts
missingok
}
EOF
cat >"$TMP/lr/openvswitch-switch" <<'EOF'
/var/log/openvswitch/*.log {
daily
compress
sharedscripts
create 640 root adm
missingok
rotate 30
}
EOF
cat >"$TMP/lr/ovn" <<'EOF'
/var/log/ovn/*.log {
su openvswitch openvswitch
daily
compress
sharedscripts
missingok
}
EOF
printf '/var/log/other.log {\n daily\n}\n' >"$TMP/lr/odd"
# directives <file> — the stanza's directives, sorted, one per line.
directives() { sed -n 's/^ //p' "$1" | sort; }
ovn_fn cap_logrotate "$TMP/lr/ovn-common"
if [ "$RC" = 0 ] && [ "$(directives "$TMP/lr/ovn-common")" = "$(printf 'compress\ndaily\nmaxsize 100M\nmissingok\nrotate 5\nsharedscripts\nsu root root\n')" ]; then
pass "logrotate: Debian OVN gets rotate 5 and maxsize, as before"
else
fail "logrotate: Debian OVN rc=$RC: $(cat "$TMP/lr/ovn-common")"
fi
ovn_fn cap_logrotate "$TMP/lr/openvswitch-switch"
if [ "$RC" = 0 ] && grep -qx ' maxsize 100M' "$TMP/lr/openvswitch-switch" &&
[ "$(grep -c '^ *rotate ' "$TMP/lr/openvswitch-switch")" = 1 ] && grep -qx ' rotate 30' "$TMP/lr/openvswitch-switch"; then
pass "logrotate: Debian OVS keeps its rotate 30 and gains maxsize"
else
fail "logrotate: Debian OVS rc=$RC: $(cat "$TMP/lr/openvswitch-switch")"
fi
ovn_fn cap_logrotate "$TMP/lr/ovn"
if [ "$RC" = 0 ] && [ "$(directives "$TMP/lr/ovn")" = "$(printf 'compress\ndaily\nmaxsize 100M\nmissingok\nrotate 5\nsharedscripts\nsu root root\n')" ]; then
pass "logrotate: EL OVN rotates as root, with rotate 5 and maxsize"
else
fail "logrotate: EL OVN rc=$RC: $(cat "$TMP/lr/ovn")"
fi
before=$(cat "$TMP/lr/ovn")
ovn_fn cap_logrotate "$TMP/lr/ovn"
if [ "$RC" = 0 ] && [ "$(cat "$TMP/lr/ovn")" = "$before" ]; then pass "logrotate: a rerun changes nothing"; else fail "logrotate: rerun changed $TMP/lr/ovn: $(cat "$TMP/lr/ovn")"; fi
ovn_fn cap_logrotate "$TMP/lr/odd"
case "$RC:$OUT" in
0:*"left uncapped"*) pass "logrotate: a file without the expected stanza warns" ;;
*) fail "logrotate: unexpected stanza gave rc=$RC out=$OUT" ;;
esac
if [ "$fails" -ne 0 ]; then
echo " setup-ovn.sh: $fails failure(s)"
exit 1
+617 -79
View File
@@ -7,7 +7,7 @@
# INSTALL_SPINIFEX_CHANNEL Release channel: latest (default), dev
# INSTALL_SPINIFEX_VERSION Pin to specific version (overrides channel)
# INSTALL_SPINIFEX_TARBALL Path to local tarball (skips download, for testing/air-gapped)
# INSTALL_SPINIFEX_SKIP_APT Set to 1 to skip apt dependency install
# INSTALL_SPINIFEX_SKIP_APT Set to 1 to skip the system package install (apt or dnf)
# INSTALL_SPINIFEX_SKIP_AWS Set to 1 to skip AWS CLI install
# INSTALL_SPINIFEX_SKIP_NEWGRP Set to 1 to skip newgrp exec at end (for callers like dev-install.sh)
# ISO_BUILD Set to 1 when running inside a debootstrap chroot from the ISO
@@ -15,9 +15,9 @@
# skip systemctl daemon-reload + enable, short-circuit setup_sudo.
# VERBOSE Set to 1 to echo "[setup] <stage>" before each top-level step.
# SETUP_STAGES Comma-separated subset of stages to run:
# deps, aws, users, sudoers, firewall, timesync, files,
# directories, env, systemd, sysctl, logrotate, udev,
# fixown, migrations
# deps, aws, dns, users, sudoers, firewall, timesync, files,
# directories, env, systemd, sysctl, modules, logrotate,
# udev, fixown, migrations
# Unset = run every stage appropriate for the current mode.
set -e
@@ -39,9 +39,13 @@ FIREWALL_PEERS="${FIREWALL_DIR}/peers.nft"
FIREWALL_LOCAL="${FIREWALL_DIR}/local.nft"
FIREWALL_OPEN="${FIREWALL_DIR}/open-ports.nft"
FIREWALL_CUSTOM="${FIREWALL_DIR}/custom.nft"
FIREWALL_MODE_FILE="${FIREWALL_DIR}/mode"
FIREWALL_MODE_FILE="${SPINIFEX_FIREWALL_MODE_FILE:-${FIREWALL_DIR}/mode}"
FIREWALL_APPLY="/usr/local/lib/spinifex/spinifex-firewall-apply"
# The daemon runs with ProtectKernelModules=yes, so every module it needs must
# already be loaded when it starts.
SPINIFEX_KERNEL_MODULES="openvswitch geneve vhost_net tun"
# Whether the policy is armed, not merely installed. The ISO is an appliance on
# hardware we define, so a default-deny policy is the product; a curl-to-bash
# install lands on a machine that was already doing something, where arming a
@@ -58,6 +62,48 @@ 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
}
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_chrony_conf() {
local deb=false el=false
[ -f "${DISTRO_ROOT:-}/etc/chrony/chrony.conf" ] && deb=true
[ -f "${DISTRO_ROOT:-}/etc/chrony.conf" ] && el=true
if $deb && $el; then
echo "distro.sh: chrony config: found both /etc/chrony/chrony.conf and /etc/chrony.conf; cannot tell which one chronyd reads" >&2
return 1
fi
if $deb; then echo /etc/chrony/chrony.conf; return 0; fi
if $el; then echo /etc/chrony.conf; return 0; fi
echo "distro.sh: chrony config: found neither /etc/chrony/chrony.conf nor /etc/chrony.conf; is chrony installed?" >&2
return 1
}
# --- END distro.sh probes ---
stage() {
[ "${VERBOSE:-0}" = "1" ] && echo "[setup] $*"
return 0
@@ -92,14 +138,25 @@ setup_sudo() {
}
# --- OS detection ---
# os_release_field <key> <file> — 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 +168,77 @@ 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:-<no 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
check_selinux_not_enforcing
}
# Spinifex ships no SELinux policy yet. Oracle's templates boot enforcing, and
# setenforce alone reverts on reboot, so the config file is checked as well.
check_selinux_not_enforcing() {
local config="${SPINIFEX_SELINUX_CONFIG:-/etc/selinux/config}" now="" boot=""
if command -v getenforce >/dev/null 2>&1; then
now=$(getenforce 2>/dev/null | tr '[:upper:]' '[:lower:]')
fi
if [ -f "$config" ]; then
boot=$(os_release_field SELINUX "$config" | tr '[:upper:]' '[:lower:]')
fi
if [ "$now" = "enforcing" ]; then
fatal "SELinux is enforcing, and Spinifex has no SELinux policy. Run 'sudo setenforce 0', set SELINUX=permissive in $config, then re-run this installer"
fi
if [ "$boot" = "enforcing" ]; then
fatal "SELinux is set to enforcing at next boot (SELINUX=enforcing in $config). Set SELINUX=permissive there, then re-run this installer"
fi
}
# 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 ---
@@ -287,7 +409,12 @@ case "$1" in
exec systemctl start openvswitch-ipsec.service
;;
off)
exec systemctl mask --now openvswitch-ipsec.service
systemctl mask --now openvswitch-ipsec.service
# libreswan's pluto listens on UDP 500/4500 whenever ipsec.service runs.
# On Debian that name is strongswan-starter's alias, masked above.
if [ -x /usr/libexec/ipsec/pluto ]; then
systemctl mask --now ipsec.service
fi
;;
*)
echo "state not permitted: $1" >&2
@@ -326,6 +453,73 @@ sshd_ports() {
printf '%s' "$_out"
}
# An unset --firewall keeps the mode a previous install recorded, so a rerun
# never disarms a node. The record is read as root: /etc/spinifex is closed to
# anyone outside the spinifex group, who would otherwise see no record at all.
resolve_firewall_mode() {
local recorded
case "${INSTALL_SPINIFEX_FIREWALL}" in
on|off) return 0 ;;
"") ;;
*) fatal "--firewall must be 'on' or 'off', got: ${INSTALL_SPINIFEX_FIREWALL}" ;;
esac
if ${SUDO:-} test -e "$FIREWALL_MODE_FILE"; then
recorded=$(${SUDO:-} cat "$FIREWALL_MODE_FILE") \
|| fatal "Cannot read the recorded firewall mode in $FIREWALL_MODE_FILE. Re-run with --firewall=on or --firewall=off"
recorded=$(printf '%s' "$recorded" | tr -d '[:space:]')
case "$recorded" in
on|off) INSTALL_SPINIFEX_FIREWALL="$recorded"
info "Host firewall: keeping the recorded mode '$recorded' ($FIREWALL_MODE_FILE)"
return 0 ;;
*) fatal "$FIREWALL_MODE_FILE holds '${recorded}', not 'on' or 'off'. Re-run with --firewall=on or --firewall=off" ;;
esac
fi
if [ "${ISO_BUILD:-0}" != "1" ] && firewalld_active; then
INSTALL_SPINIFEX_FIREWALL="on"
info "Host firewall: firewalld is active, so the Spinifex policy will be armed in its place"
return 0
fi
# The ISO is an appliance and arms; any other host was already doing something.
if [ "${ISO_BUILD:-0}" = "1" ]; then
INSTALL_SPINIFEX_FIREWALL="on"
else
INSTALL_SPINIFEX_FIREWALL="off"
fi
}
firewalld_active() {
systemctl is-active --quiet firewalld.service 2>/dev/null
}
# Disable firewalld if present. The node firewall is spinifex-managed: with
# firewalld running, a packet must be accepted by both its table and ours, so
# routed NAT, elastic IPs and IMDS on ime-* ports break with nothing logged.
# Masked, not just disabled, so a package update or D-Bus activation cannot
# bring it back. Its rules are not saved or migrated.
mask_firewalld() {
local state
[ "${ISO_BUILD:-0}" = "1" ] && return 0
state=$(systemctl is-enabled firewalld.service 2>/dev/null) || true
case "$state" in
"" | not-found) return 0 ;;
masked) if ! firewalld_active; then
info " firewalld already masked"
return 0
fi ;;
esac
$SUDO systemctl mask --now firewalld.service > /dev/null \
|| fatal "Cannot stop and mask firewalld. The Spinifex nftables policy cannot run beside it"
warn "firewalld is now stopped and masked: the Spinifex nftables policy is the only supported node firewall."
warn " firewalld's rules were not migrated. The Spinifex policy filters input only, accepts ssh"
warn " from every IPv4 source until \$trusted_ssh_peers in $FIREWALL_CUSTOM narrows it, and opens"
warn " 53, 443, 3000, 8443 and 9999 to any source. Restate any other restriction in that file."
if [ "$INSTALL_SPINIFEX_FIREWALL" = "on" ]; then
warn " Until the daemon first loads the policy, this host accepts every connection."
else
warn " The Spinifex policy is not armed (--firewall=off), so this host now has no firewall."
fi
}
# The policy is written here rather than shipped in the tarball so the ISO chroot
# and the ansible path get an identical file without a packaging step. Installed
# but NOT enabled: the peer set is empty until spinifex-daemon resolves the
@@ -499,6 +693,10 @@ CUSTOM
$SUDO chmod 0644 "$FIREWALL_MODE_FILE"
info " $FIREWALL_MODE_FILE ($INSTALL_SPINIFEX_FIREWALL)"
# After the mode file, so a retry after an interrupted install still finds
# 'on' once firewalld is no longer active.
mask_firewalld
$SUDO tee "$FIREWALL_APPLY" > /dev/null << 'APPLY'
#!/bin/sh
# Applies the spinifex host firewall. Fails without changing the ruleset when
@@ -650,21 +848,171 @@ APPLY
fi
}
# --- Host DNS ---
# The one resolver setup supported on a host with systemd-resolved installed:
# NetworkManager hands link DNS to resolved, and /etc/resolv.conf is its stub.
RESOLV_CONF="${SPINIFEX_RESOLV_CONF:-/etc/resolv.conf}"
RESOLVED_STUB=/run/systemd/resolve/stub-resolv.conf
# node-reset.sh keeps /var/lib/spinifex-reset, so the copy survives until uninstall.
RESOLV_CONF_SAVED="${SPINIFEX_RESOLV_CONF_SAVED:-/var/lib/spinifex-reset/resolv.conf.orig}"
# systemd-resolved ships resolvconf as a link to resolvectl, which only works
# while resolved runs, so that one is not a resolvconf backend.
real_resolvconf() {
local path
path=$(command -v resolvconf) || return 1
[ "$(basename "$(readlink -f "$path")")" != resolvectl ]
}
# Prints the NetworkManager setting that keeps it from handing DNS to resolved,
# or nothing. Only explicitly set keys are uncommented in --print-config.
nm_dns_blocker() {
local config
config=$($SUDO NetworkManager --print-config) || return 1
printf '%s\n' "$config" | awk -F= '
/^\[/ { main = ($0 == "[main]"); next }
!main || /^#/ { next }
$1 == "dns" && $2 != "default" && $2 != "systemd-resolved" { print; exit }
$1 == "systemd-resolved" && ($2 == "false" || $2 == "no" || $2 == "0" || $2 == "off") { print; exit }'
}
# The URL of the first enabled package repository, which the install has
# reached unless system packages were skipped.
first_repo_url() {
# shellcheck disable=SC2016 # apt's own $(FIELD) template, not a shell expansion
case "$(distro_pkg_manager 2>/dev/null)" in
apt) apt-get indextargets --format '$(REPO_URI)' 2>/dev/null | head -n 1 ;;
dnf) dnf config-manager --dump '*' 2>/dev/null | awk '
function flush() { if (enabled == "1" && url != "") { print url; done = 1; exit } }
/^\[/ { flush(); enabled = ""; url = ""; next }
/^(baseurl|mirrorlist|metalink) = / { v = $3; sub(/,.*/, "", v); if (url == "") url = v }
/^enabled = / { enabled = $3 }
END { if (!done) flush() }' ;;
esac
}
# The host part of a URL. Prints nothing for a file:// or schemeless URL.
url_host() {
local rest="${1#*://}"
case "$1" in file://* | "$rest") return 0 ;; esac
rest="${rest%%/*}"
rest="${rest##*@}"
case "$rest" in
\[*) echo "${rest%%]*}]" ;;
*) echo "${rest%%:*}" ;;
esac
}
dns_handover_failed() {
fatal "Host DNS through systemd-resolved does not work: $1.
To put host DNS back as it was before this installer:
sudo systemctl disable --now systemd-resolved
sudo cp -a --remove-destination $RESOLV_CONF_SAVED $RESOLV_CONF
sudo systemctl restart NetworkManager"
}
# Checks resolved has upstream servers and still resolves the package
# repository host, the one name this host is known to have resolved.
check_resolved_dns() {
local i url host
for i in 1 2 3 4 5 6 7 8 9 10; do
resolvectl dns 2>/dev/null | grep -q ':[[:space:]]*[^[:space:]]' && break
[ "$i" = 10 ] && dns_handover_failed "'resolvectl dns' lists no DNS servers 10 seconds after NetworkManager restarted"
sleep 1
done
url=$(first_repo_url)
host=$(url_host "$url")
case "$host" in
"")
warn "No package repository with a host name to look up (${url:-none found}); not checking that names resolve"
return 0 ;;
\[*) ;;
*[!0-9.]*)
getent ahosts "$host" > /dev/null || dns_handover_failed "the package repository host $host no longer resolves"
return 0 ;;
esac
warn "The package repository host $host is an IP address; not checking that names resolve"
}
# Where systemd-resolved is installed but not running and nothing else manages
# DNS, turns it on behind NetworkManager. The original resolv.conf is saved
# once, so a rerun never replaces it with one this installer wrote.
hand_dns_to_resolved() {
stage "handing host DNS to systemd-resolved"
local blocker
[ "${ISO_BUILD:-0}" = "1" ] && return 0
if systemctl is-active --quiet systemd-resolved.service; then
# A handover made by an earlier run is checked again on every run.
if [ -e "$RESOLV_CONF_SAVED" ] || [ -L "$RESOLV_CONF_SAVED" ]; then
check_resolved_dns
fi
return 0
fi
if ! distro_unit_exists systemd-resolved.service; then
return 0
fi
if real_resolvconf; then
info "resolvconf manages host DNS; leaving it as it is"
return 0
fi
if ! systemctl is-active --quiet NetworkManager.service; then
warn "systemd-resolved is installed but not running, and NetworkManager is not running to hand it DNS. Leaving host DNS as it is: Spinifex names will not resolve on this host until its resolver forwards them to northstar"
return 0
fi
blocker=$(nm_dns_blocker) || fatal "Cannot read NetworkManager's configuration with 'NetworkManager --print-config'"
if [ -n "$blocker" ]; then
fatal "NetworkManager sets $blocker, so it cannot hand DNS to systemd-resolved. Remove that setting ('NetworkManager --print-config' shows the files it reads) and re-run this installer"
fi
info "Handing host DNS to systemd-resolved..."
if [ ! -e "$RESOLV_CONF_SAVED" ] && [ ! -L "$RESOLV_CONF_SAVED" ]; then
$SUDO mkdir -p "$(dirname "$RESOLV_CONF_SAVED")"
$SUDO cp -a "$RESOLV_CONF" "$RESOLV_CONF_SAVED" || fatal "Cannot save $RESOLV_CONF to $RESOLV_CONF_SAVED"
info " saved $RESOLV_CONF to $RESOLV_CONF_SAVED"
fi
$SUDO systemctl enable --now systemd-resolved.service > /dev/null 2>&1 \
|| dns_handover_failed "systemd-resolved did not start"
$SUDO systemctl restart NetworkManager.service || dns_handover_failed "NetworkManager did not restart"
$SUDO ln -sfn "$RESOLVED_STUB" "$RESOLV_CONF"
check_resolved_dns
info " $RESOLV_CONF -> $RESOLVED_STUB"
}
# Debian's stock chrony.conf carries `makestep 1 3`: step the clock only for the
# first three updates, then slew at ~83us/s, which is roughly 7 seconds of
# correction per day. A node that burns those three before the network is usable
# then crawls toward correct time for days, and SigV4 rejects its requests the
# whole time. A drop-in rather than an edit to chrony.conf, which is a dpkg
# conffile and would prompt on every future upgrade.
#
# EL's chrony.conf reads no drop-ins, so a confdir line is appended to it.
# chronyd keeps the last makestep it reads, so drop-ins must come after the
# stock line. DISTRO_ROOT prefixes the paths here as it does for the probes.
install_chrony_conf() {
stage "installing chrony drop-in"
local root="${DISTRO_ROOT:-}" conf dir="" confdir_line=0 makestep_line
if [ ! -d /etc/chrony/conf.d ]; then
warn "No /etc/chrony/conf.d — skipping chrony drop-in"
if ! conf=$(distro_chrony_conf); then
warn "Skipping the chrony drop-in"
return
fi
read -r confdir_line dir < <(awk '$1 == "confdir" { print NR, $2; exit }' "$root$conf") || true
if [ -z "$dir" ]; then
dir=/etc/chrony.d
printf '\n# Added by the Spinifex installer. Last, so a drop-in overrides the settings above.\nconfdir %s\n' "$dir" \
| $SUDO tee -a "$root$conf" > /dev/null
confdir_line=$(wc -l < "$root$conf")
info " $conf: confdir $dir"
fi
makestep_line=$(awk '$1 == "makestep" { n = NR } END { print n + 0 }' "$root$conf")
if [ "$makestep_line" -gt "$confdir_line" ]; then
warn "$conf sets makestep on line $makestep_line, after its confdir line, which overrides the Spinifex drop-in. Move the confdir line to the end of $conf"
fi
$SUDO tee /etc/chrony/conf.d/spinifex.conf > /dev/null << 'CHRONY'
$SUDO mkdir -p "$root$dir"
$SUDO tee "$root$dir/spinifex.conf" > /dev/null << 'CHRONY'
# Managed by scripts/setup.sh — edits are overwritten.
# Step at any offset over a second, however many times it takes. The clock can
# jump backwards under load, which is the accepted cost: everything
@@ -672,8 +1020,8 @@ install_chrony_conf() {
# step fails every signed request until it converges.
makestep 1 -1
CHRONY
$SUDO chmod 0644 /etc/chrony/conf.d/spinifex.conf
info " /etc/chrony/conf.d/spinifex.conf"
$SUDO chmod 0644 "$root$dir/spinifex.conf"
info " $dir/spinifex.conf"
}
install_sudoers() {
@@ -733,7 +1081,7 @@ SUDOERS
info "Scoped sudoers rules installed for spinifex-daemon (spinifex-vpcd needs none)"
}
# --- Install apt dependencies ---
# --- Install system dependencies ---
# NOTE: Runtime deps must stay in sync with the ISO package list at
# scripts/iso-builder/build/packages.list in the mulga repo. When you add,
# remove, or change a runtime package here, review that file too — drift means
@@ -750,29 +1098,43 @@ jq curl iproute2 ethtool netcat-openbsd wget unzip xz-utils file
ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon dhcpcd-base
chrony nftables"
install_apt_deps() {
stage "installing apt dependencies"
# The Oracle Linux equivalents, plus what a minimal OL install lacks. OVS 3.5
# and OVN 25.03 come from the CentOS NFV SIG; 25.03 is Debian 13's OVN, which
# the hand-written OVN NB models are written against.
OL_RUNTIME_PACKAGES="nbdkit nbdkit-basic-filters
qemu-kvm qemu-img edk2-ovmf seabios-bin less
pciutils
jq curl iproute ethtool nmap-ncat wget unzip xz file
ovn25.03-central ovn25.03-host openvswitch3.5 openvswitch3.5-ipsec libreswan dhcpcd
chrony nftables iptables-nft systemd-resolved"
# Oracle ships no OVS or OVN for OL10. The allowlist stops the SIG supplying
# anything but OVS, OVN and the two SIG-only packages they depend on.
NFV_SIG_REPO_ID="spinifex-nfv-sig"
NFV_SIG_KEY_URL="https://www.centos.org/keys/RPM-GPG-KEY-CentOS-SIG-NFV"
NFV_SIG_KEY_FILE="/etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-SIG-NFV"
NFV_SIG_KEY_FPR="35154228174901BEFA8E69A621465E289D2A76A7"
NFV_SIG_INCLUDEPKGS="openvswitch3.5* ovn25.03* python3-openvswitch3.5 openvswitch-selinux-extra-policy"
# Only the KVM Utilities QEMU has the microvm machine. Excluding QEMU from
# AppStream stops a later AppStream build ever replacing part of that stack.
OL_KVM_UTILS_REPO="ol10_kvm_utils"
OL_APPSTREAM_REPO="ol10_appstream"
OL_APPSTREAM_EXCLUDES="qemu-kvm* qemu-img*"
# The running kernel's modules come from here, and KVM Utilities requires kernel-uek.
OL_UEK_REPO="ol10_UEKR8"
install_deps() {
stage "installing system dependencies"
if [ "${INSTALL_SPINIFEX_SKIP_APT}" = "1" ]; then
info "Skipping apt dependencies (INSTALL_SPINIFEX_SKIP_APT=1)"
info "Skipping system dependencies (INSTALL_SPINIFEX_SKIP_APT=1)"
else
info "Installing system dependencies..."
$SUDO apt-get update -qq
# Unquoted on purpose: both variables are whitespace-separated package lists.
# shellcheck disable=SC2086
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq \
$QEMU_PACKAGES $APT_RUNTIME_PACKAGES \
> /dev/null
info "System dependencies installed"
# Only on a machine that already boots from ZFS. Pulling zfsutils-linux
# in unconditionally would drag zfs-dkms onto every dev box and rebuild
# the module on each kernel upgrade for no benefit.
if [ "$(findmnt -no FSTYPE /)" = "zfs" ] && ! command -v zpool >/dev/null 2>&1; then
info "ZFS root detected, installing zfsutils-linux..."
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq zfsutils-linux > /dev/null
fi
local pm
pm=$(distro_pkg_manager) || fatal "Cannot tell which package manager this host uses"
case "$pm" in
apt) install_apt_deps ;;
dnf) install_dnf_deps ;;
esac
fi
# Mask the standalone dhcpcd.service auto-enabled on Debian Trixie when
@@ -786,6 +1148,150 @@ install_apt_deps() {
$SUDO systemctl mask dhcpcd.service 2>/dev/null || true
}
install_apt_deps() {
info "Installing system dependencies..."
$SUDO apt-get update -qq
# Unquoted on purpose: both variables are whitespace-separated package lists.
# shellcheck disable=SC2086
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq \
$QEMU_PACKAGES $APT_RUNTIME_PACKAGES \
> /dev/null
info "System dependencies installed"
# Only on a machine that already boots from ZFS. Pulling zfsutils-linux
# in unconditionally would drag zfs-dkms onto every dev box and rebuild
# the module on each kernel upgrade for no benefit.
if [ "$(findmnt -no FSTYPE /)" = "zfs" ] && ! command -v zpool >/dev/null 2>&1; then
info "ZFS root detected, installing zfsutils-linux..."
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq zfsutils-linux > /dev/null
fi
}
# The package table is the other place keyed on the OS ID: dnf alone does not
# say which repositories or package names a host has.
install_dnf_deps() {
case "${ID:-}" in
ol) ;;
*) fatal "No dnf package list for ${ID:-an unknown OS}. Spinifex installs with dnf on Oracle Linux only" ;;
esac
if [ "$(findmnt -no FSTYPE /)" = "zfs" ]; then
fatal "A ZFS root is not supported on Oracle Linux"
fi
ensure_ol_repos
install_running_kernel_modules
info "Installing system dependencies..."
# shellcheck disable=SC2086 # a whitespace-separated package list
$SUDO dnf install -y -q $OL_RUNTIME_PACKAGES > /dev/null
info "System dependencies installed"
# shellcheck disable=SC2086 # a whitespace-separated module list
load_kernel_modules $SPINIFEX_KERNEL_MODULES
}
# Enables KVM Utilities, pins QEMU to it, and adds the NFV SIG repository with
# a key whose fingerprint must match the one recorded above.
ensure_ol_repos() {
info "Configuring Oracle Linux repositories..."
local repo
$SUDO dnf install -y -q dnf-plugins-core gnupg2 curl > /dev/null
for repo in "$OL_UEK_REPO" "$OL_KVM_UTILS_REPO" "$OL_APPSTREAM_REPO"; do
dnf config-manager --dump "$repo" > /dev/null 2>&1 \
|| fatal "Repository $repo is not defined on this host. Oracle Linux's release packages define it; reinstall oraclelinux-release-el10"
done
$SUDO dnf config-manager --set-enabled "$OL_UEK_REPO" "$OL_KVM_UTILS_REPO" > /dev/null
exclude_from_appstream
install_nfv_sig_key
$SUDO tee "/etc/yum.repos.d/${NFV_SIG_REPO_ID}.repo" > /dev/null <<EOF
# Written by the Spinifex installer. Re-running it rewrites this file.
[${NFV_SIG_REPO_ID}]
name=CentOS NFV SIG Open vSwitch and OVN, for Spinifex
baseurl=https://mirror.stream.centos.org/SIGs/10-stream/nfv/\$basearch/openvswitch-2/
enabled=1
gpgcheck=1
gpgkey=file://${NFV_SIG_KEY_FILE}
includepkgs=${NFV_SIG_INCLUDEPKGS}
EOF
for repo in "$OL_UEK_REPO" "$OL_APPSTREAM_REPO" "$OL_KVM_UTILS_REPO" "$NFV_SIG_REPO_ID"; do
$SUDO dnf -q makecache --repo "$repo" > /dev/null \
|| fatal "Repository $repo is unreachable. Check this host's network access and proxy settings, then re-run this installer"
done
}
# Adds the QEMU excludes to AppStream, keeping any exclude already set there.
exclude_from_appstream() {
local current pattern merged="" patterns
current=$(dnf config-manager --dump "$OL_APPSTREAM_REPO" | sed -n 's/^excludepkgs = //p' | tr ',' ' ')
# read, not an unquoted expansion, so the patterns are never globbed.
read -ra patterns <<< "$current $OL_APPSTREAM_EXCLUDES"
for pattern in "${patterns[@]}"; do
case " $merged " in
*" $pattern "*) ;;
*) merged="${merged:+$merged }$pattern" ;;
esac
done
$SUDO dnf config-manager --save --setopt="${OL_APPSTREAM_REPO}.excludepkgs=${merged// /,}" > /dev/null
}
# gpg_fingerprints <file> — the primary-key fingerprint of each key in file.
gpg_fingerprints() {
local home
home=$(mktemp -d)
GNUPGHOME="$home" gpg --batch --with-colons --show-keys "$1" 2>/dev/null \
| awk -F: '$1 == "pub" { want = 1; next } want && $1 == "fpr" { print $10; want = 0 }'
rm -rf "$home"
}
install_nfv_sig_key() {
if [ -f "$NFV_SIG_KEY_FILE" ] && [ "$(gpg_fingerprints "$NFV_SIG_KEY_FILE")" = "$NFV_SIG_KEY_FPR" ]; then
$SUDO rpm --import "$NFV_SIG_KEY_FILE"
return 0
fi
local tmp got
tmp=$(mktemp)
curl -fsSL "$NFV_SIG_KEY_URL" -o "$tmp" \
|| { rm -f "$tmp"; fatal "Cannot download the CentOS NFV SIG key from $NFV_SIG_KEY_URL"; }
got=$(gpg_fingerprints "$tmp")
if [ "$got" != "$NFV_SIG_KEY_FPR" ]; then
rm -f "$tmp"
fatal "The key at $NFV_SIG_KEY_URL has fingerprint ${got:-<unreadable>}, not the CentOS NFV SIG's $NFV_SIG_KEY_FPR. Refusing to trust it"
fi
$SUDO install -D -m 0644 "$tmp" "$NFV_SIG_KEY_FILE"
rm -f "$tmp"
$SUDO rpm --import "$NFV_SIG_KEY_FILE"
}
# Oracle ships kernel modules per kernel version. The unversioned package
# follows the newest kernel and leaves the running one without openvswitch.ko.
install_running_kernel_modules() {
local pkg
pkg="kernel-uek-modules-$(uname -r)"
if rpm -q "$pkg" > /dev/null 2>&1; then
return 0
fi
if ! $SUDO dnf -q list --available "$pkg" > /dev/null 2>&1; then
fatal "$pkg, the modules for the running kernel, is no longer in the repositories. Run 'sudo dnf update kernel-uek', reboot into the new kernel, then re-run this installer"
fi
info "Installing $pkg (it pulls in linux-firmware, about 800 MB)..."
$SUDO dnf install -y -q "$pkg" > /dev/null
}
load_kernel_modules() {
local mod failed=""
for mod in "$@"; do
$SUDO modprobe "$mod" || failed="${failed:+$failed }$mod"
done
if [ -n "$failed" ]; then
fatal "Cannot load kernel modules for the running kernel $(uname -r): $failed"
fi
}
# --- Install AWS CLI ---
install_aws_cli() {
stage "installing AWS CLI v2"
@@ -794,8 +1300,12 @@ install_aws_cli() {
return
fi
if command -v aws >/dev/null 2>&1; then
info "AWS CLI already installed: $(aws --version 2>&1 | head -1)"
# The bundled installer links /usr/local/bin/aws, which OL's sudo secure_path
# leaves out, so a PATH lookup alone misses it and reinstalls on every run.
local aws_bin
aws_bin=$(command -v aws 2>/dev/null || echo /usr/local/bin/aws)
if [ -x "$aws_bin" ]; then
info "AWS CLI already installed: $("$aws_bin" --version 2>&1 | head -1)"
return
fi
@@ -806,7 +1316,7 @@ install_aws_cli() {
$SUDO "$AWS_TMPDIR/aws/install" --update > /dev/null
rm -rf "$AWS_TMPDIR"
info "AWS CLI installed: $(aws --version 2>&1 | head -1)"
info "AWS CLI installed: $(/usr/local/bin/aws --version 2>&1 | head -1)"
}
# --- Download tarball ---
@@ -861,6 +1371,34 @@ download_spinifex() {
EXTRACT_DIR="$SPINIFEX_TMPDIR"
}
# OL's sudo secure_path leaves out /usr/local/bin, so `sudo spx` would find
# nothing there. A link in /usr/bin changes sudo for nothing else on the host.
link_spx_for_sudo() {
local sudo_path
[ "${ISO_BUILD:-0}" = "1" ] && return 0
command -v sudo > /dev/null 2>&1 || return 0
sudo_path=$(sudo -n /usr/bin/env 2> /dev/null | sed -n 's/^PATH=//p') || true
if [ -z "$sudo_path" ]; then
warn "Cannot read the PATH sudo gives commands. If 'sudo spx' is not found, run: sudo ln -s /usr/local/bin/spx /usr/bin/spx"
return 0
fi
case ":$sudo_path:" in *:/usr/local/bin:*) return 0 ;; esac
if [ -e /usr/bin/spx ] || [ -L /usr/bin/spx ]; then
[ "$(readlink /usr/bin/spx)" = /usr/local/bin/spx ] && return 0
fatal "/usr/bin/spx exists and is not a link to /usr/local/bin/spx. sudo's PATH ($sudo_path) leaves out /usr/local/bin, so 'sudo spx' would run that file. Remove it and re-run this installer"
fi
$SUDO ln -s /usr/local/bin/spx /usr/bin/spx
info " /usr/bin/spx -> /usr/local/bin/spx (sudo's PATH leaves out /usr/local/bin)"
}
# Sets NBDKIT_PLUGINDIR from nbdkit itself; no guessed path is safe, since the
# daemon loads the plugin from wherever systemd.env says it is.
resolve_nbdkit_plugindir() {
NBDKIT_PLUGINDIR=$(nbdkit --dump-config 2> /dev/null | sed -n 's/^plugindir=//p') || true
[ -n "$NBDKIT_PLUGINDIR" ] \
|| fatal "Cannot find nbdkit's plugin directory: 'nbdkit --dump-config' printed no plugindir. Check that nbdkit is installed and runs, then re-run this installer"
}
# --- Place files ---
install_files() {
stage "installing binaries and scripts"
@@ -870,19 +1408,13 @@ install_files() {
$SUDO install -m 0755 "$EXTRACT_DIR/spx" /usr/local/bin/spx
info " /usr/local/bin/spx"
link_spx_for_sudo
# nbdkit plugin
PLUGINDIR=$(nbdkit --dump-config 2>/dev/null | grep ^plugindir= | cut -d= -f2)
if [ -z "$PLUGINDIR" ]; then
warn "Could not detect nbdkit plugin directory, using default"
if [ "$ARCH" = "arm64" ]; then
PLUGINDIR="/usr/lib/aarch64-linux-gnu/nbdkit/plugins"
else
PLUGINDIR="/usr/lib/x86_64-linux-gnu/nbdkit/plugins"
fi
fi
$SUDO mkdir -p "$PLUGINDIR"
$SUDO install -m 0755 "$EXTRACT_DIR/nbdkit-viperblock-plugin.so" "$PLUGINDIR/nbdkit-viperblock-plugin.so"
info " $PLUGINDIR/nbdkit-viperblock-plugin.so"
resolve_nbdkit_plugindir
$SUDO mkdir -p "$NBDKIT_PLUGINDIR"
$SUDO install -m 0755 "$EXTRACT_DIR/nbdkit-viperblock-plugin.so" "$NBDKIT_PLUGINDIR/nbdkit-viperblock-plugin.so"
info " $NBDKIT_PLUGINDIR/nbdkit-viperblock-plugin.so"
# Setup scripts
$SUDO mkdir -p /usr/local/share/spinifex
@@ -891,6 +1423,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
@@ -1062,23 +1601,13 @@ TMPEOF
install_systemd_env() {
stage "generating /etc/spinifex/systemd.env"
# nbdkit plugin path is arch-dependent (`nbdkit --dump-config` returns
# /usr/lib/{x86_64,aarch64}-linux-gnu/nbdkit/plugins). Resolve it here so
# systemd.env always matches wherever install_files placed the .so.
local plugindir
plugindir=$(nbdkit --dump-config 2>/dev/null | grep ^plugindir= | cut -d= -f2)
if [ -z "$plugindir" ]; then
if [ "${ARCH:-}" = "arm64" ]; then
plugindir="/usr/lib/aarch64-linux-gnu/nbdkit/plugins"
else
plugindir="/usr/lib/x86_64-linux-gnu/nbdkit/plugins"
fi
fi
# The same directory install_files placed the plugin in.
resolve_nbdkit_plugindir
$SUDO mkdir -p /etc/spinifex
$SUDO tee /etc/spinifex/systemd.env > /dev/null << EOF
# Generated by setup.sh — install-specific environment variables
SPINIFEX_VIPERBLOCK_PLUGIN_PATH=${plugindir}/nbdkit-viperblock-plugin.so
SPINIFEX_VIPERBLOCK_PLUGIN_PATH=${NBDKIT_PLUGINDIR}/nbdkit-viperblock-plugin.so
EOF
$SUDO chown "spinifex-viperblock:${SPINIFEX_GROUP:-spinifex}" /etc/spinifex/systemd.env
$SUDO chmod 0640 /etc/spinifex/systemd.env
@@ -1242,6 +1771,23 @@ EOF
info "Systemd units installed and enabled (per-service users)"
}
# --- Install kernel module list ---
# The file covers later boots; modprobe covers this one.
install_kernel_modules() {
stage "installing kernel module list"
$SUDO install -d /etc/modules-load.d
# shellcheck disable=SC2086 # one module per line
printf '# Managed by spinifex setup.sh — do not edit.\n%s\n' $SPINIFEX_KERNEL_MODULES \
| $SUDO tee /etc/modules-load.d/spinifex.conf > /dev/null
$SUDO chmod 0644 /etc/modules-load.d/spinifex.conf
# A chroot would load them into the build host's kernel.
if [ "${ISO_BUILD:-0}" != "1" ]; then
# shellcheck disable=SC2086 # a whitespace-separated module list
load_kernel_modules $SPINIFEX_KERNEL_MODULES
fi
info "Kernel modules: $SPINIFEX_KERNEL_MODULES (/etc/modules-load.d/spinifex.conf)"
}
# --- Install kernel tunables ---
# Predastore carries blob, meta and raft over QUIC, and quic-go asks for a 7 MiB
# UDP receive buffer. Left at the kernel default of 208 KiB it silently drops
@@ -1418,22 +1964,12 @@ main() {
*) fatal "--channel must be 'latest' or 'dev', got: ${INSTALL_SPINIFEX_CHANNEL} (use --version for a specific tag)" ;;
esac
case "${INSTALL_SPINIFEX_FIREWALL}" in
on|off) ;;
# Unset resolves by install path: the ISO arms, everything else does not.
"") if [ "${ISO_BUILD:-0}" = "1" ]; then
INSTALL_SPINIFEX_FIREWALL="on"
else
INSTALL_SPINIFEX_FIREWALL="off"
fi ;;
*) fatal "--firewall must be 'on' or 'off', got: ${INSTALL_SPINIFEX_FIREWALL}" ;;
esac
info "Spinifex installer"
echo ""
# Always needed: sudo resolution, OS/arch detection.
setup_sudo
resolve_firewall_mode
detect_os
detect_arch
@@ -1451,8 +1987,9 @@ main() {
download_spinifex
fi
stage_enabled deps && install_apt_deps
stage_enabled deps && install_deps
stage_enabled aws && install_aws_cli
stage_enabled dns && hand_dns_to_resolved
stage_enabled users && create_service_users
stage_enabled sudoers && install_sudoers
stage_enabled firewall && install_firewall
@@ -1463,6 +2000,7 @@ main() {
stage_enabled fixown && fix_file_ownership
stage_enabled systemd && install_systemd
stage_enabled sysctl && install_sysctl
stage_enabled modules && install_kernel_modules
stage_enabled logrotate && install_logrotate
stage_enabled udev && install_udev
stage_enabled swap && setup_swap
+206
View File
@@ -0,0 +1,206 @@
#!/bin/sh
# setup_firewall_test.sh — tests for how setup.sh picks the firewall mode and
# masks firewalld.
#
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs. A stub
# systemctl keeps firewalld's state in fixture files, and the mode file is a
# fixture path.
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 firewall mode and firewalld"
mkdir -p "$TMP/bin"
cat > "$TMP/bin/systemctl" <<EOF
#!/bin/sh
state="$TMP/state"
[ "\$1" = "--quiet" ] && shift
case "\$1 \$2" in
"is-active --quiet") [ -e "\$state/active" ] ;;
"is-enabled firewalld.service") [ -e "\$state/enabled" ] || { echo not-found; exit 4; }; cat "\$state/enabled" ;;
"mask --now")
echo "mask" >> "\$state/calls"
[ -e "\$state/fail-mask" ] && exit 1
echo masked > "\$state/enabled"; rm -f "\$state/active" ;;
*) echo "unexpected systemctl \$*" >&2; exit 2 ;;
esac
EOF
chmod +x "$TMP/bin/systemctl"
# host <firewalld: none|inactive|active|masked> [recorded mode] — sets up the
# fixture host. The mode file is absent when no mode is given.
host() {
rm -rf "$TMP/state" "$TMP/mode"
mkdir -p "$TMP/state"
case "$1" in
inactive) echo disabled > "$TMP/state/enabled" ;;
active) echo enabled > "$TMP/state/enabled"; : > "$TMP/state/active" ;;
masked) echo masked > "$TMP/state/enabled" ;;
esac
if [ -n "${2:-}" ]; then printf '%s\n' "$2" > "$TMP/mode"; fi
}
# run <function...> [--firewall value via FW] — sets RC and OUT, and MODE to
# the mode setup.sh resolved.
run() {
RC=0
OUT=$(PATH="$TMP/bin:$PATH" SPINIFEX_FIREWALL_MODE_FILE="$TMP/mode" INSTALL_SPINIFEX_FIREWALL="${FW:-}" \
INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. "$1"; shift; for f in "$@"; do "$f"; done; echo "MODE=$INSTALL_SPINIFEX_FIREWALL"' \
_ "$SETUP" "$@" 2>&1) || RC=$?
MODE=$(printf '%s\n' "$OUT" | sed -n 's/^MODE=//p')
unset FW
}
resolves() { # resolves <desc> <mode>
if [ "$RC" = 0 ] && [ "$MODE" = "$2" ]; then pass "$1 resolves to $2"; else fail "$1: rc=$RC mode='$MODE': $OUT"; fi
}
host none
run resolve_firewall_mode
resolves "no firewalld, no recorded mode" off
host inactive
run resolve_firewall_mode
resolves "firewalld installed but inactive" off
host active
run resolve_firewall_mode
resolves "firewalld active" on
host masked on
run resolve_firewall_mode
resolves "a rerun after an install with --firewall=on" on
host active on
run resolve_firewall_mode
resolves "a retry after the mode file was written but before firewalld was masked" on
host active off
run resolve_firewall_mode
resolves "a recorded 'off' with firewalld active" off
host masked on
FW=off run resolve_firewall_mode
resolves "an explicit --firewall=off over a recorded 'on'" off
host none on
FW=off run resolve_firewall_mode
resolves "an explicit --firewall=off" off
host none
FW=maybe run resolve_firewall_mode
case "$RC:$OUT" in
0:*) fail "accepts --firewall=maybe" ;;
*"--firewall must be"*) pass "rejects --firewall=maybe" ;;
*) fail "rejects --firewall=maybe without saying why: $OUT" ;;
esac
host none garbage
run resolve_firewall_mode
case "$RC:$OUT" in
0:*) fail "accepts a mode file holding 'garbage'" ;;
*"holds 'garbage'"*) pass "rejects a mode file holding 'garbage', naming it" ;;
*) fail "rejects a bad mode file without naming its content: $OUT" ;;
esac
# The record sits under /etc/spinifex, which only root and the spinifex group
# can enter, so an installer run by any other sudoer must read it through sudo.
cat > "$TMP/bin/sudo" <<EOF
#!/bin/sh
echo "\$*" >> "$TMP/sudo-calls"
exec "\$@"
EOF
chmod +x "$TMP/bin/sudo"
host masked on
rm -f "$TMP/sudo-calls"
RC=0
OUT=$(PATH="$TMP/bin:$PATH" SUDO=sudo SPINIFEX_FIREWALL_MODE_FILE="$TMP/mode" INSTALL_SPINIFEX_FIREWALL="" \
INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. "$1"; resolve_firewall_mode; echo "MODE=$INSTALL_SPINIFEX_FIREWALL"' \
_ "$SETUP" 2>&1) || RC=$?
MODE=$(printf '%s\n' "$OUT" | sed -n 's/^MODE=//p')
if [ "$RC" = 0 ] && [ "$MODE" = on ] && grep -qx "cat $TMP/mode" "$TMP/sudo-calls" 2>/dev/null; then
pass "a non-root rerun reads the recorded mode through sudo"
else
fail "a non-root rerun did not read the recorded mode through sudo: rc=$RC mode='$MODE' calls='$(cat "$TMP/sudo-calls" 2>/dev/null)': $OUT"
fi
# A record that exists but cannot be read must stop the install, never fall
# through to the firewalld check, which reads a masked firewalld as "off".
if [ "$(id -u)" != 0 ]; then
host masked on
chmod 000 "$TMP/mode"
run resolve_firewall_mode
chmod 644 "$TMP/mode"
case "$RC:$OUT" in
0:*) fail "an unreadable mode file resolved to '$MODE'" ;;
*"Cannot read the recorded firewall mode"*) pass "an unreadable mode file stops the install" ;;
*) fail "an unreadable mode file stopped without saying why: $OUT" ;;
esac
fi
masks() { # masks <desc> <expect: masked|untouched>
calls=$(cat "$TMP/state/calls" 2>/dev/null || true)
if [ "$RC" != 0 ]; then
fail "$1: rc=$RC: $OUT"
elif [ "$2" = masked ] && [ -n "$calls" ] && [ "$(cat "$TMP/state/enabled")" = masked ] && [ ! -e "$TMP/state/active" ]; then
pass "$1: masked and stopped"
elif [ "$2" = untouched ] && [ -z "$calls" ]; then
pass "$1: left alone"
else
fail "$1: expected $2, calls='$calls': $OUT"
fi
}
host active
run resolve_firewall_mode mask_firewalld
masks "firewalld active" masked
case "$OUT" in
*"Restate any other restriction"*) pass "masking firewalld says its rules were not migrated" ;;
*) fail "masking firewalld printed no notice: $OUT" ;;
esac
host inactive
run mask_firewalld
masks "firewalld installed but inactive" masked
host none
run mask_firewalld
masks "no firewalld" untouched
host masked
run mask_firewalld
masks "firewalld already masked" untouched
host active
echo masked > "$TMP/state/enabled"
run mask_firewalld
masks "firewalld masked but still running" masked
host active
: > "$TMP/state/fail-mask"
run mask_firewalld
case "$RC:$OUT" in
0:*) fail "succeeds when firewalld cannot be masked" ;;
*"Cannot stop and mask firewalld"*) pass "fails by name when firewalld cannot be masked" ;;
*) fail "fails without naming firewalld: $OUT" ;;
esac
host active
FW=off run mask_firewalld
case "$OUT" in
*"this host now has no firewall"*) pass "masking firewalld under --firewall=off says the host has no firewall" ;;
*) fail "masking under --firewall=off did not say the host has no firewall: $OUT" ;;
esac
if [ "$fails" -gt 0 ]; then
echo "$fails failure(s)"
exit 1
fi
+272
View File
@@ -0,0 +1,272 @@
#!/bin/sh
# setup_host_test.sh — tests for setup.sh's DNS handover to systemd-resolved,
# its chrony drop-in and its nbdkit plugin directory.
#
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs. PATH
# holds stub commands and links to a few real tools only, so no host resolver,
# package manager or service is ever touched.
# shellcheck disable=SC2016 # stub bodies are single-quoted so they expand when the stub runs
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 host DNS, chrony and nbdkit"
mkdir -p "$TMP/sys"
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod; do
ln -s "$(command -v "$tool")" "$TMP/sys/$tool"
done
# stub <name> <body> — a stub command; $S is the fixture's state directory.
stub() {
printf '#!/bin/sh\nS="%s"\n%s\n' "$TMP/state" "$2" > "$TMP/bin/$1"
chmod +x "$TMP/bin/$1"
}
# host — a fresh fixture: resolved installed and inactive, resolvconf the
# resolvectl link, NetworkManager running with no DNS settings, a dnf host
# whose first enabled repository is yum.oracle.com.
host() {
rm -rf "${TMP:?}/state" "${TMP:?}/root" "${TMP:?}/bin"
mkdir -p "$TMP/state/active" "$TMP/root/etc" "$TMP/root/usr/lib/systemd/system" "$TMP/bin"
: > "$TMP/root/usr/lib/systemd/system/systemd-resolved.service"
: > "$TMP/state/active/NetworkManager.service"
printf 'nameserver 192.168.1.1\nsearch example.lan\n' > "$TMP/root/etc/resolv.conf"
printf '[main]\n# dns=default\nconfigure-and-quit=no\n' > "$TMP/state/nm-config"
printf 'Global:\nLink 2 (br-wan): 192.168.1.1\n' > "$TMP/state/resolvectl-dns"
printf '[ol10_UEKR8]\nbaseurl = https://mirror.example/uek/\nenabled = 0\n[ol10_baseos_latest]\nbaseurl = https://yum.oracle.com/repo/OracleLinux/OL10/baseos/latest/x86_64/\nenabled = 1\n' > "$TMP/state/dnf-dump"
stub systemctl '[ "$1" = is-active ] && { [ -e "$S/active/$3" ]; exit; }
echo "$*" >> "$S/calls"
[ "$1 $2" = "enable --now" ] && : > "$S/active/$3"
exit 0'
stub NetworkManager 'cat "$S/nm-config"'
stub resolvectl '[ "$1" = dns ] && cat "$S/resolvectl-dns"'
stub resolvectl-real ':'
ln -s resolvectl "$TMP/bin/resolvconf"
stub getent '[ ! -e "$S/getent-fail" ] && echo "$2" >> "$S/looked-up"'
stub dnf '[ "$1 $2" = "config-manager --dump" ] && cat "$S/dnf-dump"'
stub sleep ':'
}
# run <function...> — runs setup.sh functions against the fixture. Sets RC and OUT.
run() {
RC=0
OUT=$(PATH="$TMP/bin:$TMP/sys" DISTRO_ROOT="$TMP/root" \
SPINIFEX_RESOLV_CONF="$TMP/root/etc/resolv.conf" SPINIFEX_RESOLV_CONF_SAVED="$TMP/root/saved/resolv.conf.orig" \
INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. "$1"; shift; for f in "$@"; do $f; done' _ "$SETUP" "$@" 2>&1) || RC=$?
}
calls() { cat "$TMP/state/calls" 2>/dev/null || true; }
saved() { cat "$TMP/root/saved/resolv.conf.orig" 2>/dev/null || echo "<none>"; }
expect_untouched() {
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && [ ! -L "$TMP/root/etc/resolv.conf" ] && [ "$(saved)" = "<none>" ]; then
pass "$1"
else
fail "$1: rc=$RC calls='$(calls)' saved='$(saved)': $OUT"
fi
}
expect_fatal() { # expect_fatal <desc> <text>...
desc=$1; shift
if [ "$RC" = 0 ]; then fail "$desc (succeeded): $OUT"; return; fi
for want in "$@"; do
case "$OUT" in *"$want"*) ;; *) fail "$desc (output lacks '$want'): $OUT"; return ;; esac
done
pass "$desc"
}
# --------------------------------------------------------------------------
# DNS handover.
# --------------------------------------------------------------------------
host
run hand_dns_to_resolved
if [ "$RC" = 0 ] && [ "$(readlink "$TMP/root/etc/resolv.conf")" = /run/systemd/resolve/stub-resolv.conf ] \
&& [ "$(saved)" = "$(printf 'nameserver 192.168.1.1\nsearch example.lan')" ] \
&& [ "$(calls)" = "$(printf 'enable --now systemd-resolved.service\nrestart NetworkManager.service')" ] \
&& [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
pass "hands DNS to resolved: saves resolv.conf, starts resolved, restarts NM, links the stub, looks up the repo host"
else
fail "handover: rc=$RC calls='$(calls)' saved='$(saved)' link='$(readlink "$TMP/root/etc/resolv.conf")': $OUT"
fi
: > "$TMP/state/calls"
rm -f "$TMP/state/looked-up"
run hand_dns_to_resolved
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
pass "a rerun after a handover checks DNS again and changes nothing"
else
fail "rerun after handover: rc=$RC calls='$(calls)': $OUT"
fi
host
mkdir -p "$TMP/root/saved"
echo "the original" > "$TMP/root/saved/resolv.conf.orig"
run hand_dns_to_resolved
if [ "$RC" = 0 ] && [ "$(saved)" = "the original" ]; then
pass "a rerun never overwrites the saved resolv.conf"
else
fail "saved copy overwritten: rc=$RC saved='$(saved)': $OUT"
fi
host
rm "$TMP/root/usr/lib/systemd/system/systemd-resolved.service"
run hand_dns_to_resolved
expect_untouched "resolved not installed: DNS is left alone"
host
: > "$TMP/state/active/systemd-resolved.service"
run hand_dns_to_resolved
expect_untouched "resolved already active, with no earlier handover: DNS is left alone"
host
rm "$TMP/bin/resolvconf"
stub resolvconf ':'
run hand_dns_to_resolved
expect_untouched "a real resolvconf: DNS is left alone"
host
rm "$TMP/state/active/NetworkManager.service"
run hand_dns_to_resolved
case "$OUT" in
*"NetworkManager is not running"*) expect_untouched "NetworkManager not running: DNS is left alone, with a warning" ;;
*) fail "NetworkManager not running: no warning: $OUT" ;;
esac
for setting in dns=none dns=dnsmasq systemd-resolved=false; do
host
printf '[main]\n# dns=default\n%s\n[logging]\ndns=none\n' "$setting" > "$TMP/state/nm-config"
run hand_dns_to_resolved
expect_fatal "NetworkManager $setting stops the install, naming it" "NetworkManager sets $setting"
[ -z "$(calls)" ] || fail "NetworkManager $setting: acted before refusing: $(calls)"
done
host
printf '[main]\ndns=systemd-resolved\n[logging]\ndns=none\n' > "$TMP/state/nm-config"
run hand_dns_to_resolved
[ "$RC" = 0 ] && pass "dns=systemd-resolved, and dns= outside [main], are accepted" || fail "dns=systemd-resolved refused: $OUT"
host
printf 'Global:\nLink 2 (br-wan):\n' > "$TMP/state/resolvectl-dns"
run hand_dns_to_resolved
expect_fatal "no upstream servers stops the install, naming the check and printing the restore" \
"'resolvectl dns' lists no DNS servers" "systemctl disable --now systemd-resolved" "cp -a --remove-destination $TMP/root/saved/resolv.conf.orig"
host
: > "$TMP/state/getent-fail"
run hand_dns_to_resolved
expect_fatal "a repository host that no longer resolves stops the install, naming it" "yum.oracle.com no longer resolves" "systemctl restart NetworkManager"
host
printf '[local]\nbaseurl = file:///srv/repo/\nenabled = 1\n' > "$TMP/state/dnf-dump"
run hand_dns_to_resolved
case "$RC:$OUT" in
0:*"not checking that names resolve"*) pass "a file:// repository skips the name check with a warning" ;;
*) fail "file:// repository: rc=$RC: $OUT" ;;
esac
host
printf '[local]\nbaseurl = http://10.0.0.5/repo/\nenabled = 1\n' > "$TMP/state/dnf-dump"
run hand_dns_to_resolved
case "$RC:$OUT" in
0:*"10.0.0.5 is an IP address"*) pass "a repository at an IP address skips the name check with a warning" ;;
*) fail "IP repository: rc=$RC: $OUT" ;;
esac
# --------------------------------------------------------------------------
# Repository URLs and their hosts.
# --------------------------------------------------------------------------
host
printf '[a]\nbaseurl = \nenabled = 1\nmirrorlist = https://mirrors.example/list\n[b]\nbaseurl = https://b.example/\nenabled = 1\n' > "$TMP/state/dnf-dump"
run first_repo_url
[ "$OUT" = https://mirrors.example/list ] && pass "dnf: a mirrorlist stands in for an empty baseurl" || fail "dnf mirrorlist: '$OUT'"
printf '[a]\nbaseurl = https://a1.example/, https://a2.example/\nenabled = 1\n' > "$TMP/state/dnf-dump"
run first_repo_url
[ "$OUT" = https://a1.example/ ] && pass "dnf: the first of several baseurls" || fail "dnf baseurl list: '$OUT'"
host
rm "$TMP/bin/dnf"
stub apt-get '[ "$1" = indextargets ] && printf "http://deb.debian.org/debian/\nhttp://deb.debian.org/debian-security/\n"'
run first_repo_url
[ "$OUT" = http://deb.debian.org/debian/ ] && pass "apt: the first repository URI" || fail "apt: '$OUT'"
for case in "https://yum.oracle.com/repo/=yum.oracle.com" "http://user@mirror.lan:8080/x=mirror.lan" \
"http://[2001:db8::1]:80/x=[2001:db8::1]" "file:///srv/repo=" "cdrom:[Debian]/=" "=" ; do
url=${case%=*}
want=${case##*=}
run "url_host $url"
[ "$OUT" = "$want" ] && pass "url_host '$url' is '$want'" || fail "url_host '$url' = '$OUT', want '$want'"
done
# --------------------------------------------------------------------------
# chrony drop-in.
# --------------------------------------------------------------------------
host
mkdir -p "$TMP/root/etc/chrony"
printf 'pool 2.debian.pool.ntp.org iburst\nmakestep 1 3\nconfdir /etc/chrony/conf.d\n' > "$TMP/root/etc/chrony/chrony.conf"
before=$(cat "$TMP/root/etc/chrony/chrony.conf")
run install_chrony_conf
if [ "$RC" = 0 ] && grep -qx 'makestep 1 -1' "$TMP/root/etc/chrony/conf.d/spinifex.conf" 2>/dev/null \
&& [ "$(cat "$TMP/root/etc/chrony/chrony.conf")" = "$before" ]; then
case "$OUT" in
*WARN*) fail "Debian chrony layout warned: $OUT" ;;
*) pass "Debian: the drop-in goes in the confdir, chrony.conf is not edited" ;;
esac
else
fail "Debian chrony: rc=$RC: $OUT"
fi
host
printf 'pool 2.pool.ntp.org iburst\nmakestep 1.0 3\nrtcsync' > "$TMP/root/etc/chrony.conf"
run install_chrony_conf
run install_chrony_conf
if [ "$RC" = 0 ] && grep -qx 'makestep 1 -1' "$TMP/root/etc/chrony.d/spinifex.conf" 2>/dev/null \
&& [ "$(grep -c '^confdir' "$TMP/root/etc/chrony.conf")" = 1 ] \
&& [ "$(tail -n 1 "$TMP/root/etc/chrony.conf")" = "confdir /etc/chrony.d" ] \
&& grep -qx rtcsync "$TMP/root/etc/chrony.conf"; then
pass "EL: one confdir line is appended after the stock makestep, and the drop-in goes there"
else
fail "EL chrony: rc=$RC: $OUT: $(cat "$TMP/root/etc/chrony.conf")"
fi
host
printf 'confdir /etc/chrony.d\nmakestep 1.0 3\n' > "$TMP/root/etc/chrony.conf"
run install_chrony_conf
case "$RC:$OUT" in
0:*"makestep on line 2, after its confdir line"*) pass "a makestep after the confdir line warns that it overrides the drop-in" ;;
*) fail "late makestep: rc=$RC: $OUT" ;;
esac
host
run install_chrony_conf
case "$RC:$OUT" in
0:*"Skipping the chrony drop-in"*) pass "no chrony config: the drop-in is skipped with a warning" ;;
*) fail "no chrony: rc=$RC: $OUT" ;;
esac
# --------------------------------------------------------------------------
# nbdkit plugin directory.
# --------------------------------------------------------------------------
host
stub nbdkit '[ "$1" = --dump-config ] && printf "binary=/usr/sbin/nbdkit\nplugindir=/usr/lib64/nbdkit/plugins\n"'
OUT=$(PATH="$TMP/bin:$TMP/sys" INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. "$1"; resolve_nbdkit_plugindir; echo "DIR=$NBDKIT_PLUGINDIR"' _ "$SETUP" 2>&1) && RC=0 || RC=$?
[ "$OUT" = DIR=/usr/lib64/nbdkit/plugins ] && pass "the plugin directory comes from nbdkit --dump-config" || fail "nbdkit plugindir: rc=$RC: $OUT"
host
stub nbdkit 'exit 1'
run resolve_nbdkit_plugindir
expect_fatal "a broken nbdkit stops the install, naming it" "nbdkit --dump-config"
host
run resolve_nbdkit_plugindir
expect_fatal "no nbdkit stops the install, naming it" "nbdkit --dump-config"
if [ "$fails" -gt 0 ]; then
echo "$fails failure(s)"
exit 1
fi
+150
View File
@@ -0,0 +1,150 @@
#!/bin/sh
# setup_repos_test.sh — tests for the Oracle Linux repository trust setup in
# setup.sh: the NFV SIG key fingerprint check and the AppStream QEMU exclude.
#
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs. Stub
# gpg, curl, rpm and dnf read and log fixture files, and the key path is a
# fixture path.
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 NFV SIG key and AppStream exclude"
GOOD=35154228174901BEFA8E69A621465E289D2A76A7
BAD=0000000000000000000000000000000000000BAD
mkdir -p "$TMP/bin"
# A fixture key file holds the --with-colons listing gpg would print for it.
cat > "$TMP/bin/gpg" <<'EOF'
#!/bin/sh
for last; do :; done
cat "$last"
EOF
cat > "$TMP/bin/curl" <<EOF
#!/bin/sh
echo "curl" >> "$TMP/calls"
while [ \$# -gt 0 ]; do
case "\$1" in -o) cp "$TMP/served" "\$2"; exit 0 ;; esac
shift
done
exit 1
EOF
cat > "$TMP/bin/rpm" <<EOF
#!/bin/sh
echo "rpm \$*" >> "$TMP/calls"
EOF
cat > "$TMP/bin/dnf" <<EOF
#!/bin/sh
case "\$*" in
"config-manager --dump "*) cat "$TMP/appstream" ;;
"config-manager --save "*) echo "\$3" >> "$TMP/calls" ;;
*) echo "unexpected dnf \$*" >&2; exit 2 ;;
esac
EOF
chmod +x "$TMP/bin/"*
key() { # key <file> <pub-fpr> [sub-fpr] [second-pub-fpr]
{
printf 'pub:-:4096:1:AAAA:1:::-:::scESC::::::23::0:\nfpr:::::::::%s:\n' "$2"
if [ -n "${3:-}" ]; then printf 'sub:-:4096:1:BBBB:1::::::e::::::23:\nfpr:::::::::%s:\n' "$3"; fi
if [ -n "${4:-}" ]; then printf 'pub:-:4096:1:CCCC:1:::-:::scESC::::::23::0:\nfpr:::::::::%s:\n' "$4"; fi
} > "$1"
}
# run <function> — sets RC, OUT and CALLS, from $TMP as the working directory.
run() {
rm -f "$TMP/calls"
RC=0
OUT=$(cd "$TMP/cwd" && PATH="$TMP/bin:$PATH" INSTALL_SPINIFEX_LIB_ONLY=1 \
bash -c '. "$1"; SUDO=""; NFV_SIG_KEY_FILE="$2/installed.key"; "$3"' _ "$SETUP" "$TMP" "$1" 2>&1) || RC=$?
CALLS=$(cat "$TMP/calls" 2>/dev/null || true)
}
mkdir -p "$TMP/cwd"
imports_once() { [ "$(printf '%s\n' "$CALLS" | grep -c '^rpm --import')" = 1 ]; }
rm -f "$TMP/installed.key"
key "$TMP/served" "$GOOD"
run install_nfv_sig_key
if [ "$RC" = 0 ] && imports_once && cmp -s "$TMP/served" "$TMP/installed.key"; then
pass "the SIG's key is installed and imported once"
else
fail "the SIG's key was not installed and imported once: rc=$RC calls='$CALLS': $OUT"
fi
refused() { # refused <desc>
case "$RC:$OUT" in
0:*) fail "$1 was accepted" ;;
*"Refusing to trust it"*)
if printf '%s\n' "$CALLS" | grep -q '^rpm' || [ -e "$TMP/installed.key" ]; then
fail "$1 was refused, but only after it was installed or imported: $CALLS"
else
pass "$1 is refused before anything is trusted"
fi ;;
*) fail "$1 stopped without the refusal: $OUT" ;;
esac
}
rm -f "$TMP/installed.key"
key "$TMP/served" "$BAD"
run install_nfv_sig_key
refused "a key with another fingerprint"
case "$OUT" in
*"$BAD"*"$GOOD"*) pass "the refusal names both fingerprints" ;;
*) fail "the refusal does not name both fingerprints: $OUT" ;;
esac
rm -f "$TMP/installed.key"
key "$TMP/served" "$GOOD" "" "$BAD"
run install_nfv_sig_key
refused "a file holding the SIG's key and a second key"
rm -f "$TMP/installed.key"
key "$TMP/served" "$BAD" "$GOOD"
run install_nfv_sig_key
refused "a key whose subkey, not primary key, has the SIG's fingerprint"
key "$TMP/installed.key" "$BAD"
key "$TMP/served" "$GOOD"
run install_nfv_sig_key
if [ "$RC" = 0 ] && printf '%s\n' "$CALLS" | grep -q '^curl' && imports_once && cmp -s "$TMP/served" "$TMP/installed.key"; then
pass "a cached key with another fingerprint is downloaded again and replaced"
else
fail "a cached key with another fingerprint was not replaced: rc=$RC calls='$CALLS': $OUT"
fi
key "$TMP/installed.key" "$GOOD"
run install_nfv_sig_key
if [ "$RC" = 0 ] && ! printf '%s\n' "$CALLS" | grep -q '^curl' && imports_once; then
pass "a cached SIG key is imported without a download"
else
fail "a cached SIG key was not reused: rc=$RC calls='$CALLS': $OUT"
fi
# excludes <desc> <dump excludepkgs line, or empty> <expected setopt>
excludes() {
if [ -n "$2" ]; then printf '[ol10_appstream]\nexcludepkgs = %s\nenabled = 1\n' "$2" > "$TMP/appstream"
else printf '[ol10_appstream]\nenabled = 1\n' > "$TMP/appstream"; fi
run exclude_from_appstream
if [ "$RC" = 0 ] && [ "$CALLS" = "--setopt=ol10_appstream.excludepkgs=$3" ]; then
pass "$1"
else
fail "$1: rc=$RC calls='$CALLS': $OUT"
fi
}
excludes "no exclude set gains the QEMU patterns" "" "qemu-kvm*,qemu-img*"
excludes "an operator's excludes are kept" "foo*, bar" "foo*,bar,qemu-kvm*,qemu-img*"
excludes "a rerun adds no duplicates" "foo*, bar, qemu-kvm*, qemu-img*" "foo*,bar,qemu-kvm*,qemu-img*"
: > "$TMP/cwd/qemu-kvm-x"
excludes "the patterns are never expanded against the working directory" "" "qemu-kvm*,qemu-img*"
[ "$fails" = 0 ] || { echo "$fails failure(s)"; exit 1; }
+172
View File
@@ -0,0 +1,172 @@
#!/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 fixture os-release, cpuinfo and SELinux config files, and stub
# uname and getenforce commands.
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 <os-release body> [machine] [kernel] [cpu flags] — runs detect_os and
# sets RC and OUT (stdout and stderr together). SELINUX_NOW is what getenforce
# prints; SELINUX_BOOT is the config file's SELINUX=, or "none" for no file.
gate() {
printf '%s\n' "$1" > "$TMP/os-release"
printf 'processor\t: 0\nflags\t\t: %s\n' "${4:-$V3_FLAGS}" > "$TMP/cpuinfo"
rm -f "$TMP/selinux-config"
if [ "${SELINUX_BOOT:-permissive}" != none ]; then
printf '# fixture\nSELINUX=%s\nSELINUXTYPE=targeted\n' "${SELINUX_BOOT:-permissive}" > "$TMP/selinux-config"
fi
mkdir -p "$TMP/bin"
printf '#!/bin/sh\necho %s\n' "${SELINUX_NOW:-Permissive}" > "$TMP/bin/getenforce"
chmod +x "$TMP/bin/getenforce"
cat > "$TMP/bin/uname" <<EOF
#!/bin/sh
case "\$1" in
-m) echo "${2:-x86_64}" ;;
-r) echo "${3:-$UEK_R8}" ;;
*) exit 1 ;;
esac
EOF
chmod +x "$TMP/bin/uname"
RC=0
OUT=$(PATH="$TMP/bin:$PATH" SPINIFEX_OS_RELEASE="$TMP/os-release" SPINIFEX_CPUINFO="$TMP/cpuinfo" \
SPINIFEX_SELINUX_CONFIG="$TMP/selinux-config" INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. "$1"; detect_os' _ "$SETUP" 2>&1) || RC=$?
# Some shells keep a prefix assignment to a function call after it returns.
unset SELINUX_NOW SELINUX_BOOT
}
accepts() {
if [ "$RC" = 0 ]; then pass "accepts $1"; else fail "rejects $1: $OUT"; fi
}
# rejects <desc> <word> — 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"
OL102="$OL
VERSION_ID=\"10.2\""
SELINUX_NOW=Enforcing gate "$OL102"
rejects "Oracle Linux with SELinux enforcing" "SELinux is enforcing"
SELINUX_BOOT=enforcing gate "$OL102"
rejects "Oracle Linux with SELinux permissive now but enforcing at next boot" "enforcing at next boot"
SELINUX_BOOT='"enforcing"' gate "$OL102"
rejects "Oracle Linux with a quoted SELINUX=\"enforcing\"" "enforcing at next boot"
SELINUX_NOW=Disabled SELINUX_BOOT=disabled gate "$OL102"
accepts "Oracle Linux with SELinux disabled"
SELINUX_NOW=Disabled SELINUX_BOOT=none gate "$OL102"
accepts "Oracle Linux with no SELinux config file"
# Spinifex has no SELinux policy on any OS, but refusing enforcing on Debian
# would change Debian behaviour, so only the Oracle Linux gate checks it.
SELINUX_NOW=Enforcing SELINUX_BOOT=enforcing gate 'ID=debian
VERSION_ID="13"'
accepts "Debian 13 regardless of SELinux"
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
+6 -1
View File
@@ -60,7 +60,12 @@ export AWS_PROFILE=spinifex
# + CA bundle) is used when the script is called via sudo.
INVOKING_USER="${SUDO_USER:-$(id -un)}"
INVOKING_HOME=$(getent passwd "$INVOKING_USER" | cut -d: -f6)
aws_as_user() { sudo -u "$INVOKING_USER" env HOME="$INVOKING_HOME" AWS_PROFILE=spinifex aws "$@"; }
# By absolute path: sudo's secure_path leaves out /usr/local/bin on some hosts,
# Oracle Linux among them, and setup.sh installs the AWS CLI there.
AWS_BIN=$(command -v aws || true)
[ -z "$AWS_BIN" ] && [ -x /usr/local/bin/aws ] && AWS_BIN=/usr/local/bin/aws
[ -n "$AWS_BIN" ] || { echo "❌ aws CLI not found on PATH or at /usr/local/bin/aws" >&2; exit 1; }
aws_as_user() { sudo -u "$INVOKING_USER" env HOME="$INVOKING_HOME" AWS_PROFILE=spinifex "$AWS_BIN" "$@"; }
# --- Wait for EC2 daemon to subscribe to NATS ---
# Port 3000 (UI) becomes ready before the daemon finishes initialising.
+168 -25
View File
@@ -12,9 +12,10 @@
# Options:
# --purge-data Also remove /var/lib/spinifex — volumes, S3 objects, JetStream.
# Off by default: uninstalling software should not destroy data.
# --purge-deps Also apt-remove the packages setup.sh installed. Names them and
# confirms first, even under --yes. Off by default because a
# curl|bash install lands on hosts that were already using them.
# --purge-deps Also remove the packages setup.sh installed, with apt. Refused
# on dnf hosts, where it cannot tell those from the host's own.
# Names them and confirms first, even under --yes. Off by default
# because a curl|bash install lands on hosts already using them.
# --yes Skip the confirmation prompt. Does NOT cover --purge-data,
# --purge-deps, or the multi-node refusal.
# --dry-run Print the whole plan, touch nothing, exit 0.
@@ -30,14 +31,22 @@
# /usr/share/spinifex, every spinifex systemd unit and slice, the eight
# spinifex-* service users, the sudoers grant, the sysctl/logrotate/chrony/udev
# drop-ins, the host firewall policy and its loaded nft table, the OVS bridges
# and OVN databases, and the Spinifex CA from the host trust store.
# and OVN databases, the Spinifex resolved drop-in, and the Spinifex CA from the
# host trust store. Where setup.sh turned on systemd-resolved, it restores
# /etc/resolv.conf and turns resolved off.
#
# WHAT THIS LEAVES ALONE
#
# /var/lib/spinifex unless --purge-data. Every apt package unless --purge-deps.
# /var/lib/spinifex unless --purge-data. Every package unless --purge-deps.
# /swapfile, which setup.sh may have created but cannot be told apart from one
# the host already had. Anything the operator configured around us: netplan,
# /etc/iptables/rules.v4, hand-written bridges.
# /etc/iptables/rules.v4, hand-written bridges. firewalld, which setup.sh
# masked, is unmasked but not started. Host settings setup.sh changed and other
# software may now rely on: /etc/modules-load.d/spinifex.conf, the confdir line
# appended to an EL /etc/chrony.conf, the dnf repositories and AppStream QEMU
# exclude, OVS_USER_ID and OVN_USER_ID, the OVS and OVN logrotate edits, masked
# IPsec units, the SELinux mode, and the GPU passthrough config spx admin gpu
# wrote. The run ends by listing each of these it finds.
set -euo pipefail
ETC_DIR="${SPX_ETC_DIR:-/etc/spinifex}"
@@ -73,6 +82,32 @@ 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"
# Resolved before anything is removed, so --purge-deps on a host with no package
# manager, or two, stops with nothing changed.
PKG_MANAGER=""
if $PURGE_DEPS; then
PKG_MANAGER=$(distro_pkg_manager) || exit 1
if [ "$PKG_MANAGER" = dnf ]; then
echo "ERROR: --purge-deps is not supported on dnf hosts: its package list cannot tell what setup.sh installed from what the host already had, and dnf also removes every package that requires them. Re-run without --purge-deps." >&2
exit 1
fi
fi
# A failed OVS unit is restarted after its drop-in goes; absent when OVS is.
OVS_UNIT=$(distro_ovs_unit 2>/dev/null) || OVS_UNIT=""
log() { echo "[uninstall-spx] $*"; }
run() {
if $DRY_RUN; then
@@ -147,7 +182,7 @@ if [ -d "$DATA_DIR" ]; then
log " keeping $DATA_DIR ($instances instance(s), $volumes volume(s), ${size:-unknown}) — pass --purge-data to remove it"
fi
fi
$PURGE_DEPS && log " --purge-deps: apt packages will be removed (confirmed separately below)"
$PURGE_DEPS && log " --purge-deps: $PKG_MANAGER packages will be removed (confirmed separately below)"
if ! $ASSUME_YES && ! $DRY_RUN; then
read -r -p "Remove the Spinifex install from this node? Type 'uninstall' to continue: " reply
@@ -208,7 +243,7 @@ fi
#
# It handles the guests, the OVS bridges, the OVN databases and the JetStream
# store, and it carries correctness that is invisible in its code: QEMU matched
# as ^qemu-system because comm truncates at 15 characters, a wait for QEMU to
# by a comm prefix because comm truncates at 15 characters, a wait for QEMU to
# exit rather than for the signal, and the firewall-mode stash that stops an
# interrupted run leaving the node unarmed. A second copy of that would drift,
# and the failure mode of the drift is data loss.
@@ -265,6 +300,16 @@ if command -v iptables-save >/dev/null 2>&1 && ! $DRY_RUN; then
elif $DRY_RUN; then
echo " would remove iptables rules commented spinifex-nat-egress / spinifex-eip-ingress / spinifex-imds / spinifex-imds-remap"
fi
# setup.sh masks firewalld. Unmasked but left disabled: its rules were never
# migrated, so starting it is the operator's decision. A mask keeps the enable
# links, so without the disable it would start again at the next boot.
FIREWALLD_UNMASKED=false
if [ "$(systemctl is-enabled firewalld.service 2>/dev/null)" = masked ]; then
run sudo systemctl unmask firewalld.service
run sudo systemctl disable firewalld.service 2>/dev/null || true
FIREWALLD_UNMASKED=true
$DRY_RUN || log " firewalld unmasked, left disabled"
fi
# ---------------------------------------------------------------------------
# 6. systemd units.
@@ -294,12 +339,14 @@ rm_path /etc/systemd/system/spinifex-*.service \
# exactly that way on the first dev-oci round trip.
log "removing drop-ins written into other units"
rm_path /etc/systemd/system/openvswitch-switch.service.d/spinifex-perms.conf \
/etc/systemd/system/ovs-vswitchd.service.d/spinifex-perms.conf \
/etc/systemd/system/ovn-controller.service.d/spinifex-perms.conf \
/etc/systemd/system/ovn-controller.service.d/log-level.conf \
/etc/systemd/system/openvswitch-ipsec.service.d/spinifex-perms.conf \
/etc/systemd/system/ovn-northd.service.d/spinifex-perms.conf \
/etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf
# Only if we emptied them — another package may own the same override dir.
for d in openvswitch-switch ovn-controller openvswitch-ipsec ovn-northd; do
for d in openvswitch-switch ovs-vswitchd ovn-controller openvswitch-ipsec ovn-northd; do
dir="/etc/systemd/system/${d}.service.d"
if sudo test -d "$dir" && [ -z "$(sudo ls -A "$dir" 2>/dev/null)" ]; then
rm_path "$dir"
@@ -313,8 +360,11 @@ run sudo systemctl reset-failed 2>/dev/null || true
# attempt. Restarting them here is not housekeeping: openvswitch-switch failing
# is how the operator discovers the drop-in problem, and leaving it failed after
# an uninstall that claims success is the same silent failure one step later.
# Each unit that carried one of the drop-ins above, under this host's OVS name.
RESTART_UNITS=(ovs-vswitchd ovn-controller ovn-northd)
[ -n "$OVS_UNIT" ] && RESTART_UNITS=("$OVS_UNIT" "${RESTART_UNITS[@]}")
if ! $DRY_RUN; then
for u in openvswitch-switch ovn-controller; do
for u in "${RESTART_UNITS[@]}"; do
if systemctl is-failed --quiet "$u" 2>/dev/null; then
log " restarting $u, which our drop-in had failed"
sudo systemctl reset-failed "$u" 2>/dev/null || true
@@ -367,6 +417,10 @@ rm_path /usr/local/bin/spx \
/usr/local/share/spinifex \
/usr/local/lib/spinifex \
/usr/share/spinifex
# setup.sh adds this link where sudo's PATH leaves out /usr/local/bin.
if [ "$(readlink /usr/bin/spx 2>/dev/null)" = /usr/local/bin/spx ]; then
rm_path /usr/bin/spx
fi
# ---------------------------------------------------------------------------
# 9. Drop-ins. Globbed, not listed: 99-spinifex-vpc.conf is written at runtime
@@ -378,9 +432,15 @@ log "removing configuration drop-ins"
rm_path /etc/sysctl.d/*spinifex* \
/etc/sudoers.d/spinifex-network \
/etc/chrony/conf.d/spinifex.conf \
/etc/chrony.d/spinifex.conf \
/etc/logrotate.d/spinifex \
/etc/udev/rules.d/*spinifex*
/etc/tmpfiles.d/spinifex.conf \
/etc/udev/rules.d/*spinifex* \
/etc/NetworkManager/conf.d/*spinifex*
run sudo sysctl --system >/dev/null 2>&1 || true
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
run sudo nmcli general reload conf 2>/dev/null || true
fi
# ---------------------------------------------------------------------------
# 10. Spinifex directories.
@@ -431,14 +491,42 @@ done
# 12. The CA in the host trust store. Leaving it means the host trusts a CA
# whose key no longer exists anywhere.
# ---------------------------------------------------------------------------
if [ -f /usr/local/share/ca-certificates/spinifex-ca.crt ]; then
CA_TRUST=$(distro_ca_trust) || { echo "ERROR: cannot tell which trust store to remove the Spinifex CA from" >&2; exit 1; }
CA_ANCHOR="${CA_TRUST#* }/spinifex-ca.crt"
if [ -f "$CA_ANCHOR" ]; then
log "removing the Spinifex CA from the host trust store"
rm_path /usr/local/share/ca-certificates/spinifex-ca.crt
run sudo update-ca-certificates >/dev/null 2>&1 || true
rm_path "$CA_ANCHOR"
run sudo "${CA_TRUST%% *}"
fi
# ---------------------------------------------------------------------------
# 13. Swap is reported, never removed.
# 13. Host DNS. setup.sh saved /etc/resolv.conf only where it turned on
# systemd-resolved, so the saved copy is what says to turn it off again.
# ---------------------------------------------------------------------------
RESOLV_CONF_SAVED=/var/lib/spinifex-reset/resolv.conf.orig
# The daemon's drop-in routes the cluster zones to a northstar that is gone now.
RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/spinifex-dns.conf
if exists "$RESOLVED_DROPIN"; then
log "removing the systemd-resolved drop-in for the cluster zones"
rm_path "$RESOLVED_DROPIN"
if systemctl is-active --quiet systemd-resolved.service 2>/dev/null; then
run sudo systemctl restart systemd-resolved.service
fi
fi
if exists "$RESOLV_CONF_SAVED"; then
log "restoring /etc/resolv.conf and turning systemd-resolved off, as before the install"
run sudo systemctl disable --now systemd-resolved.service
run sudo cp -a --remove-destination "$RESOLV_CONF_SAVED" /etc/resolv.conf
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
run sudo systemctl restart NetworkManager.service
fi
rm_path "$RESOLV_CONF_SAVED"
fi
# node-reset keeps its firewall-mode stash here too, so remove it only once empty.
run sudo rmdir --ignore-fail-on-non-empty "$(dirname "$RESOLV_CONF_SAVED")" 2>/dev/null || true
# ---------------------------------------------------------------------------
# 14. Swap is reported, never removed.
#
# setup_swap creates an 8G /swapfile with the fstab line `/swapfile none swap sw
# 0 0` — which is byte-identical to the line a host would already have if it
@@ -455,26 +543,32 @@ if [ -f /swapfile ] && grep -q '^/swapfile ' /etc/fstab 2>/dev/null; then
fi
# ---------------------------------------------------------------------------
# 14. apt packages, only when asked, and only after naming them.
# 15. Packages, only when asked, and only after naming them.
#
# This is the one irreversible thing in the script. On a dedicated node these
# are ours; on the host a curl|bash install actually lands on they are someone
# else's, and openvswitch/libvirt/qemu/chrony going away underneath a running
# workload is a far worse outcome than a few megabytes left behind. Confirmed
# separately even under --yes, and never autoremove, which is unbounded.
#
# apt only: dnf hosts are refused up front.
# ---------------------------------------------------------------------------
if $PURGE_DEPS; then
PACKAGES="nbdkit qemu-utils gdisk ovmf less libvirt-daemon-system libvirt-clients
pciutils jq ethtool netcat-openbsd unzip xz-utils
ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon
chrony nftables"
KEPT="curl, wget, file, iproute2, dhcpcd-base, qemu-system-*"
REMOVE_CMD="apt-get remove -y"
log "--purge-deps: these packages would be removed:"
# shellcheck disable=SC2086
for p in $PACKAGES; do echo " $p"; done
log " NOT removed: curl, wget, file, iproute2, dhcpcd-base, qemu-system-* — too"
log " NOT removed: $KEPT — too"
log " widely depended on to remove from a host we do not own."
if $DRY_RUN; then
echo " would run: apt-get remove -y <the above>"
if [ -z "$PACKAGES" ]; then
log " none of them is installed"
elif $DRY_RUN; then
echo " would run: $REMOVE_CMD <the above>"
else
read -r -p "Remove these packages? Type 'remove' to continue: " reply
if [ "$reply" = "remove" ]; then
@@ -488,7 +582,7 @@ chrony nftables"
fi
# ---------------------------------------------------------------------------
# 15. Verify. An uninstaller that reports success over a surviving unit file is
# 16. Verify. An uninstaller that reports success over a surviving unit file is
# worse than one that refuses, because the next install silently inherits it.
# ---------------------------------------------------------------------------
if ! $DRY_RUN; then
@@ -496,21 +590,26 @@ if ! $DRY_RUN; then
LEFT=()
for p in /usr/local/bin/spx /usr/local/share/spinifex /usr/local/lib/spinifex \
/usr/share/spinifex "$ETC_DIR" "$LOG_DIR" \
/etc/sudoers.d/spinifex-network /etc/logrotate.d/spinifex \
/etc/chrony/conf.d/spinifex.conf; do
/etc/sudoers.d/spinifex-network /etc/logrotate.d/spinifex /etc/tmpfiles.d/spinifex.conf \
/etc/chrony/conf.d/spinifex.conf /etc/chrony.d/spinifex.conf "$RESOLV_CONF_SAVED" \
"$RESOLVED_DROPIN"; do
exists "$p" && LEFT+=("$p")
done
[ "$(readlink /usr/bin/spx 2>/dev/null)" = /usr/local/bin/spx ] && LEFT+=(/usr/bin/spx)
for p in /etc/systemd/system/spinifex-*.service /etc/systemd/system/spinifex*.slice \
/etc/systemd/system/spinifex.target /etc/sysctl.d/*spinifex* \
/etc/systemd/network/1[5-8]-spinifex-* \
/etc/NetworkManager/conf.d/*spinifex* \
/etc/systemd/system/*.service.d/spinifex-perms.conf \
/etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf \
/etc/systemd/system/ovn-controller.service.d/log-level.conf; do
exists "$p" && LEFT+=("$p")
done
$FIREWALLD_UNMASKED && systemctl is-enabled --quiet firewalld.service 2>/dev/null &&
LEFT+=("firewalld.service is enabled")
# A unit we broke and could not restart is a failed uninstall, not a wart.
for u in openvswitch-switch ovn-controller; do
systemctl is-failed --quiet "$u" 2>/dev/null && LEFT+=("$u.service is failed")
for u in "${RESTART_UNITS[@]}"; do
systemctl is-failed --quiet "$u" 2>/dev/null && LEFT+=("${u%.service}.service is failed")
done
for svc in nats gw daemon storage northstar viperblock vpcd ui; do
id -u "spinifex-${svc}" >/dev/null 2>&1 && LEFT+=("user spinifex-${svc}")
@@ -527,7 +626,51 @@ if ! $DRY_RUN; then
fi
fi
# ---------------------------------------------------------------------------
# 17. Host settings setup.sh changed and this leaves, each listed only where it
# is found, so the operator can see what to undo by hand.
# ---------------------------------------------------------------------------
KEPT_SETTINGS=()
kept() { KEPT_SETTINGS+=("$1"); }
$FIREWALLD_UNMASKED && kept "firewalld: unmasked but disabled, with the rules it had before Spinifex"
exists /etc/modules-load.d/spinifex.conf && kept "/etc/modules-load.d/spinifex.conf: openvswitch geneve vhost_net tun load at boot"
CHRONY_CONF=$(distro_chrony_conf 2>/dev/null) || CHRONY_CONF=""
if [ -n "$CHRONY_CONF" ] && grep -q '^# Added by the Spinifex installer' "$CHRONY_CONF" 2>/dev/null; then
kept "$CHRONY_CONF: the confdir line the installer appended"
fi
if command -v dnf >/dev/null 2>&1; then
exists /etc/yum.repos.d/spinifex-nfv-sig.repo &&
kept "dnf: the CentOS NFV SIG repository (/etc/yum.repos.d/spinifex-nfv-sig.repo) and its key in /etc/pki/rpm-gpg"
for repo in ol10_kvm_utils ol10_UEKR8; do
dnf config-manager --dump "$repo" 2>/dev/null | grep -qx 'enabled = 1' && kept "dnf: $repo enabled"
done
dnf config-manager --dump ol10_appstream 2>/dev/null | grep -q '^excludepkgs = .*qemu-kvm' &&
kept "dnf: ol10_appstream excludes qemu-kvm* and qemu-img*, so QEMU stays on KVM Utilities"
fi
for setting in /etc/sysconfig/openvswitch:OVS_USER_ID /etc/sysconfig/ovn:OVN_USER_ID; do
grep -q "^${setting#*:}=\"root:root\"" "${setting%%:*}" 2>/dev/null &&
kept "${setting%%:*}: ${setting#*:}=\"root:root\""
done
for lr in "$(distro_ovs_logrotate 2>/dev/null)" "$(distro_ovn_logrotate 2>/dev/null)"; do
[ -n "$lr" ] && grep -q 'maxsize 100M' "$lr" 2>/dev/null && kept "$lr: the rotate, maxsize and su edits setup-ovn.sh made"
done
IPSEC_UNITS=(openvswitch-ipsec.service strongswan-starter.service)
distro_has_libreswan && IPSEC_UNITS+=(ipsec.service)
for u in "${IPSEC_UNITS[@]}"; do
[ "$(systemctl is-enabled "$u" 2>/dev/null)" = masked ] && kept "$u: masked, as IPsec was off"
done
command -v getenforce >/dev/null 2>&1 && kept "SELinux: $(getenforce 2>/dev/null); setup.sh refuses enforcing, so it was set for the install"
GPU_FILES=""
for gpu in /etc/modprobe.d/vfio-pci.conf /etc/dracut.conf.d/spinifex-vfio.conf; do
exists "$gpu" && GPU_FILES="${GPU_FILES:+$GPU_FILES, }$gpu"
done
[ -n "$GPU_FILES" ] && kept "GPU passthrough: $GPU_FILES and the IOMMU kernel arguments, written by spx admin gpu setup"
if [ "${#KEPT_SETTINGS[@]}" -gt 0 ]; then
log "left in place, as other software on the host may rely on them:"
for k in "${KEPT_SETTINGS[@]}"; do log " - $k"; done
fi
log "done — Spinifex is removed from this node"
$PURGE_DATA || { [ -d "$DATA_DIR" ] && log " $DATA_DIR was kept (--purge-data removes it)"; }
$PURGE_DEPS || log " apt dependencies were kept (--purge-deps removes them)"
$PURGE_DEPS || log " package dependencies were kept (--purge-deps removes them)"
exit 0
+11
View File
@@ -56,6 +56,17 @@ grep -q "unknown option" "$TMP/err" \
&& pass "unknown option explains itself on stderr" \
|| fail "unknown option wrote nothing useful to stderr"
# --purge-deps on a dnf host stops before anything is touched. PATH holds only
# the tools the script needs up to that check, with dnf and no apt-get.
mkdir -p "$TMP/dnfbin"
for tool in bash dirname; do ln -s "$(command -v "$tool")" "$TMP/dnfbin/$tool"; done
printf '#!/bin/sh\nexit 0\n' >"$TMP/dnfbin/dnf"
chmod +x "$TMP/dnfbin/dnf"
PATH="$TMP/dnfbin" "$TMP/dnfbin/bash" "$UNINSTALL" --purge-deps --yes >"$TMP/out" 2>"$TMP/err" && rc=0 || rc=$?
[ "$rc" = 1 ] && grep -q "not supported on dnf hosts" "$TMP/err" \
&& pass "--purge-deps is refused on a dnf host, saying why" \
|| fail "--purge-deps on a dnf host: rc=$rc: $(cat "$TMP/err")"
# --------------------------------------------------------------------------
# The multi-node refusal.
#
+6 -1
View File
@@ -29,7 +29,12 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INVOKING_USER="${SUDO_USER:-$(id -un)}"
INVOKING_HOME=$(getent passwd "$INVOKING_USER" | cut -d: -f6)
aws_as_user() { sudo -u "$INVOKING_USER" env HOME="$INVOKING_HOME" AWS_PROFILE=spinifex aws "$@"; }
# By absolute path: sudo's secure_path leaves out /usr/local/bin on some hosts,
# Oracle Linux among them, and setup.sh installs the AWS CLI there.
AWS_BIN=$(command -v aws || true)
[ -z "$AWS_BIN" ] && [ -x /usr/local/bin/aws ] && AWS_BIN=/usr/local/bin/aws
[ -n "$AWS_BIN" ] || { echo "ERROR: aws CLI not found on PATH or at /usr/local/bin/aws" >&2; exit 1; }
aws_as_user() { sudo -u "$INVOKING_USER" env HOME="$INVOKING_HOME" AWS_PROFILE=spinifex "$AWS_BIN" "$@"; }
FAILURES=0
pass() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
+44
View File
@@ -0,0 +1,44 @@
package preflight
import (
"fmt"
"os"
"path/filepath"
"slices"
)
const resolvConf = "/etc/resolv.conf"
// resolvedStubs are the files that send every lookup through systemd-resolved:
// its runtime stub and the static copy it ships.
var resolvedStubs = []string{
"/run/systemd/resolve/stub-resolv.conf",
"/usr/lib/systemd/resolv.conf",
"/lib/systemd/resolv.conf",
}
// checkHostDNS warns when systemd-resolved runs but /etc/resolv.conf bypasses
// it. The Spinifex zones are routed to northstar inside resolved, so lookups
// that skip it never see them.
func checkHostDNS(root string) Result {
const unit = "systemd-resolved.service"
if !unitActive(unit) {
return Result{Path: resolvConf, Kind: "host-dns", Status: OK}
}
target, err := os.Readlink(filepath.Join(root, resolvConf))
if err == nil {
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(resolvConf), target)
}
if slices.Contains(resolvedStubs, filepath.Clean(target)) {
return Result{Path: resolvConf, Kind: "host-dns", Status: OK}
}
}
what := "is not a link"
if err == nil {
what = fmt.Sprintf("links to %s", target)
}
return Result{Path: resolvConf, Kind: "host-dns", Status: Warning,
Detail: fmt.Sprintf("%s is active but %s %s to its stub, so Spinifex names may not resolve on this host; run 'ln -sf %s %s'",
unit, resolvConf, what, resolvedStubs[0], resolvConf)}
}
@@ -0,0 +1,55 @@
package preflight
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCheckHostAt_HostDNS(t *testing.T) {
for name, tc := range map[string]struct {
resolved bool
link string // target of /etc/resolv.conf; "" writes a regular file
want Status
detail string
}{
"resolved inactive, plain file": {want: OK},
"resolved on the runtime stub": {resolved: true, link: "/run/systemd/resolve/stub-resolv.conf", want: OK},
"resolved on a relative stub link": {resolved: true, link: "../run/systemd/resolve/stub-resolv.conf", want: OK},
"resolved on the static stub": {resolved: true, link: "/usr/lib/systemd/resolv.conf", want: OK},
"resolved behind a plain file": {resolved: true, want: Warning, detail: "is not a link"},
"resolved behind its uplink file": {resolved: true, link: "/run/systemd/resolve/resolv.conf", want: Warning, detail: "links to /run/systemd/resolve/resolv.conf"},
"resolved behind NetworkManager file": {resolved: true, link: "/run/NetworkManager/resolv.conf", want: Warning, detail: "links to"},
} {
t.Run(name, func(t *testing.T) {
orig := unitActive
t.Cleanup(func() { unitActive = orig })
unitActive = func(unit string) bool { return tc.resolved && unit == "systemd-resolved.service" }
root := t.TempDir()
path := filepath.Join(root, resolvConf)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
var err error
if tc.link == "" {
err = os.WriteFile(path, []byte("nameserver 192.0.2.1\n"), 0o644)
} else {
err = os.Symlink(tc.link, path)
}
if err != nil {
t.Fatal(err)
}
r := findResult(t, CheckHostAt(root), resolvConf)
if r.Status != tc.want {
t.Fatalf("status = %s %q, want %s", r.Status, r.Detail, tc.want)
}
if !strings.Contains(r.Detail, tc.detail) {
t.Errorf("detail %q does not contain %q", r.Detail, tc.detail)
}
if r.Status == Warning && HasProblem([]Result{r}) {
t.Error("a Warning fails preflight; it must only be reported")
}
})
}
}
@@ -0,0 +1,62 @@
package preflight
import (
"bufio"
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ufwConfig records whether ufw is enabled. ufw.service is a oneshot that stays
// active after `ufw disable`, so the unit state alone does not say ufw is on.
const ufwConfig = "/etc/ufw/ufw.conf"
// unitActive is a variable so tests never query a real systemd.
var unitActive = func(unit string) bool {
return exec.Command("systemctl", "is-active", "--quiet", unit).Run() == nil
}
// checkHostFirewalls fails while firewalld or ufw is active. A forwarded packet
// must then be accepted by their ruleset as well as Spinifex's, so routed NAT,
// EIPs and IMDS break with nothing logged.
func checkHostFirewalls(root string) []Result {
return []Result{checkFirewalld(), checkUFW(root)}
}
func checkFirewalld() Result {
const unit = "firewalld.service"
if unitActive(unit) {
return Result{Path: unit, Kind: "host-firewall", Status: Failed,
Detail: "firewalld is active beside the Spinifex nftables policy; run 'systemctl mask --now firewalld'"}
}
return Result{Path: unit, Kind: "host-firewall", Status: OK}
}
func checkUFW(root string) Result {
const unit = "ufw.service"
if unitActive(unit) && ufwEnabled(root) {
return Result{Path: unit, Kind: "host-firewall", Status: Failed,
Detail: "ufw is enabled beside the Spinifex nftables policy; run 'ufw disable'"}
}
return Result{Path: unit, Kind: "host-firewall", Status: OK}
}
// ufwEnabled reads ENABLED= from ufw.conf, as ufw-init does. A missing file
// means ufw is not installed.
func ufwEnabled(root string) bool {
data, err := os.ReadFile(filepath.Join(root, ufwConfig))
if err != nil {
return false
}
sc := bufio.NewScanner(bytes.NewReader(data))
for sc.Scan() {
key, value, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=")
if ok && key == "ENABLED" {
value = strings.Trim(value, `"'`)
return value == "yes" || value == "YES" // the two spellings ufw-init accepts
}
}
return false
}
@@ -0,0 +1,53 @@
package preflight
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestCheckHostAt_HostFirewalls(t *testing.T) {
for name, tc := range map[string]struct {
active []string
ufwConf string
firewalld Status
ufw Status
detail string
}{
"neither installed": {firewalld: OK, ufw: OK},
"firewalld active": {active: []string{"firewalld.service"}, firewalld: Failed, ufw: OK, detail: "systemctl mask --now firewalld"},
"ufw unit active after ufw disable": {active: []string{"ufw.service"}, ufwConf: "ENABLED=no\n", firewalld: OK, ufw: OK},
"ufw enabled": {active: []string{"ufw.service"}, ufwConf: "# /etc/ufw/ufw.conf\nENABLED=yes\nLOGLEVEL=low\n", firewalld: OK, ufw: Failed, detail: "ufw disable"},
"ufw enabled, upper case": {active: []string{"ufw.service"}, ufwConf: "ENABLED=YES\n", firewalld: OK, ufw: Failed, detail: "ufw disable"},
"ufw enabled but its unit stopped": {ufwConf: "ENABLED=yes\n", firewalld: OK, ufw: OK},
"both active": {active: []string{"firewalld.service", "ufw.service"}, ufwConf: "ENABLED=yes\n", firewalld: Failed, ufw: Failed},
} {
t.Run(name, func(t *testing.T) {
orig := unitActive
t.Cleanup(func() { unitActive = orig })
unitActive = func(unit string) bool { return slices.Contains(tc.active, unit) }
root := t.TempDir()
if tc.ufwConf != "" {
path := filepath.Join(root, ufwConfig)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(tc.ufwConf), 0o644); err != nil {
t.Fatal(err)
}
}
results := CheckHostAt(root)
for unit, want := range map[string]Status{"firewalld.service": tc.firewalld, "ufw.service": tc.ufw} {
r := findResult(t, results, unit)
if r.Status != want {
t.Errorf("%s = %s %q, want %s", unit, r.Status, r.Detail, want)
}
if r.Status == Failed && !strings.Contains(r.Detail, tc.detail) {
t.Errorf("%s detail %q does not name the fix %q", unit, r.Detail, tc.detail)
}
}
})
}
}
+25
View File
@@ -0,0 +1,25 @@
package preflight
import (
"context"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostnet"
)
// hostnetUnit is the unit whose failure this check surfaces.
const hostnetUnit = "spinifex-hostnet.service"
// hostnetOVNRemote is a variable so tests never query a real OVS.
var hostnetOVNRemote = hostnet.DefaultEnv().OVNRemote
// checkHostnet runs the check the daemon and vpcd run before they start, so a
// failed host network apply shows here, not only as a refused service start.
func checkHostnet(root string) Result {
env := hostnet.DefaultEnv()
env.Paths = hostnet.PathsAt(root)
env.OVNRemote = hostnetOVNRemote
if err := hostnet.Check(context.Background(), env); err != nil {
return Result{Path: hostnetUnit, Kind: "hostnet", Status: Failed, Detail: err.Error()}
}
return Result{Path: hostnetUnit, Kind: "hostnet", Status: OK}
}
@@ -0,0 +1,45 @@
package preflight
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCheckHostAt_Hostnet(t *testing.T) {
for name, tc := range map[string]struct {
backend string
state bool
want Status
detail string
}{
"no record, nothing set up": {want: OK},
"networkd backend": {backend: "networkd", want: OK},
"hostnet backend, no state": {backend: "hostnet", want: Failed, detail: "hostnet.json"},
"hostnet backend, no marker": {backend: "hostnet", state: true, want: Failed, detail: "host network not applied"},
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
etc := filepath.Join(root, "etc/spinifex")
if err := os.MkdirAll(etc, 0o755); err != nil {
t.Fatal(err)
}
if tc.backend != "" {
if err := os.WriteFile(filepath.Join(etc, "hostnet-backend"), []byte(tc.backend+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
if tc.state {
state := `{"version": 1, "pending": false, "int_bridge": "br-int"}`
if err := os.WriteFile(filepath.Join(etc, "hostnet.json"), []byte(state), 0o644); err != nil {
t.Fatal(err)
}
}
r := findResult(t, CheckHostAt(root), hostnetUnit)
if r.Status != tc.want || !strings.Contains(r.Detail, tc.detail) {
t.Errorf("hostnet result = %s %q, want %s containing %q", r.Status, r.Detail, tc.want, tc.detail)
}
})
}
}
+84
View File
@@ -0,0 +1,84 @@
package preflight
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
)
// microvmMachine is the QEMU machine type load balancer system instances boot on.
const microvmMachine = "microvm"
// microvmChecked reports whether this host runs system instances. The microvm
// machine type exists only in x86 QEMU.
var microvmChecked = runtime.GOARCH == "amd64"
// resolveQEMU and machineHelp are variables so tests never run a real QEMU.
var (
resolveQEMU = func() (string, error) { return hostos.QEMUBinary("x86_64") }
machineHelp = func(binary string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return exec.CommandContext(ctx, binary, "-machine", "help").Output()
}
)
// checkMicroVM checks the QEMU binary the daemon will run, and the firmware it
// passes, can boot a microvm. A QEMU build without microvm, such as Oracle
// Linux's AppStream qemu-kvm, otherwise surfaces only at the first ALB launch.
func checkMicroVM(root string) []Result {
if !microvmChecked {
return nil
}
return []Result{checkQEMUMicroVM(), checkMicroVMFirmware(root)}
}
func checkQEMUMicroVM() Result {
binary, err := resolveQEMU()
if err != nil {
return Result{Path: "qemu", Kind: "qemu", Status: Missing, Detail: err.Error()}
}
out, err := machineHelp(binary)
if err != nil {
return Result{Path: binary, Kind: "qemu", Status: Missing, Detail: fmt.Sprintf("%s -machine help: %v", binary, err)}
}
if !listsMachine(out, microvmMachine) {
return Result{
Path: binary,
Kind: "qemu",
Status: Unsupported,
Detail: fmt.Sprintf("no %s machine type, so load balancers cannot boot; install a QEMU build that has it (on Oracle Linux, qemu-kvm from ol10_kvm_utils)", microvmMachine),
}
}
return Result{Path: binary, Kind: "qemu", Status: OK}
}
// listsMachine reports whether `-machine help` output has a row for name. Rows
// are "<name> <description>", after a "Supported machines are:" header.
func listsMachine(help []byte, name string) bool {
sc := bufio.NewScanner(bytes.NewReader(help))
for sc.Scan() {
if fields := strings.Fields(sc.Text()); len(fields) > 0 && fields[0] == name {
return true
}
}
return false
}
func checkMicroVMFirmware(root string) Result {
path := filepath.Join(vm.MicroVMDir, vm.MicroVMFirmware)
if _, err := os.Stat(filepath.Join(root, path)); err != nil {
return Result{Path: path, Kind: "firmware", Status: Missing, Detail: err.Error()}
}
return Result{Path: path, Kind: "firmware", Status: OK}
}
@@ -0,0 +1,132 @@
package preflight
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
)
// Debian's qemu-system-x86_64 -machine help, trimmed.
const helpWithMicroVM = `Supported machines are:
microvm microvm (i386)
pc Standard PC (i440FX + PIIX, 1996) (alias of pc-i440fx-10.0)
q35 Standard PC (Q35 + ICH9, 2009) (alias of pc-q35-10.0)
`
// Oracle Linux 10's AppStream qemu-kvm 10.1.0 -machine help, trimmed. It has
// no microvm row.
const helpWithoutMicroVM = `Supported machines are:
pc RHEL 10.0.0 PC (i440FX + PIIX, 1996) (alias of pc-i440fx-rhel10.0.0)
pc-i440fx-rhel10.0.0 RHEL 10.0.0 PC (i440FX + PIIX, 1996) (default) (deprecated)
pc-q35-rhel9.8.0 RHEL-9.8.0 PC (Q35 + ICH9, 2009) (deprecated)
`
var microvmFirmwarePath = filepath.Join(vm.MicroVMDir, vm.MicroVMFirmware)
func TestMain(m *testing.M) {
microvmChecked = true
resolveQEMU = func() (string, error) { return hostos.QEMUKVMPath, nil }
machineHelp = func(string) ([]byte, error) { return []byte(helpWithMicroVM), nil }
hostnetOVNRemote = func(context.Context) (string, error) { return "", nil }
unitActive = func(string) bool { return false }
os.Exit(m.Run())
}
func stubQEMU(t *testing.T, binary string, resolveErr error, help string, helpErr error) {
t.Helper()
origResolve, origHelp := resolveQEMU, machineHelp
t.Cleanup(func() { resolveQEMU, machineHelp = origResolve, origHelp })
var asked string
resolveQEMU = func() (string, error) { return binary, resolveErr }
machineHelp = func(b string) ([]byte, error) {
asked = b
return []byte(help), helpErr
}
t.Cleanup(func() {
if resolveErr == nil && asked != binary {
t.Errorf("machine help ran %q, want the resolved binary %q", asked, binary)
}
})
}
func qemuResult(t *testing.T, results []Result) Result {
t.Helper()
for _, r := range results {
if r.Kind == "qemu" {
return r
}
}
t.Fatalf("no qemu result in %+v", results)
return Result{}
}
func TestCheckHostAt_QEMUMicroVM(t *testing.T) {
t.Run("resolved binary lists microvm", func(t *testing.T) {
stubQEMU(t, hostos.QEMUKVMPath, nil, helpWithMicroVM, nil)
r := qemuResult(t, CheckHostAt(t.TempDir()))
if r.Status != OK || r.Path != hostos.QEMUKVMPath {
t.Errorf("got %+v, want OK for %s", r, hostos.QEMUKVMPath)
}
})
t.Run("resolved binary has no microvm", func(t *testing.T) {
stubQEMU(t, hostos.QEMUKVMPath, nil, helpWithoutMicroVM, nil)
r := qemuResult(t, CheckHostAt(t.TempDir()))
if r.Status != Unsupported || !strings.Contains(r.Detail, "ol10_kvm_utils") {
t.Errorf("got %+v, want Unsupported naming ol10_kvm_utils", r)
}
})
t.Run("no QEMU resolves", func(t *testing.T) {
noQEMU := errors.New("no QEMU for x86_64 guests: found neither /usr/libexec/qemu-kvm (package qemu-kvm) nor qemu-system-x86_64 (package qemu-system-x86) on PATH")
stubQEMU(t, "", noQEMU, "", nil)
r := qemuResult(t, CheckHostAt(t.TempDir()))
if r.Status != Missing || r.Detail != noQEMU.Error() {
t.Errorf("got %+v, want Missing carrying the resolver's error", r)
}
})
t.Run("machine help fails", func(t *testing.T) {
stubQEMU(t, "/usr/bin/qemu-system-x86_64", nil, "", errors.New("exit status 1"))
r := qemuResult(t, CheckHostAt(t.TempDir()))
if r.Status != Missing || !strings.Contains(r.Detail, "exit status 1") {
t.Errorf("got %+v, want Missing with the exec error", r)
}
})
}
func TestCheckHostAt_MicroVMFirmware(t *testing.T) {
t.Run("absent", func(t *testing.T) {
r := findResult(t, CheckHostAt(t.TempDir()), microvmFirmwarePath)
if r.Status != Missing {
t.Errorf("status = %v, want Missing", r.Status)
}
})
t.Run("present", func(t *testing.T) {
root := t.TempDir()
writeHelper(t, root, microvmFirmwarePath, []byte("qboot"))
r := findResult(t, CheckHostAt(root), microvmFirmwarePath)
if r.Status != OK {
t.Errorf("status = %v, want OK (detail: %s)", r.Status, r.Detail)
}
})
}
func TestCheckHostAt_NoMicroVMChecksOffX86(t *testing.T) {
orig := microvmChecked
microvmChecked = false
t.Cleanup(func() { microvmChecked = orig })
for _, r := range CheckHostAt(t.TempDir()) {
if r.Kind == "qemu" || r.Kind == "firmware" {
t.Errorf("unexpected %s result on a host without system instances: %+v", r.Kind, r)
}
}
}
+20 -3
View File
@@ -29,6 +29,13 @@ const (
Stale
// Ungranted means the sudoers grant for a helper is absent or missing its NOPASSWD line.
Ungranted
// Unsupported means the asset is present but lacks a capability this build needs.
Unsupported
// Failed means a host check that gates the services did not pass.
Failed
// Warning means a host setting degrades a feature without gating the
// services. It is reported but does not fail preflight.
Warning
)
// String returns the human-readable name of the status, used in table output and log lines.
@@ -42,6 +49,12 @@ func (s Status) String() string {
return "Stale"
case Ungranted:
return "Ungranted"
case Unsupported:
return "Unsupported"
case Failed:
return "Failed"
case Warning:
return "Warning"
default:
return "Unknown"
}
@@ -50,7 +63,7 @@ func (s Status) String() string {
// Result is the outcome of checking one managed asset against this build's manifest.
type Result struct {
Path string
Kind string // "helper" | "sudoers-grant" | "service"
Kind string // "helper" | "sudoers-grant" | "service" | "qemu" | "firmware" | "hostnet" | "host-firewall" | "host-dns"
Status Status
Detail string
}
@@ -82,13 +95,17 @@ func CheckHostAt(root string) []Result {
}
results = append(results, checkSudoersGrant(root))
results = append(results, checkRunningBinaries(root)...)
results = append(results, checkMicroVM(root)...)
results = append(results, checkHostnet(root))
results = append(results, checkHostFirewalls(root)...)
results = append(results, checkHostDNS(root))
return results
}
// HasProblem reports whether any result is not OK.
// HasProblem reports whether any result is neither OK nor a Warning.
func HasProblem(results []Result) bool {
return slices.ContainsFunc(results, func(r Result) bool {
return r.Status != OK
return r.Status != OK && r.Status != Warning
})
}
@@ -105,7 +105,7 @@ func TestHasProblem(t *testing.T) {
}
func TestStatusString(t *testing.T) {
cases := map[Status]string{OK: "OK", Missing: "Missing", Stale: "Stale", Ungranted: "Ungranted"}
cases := map[Status]string{OK: "OK", Missing: "Missing", Stale: "Stale", Ungranted: "Ungranted", Unsupported: "Unsupported", Failed: "Failed"}
for status, want := range cases {
if got := status.String(); got != want {
t.Errorf("Status(%d).String() = %q, want %q", status, got, want)
+12 -5
View File
@@ -84,6 +84,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/gpu"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -1395,11 +1396,17 @@ func (d *Daemon) startLocal() error {
slog.Warn("Failed to set daemon OOM score", "err", err)
}
// Warn on host assets this build expects but that are missing, stale, or
// ungranted, so drift surfaces as a named log line rather than a later
// cryptic failure. Never fatal — unrelated asset problems must not ground a node.
// Log host assets that are missing, stale or ungranted, and host faults whose
// detail names the fix, so drift surfaces as a named line rather than a later
// cryptic failure. Never fatal: an unrelated asset problem must not ground a node.
for _, r := range preflight.CheckHost() {
if r.Status != preflight.OK {
switch r.Status {
case preflight.OK:
case preflight.Failed:
slog.Error("Host preflight check failed", "path", r.Path, "kind", r.Kind, "detail", r.Detail)
case preflight.Warning:
slog.Warn("Host preflight warning", "path", r.Path, "kind", r.Kind, "detail", r.Detail)
default:
slog.Warn("Host asset preflight problem", "path", r.Path, "kind", r.Kind,
"status", r.Status.String(), "detail", r.Detail,
"remediation", "run scripts/update-nodes.sh or make reinstall")
@@ -1809,7 +1816,7 @@ func (d *Daemon) startCluster() error {
// background because the first attempt routinely loses the race with
// ovn-central accepting connections, and a node that gives up there stays
// unconfigured while a peer may already require encryption cluster-wide.
go host.MaintainIPSec(d.ctx, d.configPath, d.clusterConfig, d.ipsecBarrier())
go host.MaintainIPSec(d.ctx, d.configPath, d.clusterConfig, d.ipsecBarrier(), hostos.HasLibreswan())
// Keep the host firewall's peer sets in step with cluster membership. Every
// formation path reaches this, which none of the installers do. Runs in the
+13 -5
View File
@@ -774,10 +774,14 @@ func (d *Daemon) WaitForSystemInstance(instanceID string, timeout time.Duration)
return fmt.Errorf("instance %s did not reach running state within %s", instanceID, timeout)
}
// microvmDir holds the microvm kernel, initramfs and firmware. A variable so
// tests can supply their own.
var microvmDir = vm.MicroVMDir
// buildDirectBootConfig constructs a vm.Config for a direct-boot microVM
// launch. It does not call buildBaseVMConfig — the microvm machine type
// requires no PCIe root ports, no UEFI/BIOS, and no drives. Runtime paths
// (PID file, console log, serial socket) are filled in later by startQEMU.
// requires no PCIe root ports, no UEFI, and no drives; it boots qboot. Runtime
// paths (PID file, console log, serial socket) are filled in later by startQEMU.
//
// Network topology from input.NICs is serialised to three fw_cfg tmpfiles
// (netcfg, lb-agent-env, ca-cert) under hostprocess.RuntimeDir().
@@ -794,7 +798,10 @@ func (d *Daemon) buildDirectBootConfig(instanceID string, input *handlers_elbv2.
memMiB = int(instanceTypeMemoryMiB(it))
}
const imagePath = "/usr/share/spinifex/microvm"
firmware := filepath.Join(microvmDir, vm.MicroVMFirmware)
if _, err := os.Stat(firmware); err != nil {
return vm.Config{}, fmt.Errorf("microvm firmware %s is missing, so load balancer instances cannot boot; reinstall Spinifex to restore it: %w", firmware, err)
}
fwCfg, err := d.writeFwCfgBlobs(instanceID, input)
if err != nil {
@@ -846,8 +853,9 @@ func (d *Daemon) buildDirectBootConfig(instanceID string, input *handlers_elbv2.
CPUCount: vcpus,
Memory: memMiB,
MachineType: machineType,
KernelImage: filepath.Join(imagePath, "vmlinuz"),
Initrd: filepath.Join(imagePath, "initramfs.cpio.gz"),
BIOS: firmware,
KernelImage: filepath.Join(microvmDir, "vmlinuz"),
Initrd: filepath.Join(microvmDir, "initramfs.cpio.gz"),
KernelCmdline: cmdline,
FwCfg: fwCfg,
}
@@ -804,6 +804,37 @@ func TestBuildDirectBootConfig_InstanceTypeShape(t *testing.T) {
assert.Len(t, cfg.FwCfg, 3)
}
func TestBuildDirectBootConfig_BootsTheShippedQboot(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
d := &Daemon{resourceMgr: &ResourceManager{}}
cfg, err := d.buildDirectBootConfig("i-qboot", &handlers_elbv2.SystemInstanceInput{
NICs: []handlers_elbv2.NICConfig{{MAC: "02:00:00:00:00:05", IsDefault: true}},
})
require.NoError(t, err)
assert.Equal(t, filepath.Join(microvmDir, vm.MicroVMFirmware), cfg.BIOS)
for _, e := range cfg.FwCfg {
assert.NoError(t, os.Remove(e.File))
}
}
func TestBuildDirectBootConfig_MissingFirmwareFailsByName(t *testing.T) {
runtimeDir := t.TempDir()
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
orig := microvmDir
microvmDir = t.TempDir()
t.Cleanup(func() { microvmDir = orig })
_, err := (&Daemon{resourceMgr: &ResourceManager{}}).buildDirectBootConfig("i-nofw", &handlers_elbv2.SystemInstanceInput{
NICs: []handlers_elbv2.NICConfig{{MAC: "02:00:00:00:00:06", IsDefault: true}},
})
require.Error(t, err)
assert.Contains(t, err.Error(), filepath.Join(microvmDir, vm.MicroVMFirmware))
entries, readErr := os.ReadDir(runtimeDir)
require.NoError(t, readErr)
assert.Empty(t, entries, "a launch refused for missing firmware must leave no fw_cfg blobs behind")
}
func TestBuildDirectBootConfig_FwCfgWriteError(t *testing.T) {
notADir := filepath.Join(t.TempDir(), "file")
require.NoError(t, os.WriteFile(notADir, nil, 0o600))
+16
View File
@@ -3,10 +3,12 @@ package daemon
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/nats-io/nats-server/v2/server"
)
@@ -31,6 +33,19 @@ func TestMain(m *testing.M) {
// has its own tests that inject a reader.
os.Setenv("SPINIFEX_ADMISSION_LIVE_MEM", "0")
// System-instance launches refuse to build a config without the microvm
// firmware, which a test host does not have installed.
fwDir, err := os.MkdirTemp("", "microvm-*")
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create microvm fixture dir: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(filepath.Join(fwDir, vm.MicroVMFirmware), []byte("qboot"), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write microvm firmware fixture: %v\n", err)
os.Exit(1)
}
microvmDir = fwDir
// Start a plain NATS server (no JetStream)
ns, err := server.NewServer(&server.Options{
Host: "127.0.0.1",
@@ -88,6 +103,7 @@ func TestMain(m *testing.M) {
ns.Shutdown()
jsNS.Shutdown()
os.RemoveAll(jsTmpDir)
os.RemoveAll(fwDir)
os.Exit(code)
}
+30 -12
View File
@@ -75,6 +75,10 @@ const (
// strongswanStarterUnit would start a second charon competing for UDP
// 500/4500. Nothing here needs it, so it stays off in both states.
strongswanStarterUnit = "strongswan-starter.service"
// libreswanUnit runs pluto, which listens on UDP 500/4500 once started. On
// Debian the same name is an alias of strongswanStarterUnit.
libreswanUnit = "ipsec.service"
)
// ipsecStateHelper is the fixed-verb root helper installed by setup.sh. The
@@ -89,19 +93,24 @@ var ipsecStateHelper = "/usr/local/lib/spinifex/spinifex-set-ipsec-state"
// accepting connections, and dropping that attempt leaves the node's IPsec
// unconfigured until something restarts the daemon — while another node may
// already have made encryption mandatory cluster-wide.
func MaintainIPSec(ctx context.Context, configPath string, clusterConfig *config.ClusterConfig, barrier IPSecBarrier) {
//
// libreswan reports that the host's IPsec stack is libreswan, which OVN IPsec
// does not run on yet, so such a host keeps IPsec off whatever the cluster asks.
func MaintainIPSec(ctx context.Context, configPath string, clusterConfig *config.ClusterConfig, barrier IPSecBarrier, libreswan bool) {
// Said once, because a pass that reconciles to nothing logs nothing, which in
// a journal is indistinguishable from a node that armed.
switch {
case clusterConfig == nil:
slog.Info("ipsec: cluster membership unknown, leaving the host's IPsec services alone")
case !ipsecWanted(clusterConfig):
case ipsecRequested(clusterConfig) && libreswan:
slog.Error("ipsec: the cluster requires IPsec, but this host's IPsec stack is libreswan, which Spinifex cannot run OVN IPsec on yet; IPsec stays off here")
case !ipsecWanted(clusterConfig, libreswan):
slog.Info("ipsec: not in use on this cluster, IKE and NAT-T listeners stay stopped")
}
delay, announced := ipsecRetryDelay, false
for {
err := ReconcileOVNIPSec(ctx, configPath, clusterConfig, barrier)
err := ReconcileOVNIPSec(ctx, configPath, clusterConfig, barrier, libreswan)
switch {
case err != nil:
delay = min(delay*2, ipsecReconcileInterval)
@@ -110,7 +119,7 @@ func MaintainIPSec(ctx context.Context, configPath string, clusterConfig *config
// Only the first success is news. The steady state re-runs every
// interval for the lifetime of the daemon, and saying so each time
// buries the passes that changed something.
if !announced && ipsecWanted(clusterConfig) {
if !announced && ipsecWanted(clusterConfig, libreswan) {
slog.Info("ipsec: local IPsec configuration complete on this node")
announced = true
}
@@ -125,26 +134,32 @@ func MaintainIPSec(ctx context.Context, configPath string, clusterConfig *config
}
}
// ipsecWanted reports the cluster's intent. Single-node clusters have no Geneve
// ipsecWanted reports whether this host turns IPsec on: the cluster asks for
// it and the host's IPsec stack can run it.
func ipsecWanted(clusterConfig *config.ClusterConfig, libreswan bool) bool {
return ipsecRequested(clusterConfig) && !libreswan
}
// ipsecRequested reports the cluster's intent. Single-node clusters have no Geneve
// tunnels to protect, so charon must not be left listening on 500/4500 there
// even though ipsec_enabled defaults to true.
func ipsecWanted(clusterConfig *config.ClusterConfig) bool {
func ipsecRequested(clusterConfig *config.ClusterConfig) bool {
return clusterConfig != nil && clusterConfig.Network.IPSecEnabled && len(clusterConfig.Nodes) > 1
}
// ReconcileOVNIPSec brings the host's IPsec services in line with the cluster
// config, then enables OVN IPsec if it is wanted. Runs on every startup so the
// disabled case is reached too, which EnableOVNIPSec alone never is.
func ReconcileOVNIPSec(ctx context.Context, configPath string, clusterConfig *config.ClusterConfig, barrier IPSecBarrier) error {
func ReconcileOVNIPSec(ctx context.Context, configPath string, clusterConfig *config.ClusterConfig, barrier IPSecBarrier, libreswan bool) error {
// A nil config means the intent is unknown; leave the host's services alone
// rather than guessing and tearing down working tunnels.
if clusterConfig == nil {
return nil
}
want := ipsecWanted(clusterConfig)
want := ipsecWanted(clusterConfig, libreswan)
if err := ensureIPSecServices(want); err != nil {
if err := ensureIPSecServices(want, libreswan); err != nil {
return err
}
if !want {
@@ -189,8 +204,8 @@ func skipUnlessNBReadable(err error) error {
// ensureIPSecServices runs the helper only when the host does not already match
// the wanted state, so a steady-state startup executes nothing.
func ensureIPSecServices(want bool) error {
if ipsecServicesMatch(want) {
func ensureIPSecServices(want, libreswan bool) error {
if ipsecServicesMatch(want, libreswan) {
return nil
}
@@ -206,10 +221,13 @@ func ensureIPSecServices(want bool) error {
return nil
}
func ipsecServicesMatch(want bool) bool {
func ipsecServicesMatch(want, libreswan bool) bool {
if unitIsEnabled(strongswanStarterUnit) || unitIsActive(strongswanStarterUnit) {
return false
}
if !want && libreswan && (unitIsEnabled(libreswanUnit) || unitIsActive(libreswanUnit)) {
return false
}
return unitIsEnabled(ovsIPSecUnit) == want && unitIsActive(ovsIPSecUnit) == want
}
@@ -355,7 +355,7 @@ func TestReconcileOVNIPSec_DisabledStopsCharon(t *testing.T) {
recorder := &recordingSudo{}
t.Cleanup(hostcommand.SetSudoCommandForTest(recorder.stub))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady()))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady(), false))
assert.Equal(t, [][]string{{ipsecStateHelper, "off"}}, recorder.helperRuns())
@@ -370,7 +370,7 @@ func TestReconcileOVNIPSec_NeverCallsSystemctlDirectly(t *testing.T) {
recorder := &recordingSudo{}
t.Cleanup(hostcommand.SetSudoCommandForTest(recorder.stub))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady()))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady(), false))
for _, run := range recorder.runs {
if run[0] != "systemctl" {
@@ -390,7 +390,7 @@ func TestReconcileOVNIPSec_SingleNodeStopsCharon(t *testing.T) {
cfg := &config.ClusterConfig{Node: "node1", Nodes: map[string]config.Config{"node1": {}}}
cfg.Network.IPSecEnabled = true
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", cfg, allReady()))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", cfg, allReady(), false))
assert.Equal(t, [][]string{{ipsecStateHelper, "off"}}, recorder.helperRuns())
}
@@ -411,7 +411,7 @@ func TestReconcileOVNIPSec_EnabledTurnsTheServicesOn(t *testing.T) {
recorder.nbUnreachable = true
configPath := filepath.Join(ipsecTestConfigDir(t), "spinifex.toml")
require.NoError(t, ReconcileOVNIPSec(t.Context(), configPath, multiNodeIPSecConfig(true), allReady()))
require.NoError(t, ReconcileOVNIPSec(t.Context(), configPath, multiNodeIPSecConfig(true), allReady(), false))
assert.Equal(t, [][]string{{ipsecStateHelper, "on"}}, recorder.helperRuns())
@@ -432,7 +432,7 @@ func TestReconcileOVNIPSec_AlreadyInStateIsNoOp(t *testing.T) {
}
t.Cleanup(hostcommand.SetSudoCommandForTest(recorder.stub))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady()))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady(), false))
assert.Empty(t, recorder.helperRuns())
}
@@ -449,10 +449,85 @@ func TestReconcileOVNIPSec_UnknownUnitIsNotAnError(t *testing.T) {
}
t.Cleanup(hostcommand.SetSudoCommandForTest(recorder.stub))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady()))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady(), false))
assert.Empty(t, recorder.helperRuns())
}
// ipsecOffHost is a host already in the off state: both IPsec units masked
// and stopped, with strongSwan absent.
func ipsecOffHost() *recordingSudo {
return &recordingSudo{
enabledOutput: map[string]string{
strongswanStarterUnit: "Failed to get unit file state for strongswan-starter.service: No such file or directory\n",
ovsIPSecUnit: "masked\n",
libreswanUnit: "masked\n",
},
activePerUnit: map[string]string{
strongswanStarterUnit: "inactive\n",
ovsIPSecUnit: "inactive\n",
libreswanUnit: "inactive\n",
},
}
}
// pluto started by hand listens on UDP 500/4500 with openvswitch-ipsec still
// masked, so the off state has to look at libreswan's own unit too.
func TestReconcileOVNIPSec_LibreswanStartedByHandIsStopped(t *testing.T) {
for _, tc := range []struct{ name, enabled, active string }{
{name: "active", enabled: "masked\n", active: "active\n"},
{name: "enabled", enabled: "enabled\n", active: "inactive\n"},
} {
t.Run(tc.name, func(t *testing.T) {
recorder := ipsecOffHost()
recorder.enabledOutput[libreswanUnit] = tc.enabled
recorder.activePerUnit[libreswanUnit] = tc.active
t.Cleanup(hostcommand.SetSudoCommandForTest(recorder.stub))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady(), true))
assert.Equal(t, [][]string{{ipsecStateHelper, "off"}}, recorder.helperRuns())
})
}
}
func TestReconcileOVNIPSec_LibreswanAlreadyOffIsNoOp(t *testing.T) {
recorder := ipsecOffHost()
t.Cleanup(hostcommand.SetSudoCommandForTest(recorder.stub))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady(), true))
assert.Empty(t, recorder.helperRuns())
}
// OVN IPsec does not run on libreswan yet, so a cluster that asks for it gets
// the off state on such a host rather than a half-armed one.
func TestReconcileOVNIPSec_LibreswanStaysOffWhenTheClusterAsksForIPsec(t *testing.T) {
recorder := ipsecOffHost()
recorder.activePerUnit[libreswanUnit] = "active\n"
t.Cleanup(hostcommand.SetSudoCommandForTest(recorder.stub))
configPath := filepath.Join(ipsecTestConfigDir(t), "spinifex.toml")
require.NoError(t, ReconcileOVNIPSec(t.Context(), configPath, multiNodeIPSecConfig(true), allReady(), true))
assert.Equal(t, [][]string{{ipsecStateHelper, "off"}}, recorder.helperRuns())
for _, run := range recorder.runs {
assert.NotEqual(t, "ovs-vsctl", run[0], "must not configure OVS IPsec on libreswan: %v", run)
}
}
// On Debian ipsec.service is an alias of strongswan-starter, which is already
// checked under its own name; reading the alias again must not change the answer.
func TestReconcileOVNIPSec_WithoutLibreswanIPsecServiceIsNotRead(t *testing.T) {
recorder := ipsecOffHost()
recorder.enabledOutput[libreswanUnit] = "alias\n"
recorder.activePerUnit[libreswanUnit] = "active\n"
t.Cleanup(hostcommand.SetSudoCommandForTest(recorder.stub))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", multiNodeIPSecConfig(false), allReady(), false))
assert.Empty(t, recorder.helperRuns())
for _, run := range recorder.runs {
assert.NotContains(t, run, libreswanUnit, "read libreswan's unit on a host without libreswan: %v", run)
}
}
// A nil cluster config means the intent is unknown; tearing down working
// tunnels on a guess would be worse than leaving them.
func TestReconcileOVNIPSec_NilConfigLeavesUnitsAlone(t *testing.T) {
@@ -461,7 +536,7 @@ func TestReconcileOVNIPSec_NilConfigLeavesUnitsAlone(t *testing.T) {
return exec.Command("true")
}))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", nil, allReady()))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", nil, allReady(), false))
}
// A single attempt is what left two of three chassis unconfigured for minutes
@@ -481,7 +556,7 @@ func TestMaintainIPSec_RetriesAFailedPass(t *testing.T) {
defer cancel()
done := make(chan struct{})
go func() {
MaintainIPSec(ctx, configPath, multiNodeIPSecConfig(true), barrier)
MaintainIPSec(ctx, configPath, multiNodeIPSecConfig(true), barrier, false)
close(done)
}()
@@ -587,7 +662,7 @@ func TestReconcileOVNIPSec_DisabledRetractsNBGlobal(t *testing.T) {
barrier := allReady()
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml",
multiNodeIPSecConfig(false), barrier))
multiNodeIPSecConfig(false), barrier, false))
assert.Equal(t, [][]string{{"ovn-nbctl", "--timeout=5", "set", "NB_Global", ".", "ipsec=false"}},
recorder.nbctlWrites())
@@ -601,7 +676,7 @@ func TestReconcileOVNIPSec_DisabledIsANoOpWhenAlreadyReleased(t *testing.T) {
t.Cleanup(hostcommand.SetSudoCommandForTest(recorder.stub))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml",
multiNodeIPSecConfig(false), allReady()))
multiNodeIPSecConfig(false), allReady(), false))
assert.Empty(t, recorder.nbctlWrites())
}
@@ -685,7 +760,7 @@ func TestReconcileOVNIPSec_UsesTheConfiguredNBRemotes(t *testing.T) {
cfg := multiNodeIPSecConfig(false)
cfg.Nodes["node1"] = config.Config{VPCD: config.VPCDConfig{OVNNBAddr: nb}}
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", cfg, allReady()))
require.NoError(t, ReconcileOVNIPSec(t.Context(), "/etc/spinifex/spinifex.toml", cfg, allReady(), false))
assert.Equal(t, [][]string{{"ovn-nbctl", "--db=" + nb, "--no-leader-only", "--timeout=5", "get", "NB_Global", ".", "ipsec"}},
recorder.nbctlReads())
+7 -2
View File
@@ -28,6 +28,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/runtime/formation"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
toml "github.com/pelletier/go-toml/v2"
"gopkg.in/ini.v1"
)
@@ -245,8 +246,12 @@ func GenerateCertificatesIfNeeded(configDir string, force bool, bindIP string, a
// Print manual instructions only when not root (root gets auto-install)
if os.Getuid() != 0 {
fmt.Println("\n📋 To trust the Spinifex CA system-wide (recommended):")
fmt.Printf(" sudo cp %s /usr/local/share/ca-certificates/spinifex-ca.crt\n", caCertPath)
fmt.Println(" sudo update-ca-certificates")
if trust, err := hostos.SystemCATrust(); err == nil {
fmt.Printf(" sudo cp %s %s\n", caCertPath, filepath.Join(trust.AnchorDir, "spinifex-ca.crt"))
fmt.Printf(" sudo %s\n", trust.Update.Name)
} else {
fmt.Printf(" Add %s to the host trust store (%v)\n", caCertPath, err)
}
fmt.Println("\n This allows AWS CLI and other tools to trust Spinifex services automatically.")
}
} else {
+82 -33
View File
@@ -48,6 +48,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/runtime/formation"
hostcommand "github.com/mulgadc/spinifex/spinifex/runtime/host/command"
"github.com/mulgadc/spinifex/spinifex/runtime/host/dns"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/pterm/pterm"
@@ -407,7 +408,7 @@ func init() {
adminInitCmd.Flags().StringSlice("external-dns", nil, "Comma-separated IPv4 DNS servers handed to guests on the external pool, at most 3 (auto-detected from the host resolver when omitted)")
adminInitCmd.Flags().Int("external-prefix-len", 24, "External pool subnet prefix length (auto-detected)")
adminInitCmd.Flags().Bool("gpu-passthrough", false, "Enable VFIO GPU passthrough (sets gpu_passthrough = true in daemon config)")
adminInitCmd.Flags().Bool("ipsec", true, "Encrypt intra-AZ Geneve via OVN native IPsec (cluster-wide); disable only for trusted single-rack lab")
adminInitCmd.Flags().Bool("ipsec", true, "Encrypt intra-AZ Geneve via OVN native IPsec (cluster-wide); disable only for trusted single-rack lab. Defaults to false, and true is refused, on a host whose IPsec stack is libreswan")
adminInitCmd.Flags().Bool("skip-host-dns", false, "Do not point this node's host resolver at its local northstar (LB/EKS names then won't resolve from the node)")
// Flags for admin join
@@ -1270,6 +1271,20 @@ func validateNodeCount(nodes int) error {
return nil
}
// resolveIPSecForHost applies the host's IPsec stack to the --ipsec choice. On
// libreswan IPsec defaults to off and an explicit request for it is refused, so
// nobody builds a cluster believing its underlay is encrypted.
func resolveIPSecForHost(requested, explicit, libreswan bool) (bool, error) {
if !libreswan {
return requested, nil
}
if explicit && requested {
return false, fmt.Errorf("true is refused: this host's IPsec stack is libreswan (%s), and Spinifex cannot run OVN IPsec on libreswan yet; run without --ipsec, which defaults to false here",
hostos.LibreswanPlutoPath)
}
return false, nil
}
// TODO: Move all logic to a module, use minimal application logic in viper commands.
func runAdminInit(cmd *cobra.Command, args []string) {
if os.Getuid() != 0 {
@@ -1277,6 +1292,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Fprintln(os.Stderr, " Service user setup and CA certificate installation will be skipped.")
fmt.Fprintln(os.Stderr, " For production deployments, run with sudo.")
}
requireCATrust()
force, _ := cmd.Flags().GetBool("force")
configDir, _ := cmd.Flags().GetString("config-dir")
@@ -1335,6 +1351,15 @@ func runAdminInit(cmd *cobra.Command, args []string) {
gatewayIP, _ := cmd.Flags().GetString("gateway-ip")
gpuPassthrough, _ := cmd.Flags().GetBool("gpu-passthrough")
ipsecEnabled, _ := cmd.Flags().GetBool("ipsec")
libreswan := hostos.HasLibreswan()
ipsecEnabled, err := resolveIPSecForHost(ipsecEnabled, cmd.Flags().Changed("ipsec"), libreswan)
if err != nil {
fmt.Fprintf(os.Stderr, "--ipsec: %v\n", err)
os.Exit(1)
}
if libreswan && !cmd.Flags().Changed("ipsec") {
fmt.Println("IPsec: off, because this host's IPsec stack is libreswan, which Spinifex cannot run OVN IPsec on yet")
}
// Fire telemetry in background (completes during init work, waited at end)
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
@@ -1725,7 +1750,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
// Install CA certificate into system trust store
installCACertificate(filepath.Join(configDir, "ca.pem"))
exitOnCATrustError(installCACertificate(filepath.Join(configDir, "ca.pem")))
// Generate NATS token
natsToken, err := admin.GenerateNATSToken()
@@ -2331,6 +2356,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
fmt.Fprintln(os.Stderr, " Service user setup and CA certificate installation will be skipped.")
fmt.Fprintln(os.Stderr, " For production deployments, run with sudo.")
}
requireCATrust()
node, _ := cmd.Flags().GetString("node")
leaderHost, _ := cmd.Flags().GetString("host")
@@ -2630,7 +2656,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
fmt.Printf("✅ CA certificate received from leader: %s\n", caCertPath)
// Install CA certificate into system trust store
installCACertificate(caCertPath)
exitOnCATrustError(installCACertificate(caCertPath))
// Extract and write master key from formation server
if statusResp.MasterKey == "" {
@@ -3271,7 +3297,7 @@ func hostDNSParams(settings admin.ConfigSettings, skip bool) (hostdns.Params, bo
// configureHostDNS points this node's host resolver at its own northstar so the
// Spinifex authoritative zones (ELB/EKS names) resolve from the node itself on
// every install path. Failures are surfaced loudly but never abort a formed
// cluster, mirroring installCACertificate.
// cluster.
func configureHostDNS(settings admin.ConfigSettings, skip bool) {
params, ok := hostDNSParams(settings, skip)
if !ok {
@@ -3740,41 +3766,64 @@ func parseDNSFromResolvectl(output string) []string {
return servers
}
// installCACertificate copies the Spinifex CA certificate into the system
// trust store and runs update-ca-certificates so TLS clients (AWS CLI, etc.)
// trust the self-signed gateway certificate without extra configuration.
func installCACertificate(caPemPath string) {
// caTrustFile is the Spinifex CA's file name in the host's anchor directory.
const caTrustFile = "spinifex-ca.crt"
// installCACertificate adds the Spinifex CA to the host trust store and rebuilds
// the bundle, so TLS clients (AWS CLI, etc.) trust the gateway certificate
// without extra configuration. Without root it does nothing.
func installCACertificate(caPemPath string) error {
if os.Getuid() != 0 {
return nil
}
trust, err := hostos.SystemCATrust()
if err != nil {
return err
}
return trustCACertificate(caPemPath, trust, func(path string) error {
cmd := exec.Command(path)
cmd.Stdout = io.Discard
cmd.Stderr = os.Stderr
return cmd.Run()
})
}
func trustCACertificate(caPemPath string, trust hostos.CATrust, update func(path string) error) error {
data, err := os.ReadFile(caPemPath)
if err != nil {
return fmt.Errorf("read CA certificate: %w", err)
}
dest := filepath.Join(trust.AnchorDir, caTrustFile)
if err := os.MkdirAll(trust.AnchorDir, 0755); err != nil { //nolint:gosec // G301: system CA trust-store directory must be 0755 for every TLS client to read it. Contains no secret material.
return fmt.Errorf("create %s: %w", trust.AnchorDir, err)
}
if err := os.WriteFile(dest, data, 0644); err != nil { //nolint:gosec // G306: public CA certificate in the system trust store must be world-readable (0644); no private key is written here.
return fmt.Errorf("write %s: %w", dest, err)
}
if err := update(trust.Update.Path); err != nil {
return fmt.Errorf("%s failed after writing %s: %w", trust.Update.Name, dest, err)
}
fmt.Printf("✅ CA certificate installed to system trust store (%s)\n", dest)
return nil
}
// requireCATrust fails before init or join writes anything when the host has
// no single trust store to add the CA to, so neither stops halfway on it.
func requireCATrust() {
if os.Getuid() != 0 {
return
}
_, err := hostos.SystemCATrust()
exitOnCATrustError(err)
}
const systemCertPath = "/usr/local/share/ca-certificates/spinifex-ca.crt"
data, err := os.ReadFile(caPemPath)
// exitOnCATrustError stops the command when the Spinifex CA could not be
// trusted, since every AWS CLI call on the node would then fail TLS.
func exitOnCATrustError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not read CA certificate %s: %v\n", caPemPath, err)
return
fmt.Fprintf(os.Stderr, "❌ Error: could not add the Spinifex CA to the host trust store: %v\n", err)
os.Exit(1)
}
if err := os.MkdirAll(filepath.Dir(systemCertPath), 0755); err != nil { //nolint:gosec // G301: system CA trust-store directory must be 0755 for every TLS client to read it. Contains no secret material.
fmt.Fprintf(os.Stderr, "Warning: could not create certificate directory: %v\n", err)
return
}
if err := os.WriteFile(systemCertPath, data, 0644); err != nil { //nolint:gosec // G306: public CA certificate in the system trust store must be world-readable (0644); no private key is written here.
fmt.Fprintf(os.Stderr, "Warning: could not install CA certificate: %v\n", err)
return
}
cmd := exec.Command("update-ca-certificates")
cmd.Stdout = io.Discard
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: update-ca-certificates failed: %v\n", err)
return
}
fmt.Printf("✅ CA certificate installed to system trust store\n")
}
// runAdminBanner writes the Spinifex console banner to /etc/motd.
@@ -0,0 +1,64 @@
package cli
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
)
func TestTrustCACertificate(t *testing.T) {
src := filepath.Join(t.TempDir(), "ca.pem")
if err := os.WriteFile(src, []byte("CA PEM"), 0o600); err != nil {
t.Fatal(err)
}
for name, tc := range map[string]struct {
update string
anchor string
updateErr error
wantErr string
}{
"Debian trust store": {update: "update-ca-certificates", anchor: "usr/local/share/ca-certificates"},
"EL trust store": {update: "update-ca-trust", anchor: "etc/pki/ca-trust/source/anchors"},
"update fails": {update: "update-ca-trust", anchor: "anchors", updateErr: errors.New("exit status 1"), wantErr: "update-ca-trust failed"},
} {
t.Run(name, func(t *testing.T) {
trust := hostos.CATrust{
Update: hostos.Tool{Name: tc.update, Path: "/usr/bin/" + tc.update},
AnchorDir: filepath.Join(t.TempDir(), tc.anchor),
}
var ran string
err := trustCACertificate(src, trust, func(path string) error { ran = path; return tc.updateErr })
if ran != trust.Update.Path {
t.Errorf("ran %q, want %q", ran, trust.Update.Path)
}
got, readErr := os.ReadFile(filepath.Join(trust.AnchorDir, caTrustFile))
if readErr != nil || string(got) != "CA PEM" {
t.Errorf("anchor file = %q, %v; want the CA written before the update runs", got, readErr)
}
if tc.wantErr == "" {
if err != nil {
t.Fatalf("trustCACertificate: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("err = %v, want it to contain %q", err, tc.wantErr)
}
})
}
}
func TestTrustCACertificate_UnreadableSourceRunsNoUpdate(t *testing.T) {
trust := hostos.CATrust{Update: hostos.Tool{Name: "update-ca-trust", Path: "/usr/bin/update-ca-trust"}, AnchorDir: t.TempDir()}
err := trustCACertificate(filepath.Join(t.TempDir(), "missing.pem"), trust, func(string) error {
t.Error("update ran without a CA to trust")
return nil
})
if err == nil {
t.Fatal("expected an error for a missing CA certificate")
}
}
+77 -106
View File
@@ -6,7 +6,6 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
@@ -44,9 +43,11 @@ var adminGpuDisableCmd = &cobra.Command{
var adminGpuSetupCmd = &cobra.Command{
Use: "setup",
Short: "Configure this host for GPU passthrough (IOMMU, vfio-pci, nouveau blacklist)",
Long: `Idempotent host setup for GPU passthrough. Run once before a reboot to configure
GRUB, vfio-pci early binding, and the nouveau blacklist; then run again after the
reboot to verify bindings and enable GPU passthrough in the daemon.
Long: `Idempotent host setup for GPU passthrough. Run once before a reboot to add the
IOMMU kernel arguments (update-grub or grubby), load vfio-pci from the initramfs
(update-initramfs or dracut) and blacklist nouveau; then run again after the reboot
to verify that every function of each GPU is bound to vfio-pci and enable GPU
passthrough in the daemon.
Must be run as root.`,
Run: runAdminGpuSetup,
@@ -279,6 +280,13 @@ func runAdminGpuSetup(_ *cobra.Command, _ []string) {
os.Exit(1)
}
// Resolve the boot tools first, so a host missing one stops before any change.
boot, err := newGPUBoot()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("==> Detecting GPU")
devices, err := gpu.Discover()
if err != nil {
@@ -294,7 +302,7 @@ func runAdminGpuSetup(_ *cobra.Command, _ []string) {
}
fmt.Println("==> Collecting PCI IDs for vfio-pci")
ids := gpuSetupCollectVFIOIDs(devices)
ids := gpuSetupCollectVFIOIDs("/sys", devices)
idsCSV := strings.Join(ids, ",")
fmt.Printf(" IDs: %s\n", idsCSV)
@@ -305,21 +313,8 @@ func runAdminGpuSetup(_ *cobra.Command, _ []string) {
if len(iommuEntries) > 0 {
fmt.Println(" Active")
} else {
params := gpuSetupIOMMUParams()
changed, err := gpuSetupAddGRUBParams(params)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to update GRUB: %v\n", err)
os.Exit(1)
}
if changed {
fmt.Printf(" GRUB updated with: %s\n", params)
} else {
fmt.Println(" GRUB already has IOMMU params — refreshing")
}
ug := exec.Command("update-grub")
ug.Stdout, ug.Stderr = os.Stdout, os.Stderr
if err := ug.Run(); err != nil {
fmt.Fprintf(os.Stderr, "update-grub failed: %v\n", err)
if err := boot.setKernelArgs(gpuSetupIOMMUParams()); err != nil {
fmt.Fprintf(os.Stderr, "Failed to set the IOMMU kernel arguments: %v\n", err)
os.Exit(1)
}
rebootNeeded = true
@@ -341,7 +336,8 @@ func runAdminGpuSetup(_ *cobra.Command, _ []string) {
}
fmt.Println("==> Configuring nouveau blacklist")
const nouveauConf = "/etc/modprobe.d/blacklist-nouveau.conf"
modprobeFiles := []string{vfioPCIConfName, nouveauConfName}
nouveauConf := filepath.Join(modprobeDir, nouveauConfName)
if _, err := os.Stat(nouveauConf); os.IsNotExist(err) {
if err := os.WriteFile(nouveauConf, []byte("blacklist nouveau\noptions nouveau modeset=0\n"), 0o644); err != nil { //nolint:gosec // G306: modprobe.d blacklist is conventionally world-readable (0644). Contains no secret material.
fmt.Fprintf(os.Stderr, "Failed to write nouveau blacklist: %v\n", err)
@@ -354,7 +350,7 @@ func runAdminGpuSetup(_ *cobra.Command, _ []string) {
}
fmt.Println("==> Configuring amdgpu blacklist")
const amdgpuConf = "/etc/modprobe.d/blacklist-amdgpu.conf"
amdgpuConf := filepath.Join(modprobeDir, amdgpuConfName)
hasAMD := false
for _, d := range devices {
if d.Vendor == gpu.VendorAMD {
@@ -363,6 +359,7 @@ func runAdminGpuSetup(_ *cobra.Command, _ []string) {
}
}
if hasAMD {
modprobeFiles = append(modprobeFiles, amdgpuConfName)
if _, err := os.Stat(amdgpuConf); os.IsNotExist(err) {
if err := os.WriteFile(amdgpuConf, []byte("blacklist amdgpu\n"), 0o644); err != nil { //nolint:gosec // G306: modprobe.d blacklist is conventionally world-readable (0644). Contains no secret material.
fmt.Fprintf(os.Stderr, "Failed to write amdgpu blacklist: %v\n", err)
@@ -378,7 +375,7 @@ func runAdminGpuSetup(_ *cobra.Command, _ []string) {
}
fmt.Println("==> Configuring vfio-pci early binding")
const vfioPCIConf = "/etc/modprobe.d/vfio-pci.conf"
vfioPCIConf := filepath.Join(modprobeDir, vfioPCIConfName)
existing, _ := os.ReadFile(vfioPCIConf)
if !strings.Contains(string(existing), "ids="+idsCSV) {
if err := os.WriteFile(vfioPCIConf, []byte("options vfio-pci ids="+idsCSV+"\n"), 0o644); err != nil { //nolint:gosec // G306: modprobe.d vfio-pci options are conventionally world-readable (0644). Contains no secret material.
@@ -391,32 +388,17 @@ func runAdminGpuSetup(_ *cobra.Command, _ []string) {
fmt.Printf(" Already configured for %s\n", idsCSV)
}
const initramfsModules = "/etc/initramfs-tools/modules"
modData, _ := os.ReadFile(initramfsModules)
if !strings.Contains(string(modData), "vfio_pci") {
f, err := os.OpenFile(initramfsModules, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) //nolint:gosec // G302: initramfs modules list is conventionally world-readable (0644). Contains no secret material.
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to open initramfs modules: %v\n", err)
os.Exit(1)
}
_, werr := fmt.Fprint(f, "\n# vfio early binding for GPU passthrough\nvfio\nvfio_iommu_type1\nvfio_pci\n")
f.Close()
if werr != nil {
fmt.Fprintf(os.Stderr, "Failed to write initramfs modules: %v\n", werr)
os.Exit(1)
}
fmt.Println(" vfio modules added to initramfs")
if changed, err := boot.configureEarlyVFIO(modprobeFiles); err != nil {
fmt.Fprintf(os.Stderr, "Failed to configure early vfio loading: %v\n", err)
os.Exit(1)
} else if changed {
rebootNeeded = true
} else {
fmt.Println(" vfio modules already in initramfs")
}
if rebootNeeded {
fmt.Println("==> Updating initramfs")
ui := exec.Command("update-initramfs", "-u")
ui.Stdout, ui.Stderr = os.Stdout, os.Stderr
if err := ui.Run(); err != nil {
fmt.Fprintf(os.Stderr, "update-initramfs failed: %v\n", err)
if err := boot.rebuildInitramfs(modprobeFiles); err != nil {
fmt.Fprintf(os.Stderr, "Failed to update the initramfs: %v\n", err)
os.Exit(1)
}
fmt.Println("\nSetup complete — reboot required.")
@@ -433,30 +415,11 @@ func runAdminGpuSetup(_ *cobra.Command, _ []string) {
fmt.Println(" Loaded")
fmt.Println("==> Verifying GPU driver binding")
for _, d := range devices {
current, err := gpu.CurrentDriver(d.PCIAddress)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot read driver for %s: %v\n", d.PCIAddress, err)
os.Exit(1)
}
if current == "vfio-pci" {
fmt.Printf(" %s → vfio-pci\n", d.PCIAddress)
continue
}
if current != "" {
// Bound to native driver — blacklist/initramfs changes may not have taken effect.
fmt.Fprintf(os.Stderr, "%s is bound to %q, not vfio-pci — reboot may be required\n", d.PCIAddress, current)
fmt.Fprintf(os.Stderr, " Check: ls -la /sys/bus/pci/devices/%s/driver\n", d.PCIAddress)
os.Exit(1)
}
// Device is unbound (ids= did not auto-claim it) — bind explicitly via driver_override.
fmt.Printf(" %s unbound — binding to vfio-pci\n", d.PCIAddress)
if _, err := gpu.BindVFIO(d.PCIAddress); err != nil {
fmt.Fprintf(os.Stderr, "Failed to bind %s to vfio-pci: %v\n", d.PCIAddress, err)
fmt.Fprintf(os.Stderr, " Check: ls -la /sys/bus/pci/devices/%s/driver\n", d.PCIAddress)
os.Exit(1)
}
fmt.Printf(" %s → vfio-pci\n", d.PCIAddress)
functions := func(addr string) []string { return gpuSlotFunctions("/sys", addr) }
bind := func(addr string) error { _, err := gpu.BindVFIO(addr); return err }
if err := gpuSetupVerifyBindings(devices, functions, gpu.CurrentDriver, bind); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
fmt.Println("==> Enabling GPU passthrough")
@@ -734,20 +697,32 @@ func migReloadDaemon() {
fmt.Println("==> Daemon signalled (SIGHUP) — MIG pool will be rebuilt on next request.")
}
// gpuSlotFunctions returns every PCI function in addr's slot: the GPU and
// siblings such as its HDMI audio, which must all bind to vfio-pci together.
func gpuSlotFunctions(sysRoot, addr string) []string {
slot, _, ok := strings.CutLast(addr, ".")
if !ok {
return []string{addr}
}
matches, _ := filepath.Glob(filepath.Join(sysRoot, "bus/pci/devices", slot+".*"))
if len(matches) == 0 {
return []string{addr}
}
functions := make([]string, len(matches))
for i, m := range matches {
functions[i] = filepath.Base(m)
}
return functions
}
// gpuSetupCollectVFIOIDs collects vendor:device PCI ID pairs for each GPU and
// all sibling PCI functions on the same device slot (e.g. GPU + HDMI audio).
// All siblings must bind to vfio-pci together for IOMMU group isolation.
func gpuSetupCollectVFIOIDs(devices []gpu.GPUDevice) []string {
func gpuSetupCollectVFIOIDs(sysRoot string, devices []gpu.GPUDevice) []string {
seen := make(map[string]bool)
var ids []string
for _, d := range devices {
dot := strings.LastIndex(d.PCIAddress, ".")
if dot < 0 {
continue
}
prefix := d.PCIAddress[:dot]
matches, _ := filepath.Glob("/sys/bus/pci/devices/" + prefix + ".*")
for _, m := range matches {
for _, fn := range gpuSlotFunctions(sysRoot, d.PCIAddress) {
m := filepath.Join(sysRoot, "bus/pci/devices", fn)
vb, err1 := os.ReadFile(m + "/vendor")
db, err2 := os.ReadFile(m + "/device")
if err1 != nil || err2 != nil {
@@ -765,6 +740,31 @@ func gpuSetupCollectVFIOIDs(devices []gpu.GPUDevice) []string {
return ids
}
// gpuSetupVerifyBindings checks every function of each GPU is bound to
// vfio-pci, binding any that no driver claimed.
func gpuSetupVerifyBindings(devices []gpu.GPUDevice, functions func(string) []string, driverOf func(string) (string, error), bind func(string) error) error {
for _, d := range devices {
for _, fn := range functions(d.PCIAddress) {
current, err := driverOf(fn)
if err != nil {
return fmt.Errorf("cannot read driver for %s: %w", fn, err)
}
switch current {
case "vfio-pci":
case "":
fmt.Printf(" %s unbound — binding to vfio-pci\n", fn)
if err := bind(fn); err != nil {
return fmt.Errorf("failed to bind %s to vfio-pci: %w\n Check: ls -la /sys/bus/pci/devices/%s/driver", fn, err, fn)
}
default:
return fmt.Errorf("%s is bound to %q, not vfio-pci — reboot may be required\n Check: ls -la /sys/bus/pci/devices/%s/driver", fn, current, fn)
}
fmt.Printf(" %s → vfio-pci\n", fn)
}
}
return nil
}
func gpuSetupIOMMUParams() string {
data, _ := os.ReadFile("/proc/cpuinfo")
if strings.Contains(string(data), "GenuineIntel") {
@@ -772,32 +772,3 @@ func gpuSetupIOMMUParams() string {
}
return "amd_iommu=on iommu=pt"
}
// gpuSetupAddGRUBParams appends iommu kernel params to GRUB_CMDLINE_LINUX_DEFAULT.
// Returns (true, nil) if the file was modified, (false, nil) if params were already present.
func gpuSetupAddGRUBParams(params string) (bool, error) {
const grubFile = "/etc/default/grub"
data, err := os.ReadFile(grubFile)
if err != nil {
return false, fmt.Errorf("read %s: %w", grubFile, err)
}
content := string(data)
if strings.Contains(content, "intel_iommu=on") || strings.Contains(content, "amd_iommu=on") {
return false, nil
}
re := regexp.MustCompile(`(GRUB_CMDLINE_LINUX_DEFAULT="[^"]*)"`)
updated := re.ReplaceAllStringFunc(content, func(match string) string {
groups := re.FindStringSubmatch(match)
if len(groups) < 2 {
return match
}
return groups[1] + " " + params + `"`
})
if updated == content {
return false, fmt.Errorf("GRUB_CMDLINE_LINUX_DEFAULT not found in %s", grubFile)
}
if err := os.WriteFile(grubFile, []byte(updated), 0o644); err != nil { //nolint:gosec // G306: /etc/default/grub is read by grub-mkconfig and is conventionally 0644. Contains no secret material.
return false, fmt.Errorf("write %s: %w", grubFile, err)
}
return true, nil
}
+319
View File
@@ -0,0 +1,319 @@
package cli
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
)
const (
grubDefaultFile = "/etc/default/grub"
kernelCmdlineFile = "/etc/kernel/cmdline"
initramfsModules = "/etc/initramfs-tools/modules"
dracutVFIOConf = "/etc/dracut.conf.d/spinifex-vfio.conf"
dracutForceDrivers = "etc/cmdline.d/20-force_drivers.conf"
modprobeDir = "/etc/modprobe.d"
vfioPCIConfName = "vfio-pci.conf"
nouveauConfName = "blacklist-nouveau.conf"
amdgpuConfName = "blacklist-amdgpu.conf"
initramfsVFIOModule = "vfio_pci"
)
// vfioEarlyModules load from the initramfs, before any GPU driver can claim the card.
var vfioEarlyModules = []string{"vfio", "vfio_iommu_type1", "vfio_pci"}
// gpuBoot changes the kernel command line and the initramfs with whichever
// tools the host uses. Paths are joined under root so tests run in a temp dir.
type gpuBoot struct {
tools hostos.BootTools
root string
run func(name string, args ...string) error
output func(name string, args ...string) ([]byte, error)
}
func newGPUBoot() (*gpuBoot, error) {
tools, err := hostos.SystemBootTools()
if err != nil {
return nil, err
}
if tools.Initramfs.Name == "dracut" {
if _, err := exec.LookPath("lsinitrd"); err != nil {
return nil, fmt.Errorf("lsinitrd, which checks the images dracut builds, is not on PATH: %w", err)
}
}
return &gpuBoot{
tools: tools,
run: func(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return cmd.Run()
},
output: func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output()
},
}, nil
}
func (b *gpuBoot) path(p string) string { return filepath.Join(b.root, p) }
// setKernelArgs adds the IOMMU parameters to every boot entry and to the
// command line that newly installed kernels get.
func (b *gpuBoot) setKernelArgs(params string) error {
if b.tools.Bootloader.Name != "grubby" {
changed, err := gpuSetupAddGRUBParams(b.path(grubDefaultFile), params)
if err != nil {
return err
}
if changed {
fmt.Printf(" GRUB updated with: %s\n", params)
} else {
fmt.Println(" GRUB already has IOMMU params — refreshing")
}
if err := b.run(b.tools.Bootloader.Path); err != nil {
return fmt.Errorf("%s failed: %w", b.tools.Bootloader.Name, err)
}
return nil
}
// --update-kernel=ALL also writes /etc/kernel/cmdline, which kernel-install
// copies into the boot entry of every kernel installed later.
if err := b.run(b.tools.Bootloader.Path, "--update-kernel=ALL", "--args="+params); err != nil {
return fmt.Errorf("grubby --update-kernel=ALL failed: %w", err)
}
info, err := b.output(b.tools.Bootloader.Path, "--info=DEFAULT")
if err != nil {
return fmt.Errorf("grubby --info=DEFAULT failed: %w", err)
}
if missing := missingArgs(grubbyArgs(string(info)), params); len(missing) > 0 {
return fmt.Errorf("the default boot entry lacks %s after grubby --update-kernel=ALL", strings.Join(missing, " "))
}
cmdline, err := os.ReadFile(b.path(kernelCmdlineFile))
if err != nil {
return fmt.Errorf("read %s, which new kernels take their arguments from: %w", kernelCmdlineFile, err)
}
if missing := missingArgs(string(cmdline), params); len(missing) > 0 {
return fmt.Errorf("%s lacks %s, so newly installed kernels would boot without them", kernelCmdlineFile, strings.Join(missing, " "))
}
fmt.Printf(" Every boot entry and %s carry: %s\n", kernelCmdlineFile, params)
return nil
}
// configureEarlyVFIO makes the initramfs load vfio-pci and carry its options
// and the driver blacklists. It reports whether it changed any file.
func (b *gpuBoot) configureEarlyVFIO(modprobeFiles []string) (bool, error) {
if b.tools.Initramfs.Name == "dracut" {
want := dracutVFIOConfig(modprobeFiles)
if have, _ := os.ReadFile(b.path(dracutVFIOConf)); string(have) == want {
fmt.Printf(" %s already configured\n", dracutVFIOConf)
return false, nil
}
if err := os.MkdirAll(filepath.Dir(b.path(dracutVFIOConf)), 0o755); err != nil { //nolint:gosec // G301: dracut.conf.d is a conventional 0755 config directory.
return false, err
}
if err := os.WriteFile(b.path(dracutVFIOConf), []byte(want), 0o644); err != nil { //nolint:gosec // G306: dracut config is conventionally world-readable (0644). Contains no secret material.
return false, fmt.Errorf("write %s: %w", dracutVFIOConf, err)
}
fmt.Printf(" %s written\n", dracutVFIOConf)
return true, nil
}
modData, _ := os.ReadFile(b.path(initramfsModules))
if strings.Contains(string(modData), initramfsVFIOModule) {
fmt.Println(" vfio modules already in initramfs")
return false, nil
}
f, err := os.OpenFile(b.path(initramfsModules), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) //nolint:gosec // G302: initramfs modules list is conventionally world-readable (0644). Contains no secret material.
if err != nil {
return false, fmt.Errorf("open initramfs modules: %w", err)
}
_, werr := fmt.Fprintf(f, "\n# vfio early binding for GPU passthrough\n%s\n", strings.Join(vfioEarlyModules, "\n"))
if cerr := f.Close(); werr == nil {
werr = cerr
}
if werr != nil {
return false, fmt.Errorf("write initramfs modules: %w", werr)
}
fmt.Println(" vfio modules added to initramfs")
return true, nil
}
// dracutVFIOConfig uses force_drivers because add_drivers only copies a module
// into the image; force_drivers also loads it before udev probes devices.
func dracutVFIOConfig(modprobeFiles []string) string {
var items []string
for _, name := range modprobeFiles {
items = append(items, filepath.Join(modprobeDir, name))
}
return "# Written by spx admin gpu setup: load vfio-pci before any GPU driver.\n" +
"force_drivers+=\" " + strings.Join(vfioEarlyModules, " ") + " \"\n" +
"install_items+=\" " + strings.Join(items, " ") + " \"\n"
}
// rebuildInitramfs regenerates the initramfs. A dracut rebuild is then checked
// image by image, since dracut can leave out a module or file without failing.
func (b *gpuBoot) rebuildInitramfs(modprobeFiles []string) error {
if b.tools.Initramfs.Name != "dracut" {
if err := b.run(b.tools.Initramfs.Path, "-u"); err != nil {
return fmt.Errorf("%s failed: %w", b.tools.Initramfs.Name, err)
}
return nil
}
if err := b.run(b.tools.Initramfs.Path, "-f", "--regenerate-all"); err != nil {
return fmt.Errorf("dracut -f --regenerate-all failed: %w", err)
}
kernels, err := b.installedKernels()
if err != nil {
return err
}
for _, kver := range kernels {
if err := b.checkDracutImage(kver, modprobeFiles); err != nil {
return err
}
fmt.Printf(" initramfs for %s loads vfio-pci early\n", kver)
}
return nil
}
// installedKernels lists the kernel versions dracut --regenerate-all builds for.
func (b *gpuBoot) installedKernels() ([]string, error) {
entries, err := os.ReadDir(b.path("/lib/modules"))
if err != nil {
return nil, fmt.Errorf("list installed kernels: %w", err)
}
var kernels []string
for _, e := range entries {
if _, err := os.Stat(b.path(filepath.Join("/lib/modules", e.Name(), "modules.dep"))); err == nil {
kernels = append(kernels, e.Name())
}
}
if len(kernels) == 0 {
return nil, fmt.Errorf("no installed kernel found under /lib/modules")
}
return kernels, nil
}
func (b *gpuBoot) checkDracutImage(kver string, modprobeFiles []string) error {
listing, err := b.output("lsinitrd", "-k", kver)
if err != nil {
return fmt.Errorf("lsinitrd -k %s failed: %w", kver, err)
}
inImage := imageFiles(string(listing))
builtin, _ := os.ReadFile(b.path(filepath.Join("/lib/modules", kver, "modules.builtin")))
paths := strings.Fields(string(builtin))
for f := range inImage {
paths = append(paths, f)
}
modules := moduleNames(paths)
var missing []string
for _, mod := range vfioEarlyModules {
if !modules[mod] {
missing = append(missing, "module "+mod)
}
}
for _, name := range modprobeFiles {
if !inImage[filepath.Join("etc/modprobe.d", name)] {
missing = append(missing, filepath.Join(modprobeDir, name))
}
}
pre, err := b.output("lsinitrd", "-k", kver, "-f", dracutForceDrivers)
if err != nil {
return fmt.Errorf("lsinitrd -k %s -f %s failed: %w", kver, dracutForceDrivers, err)
}
if !slices.Contains(strings.Fields(string(pre)), "rd.driver.pre="+initramfsVFIOModule) {
missing = append(missing, "rd.driver.pre="+initramfsVFIOModule)
}
if len(missing) > 0 {
return fmt.Errorf("the initramfs for kernel %s lacks %s; check %s and rerun", kver, strings.Join(missing, ", "), dracutVFIOConf)
}
return nil
}
// imageFiles returns the paths in an lsinitrd listing, whose lines end in
// the path relative to the image root (symlinks add " -> target").
func imageFiles(listing string) map[string]bool {
files := map[string]bool{}
sc := bufio.NewScanner(strings.NewReader(listing))
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "l") {
continue
}
line, _, _ = strings.Cut(line, " -> ")
if fields := strings.Fields(line); len(fields) >= 9 {
files[fields[len(fields)-1]] = true
}
}
return files
}
var koSuffix = regexp.MustCompile(`\.ko(\.(xz|zst|gz))?$`)
// moduleNames maps module file paths to module names, which use underscores
// where file names may use dashes.
func moduleNames(paths []string) map[string]bool {
names := map[string]bool{}
for _, p := range paths {
base := filepath.Base(p)
if koSuffix.MatchString(base) {
names[strings.ReplaceAll(koSuffix.ReplaceAllString(base, ""), "-", "_")] = true
}
}
return names
}
// grubbyArgs returns the args= value from grubby --info output.
func grubbyArgs(info string) string {
for line := range strings.SplitSeq(info, "\n") {
if v, ok := strings.CutPrefix(line, "args="); ok {
return strings.Trim(v, `"`)
}
}
return ""
}
func missingArgs(cmdline, params string) []string {
have := strings.Fields(cmdline)
var missing []string
for p := range strings.FieldsSeq(params) {
if !slices.Contains(have, p) {
missing = append(missing, p)
}
}
return missing
}
// gpuSetupAddGRUBParams appends iommu kernel params to GRUB_CMDLINE_LINUX_DEFAULT.
// Returns (true, nil) if the file was modified, (false, nil) if params were already present.
func gpuSetupAddGRUBParams(grubFile, params string) (bool, error) {
data, err := os.ReadFile(grubFile)
if err != nil {
return false, fmt.Errorf("read %s: %w", grubFile, err)
}
content := string(data)
if strings.Contains(content, "intel_iommu=on") || strings.Contains(content, "amd_iommu=on") {
return false, nil
}
re := regexp.MustCompile(`(GRUB_CMDLINE_LINUX_DEFAULT="[^"]*)"`)
updated := re.ReplaceAllStringFunc(content, func(match string) string {
groups := re.FindStringSubmatch(match)
if len(groups) < 2 {
return match
}
return groups[1] + " " + params + `"`
})
if updated == content {
return false, fmt.Errorf("GRUB_CMDLINE_LINUX_DEFAULT not found in %s", grubFile)
}
if err := os.WriteFile(grubFile, []byte(updated), 0o644); err != nil { //nolint:gosec // G306: /etc/default/grub is read by grub-mkconfig and is conventionally 0644. Contains no secret material.
return false, fmt.Errorf("write %s: %w", grubFile, err)
}
return true, nil
}
@@ -0,0 +1,424 @@
package cli
import (
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/gpu"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
)
const testKver = "6.12.0-207.111.5.6.el10uek.x86_64"
// An lsinitrd listing trimmed from a dracut 107 image built on OL10 with UEK R8.
const testLsinitrd = `Image: /boot/initramfs-6.12.0-207.111.5.6.el10uek.x86_64.img: 28M
========================================================================
dracut modules:
kernel-modules
========================================================================
drwxr-xr-x 2 root root 0 Aug 13 00:00 etc/cmdline.d
-rw-r--r-- 1 root root 73 Aug 13 00:00 etc/cmdline.d/20-force_drivers.conf
-rw-r--r-- 1 root root 44 Aug 13 00:00 etc/modprobe.d/blacklist-nouveau.conf
-rw-r--r-- 1 root root 41 Aug 13 00:00 etc/modprobe.d/vfio-pci.conf
lrwxrwxrwx 1 root root 7 Aug 13 00:00 lib -> usr/lib
-rw-r--r-- 1 root root 43192 Aug 13 00:00 usr/lib/modules/6.12.0-207.111.5.6.el10uek.x86_64/kernel/drivers/vfio/pci/vfio-pci-core.ko.xz
-rw-r--r-- 1 root root 9704 Aug 13 00:00 usr/lib/modules/6.12.0-207.111.5.6.el10uek.x86_64/kernel/drivers/vfio/pci/vfio-pci.ko.xz
`
// UEK R8 builds vfio and vfio_iommu_type1 into the kernel.
const testBuiltin = "kernel/drivers/vfio/vfio.ko\nkernel/drivers/vfio/vfio_iommu_type1.ko\n"
const testForceDrivers = "rd.driver.pre=vfio\nrd.driver.pre=vfio_iommu_type1\nrd.driver.pre=vfio_pci\n"
type fakeBootHost struct {
t *testing.T
root string
calls []string
runErr map[string]error
outputs map[string]string
}
func newFakeBootHost(t *testing.T, bootloader, initramfs string) (*fakeBootHost, *gpuBoot) {
h := &fakeBootHost{t: t, root: t.TempDir(), runErr: map[string]error{}, outputs: map[string]string{}}
b := &gpuBoot{
tools: hostos.BootTools{
Bootloader: hostos.Tool{Name: bootloader, Path: "/usr/sbin/" + bootloader},
Initramfs: hostos.Tool{Name: initramfs, Path: "/usr/bin/" + initramfs},
},
root: h.root,
run: func(name string, args ...string) error {
call := strings.Join(append([]string{filepath.Base(name)}, args...), " ")
h.calls = append(h.calls, call)
return h.runErr[call]
},
output: func(name string, args ...string) ([]byte, error) {
call := strings.Join(append([]string{filepath.Base(name)}, args...), " ")
out, ok := h.outputs[call]
if !ok {
return nil, errors.New("unexpected call " + call)
}
return []byte(out), nil
},
}
return h, b
}
func (h *fakeBootHost) write(path, content string) {
h.t.Helper()
full := filepath.Join(h.root, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
h.t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
h.t.Fatal(err)
}
}
func (h *fakeBootHost) read(path string) string {
h.t.Helper()
data, err := os.ReadFile(filepath.Join(h.root, path))
if err != nil {
h.t.Fatal(err)
}
return string(data)
}
func requireErrContains(t *testing.T, err error, want ...string) {
t.Helper()
if err == nil {
t.Fatalf("got no error, want one naming %v", want)
}
for _, w := range want {
if !strings.Contains(err.Error(), w) {
t.Errorf("error %q does not name %q", err, w)
}
}
}
func TestSetKernelArgsUpdateGrub(t *testing.T) {
h, b := newFakeBootHost(t, "update-grub", "update-initramfs")
h.write(grubDefaultFile, "GRUB_CMDLINE_LINUX_DEFAULT=\"quiet\"\nGRUB_CMDLINE_LINUX=\"\"\n")
if err := b.setKernelArgs("intel_iommu=on iommu=pt"); err != nil {
t.Fatal(err)
}
if got := h.read(grubDefaultFile); !strings.Contains(got, `GRUB_CMDLINE_LINUX_DEFAULT="quiet intel_iommu=on iommu=pt"`) {
t.Errorf("/etc/default/grub = %q", got)
}
if !slices.Equal(h.calls, []string{"update-grub"}) {
t.Errorf("calls = %v, want update-grub", h.calls)
}
}
func TestSetKernelArgsGrubby(t *testing.T) {
const params = "amd_iommu=on iommu=pt"
const update = "grubby --update-kernel=ALL --args=" + params
cases := map[string]struct {
defaultArgs string
cmdline string
runErr error
wantErr []string
}{
"applied": {
defaultArgs: "ro crashkernel=auto amd_iommu=on iommu=pt",
cmdline: "ro crashkernel=auto amd_iommu=on iommu=pt\n",
},
"grubby fails": {
runErr: errors.New("exit status 1"),
wantErr: []string{"grubby --update-kernel=ALL"},
},
"default entry lacks an argument": {
defaultArgs: "ro amd_iommu=on",
cmdline: "ro amd_iommu=on iommu=pt\n",
wantErr: []string{"default boot entry", "iommu=pt"},
},
"new kernels would lack an argument": {
defaultArgs: "ro amd_iommu=on iommu=pt",
cmdline: "ro iommu=pt\n",
wantErr: []string{kernelCmdlineFile, "amd_iommu=on"},
},
"no kernel cmdline file": {
defaultArgs: "ro amd_iommu=on iommu=pt",
wantErr: []string{kernelCmdlineFile},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
h, b := newFakeBootHost(t, "grubby", "dracut")
h.runErr[update] = tc.runErr
h.outputs["grubby --info=DEFAULT"] = "index=0\nkernel=\"/boot/vmlinuz-" + testKver + "\"\nargs=\"" + tc.defaultArgs + "\"\nroot=\"UUID=abc\"\n"
if tc.cmdline != "" {
h.write(kernelCmdlineFile, tc.cmdline)
}
err := b.setKernelArgs(params)
if tc.wantErr != nil {
requireErrContains(t, err, tc.wantErr...)
return
}
if err != nil {
t.Fatal(err)
}
if !slices.Equal(h.calls, []string{update}) {
t.Errorf("calls = %v, want %q", h.calls, update)
}
})
}
}
func TestConfigureEarlyVFIODracut(t *testing.T) {
h, b := newFakeBootHost(t, "grubby", "dracut")
files := []string{vfioPCIConfName, nouveauConfName}
changed, err := b.configureEarlyVFIO(files)
if err != nil || !changed {
t.Fatalf("first run: changed=%v err=%v, want a write", changed, err)
}
conf := h.read(dracutVFIOConf)
for _, want := range []string{
`force_drivers+=" vfio vfio_iommu_type1 vfio_pci "`,
`install_items+=" /etc/modprobe.d/vfio-pci.conf /etc/modprobe.d/blacklist-nouveau.conf "`,
} {
if !strings.Contains(conf, want) {
t.Errorf("%s lacks %s:\n%s", dracutVFIOConf, want, conf)
}
}
if strings.Contains(conf, "add_drivers") {
t.Errorf("%s uses add_drivers, which does not load the module early:\n%s", dracutVFIOConf, conf)
}
if changed, err := b.configureEarlyVFIO(files); err != nil || changed {
t.Fatalf("rerun: changed=%v err=%v, want no change", changed, err)
}
if changed, err := b.configureEarlyVFIO(append(files, amdgpuConfName)); err != nil || !changed {
t.Fatalf("with the amdgpu blacklist: changed=%v err=%v, want a rewrite", changed, err)
}
if !strings.Contains(h.read(dracutVFIOConf), "/etc/modprobe.d/blacklist-amdgpu.conf") {
t.Errorf("rewrite lacks the amdgpu blacklist")
}
if _, err := os.Stat(filepath.Join(h.root, initramfsModules)); !os.IsNotExist(err) {
t.Errorf("dracut host got %s", initramfsModules)
}
}
func TestConfigureEarlyVFIOInitramfsTools(t *testing.T) {
h, b := newFakeBootHost(t, "update-grub", "update-initramfs")
h.write(initramfsModules, "# List of modules\n")
if changed, err := b.configureEarlyVFIO(nil); err != nil || !changed {
t.Fatalf("first run: changed=%v err=%v, want a write", changed, err)
}
if changed, err := b.configureEarlyVFIO(nil); err != nil || changed {
t.Fatalf("rerun: changed=%v err=%v, want no change", changed, err)
}
if got := h.read(initramfsModules); strings.Count(got, "vfio_pci") != 1 || !strings.Contains(got, "vfio\nvfio_iommu_type1\nvfio_pci\n") {
t.Errorf("%s = %q", initramfsModules, got)
}
if _, err := os.Stat(filepath.Join(h.root, dracutVFIOConf)); !os.IsNotExist(err) {
t.Errorf("initramfs-tools host got %s", dracutVFIOConf)
}
}
func TestRebuildInitramfsUpdateInitramfs(t *testing.T) {
h, b := newFakeBootHost(t, "update-grub", "update-initramfs")
if err := b.rebuildInitramfs(nil); err != nil {
t.Fatal(err)
}
if !slices.Equal(h.calls, []string{"update-initramfs -u"}) {
t.Errorf("calls = %v", h.calls)
}
}
func TestRebuildInitramfsDracut(t *testing.T) {
files := []string{vfioPCIConfName, nouveauConfName}
cases := map[string]struct {
listing string
builtin string
forceDrivers string
files []string
wantErr []string
}{
"complete image": {listing: testLsinitrd, builtin: testBuiltin, forceDrivers: testForceDrivers},
"every vfio module as a file": {
listing: testLsinitrd +
"-rw-r--r-- 1 root root 1 Aug 13 00:00 usr/lib/modules/x/kernel/drivers/vfio/vfio.ko.zst\n" +
"-rw-r--r-- 1 root root 1 Aug 13 00:00 usr/lib/modules/x/kernel/drivers/vfio/vfio_iommu_type1.ko\n",
forceDrivers: testForceDrivers,
},
"no vfio-pci module": {
listing: strings.ReplaceAll(testLsinitrd, "/vfio-pci.ko.xz", "/other.ko.xz"),
builtin: testBuiltin,
forceDrivers: testForceDrivers,
wantErr: []string{testKver, "module vfio_pci"},
},
"vfio core neither built in nor in the image": {
listing: testLsinitrd,
forceDrivers: testForceDrivers,
wantErr: []string{"module vfio,", "module vfio_iommu_type1"},
},
"no vfio-pci options": {
listing: strings.ReplaceAll(testLsinitrd, "etc/modprobe.d/vfio-pci.conf", "etc/modprobe.d/other.conf"),
builtin: testBuiltin,
forceDrivers: testForceDrivers,
wantErr: []string{"/etc/modprobe.d/vfio-pci.conf"},
},
"no amdgpu blacklist": {
listing: testLsinitrd,
builtin: testBuiltin,
forceDrivers: testForceDrivers,
files: append(slices.Clone(files), amdgpuConfName),
wantErr: []string{"/etc/modprobe.d/blacklist-amdgpu.conf"},
},
"vfio-pci not loaded early": {
listing: testLsinitrd,
builtin: testBuiltin,
forceDrivers: "rd.driver.pre=vfio\n",
wantErr: []string{"rd.driver.pre=vfio_pci"},
},
// lsinitrd -f prints nothing and exits 0 when the file is absent.
"no force_drivers file": {
listing: testLsinitrd,
builtin: testBuiltin,
wantErr: []string{"rd.driver.pre=vfio_pci"},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
h, b := newFakeBootHost(t, "grubby", "dracut")
h.write(filepath.Join("/lib/modules", testKver, "modules.dep"), "")
h.write(filepath.Join("/lib/modules", testKver, "modules.builtin"), tc.builtin)
h.outputs["lsinitrd -k "+testKver] = tc.listing
h.outputs["lsinitrd -k "+testKver+" -f "+dracutForceDrivers] = tc.forceDrivers
want := files
if tc.files != nil {
want = tc.files
}
err := b.rebuildInitramfs(want)
if !slices.Equal(h.calls, []string{"dracut -f --regenerate-all"}) {
t.Errorf("calls = %v", h.calls)
}
if tc.wantErr != nil {
requireErrContains(t, err, tc.wantErr...)
return
}
if err != nil {
t.Fatal(err)
}
})
}
}
func TestRebuildInitramfsDracutChecksEveryKernel(t *testing.T) {
const older = "6.12.0-200.1.el10uek.x86_64"
h, b := newFakeBootHost(t, "grubby", "dracut")
for _, k := range []string{testKver, older} {
h.write(filepath.Join("/lib/modules", k, "modules.dep"), "")
h.write(filepath.Join("/lib/modules", k, "modules.builtin"), testBuiltin)
h.outputs["lsinitrd -k "+k+" -f "+dracutForceDrivers] = testForceDrivers
}
// A modules directory left behind by a removed kernel has no modules.dep.
h.write("/lib/modules/6.11.0-old/extra/leftover.ko", "")
h.outputs["lsinitrd -k "+testKver] = testLsinitrd
h.outputs["lsinitrd -k "+older] = strings.ReplaceAll(testLsinitrd, "/vfio-pci.ko.xz", "/other.ko.xz")
requireErrContains(t, b.rebuildInitramfs([]string{vfioPCIConfName}), older, "module vfio_pci")
}
func TestRebuildInitramfsDracutFails(t *testing.T) {
h, b := newFakeBootHost(t, "grubby", "dracut")
h.runErr["dracut -f --regenerate-all"] = errors.New("exit status 1")
requireErrContains(t, b.rebuildInitramfs(nil), "dracut -f --regenerate-all")
}
func TestGPUSetupAddGRUBParams(t *testing.T) {
cases := map[string]struct {
grub string
wantChanged bool
wantErr bool
want string
}{
"appends": {grub: "GRUB_CMDLINE_LINUX_DEFAULT=\"quiet\"\n", wantChanged: true, want: "GRUB_CMDLINE_LINUX_DEFAULT=\"quiet amd_iommu=on iommu=pt\"\n"},
"already present": {grub: "GRUB_CMDLINE_LINUX_DEFAULT=\"quiet amd_iommu=on\"\n", want: "GRUB_CMDLINE_LINUX_DEFAULT=\"quiet amd_iommu=on\"\n"},
"no DEFAULT line": {grub: "GRUB_CMDLINE_LINUX=\"rhgb\"\n", wantErr: true, want: "GRUB_CMDLINE_LINUX=\"rhgb\"\n"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "grub")
if err := os.WriteFile(path, []byte(tc.grub), 0o644); err != nil {
t.Fatal(err)
}
changed, err := gpuSetupAddGRUBParams(path, "amd_iommu=on iommu=pt")
if (err != nil) != tc.wantErr || changed != tc.wantChanged {
t.Fatalf("changed=%v err=%v, want changed=%v error=%v", changed, err, tc.wantChanged, tc.wantErr)
}
if got, _ := os.ReadFile(path); string(got) != tc.want {
t.Errorf("file = %q, want %q", got, tc.want)
}
})
}
}
func TestGPUSlotFunctions(t *testing.T) {
sys := t.TempDir()
for _, addr := range []string{"0000:03:00.0", "0000:03:00.1", "0000:03:01.0", "0000:04:00.0"} {
if err := os.MkdirAll(filepath.Join(sys, "bus/pci/devices", addr), 0o755); err != nil {
t.Fatal(err)
}
}
if got := gpuSlotFunctions(sys, "0000:03:00.0"); !slices.Equal(got, []string{"0000:03:00.0", "0000:03:00.1"}) {
t.Errorf("got %v, want the two functions of slot 03:00", got)
}
}
func TestGPUSetupVerifyBindings(t *testing.T) {
devices := []gpu.GPUDevice{{PCIAddress: "0000:03:00.0"}}
functions := func(string) []string { return []string{"0000:03:00.0", "0000:03:00.1"} }
cases := map[string]struct {
drivers map[string]string
bindErr error
wantBound []string
wantErr []string
}{
"all on vfio-pci": {drivers: map[string]string{"0000:03:00.0": "vfio-pci", "0000:03:00.1": "vfio-pci"}},
"unbound sibling is bound": {
drivers: map[string]string{"0000:03:00.0": "vfio-pci", "0000:03:00.1": ""},
wantBound: []string{"0000:03:00.1"},
},
"sibling on a host driver": {
drivers: map[string]string{"0000:03:00.0": "vfio-pci", "0000:03:00.1": "snd_hda_intel"},
wantErr: []string{"0000:03:00.1", "snd_hda_intel"},
},
"GPU on its native driver": {
drivers: map[string]string{"0000:03:00.0": "nvidia", "0000:03:00.1": "vfio-pci"},
wantErr: []string{"0000:03:00.0", "nvidia"},
},
"bind fails": {
drivers: map[string]string{"0000:03:00.0": "", "0000:03:00.1": "vfio-pci"},
bindErr: errors.New("permission denied"),
wantErr: []string{"0000:03:00.0", "permission denied"},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
var bound []string
err := gpuSetupVerifyBindings(devices, functions,
func(addr string) (string, error) { return tc.drivers[addr], nil },
func(addr string) error { bound = append(bound, addr); return tc.bindErr })
if tc.wantErr != nil {
requireErrContains(t, err, tc.wantErr...)
return
}
if err != nil {
t.Fatal(err)
}
if !slices.Equal(bound, tc.wantBound) {
t.Errorf("bound %v, want %v", bound, tc.wantBound)
}
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package cli
import (
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostnet"
"github.com/spf13/cobra"
)
var adminHostnetCmd = &cobra.Command{
Use: "hostnet",
Short: "Re-create or check this node's host network wiring",
Long: `On nodes whose host network wiring is not persisted by systemd-networkd,
setup-ovn.sh records it in /etc/spinifex/hostnet.json and spinifex-hostnet.service
re-creates it at boot and whenever ovs-vswitchd restarts.`,
}
var adminHostnetApplyCmd = &cobra.Command{
Use: "apply",
Short: "Make the host network match the recorded wiring (run by spinifex-hostnet.service)",
Long: `Creates the uplink veth pairs and sets the links, addresses and encap route
named in /etc/spinifex/hostnet.json. It never deletes a link, and never touches a
link the file does not name. Does nothing on a node whose wiring systemd-networkd
persists. Writes /run/spinifex/hostnet-applied only on success.`,
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return hostnet.Apply(cmd.Context(), hostnet.DefaultEnv())
},
}
var adminHostnetCheckCmd = &cobra.Command{
Use: "check",
Short: "Fail unless the host network is ready for spinifex-daemon and spinifex-vpcd",
Long: `Runs the same backend and state-file checks as apply, and on the hostnet backend
also needs the marker apply writes on success. The daemon and vpcd run it before
they start, so neither starts on a node whose wiring failed to apply.`,
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return hostnet.Check(cmd.Context(), hostnet.DefaultEnv())
},
}
func init() {
adminCmd.AddCommand(adminHostnetCmd)
adminHostnetCmd.AddCommand(adminHostnetApplyCmd)
adminHostnetCmd.AddCommand(adminHostnetCheckCmd)
}
@@ -0,0 +1,48 @@
//test:in-package — resolveIPSecForHost is an unexported guard on this command's
// flags, and exporting it would widen the package's API for a test's convenience.
package cli
import (
"strings"
"testing"
)
// A libreswan host cannot run OVN IPsec, so it defaults to off and refuses an
// explicit request; every other host keeps whatever --ipsec says.
func TestResolveIPSecForHost(t *testing.T) {
for _, tc := range []struct {
name string
requested, explicit, libreswan bool
want bool
wantErr bool
}{
{name: "strongSwan default", requested: true, want: true},
{name: "strongSwan explicit off", explicit: true},
{name: "libreswan default", requested: true, libreswan: true},
{name: "libreswan explicit off", explicit: true, libreswan: true},
{name: "libreswan explicit on", requested: true, explicit: true, libreswan: true, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveIPSecForHost(tc.requested, tc.explicit, tc.libreswan)
if tc.wantErr {
if err == nil {
t.Fatal("want an error, got nil")
}
// The operator needs to know why, and what to run instead.
for _, word := range []string{"libreswan", "/usr/libexec/ipsec/pluto", "without --ipsec"} {
if !strings.Contains(err.Error(), word) {
t.Errorf("error does not name %q: %v", word, err)
}
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
})
}
}
+17 -1
View File
@@ -24,8 +24,24 @@ Overwriting /usr/local/bin/spx while spinifex.target is up does not move the
services onto it: each keeps executing the replaced inode until it restarts,
so a node can serve a mixture of two builds indefinitely.
On x86_64 it checks that the QEMU binary the daemon will run has the microvm
machine type, and that the microvm firmware is installed: load balancers boot
on both.
It runs the host network check spinifex-daemon and spinifex-vpcd run before they
start, so a failed spinifex-hostnet apply shows here.
It fails while firewalld or ufw is active: the Spinifex nftables policy is the
only supported node firewall, and a second ruleset silently drops routed NAT,
elastic IP and IMDS traffic.
It warns, without failing, when systemd-resolved is active but /etc/resolv.conf
is not its stub: lookups then bypass the route that sends Spinifex names to
northstar.
Checks compare content, not mere existence, so a present-but-stale asset is
caught too. Exits non-zero if any asset is Missing, Stale, or Ungranted.`,
caught too. Exits non-zero if any asset is Missing, Stale, Ungranted, Unsupported or
Failed.`,
RunE: runAdminPreflight,
SilenceUsage: true,
}
+4 -3
View File
@@ -8,9 +8,10 @@ package systemd
var Units = map[string]string{
"regenerate-ssh-host-keys.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Regenerate SSH host keys\nConditionPathExists=!/etc/ssh/ssh_host_ed25519_key\nBefore=ssh.service\n\n[Service]\nType=oneshot\nExecStart=/usr/bin/ssh-keygen -A\nRemainAfterExit=yes\n# No ExecStop: the keygen process has already exited by the time this unit\n# is stopped, so there is nothing to signal.\nTimeoutStopSec=5\n\n[Install]\nWantedBy=multi-user.target\n",
"spinifex-awsgw.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex AWS Gateway\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-daemon.service\nWants=spinifex-nats.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-gw\nGroup=spinifex\nExecStartPre=/var/lib/spinifex/wait-for-nats.sh\nExecStart=/usr/local/bin/spx service awsgw start\nRestart=on-failure\nRestartSec=5\n# No SIGTERM handler in this process: it exits immediately on signal, so this\n# only bounds an unexpectedly wedged process before SIGKILL.\nTimeoutStopSec=10\n\nOOMScoreAdjust=-500\n# Per-service ceiling bounds a leak in this service alone rather than\n# tripping the slice-wide cap, which would pick an arbitrary victim.\nMemoryHigh=10%\nMemoryMax=20%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/awsgw/\nEnvironment=SPINIFEX_AWSGW_TLS_CERT=/etc/spinifex/server.pem\nEnvironment=SPINIFEX_AWSGW_TLS_KEY=/etc/spinifex/server.key\n\n# Filesystem access\nNoNewPrivileges=yes\nProtectSystem=strict\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/awsgw /var/log/spinifex\n\n# Hardening\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nCapabilityBoundingSet=\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n",
"spinifex-daemon.service": "# spinifex-unit-version: 3\n[Unit]\nDescription=Spinifex Daemon (VM Orchestration)\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service openvswitch-ipsec.service\n# Wants (not Requires): stay bootable when openvswitch-ipsec is absent\nWants=openvswitch-ipsec.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\n# ZFS root: installer raises SPINIFEX_RESERVED_MEM_GB by the ARC ceiling the admission gate cannot see\nEnvironmentFile=-/etc/spinifex/host.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-daemon\nGroup=spinifex\nExecStart=/usr/local/bin/spx service spinifex start\nRestart=on-failure\nRestartSec=5\n# KillMode=process: guest QEMU survives daemon restart; the new daemon reattaches (DDIL)\nKillMode=process\nTimeoutStopSec=15\n# RG-4: infra tier — reaped only after every guest\nOOMScoreAdjust=-500\n# Guest QEMU shares this cgroup: the ceiling tracks the slice budget, not daemon overhead\nMemoryHigh=75%\nMemoryMax=85%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/\nEnvironment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/\n# Pin to host tmpfs: QEMU pidfiles/QMP sockets must survive PrivateTmp namespace churn (DDIL)\nEnvironment=XDG_RUNTIME_DIR=/run/spinifex\n\n# RG-9: daemon tier — privileged by necessity (GPU vfio). No CAP_NET_ADMIN here,\n# so unlike vpcd it still escalates ip/sysctl and NoNewPrivileges stays omitted.\nAmbientCapabilities=CAP_SYS_ADMIN CAP_DAC_OVERRIDE\nProtectSystem=full\nPrivateDevices=no\nDevicePolicy=closed\nDeviceAllow=/dev/kvm rw\nDeviceAllow=/dev/net/tun rw\nDeviceAllow=/dev/vhost-net rw\nDeviceAllow=char-vfio rw\nDeviceAllow=/dev/vfio/vfio rw\nReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\n# /proc/sys/net/ipv4/conf: ProtectKernelTunables would keep the per-tap IMDS\n# rp_filter/accept_local read-only; /etc/spinifex/firewall: the root helper writes peers.nft.\nReadWritePaths=/var/lib/spinifex/spinifex /var/log/spinifex /run/spinifex /run/spinifex/nbd /proc/sys/net/ipv4/conf -/etc/spinifex/firewall\n\n# RG-9: hardening baseline\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n# LimitMEMLOCK=infinity: VFIO pins all guest RAM\nLimitMEMLOCK=infinity\n\n[Install]\nWantedBy=spinifex.target\n",
"spinifex-daemon.service": "# spinifex-unit-version: 5\n[Unit]\nDescription=Spinifex Daemon (VM Orchestration)\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-viperblock.service ovsdb-server.service ovs-vswitchd.service ovn-controller.service openvswitch-ipsec.service spinifex-hostnet.service\n# Wants (not Requires): boot without openvswitch-ipsec, survive OVS restarts via hostnet\nWants=openvswitch-ipsec.service spinifex-hostnet.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\n# ZFS root: installer raises SPINIFEX_RESERVED_MEM_GB by the ARC ceiling the admission gate cannot see\nEnvironmentFile=-/etc/spinifex/host.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-daemon\nGroup=spinifex\nExecStartPre=/usr/local/bin/spx admin hostnet check\nExecStart=/usr/local/bin/spx service spinifex start\nRestart=on-failure\nRestartSec=5\n# KillMode=process: guest QEMU survives daemon restart; the new daemon reattaches (DDIL)\nKillMode=process\nTimeoutStopSec=15\n# RG-4: infra tier — reaped only after every guest\nOOMScoreAdjust=-500\n# Guest QEMU shares this cgroup: the ceiling tracks the slice budget, not daemon overhead\nMemoryHigh=75%\nMemoryMax=85%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/\nEnvironment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/\n# Pin to host tmpfs: QEMU pidfiles/QMP sockets must survive PrivateTmp namespace churn (DDIL)\nEnvironment=XDG_RUNTIME_DIR=/run/spinifex\n\n# RG-9: daemon tier — privileged by necessity (GPU vfio). No CAP_NET_ADMIN here,\n# so unlike vpcd it still escalates ip/sysctl and NoNewPrivileges stays omitted.\nAmbientCapabilities=CAP_SYS_ADMIN CAP_DAC_OVERRIDE\nProtectSystem=full\nPrivateDevices=no\nDevicePolicy=closed\nDeviceAllow=/dev/kvm rw\nDeviceAllow=/dev/net/tun rw\nDeviceAllow=/dev/vhost-net rw\nDeviceAllow=char-vfio rw\nDeviceAllow=/dev/vfio/vfio rw\nReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\n# /proc/sys/net/ipv4/conf: ProtectKernelTunables would keep the per-tap IMDS\n# rp_filter/accept_local read-only; /etc/spinifex/firewall: the root helper writes peers.nft.\nReadWritePaths=/var/lib/spinifex/spinifex /var/log/spinifex /run/spinifex /run/spinifex/nbd /proc/sys/net/ipv4/conf -/etc/spinifex/firewall\n\n# RG-9: hardening baseline\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n# LimitMEMLOCK=infinity: VFIO pins all guest RAM\nLimitMEMLOCK=infinity\n\n[Install]\nWantedBy=spinifex.target\n",
"spinifex-firewall.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex host firewall (nftables input policy)\nDocumentation=file:/etc/spinifex/firewall/spinifex.nft\nWants=network-pre.target\nBefore=network-pre.target shutdown.target\nDefaultDependencies=no\nConditionPathExists=/etc/spinifex/firewall/peers.nft\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nStandardInput=null\nProtectSystem=full\nProtectHome=true\nExecStart=/usr/local/lib/spinifex/spinifex-firewall-apply\nExecReload=/usr/local/lib/spinifex/spinifex-firewall-apply\nTimeoutStopSec=10s\n\n# No ExecStop: flushing on stop would take vpcd's MASQUERADE and per-EIP FORWARD\n# rules with it. Remove the policy deliberately with\n# `nft delete table inet spinifex_filter`.\n\n[Install]\nWantedBy=sysinit.target\n",
"spinifex-guests.slice": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex guest VM tier\nBefore=slices.target\n\n[Slice]\n# RG-3: child of spinifex.slice (spinifex-guests -> spinifex), sibling of\n# spinifex-system. Parent for the per-instance spinifex-instance-<id>.slice\n# leaves (0002 RG-7). Inherits the spinifex.slice ceiling; no extra limit here.\n",
"spinifex-hostnet.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex host network wiring (veth pairs, mgmt address, encap route)\n# PartOf/WantedBy ovs-vswitchd: re-applied whenever OVS starts; daemon and vpcd only Want it\nRequires=ovs-vswitchd.service\nAfter=ovs-vswitchd.service NetworkManager-wait-online.service\nPartOf=ovs-vswitchd.service\nBefore=spinifex.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/usr/local/bin/spx admin hostnet apply\n# Covers the 120s wait behind setup-ovn.sh plus the 60s wait for links\nTimeoutStartSec=240\nTimeoutStopSec=10\nProtectSystem=full\nProtectHome=yes\nPrivateTmp=yes\n\n[Install]\nWantedBy=spinifex.target ovs-vswitchd.service\n",
"spinifex-nats-watchdog.service": "# spinifex-unit-version: 2\n[Unit]\nDescription=Spinifex NATS JetStream liveness watchdog\nPartOf=spinifex.target\nAfter=spinifex-nats.service\n\n[Service]\nType=oneshot\n# Runs as root: the probes are a local curl to the NATS monitoring port and a\n# canary KV round-trip via spx, and the remedy is `systemctl try-restart\n# spinifex-nats` when JetStream stops accepting writes while the process stays\n# alive (which Restart=on-failure cannot catch).\nExecStart=/var/lib/spinifex/nats-js-watchdog.sh\n# Sized for the probe loop's own worst case (3 samples x (5s curl + 5s canary)\n# + 2 gaps x 2s = 34s): a stop mid-cycle lets the script finish rather than\n# cutting it off. Only the failing path runs that long; a healthy node exits on\n# the first sample.\nTimeoutStopSec=45\n# Same 34s worst case bounds the start side: without this the unit inherits\n# oneshot's infinite default, so a genuinely hung run would sit in\n# activating(start) forever and could not be retriggered by its timer.\nTimeoutStartSec=120\n\n# Hardening (kept compatible with talking to PID1 over /run/systemd).\nNoNewPrivileges=yes\nProtectHome=yes\nPrivateTmp=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectControlGroups=yes\nRestrictRealtime=yes\nLockPersonality=yes\nRestrictSUIDSGID=yes\n",
"spinifex-nats-watchdog.timer": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex NATS JetStream liveness watchdog timer\nPartOf=spinifex.target\n\n[Timer]\n# Delay the first probe so NATS startup + JetStream cluster quorum settle (a\n# forming cluster reports JS not-current transiently, which is not a fault).\nOnBootSec=60s\nOnUnitActiveSec=30s\nAccuracySec=5s\n\n[Install]\nWantedBy=timers.target\n",
"spinifex-nats.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex NATS Message Bus\nPartOf=spinifex.target\nBefore=spinifex-predastore.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-nats\nGroup=spinifex\nExecStart=/usr/local/bin/spx service nats start\nRestart=on-failure\nRestartSec=5\n# nats-server installs its own SIGTERM handler (JetStream store shutdown, raft\n# stepdown, client drain) with no internal deadline; every dependent service\n# stops before nats does, so size this for nats's own graceful-stop needs.\nTimeoutStopSec=20\nOOMScoreAdjust=-500\n# Per-service ceiling bounds a leak in this service alone rather than\n# tripping the slice-wide cap, which would pick an arbitrary victim.\nMemoryHigh=10%\nMemoryMax=20%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/nats/nats.conf\n\n# Filesystem access\nNoNewPrivileges=yes\nProtectSystem=strict\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/nats /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/nats /var/log/spinifex\n\n# Hardening\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nCapabilityBoundingSet=\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n",
@@ -21,7 +22,7 @@ var Units = map[string]string{
"spinifex-system.slice": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex control/infra tier (NATS, predastore, viperblock, daemon, awsgw, vpcd, ui)\nBefore=slices.target\n\n[Slice]\n# RG-3: child of spinifex.slice (spinifex-system -> spinifex), inherits the\n# parent ceiling, no extra limit. Holds the 7 control/infra services. Guest QEMU\n# moves to the sibling spinifex-guests.slice (per-instance placement lands in\n# 0002 RG-7); until then the daemon-forked guests stay under spinifex-daemon.service.\n",
"spinifex-ui.service": "# spinifex-unit-version: 2\n[Unit]\nDescription=Spinifex Web UI\nPartOf=spinifex.target\nAfter=spinifex-awsgw.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-ui\nGroup=spinifex\nExecStart=/usr/local/bin/spx service spinifex-ui start\nRestart=on-failure\nRestartSec=5\n# server.Shutdown() uses an internal 30s context (WriteTimeout=120s covers\n# long streaming proxy responses, e.g. console passthrough); add margin above it.\nTimeoutStopSec=40\nOOMScoreAdjust=-500\n# Per-service ceiling bounds a leak in this service alone rather than\n# tripping the slice-wide cap, which would pick an arbitrary victim.\nMemoryHigh=10%\nMemoryMax=20%\nEnvironment=SPINIFEX_UI_TLS_CERT=/etc/spinifex/server.pem\nEnvironment=SPINIFEX_UI_TLS_KEY=/etc/spinifex/server.key\nEnvironment=SPINIFEX_UI_BASE_DIR=/var/lib/spinifex/spinifex-ui/\nEnvironment=SPINIFEX_UI_OCHRE_ENABLED=false\n\n# Filesystem access\nNoNewPrivileges=yes\nProtectSystem=strict\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/server.pem /etc/spinifex/server.key\nReadWritePaths=/var/lib/spinifex/spinifex-ui /var/log/spinifex\n\n# Hardening\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nCapabilityBoundingSet=\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n",
"spinifex-viperblock.service": "# spinifex-unit-version: 2\n[Unit]\nDescription=Spinifex Viperblock (Block Storage)\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-predastore.service\nWants=spinifex-nats.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-viperblock\nGroup=spinifex\nExecStartPre=/var/lib/spinifex/wait-for-nats.sh\nExecStart=/usr/local/bin/spx service viperblock start\nRestart=on-failure\nRestartSec=5\nOOMScoreAdjust=-500\n# Per-service ceiling bounds a leak in this service alone rather than\n# tripping the slice-wide cap, which would pick an arbitrary victim.\nMemoryHigh=15%\nMemoryMax=25%\n# KillMode=process: nbdkit still serving a guest survives a restart here, the\n# way guest QEMU survives a daemon restart. Tearing one out mid-write corrupts\n# that guest's filesystem; idle nbdkit is reaped explicitly on shutdown.\nKillMode=process\n# One worst-case nbdkit reap: killProcessGracePeriod (120s, utils.KillProcess)\n# plus its own shutdownDrainTimeout (20s, viperblock/nbd) and headroom — must\n# strictly exceed 120s now shutdownVolumes reaps concurrently, not serially.\nTimeoutStopSec=150\nEnvironmentFile=/etc/spinifex/systemd.env\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/viperblock/\n\n# Filesystem access\nNoNewPrivileges=yes\nProtectSystem=strict\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/viperblock /var/log/spinifex /run/spinifex/nbd\n\n# Hardening\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\n# ProtectProc must stay default: hidepid=invisible hides spinifex-daemon's\n# QEMU /proc entries (different uid) orphan nbdkit reaping must see.\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nCapabilityBoundingSet=\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n",
"spinifex-vpcd.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex VPC Daemon (OVN Networking)\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-daemon.service systemd-networkd.service openvswitch-switch.service ovn-controller.service\nWants=spinifex-nats.service ovn-controller.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-vpcd\nGroup=spinifex\nExecStartPre=/var/lib/spinifex/wait-for-nats.sh\nExecStart=/usr/local/bin/spx service vpcd start\nRestart=on-failure\nRestartSec=5\n# Waits for an in-flight OVN drift-reconcile pass to return before exiting;\n# no sub-timeout bounds that call today, so this covers a normal pass.\nTimeoutStopSec=20\nOOMScoreAdjust=-500\n# Per-service ceiling: bounds a leak here instead of the slice-wide cap.\nMemoryHigh=10%\nMemoryMax=20%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/\n\n# RG-9: network tier. Per-tap IMDS removed the in-process setns, so vpcd no\n# longer enters a per-VPC netns: CAP_SYS_ADMIN is dropped and the filesystem\n# sandbox is restored.\n#\n# RG-10: these are ambient so ip/iptables/sysctl/arping inherit them on exec and\n# vpcd holds no sudoers rules at all. NoNewPrivileges stays off pending the ADR\n# update that retires the last escalation path.\nAmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID\nCapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID\nNoNewPrivileges=no\nProtectSystem=full\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/vpcd /var/log/spinifex\n\n# RG-9: hardening baseline\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\n# AF_PACKET: raw L2 BOOTP for the in-process DHCP client before the iface has an IP\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK AF_PACKET\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n",
"spinifex-vpcd.service": "# spinifex-unit-version: 3\n[Unit]\nDescription=Spinifex VPC Daemon (OVN Networking)\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-daemon.service systemd-networkd.service ovsdb-server.service ovs-vswitchd.service ovn-controller.service spinifex-hostnet.service\nWants=spinifex-nats.service ovn-controller.service spinifex-hostnet.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-vpcd\nGroup=spinifex\nExecStartPre=/usr/local/bin/spx admin hostnet check\nExecStartPre=/var/lib/spinifex/wait-for-nats.sh\nExecStart=/usr/local/bin/spx service vpcd start\nRestart=on-failure\nRestartSec=5\n# Waits for an in-flight OVN drift-reconcile pass to return before exiting;\n# no sub-timeout bounds that call today, so this covers a normal pass.\nTimeoutStopSec=20\nOOMScoreAdjust=-500\n# Per-service ceiling: bounds a leak here instead of the slice-wide cap.\nMemoryHigh=10%\nMemoryMax=20%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/\n\n# RG-9: network tier. Per-tap IMDS removed the in-process setns, so vpcd no\n# longer enters a per-VPC netns: CAP_SYS_ADMIN is dropped and the filesystem\n# sandbox is restored.\n#\n# RG-10: these are ambient so ip/iptables/sysctl/arping inherit them on exec and\n# vpcd holds no sudoers rules at all. NoNewPrivileges stays off pending the ADR\n# update that retires the last escalation path.\nAmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID\nCapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID\nNoNewPrivileges=no\nProtectSystem=full\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/vpcd /var/log/spinifex\n\n# RG-9: hardening baseline\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\n# AF_PACKET: raw L2 BOOTP for the in-process DHCP client before the iface has an IP\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK AF_PACKET\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n",
"spinifex.slice": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex resource-bounded slice (host ceiling)\nBefore=slices.target\n\n[Slice]\n# RG-1: no CPUWeight — slice roots are siblings; capping below the default would\n# let user.slice out-prioritize the VM fleet. Operator CPU priority lives on\n# system.slice (system.slice.d/spinifex-reserve.conf).\n# RG-1/RG-5: throttle+reclaim at High, hard wall at Max (% of physical RAM).\nMemoryHigh=80%\nMemoryMax=90%\n# RG-5: spill to the full swap device before the MemoryMax wall; matched to swap size.\nMemorySwapMax=8G\n",
"spinifex.target": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex Infrastructure Platform\nAfter=network-online.target\nWants=network-online.target\nWants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer openvswitch-switch.service ovn-controller.service\n\n[Install]\nWantedBy=multi-user.target\n",
"spinifex.target": "# spinifex-unit-version: 2\n[Unit]\nDescription=Spinifex Infrastructure Platform\nAfter=network-online.target\nWants=network-online.target\nWants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer ovsdb-server.service ovs-vswitchd.service ovn-controller.service\n\n[Install]\nWantedBy=multi-user.target\n",
}
@@ -423,9 +423,12 @@ var unitBodyHashes = map[string]map[int]string{
1: "04b0f23b9cda322e4012d589acc196abe466b619dec5ac0d832897b9af2f926c",
2: "97a2d4c72967a9bdf9545ff74593de532e8469568bb717c9958d33b77e62d0ed",
3: "8059fe13a678b8cb2a383ae75c069560cf39adece9a9394e17781998c711cd57",
4: "f66e1240cc5655f6a13f3b56ca282c73e2fe289f2e51d0ce624d14b90f12f838",
5: "d3d25d7eb0051ccf97f70838ddff763bade38bef934b8f85733e048151ac9319",
},
"spinifex-firewall.service": {1: "01d0a7a79f47eedca02aaab3ff97a0b6462d61b7834e16c7336a7b96034dc392"},
"spinifex-guests.slice": {1: "22dac772e81a9a716db98415a4cf590885c9cf19d4290cc21e645e5fe15bc793"},
"spinifex-hostnet.service": {1: "4a110daac63b652aef49e358f22ac8a27a7797261b231d5b1184e171cccb8f87"},
"spinifex-nats-watchdog.service": {
1: "b778e398da37c4cb3c170781f074c76555cb6a137a7f96f8dc692457b12aa9b8",
2: "c31eb4fa964060e1681113a3de4f8f3ee4336aee96469380aac5fc77480c964a",
@@ -449,9 +452,16 @@ var unitBodyHashes = map[string]map[int]string{
1: "5c8cdd2004abf8e5725cbce0200565b9b29be7ab3210a4e0a2cdfe37ec5facb9",
2: "5d691bf5ce4a5a636d5dcc07f7bfa36d0da9eb0d7fb26079f89358ca0e5440cd",
},
"spinifex-vpcd.service": {1: "1b722640310145767cd34e87f4804852e63f46d2145ed20f8cc5f5400ebc5965"},
"spinifex.slice": {1: "f73d9343e0e1bedd647835c8bb0c80fb3a3bd66474661234ecac23a4caafc24f"},
"spinifex.target": {1: "0ffba9faee5a477f8ff7466a6bccb4dc7e04f5cf92a405553c242e6548402078"},
"spinifex-vpcd.service": {
1: "1b722640310145767cd34e87f4804852e63f46d2145ed20f8cc5f5400ebc5965",
2: "6ca01c8104ce0fc96ee4fdd8c0f2cd2ae907211b58b6f868fdc6a26f55a579f8",
3: "3ac639bf8017f6eeb8777096edc40aaeeda9ca2bcc0a8313b93c51d20847ea20",
},
"spinifex.slice": {1: "f73d9343e0e1bedd647835c8bb0c80fb3a3bd66474661234ecac23a4caafc24f"},
"spinifex.target": {
1: "0ffba9faee5a477f8ff7466a6bccb4dc7e04f5cf92a405553c242e6548402078",
2: "0dc31d0e415277d10e4d90959f40d574f5c2319560299448002f701453737d5b",
},
}
// TestUnitBodyPinnedToVersion asserts each unit's body hash matches the
@@ -493,3 +503,98 @@ func stripVersionMarkerLine(content string) string {
}
return content
}
// crossDistroOVSUnits are the OVS and OVN units Debian and EL both ship under
// the same name. The umbrella differs (openvswitch-switch against openvswitch),
// and systemd silently drops ordering against a unit that does not exist.
var crossDistroOVSUnits = []string{
"ovsdb-server.service",
"ovs-vswitchd.service",
"ovn-controller.service",
"ovn-northd.service",
"openvswitch-ipsec.service",
}
// TestOVSDependenciesExistOnEveryDistro asserts every OVS or OVN unit a
// Spinifex unit depends on or orders against is one both distros ship.
func TestOVSDependenciesExistOnEveryDistro(t *testing.T) {
dir := unitsDir(t)
keys := []string{"After", "Before", "Wants", "Requires", "Requisite", "BindsTo", "PartOf", "Upholds"}
for _, name := range allUnitFiles(t, dir) {
u := readUnit(t, dir, name)
for line := range strings.SplitSeq(u, "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok || !slices.Contains(keys, key) {
continue
}
for dep := range strings.FieldsSeq(value) {
if !strings.HasPrefix(dep, "ovs") && !strings.HasPrefix(dep, "ovn") && !strings.HasPrefix(dep, "openvswitch") {
continue
}
if !slices.Contains(crossDistroOVSUnits, dep) {
t.Errorf("%s: %s=%s is not shipped under that name on every distro; use one of %v", name, key, dep, crossDistroOVSUnits)
}
}
}
}
}
// TestHostnetIsWantedNotRequired asserts the daemon and vpcd start after
// spinifex-hostnet without Requires=: hostnet is PartOf ovs-vswitchd, so a
// Requires= would restart both on every OVS restart or package upgrade.
func TestHostnetIsWantedNotRequired(t *testing.T) {
dir := unitsDir(t)
for _, name := range []string{"spinifex-daemon.service", "spinifex-vpcd.service"} {
u := readUnit(t, dir, name)
for _, key := range []string{"Wants", "After"} {
if !directiveContains(u, key, "spinifex-hostnet.service") {
t.Errorf("%s: %s= must include spinifex-hostnet.service", name, key)
}
}
for _, key := range []string{"Requires", "Requisite", "BindsTo", "PartOf"} {
if directiveContains(u, key, "spinifex-hostnet.service") {
t.Errorf("%s: %s=spinifex-hostnet.service would restart it with OVS; use Wants=", name, key)
}
}
}
}
// TestHostnetFollowsOVS asserts spinifex-hostnet re-applies whenever
// ovs-vswitchd starts, and runs even when its state file is gone: a unit
// skipped by a condition still satisfies the daemon and vpcd ordering on it.
func TestHostnetFollowsOVS(t *testing.T) {
dir := unitsDir(t)
u := readUnit(t, dir, "spinifex-hostnet.service")
for key, values := range map[string][]string{
"Requires": {"ovs-vswitchd.service"},
"After": {"ovs-vswitchd.service", "NetworkManager-wait-online.service"},
"PartOf": {"ovs-vswitchd.service"},
"Before": {"spinifex.target"},
"WantedBy": {"spinifex.target", "ovs-vswitchd.service"},
} {
for _, v := range values {
if !directiveContains(u, key, v) {
t.Errorf("spinifex-hostnet.service: %s= must include %s", key, v)
}
}
}
for _, want := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=/usr/local/bin/spx admin hostnet apply"} {
if !hasDirective(u, want) {
t.Errorf("spinifex-hostnet.service must carry %q", want)
}
}
if strings.Contains(u, "\nCondition") {
t.Error("spinifex-hostnet.service must not be conditional: a skipped unit lets the daemon and vpcd start unwired")
}
}
// TestDaemonAndVPCDCheckHostnet asserts both refuse to start, including on a
// crash restart, while the host network is not applied.
func TestDaemonAndVPCDCheckHostnet(t *testing.T) {
dir := unitsDir(t)
for _, name := range []string{"spinifex-daemon.service", "spinifex-vpcd.service"} {
if !hasDirective(readUnit(t, dir, name), "ExecStartPre=/usr/local/bin/spx admin hostnet check") {
t.Errorf("%s must run `spx admin hostnet check` before it starts", name)
}
}
}
+9
View File
@@ -0,0 +1,9 @@
package vm
// MicroVMDir is where Spinifex installs the kernel, initramfs and firmware
// that system instances boot on the microvm machine.
const MicroVMDir = "/usr/share/spinifex/microvm"
// MicroVMFirmware is the qboot build Spinifex ships in MicroVMDir and passes
// with -bios on every host OS.
const MicroVMFirmware = "qboot.rom"
+5 -4
View File
@@ -8,14 +8,15 @@ import (
)
// TestMicroVMDirectBoot asserts that a microvm Config with direct-boot fields
// set produces the expected QEMU args and does not include PCI-specific flags
// such as -bios or pcie-root-port.
// set produces the expected QEMU args, including the explicit firmware, and
// does not include PCI-specific devices such as pcie-root-port.
func TestMicroVMDirectBoot(t *testing.T) {
cfg := Config{
CPUCount: 2,
Memory: 512,
Architecture: "x86_64",
MachineType: "microvm,x-option-roms=off,pic=off,pit=off,rtc=on,acpi=on,isa-serial=on",
BIOS: "/path/qboot.rom",
KernelImage: "/path/vmlinuz",
Initrd: "/path/initramfs.cpio.gz",
KernelCmdline: "console=ttyS0 panic=1",
@@ -34,13 +35,13 @@ func TestMicroVMDirectBoot(t *testing.T) {
assert.Equal(t, "microvm,x-option-roms=off,pic=off,pit=off,rtc=on,acpi=on,isa-serial=on", argValue(args, "-M"))
// Direct-boot flags must be present
assert.Equal(t, "/path/qboot.rom", argValue(args, "-bios"))
assert.Equal(t, "/path/vmlinuz", argValue(args, "-kernel"))
assert.Equal(t, "/path/initramfs.cpio.gz", argValue(args, "-initrd"))
assert.Equal(t, "console=ttyS0 panic=1", argValue(args, "-append"))
assert.Equal(t, "name=opt/spinifex/netcfg,file=/tmp/nc.tmp", argValue(args, "-fw_cfg"))
// PCI-specific flags must NOT appear
assert.False(t, argExists(args, "-bios"), "-bios must not appear for microvm direct-boot")
// PCI-specific devices must NOT appear
for _, a := range args {
assert.NotContains(t, a, "pcie-root-port", "pcie-root-port must not appear for microvm")
}
@@ -4,13 +4,17 @@ import (
"context"
"errors"
"fmt"
"os"
"path"
"reflect"
"regexp"
"strings"
"sync"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
)
// fakeNetworkPlumber records calls so tests can assert per-spec behaviour.
@@ -168,6 +172,32 @@ func TestMgmtTapName(t *testing.T) {
}
}
// TestMgmtTapNameMatchesNMUnmanagedGlob checks the udev glob setup-ovn.sh uses
// to keep NetworkManager off management TAPs matches every real TAP name, and
// not a host NIC that merely starts with "mg".
func TestMgmtTapNameMatchesNMUnmanagedGlob(t *testing.T) {
data, err := os.ReadFile("../../../../scripts/setup-ovn.sh")
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`(?m)^MGMT_TAP_GLOB='([^']+)'$`).FindSubmatch(data)
if m == nil {
t.Fatal("setup-ovn.sh: no MGMT_TAP_GLOB")
}
glob := string(m[1])
for range 1000 {
tap := MgmtTapName(identifiers.GenerateResourceID("i"))
if ok, err := path.Match(glob, tap); err != nil || !ok {
t.Fatalf("glob %q does not match TAP %q (err %v)", glob, tap, err)
}
}
for _, name := range []string{"mgmt0", "mgmt", "br-mgmt", "mg0123456789abcdef"} {
if ok, _ := path.Match(glob, name); ok {
t.Errorf("glob %q matches %q, which is not a management TAP", glob, name)
}
}
}
func TestOVSIfaceID(t *testing.T) {
tests := []struct {
eniID string
+11 -14
View File
@@ -7,20 +7,17 @@ import (
"strconv"
"strings"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
)
// qemuProcessPrefix matches both qemu-system-x86_64 and qemu-system-aarch64;
// /proc/<pid>/comm truncates at 15 bytes but this prefix is only 11.
const qemuProcessPrefix = "qemu-system"
// qemuProcRoot is the /proc mount scanned for live qemu-system processes.
// qemuProcRoot is the /proc mount scanned for live QEMU processes.
// Overridden in tests to avoid depending on the real host process table.
var qemuProcRoot = "/proc"
// scanLiveQEMUPIDs walks procRoot for processes whose comm starts with
// qemuProcessPrefix. procRoot is a parameter so tests point it at a
// fabricated directory instead of spawning a real qemu-system binary.
// scanLiveQEMUPIDs walks procRoot for processes whose comm names a QEMU
// guest. procRoot is a parameter so tests point it at a fabricated directory
// instead of spawning a real QEMU binary.
func scanLiveQEMUPIDs(procRoot string) ([]int, error) {
entries, err := os.ReadDir(procRoot)
if err != nil {
@@ -37,7 +34,7 @@ func scanLiveQEMUPIDs(procRoot string) ([]int, error) {
if err != nil {
continue
}
if strings.HasPrefix(strings.TrimSpace(string(comm)), qemuProcessPrefix) {
if hostos.IsQEMUProcess(string(comm)) {
pids = append(pids, pid)
}
}
@@ -67,7 +64,7 @@ func pidFileOwners(runtimeDir string) map[int]string {
return owners
}
// qemuOrphan is a live qemu-system PID with no matching instance in
// qemuOrphan is a live QEMU PID with no matching instance in
// Snapshot(). hasPidFile distinguishes two very different situations: a
// pidfile this daemon itself wrote (instanceID is that pidfile's claim,
// positively this daemon's process) versus no pidfile at all (could be
@@ -78,7 +75,7 @@ type qemuOrphan struct {
hasPidFile bool
}
// classifyQEMUOrphans returns every live qemu-system PID not vouched for by
// classifyQEMUOrphans returns every live QEMU PID not vouched for by
// a pidfile naming a known instance. This is the gap classifyRestoredInstances
// cannot see, since it only walks Snapshot().
func classifyQEMUOrphans(procRoot, runtimeDir string, known map[string]bool) ([]qemuOrphan, error) {
@@ -102,7 +99,7 @@ func classifyQEMUOrphans(procRoot, runtimeDir string, known map[string]bool) ([]
return orphans, nil
}
// reportRecordlessQEMUOrphans logs, but never kills, a qemu-system process
// reportRecordlessQEMUOrphans logs, but never kills, a QEMU process
// with no instance record. A pidfile in runtimeDir naming an unknown
// instance positively identifies the process as this daemon's own stale
// orphan (logged distinctly, safe for an operator to reap by hand); no
@@ -123,11 +120,11 @@ func (m *Manager) reportRecordlessQEMUOrphans() {
}
for _, o := range orphans {
if o.hasPidFile {
slog.Warn("qemu-system process has a stale spinifex pidfile with no matching instance record; not killing, safe to reap manually",
slog.Warn("QEMU process has a stale spinifex pidfile with no matching instance record; not killing, safe to reap manually",
"pid", o.pid, "instanceId", o.instanceID)
continue
}
slog.Warn("qemu-system process has no spinifex pidfile at all; not killing, ownership unconfirmed",
slog.Warn("QEMU process has no spinifex pidfile at all; not killing, ownership unconfirmed",
"pid", o.pid)
}
}
@@ -24,13 +24,16 @@ func TestScanLiveQEMUPIDs(t *testing.T) {
fakeProcEntry(t, procRoot, 111, "qemu-system-x86_64")
fakeProcEntry(t, procRoot, 222, "qemu-system-aarch64")
fakeProcEntry(t, procRoot, 333, "bash")
// EL-family hosts run QEMU as /usr/libexec/qemu-kvm.
fakeProcEntry(t, procRoot, 444, "qemu-kvm")
fakeProcEntry(t, procRoot, 555, "qemu-img")
// Non-numeric /proc entries (self, curproc, etc.) must not crash the scan.
require.NoError(t, os.MkdirAll(filepath.Join(procRoot, "self"), 0o755))
pids, err := scanLiveQEMUPIDs(procRoot)
require.NoError(t, err)
assert.ElementsMatch(t, []int{111, 222}, pids, "only qemu-system* processes are returned")
assert.ElementsMatch(t, []int{111, 222, 444}, pids, "only QEMU guest processes are returned")
}
func TestScanLiveQEMUPIDs_MissingProcRoot(t *testing.T) {
@@ -0,0 +1,70 @@
package vm
import (
"errors"
"fmt"
"os"
"testing"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
resolveQEMUBinary = fakeQEMUBinary
os.Exit(m.Run())
}
// fakeQEMUBinary stands in for hostos.QEMUBinary so Execute tests need no QEMU
// installed on the host.
func fakeQEMUBinary(arch string) (string, error) {
switch arch {
case "x86_64":
return "/usr/bin/qemu-system-x86_64", nil
case "arm64":
return "/usr/bin/qemu-system-aarch64", nil
}
return "", fmt.Errorf("no QEMU for guest architecture %q", arch)
}
func withQEMUResolver(t *testing.T, fn func(string) (string, error)) {
t.Helper()
orig := resolveQEMUBinary
resolveQEMUBinary = fn
t.Cleanup(func() { resolveQEMUBinary = orig })
}
func minimalConfig() Config {
return Config{
CPUCount: 1,
Memory: 512,
Architecture: "x86_64",
Drives: []Drive{{File: "disk.img", Format: "raw"}},
}
}
func TestExecute_RunsTheResolvedQEMUBinary(t *testing.T) {
var asked string
withQEMUResolver(t, func(arch string) (string, error) {
asked = arch
return hostos.QEMUKVMPath, nil
})
cfg := minimalConfig()
cmd, err := cfg.Execute()
require.NoError(t, err)
assert.Equal(t, "x86_64", asked)
assert.Equal(t, hostos.QEMUKVMPath, cmd.Path)
assert.Equal(t, hostos.QEMUKVMPath, cmd.Args[0])
}
func TestExecute_FailsWhenNoQEMUResolves(t *testing.T) {
noQEMU := errors.New("no QEMU for x86_64 guests: found neither /usr/libexec/qemu-kvm (package qemu-kvm) nor qemu-system-x86_64 (package qemu-system-x86) on PATH")
withQEMUResolver(t, func(string) (string, error) { return "", noQEMU })
cfg := minimalConfig()
cmd, err := cfg.Execute()
assert.Nil(t, cmd)
assert.ErrorIs(t, err, noQEMU)
}
+20 -12
View File
@@ -21,6 +21,7 @@ import (
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/gpu"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/qmp"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
)
// InstanceHealthState tracks crash detection and auto-restart metadata for a VM.
@@ -408,6 +409,11 @@ type Config struct {
SMBIOSManufacturer string `json:"smbios_manufacturer,omitempty"`
SMBIOSAssetTag string `json:"smbios_asset_tag,omitempty"`
// BIOS is a firmware image passed with -bios. The microvm machine needs
// one: its default, bios-microvm.bin, is not shipped under that name by
// every distro.
BIOS string `json:"bios,omitempty"`
KernelImage string `json:"kernel_image,omitempty"` // path to vmlinuz; emits -kernel when set
Initrd string `json:"initrd,omitempty"` // path to initramfs; emits -initrd when set
KernelCmdline string `json:"kernel_cmdline,omitempty"` // emits -append when set
@@ -420,6 +426,9 @@ type FwCfgEntry struct {
File string `json:"file"`
}
// resolveQEMUBinary is a variable so tests run without QEMU installed.
var resolveQEMUBinary = hostos.QEMUBinary
func (cfg *Config) Execute() (*exec.Cmd, error) {
args := []string{}
@@ -593,6 +602,10 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
args = append(args, "-netdev", netdev.Value)
}
if cfg.BIOS != "" {
args = append(args, "-bios", cfg.BIOS)
}
if cfg.KernelImage != "" {
args = append(args, "-kernel", cfg.KernelImage)
}
@@ -609,18 +622,13 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
args = append(args, "-fw_cfg", fmt.Sprintf("name=%s,file=%s", fw.Name, fw.File))
}
var qemuArchitecture string
switch cfg.Architecture {
case "arm64":
qemuArchitecture = "qemu-system-aarch64"
case "x86_64":
qemuArchitecture = "qemu-system-x86_64"
default:
if cfg.Architecture == "" {
return nil, fmt.Errorf("architecture missing")
}
qemuBinary, err := resolveQEMUBinary(cfg.Architecture)
if err != nil {
return nil, err
}
// arm64 q35 is incompatible with the q35 PC machine; override to virt.
// Firmware loading is unified: UseUEFI emits pflash drives above; no
@@ -632,9 +640,9 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
args = append(args, "-M", cfg.MachineType)
}
slog.Info("Executing QEMU command:", "cmd", qemuArchitecture, "args", args)
slog.Info("Executing QEMU command:", "cmd", qemuBinary, "args", args)
cmd := exec.Command(qemuArchitecture, args...)
cmd := exec.Command(qemuBinary, args...)
//cmd.Stdout = os.Stdout
//cmd.Stderr = os.Stderr
+14 -5
View File
@@ -100,10 +100,7 @@ func newConfigurator() *configurator {
_, err := hostcommand.RunCommandWithTimeout(resolverCommandTimeout, "systemctl", "is-active", "--quiet", "systemd-resolved")
return err == nil
},
hasResolvconf: func() bool {
_, err := exec.LookPath("resolvconf")
return err == nil
},
hasResolvconf: func() bool { return resolvconfOnPath(exec.LookPath) },
restartResolved: func() error {
_, err := hostcommand.RunCommandWithTimeout(resolverCommandTimeout, "systemctl", "restart", "systemd-resolved")
return err
@@ -118,6 +115,18 @@ func newConfigurator() *configurator {
}
}
// resolvconfOnPath reports whether a real resolvconf is on PATH. systemd-resolved
// ships resolvconf as a link to resolvectl, which only works while resolved runs,
// so that one is not a resolvconf backend.
func resolvconfOnPath(lookPath func(string) (string, error)) bool {
path, err := lookPath("resolvconf")
if err != nil {
return false
}
target, err := filepath.EvalSymlinks(path)
return err == nil && filepath.Base(target) != "resolvectl"
}
// configure selects the host's resolver manager and applies the northstar route.
// systemd-resolved wins when active because its route-only drop-in is the least
// disruptive; otherwise resolvconf's head fragment prepends northstar.
@@ -132,7 +141,7 @@ func (c *configurator) configure(p Params) error {
return c.configureResolvconf(p)
default:
return fmt.Errorf(
"no supported resolver manager (systemd-resolved or resolvconf) is present; "+
"no supported resolver manager: systemd-resolved is not active and resolvconf is not installed; "+
"manually forward the Spinifex zones %s and %s to %s:53",
p.BaseDomain, p.InternalDomain, p.ResolverIP)
}
+44
View File
@@ -2,6 +2,7 @@ package hostdns
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -203,3 +204,46 @@ func writeFile(t *testing.T, path string, content []byte) {
t.Fatalf("write %s: %v", path, err)
}
}
func TestResolvconfOnPath(t *testing.T) {
dir := t.TempDir()
write := func(name string) string {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
return path
}
link := func(name, target string) string {
path := filepath.Join(dir, name)
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
return path
}
resolvconf := write("resolvconf")
resolvectl := write("resolvectl")
// The layout systemd-resolved installs: /usr/sbin/resolvconf -> ../bin/resolvectl.
compat := link("resolvconf-compat", "resolvectl")
dangling := link("resolvconf-dangling", "missing")
for name, tc := range map[string]struct {
path string
err error
want bool
}{
"real resolvconf": {path: resolvconf, want: true},
"link to a real resolvconf": {path: link("resolvconf-link", resolvconf), want: true},
"resolvectl compat link": {path: compat, want: false},
"resolvectl itself": {path: resolvectl, want: false},
"dangling link": {path: dangling, want: false},
"not on PATH": {err: exec.ErrNotFound, want: false},
} {
t.Run(name, func(t *testing.T) {
lookPath := func(string) (string, error) { return tc.path, tc.err }
if got := resolvconfOnPath(lookPath); got != tc.want {
t.Errorf("resolvconfOnPath = %v, want %v", got, tc.want)
}
})
}
}
+423
View File
@@ -0,0 +1,423 @@
package hostnet
import (
"context"
"errors"
"fmt"
"log/slog"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
const (
// DefaultLinkWait bounds the wait for links other components create: OVS
// bridges, and NetworkManager's uplink bridge and NICs.
DefaultLinkWait = 60 * time.Second
// DefaultLockWait bounds the wait behind setup-ovn.sh, so a stuck setup
// cannot hang boot.
DefaultLockWait = 120 * time.Second
pollInterval = 500 * time.Millisecond
)
// Env is everything Apply and Check touch on the host.
type Env struct {
Paths Paths
Host Host
LinkWait time.Duration
LockWait time.Duration
// OVNRemote returns external_ids:ovn-remote, or "" when unset.
OVNRemote func(ctx context.Context) (string, error)
// Sleep waits between polls; tests replace it to advance a fake clock.
Sleep func(time.Duration)
Now func() time.Time
}
// DefaultEnv returns the Env for a live host.
func DefaultEnv() Env {
return Env{
Paths: DefaultPaths(),
Host: NewIPHost(),
LinkWait: DefaultLinkWait,
LockWait: DefaultLockWait,
OVNRemote: func(ctx context.Context) (string, error) { return ovnRemote(ctx, execRunner{}) },
Sleep: time.Sleep,
Now: time.Now,
}
}
// resolve decides what this node needs: nil state means nothing to apply.
// Apply and Check share it, so every case that fails Apply fails Check.
func resolve(ctx context.Context, env Env) (*State, error) {
backend, err := ReadBackend(env.Paths.Backend)
if err != nil {
return nil, err
}
switch backend {
case BackendNetworkd:
return nil, nil
case BackendHostnet:
return LoadState(env.Paths.State)
}
// No record: a node set up before the record existed has networkd files.
legacy, err := hasNetworkdFiles(env.Paths.NetworkdDir)
if err != nil {
return nil, fmt.Errorf("look for networkd files in %s: %w", env.Paths.NetworkdDir, err)
}
if legacy {
return nil, nil
}
remote, err := env.OVNRemote(ctx)
if err != nil {
return nil, fmt.Errorf("no backend record at %s, and cannot tell whether this node is set up: %w", env.Paths.Backend, err)
}
if remote != "" {
return nil, fmt.Errorf("this node is set up (external_ids:ovn-remote=%s) but has no backend record at %s and no networkd files; rerun setup-ovn.sh", remote, env.Paths.Backend)
}
return nil, nil
}
// Check reports whether the daemon and vpcd may start: the same decision as
// Apply, plus, on the hostnet backend, proof the last Apply succeeded.
func Check(ctx context.Context, env Env) error {
s, err := resolve(ctx, env)
if err != nil {
return err
}
if s == nil {
return nil
}
if _, err := os.Stat(env.Paths.Marker); err != nil {
return fmt.Errorf("host network not applied (no %s): see `systemctl status spinifex-hostnet` and `journalctl -u spinifex-hostnet`", env.Paths.Marker)
}
return nil
}
// Apply makes the host match the state file, under the hostnet lock. It
// removes the readiness marker first and writes it only on success.
func Apply(ctx context.Context, env Env) error {
unlock, err := acquireLock(env)
if err != nil {
return err
}
defer unlock()
if err := os.Remove(env.Paths.Marker); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove %s: %w", env.Paths.Marker, err)
}
s, err := resolve(ctx, env)
if err != nil {
return err
}
if s == nil {
slog.Info("hostnet: nothing to apply on this node")
return nil
}
if err := reconcile(ctx, env, s); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(env.Paths.Marker), 0o770); err != nil { //nolint:gosec // G301: the mode setup.sh's tmpfiles entry gives /run/spinifex, which the daemon and vpcd traverse to check the marker. Holds no secret material.
return fmt.Errorf("create %s: %w", filepath.Dir(env.Paths.Marker), err)
}
stamp := env.Now().UTC().Format(time.RFC3339) + "\n"
if err := os.WriteFile(env.Paths.Marker, []byte(stamp), 0o600); err != nil {
return fmt.Errorf("write %s: %w", env.Paths.Marker, err)
}
slog.Info("hostnet: host network applied", "state", env.Paths.State)
return nil
}
func reconcile(ctx context.Context, env Env, s *State) error {
if err := waitForLinks(ctx, env, s); err != nil {
return err
}
h := env.Host
if err := ensureUp(ctx, h, s.IntBridge); err != nil {
return err
}
if w := s.WAN; w != nil {
if err := ensureUp(ctx, h, w.OVSBridge); err != nil {
return err
}
switch w.Mode {
case ModeVeth:
if err := ensureVethPair(ctx, h, w.HostEnd, w.OVSEnd); err != nil {
return err
}
if err := ensureMaster(ctx, h, w.HostEnd, w.LinuxBridge); err != nil {
return err
}
case ModeNAT:
if err := ensureVethPair(ctx, h, w.HostEnd, w.OVSEnd); err != nil {
return err
}
if err := ensureMAC(ctx, h, w.HostEnd, w.HostMAC); err != nil {
return err
}
if err := ensureAddr(ctx, h, w.HostEnd, w.HostCIDR); err != nil {
return err
}
case ModeDirect:
if err := ensurePortNIC(ctx, h, w.NIC); err != nil {
return err
}
}
if w.Mode == ModeVeth || w.Mode == ModeNAT {
for _, end := range []string{w.HostEnd, w.OVSEnd} {
if err := ensureUp(ctx, h, end); err != nil {
return err
}
}
}
}
if m := s.Mgmt; m != nil {
if err := ensureUp(ctx, h, m.Bridge); err != nil {
return err
}
if m.CIDR != "" {
if err := ensureAddr(ctx, h, m.Bridge, m.CIDR); err != nil {
return err
}
}
if m.NIC != "" {
if err := ensurePortNIC(ctx, h, m.NIC); err != nil {
return err
}
}
}
if r := s.EncapRoute; r != nil {
if err := ensureRoute(ctx, h, *r); err != nil {
return err
}
}
return nil
}
// waitForLinks waits for the links other components create and the recorded
// topology needs. The veth pairs are not waited for: Apply creates them.
func waitForLinks(ctx context.Context, env Env, s *State) error {
needed := []string{s.IntBridge}
if w := s.WAN; w != nil {
needed = append(needed, w.OVSBridge)
switch w.Mode {
case ModeVeth:
needed = append(needed, w.LinuxBridge)
case ModeDirect:
needed = append(needed, w.NIC)
}
}
if m := s.Mgmt; m != nil {
needed = append(needed, m.Bridge)
if m.NIC != "" {
needed = append(needed, m.NIC)
}
}
if r := s.EncapRoute; r != nil {
needed = append(needed, r.Dev)
}
needed = slices.Compact(slices.Sorted(slices.Values(needed)))
deadline := env.Now().Add(env.LinkWait)
for {
var missing []string
for _, name := range needed {
l, err := env.Host.Link(ctx, name)
if err != nil {
return err
}
if l == nil {
missing = append(missing, name)
}
}
// The encap route's source address must be up before the route can
// name it; on a DHCP uplink it can arrive after the link does.
if len(missing) == 0 && s.EncapRoute != nil {
has, err := hasAddr(ctx, env.Host, s.EncapRoute.Dev, s.EncapRoute.Src)
if err != nil {
return err
}
if !has {
missing = append(missing, s.EncapRoute.Src+" on "+s.EncapRoute.Dev)
}
}
if len(missing) == 0 {
return nil
}
if !env.Now().Before(deadline) {
return fmt.Errorf("still missing after %s: %s", env.LinkWait, strings.Join(missing, ", "))
}
env.Sleep(pollInterval)
if err := ctx.Err(); err != nil {
return err
}
}
}
func ensureUp(ctx context.Context, h Host, name string) error {
l, err := h.Link(ctx, name)
if err != nil {
return err
}
if l == nil {
return fmt.Errorf("%s: link missing", name)
}
if l.Up {
return nil
}
if err := h.SetUp(ctx, name); err != nil {
return fmt.Errorf("set %s up: %w", name, err)
}
slog.Info("hostnet: set link up", "link", name)
return nil
}
// ensureVethPair creates the pair when neither end exists, and refuses any
// other link already holding either name.
func ensureVethPair(ctx context.Context, h Host, a, b string) error {
la, err := h.Link(ctx, a)
if err != nil {
return err
}
lb, err := h.Link(ctx, b)
if err != nil {
return err
}
if la == nil && lb == nil {
if err := h.AddVethPair(ctx, a, b); err != nil {
return fmt.Errorf("create veth pair %s/%s: %w", a, b, err)
}
slog.Info("hostnet: created veth pair", "link", a, "peer", b)
return nil
}
for _, l := range []*Link{la, lb} {
if l != nil && l.Kind != "veth" {
return fmt.Errorf("refusing to use %s: it is a %s link, not one end of the %s/%s veth pair", l.Name, kindName(l.Kind), a, b)
}
}
if la == nil || lb == nil || la.Peer != b || lb.Peer != a {
return fmt.Errorf("refusing to use veth pair %s/%s: %s", a, b, describePeers(a, b, la, lb))
}
return nil
}
func describePeers(a, b string, la, lb *Link) string {
switch {
case la == nil:
return fmt.Sprintf("%s is missing and %s is peered with %q", a, b, lb.Peer)
case lb == nil:
return fmt.Sprintf("%s is missing and %s is peered with %q", b, a, la.Peer)
default:
return fmt.Sprintf("%s is peered with %q and %s with %q", a, la.Peer, b, lb.Peer)
}
}
func kindName(kind string) string {
if kind == "" {
return "physical"
}
return kind
}
func ensureMaster(ctx context.Context, h Host, name, master string) error {
l, err := h.Link(ctx, name)
if err != nil {
return err
}
if l == nil {
return fmt.Errorf("%s: link missing", name)
}
if l.Master == master {
return nil
}
if err := h.SetMaster(ctx, name, master); err != nil {
return fmt.Errorf("attach %s to %s: %w", name, master, err)
}
slog.Info("hostnet: attached link to bridge", "link", name, "bridge", master)
return nil
}
func ensureMAC(ctx context.Context, h Host, name, mac string) error {
l, err := h.Link(ctx, name)
if err != nil {
return err
}
if l == nil {
return fmt.Errorf("%s: link missing", name)
}
if strings.EqualFold(l.MAC, mac) {
return nil
}
if err := h.SetMAC(ctx, name, mac); err != nil {
return fmt.Errorf("set %s MAC %s: %w", name, mac, err)
}
slog.Info("hostnet: set MAC", "link", name, "mac", mac)
return nil
}
func ensureAddr(ctx context.Context, h Host, name, cidr string) error {
addrs, err := h.IPv4Addrs(ctx, name)
if err != nil {
return err
}
if slices.Contains(addrs, cidr) {
return nil
}
if err := h.AddAddr(ctx, name, cidr); err != nil {
return fmt.Errorf("add %s to %s: %w", cidr, name, err)
}
slog.Info("hostnet: added address", "link", name, "cidr", cidr)
return nil
}
func hasAddr(ctx context.Context, h Host, name, ip string) (bool, error) {
addrs, err := h.IPv4Addrs(ctx, name)
if err != nil {
return false, err
}
for _, a := range addrs {
if p, err := netip.ParsePrefix(a); err == nil && p.Addr().String() == ip {
return true, nil
}
}
return false, nil
}
// ensurePortNIC brings up a NIC that is an OVS port and strips any IPv4
// address NetworkManager restored to it; the address belongs on the bridge.
func ensurePortNIC(ctx context.Context, h Host, name string) error {
if err := ensureUp(ctx, h, name); err != nil {
return err
}
addrs, err := h.IPv4Addrs(ctx, name)
if err != nil {
return err
}
for _, a := range addrs {
if err := h.DelAddr(ctx, name, a); err != nil {
return fmt.Errorf("remove %s from %s: %w", a, name, err)
}
slog.Info("hostnet: removed address from OVS port", "link", name, "cidr", a)
}
return nil
}
func ensureRoute(ctx context.Context, h Host, r Route) error {
routes, err := h.Routes(ctx, r.Dst)
if err != nil {
return err
}
for _, e := range routes {
if e.Metric == r.Metric && e.Dev == r.Dev && e.Src == r.Src {
return nil
}
}
if err := h.ReplaceRoute(ctx, r); err != nil {
return fmt.Errorf("encap route %s dev %s src %s metric %d: %w", r.Dst, r.Dev, r.Src, r.Metric, err)
}
slog.Info("hostnet: set encap route", "dst", r.Dst, "dev", r.Dev, "src", r.Src, "metric", r.Metric)
return nil
}
+216
View File
@@ -0,0 +1,216 @@
package hostnet
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"slices"
"strconv"
"strings"
)
// Link is the part of a kernel link Apply compares against the state file.
type Link struct {
Name string
// Kind is the rtnetlink link kind ("veth", "bridge", "openvswitch"), or
// "" for a physical device.
Kind string
// Peer is the other end of a veth in the same namespace.
Peer string
Master string
MAC string
Up bool
}
// RouteEntry is one IPv4 route to a destination.
type RouteEntry struct {
Dev string
Src string
Metric int
}
// Host reads and changes kernel network state. It has no way to delete a
// link: Apply only creates and adjusts, so a link it does not name, or one it
// wrongly matched, is never destroyed.
type Host interface {
// Link returns nil, nil when no link has this name.
Link(ctx context.Context, name string) (*Link, error)
AddVethPair(ctx context.Context, name, peer string) error
SetUp(ctx context.Context, name string) error
SetMaster(ctx context.Context, name, master string) error
SetMAC(ctx context.Context, name, mac string) error
// IPv4Addrs returns the link's IPv4 addresses as CIDR prefixes.
IPv4Addrs(ctx context.Context, name string) ([]string, error)
AddAddr(ctx context.Context, name, cidr string) error
DelAddr(ctx context.Context, name, cidr string) error
Routes(ctx context.Context, dst string) ([]RouteEntry, error)
ReplaceRoute(ctx context.Context, r Route) error
}
// Runner runs a host command and returns its stdout.
type Runner interface {
Run(ctx context.Context, name string, args ...string) ([]byte, error)
}
type execRunner struct{}
func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
}
return out, nil
}
// IPHost is the Host backed by iproute2's JSON output.
type IPHost struct {
Runner Runner
}
var _ Host = (*IPHost)(nil)
// NewIPHost returns an IPHost that runs ip directly; Apply runs as root.
func NewIPHost() *IPHost { return &IPHost{Runner: execRunner{}} }
func (h *IPHost) ip(ctx context.Context, args ...string) ([]byte, error) {
return h.Runner.Run(ctx, "ip", args...)
}
type ipLink struct {
IfName string `json:"ifname"`
Link string `json:"link"`
Master string `json:"master"`
Address string `json:"address"`
Flags []string `json:"flags"`
LinkInfo struct {
InfoKind string `json:"info_kind"`
} `json:"linkinfo"`
}
// Link lists every link rather than asking for one by name, so a missing link
// is told apart from a failed ip by the output, not by matching error text.
func (h *IPHost) Link(ctx context.Context, name string) (*Link, error) {
out, err := h.ip(ctx, "-j", "-d", "link", "show")
if err != nil {
return nil, err
}
var links []ipLink
if err := json.Unmarshal(out, &links); err != nil {
return nil, fmt.Errorf("parse ip link output: %w", err)
}
for _, l := range links {
if l.IfName != name {
continue
}
return &Link{
Name: l.IfName,
Kind: l.LinkInfo.InfoKind,
Peer: l.Link,
Master: l.Master,
MAC: strings.ToLower(l.Address),
Up: slices.Contains(l.Flags, "UP"),
}, nil
}
return nil, nil
}
func (h *IPHost) AddVethPair(ctx context.Context, name, peer string) error {
_, err := h.ip(ctx, "link", "add", name, "type", "veth", "peer", "name", peer)
return err
}
func (h *IPHost) SetUp(ctx context.Context, name string) error {
_, err := h.ip(ctx, "link", "set", "dev", name, "up")
return err
}
func (h *IPHost) SetMaster(ctx context.Context, name, master string) error {
_, err := h.ip(ctx, "link", "set", "dev", name, "master", master)
return err
}
func (h *IPHost) SetMAC(ctx context.Context, name, mac string) error {
_, err := h.ip(ctx, "link", "set", "dev", name, "address", mac)
return err
}
func (h *IPHost) IPv4Addrs(ctx context.Context, name string) ([]string, error) {
out, err := h.ip(ctx, "-j", "-4", "addr", "show", "dev", name)
if err != nil {
return nil, err
}
var links []struct {
AddrInfo []struct {
Local string `json:"local"`
PrefixLen int `json:"prefixlen"`
} `json:"addr_info"`
}
if err := json.Unmarshal(out, &links); err != nil {
return nil, fmt.Errorf("parse ip addr output for %s: %w", name, err)
}
var addrs []string
for _, l := range links {
for _, a := range l.AddrInfo {
addrs = append(addrs, a.Local+"/"+strconv.Itoa(a.PrefixLen))
}
}
return addrs, nil
}
func (h *IPHost) AddAddr(ctx context.Context, name, cidr string) error {
_, err := h.ip(ctx, "addr", "add", cidr, "dev", name)
return err
}
func (h *IPHost) DelAddr(ctx context.Context, name, cidr string) error {
_, err := h.ip(ctx, "addr", "del", cidr, "dev", name)
return err
}
func (h *IPHost) Routes(ctx context.Context, dst string) ([]RouteEntry, error) {
out, err := h.ip(ctx, "-j", "-4", "route", "show", "exact", dst)
if err != nil {
return nil, err
}
var routes []struct {
Dev string `json:"dev"`
PrefSrc string `json:"prefsrc"`
Metric int `json:"metric"`
Protocol string `json:"protocol"`
}
if err := json.Unmarshal(out, &routes); err != nil {
return nil, fmt.Errorf("parse ip route output for %s: %w", dst, err)
}
entries := make([]RouteEntry, 0, len(routes))
for _, r := range routes {
entries = append(entries, RouteEntry{Dev: r.Dev, Src: r.PrefSrc, Metric: r.Metric})
}
return entries, nil
}
func (h *IPHost) ReplaceRoute(ctx context.Context, r Route) error {
_, err := h.ip(ctx, "route", "replace", r.Dst, "dev", r.Dev, "src", r.Src, "metric", strconv.Itoa(r.Metric))
return err
}
// ovnRemote returns the node's external_ids:ovn-remote, or "" when it is unset
// or OVS is not installed.
func ovnRemote(ctx context.Context, r Runner) (string, error) {
if !onPath("ovs-vsctl") {
return "", nil
}
out, err := r.Run(ctx, "ovs-vsctl", "--timeout=5", "--if-exists", "get", "Open_vSwitch", ".", "external_ids:ovn-remote")
if err != nil {
return "", fmt.Errorf("read external_ids:ovn-remote: %w", err)
}
return strings.Trim(strings.TrimSpace(string(out)), `"`), nil
}
func onPath(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
+169
View File
@@ -0,0 +1,169 @@
package hostnet
import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"golang.org/x/sys/unix"
)
// scriptRunner answers ip commands from canned output keyed by the joined
// command line, and records every command it is asked to run.
type scriptRunner struct {
out map[string]string
runs []string
}
func (r *scriptRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
line := name + " " + strings.Join(args, " ")
r.runs = append(r.runs, line)
if out, ok := r.out[line]; ok {
return []byte(out), nil
}
if strings.HasPrefix(line, "ip -j") {
return nil, fmt.Errorf("unexpected query %q", line)
}
return nil, nil
}
// Captured from iproute2 on Debian 13.
const ipLinkJSON = `[
{"ifindex":13,"link":"veth-wan-ovs","ifname":"veth-wan-br","flags":["BROADCAST","MULTICAST","UP","LOWER_UP"],"master":"br-wan","address":"1E:90:24:9F:95:0C","linkinfo":{"info_kind":"veth","info_slave_kind":"bridge"}},
{"ifindex":12,"link":"veth-wan-br","ifname":"veth-wan-ovs","flags":["BROADCAST","MULTICAST","UP","LOWER_UP"],"master":"ovs-system","address":"5a:1c:00:00:00:01","linkinfo":{"info_kind":"veth","info_slave_kind":"openvswitch"}},
{"ifindex":9,"ifname":"br-int","flags":["BROADCAST","MULTICAST"],"address":"6e:00:00:00:00:02","linkinfo":{"info_kind":"openvswitch"}},
{"ifindex":2,"ifname":"eth1","flags":["BROADCAST","MULTICAST","UP"],"address":"52:54:00:00:00:03"}
]`
func TestIPHostLink(t *testing.T) {
r := &scriptRunner{out: map[string]string{"ip -j -d link show": ipLinkJSON}}
h := &IPHost{Runner: r}
ctx := context.Background()
for _, tc := range []struct {
name string
want *Link
}{
{"veth-wan-br", &Link{Name: "veth-wan-br", Kind: "veth", Peer: "veth-wan-ovs", Master: "br-wan", MAC: "1e:90:24:9f:95:0c", Up: true}},
{"br-int", &Link{Name: "br-int", Kind: "openvswitch", MAC: "6e:00:00:00:00:02"}},
{"eth1", &Link{Name: "eth1", MAC: "52:54:00:00:00:03", Up: true}},
{"br-wan-missing", nil},
} {
got, err := h.Link(ctx, tc.name)
if err != nil {
t.Fatalf("Link(%s): %v", tc.name, err)
}
if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
t.Errorf("Link(%s) = %+v, want %+v", tc.name, got, tc.want)
}
}
}
func TestIPHostAddrsAndRoutes(t *testing.T) {
r := &scriptRunner{out: map[string]string{
"ip -j -4 addr show dev br-mgmt": `[{"ifname":"br-mgmt","addr_info":[{"family":"inet","local":"10.15.8.1","prefixlen":24}]}]`,
"ip -j -4 route show exact 10.15.8.0/24": `[{"dst":"10.15.8.0/24","dev":"br-mgmt","protocol":"kernel","scope":"link","prefsrc":"10.15.8.1","flags":[]},{"dst":"10.15.8.0/24","dev":"br-mgmt","prefsrc":"10.15.8.1","metric":50,"flags":[]}]`,
}}
h := &IPHost{Runner: r}
ctx := context.Background()
addrs, err := h.IPv4Addrs(ctx, "br-mgmt")
if err != nil || !slices.Equal(addrs, []string{"10.15.8.1/24"}) {
t.Errorf("IPv4Addrs = %q, %v", addrs, err)
}
routes, err := h.Routes(ctx, "10.15.8.0/24")
want := []RouteEntry{{Dev: "br-mgmt", Src: "10.15.8.1"}, {Dev: "br-mgmt", Src: "10.15.8.1", Metric: 50}}
if err != nil || !slices.Equal(routes, want) {
t.Errorf("Routes = %+v, %v; want %+v", routes, err, want)
}
}
// TestApplyNeverDeletesALink runs a full apply through IPHost, so the check is
// on the ip commands themselves, not on the Host interface.
func TestApplyNeverDeletesALink(t *testing.T) {
r := &scriptRunner{out: map[string]string{
"ip -j -d link show": `[
{"ifname":"br-int","flags":[],"linkinfo":{"info_kind":"openvswitch"}},
{"ifname":"br-ext","flags":[],"linkinfo":{"info_kind":"openvswitch"}},
{"ifname":"br-wan","flags":["UP"],"linkinfo":{"info_kind":"bridge"}},
{"ifname":"br-mgmt","flags":[],"linkinfo":{"info_kind":"openvswitch"}},
{"ifname":"br-vpc","flags":["UP"],"linkinfo":{"info_kind":"bridge"}},
{"ifname":"mgmt","flags":[]}
]`,
"ip -j -4 addr show dev br-mgmt": `[]`,
"ip -j -4 addr show dev mgmt": `[{"addr_info":[{"local":"169.254.3.4","prefixlen":16}]}]`,
"ip -j -4 addr show dev br-vpc": `[{"addr_info":[{"local":"10.1.0.5","prefixlen":16}]}]`,
"ip -j -4 route show exact 10.1.0.0/16": `[]`,
}}
te := newTestEnv(t, nil)
te.Host = &IPHost{Runner: r}
te.hostnetBackend(t, vethState)
// The canned link list never gains the veth pair, so this apply fails
// once it looks for it again; every command up to then is still checked.
_ = Apply(context.Background(), te.Env)
if !slices.Contains(r.runs, "ip link add veth-wan-br type veth peer name veth-wan-ovs") {
t.Fatalf("apply did not reach the veth pair: %q", r.runs)
}
for _, run := range r.runs {
f := strings.Fields(run)
if len(f) >= 3 && f[1] == "link" && (f[2] == "del" || f[2] == "delete") {
t.Errorf("apply deleted a link: %q", run)
}
}
}
func TestApplyTakesLockBeforeReadingState(t *testing.T) {
te := newTestEnv(t, newFakeHost())
te.LockWait = 2 * time.Second
te.hostnetBackend(t, "{")
held := holdLock(t, te.Paths.Lock)
// Replace the broken state while Apply waits on the lock: it must read the
// replacement, so the wait is where the state file changes.
te.Sleep = func(time.Duration) {
te.write(t, te.Paths.State, natState)
te.host.add(Link{Name: "br-int", Kind: "openvswitch"}).add(Link{Name: "br-ext", Kind: "openvswitch"})
held.Close()
}
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("Apply read the state before taking the lock: %v", err)
}
}
func TestApplyLockTimeoutNamesHolder(t *testing.T) {
te := newTestEnv(t, newFakeHost())
te.hostnetBackend(t, natState)
held := holdLock(t, te.Paths.Lock)
defer held.Close()
err := Apply(context.Background(), te.Env)
if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("still held after 2m0s by PID %d", os.Getpid())) {
t.Fatalf("Apply error = %v, want it to name this test's PID", err)
}
if te.slept < DefaultLockWait {
t.Errorf("gave up after %s, before the %s wait", te.slept, DefaultLockWait)
}
}
// holdLock takes the hostnet flock on a separate open file description, as
// setup-ovn.sh does from another process.
func holdLock(t *testing.T, path string) *os.File {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0o644)
if err != nil {
t.Fatal(err)
}
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
t.Fatal(err)
}
return f
}
@@ -0,0 +1,645 @@
package hostnet
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
// fakeHost models kernel links, addresses and routes, records every change,
// and can make links appear after a number of polls.
type fakeHost struct {
links map[string]*Link
addrs map[string][]string
routes map[string][]RouteEntry
changes []string
// appearAfter makes a link exist only from the nth Link lookup of it.
appearAfter map[string]int
lookups map[string]int
pending map[string]*Link
failOn string
}
func newFakeHost() *fakeHost {
return &fakeHost{
links: map[string]*Link{},
addrs: map[string][]string{},
routes: map[string][]RouteEntry{},
appearAfter: map[string]int{},
lookups: map[string]int{},
pending: map[string]*Link{},
}
}
func (f *fakeHost) add(l Link) *fakeHost {
f.links[l.Name] = &l
return f
}
func (f *fakeHost) later(l Link, lookups int) *fakeHost {
f.pending[l.Name] = &l
f.appearAfter[l.Name] = lookups
return f
}
func (f *fakeHost) change(format string, args ...any) error {
c := fmt.Sprintf(format, args...)
if f.failOn != "" && strings.HasPrefix(c, f.failOn) {
return errors.New("injected failure")
}
f.changes = append(f.changes, c)
return nil
}
func (f *fakeHost) Link(_ context.Context, name string) (*Link, error) {
f.lookups[name]++
if p, ok := f.pending[name]; ok && f.lookups[name] >= f.appearAfter[name] {
f.links[name] = p
delete(f.pending, name)
}
l, ok := f.links[name]
if !ok {
return nil, nil
}
c := *l
return &c, nil
}
func (f *fakeHost) AddVethPair(_ context.Context, name, peer string) error {
if err := f.change("add-veth %s %s", name, peer); err != nil {
return err
}
f.links[name] = &Link{Name: name, Kind: "veth", Peer: peer, MAC: "aa:aa:aa:aa:aa:01"}
f.links[peer] = &Link{Name: peer, Kind: "veth", Peer: name, MAC: "aa:aa:aa:aa:aa:02"}
return nil
}
func (f *fakeHost) SetUp(_ context.Context, name string) error {
if err := f.change("up %s", name); err != nil {
return err
}
f.links[name].Up = true
return nil
}
func (f *fakeHost) SetMaster(_ context.Context, name, master string) error {
if err := f.change("master %s %s", name, master); err != nil {
return err
}
f.links[name].Master = master
return nil
}
func (f *fakeHost) SetMAC(_ context.Context, name, mac string) error {
if err := f.change("mac %s %s", name, mac); err != nil {
return err
}
f.links[name].MAC = mac
return nil
}
func (f *fakeHost) IPv4Addrs(_ context.Context, name string) ([]string, error) {
return slices.Clone(f.addrs[name]), nil
}
func (f *fakeHost) AddAddr(_ context.Context, name, cidr string) error {
if err := f.change("addr-add %s %s", name, cidr); err != nil {
return err
}
f.addrs[name] = append(f.addrs[name], cidr)
return nil
}
func (f *fakeHost) DelAddr(_ context.Context, name, cidr string) error {
if err := f.change("addr-del %s %s", name, cidr); err != nil {
return err
}
f.addrs[name] = slices.DeleteFunc(f.addrs[name], func(a string) bool { return a == cidr })
return nil
}
func (f *fakeHost) Routes(_ context.Context, dst string) ([]RouteEntry, error) {
return slices.Clone(f.routes[dst]), nil
}
func (f *fakeHost) ReplaceRoute(_ context.Context, r Route) error {
if err := f.change("route %s %s %s %d", r.Dst, r.Dev, r.Src, r.Metric); err != nil {
return err
}
f.routes[r.Dst] = slices.DeleteFunc(f.routes[r.Dst], func(e RouteEntry) bool { return e.Metric == r.Metric })
f.routes[r.Dst] = append(f.routes[r.Dst], RouteEntry{Dev: r.Dev, Src: r.Src, Metric: r.Metric})
return nil
}
var _ Host = (*fakeHost)(nil)
// testEnv is a temp root with a fake clock that Sleep advances.
type testEnv struct {
Env
host *fakeHost
now time.Time
slept time.Duration
// remote is what OVNRemote returns; remoteErr fails it.
remote string
remoteErr error
}
func newTestEnv(t *testing.T, h *fakeHost) *testEnv {
t.Helper()
root := t.TempDir()
te := &testEnv{host: h, now: time.Date(2026, 10, 21, 0, 0, 0, 0, time.UTC)}
te.Env = Env{
Paths: PathsAt(root),
Host: h,
LinkWait: DefaultLinkWait,
LockWait: DefaultLockWait,
OVNRemote: func(context.Context) (string, error) { return te.remote, te.remoteErr },
Sleep: func(d time.Duration) {
te.now = te.now.Add(d)
te.slept += d
},
Now: func() time.Time { return te.now },
}
for _, dir := range []string{filepath.Dir(te.Paths.Backend), filepath.Dir(te.Paths.Marker), te.Paths.NetworkdDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
return te
}
func (te *testEnv) write(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func (te *testEnv) hostnetBackend(t *testing.T, state string) {
t.Helper()
te.write(t, te.Paths.Backend, "hostnet\n")
te.write(t, te.Paths.State, state)
}
func (te *testEnv) markerExists() bool {
_, err := os.Stat(te.Paths.Marker)
return err == nil
}
const vethState = `{
"version": 1,
"pending": false,
"int_bridge": "br-int",
"wan": {"mode": "veth", "ovs_bridge": "br-ext", "linux_bridge": "br-wan", "host_end": "veth-wan-br", "ovs_end": "veth-wan-ovs"},
"mgmt": {"bridge": "br-mgmt", "cidr": "10.15.8.1/24", "nic": "mgmt"},
"encap_route": {"dst": "10.1.0.0/16", "dev": "br-vpc", "src": "10.1.0.5", "metric": 50}
}`
const natState = `{
"version": 1,
"pending": false,
"int_bridge": "br-int",
"wan": {"mode": "nat", "ovs_bridge": "br-ext", "host_end": "spx-nat-host", "ovs_end": "spx-nat-ovs", "host_mac": "02:00:64:7f:00:01", "host_cidr": "100.127.0.1/24"}
}`
const directState = `{
"version": 1,
"pending": false,
"int_bridge": "br-int",
"wan": {"mode": "direct", "ovs_bridge": "br-ext", "nic": "eth1"}
}`
// rebootedVethHost is a veth-mode node just after a reboot: OVS and
// NetworkManager have brought back their own links, nothing else.
func rebootedVethHost() *fakeHost {
h := newFakeHost().
add(Link{Name: "br-int", Kind: "openvswitch"}).
add(Link{Name: "br-ext", Kind: "openvswitch"}).
add(Link{Name: "br-mgmt", Kind: "openvswitch"}).
add(Link{Name: "br-wan", Kind: "bridge", Up: true}).
add(Link{Name: "br-vpc", Kind: "bridge", Up: true}).
add(Link{Name: "mgmt", Up: false})
h.addrs["br-vpc"] = []string{"10.1.0.5/16"}
h.addrs["mgmt"] = []string{"169.254.3.4/16"}
return h
}
func TestApplyRebuildsVethModeAfterReboot(t *testing.T) {
h := rebootedVethHost()
te := newTestEnv(t, h)
te.hostnetBackend(t, vethState)
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("Apply: %v", err)
}
want := []string{
"up br-int",
"up br-ext",
"add-veth veth-wan-br veth-wan-ovs",
"master veth-wan-br br-wan",
"up veth-wan-br",
"up veth-wan-ovs",
"up br-mgmt",
"addr-add br-mgmt 10.15.8.1/24",
"up mgmt",
"addr-del mgmt 169.254.3.4/16",
"route 10.1.0.0/16 br-vpc 10.1.0.5 50",
}
if !slices.Equal(h.changes, want) {
t.Errorf("changes:\n got %q\nwant %q", h.changes, want)
}
if !te.markerExists() {
t.Error("marker not written after a successful apply")
}
}
func TestApplyIsIdempotent(t *testing.T) {
for name, tc := range map[string]struct {
state string
host func() *fakeHost
}{
"veth": {vethState, rebootedVethHost},
"nat": {natState, func() *fakeHost {
return newFakeHost().add(Link{Name: "br-int", Kind: "openvswitch"}).add(Link{Name: "br-ext", Kind: "openvswitch"})
}},
"direct": {directState, func() *fakeHost {
return newFakeHost().add(Link{Name: "br-int", Kind: "openvswitch"}).add(Link{Name: "br-ext", Kind: "openvswitch"}).add(Link{Name: "eth1"})
}},
} {
t.Run(name, func(t *testing.T) {
h := tc.host()
te := newTestEnv(t, h)
te.hostnetBackend(t, tc.state)
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("first Apply: %v", err)
}
h.changes = nil
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("second Apply: %v", err)
}
if len(h.changes) != 0 {
t.Errorf("second Apply changed the host: %q", h.changes)
}
})
}
}
func TestApplyNATSetsFixedMACAndTransitAddress(t *testing.T) {
h := newFakeHost().
add(Link{Name: "br-int", Kind: "openvswitch", Up: true}).
add(Link{Name: "br-ext", Kind: "openvswitch", Up: true}).
add(Link{Name: "spx-nat-host", Kind: "veth", Peer: "spx-nat-ovs", MAC: "be:ef:00:00:00:01"}).
add(Link{Name: "spx-nat-ovs", Kind: "veth", Peer: "spx-nat-host", Up: true})
te := newTestEnv(t, h)
te.hostnetBackend(t, natState)
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("Apply: %v", err)
}
want := []string{
"mac spx-nat-host 02:00:64:7f:00:01",
"addr-add spx-nat-host 100.127.0.1/24",
"up spx-nat-host",
}
if !slices.Equal(h.changes, want) {
t.Errorf("changes:\n got %q\nwant %q", h.changes, want)
}
}
func TestApplyMovesVethBridgeEndToRecordedBridge(t *testing.T) {
h := rebootedVethHost().
add(Link{Name: "veth-wan-br", Kind: "veth", Peer: "veth-wan-ovs", Master: "br-old", Up: true}).
add(Link{Name: "veth-wan-ovs", Kind: "veth", Peer: "veth-wan-br", Up: true})
te := newTestEnv(t, h)
te.hostnetBackend(t, vethState)
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("Apply: %v", err)
}
if !slices.Contains(h.changes, "master veth-wan-br br-wan") {
t.Errorf("veth bridge end not moved to br-wan: %q", h.changes)
}
if slices.ContainsFunc(h.changes, func(c string) bool { return strings.HasPrefix(c, "add-veth") }) {
t.Errorf("recreated a veth pair that already existed: %q", h.changes)
}
}
func TestApplyReplacesEncapRouteOnOtherDevice(t *testing.T) {
h := rebootedVethHost()
h.routes["10.1.0.0/16"] = []RouteEntry{
{Dev: "br-vpc", Src: "10.1.0.5", Metric: 0},
{Dev: "br-wan", Src: "10.1.0.5", Metric: 50},
}
te := newTestEnv(t, h)
te.hostnetBackend(t, vethState)
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("Apply: %v", err)
}
if !slices.Contains(h.changes, "route 10.1.0.0/16 br-vpc 10.1.0.5 50") {
t.Errorf("encap route on the wrong device not replaced: %q", h.changes)
}
}
func TestApplyRefusesForeignLinks(t *testing.T) {
for name, tc := range map[string]struct {
links []Link
want string
}{
"non-veth under the bridge end's name": {
[]Link{{Name: "veth-wan-br", Kind: "dummy"}},
"veth-wan-br: it is a dummy link",
},
"physical NIC under the OVS end's name": {
[]Link{{Name: "veth-wan-ovs"}},
"veth-wan-ovs: it is a physical link",
},
"ends peered elsewhere": {
[]Link{
{Name: "veth-wan-br", Kind: "veth", Peer: "other0"},
{Name: "veth-wan-ovs", Kind: "veth", Peer: "other1"},
},
`veth-wan-br is peered with "other0" and veth-wan-ovs with "other1"`,
},
"one end alone": {
[]Link{{Name: "veth-wan-ovs", Kind: "veth", Peer: "x"}},
"veth-wan-br is missing",
},
} {
t.Run(name, func(t *testing.T) {
h := rebootedVethHost()
for _, l := range tc.links {
h.add(l)
}
te := newTestEnv(t, h)
te.hostnetBackend(t, vethState)
err := Apply(context.Background(), te.Env)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Apply error = %v, want it to contain %q", err, tc.want)
}
if te.markerExists() {
t.Error("marker written after a refused apply")
}
if slices.ContainsFunc(h.changes, func(c string) bool { return strings.Contains(c, "veth-wan") }) {
t.Errorf("touched a refused link: %q", h.changes)
}
})
}
}
func TestApplyWaitsForLinksOthersCreate(t *testing.T) {
h := rebootedVethHost()
delete(h.links, "br-wan")
h.later(Link{Name: "br-wan", Kind: "bridge", Up: true}, 5)
te := newTestEnv(t, h)
te.hostnetBackend(t, vethState)
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("Apply: %v", err)
}
if te.slept == 0 {
t.Error("Apply did not wait for br-wan")
}
}
func TestApplyFailsNamingLinksStillMissing(t *testing.T) {
h := rebootedVethHost()
delete(h.links, "br-wan")
delete(h.links, "br-mgmt")
te := newTestEnv(t, h)
te.hostnetBackend(t, vethState)
err := Apply(context.Background(), te.Env)
if err == nil || !strings.Contains(err.Error(), "still missing after 1m0s: br-mgmt, br-wan") {
t.Fatalf("Apply error = %v, want it to name br-mgmt and br-wan", err)
}
if te.slept < DefaultLinkWait {
t.Errorf("gave up after %s, before the %s wait", te.slept, DefaultLinkWait)
}
if len(h.changes) != 0 {
t.Errorf("changed the host before every link was there: %q", h.changes)
}
}
func TestApplyWaitsOnlyForLinksTheModeNeeds(t *testing.T) {
// A NAT node has no Linux uplink bridge and no mgmt NIC: their absence
// must not cost a wait.
h := newFakeHost().
add(Link{Name: "br-int", Kind: "openvswitch"}).
add(Link{Name: "br-ext", Kind: "openvswitch"})
te := newTestEnv(t, h)
te.hostnetBackend(t, natState)
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("Apply: %v", err)
}
if te.slept != 0 {
t.Errorf("waited %s on a NAT node", te.slept)
}
for name := range h.lookups {
if name == "br-wan" || name == "mgmt" {
t.Errorf("looked up %s, which a NAT node does not have", name)
}
}
}
func TestApplyWaitsForEncapSourceAddress(t *testing.T) {
h := rebootedVethHost()
h.addrs["br-vpc"] = nil
te := newTestEnv(t, h)
te.hostnetBackend(t, vethState)
err := Apply(context.Background(), te.Env)
if err == nil || !strings.Contains(err.Error(), "10.1.0.5 on br-vpc") {
t.Fatalf("Apply error = %v, want it to name the missing encap source", err)
}
}
func TestApplyLeavesUnnamedLinksAlone(t *testing.T) {
h := rebootedVethHost().add(Link{Name: "wg0", Kind: "wireguard"})
h.addrs["wg0"] = []string{"10.99.0.1/24"}
h.addrs["br-wan"] = []string{"192.168.0.13/24"}
te := newTestEnv(t, h)
te.hostnetBackend(t, vethState)
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("Apply: %v", err)
}
for _, c := range h.changes {
if strings.Contains(c, "wg0") || strings.HasPrefix(c, "addr-del br-wan") {
t.Errorf("touched something the state file does not name: %q", c)
}
}
if h.links["wg0"] == nil || len(h.addrs["wg0"]) != 1 || len(h.addrs["br-wan"]) != 1 {
t.Error("an operator link or the uplink address changed")
}
}
func TestApplyFailureRemovesMarker(t *testing.T) {
h := rebootedVethHost()
te := newTestEnv(t, h)
te.hostnetBackend(t, vethState)
if err := Apply(context.Background(), te.Env); err != nil {
t.Fatalf("first Apply: %v", err)
}
if err := Check(context.Background(), te.Env); err != nil {
t.Fatalf("Check after a successful apply: %v", err)
}
delete(h.links, "veth-wan-br")
delete(h.links, "veth-wan-ovs")
h.failOn = "add-veth"
if err := Apply(context.Background(), te.Env); err == nil {
t.Fatal("second Apply succeeded despite the injected failure")
}
if te.markerExists() {
t.Error("marker survived a failed apply")
}
if err := Check(context.Background(), te.Env); err == nil {
t.Error("Check passed after a successful apply followed by a failed one")
}
}
func TestBackendTable(t *testing.T) {
ctx := context.Background()
for name, tc := range map[string]struct {
setup func(t *testing.T, te *testEnv)
// applyErr and checkErr are substrings; "" means success.
applyErr string
checkErr string
}{
"networkd record": {
setup: func(t *testing.T, te *testEnv) { te.write(t, te.Paths.Backend, "networkd\n") },
},
"networkd record ignores a broken state file": {
setup: func(t *testing.T, te *testEnv) {
te.write(t, te.Paths.Backend, "networkd\n")
te.write(t, te.Paths.State, "{")
},
},
"no record, legacy networkd files": {
setup: func(t *testing.T, te *testEnv) {
te.write(t, filepath.Join(te.Paths.NetworkdDir, "10-spinifex-mgmt.network"), "")
te.remote = "tcp:127.0.0.1:6642"
},
},
"no record, nothing set up": {},
"no record, set up without networkd files": {
setup: func(_ *testing.T, te *testEnv) { te.remote = "tcp:127.0.0.1:6642" },
applyErr: "has no backend record",
checkErr: "has no backend record",
},
"no record, OVS unreachable": {
setup: func(_ *testing.T, te *testEnv) { te.remoteErr = errors.New("database connection failed") },
applyErr: "cannot tell whether this node is set up",
checkErr: "cannot tell whether this node is set up",
},
"unknown backend": {
setup: func(t *testing.T, te *testEnv) { te.write(t, te.Paths.Backend, "netplan\n") },
applyErr: `unknown backend "netplan"`,
checkErr: `unknown backend "netplan"`,
},
"hostnet, state missing": {
setup: func(t *testing.T, te *testEnv) { te.write(t, te.Paths.Backend, "hostnet\n") },
applyErr: "no such file",
checkErr: "no such file",
},
"hostnet, state unreadable": {
setup: func(t *testing.T, te *testEnv) {
te.write(t, te.Paths.Backend, "hostnet\n")
if err := os.Mkdir(te.Paths.State, 0o755); err != nil {
t.Fatal(err)
}
},
applyErr: "is a directory",
checkErr: "is a directory",
},
"hostnet, state truncated": {
setup: func(t *testing.T, te *testEnv) { te.hostnetBackend(t, vethState[:40]) },
applyErr: "not valid JSON",
checkErr: "not valid JSON",
},
"hostnet, unknown version": {
setup: func(t *testing.T, te *testEnv) {
te.hostnetBackend(t, strings.Replace(natState, `"version": 1`, `"version": 2`, 1))
},
applyErr: "unknown version 2",
checkErr: "unknown version 2",
},
"hostnet, pending": {
setup: func(t *testing.T, te *testEnv) {
te.hostnetBackend(t, strings.Replace(natState, `"pending": false`, `"pending": true`, 1))
},
applyErr: "pending",
checkErr: "pending",
},
"hostnet, never applied": {
setup: func(t *testing.T, te *testEnv) { te.hostnetBackend(t, natState) },
checkErr: "host network not applied",
},
} {
t.Run(name, func(t *testing.T) {
h := newFakeHost().
add(Link{Name: "br-int", Kind: "openvswitch"}).
add(Link{Name: "br-ext", Kind: "openvswitch"})
// Check runs before Apply so "never applied" sees no marker.
te := newTestEnv(t, h)
if tc.setup != nil {
tc.setup(t, te)
}
wantErr(t, "Check", Check(ctx, te.Env), tc.checkErr)
wantErr(t, "Apply", Apply(ctx, te.Env), tc.applyErr)
if tc.applyErr != "" && te.markerExists() {
t.Error("marker written after a failed apply")
}
})
}
}
func wantErr(t *testing.T, what string, err error, want string) {
t.Helper()
switch {
case want == "" && err != nil:
t.Errorf("%s: unexpected error %v", what, err)
case want != "" && err == nil:
t.Errorf("%s: succeeded, want an error containing %q", what, want)
case want != "" && !strings.Contains(err.Error(), want):
t.Errorf("%s: error %q does not contain %q", what, err, want)
}
}
func TestLoadStateRejectsInvalidFields(t *testing.T) {
for name, tc := range map[string]struct {
from, to, want string
}{
"unknown mode": {`"mode": "nat"`, `"mode": "bonded"`, `wan.mode: unknown mode "bonded"`},
"long name": {`"ovs_bridge": "br-ext"`, `"ovs_bridge": "br-ext-much-too-long"`, "wan.ovs_bridge"},
"slash in name": {`"host_end": "spx-nat-host"`, `"host_end": "spx/nat"`, "wan.host_end"},
"bad MAC": {`"host_mac": "02:00:64:7f:00:01"`, `"host_mac": "02:00"`, "wan.host_mac"},
"bad CIDR": {`"host_cidr": "100.127.0.1/24"`, `"host_cidr": "100.127.0.1"`, "wan.host_cidr"},
"unknown field": {`"pending": false`, `"pending": false, "extra": 1`, `unknown field "extra"`},
"missing int bridge": {`"int_bridge": "br-int"`, `"int_bridge": ""`, "int_bridge: missing"},
} {
t.Run(name, func(t *testing.T) {
if !strings.Contains(natState, tc.from) {
t.Fatalf("fixture lacks %q", tc.from)
}
path := filepath.Join(t.TempDir(), "hostnet.json")
if err := os.WriteFile(path, []byte(strings.Replace(natState, tc.from, tc.to, 1)), 0o644); err != nil {
t.Fatal(err)
}
_, err := LoadState(path)
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), path) {
t.Fatalf("LoadState error = %v, want it to name %s and contain %q", err, path, tc.want)
}
})
}
}
+80
View File
@@ -0,0 +1,80 @@
package hostnet
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"golang.org/x/sys/unix"
)
// acquireLock takes the exclusive flock setup-ovn.sh also takes, waiting at
// most env.LockWait, and returns the function that releases it.
func acquireLock(env Env) (func(), error) {
path := env.Paths.Lock
if err := os.MkdirAll(filepath.Dir(path), 0o770); err != nil { //nolint:gosec // G301: the mode setup.sh's tmpfiles entry gives /run/spinifex, which the daemon and vpcd traverse to check the marker. Holds no secret material.
return nil, fmt.Errorf("create %s: %w", filepath.Dir(path), err)
}
f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("open lock %s: %w", path, err)
}
deadline := env.Now().Add(env.LockWait)
for {
err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)
if err == nil {
return func() { _ = f.Close() }, nil
}
if !errors.Is(err, unix.EWOULDBLOCK) {
_ = f.Close()
return nil, fmt.Errorf("lock %s: %w", path, err)
}
if !env.Now().Before(deadline) {
holder := lockHolder(f, "/proc/locks")
_ = f.Close()
return nil, fmt.Errorf("lock %s still held after %s by %s; is setup-ovn.sh running?", path, env.LockWait, holder)
}
env.Sleep(pollInterval)
}
}
// lockHolder names the process holding a flock on f, from procLocks, whose
// rows read "1: FLOCK ADVISORY WRITE <pid> <maj>:<min>:<inode> 0 EOF".
func lockHolder(f *os.File, procLocks string) string {
var st unix.Stat_t
if err := unix.Fstat(int(f.Fd()), &st); err != nil {
return "an unknown process"
}
id := fmt.Sprintf("%02x:%02x:%d", unix.Major(uint64(st.Dev)), unix.Minor(uint64(st.Dev)), st.Ino) //nolint:unconvert // Dev is not uint64 on every arch
locks, err := os.Open(procLocks)
if err != nil {
return "an unknown process"
}
defer locks.Close()
sc := bufio.NewScanner(locks)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 6 || fields[1] != "FLOCK" || fields[5] != id {
continue
}
pid, err := strconv.Atoi(fields[4])
if err != nil {
continue
}
return describePID(pid)
}
return "an unknown process"
}
func describePID(pid int) string {
cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil || len(cmdline) == 0 {
return "PID " + strconv.Itoa(pid)
}
args := strings.TrimRight(strings.ReplaceAll(string(cmdline), "\x00", " "), " ")
return fmt.Sprintf("PID %d (%s)", pid, args)
}
@@ -0,0 +1,94 @@
package hostnet
import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
const setupOVN = "../../../../scripts/setup-ovn.sh"
// shellFunc returns the body of a top-level function in setup-ovn.sh.
func shellFunc(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(setupOVN)
if err != nil {
t.Fatal(err)
}
src := string(data)
start := strings.Index(src, "\n"+name+"() {\n")
if start < 0 {
t.Fatalf("%s: no function %s", setupOVN, name)
}
end := strings.Index(src[start:], "\n}\n")
if end < 0 {
t.Fatalf("%s: function %s is not closed", setupOVN, name)
}
return src[start : start+end+3]
}
// TestScriptStateLoads runs setup-ovn.sh's state writer for every WAN mode and
// loads what it wrote, so the script and LoadState cannot drift apart.
func TestScriptStateLoads(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not installed")
}
fn := shellFunc(t, "write_hostnet_state")
bin := t.TempDir()
if err := os.WriteFile(filepath.Join(bin, "sudo"), []byte("#!/bin/sh\nexec \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
mgmt := &Mgmt{Bridge: "br-mgmt", CIDR: "10.15.8.1/24", NIC: "eth2"}
route := &Route{Dst: "10.1.0.0/16", Dev: "eth1", Src: "10.1.0.5", Metric: 50}
for _, tc := range []struct {
name string
env []string
want State
}{
{"existing", []string{"WAN_BRIDGE_MODE=existing", "WAN_BRIDGE=br-ext"},
State{WAN: &WAN{Mode: ModeExisting, OVSBridge: "br-ext"}}},
{"veth", []string{"WAN_BRIDGE_MODE=veth", "WAN_BRIDGE=br-ext", "LINUX_BRIDGE=br-wan"},
State{WAN: &WAN{Mode: ModeVeth, OVSBridge: "br-ext", LinuxBridge: "br-wan", HostEnd: "veth-wan-br", OVSEnd: "veth-wan-ovs"}}},
{"direct", []string{"WAN_BRIDGE_MODE=direct", "WAN_BRIDGE=br-ext", "WAN_IFACE=eth3"},
State{WAN: &WAN{Mode: ModeDirect, OVSBridge: "br-ext", NIC: "eth3"}}},
{"nat", []string{"WAN_BRIDGE_MODE=nat", "WAN_BRIDGE=br-ext", "NAT_TRANSIT_HOST_MAC=02:00:64:7f:00:01", "NAT_TRANSIT_GW_CIDR=100.127.0.1/24"},
State{WAN: &WAN{Mode: ModeNAT, OVSBridge: "br-ext", HostEnd: "spx-nat-host", OVSEnd: "spx-nat-ovs", HostMAC: "02:00:64:7f:00:01", HostCIDR: "100.127.0.1/24"}}},
{"no wan", nil, State{}},
{"mgmt and route", []string{"MGMT_BRIDGE_ENABLED=true", "MGMT_BRIDGE=br-mgmt", "MGMT_CIDR=10.15.8.1/24", "MGMT_IFACE=eth2",
"ENCAP_ROUTE_DST=10.1.0.0/16", "ENCAP_ROUTE_DEV=eth1", "ENCAP_IP=10.1.0.5", "ENCAP_ROUTE_METRIC=50"},
State{Mgmt: mgmt, EncapRoute: route}},
} {
t.Run(tc.name, func(t *testing.T) {
state := filepath.Join(t.TempDir(), "etc", "hostnet.json")
write := func(pending string) {
cmd := exec.Command("bash", "-c", fn+`write_hostnet_state "$1"`, "_", pending)
cmd.Env = append([]string{"PATH=" + bin + ":" + os.Getenv("PATH"), "HOSTNET_STATE=" + state}, tc.env...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("write_hostnet_state %s: %v: %s", pending, err, out)
}
}
write("false")
got, err := LoadState(state)
if err != nil {
t.Fatal(err)
}
want := tc.want
want.Version, want.IntBridge = StateVersion, "br-int"
if !reflect.DeepEqual(*got, want) {
t.Errorf("loaded %+v\nwant %+v", *got, want)
}
if _, err := os.Stat(state + ".tmp"); !os.IsNotExist(err) {
t.Errorf("temporary file left behind: %v", err)
}
write("true")
if _, err := LoadState(state); err == nil || !strings.Contains(err.Error(), "is pending") {
t.Errorf("pending write loaded as %v, want a pending error", err)
}
})
}
}
+271
View File
@@ -0,0 +1,271 @@
// Package hostnet re-creates the host network wiring setup-ovn.sh builds, on
// hosts where no systemd-networkd files persist it: the uplink veth pairs, the
// management bridge address and the encap route. spinifex-hostnet.service runs
// Apply at boot and whenever ovs-vswitchd restarts; the daemon and vpcd run
// Check before they start, so neither starts on a node left unwired.
package hostnet
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net"
"net/netip"
"os"
"path/filepath"
"strings"
)
// Backend is how a node persists its host network wiring across reboots.
type Backend string
const (
// BackendNetworkd persists the wiring in systemd-networkd files.
BackendNetworkd Backend = "networkd"
// BackendHostnet persists it in the state file, applied by this package.
BackendHostnet Backend = "hostnet"
)
// StateVersion is the only state file version this build reads.
const StateVersion = 1
// WAN uplink modes, as setup-ovn.sh names them.
const (
ModeExisting = "existing"
ModeVeth = "veth"
ModeDirect = "direct"
ModeNAT = "nat"
)
// Paths locates the files hostnet reads and writes.
type Paths struct {
// Backend holds the backend setup-ovn.sh chose on its first run.
Backend string
// State is the topology setup-ovn.sh recorded for Apply.
State string
// Marker exists only while the last Apply succeeded.
Marker string
// Lock serialises Apply against setup-ovn.sh.
Lock string
// NetworkdDir is where setup-ovn.sh writes its systemd-networkd files.
NetworkdDir string
}
// DefaultPaths returns the paths on a live host.
func DefaultPaths() Paths { return PathsAt("/") }
// PathsAt returns the paths with root prefixed, for tests and preflight.
func PathsAt(root string) Paths {
return Paths{
Backend: filepath.Join(root, "/etc/spinifex/hostnet-backend"),
State: filepath.Join(root, "/etc/spinifex/hostnet.json"),
Marker: filepath.Join(root, "/run/spinifex/hostnet-applied"),
Lock: filepath.Join(root, "/run/spinifex/hostnet.lock"),
NetworkdDir: filepath.Join(root, "/etc/systemd/network"),
}
}
// State is the host network topology setup-ovn.sh recorded. Every name in it
// comes from setup-ovn.sh, so custom bridge names need nothing here.
type State struct {
Version int `json:"version"`
// Pending is set while setup-ovn.sh is changing live wiring; Apply and Check
// refuse it, since the recorded topology may no longer match OVSDB.
Pending bool `json:"pending"`
IntBridge string `json:"int_bridge"`
WAN *WAN `json:"wan,omitempty"`
Mgmt *Mgmt `json:"mgmt,omitempty"`
EncapRoute *Route `json:"encap_route,omitempty"`
}
// WAN is the public-subnet uplink.
type WAN struct {
Mode string `json:"mode"`
// OVSBridge is the OVS bridge ovn-bridge-mappings names.
OVSBridge string `json:"ovs_bridge"`
// LinuxBridge is the host's bridge the veth pair joins (veth mode).
LinuxBridge string `json:"linux_bridge,omitempty"`
// NIC is the physical NIC moved into OVSBridge (direct mode).
NIC string `json:"nic,omitempty"`
// HostEnd and OVSEnd name the veth pair (veth and nat modes); OVSEnd is
// the end that is a port on OVSBridge.
HostEnd string `json:"host_end,omitempty"`
OVSEnd string `json:"ovs_end,omitempty"`
// HostMAC and HostCIDR are set on HostEnd (nat mode).
HostMAC string `json:"host_mac,omitempty"`
HostCIDR string `json:"host_cidr,omitempty"`
}
// Mgmt is the system-instance management bridge.
type Mgmt struct {
Bridge string `json:"bridge"`
CIDR string `json:"cidr,omitempty"`
// NIC is moved into Bridge and keeps no IPv4 address of its own.
NIC string `json:"nic,omitempty"`
}
// Route is the Geneve encap route: Dst via Dev from Src, at Metric.
type Route struct {
Dst string `json:"dst"`
Dev string `json:"dev"`
Src string `json:"src"`
Metric int `json:"metric"`
}
// ReadBackend returns the recorded backend, or "" when there is no record.
func ReadBackend(path string) (Backend, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read backend record %s: %w", path, err)
}
switch b := Backend(strings.TrimSpace(string(data))); b {
case BackendNetworkd, BackendHostnet:
return b, nil
default:
return "", fmt.Errorf("backend record %s: unknown backend %q (want %q or %q)", path, b, BackendNetworkd, BackendHostnet)
}
}
// LoadState reads and validates the state file. Every error names the file.
func LoadState(path string) (*State, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("state file %s: %w", path, err)
}
// Read the version alone first, so a future layout is reported as an
// unknown version rather than as whichever field it renamed.
var head struct {
Version *int `json:"version"`
Pending bool `json:"pending"`
}
if err := json.Unmarshal(data, &head); err != nil {
return nil, fmt.Errorf("state file %s: not valid JSON (truncated?): %w", path, err)
}
if head.Version == nil {
return nil, fmt.Errorf("state file %s: no version field", path)
}
if *head.Version != StateVersion {
return nil, fmt.Errorf("state file %s: unknown version %d (this build reads version %d)", path, *head.Version, StateVersion)
}
// A pending file may describe a topology setup-ovn.sh never finished
// building, so it is refused before its contents are judged.
if head.Pending {
return nil, fmt.Errorf("state file %s is pending: setup-ovn.sh stopped part-way through changing the host network; rerun it with the same arguments", path)
}
var s State
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&s); err != nil {
return nil, fmt.Errorf("state file %s: %w", path, err)
}
if err := s.validate(); err != nil {
return nil, fmt.Errorf("state file %s: %w", path, err)
}
return &s, nil
}
func (s *State) validate() error {
if err := checkName("int_bridge", s.IntBridge); err != nil {
return err
}
if w := s.WAN; w != nil {
if err := checkName("wan.ovs_bridge", w.OVSBridge); err != nil {
return err
}
switch w.Mode {
case ModeExisting:
case ModeVeth:
if err := checkNames(map[string]string{"wan.linux_bridge": w.LinuxBridge, "wan.host_end": w.HostEnd, "wan.ovs_end": w.OVSEnd}); err != nil {
return err
}
case ModeDirect:
if err := checkName("wan.nic", w.NIC); err != nil {
return err
}
case ModeNAT:
if err := checkNames(map[string]string{"wan.host_end": w.HostEnd, "wan.ovs_end": w.OVSEnd}); err != nil {
return err
}
if _, err := net.ParseMAC(w.HostMAC); err != nil {
return fmt.Errorf("wan.host_mac: %w", err)
}
if _, err := netip.ParsePrefix(w.HostCIDR); err != nil {
return fmt.Errorf("wan.host_cidr: %w", err)
}
default:
return fmt.Errorf("wan.mode: unknown mode %q", w.Mode)
}
}
if m := s.Mgmt; m != nil {
if err := checkName("mgmt.bridge", m.Bridge); err != nil {
return err
}
if m.CIDR != "" {
if _, err := netip.ParsePrefix(m.CIDR); err != nil {
return fmt.Errorf("mgmt.cidr: %w", err)
}
}
if m.NIC != "" {
if err := checkName("mgmt.nic", m.NIC); err != nil {
return err
}
}
}
if r := s.EncapRoute; r != nil {
if _, err := netip.ParsePrefix(r.Dst); err != nil {
return fmt.Errorf("encap_route.dst: %w", err)
}
if err := checkName("encap_route.dev", r.Dev); err != nil {
return err
}
if _, err := netip.ParseAddr(r.Src); err != nil {
return fmt.Errorf("encap_route.src: %w", err)
}
if r.Metric < 0 {
return fmt.Errorf("encap_route.metric: %d is negative", r.Metric)
}
}
return nil
}
func checkNames(fields map[string]string) error {
for field, name := range fields {
if err := checkName(field, name); err != nil {
return err
}
}
return nil
}
// checkName applies the kernel's interface name rules (IFNAMSIZ, no '/' or
// whitespace), so a bad name is reported against its field, not by ip.
func checkName(field, name string) error {
switch {
case name == "":
return fmt.Errorf("%s: missing", field)
case len(name) > 15:
return fmt.Errorf("%s: %q is longer than 15 characters", field, name)
case name == "." || name == ".." || strings.ContainsAny(name, "/: \t\n"):
return fmt.Errorf("%s: %q is not a valid interface name", field, name)
}
return nil
}
// hasNetworkdFiles reports whether setup-ovn.sh has written systemd-networkd
// files here, which every node set up before the backend record existed has.
func hasNetworkdFiles(dir string) (bool, error) {
for _, pattern := range []string{"*-spinifex-*.network", "*-spinifex-*.netdev"} {
matches, err := filepath.Glob(filepath.Join(dir, pattern))
if err != nil {
return false, err
}
if len(matches) > 0 {
return true, nil
}
}
return false, nil
}
+234
View File
@@ -0,0 +1,234 @@
// 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"
// LibreswanPlutoPath is libreswan's IKE daemon on Debian and EL alike. EL's
// openvswitch-ipsec drives libreswan even when strongSwan is also installed.
const LibreswanPlutoPath = "/usr/libexec/ipsec/pluto"
// QEMU guest processes are named qemu-system-<arch> (comm truncates it to 15
// bytes, so qemu-system-x86_64 reads qemu-system-x86) or qemu-kvm.
const (
qemuSystemPrefix = "qemu-system"
qemuKVMName = "qemu-kvm"
// QEMUProcessPattern is an extended regular expression, for grep -E and
// pgrep, that matches a QEMU guest's process name or command line.
QEMUProcessPattern = qemuSystemPrefix + "|" + qemuKVMName
)
// IsQEMUProcess reports whether a /proc/<pid>/comm value names a QEMU guest.
func IsQEMUProcess(comm string) bool {
comm = strings.TrimSpace(comm)
return strings.HasPrefix(comm, qemuSystemPrefix) || comm == qemuKVMName
}
// 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()
}
// HasLibreswan reports whether the host's IPsec stack is libreswan.
func HasLibreswan() bool {
return std.hasLibreswan()
}
// 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) hasLibreswan() bool {
return r.isExecutable(LibreswanPlutoPath)
}
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
}
+360
View File
@@ -0,0 +1,360 @@
package hostos
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"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()
h.install(t, QEMUKVMPath, mode)
}
func (h *fakeHost) install(t *testing.T, path string, mode os.FileMode) {
t.Helper()
p := filepath.Join(h.root, path)
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)
}
})
}
}
// strongSwan's charon is not pluto, so only libreswan's own daemon counts, and
// a pluto that cannot run is not an IPsec stack.
func TestHasLibreswan(t *testing.T) {
for _, tc := range []struct {
name string
install map[string]os.FileMode
want bool
}{
{name: "pluto", install: map[string]os.FileMode{LibreswanPlutoPath: 0o755}, want: true},
{name: "strongSwan charon only", install: map[string]os.FileMode{"/usr/lib/ipsec/charon": 0o755}},
{name: "pluto not executable", install: map[string]os.FileMode{LibreswanPlutoPath: 0o644}},
{name: "neither"},
} {
t.Run(tc.name, func(t *testing.T) {
h := newFakeHost(t)
for path, mode := range tc.install {
h.install(t, path, mode)
}
if got := h.resolver("x86_64").hasLibreswan(); got != tc.want {
t.Fatalf("hasLibreswan() = %v, want %v", got, tc.want)
}
})
}
}
func TestIsQEMUProcess(t *testing.T) {
for comm, want := range map[string]bool{
// comm truncates to 15 bytes, so qemu-system-x86_64 reads qemu-system-x86.
"qemu-system-x86\n": true,
"qemu-system-aar": true,
"qemu-kvm\n": true,
"qemu-img": false,
"qemu-kvm-helper": false,
"qemu-nbd": false,
"nbdkit": false,
"": false,
} {
if got := IsQEMUProcess(comm); got != want {
t.Errorf("IsQEMUProcess(%q) = %v, want %v", comm, got, want)
}
}
}
func TestQEMUProcessPatternMatchesEveryQEMUName(t *testing.T) {
re := regexp.MustCompile(QEMUProcessPattern)
for _, line := range []string{
"spinifex 4242 qemu-system-x86_64 -name guest=i-0abc,debug-threads=on",
"spinifex 4243 /usr/libexec/qemu-kvm -name guest=i-0def,debug-threads=on",
} {
if !re.MatchString(line) {
t.Errorf("QEMUProcessPattern %q does not match %q", QEMUProcessPattern, line)
}
}
if re.MatchString("spinifex 4244 nbdkit --unix /run/spinifex/nbd.sock") {
t.Errorf("QEMUProcessPattern %q matches an nbdkit line", QEMUProcessPattern)
}
}
+4 -2
View File
@@ -10,10 +10,12 @@ import (
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
)
// InstanceHostingNode returns the cluster node running the named instance's
// qemu process (ps auxw | grep qemu-system). Returns nil if no node owns it.
// QEMU process (ps auxw | grep -E hostos.QEMUProcessPattern). Returns nil if no node owns it.
func InstanceHostingNode(t *testing.T, c *Cluster, instanceID string) *Node {
t.Helper()
hosting := InstanceHostingNodes(t, c, instanceID, nil)
@@ -56,7 +58,7 @@ func InstanceHostingNodes(t *testing.T, c *Cluster, instanceID string, skip []st
// runPSGrep runs the ps+grep pipeline on a node and returns combined output.
func runPSGrep(ctx context.Context, ssh *PeerSSH, n Node, instanceID string) (string, error) {
cmd := fmt.Sprintf("ps auxw | grep %q | grep qemu-system | grep -v grep", instanceID)
cmd := fmt.Sprintf("ps auxw | grep %q | grep -E %s | grep -v grep", instanceID, ShellQuote(hostos.QEMUProcessPattern))
out, err := ssh.Run(ctx, n.Addr, cmd)
if err != nil {
// exit 1 from grep means no match, not a transport error.
+2 -1
View File
@@ -21,6 +21,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
"golang.org/x/crypto/ssh"
)
@@ -380,7 +381,7 @@ func (w *Witness) waitForRunning(ctx context.Context, id string, timeout time.Du
// findHost walks cluster nodes looking for the QEMU process that owns instanceID
// (the instance ID appears in -pidfile, -qmp, and -name on the QEMU command line).
func (w *Witness) findHost(ctx context.Context, instanceID string) (Node, error) {
cmd := fmt.Sprintf("ps auxw | grep %s | grep qemu-system | grep -v grep", ShellQuote(instanceID))
cmd := fmt.Sprintf("ps auxw | grep %s | grep -E %s | grep -v grep", ShellQuote(instanceID), ShellQuote(hostos.QEMUProcessPattern))
// EC2 "running" state is reported before QEMU is forked; give it a window.
deadline := time.Now().Add(30 * time.Second)
const interval = 1 * time.Second
+8 -4
View File
@@ -19,6 +19,12 @@ const (
ovnSBSock = "/var/run/ovn/ovnsb_db.ctl"
)
// ovnCentralUnits expands, on the node, to debianUnits where ovn-central.service
// exists. EL has no such unit: ovn-northd.service runs both databases too.
func ovnCentralUnits(debianUnits string) string {
return "$(systemctl cat ovn-central.service >/dev/null 2>&1 && echo " + debianUnits + " || echo ovn-northd)"
}
// runOVNRaft asserts the clustered OVN control plane: every DB node's NB and SB
// RAFT cluster reports the full quorum with exactly one leader, then kills the
// NB leader's ovn-central and proves NB writes still land via a surviving
@@ -68,14 +74,12 @@ func runOVNRaft(t *testing.T, fix *Fixture) {
harness.Step(t, "stop ovn-central on NB leader %s (%s)", leader.Name, leader.Addr)
stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer stopCancel()
_, err := ssh.Run(stopCtx, leader.Addr,
"sudo systemctl stop ovn-central ovn-ovsdb-server-nb ovn-ovsdb-server-sb ovn-northd")
_, err := ssh.Run(stopCtx, leader.Addr, "sudo systemctl stop "+ovnCentralUnits("ovn-central ovn-ovsdb-server-nb ovn-ovsdb-server-sb ovn-northd"))
require.NoErrorf(t, err, "stop ovn-central on %s", leader.Name)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if _, e := ssh.Run(ctx, leader.Addr,
"sudo systemctl start ovn-ovsdb-server-nb ovn-ovsdb-server-sb ovn-northd"); e != nil {
if _, e := ssh.Run(ctx, leader.Addr, "sudo systemctl start "+ovnCentralUnits("ovn-ovsdb-server-nb ovn-ovsdb-server-sb ovn-northd")); e != nil {
t.Logf("cleanup: restart ovn-central on %s: %v", leader.Name, e)
}
})
+4 -4
View File
@@ -274,9 +274,9 @@ find_instance_node() {
for ip in "${NODE_IPS[@]}"; do
local found
if [ "$ip" = "$LOCAL_IP" ]; then
found=$(ps auxw | grep "$instance_id" | grep qemu-system | grep -v grep || true)
found=$(ps auxw | grep "$instance_id" | grep -E "qemu-system|qemu-kvm" | grep -v grep || true)
else
found=$(peer_ssh "$ip" "ps auxw | grep '$instance_id' | grep qemu-system | grep -v grep" 2>/dev/null || true)
found=$(peer_ssh "$ip" "ps auxw | grep '$instance_id' | grep -E 'qemu-system|qemu-kvm' | grep -v grep" 2>/dev/null || true)
fi
if [ -n "$found" ]; then
echo "$ip"
@@ -298,9 +298,9 @@ get_remote_ssh_port() {
while [ $attempt -lt $max_attempts ]; do
local qemu_cmd
if [ "$node_ip" = "$LOCAL_IP" ]; then
qemu_cmd=$(ps auxw | grep "$instance_id" | grep qemu-system | grep -v grep || true)
qemu_cmd=$(ps auxw | grep "$instance_id" | grep -E "qemu-system|qemu-kvm" | grep -v grep || true)
else
qemu_cmd=$(peer_ssh "$node_ip" "ps auxw | grep '$instance_id' | grep qemu-system | grep -v grep" 2>/dev/null || true)
qemu_cmd=$(peer_ssh "$node_ip" "ps auxw | grep '$instance_id' | grep -E 'qemu-system|qemu-kvm' | grep -v grep" 2>/dev/null || true)
fi
if [ -n "$qemu_cmd" ]; then