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
+15 -2
View File
@@ -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 {
+81 -18
View File
@@ -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 ""
}
+78 -12
View File
@@ -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()
+170 -29
View File
@@ -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+)`)
+33 -5
View File
@@ -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 {