Offer only what this machine can deliver

The installer knew one distribution: Debian, with docker, on a new enough
release. Everything else it would have offered and then failed at.

Three facts now decide what the matrix offers, and each is read from the
machine rather than assumed:

- The container runtime. Docker where the machine has one, podman on the Red
  Hat family, which ships no docker at all. Both go through the same compose
  plugin: compose speaks the Docker API and podman serves it, so there is one
  compose file and one deployment path, not two. Telling a Fedora operator to
  add Docker's own repository to a machine that already has a container
  runtime would have been the wrong trade.

- glibc. The server binary is downloaded, not built here, and it is linked
  against 2.39. Rocky 9 (2.34), Debian 12 and Ubuntu 22.04 cannot run it, so
  the host shape is refused there with the version found and the container
  shape named as the answer -- rather than installing a file that cannot
  start.

- The operating system itself. This compiles for macOS and Windows because Go
  compiles anything, and on either it would read no os-release, find no
  systemd, and describe a machine that does not exist. It now says what it is
  and exits.

Two bugs the other distributions found, both of which Debian could not have:

- The survey reported the first thing in the way and stopped, so on Fedora it
  asked to start podman.socket, and then -- having done it -- asked for the
  compose plugin. Needs are named now, not described, and reported together.

- apply used the survey taken before dependencies were installed, so on a
  machine that had no runtime at all it installed podman and then reached for
  docker. It re-surveys after resolving, and stops if containers still are
  not usable.

