From e55ff6b2dff0b0b1c39bc1bbfc6d1b2f2535fdbb Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 22:22:45 -0700 Subject: [PATCH] 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. --- README.md | 8 ++ cmd/inbuxa/main.go | 41 ++++++-- e2e/cases/install-local.sh | 20 +++- e2e/cases/install-public.sh | 18 +++- e2e/vm/down.sh | 8 +- e2e/vm/lib.sh | 41 +++++++- e2e/vm/reset.sh | 8 +- e2e/vm/run.sh | 8 +- e2e/vm/up.sh | 26 +++-- internal/apply/apply.go | 17 ++- internal/deps/deps.go | 99 ++++++++++++++---- internal/docker/docker.go | 90 +++++++++++++--- internal/host/host.go | 199 ++++++++++++++++++++++++++++++------ internal/plan/plan.go | 38 ++++++- 14 files changed, 525 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index e2f123d..92aab32 100644 --- a/README.md +++ b/README.md @@ -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` 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 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: 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/deps.sh the offer, and taking it e2e/vm/run.sh e2e/cases/install-local.sh a whole suite, and signing in to it diff --git a/cmd/inbuxa/main.go b/cmd/inbuxa/main.go index 2664ee2..87f4c8e 100644 --- a/cmd/inbuxa/main.go +++ b/cmd/inbuxa/main.go @@ -15,6 +15,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/apply" @@ -60,6 +61,20 @@ deps flags: ` 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 { // The interface is the no-argument case. Until it lands, say so // plainly rather than pretending: a half-built screen is worse than @@ -145,7 +160,7 @@ func install(args []string) int { fmt.Println("\nApplying:") log := &printer{} - res, err := apply.Run(context.Background(), p, log) + res, err := apply.Run(context.Background(), p, f, log) if err != nil { fmt.Fprintln(os.Stderr, "\nstopped: "+err.Error()) return 1 @@ -285,14 +300,26 @@ func render(f host.Facts) string { fmt.Fprintf(&b, " %-22s %d GB free\n", "disk", f.DiskFreeGB) } - docker := "not installed" + runtime := "none: neither docker nor podman is installed" switch { - case f.Docker.Usable: - docker = "usable, server " + f.Docker.Version + ", compose " + f.Docker.Compose - case f.Docker.Present: - docker = "installed but not usable: " + f.Docker.Why + case f.Runtime.Usable: + runtime = f.Runtime.Kind + " " + f.Runtime.Version + ", compose " + f.Runtime.Compose + if f.Runtime.Socket != "" { + runtime += ", at " + f.Runtime.Socket + } + case f.Runtime.Present: + runtime = f.Runtime.Kind + " installed but not usable: " + f.Runtime.Why } - fmt.Fprintf(&b, " %-22s %s\n", "docker", docker) + 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" switch { diff --git a/e2e/cases/install-local.sh b/e2e/cases/install-local.sh index ac42d10..c7b9b86 100644 --- a/e2e/cases/install-local.sh +++ b/e2e/cases/install-local.sh @@ -21,6 +21,20 @@ has() { grep -q -- "$2" <<<"$1" && ok "$3" || { bad "$3"; echo "$1" | tail -20 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" OUT="$(/tmp/inbuxa install --local --domain example.test --install-deps --yes 2>&1)"; rc=$? 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 "==> 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 grep -q "^$s running" <<<"$STATE" && ok "$s is running" || { bad "$s is not running"; echo "$STATE" | sed 's/^/ /'; } done @@ -52,7 +66,7 @@ curl -fsS http://127.0.0.1:8080/api/health 2>/dev/null | grep -q '"ok":true' && echo 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 "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 "==> running it again converges rather than duplicating" 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" echo diff --git a/e2e/cases/install-public.sh b/e2e/cases/install-public.sh index 8d1f110..e4f270b 100644 --- a/e2e/cases/install-public.sh +++ b/e2e/cases/install-public.sh @@ -26,6 +26,20 @@ MAIL=mx.lab.test # not "mail": proves the names follow --mail-host CONSOLE=console.lab.test WEBMAIL=webmail.lab.test 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 LABNET=inbuxa-e2e LABSUBNET=172.31.254.0/24 @@ -35,7 +49,7 @@ rm -rf "$WORK"; mkdir -p "$WORK" echo "==> what the installer needs, before the lab" /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 "==> 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 "==> 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" echo diff --git a/e2e/vm/down.sh b/e2e/vm/down.sh index 81d46f1..e1bb5a4 100755 --- a/e2e/vm/down.sh +++ b/e2e/vm/down.sh @@ -5,11 +5,13 @@ # 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}" +. "$(dirname "$0")/lib.sh" + +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" LOG="$LAB/console.log"; PIDFILE="$LAB/qemu.pid" -. "$(dirname "$0")/lib.sh" vm_stop rm -f "$DISK" "$CLEAN" "$SEED" "$PIDFILE" "$LAB/monitor.sock" diff --git a/e2e/vm/lib.sh b/e2e/vm/lib.sh index 0d8ea86..223b2dd 100644 --- a/e2e/vm/lib.sh +++ b/e2e/vm/lib.sh @@ -2,7 +2,11 @@ # 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. +# 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 # 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 # 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() { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null } diff --git a/e2e/vm/reset.sh b/e2e/vm/reset.sh index 4f20c49..5682f42 100755 --- a/e2e/vm/reset.sh +++ b/e2e/vm/reset.sh @@ -6,11 +6,13 @@ # 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}" +. "$(dirname "$0")/lib.sh" + +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" 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; } diff --git a/e2e/vm/run.sh b/e2e/vm/run.sh index 664c122..6dcdf36 100755 --- a/e2e/vm/run.sh +++ b/e2e/vm/run.sh @@ -12,11 +12,13 @@ set -euo pipefail CASE="${1:?usage: run.sh e2e/cases/.sh}" ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -LAB="${LAB:-$HOME/.cache/inbuxa-lab}" -MEM="${MEM:-4096}"; VCPUS="${VCPUS:-2}"; SSH_PORT="${SSH_PORT:-2222}" +. "$(dirname "$0")/lib.sh" + +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" 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" diff --git a/e2e/vm/up.sh b/e2e/vm/up.sh index 38a3756..95bd71b 100755 --- a/e2e/vm/up.sh +++ b/e2e/vm/up.sh @@ -7,10 +7,17 @@ # 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. +# test. So: a distribution's own 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. # +# 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/reset.sh back to the clean copy, a few seconds # 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. 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}" 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" +SSH_PORT="${SSH_PORT:-$(distro_port "$DISTRO")}" +BASE_URL="${BASE_URL:-$(distro_image "$DISTRO")}" +BASE="$LAB/base.qcow2" DISK="$LAB/lab.qcow2" CLEAN="$LAB/lab-clean.qcow2" SEED="$LAB/seed.iso" @@ -39,8 +49,8 @@ PIDFILE="$LAB/qemu.pid" KEY="${KEY:-$HOME/.ssh/id_ed25519.pub}" 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; } [ -w /dev/kvm ] || { echo "no writable /dev/kvm -- is this user in the kvm group?" >&2; exit 1; } diff --git a/internal/apply/apply.go b/internal/apply/apply.go index ac43d11..e6f7490 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -30,6 +30,7 @@ import ( "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/compose" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps" "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/plan" ) @@ -70,7 +71,7 @@ type Log interface { // Run carries out p. It refuses anything but container shapes for now, and // 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 for _, c := range []plan.Component{plan.Server, plan.Console, plan.Webmail} { 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 { 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 @@ -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]) - 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") if err := cmp.Run(ctx, "pull", "--quiet"); err != nil { diff --git a/internal/deps/deps.go b/internal/deps/deps.go index eb3e1b0..bc36a7d 100644 --- a/internal/deps/deps.go +++ b/internal/deps/deps.go @@ -80,18 +80,7 @@ type step struct { func For(f host.Facts, wantContainers, wantHostWebmail bool) []Need { var needs []Need if wantContainers { - switch { - 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", - }) - } + needs = append(needs, runtimeNeeds(f)...) } if wantHostWebmail && (!f.Node.Present || f.Node.Major < 22) { needs = append(needs, nodeNeed(f)) @@ -105,11 +94,84 @@ func For(f host.Facts, wantContainers, wantHostWebmail bool) []Need { 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 { n := Need{Name: "docker", Because: "a container install"} pkg, install := packageInstall(f.OS.Family, dockerPackage(f.OS.Family)) 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 } n.Fixable = true @@ -126,6 +188,8 @@ func dockerNeed(f host.Facts) Need { 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 { arch := goarch() 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", composeVersion, archName(arch)) - dest := "/usr/local/lib/docker/cli-plugins/docker-compose" n.Fixable = true n.Actions = []string{ 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{ {"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 @@ -185,11 +248,11 @@ func dockerPackage(family string) string { return "docker.io" case "arch": return "docker" - case "rhel": - return "docker" case "suse": return "docker" } + // The Red Hat family is deliberately absent: its distributions ship + // podman, not Docker, and runtimeNeeds sends them there. return "" } diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 42549a0..db0af9d 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -16,11 +16,28 @@ import ( "os" "os/exec" "strings" + + "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host" ) // 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) { - cmd := exec.CommandContext(ctx, "docker", args...) + cmd := exec.CommandContext(ctx, CLI, args...) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr if err := cmd.Run(); err != nil { @@ -28,7 +45,7 @@ func Output(ctx context.Context, args ...string) (string, error) { if msg == "" { 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 } @@ -124,10 +141,38 @@ func ProjectLeftovers(ctx context.Context, project string) ([]string, error) { // Compose runs docker compose against one deployment directory. type Compose struct { - Dir string - Files []string // extra -f files after compose.yaml, e.g. the bootstrap override - Env []string // added to the environment compose interpolates from - Out io.Writer + Dir string + Files []string // extra -f files after compose.yaml, e.g. the bootstrap override + Env []string // added to the environment compose interpolates from + 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 @@ -135,7 +180,7 @@ type Compose struct { // 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. 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" { 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, args...) - cmd := exec.CommandContext(ctx, "docker", full...) - cmd.Env = append(os.Environ(), c.Env...) + cmd := c.command(ctx, full) + cmd.Env = append(cmd.Env, c.Env...) + if cmd.Env == nil { + cmd.Env = append(os.Environ(), c.Env...) + } cmd.Stdout, cmd.Stderr = c.Out, c.Out 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 } +// 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. 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") if err != nil { 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. 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) if err != nil { return err.Error() diff --git a/internal/host/host.go b/internal/host/host.go index 5c289c0..573faa3 100644 --- a/internal/host/host.go +++ b/internal/host/host.go @@ -32,12 +32,13 @@ import ( // anybody anything. type Facts struct { OS OSInfo - Root bool // running as uid 0 - Systemd bool // systemd is pid 1 - Docker Docker // - Node Node // - Ports []Port // the ports the suite wants, and who holds them - DiskFreeGB int // on the filesystem that would hold the install + Root bool // running as uid 0 + Systemd bool // systemd is pid 1 + Runtime Runtime // docker or podman, whichever this machine can use + 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 + DiskFreeGB int // on the filesystem that would hold the install MemoryMB int Existing string // path of a previous install's state file, or "" } @@ -50,13 +51,42 @@ type OSInfo struct { Family string // "debian", "rhel", "arch", "suse", "" when unknown } -// Docker is whether containers are a real option for this user. -type Docker struct { - Present bool - Usable bool // the daemon answers *as this user*, which is the part that matters - Version string // - Compose string // "v2" when `docker compose` works, "" otherwise +// Runtime is whether containers are a real option for this user, and with +// what. Docker and podman are both first-class: podman is what the Red Hat +// family ships, and refusing to see it would mean telling a Fedora or Rocky +// operator to install Docker from a third-party repository on a machine that +// already has a container runtime. +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 + + // 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. @@ -106,8 +136,9 @@ func Survey(ctx context.Context) Facts { f := Facts{ OS: readOSRelease("/etc/os-release"), Systemd: isSystemd(), - Docker: surveyDocker(ctx), + Runtime: surveyRuntime(ctx), Node: surveyNode(ctx), + Glibc: surveyGlibc(ctx), MemoryMB: memoryMB(), } if u, err := user.Current(); err == nil { @@ -166,14 +197,37 @@ func isSystemd() bool { return err == nil && st.IsDir() } -func surveyDocker(ctx context.Context) Docker { - var d Docker - bin, err := exec.LookPath("docker") - if err != nil { - d.Why = "docker is not installed" +// surveyRuntime looks for docker first and podman second, and reports the +// one that actually answers. Preferring docker where both exist keeps a +// machine that has been set up for docker working the way its operator +// expects; podman is what the Red Hat family ships, and is no less +// 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 } - 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) defer cancel() @@ -182,22 +236,109 @@ func surveyDocker(ctx context.Context) Docker { // answers the question that matters: can *this user* run containers? out, err := exec.CommandContext(ctx, bin, "version", "--format", "{{.Server.Version}}").CombinedOutput() if err != nil { - d.Why = firstLine(string(out)) - if d.Why == "" { - d.Why = "the docker daemon did not answer" + r.Why = firstLine(string(out)) + if r.Why == "" { + r.Why = "the docker daemon did not answer" } - return d + return r } - d.Usable = true - d.Version = strings.TrimSpace(string(out)) + r.Usable = true + r.Version = strings.TrimSpace(string(out)) if err := exec.CommandContext(ctx, bin, "compose", "version").Run(); err == nil { - d.Compose = "v2" + r.Compose = "v2" } else { - d.Usable = false - d.Why = "docker compose (v2) is not available" + r.Usable = false + 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+)`) diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 9884647..a3f0a3e 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -114,27 +114,55 @@ type Availability struct { 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 { switch s { case Skip: return Availability{OK: true} + case Container: - if !f.Docker.Present { - return Availability{Why: "docker is not installed"} + if !f.Runtime.Present { + return Availability{Why: "no container runtime: neither docker nor podman is installed"} } - if !f.Docker.Usable { - return Availability{Why: f.Docker.Why} + if !f.Runtime.Usable { + return Availability{Why: f.Runtime.Why} } return Availability{OK: true} + case Host: if !f.Systemd { return Availability{Why: "a host install needs systemd, which is not running this machine"} } switch c { 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} case Console: + // Static files behind whatever serves them: nothing to run. return Availability{OK: true} case Webmail: if !f.Node.Present {