Survey the machine, and say what an install would do to it
The first two pieces of the installer, and deliberately the two that change nothing: what this machine is, and what would happen to it. The survey is the floor under every later choice -- a component can only offer a container shape if Docker answers for this user, or a host shape on a systemd machine with what that shape needs. It reports what it could not establish as unknown rather than guessing: an unprivileged probe of port 25 means "I may not bind this", not "something is listening", and reporting the first as the second is a lie an operator would act on. The plan is one list, and it will have three readers: --dry-run prints it, the interface will show it before anything happens, and apply will walk it. What you are shown is what runs. Tested on a fresh Debian 13 in qemu, which has neither Docker nor Node and so exercises every unavailable shape: 23 checks, including that nothing was created by any of it. The lab that machine runs in is in e2e/vm.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# The first case: on a machine with nothing installed on it, does the
|
||||
# installer describe that machine correctly, and does it refuse the shapes
|
||||
# that machine cannot do -- with a reason someone could act on?
|
||||
#
|
||||
# A fresh Debian cloud image has no docker and no node, which is exactly the
|
||||
# interesting case: every container shape and the webmail's host shape are
|
||||
# unavailable, and the server's host shape is not. Getting this wrong in
|
||||
# either direction is the difference between an installer that reads a
|
||||
# machine and one that guesses.
|
||||
#
|
||||
# Run from the host with: e2e/vm/run.sh e2e/cases/survey.sh
|
||||
set -uo pipefail
|
||||
|
||||
pass=0; fail=0
|
||||
ok() { echo " ok $*"; pass=$((pass+1)); }
|
||||
bad() { echo " FAIL $*"; fail=$((fail+1)); }
|
||||
has() { grep -q -- "$2" <<<"$1" && ok "$3" || { bad "$3"; echo "----"; echo "$1" | sed 's/^/ /'; echo "----"; }; }
|
||||
hasnt() { grep -q -- "$2" <<<"$1" && { bad "$3"; } || ok "$3"; }
|
||||
|
||||
echo "==> survey, on a machine with nothing on it"
|
||||
S="$(/tmp/inbuxa survey)"
|
||||
echo "$S" | sed 's/^/ /'
|
||||
|
||||
has "$S" "Debian GNU/Linux 13" "names the distribution"
|
||||
has "$S" "init systemd" "sees systemd"
|
||||
has "$S" "running as root" "knows it is root"
|
||||
has "$S" "docker not installed" "sees no docker"
|
||||
has "$S" "node not installed" "sees no node"
|
||||
has "$S" "25 SMTP" "lists the mail ports"
|
||||
hasnt "$S" "cannot tell without root" "as root, every port is a real answer"
|
||||
has "$S" "container (no: docker is not installed)" "containers unavailable, with the reason"
|
||||
has "$S" "server container (no" "the server cannot be a container here"
|
||||
has "$S" "webmail container (no: docker is not installed) host (no: a host install of the webmail needs Node 22" "the webmail can be neither shape here"
|
||||
|
||||
echo
|
||||
echo "==> a plan that this machine cannot carry out"
|
||||
OUT="$(/tmp/inbuxa install --domain example.test 2>&1)"; rc=$?
|
||||
[ $rc -ne 0 ] && ok "refuses (exit $rc)" || bad "should have refused"
|
||||
has "$OUT" "cannot install as asked" "says so plainly"
|
||||
has "$OUT" "docker is not installed" "and why"
|
||||
|
||||
echo
|
||||
echo "==> a plan it can: the server on the host, front ends elsewhere"
|
||||
OUT="$(/tmp/inbuxa install --domain example.test --server host --console skip --webmail skip --dry-run 2>&1)"; rc=$?
|
||||
echo "$OUT" | sed 's/^/ /'
|
||||
[ $rc -eq 0 ] && ok "accepted (exit 0)" || bad "should have been accepted, exit $rc"
|
||||
has "$OUT" "/etc/systemd/system/inbuxa-server.service" "names the unit it would write"
|
||||
has "$OUT" "user: inbuxa" "names the user it would create"
|
||||
has "$OUT" "Ports it will bind: 25, 465, 993, 995, 4190, 80, 443" "lists every port"
|
||||
has "$OUT" "MX 10 mail.example.test." "writes the MX the domain needs"
|
||||
has "$OUT" "_dmarc.example.test." "and DMARC"
|
||||
hasnt "$OUT" "admin.example.test" "says nothing about front ends it was told to skip"
|
||||
|
||||
echo
|
||||
echo "==> nothing was installed by any of that"
|
||||
[ ! -e /etc/systemd/system/inbuxa-server.service ] && ok "no unit written" || bad "a unit appeared"
|
||||
[ ! -e /var/lib/inbuxa ] && ok "no deployment directory" || bad "a directory appeared"
|
||||
id inbuxa >/dev/null 2>&1 && bad "a user appeared" || ok "no user created"
|
||||
|
||||
echo
|
||||
echo "==> $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Stop the lab machine and remove its disks. The base image stays, so up.sh
|
||||
# is quick the next time; --all takes that too.
|
||||
set -euo pipefail
|
||||
LAB="${LAB:-$HOME/.cache/inbuxa-lab}"
|
||||
SSH_PORT="${SSH_PORT:-2222}"
|
||||
DISK="$LAB/lab.qcow2"; CLEAN="$LAB/lab-clean.qcow2"; SEED="$LAB/seed.iso"
|
||||
LOG="$LAB/console.log"; PIDFILE="$LAB/qemu.pid"
|
||||
. "$(dirname "$0")/lib.sh"
|
||||
|
||||
vm_stop
|
||||
rm -f "$DISK" "$CLEAN" "$SEED" "$PIDFILE" "$LAB/monitor.sock"
|
||||
[ "${1:-}" = "--all" ] && rm -rf "$LAB"
|
||||
echo "==> gone"
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Shared by the four scripts beside it: how the lab machine is started,
|
||||
# stopped and talked to. Sourced, never run.
|
||||
#
|
||||
# One machine at a time, identified by its pid file. qemu is given a monitor
|
||||
# on a unix socket so a stop is a clean powerdown rather than pulling the
|
||||
# plug on a filesystem we are about to copy.
|
||||
#
|
||||
# Keep the default VGA device. `-vga none` looks right for a headless machine
|
||||
# and is not: this image's GRUB hands over to a kernel that never prints, and
|
||||
# the machine resets in a loop at "Booting Debian GNU/Linux" forever. -display
|
||||
# none is what makes it headless; the adapter has to be there.
|
||||
|
||||
vm_running() {
|
||||
[ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
|
||||
}
|
||||
|
||||
vm_start() {
|
||||
qemu-system-x86_64 \
|
||||
-enable-kvm -cpu host -m "$MEM" -smp "$VCPUS" \
|
||||
-drive "file=$DISK,if=virtio,format=qcow2" \
|
||||
-drive "file=$SEED,if=virtio,format=raw,readonly=on" \
|
||||
-netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22" \
|
||||
-device virtio-net-pci,netdev=n0 \
|
||||
-display none -serial "file:$LOG" \
|
||||
-monitor "unix:$LAB/monitor.sock,server,nowait" \
|
||||
-pidfile "$PIDFILE" \
|
||||
-daemonize
|
||||
}
|
||||
|
||||
vm_stop() {
|
||||
vm_running || return 0
|
||||
printf 'system_powerdown\n' | timeout 5 socat - "unix-connect:$LAB/monitor.sock" >/dev/null 2>&1 \
|
||||
|| printf 'system_powerdown\n' | timeout 5 nc -U "$LAB/monitor.sock" >/dev/null 2>&1 \
|
||||
|| true
|
||||
for _ in $(seq 1 30); do
|
||||
vm_running || return 0
|
||||
sleep 1
|
||||
done
|
||||
# It had its chance.
|
||||
kill "$(cat "$PIDFILE")" 2>/dev/null || true
|
||||
sleep 1
|
||||
}
|
||||
|
||||
vm_ssh() {
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=3 -o BatchMode=yes -o LogLevel=ERROR \
|
||||
-p "$SSH_PORT" [email protected] "$@"
|
||||
}
|
||||
|
||||
vm_scp() {
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-o LogLevel=ERROR -P "$SSH_PORT" "$@"
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Rewind the lab machine to the copy up.sh kept. A few seconds, and the
|
||||
# machine is as fresh as a new VPS -- which is what makes a test run free to
|
||||
# create users, write units and take ports.
|
||||
set -euo pipefail
|
||||
LAB="${LAB:-$HOME/.cache/inbuxa-lab}"
|
||||
MEM="${MEM:-4096}"; VCPUS="${VCPUS:-2}"; SSH_PORT="${SSH_PORT:-2222}"
|
||||
DISK="$LAB/lab.qcow2"; CLEAN="$LAB/lab-clean.qcow2"; SEED="$LAB/seed.iso"
|
||||
LOG="$LAB/console.log"; PIDFILE="$LAB/qemu.pid"
|
||||
. "$(dirname "$0")/lib.sh"
|
||||
|
||||
[ -f "$CLEAN" ] || { echo "no clean copy -- run e2e/vm/up.sh first" >&2; exit 1; }
|
||||
|
||||
vm_stop
|
||||
cp --reflink=auto "$CLEAN" "$DISK"
|
||||
: > "$LOG"
|
||||
vm_start
|
||||
for _ in $(seq 1 90); do vm_ssh true 2>/dev/null && break; sleep 2; done
|
||||
vm_ssh true 2>/dev/null || { echo "no ssh after the rewind; see $LOG" >&2; exit 1; }
|
||||
echo "==> clean: ssh [email protected] -p $SSH_PORT"
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Build the installer, copy it into the lab machine, and run a case there.
|
||||
#
|
||||
# e2e/vm/run.sh e2e/cases/containers.sh rewind, then run that case
|
||||
# KEEP=1 e2e/vm/run.sh e2e/cases/hosts.sh run it on the machine as it is
|
||||
#
|
||||
# The rewind is the point: every case starts on an identical machine, so a
|
||||
# failure is the installer's and not the last run's leftovers.
|
||||
set -euo pipefail
|
||||
CASE="${1:?usage: run.sh e2e/cases/<case>.sh}"
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
LAB="${LAB:-$HOME/.cache/inbuxa-lab}"
|
||||
MEM="${MEM:-4096}"; VCPUS="${VCPUS:-2}"; SSH_PORT="${SSH_PORT:-2222}"
|
||||
DISK="$LAB/lab.qcow2"; CLEAN="$LAB/lab-clean.qcow2"; SEED="$LAB/seed.iso"
|
||||
LOG="$LAB/console.log"; PIDFILE="$LAB/qemu.pid"
|
||||
. "$(dirname "$0")/lib.sh"
|
||||
|
||||
[ -f "$ROOT/$CASE" ] || { echo "no such case: $CASE" >&2; exit 1; }
|
||||
[ "${KEEP:-}" = 1 ] || "$(dirname "$0")/reset.sh"
|
||||
vm_running || { echo "the lab is not up -- run e2e/vm/up.sh" >&2; exit 1; }
|
||||
|
||||
echo "==> building the installer"
|
||||
(cd "$ROOT" && GOOS=linux GOARCH=amd64 go build -o "$LAB/inbuxa" ./cmd/inbuxa)
|
||||
|
||||
echo "==> copying it in"
|
||||
vm_scp "$LAB/inbuxa" [email protected]:/tmp/inbuxa >/dev/null
|
||||
vm_scp "$ROOT/$CASE" [email protected]:/tmp/case.sh >/dev/null
|
||||
vm_ssh 'chmod +x /tmp/inbuxa /tmp/case.sh'
|
||||
|
||||
echo "==> running $(basename "$CASE")"
|
||||
vm_ssh 'sudo -E bash /tmp/case.sh'
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Build the throwaway machine the installer is tested on.
|
||||
#
|
||||
# The installer writes units, creates users, takes 25 and 443 and can install
|
||||
# a web server. None of that belongs on a workstation, and none of it can be
|
||||
# proved in a container either -- systemd, users and ports are the thing under
|
||||
# test. So: a Debian cloud image in qemu, seeded with cloud-init, with a copy
|
||||
# of the disk kept the moment it is up. Every run starts from that copy, so a
|
||||
# run can break the machine as thoroughly as it likes.
|
||||
#
|
||||
# e2e/vm/up.sh build it and keep a clean copy (idempotent)
|
||||
# e2e/vm/reset.sh back to the clean copy, a few seconds
|
||||
# e2e/vm/run.sh build the installer, copy it in, run a case inside
|
||||
# e2e/vm/down.sh stop it and remove its disks
|
||||
#
|
||||
# Plain qemu, run as you, rather than libvirt: no root, no storage pool, no
|
||||
# permissions to argue with, and the logs are readable. Networking is qemu's
|
||||
# user-mode NAT with ssh forwarded to 127.0.0.1:2222, which gives the guest
|
||||
# the internet it needs to pull images without putting it on the LAN.
|
||||
#
|
||||
# Needs: qemu-system-x86_64, /dev/kvm, xorriso, ssh, curl.
|
||||
set -euo pipefail
|
||||
|
||||
LAB="${LAB:-$HOME/.cache/inbuxa-lab}"
|
||||
MEM="${MEM:-4096}"
|
||||
VCPUS="${VCPUS:-2}"
|
||||
DISK_GB="${DISK_GB:-20}"
|
||||
SSH_PORT="${SSH_PORT:-2222}"
|
||||
BASE_URL="https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"
|
||||
BASE="$LAB/debian-13-base.qcow2"
|
||||
DISK="$LAB/lab.qcow2"
|
||||
CLEAN="$LAB/lab-clean.qcow2"
|
||||
SEED="$LAB/seed.iso"
|
||||
LOG="$LAB/console.log"
|
||||
PIDFILE="$LAB/qemu.pid"
|
||||
KEY="${KEY:-$HOME/.ssh/id_ed25519.pub}"
|
||||
|
||||
say() { echo "==> $*"; }
|
||||
. "$(dirname "$0")/lib.sh"
|
||||
|
||||
[ -r "$KEY" ] || { echo "no public key at $KEY (set KEY=)" >&2; exit 1; }
|
||||
[ -w /dev/kvm ] || { echo "no writable /dev/kvm -- is this user in the kvm group?" >&2; exit 1; }
|
||||
|
||||
if vm_running; then
|
||||
say "already up on 127.0.0.1:$SSH_PORT -- reset.sh rewinds it, down.sh removes it"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$LAB"
|
||||
if [ ! -f "$BASE" ]; then
|
||||
say "fetching the base image (once)"
|
||||
curl -fL --progress-bar -o "$BASE.part" "$BASE_URL"
|
||||
mv "$BASE.part" "$BASE"
|
||||
fi
|
||||
|
||||
# cloud-init: one unprivileged user with our key and passwordless sudo, and
|
||||
# almost nothing else. The installer is what is under test, so the machine
|
||||
# should be as plain as a new VPS -- if the installer needs a package, the
|
||||
# installer should say so rather than the lab quietly providing it.
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
cat > "$WORK/meta-data" <<EOF
|
||||
instance-id: inbuxa-lab
|
||||
local-hostname: inbuxa-lab
|
||||
EOF
|
||||
|
||||
cat > "$WORK/user-data" <<EOF
|
||||
#cloud-config
|
||||
users:
|
||||
- name: lab
|
||||
sudo: ALL=(ALL) NOPASSWD:ALL
|
||||
shell: /bin/bash
|
||||
ssh_authorized_keys:
|
||||
- $(cat "$KEY")
|
||||
package_update: true
|
||||
packages:
|
||||
- curl
|
||||
- ca-certificates
|
||||
- openssl
|
||||
- python3
|
||||
growpart:
|
||||
mode: auto
|
||||
devices: ['/']
|
||||
resize_rootfs: true
|
||||
write_files:
|
||||
- path: /etc/inbuxa-lab
|
||||
content: |
|
||||
This machine exists to be broken by e2e/vm/run.sh. Nothing on it is kept.
|
||||
EOF
|
||||
|
||||
say "building the seed"
|
||||
xorriso -as mkisofs -quiet -o "$SEED" -V CIDATA -J -r "$WORK/user-data" "$WORK/meta-data"
|
||||
|
||||
# An overlay on the base, so the download is written once and every rebuild is
|
||||
# instant. The base itself is never modified.
|
||||
say "building the disk"
|
||||
rm -f "$DISK"
|
||||
qemu-img create -q -f qcow2 -F qcow2 -b "$BASE" "$DISK" "${DISK_GB}G"
|
||||
|
||||
say "starting the machine"
|
||||
vm_start
|
||||
|
||||
say "waiting for ssh on 127.0.0.1:$SSH_PORT"
|
||||
for _ in $(seq 1 90); do
|
||||
if vm_ssh true 2>/dev/null; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
vm_ssh true 2>/dev/null || { echo "no ssh after three minutes; see $LOG" >&2; exit 1; }
|
||||
|
||||
say "waiting for cloud-init to finish"
|
||||
vm_ssh 'sudo cloud-init status --wait >/dev/null 2>&1 || true'
|
||||
|
||||
say "keeping a clean copy of the disk"
|
||||
vm_stop
|
||||
cp --reflink=auto "$DISK" "$CLEAN"
|
||||
vm_start
|
||||
for _ in $(seq 1 90); do
|
||||
if vm_ssh true 2>/dev/null; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
say "up: ssh [email protected] -p $SSH_PORT (console log: $LOG)"
|
||||
vm_ssh 'echo " guest:" $(. /etc/os-release && echo $PRETTY_NAME) "| kernel" $(uname -r) "| disk" $(df -h / | awk "NR==2{print \$2}")'
|
||||
Reference in New Issue
Block a user