The lab takes DISTRO now: debian13, debian12, ubuntu2404, fedora, rocky9,
arch, each with its own disk and ssh port so several can be up at once. The
cases no longer say "docker" either. install-local passes on Debian 13,
Fedora 43 and Rocky 9 -- 20 checks each, ending with a sign-in to the webmail
the installer put there.
This commit is contained in:
2026-09-22 22:22:45 -07:00
parent 8725d8c11b
commit e55ff6b2df
14 changed files with 525 additions and 96 deletions
+8
View File
@@ -11,6 +11,13 @@ container or on the host, in any mixture.
It only ever installs here. A second machine runs it too, and `inbuxa join` It only ever installs here. A second machine runs it too, and `inbuxa join`
points that machine at a server already running elsewhere. points that machine at a server already running elsewhere.
Linux only, and it says so on any other system rather than reporting a
machine that does not exist. Debian, Red Hat and Arch families, and the
derivatives people run: Ubuntu, Fedora, Rocky, CentOS, CachyOS. It uses
whichever container runtime the distribution ships -- docker where there is
one, podman on the Red Hat family -- and refuses a shape the machine cannot
deliver, with the reason.
## What works today ## What works today
This is early. What is built: This is early. What is built:
@@ -56,6 +63,7 @@ The installer writes units, creates users and takes ports 25 and 443, so it
is tested on a throwaway virtual machine rather than on anybody's desk: is tested on a throwaway virtual machine rather than on anybody's desk:
e2e/vm/up.sh a Debian 13 machine, in qemu, as you e2e/vm/up.sh a Debian 13 machine, in qemu, as you
DISTRO=fedora e2e/vm/up.sh or fedora, rocky9, ubuntu2404, arch, debian12
e2e/vm/run.sh e2e/cases/survey.sh what it says about a machine e2e/vm/run.sh e2e/cases/survey.sh what it says about a machine
e2e/vm/run.sh e2e/cases/deps.sh the offer, and taking it e2e/vm/run.sh e2e/cases/deps.sh the offer, and taking it
e2e/vm/run.sh e2e/cases/install-local.sh a whole suite, and signing in to it e2e/vm/run.sh e2e/cases/install-local.sh a whole suite, and signing in to it
+34 -7
View File
@@ -15,6 +15,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/apply" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/apply"
@@ -60,6 +61,20 @@ deps flags:
` `
func main() { func main() {
// The three programs are Linux services: systemd units, a container
// runtime, /etc and /var. This binary compiles for macOS and Windows
// because Go will compile it for anything, and on either it would read
// no /etc/os-release, find no systemd, and report a machine that does
// not exist. Saying so is the only honest thing it can do there.
if runtime.GOOS != "linux" {
fmt.Fprintf(os.Stderr,
"inbuxa installs on Linux, and this is %s.\n\n"+
"The mail server, the console and the webmail are Linux services. To try them\n"+
"on this machine, run them in containers with Docker or Podman Desktop; to\n"+
"install them, run this on the Linux machine that will host them.\n",
runtime.GOOS)
os.Exit(2)
}
if len(os.Args) < 2 { if len(os.Args) < 2 {
// The interface is the no-argument case. Until it lands, say so // The interface is the no-argument case. Until it lands, say so
// plainly rather than pretending: a half-built screen is worse than // plainly rather than pretending: a half-built screen is worse than
@@ -145,7 +160,7 @@ func install(args []string) int {
fmt.Println("\nApplying:") fmt.Println("\nApplying:")
log := &printer{} log := &printer{}
res, err := apply.Run(context.Background(), p, log) res, err := apply.Run(context.Background(), p, f, log)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "\nstopped: "+err.Error()) fmt.Fprintln(os.Stderr, "\nstopped: "+err.Error())
return 1 return 1
@@ -285,14 +300,26 @@ func render(f host.Facts) string {
fmt.Fprintf(&b, " %-22s %d GB free\n", "disk", f.DiskFreeGB) fmt.Fprintf(&b, " %-22s %d GB free\n", "disk", f.DiskFreeGB)
} }
docker := "not installed" runtime := "none: neither docker nor podman is installed"
switch { switch {
case f.Docker.Usable: case f.Runtime.Usable:
docker = "usable, server " + f.Docker.Version + ", compose " + f.Docker.Compose runtime = f.Runtime.Kind + " " + f.Runtime.Version + ", compose " + f.Runtime.Compose
case f.Docker.Present: if f.Runtime.Socket != "" {
docker = "installed but not usable: " + f.Docker.Why runtime += ", at " + f.Runtime.Socket
} }
fmt.Fprintf(&b, " %-22s %s\n", "docker", docker) case f.Runtime.Present:
runtime = f.Runtime.Kind + " installed but not usable: " + f.Runtime.Why
}
fmt.Fprintf(&b, " %-22s %s\n", "container runtime", runtime)
glibc := "could not be read"
if f.Glibc.Version != "" {
glibc = f.Glibc.Version
if !f.Glibc.AtLeast(plan.ServerGlibcMajor, plan.ServerGlibcMinor) {
glibc += fmt.Sprintf(" (older than the %d.%d the server binary needs)", plan.ServerGlibcMajor, plan.ServerGlibcMinor)
}
}
fmt.Fprintf(&b, " %-22s %s\n", "glibc", glibc)
node := "not installed" node := "not installed"
switch { switch {
+17 -3
View File
@@ -21,6 +21,20 @@ has() { grep -q -- "$2" <<<"$1" && ok "$3" || { bad "$3"; echo "$1" | tail -20
DIR=/var/lib/inbuxa DIR=/var/lib/inbuxa
# Whichever runtime this machine has. The installer picks docker where there
# is one and podman on the Red Hat family; a case that says "docker" only
# tests half the distributions it is run on.
if command -v docker >/dev/null 2>&1; then
RT=docker
compose() { docker compose -f "$DIR/compose.yaml" "$@"; }
else
RT=podman
compose() {
DOCKER_HOST=unix:///run/podman/podman.sock \
/usr/local/lib/docker/cli-plugins/docker-compose -f "$DIR/compose.yaml" "$@"
}
fi
echo "==> installing" echo "==> installing"
OUT="$(/tmp/inbuxa install --local --domain example.test --install-deps --yes 2>&1)"; rc=$? OUT="$(/tmp/inbuxa install --local --domain example.test --install-deps --yes 2>&1)"; rc=$?
echo "$OUT" | grep -v '^ |' | tail -24 | sed 's/^/ /' echo "$OUT" | grep -v '^ |' | tail -24 | sed 's/^/ /'
@@ -38,7 +52,7 @@ grep -q "_domainkey" "$DIR/dns.zone" && ok "and the DKIM key it generated" || ba
echo echo
echo "==> what it left running" echo "==> what it left running"
STATE="$(docker compose -f $DIR/compose.yaml ps --format '{{.Service}} {{.State}}')" STATE="$(compose ps --format '{{.Service}} {{.State}}')"
for s in server console webmail; do for s in server console webmail; do
grep -q "^$s running" <<<"$STATE" && ok "$s is running" || { bad "$s is not running"; echo "$STATE" | sed 's/^/ /'; } grep -q "^$s running" <<<"$STATE" && ok "$s is running" || { bad "$s is not running"; echo "$STATE" | sed 's/^/ /'; }
done done
@@ -52,7 +66,7 @@ curl -fsS http://127.0.0.1:8080/api/health 2>/dev/null | grep -q '"ok":true' &&
echo echo
echo "==> the bootstrap credential did not outlive the setup" echo "==> the bootstrap credential did not outlive the setup"
ENVOUT="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$(docker compose -f $DIR/compose.yaml ps -q server)")" ENVOUT="$($RT inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$(compose ps -q server)")"
grep -q "RECOVERY_ADMIN" <<<"$ENVOUT" && { bad "the server still carries a recovery admin"; echo "$ENVOUT" | grep RECOVERY | sed 's/^/ /'; } || ok "no recovery admin in the running server" grep -q "RECOVERY_ADMIN" <<<"$ENVOUT" && { bad "the server still carries a recovery admin"; echo "$ENVOUT" | grep RECOVERY | sed 's/^/ /'; } || ok "no recovery admin in the running server"
grep -q "INBUXA_WEBMAIL_CLIENT_SECRET" <<<"$ENVOUT" && ok "the webmail's client secret is where it belongs" || bad "the server has no webmail client secret" grep -q "INBUXA_WEBMAIL_CLIENT_SECRET" <<<"$ENVOUT" && ok "the webmail's client secret is where it belongs" || bad "the server has no webmail client secret"
@@ -70,7 +84,7 @@ grep -q "urn:ietf:params:jmap:mail" /tmp/login.json && ok "and the session carri
echo echo
echo "==> running it again converges rather than duplicating" echo "==> running it again converges rather than duplicating"
OUT="$(/tmp/inbuxa install --local --domain example.test --yes 2>&1)"; rc=$? OUT="$(/tmp/inbuxa install --local --domain example.test --yes 2>&1)"; rc=$?
COUNT=$(docker compose -f $DIR/compose.yaml ps --format '{{.Service}}' | sort -u | wc -l) COUNT=$(compose ps --format '{{.Service}}' | sort -u | wc -l)
[ "$COUNT" = 3 ] && ok "still three services, not six" || bad "$COUNT services after a second run" [ "$COUNT" = 3 ] && ok "still three services, not six" || bad "$COUNT services after a second run"
echo echo
+16 -2
View File
@@ -26,6 +26,20 @@ MAIL=mx.lab.test # not "mail": proves the names follow --mail-host
CONSOLE=console.lab.test CONSOLE=console.lab.test
WEBMAIL=webmail.lab.test WEBMAIL=webmail.lab.test
DIR=/var/lib/inbuxa DIR=/var/lib/inbuxa
# Whichever runtime this machine has. The installer picks docker where there
# is one and podman on the Red Hat family; a case that says "docker" only
# tests half the distributions it is run on.
if command -v docker >/dev/null 2>&1; then
RT=docker
compose() { docker compose -f "$DIR/compose.yaml" "$@"; }
else
RT=podman
compose() {
DOCKER_HOST=unix:///run/podman/podman.sock \
/usr/local/lib/docker/cli-plugins/docker-compose -f "$DIR/compose.yaml" "$@"
}
fi
WORK=/tmp/lab WORK=/tmp/lab
LABNET=inbuxa-e2e LABNET=inbuxa-e2e
LABSUBNET=172.31.254.0/24 LABSUBNET=172.31.254.0/24
@@ -35,7 +49,7 @@ rm -rf "$WORK"; mkdir -p "$WORK"
echo "==> what the installer needs, before the lab" echo "==> what the installer needs, before the lab"
/tmp/inbuxa deps --console container --webmail container --install >/dev/null 2>&1 || true /tmp/inbuxa deps --console container --webmail container --install >/dev/null 2>&1 || true
docker version >/dev/null 2>&1 && ok "docker is usable" || { bad "no docker"; exit 1; } { docker version >/dev/null 2>&1 || podman version >/dev/null 2>&1; } && ok "a container runtime is usable" || { bad "no docker"; exit 1; }
echo echo
echo "==> standing up a private CA and a DNS stub" echo "==> standing up a private CA and a DNS stub"
@@ -145,7 +159,7 @@ CODE=$(curl -s -o /dev/null -w '%{http_code}' --cacert "$WORK/chain.pem" --resol
echo echo
echo "==> and nothing was left behind that should not be" echo "==> and nothing was left behind that should not be"
ENVOUT="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$(docker compose -f $DIR/compose.yaml ps -q server)")" ENVOUT="$($RT inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$(compose ps -q server)")"
grep -q "RECOVERY_ADMIN" <<<"$ENVOUT" && bad "the server still carries a recovery admin" || ok "no recovery admin on the server" grep -q "RECOVERY_ADMIN" <<<"$ENVOUT" && bad "the server still carries a recovery admin" || ok "no recovery admin on the server"
echo echo
+5 -3
View File
@@ -5,11 +5,13 @@
# Stop the lab machine and remove its disks. The base image stays, so up.sh # Stop the lab machine and remove its disks. The base image stays, so up.sh
# is quick the next time; --all takes that too. # is quick the next time; --all takes that too.
set -euo pipefail set -euo pipefail
LAB="${LAB:-$HOME/.cache/inbuxa-lab}" . "$(dirname "$0")/lib.sh"
SSH_PORT="${SSH_PORT:-2222}"
DISTRO="${DISTRO:-debian13}"
LAB="${LAB:-$HOME/.cache/inbuxa-lab/$DISTRO}"
SSH_PORT="${SSH_PORT:-$(distro_port "$DISTRO")}"
DISK="$LAB/lab.qcow2"; CLEAN="$LAB/lab-clean.qcow2"; SEED="$LAB/seed.iso" DISK="$LAB/lab.qcow2"; CLEAN="$LAB/lab-clean.qcow2"; SEED="$LAB/seed.iso"
LOG="$LAB/console.log"; PIDFILE="$LAB/qemu.pid" LOG="$LAB/console.log"; PIDFILE="$LAB/qemu.pid"
. "$(dirname "$0")/lib.sh"
vm_stop vm_stop
rm -f "$DISK" "$CLEAN" "$SEED" "$PIDFILE" "$LAB/monitor.sock" rm -f "$DISK" "$CLEAN" "$SEED" "$PIDFILE" "$LAB/monitor.sock"
+40 -1
View File
@@ -2,7 +2,11 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
# #
# Shared by the four scripts beside it: how the lab machine is started, # Shared by the four scripts beside it: how the lab machine is started,
# stopped and talked to. Sourced, never run. # stopped and talked to, and which distribution it runs. Sourced, never run.
#
# One lab per distribution, each with its own directory, disk and ssh port,
# so Fedora and Debian can be up at once and a case can be run against both
# without either noticing the other.
# #
# One machine at a time, identified by its pid file. qemu is given a monitor # 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 # on a unix socket so a stop is a clean powerdown rather than pulling the
@@ -13,6 +17,41 @@
# the machine resets in a loop at "Booting Debian GNU/Linux" forever. -display # 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. # none is what makes it headless; the adapter has to be there.
# The distributions this installer claims to support: Debian and Red Hat and
# Arch, and the derivatives people actually run. Each is the distribution's
# own cloud image, which is cloud-init seeded and needs no interaction.
#
# Fedora and Rocky are here because they are where the interesting
# differences live: podman instead of docker, firewalld on by default, and --
# on Rocky -- a glibc older than the server binary needs.
distro_image() {
case "${1:-debian13}" in
debian13) echo "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" ;;
debian12) echo "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2" ;;
ubuntu2404) echo "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img" ;;
# Fedora's image name carries a build number that changes with every
# release, so this is pinned rather than guessed; check the mirror index
# when moving it.
fedora) echo "https://dl.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2" ;;
rocky9) echo "https://download.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud.latest.x86_64.qcow2" ;;
arch) echo "https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2" ;;
*) echo "" ;;
esac
}
# Each lab gets its own ssh port, so several can be up at once.
distro_port() {
case "${1:-debian13}" in
debian13) echo 2222 ;;
debian12) echo 2223 ;;
ubuntu2404) echo 2224 ;;
fedora) echo 2225 ;;
rocky9) echo 2226 ;;
arch) echo 2227 ;;
*) echo 2222 ;;
esac
}
vm_running() { vm_running() {
[ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
} }
+5 -3
View File
@@ -6,11 +6,13 @@
# machine is as fresh as a new VPS -- which is what makes a test run free to # machine is as fresh as a new VPS -- which is what makes a test run free to
# create users, write units and take ports. # create users, write units and take ports.
set -euo pipefail set -euo pipefail
LAB="${LAB:-$HOME/.cache/inbuxa-lab}" . "$(dirname "$0")/lib.sh"
MEM="${MEM:-4096}"; VCPUS="${VCPUS:-2}"; SSH_PORT="${SSH_PORT:-2222}"
DISTRO="${DISTRO:-debian13}"
LAB="${LAB:-$HOME/.cache/inbuxa-lab/$DISTRO}"
MEM="${MEM:-4096}"; VCPUS="${VCPUS:-2}"; SSH_PORT="${SSH_PORT:-$(distro_port "$DISTRO")}"
DISK="$LAB/lab.qcow2"; CLEAN="$LAB/lab-clean.qcow2"; SEED="$LAB/seed.iso" DISK="$LAB/lab.qcow2"; CLEAN="$LAB/lab-clean.qcow2"; SEED="$LAB/seed.iso"
LOG="$LAB/console.log"; PIDFILE="$LAB/qemu.pid" 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; } [ -f "$CLEAN" ] || { echo "no clean copy -- run e2e/vm/up.sh first" >&2; exit 1; }
+5 -3
View File
@@ -12,11 +12,13 @@
set -euo pipefail set -euo pipefail
CASE="${1:?usage: run.sh e2e/cases/<case>.sh}" CASE="${1:?usage: run.sh e2e/cases/<case>.sh}"
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
LAB="${LAB:-$HOME/.cache/inbuxa-lab}" . "$(dirname "$0")/lib.sh"
MEM="${MEM:-4096}"; VCPUS="${VCPUS:-2}"; SSH_PORT="${SSH_PORT:-2222}"
DISTRO="${DISTRO:-debian13}"
LAB="${LAB:-$HOME/.cache/inbuxa-lab/$DISTRO}"
MEM="${MEM:-4096}"; VCPUS="${VCPUS:-2}"; SSH_PORT="${SSH_PORT:-$(distro_port "$DISTRO")}"
DISK="$LAB/lab.qcow2"; CLEAN="$LAB/lab-clean.qcow2"; SEED="$LAB/seed.iso" DISK="$LAB/lab.qcow2"; CLEAN="$LAB/lab-clean.qcow2"; SEED="$LAB/seed.iso"
LOG="$LAB/console.log"; PIDFILE="$LAB/qemu.pid" LOG="$LAB/console.log"; PIDFILE="$LAB/qemu.pid"
. "$(dirname "$0")/lib.sh"
[ -f "$ROOT/$CASE" ] || { echo "no such case: $CASE" >&2; exit 1; } [ -f "$ROOT/$CASE" ] || { echo "no such case: $CASE" >&2; exit 1; }
[ "${KEEP:-}" = 1 ] || "$(dirname "$0")/reset.sh" [ "${KEEP:-}" = 1 ] || "$(dirname "$0")/reset.sh"
+18 -8
View File
@@ -7,10 +7,17 @@
# The installer writes units, creates users, takes 25 and 443 and can install # 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 # 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 # 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 # test. So: a distribution's own cloud image in qemu, seeded with cloud-init,
# of the disk kept the moment it is up. Every run starts from that copy, so a # with a copy of the disk kept the moment it is up. Every run starts from that
# run can break the machine as thoroughly as it likes. # copy, so a run can break the machine as thoroughly as it likes.
# #
# DISTRO picks which: debian13 (the default), debian12, ubuntu2404, fedora,
# rocky9 or arch. They are not interchangeable, which is the point -- podman
# rather than docker on the Red Hat family, firewalld on by default there, and
# a glibc on Rocky 9 older than the server binary needs. Each has its own
# directory and ssh port, so several can be up at once.
#
# DISTRO=fedora e2e/vm/up.sh
# e2e/vm/up.sh build it and keep a clean copy (idempotent) # 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/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/run.sh build the installer, copy it in, run a case inside
@@ -24,13 +31,16 @@
# Needs: qemu-system-x86_64, /dev/kvm, xorriso, ssh, curl. # Needs: qemu-system-x86_64, /dev/kvm, xorriso, ssh, curl.
set -euo pipefail set -euo pipefail
LAB="${LAB:-$HOME/.cache/inbuxa-lab}" . "$(dirname "$0")/lib.sh"
DISTRO="${DISTRO:-debian13}"
LAB="${LAB:-$HOME/.cache/inbuxa-lab/$DISTRO}"
MEM="${MEM:-4096}" MEM="${MEM:-4096}"
VCPUS="${VCPUS:-2}" VCPUS="${VCPUS:-2}"
DISK_GB="${DISK_GB:-20}" DISK_GB="${DISK_GB:-20}"
SSH_PORT="${SSH_PORT:-2222}" SSH_PORT="${SSH_PORT:-$(distro_port "$DISTRO")}"
BASE_URL="https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" BASE_URL="${BASE_URL:-$(distro_image "$DISTRO")}"
BASE="$LAB/debian-13-base.qcow2" BASE="$LAB/base.qcow2"
DISK="$LAB/lab.qcow2" DISK="$LAB/lab.qcow2"
CLEAN="$LAB/lab-clean.qcow2" CLEAN="$LAB/lab-clean.qcow2"
SEED="$LAB/seed.iso" SEED="$LAB/seed.iso"
@@ -39,8 +49,8 @@ PIDFILE="$LAB/qemu.pid"
KEY="${KEY:-$HOME/.ssh/id_ed25519.pub}" KEY="${KEY:-$HOME/.ssh/id_ed25519.pub}"
say() { echo "==> $*"; } say() { echo "==> $*"; }
. "$(dirname "$0")/lib.sh"
[ -n "$BASE_URL" ] || { echo "unknown distribution '$DISTRO' (debian13, debian12, ubuntu2404, fedora, rocky9, arch)" >&2; exit 1; }
[ -r "$KEY" ] || { echo "no public key at $KEY (set KEY=)" >&2; exit 1; } [ -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; } [ -w /dev/kvm ] || { echo "no writable /dev/kvm -- is this user in the kvm group?" >&2; exit 1; }
+15 -2
View File
@@ -30,6 +30,7 @@ import (
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/compose" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/compose"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/docker" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/docker"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/jmap" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/jmap"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan"
) )
@@ -70,7 +71,7 @@ type Log interface {
// Run carries out p. It refuses anything but container shapes for now, and // Run carries out p. It refuses anything but container shapes for now, and
// says so rather than pretending a host install happened. // says so rather than pretending a host install happened.
func Run(ctx context.Context, p plan.Plan, log Log) (*Result, error) { func Run(ctx context.Context, p plan.Plan, f host.Facts, log Log) (*Result, error) {
o := p.Options o := p.Options
for _, c := range []plan.Component{plan.Server, plan.Console, plan.Webmail} { for _, c := range []plan.Component{plan.Server, plan.Console, plan.Webmail} {
if o.Shape(c) == plan.Host { if o.Shape(c) == plan.Host {
@@ -86,6 +87,15 @@ func Run(ctx context.Context, p plan.Plan, log Log) (*Result, error) {
if err := deps.Resolve(ctx, log.Out(), p.Needs); err != nil { if err := deps.Resolve(ctx, log.Out(), p.Needs); err != nil {
return nil, err return nil, err
} }
// The survey was taken before any of that existed. Installing podman
// on a machine that had no runtime and then driving the deployment
// with the old facts meant reaching for a docker that was never
// going to be there.
f = host.Survey(ctx)
if !f.Runtime.Usable {
return nil, fmt.Errorf("after installing what was missing, containers still are not usable: %s", f.Runtime.Why)
}
log.Info("using %s %s", f.Runtime.Kind, f.Runtime.Version)
} }
dir := o.Dir dir := o.Dir
@@ -165,7 +175,10 @@ func Run(ctx context.Context, p plan.Plan, log Log) (*Result, error) {
} }
log.Info("compose.yaml, .env%s", map[bool]string{true: " and Caddyfile", false: ""}[stack.Proxy]) log.Info("compose.yaml, .env%s", map[bool]string{true: " and Caddyfile", false: ""}[stack.Proxy])
cmp := docker.Compose{Dir: dir} // Whichever runtime the survey found: docker where there is one, podman
// on the Red Hat family, driven through the same compose plugin.
docker.UseRuntime(f.Runtime)
cmp := docker.Compose{Dir: dir, Runtime: f.Runtime}
log.Step("fetching the images") log.Step("fetching the images")
if err := cmp.Run(ctx, "pull", "--quiet"); err != nil { if err := cmp.Run(ctx, "pull", "--quiet"); err != nil {
+81 -18
View File
@@ -80,18 +80,7 @@ type step struct {
func For(f host.Facts, wantContainers, wantHostWebmail bool) []Need { func For(f host.Facts, wantContainers, wantHostWebmail bool) []Need {
var needs []Need var needs []Need
if wantContainers { if wantContainers {
switch { needs = append(needs, runtimeNeeds(f)...)
case !f.Docker.Present:
needs = append(needs, dockerNeed(f), composeNeed(f))
case !f.Docker.Usable && strings.Contains(f.Docker.Why, "compose"):
needs = append(needs, composeNeed(f))
case !f.Docker.Usable:
needs = append(needs, Need{
Name: "docker",
Because: "a container install",
Why: f.Docker.Why + " -- that is not something this installer should fix for you",
})
}
} }
if wantHostWebmail && (!f.Node.Present || f.Node.Major < 22) { if wantHostWebmail && (!f.Node.Present || f.Node.Major < 22) {
needs = append(needs, nodeNeed(f)) needs = append(needs, nodeNeed(f))
@@ -105,11 +94,84 @@ func For(f host.Facts, wantContainers, wantHostWebmail bool) []Need {
return out return out
} }
// runtimeNeeds is what this machine is missing before it can run containers.
//
// Which runtime depends on the distribution, and that is the whole point:
// Debian and Arch ship Docker, and the Red Hat family ships podman and no
// Docker at all. Telling a Fedora or Rocky operator to add Docker's own
// repository -- a new source and a new signing key -- to a machine that
// already has a working container runtime would be the wrong trade. Compose
// speaks the Docker API and podman serves it, so one compose file and one
// plugin drive either.
func runtimeNeeds(f host.Facts) []Need {
if f.Runtime.Usable {
return nil
}
switch {
// Something is installed and broken in a way that is not ours to fix.
case f.Runtime.Present && f.Runtime.Compose != "":
return []Need{{
Name: f.Runtime.Kind,
Because: "a container install",
Why: f.Runtime.Why + " -- that is not something this installer should fix for you",
}}
case f.Runtime.Kind == "podman":
// Podman is here; what is missing is its API socket, the compose
// plugin, or both.
var needs []Need
if strings.Contains(f.Runtime.Why, "socket") {
needs = append(needs, podmanSocketNeed())
}
if strings.Contains(f.Runtime.Why, "compose") || len(needs) == 0 {
needs = append(needs, composeNeed(f))
}
return needs
case f.OS.Family == "rhel":
// No runtime at all, on a distribution whose own is podman.
return []Need{podmanNeed(f), podmanSocketNeed(), composeNeed(f)}
case !f.Runtime.Present:
return []Need{dockerNeed(f), composeNeed(f)}
default:
return []Need{{
Name: "docker",
Because: "a container install",
Why: f.Runtime.Why + " -- that is not something this installer should fix for you",
}}
}
}
func podmanNeed(f host.Facts) Need {
n := Need{Name: "podman", Because: "a container install"}
pkg, install := packageInstall(f.OS.Family, "podman")
if install == nil {
n.Why = "this installer does not know how to install podman on " + describeOS(f.OS)
return n
}
n.Fixable = true
n.Actions = []string{fmt.Sprintf("install %s from the distribution's own archive", pkg)}
n.steps = []step{{"installing " + pkg, install}}
return n
}
// podmanSocketNeed turns on the API socket compose talks to. Podman works
// perfectly well without it; compose does not.
func podmanSocketNeed() Need {
return Need{
Name: "podman socket",
Because: "compose, which speaks the Docker API that this socket serves",
Fixable: true,
Actions: []string{"enable and start podman.socket, which serves the API at /run/podman/podman.sock"},
steps: []step{{"starting podman.socket", func(ctx context.Context, log io.Writer) error {
return run(ctx, log, "systemctl", "enable", "--now", "podman.socket")
}}},
}
}
func dockerNeed(f host.Facts) Need { func dockerNeed(f host.Facts) Need {
n := Need{Name: "docker", Because: "a container install"} n := Need{Name: "docker", Because: "a container install"}
pkg, install := packageInstall(f.OS.Family, dockerPackage(f.OS.Family)) pkg, install := packageInstall(f.OS.Family, dockerPackage(f.OS.Family))
if install == nil { if install == nil {
n.Why = "this installer does not know how to install Docker on " + describeOS(f.OS) n.Why = "this installer does not know how to install a container runtime on " + describeOS(f.OS)
return n return n
} }
n.Fixable = true n.Fixable = true
@@ -126,6 +188,8 @@ func dockerNeed(f host.Facts) Need {
return n return n
} }
// composeNeed is the same plugin whichever runtime is underneath: compose
// speaks the Docker API, and podman serves it.
func composeNeed(f host.Facts) Need { func composeNeed(f host.Facts) Need {
arch := goarch() arch := goarch()
sum, ok := composeSHA[arch] sum, ok := composeSHA[arch]
@@ -136,15 +200,14 @@ func composeNeed(f host.Facts) Need {
} }
url := fmt.Sprintf("https://github.com/docker/compose/releases/download/%s/docker-compose-linux-%s", url := fmt.Sprintf("https://github.com/docker/compose/releases/download/%s/docker-compose-linux-%s",
composeVersion, archName(arch)) composeVersion, archName(arch))
dest := "/usr/local/lib/docker/cli-plugins/docker-compose"
n.Fixable = true n.Fixable = true
n.Actions = []string{ n.Actions = []string{
fmt.Sprintf("fetch the Compose plugin %s (%s) and check it against its pinned checksum", composeVersion, arch), fmt.Sprintf("fetch the Compose plugin %s (%s) and check it against its pinned checksum", composeVersion, arch),
"put it at " + dest, "put it at " + host.ComposePluginPath,
} }
n.steps = []step{ n.steps = []step{
{"fetching compose " + composeVersion, func(ctx context.Context, log io.Writer) error { {"fetching compose " + composeVersion, func(ctx context.Context, log io.Writer) error {
return fetchVerified(ctx, log, url, sum, dest, 0o755) return fetchVerified(ctx, log, url, sum, host.ComposePluginPath, 0o755)
}}, }},
} }
return n return n
@@ -185,11 +248,11 @@ func dockerPackage(family string) string {
return "docker.io" return "docker.io"
case "arch": case "arch":
return "docker" return "docker"
case "rhel":
return "docker"
case "suse": case "suse":
return "docker" return "docker"
} }
// The Red Hat family is deliberately absent: its distributions ship
// podman, not Docker, and runtimeNeeds sends them there.
return "" return ""
} }
+73 -7
View File
@@ -16,11 +16,28 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
) )
// Output runs docker with args and returns its standard output. // Output runs docker with args and returns its standard output.
// CLI is the runtime's own command: "docker", or "podman" where that is
// what the machine has. Podman's CLI takes the same pull/create/cp/inspect
// arguments these helpers use.
var CLI = "docker"
// UseRuntime points the package's helpers at whichever runtime the survey
// found, before anything is run.
func UseRuntime(r host.Runtime) {
if r.Kind == "podman" {
CLI = "podman"
return
}
CLI = "docker"
}
func Output(ctx context.Context, args ...string) (string, error) { func Output(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", args...) cmd := exec.CommandContext(ctx, CLI, args...)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
@@ -28,7 +45,7 @@ func Output(ctx context.Context, args ...string) (string, error) {
if msg == "" { if msg == "" {
msg = err.Error() msg = err.Error()
} }
return "", fmt.Errorf("docker %s: %s", strings.Join(args, " "), msg) return "", fmt.Errorf("%s %s: %s", CLI, strings.Join(args, " "), msg)
} }
return strings.TrimSpace(stdout.String()), nil return strings.TrimSpace(stdout.String()), nil
} }
@@ -128,6 +145,34 @@ type Compose struct {
Files []string // extra -f files after compose.yaml, e.g. the bootstrap override Files []string // extra -f files after compose.yaml, e.g. the bootstrap override
Env []string // added to the environment compose interpolates from Env []string // added to the environment compose interpolates from
Out io.Writer Out io.Writer
Runtime host.Runtime // docker or podman; zero value means docker on PATH
}
// command is how compose is invoked here. With docker it is a subcommand of
// the docker CLI; with podman there is no docker CLI at all, so the plugin
// binary is run directly and pointed at podman's API socket. Compose speaks
// the Docker API and podman serves it, so the same plugin and the same
// compose file drive either -- which is why there is one deployment path and
// not two.
func (c Compose) command(ctx context.Context, args []string) *exec.Cmd {
if c.Runtime.Kind == "podman" {
cmd := exec.CommandContext(ctx, host.ComposePluginPath, args...)
socket := c.Runtime.Socket
if socket == "" {
socket = "/run/podman/podman.sock"
}
cmd.Env = append(os.Environ(), "DOCKER_HOST=unix://"+socket)
return cmd
}
return exec.CommandContext(ctx, "docker", append([]string{"compose"}, args...)...)
}
// name is what to call the thing in an error message.
func (c Compose) name() string {
if c.Runtime.Kind == "podman" {
return "podman compose"
}
return "docker compose"
} }
// Run runs a compose command with its output passed through: pulling images // Run runs a compose command with its output passed through: pulling images
@@ -135,7 +180,7 @@ type Compose struct {
// is quiet, because without a terminal compose prints each container's every // is quiet, because without a terminal compose prints each container's every
// state change twice and the tool already says what step it is on. // state change twice and the tool already says what step it is on.
func (c Compose) Run(ctx context.Context, args ...string) error { func (c Compose) Run(ctx context.Context, args ...string) error {
full := []string{"compose", "--project-directory", c.Dir, "-f", c.Dir + "/compose.yaml"} full := []string{"--project-directory", c.Dir, "-f", c.Dir + "/compose.yaml"}
if len(args) > 0 && args[0] != "pull" { if len(args) > 0 && args[0] != "pull" {
full = append(full, "--progress", "quiet") full = append(full, "--progress", "quiet")
} }
@@ -143,18 +188,39 @@ func (c Compose) Run(ctx context.Context, args ...string) error {
full = append(full, "-f", f) full = append(full, "-f", f)
} }
full = append(full, args...) full = append(full, args...)
cmd := exec.CommandContext(ctx, "docker", full...) cmd := c.command(ctx, full)
cmd.Env = append(cmd.Env, c.Env...)
if cmd.Env == nil {
cmd.Env = append(os.Environ(), c.Env...) cmd.Env = append(os.Environ(), c.Env...)
}
cmd.Stdout, cmd.Stderr = c.Out, c.Out cmd.Stdout, cmd.Stderr = c.Out, c.Out
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("docker compose %s: %w", strings.Join(args, " "), err) return fmt.Errorf("%s %s: %w", c.name(), strings.Join(args, " "), err)
} }
return nil return nil
} }
// Output runs a compose command and returns what it printed.
func (c Compose) Output(ctx context.Context, args ...string) (string, error) {
cmd := c.command(ctx, args)
if cmd.Env == nil {
cmd.Env = os.Environ()
}
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("%s %s: %s", c.name(), strings.Join(args, " "), msg)
}
return strings.TrimSpace(stdout.String()), nil
}
// Running reports whether a service has a running container. // Running reports whether a service has a running container.
func (c Compose) Running(ctx context.Context, service string) bool { func (c Compose) Running(ctx context.Context, service string) bool {
out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml", out, err := c.Output(ctx, "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml",
"ps", "--status", "running", "--services") "ps", "--status", "running", "--services")
if err != nil { if err != nil {
return false return false
@@ -169,7 +235,7 @@ func (c Compose) Running(ctx context.Context, service string) bool {
// Logs returns the last lines of one service's log, for a failure report. // Logs returns the last lines of one service's log, for a failure report.
func (c Compose) Logs(ctx context.Context, service string, lines int) string { func (c Compose) Logs(ctx context.Context, service string, lines int) string {
out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml", out, err := c.Output(ctx, "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml",
"logs", "--no-color", "--tail", fmt.Sprint(lines), service) "logs", "--no-color", "--tail", fmt.Sprint(lines), service)
if err != nil { if err != nil {
return err.Error() return err.Error()
+165 -24
View File
@@ -34,8 +34,9 @@ type Facts struct {
OS OSInfo OS OSInfo
Root bool // running as uid 0 Root bool // running as uid 0
Systemd bool // systemd is pid 1 Systemd bool // systemd is pid 1
Docker Docker // Runtime Runtime // docker or podman, whichever this machine can use
Node Node // Node Node //
Glibc Glibc // what a downloaded binary has to be able to run against
Ports []Port // the ports the suite wants, and who holds them Ports []Port // the ports the suite wants, and who holds them
DiskFreeGB int // on the filesystem that would hold the install DiskFreeGB int // on the filesystem that would hold the install
MemoryMB int MemoryMB int
@@ -50,13 +51,42 @@ type OSInfo struct {
Family string // "debian", "rhel", "arch", "suse", "" when unknown Family string // "debian", "rhel", "arch", "suse", "" when unknown
} }
// Docker is whether containers are a real option for this user. // Runtime is whether containers are a real option for this user, and with
type Docker struct { // what. Docker and podman are both first-class: podman is what the Red Hat
Present bool // family ships, and refusing to see it would mean telling a Fedora or Rocky
Usable bool // the daemon answers *as this user*, which is the part that matters // operator to install Docker from a third-party repository on a machine that
Version string // // already has a container runtime.
Compose string // "v2" when `docker compose` works, "" otherwise type Runtime struct {
Kind string // "docker", "podman", or "" when neither can be used
Present bool // one of them is installed, whether or not it works
Usable bool // it answers *as this user*, which is the part that matters
Version string
Compose string // "v2" when compose works, "" otherwise
Socket string // for podman, the API socket compose is pointed at
Why string // why it is unusable, in a sentence fit to show someone Why string // why it is unusable, in a sentence fit to show someone
// What is missing, named rather than described, so the caller does not
// have to read Why to decide what to do. Reporting only the first thing
// in the way meant fixing one of them and being told about the next.
NeedsSocket bool
NeedsCompose bool
}
// Glibc is the C library a downloaded binary is linked against. The release
// binaries are built in a current Debian, and a host install on an older
// distribution would put a binary on the machine that cannot start.
type Glibc struct {
Version string // "2.39"
Major int
Minor int
}
// AtLeast answers whether this glibc is new enough for a given version.
func (g Glibc) AtLeast(major, minor int) bool {
if g.Major != major {
return g.Major > major
}
return g.Minor >= minor
} }
// Node is what a host install of the webmail would run on. // Node is what a host install of the webmail would run on.
@@ -106,8 +136,9 @@ func Survey(ctx context.Context) Facts {
f := Facts{ f := Facts{
OS: readOSRelease("/etc/os-release"), OS: readOSRelease("/etc/os-release"),
Systemd: isSystemd(), Systemd: isSystemd(),
Docker: surveyDocker(ctx), Runtime: surveyRuntime(ctx),
Node: surveyNode(ctx), Node: surveyNode(ctx),
Glibc: surveyGlibc(ctx),
MemoryMB: memoryMB(), MemoryMB: memoryMB(),
} }
if u, err := user.Current(); err == nil { if u, err := user.Current(); err == nil {
@@ -166,14 +197,37 @@ func isSystemd() bool {
return err == nil && st.IsDir() return err == nil && st.IsDir()
} }
func surveyDocker(ctx context.Context) Docker { // surveyRuntime looks for docker first and podman second, and reports the
var d Docker // one that actually answers. Preferring docker where both exist keeps a
bin, err := exec.LookPath("docker") // machine that has been set up for docker working the way its operator
if err != nil { // expects; podman is what the Red Hat family ships, and is no less
d.Why = "docker is not installed" // first-class for being second in the list.
func surveyRuntime(ctx context.Context) Runtime {
if r := surveyDocker(ctx); r.Usable {
return r
} else if d := r; d.Present {
// Docker is installed but broken. Podman may still be here and
// working, and saying so is more use than reporting the broken one.
if p := surveyPodman(ctx); p.Usable {
return p
}
return d return d
} }
d.Present = true if p := surveyPodman(ctx); p.Present {
return p
}
return Runtime{Why: "neither docker nor podman is installed"}
}
func surveyDocker(ctx context.Context) Runtime {
r := Runtime{Kind: "docker"}
bin, err := exec.LookPath("docker")
if err != nil {
r.Kind = ""
r.Why = "docker is not installed"
return r
}
r.Present = true
ctx, cancel := context.WithTimeout(ctx, 10*time.Second) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
@@ -182,22 +236,109 @@ func surveyDocker(ctx context.Context) Docker {
// answers the question that matters: can *this user* run containers? // answers the question that matters: can *this user* run containers?
out, err := exec.CommandContext(ctx, bin, "version", "--format", "{{.Server.Version}}").CombinedOutput() out, err := exec.CommandContext(ctx, bin, "version", "--format", "{{.Server.Version}}").CombinedOutput()
if err != nil { if err != nil {
d.Why = firstLine(string(out)) r.Why = firstLine(string(out))
if d.Why == "" { if r.Why == "" {
d.Why = "the docker daemon did not answer" r.Why = "the docker daemon did not answer"
} }
return d return r
} }
d.Usable = true r.Usable = true
d.Version = strings.TrimSpace(string(out)) r.Version = strings.TrimSpace(string(out))
if err := exec.CommandContext(ctx, bin, "compose", "version").Run(); err == nil { if err := exec.CommandContext(ctx, bin, "compose", "version").Run(); err == nil {
d.Compose = "v2" r.Compose = "v2"
} else { } else {
d.Usable = false r.Usable = false
d.Why = "docker compose (v2) is not available" r.NeedsCompose = true
r.Why = "docker compose (v2) is not available"
} }
return d return r
}
// surveyPodman reports podman and the API socket compose can be pointed at.
// Compose speaks the Docker API, and podman serves it, so the same compose
// plugin drives either -- which is why this installer does not carry two
// deployment paths for one compose file.
func surveyPodman(ctx context.Context) Runtime {
r := Runtime{Kind: "podman"}
bin, err := exec.LookPath("podman")
if err != nil {
r.Kind = ""
r.Why = "podman is not installed"
return r
}
r.Present = true
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, bin, "version", "--format", "{{.Version}}").CombinedOutput()
if err != nil {
r.Why = firstLine(string(out))
if r.Why == "" {
r.Why = "podman did not answer"
}
return r
}
r.Version = strings.TrimSpace(string(out))
// Two things make compose work here, and both are checked before
// reporting: the API socket podman.socket serves, which is not running
// on a fresh machine, and the compose plugin itself.
for _, path := range []string{"/run/podman/podman.sock"} {
if st, err := os.Stat(path); err == nil && st.Mode()&os.ModeSocket != 0 {
r.Socket = path
}
}
r.NeedsSocket = r.Socket == ""
if _, err := os.Stat(ComposePluginPath); err == nil {
r.Compose = "v2"
} else {
r.NeedsCompose = true
}
switch {
case r.NeedsSocket && r.NeedsCompose:
r.Why = "podman's API socket is not running (podman.socket), and the compose plugin is not installed"
case r.NeedsSocket:
r.Why = "podman's API socket is not running (podman.socket)"
case r.NeedsCompose:
r.Why = "the compose plugin is not installed"
default:
r.Usable = true
}
return r
}
// ComposePluginPath is where this installer puts the compose plugin, and
// where it looks for one it put there before. The same file serves docker
// and podman.
const ComposePluginPath = "/usr/local/lib/docker/cli-plugins/docker-compose"
// surveyGlibc reads the C library's version, which decides whether a
// downloaded binary can run here at all. The release binaries are built in a
// current Debian; an older distribution can host containers perfectly well
// and still be unable to start that file.
func surveyGlibc(ctx context.Context) Glibc {
var g Glibc
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// getconf is in glibc itself; ldd --version is the fallback for a
// machine where it is missing.
out, err := exec.CommandContext(ctx, "getconf", "GNU_LIBC_VERSION").Output()
if err != nil || len(out) == 0 {
out, err = exec.CommandContext(ctx, "ldd", "--version").Output()
if err != nil {
return g
}
}
m := regexp.MustCompile(`([0-9]+)\.([0-9]+)`).FindStringSubmatch(firstLine(string(out)))
if m == nil {
return g
}
g.Major, _ = strconv.Atoi(m[1])
g.Minor, _ = strconv.Atoi(m[2])
g.Version = m[1] + "." + m[2]
return g
} }
var nodeVersion = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)`) var nodeVersion = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)`)
+33 -5
View File
@@ -114,27 +114,55 @@ type Availability struct {
Why string Why string
} }
// Available answers for one cell of the matrix. // ServerGlibc is the C library the published server binary is linked
// against. It is built in a current Debian, so a host install of the server
// needs a distribution at least this new -- Debian 13, Ubuntu 24.04, Fedora
// 40 and Arch all are; Rocky 9, Debian 12 and Ubuntu 22.04 are not, and on
// those the container shape is the answer rather than a binary that cannot
// start.
const (
ServerGlibcMajor = 2
ServerGlibcMinor = 39
)
// Available answers for one cell of the matrix: can this machine, as it is,
// deliver this component in this shape? Everything the interface offers and
// everything a flag will accept comes through here, so an offer is never
// made that the machine cannot keep.
func Available(f host.Facts, c Component, s Shape) Availability { func Available(f host.Facts, c Component, s Shape) Availability {
switch s { switch s {
case Skip: case Skip:
return Availability{OK: true} return Availability{OK: true}
case Container: case Container:
if !f.Docker.Present { if !f.Runtime.Present {
return Availability{Why: "docker is not installed"} return Availability{Why: "no container runtime: neither docker nor podman is installed"}
} }
if !f.Docker.Usable { if !f.Runtime.Usable {
return Availability{Why: f.Docker.Why} return Availability{Why: f.Runtime.Why}
} }
return Availability{OK: true} return Availability{OK: true}
case Host: case Host:
if !f.Systemd { if !f.Systemd {
return Availability{Why: "a host install needs systemd, which is not running this machine"} return Availability{Why: "a host install needs systemd, which is not running this machine"}
} }
switch c { switch c {
case Server: case Server:
// The binary is downloaded, not built here, so the machine has
// to be able to run it.
if f.Glibc.Version == "" {
return Availability{Why: "this machine's C library could not be read, and the server binary is linked against glibc " +
fmt.Sprintf("%d.%d", ServerGlibcMajor, ServerGlibcMinor)}
}
if !f.Glibc.AtLeast(ServerGlibcMajor, ServerGlibcMinor) {
return Availability{Why: fmt.Sprintf(
"the server binary needs glibc %d.%d or newer and this machine has %s; run the server as a container here",
ServerGlibcMajor, ServerGlibcMinor, f.Glibc.Version)}
}
return Availability{OK: true} return Availability{OK: true}
case Console: case Console:
// Static files behind whatever serves them: nothing to run.
return Availability{OK: true} return Availability{OK: true}
case Webmail: case Webmail:
if !f.Node.Present { if !f.Node.Present {