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 {