Offer to install what is missing, rather than refusing over it

Refusing because Docker is absent is not help. It is a chore handed back to
the operator, who will then install it however the first search result says
to -- which is how machines end up with a third-party apt repository and a
signing key nobody chose.

So the installer offers. It says what it would do, in the same words the
plan uses, and does it only when told: --install-deps during a run, or
"inbuxa deps --install" on its own for someone preparing a machine before
they have a domain to give it.

What it installs, and from where, is the part worth arguing about:

- the Docker daemon: the distribution's own package. One package from an
  archive the machine already trusts beats a new source.
- the Compose plugin: Debian's docker.io ships no compose v2 at all, so this
  is Docker's official static build, pinned by version with its checksum in
  the source beside it.
- Node: the official tarball into /opt/inbuxa/node, deliberately off PATH so
  it runs the webmail's unit and nothing else.

Every download is checked before it is put in place; a checksum that does
not match stops the step rather than warning and carrying on.

Tested from a bare Debian 13 in the lab: 25 checks, ending with docker and
compose answering, node 22 where the unit will look for it, and the
installer agreeing that nothing is missing any more. The last of those
failed the first time -- the survey looked for node on PATH only, and so
could not see the one it had just installed.
This commit is contained in:
2026-09-22 18:06:40 -07:00
parent f97fd12556
commit cc77f62c01
6 changed files with 628 additions and 15 deletions
+6
View File
@@ -22,6 +22,12 @@ This is early. What is built:
- **`install --dry-run`** -- the whole plan: every file, unit, container,
port, DNS record and credential, and a refusal with a reason when the
machine cannot carry out what was asked.
- **`deps`** -- what a shape needs that this machine has not got, and, with
`--install`, the doing of it: the Docker daemon from the distribution's own
archive, the Compose plugin and Node from their official builds, both
pinned by version and checked against a checksum in the source before
anything is put in place. `install --install-deps` does the same as part of
a run. A missing dependency is an offer, not a refusal.
Not built yet: applying the plan, the terminal interface, `join`, `status`,
`upgrade`, `uninstall`. The design is in the inbuxa specification (§6.1 and
+77 -1
View File
@@ -15,6 +15,7 @@ import (
"os"
"strings"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan"
)
@@ -27,6 +28,7 @@ const usage = `inbuxa -- install the inbuxa suite on this machine
inbuxa install [flags] install or converge (no flags: the interface)
inbuxa survey what this machine is, as the installer sees it
inbuxa deps [--install] what is missing for a shape, and fix it
inbuxa version this program's version
install flags:
@@ -41,8 +43,14 @@ install flags:
--proxy WHICH caddy | snippets | none (default: caddy)
--dir PATH where the installation lives; default: /var/lib/inbuxa
--local loopback evaluation: no public ports, no certificates
--install-deps install what the chosen shapes need and this
machine lacks, rather than refusing over it
--dry-run print the plan and stop
--yes do not ask for confirmation
deps flags:
--server/--console/--webmail SHAPE the shapes to work out the needs for
--install do it, rather than only saying what it would do
`
func main() {
@@ -58,6 +66,8 @@ func main() {
os.Exit(install(os.Args[2:]))
case "survey":
os.Exit(survey())
case "deps":
os.Exit(depsCmd(os.Args[2:]))
case "version":
fmt.Println(version)
case "-h", "--help", "help":
@@ -93,6 +103,7 @@ func install(args []string) int {
fs.StringVar(&o.Proxy, "proxy", "", "")
fs.StringVar(&o.Dir, "dir", "", "")
fs.BoolVar(&o.Local, "local", false, "")
fs.BoolVar(&o.InstallDeps, "install-deps", false, "")
if err := fs.Parse(args); err != nil {
return 2
}
@@ -127,6 +138,71 @@ func install(args []string) int {
return 1
}
// depsCmd is the offer on its own: what the chosen shapes need that this
// machine does not have, and -- with --install -- the doing of it. It exists
// separately from install because an operator preparing a machine should be
// able to get it ready without being asked for a domain first.
func depsCmd(args []string) int {
fs := flag.NewFlagSet("deps", flag.ContinueOnError)
fs.Usage = func() { fmt.Print(usage) }
var (
server = fs.String("server", "container", "")
console = fs.String("console", "container", "")
webmail = fs.String("webmail", "container", "")
doIt = fs.Bool("install", false, "")
)
if err := fs.Parse(args); err != nil {
return 2
}
wantContainers, wantHostWebmail := false, false
for _, c := range []struct {
comp plan.Component
val string
}{{plan.Server, *server}, {plan.Console, *console}, {plan.Webmail, *webmail}} {
sh, err := shape(c.val)
if err != nil {
fmt.Fprintf(os.Stderr, "--%s: %v\n", c.comp, err)
return 2
}
if sh == plan.Container {
wantContainers = true
}
if sh == plan.Host && c.comp == plan.Webmail {
wantHostWebmail = true
}
}
ctx := context.Background()
f := host.Survey(ctx)
needs := deps.For(f, wantContainers, wantHostWebmail)
if len(needs) == 0 {
fmt.Println("Nothing is missing for those shapes.")
return 0
}
fmt.Println("Missing, for the shapes asked about:")
fmt.Print(deps.Describe(needs))
if !deps.Fixable(needs) {
return 1
}
if !*doIt {
fmt.Println("\nPass --install to do it.")
return 0
}
if !f.Root {
fmt.Fprintln(os.Stderr, "\ninstalling this needs root")
return 1
}
fmt.Println("\nInstalling:")
if err := deps.Resolve(ctx, os.Stdout, needs); err != nil {
fmt.Fprintln(os.Stderr, "\nstopped: "+err.Error())
return 1
}
after := host.Survey(ctx)
fmt.Println("\nNow:")
fmt.Print(render(after))
return 0
}
func shape(s string) (plan.Shape, error) {
switch strings.ToLower(s) {
case "skip", "no", "none":
@@ -170,7 +246,7 @@ func render(f host.Facts) string {
node := "not installed"
switch {
case f.Node.Present && f.Node.Major >= 22:
node = f.Node.Version
node = f.Node.Version + " at " + f.Node.Path
case f.Node.Present:
node = f.Node.Version + " (too old for a host install of the webmail)"
}
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
# SPDX-FileCopyrightText: 2026 Coffey Labs
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# The offer: when a shape needs something this machine has not got, does the
# installer say what it would do, and then actually do it?
#
# Starts on a machine with neither Docker nor Node, which is the case worth
# proving: refusing there is easy and useless. By the end, containers work
# and a host install of the webmail has a Node to run on -- all of it fetched
# against pinned checksums.
#
# Run from the host with: e2e/vm/run.sh e2e/cases/deps.sh
set -uo pipefail
pass=0; fail=0
ok() { echo " ok $*"; pass=$((pass+1)); }
bad() { echo " FAIL $*"; fail=$((fail+1)); }
has() { grep -q -- "$2" <<<"$1" && ok "$3" || { bad "$3"; echo "$1" | sed 's/^/ /'; }; }
echo "==> a machine with nothing on it is told what is missing"
OUT="$(/tmp/inbuxa deps --console skip --webmail skip 2>&1)"; rc=$?
echo "$OUT" | sed 's/^/ /'
[ $rc -eq 0 ] && ok "saying so is not an error (exit 0)" || bad "exit $rc"
has "$OUT" "docker is missing" "names docker"
has "$OUT" "compose is missing" "names the compose plugin"
has "$OUT" "from the distribution's own archive" "says where docker comes from"
has "$OUT" "pinned checksum" "says the download is checked"
has "$OUT" "Pass --install to do it" "offers to do it"
command -v docker >/dev/null && bad "docker appeared without being asked for" || ok "nothing installed yet"
echo
echo "==> the refusal to install carries the same offer"
OUT="$(/tmp/inbuxa install --domain example.test --console skip --webmail skip 2>&1)"; rc=$?
[ $rc -ne 0 ] && ok "still refuses to install (exit $rc)" || bad "should have refused"
has "$OUT" "The installer can fix that" "but offers the fix"
has "$OUT" "Pass --install-deps" "and says how to accept"
echo
echo "==> the plan, once the offer is accepted, has a step for it"
OUT="$(/tmp/inbuxa install --domain example.test --console skip --webmail skip --install-deps --dry-run 2>&1)"; rc=$?
[ $rc -eq 0 ] && ok "accepted (exit 0)" || { bad "exit $rc"; echo "$OUT" | sed 's/^/ /'; }
has "$OUT" "Install what this machine is missing" "the first step is the fix"
has "$OUT" "docker: install docker.io" "which names the package"
has "$OUT" "Start the mail server as a container" "and the install goes on as a container install"
echo
echo "==> doing it"
OUT="$(/tmp/inbuxa deps --console skip --webmail skip --install 2>&1)"; rc=$?
echo "$OUT" | tail -20 | sed 's/^/ /'
[ $rc -eq 0 ] && ok "installed (exit 0)" || bad "exit $rc"
has "$OUT" "checksum ok" "checked what it downloaded"
has "$OUT" "docker usable" "and says the machine can do containers now"
docker version >/dev/null 2>&1 && ok "docker answers" || bad "docker does not answer"
docker compose version >/dev/null 2>&1 && ok "compose v2 answers" || bad "compose does not answer"
systemctl is-enabled docker >/dev/null 2>&1 && ok "docker is enabled at boot" || bad "docker is not enabled"
echo
echo "==> node, for a host install of the webmail"
OUT="$(/tmp/inbuxa deps --server skip --console skip --webmail host --install 2>&1)"; rc=$?
echo "$OUT" | head -12 | sed 's/^/ /'
[ $rc -eq 0 ] && ok "installed (exit 0)" || bad "exit $rc"
has "$OUT" "checksum ok" "checked the tarball"
V="$(/opt/inbuxa/node/bin/node --version 2>/dev/null)"
[[ "$V" == v22.* ]] && ok "node $V is in /opt/inbuxa/node" || bad "no usable node in /opt/inbuxa/node (got '$V')"
command -v node >/dev/null && bad "it put node on PATH; it should stay out of the way" || ok "it stayed out of PATH"
echo
echo "==> and now nothing is missing"
OUT="$(/tmp/inbuxa deps --server container --console container --webmail host 2>&1)"
has "$OUT" "Nothing is missing" "says so"
echo
echo "==> $pass passed, $fail failed"
[ "$fail" -eq 0 ]
+376
View File
@@ -0,0 +1,376 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package deps is what the installer does about something the machine needs
// and does not have.
//
// Refusing because Docker is missing is not help; it is a chore handed back
// to the operator, who will then install it in whatever way the first search
// result suggests. The installer knows what it needs, knows this machine, and
// can do it -- so it offers, says exactly what it would run, and does it only
// when told.
//
// What it will install, and from where:
//
// - the Docker daemon: the distribution's own package, never a third-party
// apt repository. One package from the archive the machine already
// trusts beats a new signing key and a new source.
// - the Compose plugin: Debian's docker.io has no compose v2 at all, so
// this is the official static binary from Docker's release, pinned by
// version and verified against a checksum in this source file.
// - Node: the distribution's, when it is new enough; otherwise the
// official tarball into /opt, pinned and checksummed the same way. No
// NodeSource repository either, for the same reason.
//
// Every fetch is verified before anything is put in place. A checksum that
// does not match stops the step; it does not warn and continue.
package deps
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
)
// Pinned versions. Bumping one means bumping its checksum in the same
// commit: the pair is the point.
const (
composeVersion = "v5.5.1"
nodeVersion = "v22.23.2"
)
var composeSHA = map[string]string{
"amd64": "db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576",
"arm64": "732e3a84c1a0f67256ce80bc2598a24546b10ca05f9faa97efceb1171ece2ef7",
}
var nodeSHA = map[string]string{
"amd64": "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307",
"arm64": "fff4078c5def658577f92c88db7db3bc0072924bfb93fe52c1e744a54e94abb8",
}
// Need is one missing thing, and what the installer would do about it.
type Need struct {
Name string // "docker", "compose", "node"
Because string // the shape that wants it
Fixable bool // the installer can do this here
Why string // when it cannot, the reason
Actions []string // what it would do, in the words the plan shows
steps []step // what it would actually run
}
type step struct {
title string
run func(ctx context.Context, log io.Writer) error
}
// For works out what is missing for a shape, and what could be done about it
// on this machine. An empty result means nothing is in the way.
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",
})
}
}
if wantHostWebmail && (!f.Node.Present || f.Node.Major < 22) {
needs = append(needs, nodeNeed(f))
}
var out []Need
for _, n := range needs {
if n.Name != "" {
out = append(out, n)
}
}
return out
}
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)
return n
}
n.Fixable = true
n.Actions = []string{
fmt.Sprintf("install %s from the distribution's own archive", pkg),
"enable and start the docker service",
}
n.steps = []step{
{"installing " + pkg, install},
{"starting docker", func(ctx context.Context, log io.Writer) error {
return run(ctx, log, "systemctl", "enable", "--now", "docker")
}},
}
return n
}
func composeNeed(f host.Facts) Need {
arch := goarch()
sum, ok := composeSHA[arch]
n := Need{Name: "compose", Because: "a container install"}
if !ok {
n.Why = "no pinned Compose build for " + arch
return n
}
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,
}
n.steps = []step{
{"fetching compose " + composeVersion, func(ctx context.Context, log io.Writer) error {
return fetchVerified(ctx, log, url, sum, dest, 0o755)
}},
}
return n
}
func nodeNeed(f host.Facts) Need {
n := Need{Name: "node", Because: "a host install of the webmail"}
arch := goarch()
sum, ok := nodeSHA[arch]
if !ok {
n.Why = "no pinned Node build for " + arch
return n
}
url := fmt.Sprintf("https://nodejs.org/dist/%s/node-%s-linux-%s.tar.xz", nodeVersion, nodeVersion, archName2(arch))
n.Fixable = true
n.Actions = []string{
fmt.Sprintf("fetch Node %s (%s) and check it against its pinned checksum", nodeVersion, arch),
"unpack it into /opt/inbuxa/node, used by the webmail's unit and nothing else",
}
n.steps = []step{
{"fetching node " + nodeVersion, func(ctx context.Context, log io.Writer) error {
tmp := filepath.Join(os.TempDir(), "inbuxa-node.tar.xz")
if err := fetchVerified(ctx, log, url, sum, tmp, 0o644); err != nil {
return err
}
if err := os.MkdirAll("/opt/inbuxa/node", 0o755); err != nil {
return err
}
return run(ctx, log, "tar", "-xJf", tmp, "-C", "/opt/inbuxa/node", "--strip-components=1")
}},
}
return n
}
func dockerPackage(family string) string {
switch family {
case "debian":
return "docker.io"
case "arch":
return "docker"
case "rhel":
return "docker"
case "suse":
return "docker"
}
return ""
}
// packageInstall returns the package name and the command that installs it,
// or nil when this installer has nothing to say about the machine's
// packaging.
func packageInstall(family, pkg string) (string, func(context.Context, io.Writer) error) {
if pkg == "" {
return "", nil
}
switch family {
case "debian":
return pkg, func(ctx context.Context, log io.Writer) error {
if err := run(ctx, log, "apt-get", "update", "-qq"); err != nil {
return err
}
cmd := exec.CommandContext(ctx, "apt-get", "install", "-y", "-qq", pkg)
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
return pipe(cmd, log)
}
case "arch":
return pkg, func(ctx context.Context, log io.Writer) error {
return run(ctx, log, "pacman", "-S", "--noconfirm", "--needed", pkg)
}
case "rhel":
return pkg, func(ctx context.Context, log io.Writer) error {
return run(ctx, log, "dnf", "install", "-y", pkg)
}
case "suse":
return pkg, func(ctx context.Context, log io.Writer) error {
return run(ctx, log, "zypper", "--non-interactive", "install", pkg)
}
}
return "", nil
}
// Resolve does what the needs say, in order, writing what it is doing to log.
// It stops at the first failure: a half-resolved machine is worse than one
// that is clearly still missing something.
func Resolve(ctx context.Context, log io.Writer, needs []Need) error {
for _, n := range needs {
if !n.Fixable {
return fmt.Errorf("%s: %s", n.Name, n.Why)
}
for _, s := range n.steps {
fmt.Fprintf(log, " %s\n", s.title)
if err := s.run(ctx, log); err != nil {
return fmt.Errorf("%s: %w", n.Name, err)
}
}
}
return nil
}
// Describe is the offer, in the words the interface and the plan use.
func Describe(needs []Need) string {
var b strings.Builder
for _, n := range needs {
if n.Fixable {
fmt.Fprintf(&b, " %s is missing, for %s. The installer can:\n", n.Name, n.Because)
for _, a := range n.Actions {
fmt.Fprintf(&b, " %s\n", a)
}
} else {
fmt.Fprintf(&b, " %s is missing, for %s, and cannot be installed here: %s\n", n.Name, n.Because, n.Why)
}
}
return b.String()
}
// Fixable is true when everything in the way can be dealt with.
func Fixable(needs []Need) bool {
for _, n := range needs {
if !n.Fixable {
return false
}
}
return len(needs) > 0
}
func fetchVerified(ctx context.Context, log io.Writer, url, want, dest string, mode os.FileMode) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", url, resp.Status)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
tmp := dest + ".part"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
got := hex.EncodeToString(h.Sum(nil))
if got != want {
os.Remove(tmp)
return fmt.Errorf("checksum of %s is %s, expected %s -- nothing was installed", url, got, want)
}
fmt.Fprintf(log, " checksum ok (%s…)\n", got[:16])
return os.Rename(tmp, dest)
}
func run(ctx context.Context, log io.Writer, name string, args ...string) error {
return pipe(exec.CommandContext(ctx, name, args...), log)
}
func pipe(cmd *exec.Cmd, log io.Writer) error {
out := &prefixer{w: log}
cmd.Stdout, cmd.Stderr = out, out
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s: %w", strings.Join(cmd.Args, " "), err)
}
return nil
}
// prefixer indents a command's own output so it is plainly the command
// talking and not the installer.
type prefixer struct {
w io.Writer
buf []byte
}
func (p *prefixer) Write(b []byte) (int, error) {
p.buf = append(p.buf, b...)
for {
i := strings.IndexByte(string(p.buf), '\n')
if i < 0 {
break
}
line := strings.TrimRight(string(p.buf[:i]), "\r")
p.buf = p.buf[i+1:]
if strings.TrimSpace(line) != "" {
fmt.Fprintf(p.w, " | %s\n", line)
}
}
return len(b), nil
}
func goarch() string { return runtime.GOARCH }
// Docker and Node name the same architectures differently, which is a small
// thing that breaks a download silently if it is guessed.
func archName(a string) string {
if a == "arm64" {
return "aarch64"
}
return "x86_64"
}
func archName2(a string) string {
if a == "arm64" {
return "arm64"
}
return "x64"
}
func describeOS(o host.OSInfo) string {
if o.Pretty != "" {
return o.Pretty
}
if o.ID != "" {
return o.ID
}
return "this distribution"
}
+18 -5
View File
@@ -64,9 +64,14 @@ type Node struct {
Present bool
Version string // "22.14.0"
Major int
Path string // where it is; OwnPath when the installer put it there
Why string
}
// OwnPath is where the installer puts a Node of its own, deliberately outside
// PATH so it runs the webmail's unit and nothing else on the machine.
const OwnPath = "/opt/inbuxa/node/bin/node"
// Port is one of the ports the suite would like, and what holds it now.
type Port struct {
Number int
@@ -80,7 +85,7 @@ type Port struct {
// reports all of them regardless of the shape chosen, because the interface
// needs to gray out a choice before the operator makes it.
var wanted = []struct {
n int
n int
for_ string
}{
{25, "SMTP, mail from other servers"},
@@ -199,12 +204,20 @@ var nodeVersion = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)`)
func surveyNode(ctx context.Context) Node {
var n Node
bin, err := exec.LookPath("node")
if err != nil {
n.Why = "node is not installed"
return n
// The installer's own Node first: it is deliberately not on PATH, so
// looking only there would mean never seeing what we installed ourselves
// and offering to install it again.
bin := OwnPath
if _, err := os.Stat(bin); err != nil {
var err error
bin, err = exec.LookPath("node")
if err != nil {
n.Why = "node is not installed"
return n
}
}
n.Present = true
n.Path = bin
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, bin, "--version").Output()
+75 -9
View File
@@ -13,6 +13,7 @@ import (
"fmt"
"strings"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
)
@@ -59,6 +60,7 @@ type Options struct {
Dir string
Proxy string // "caddy", "snippets", "none"
Choices []Choice
InstallDeps bool // resolve what is missing rather than refusing over it
}
// Shape returns what was chosen for a component.
@@ -151,9 +153,10 @@ type Step struct {
type Plan struct {
Options Options
Steps []Step
Ports []int // what will be bound, once, across every component
DNS []string // records the domain needs for this shape
Warnings []string // things that are not refusals but should be read
Ports []int // what will be bound, once, across every component
DNS []string // records the domain needs for this shape
Warnings []string // things that are not refusals but should be read
Needs []deps.Need // what is missing, and what would be done about it
}
// Build works out what would happen. It does not touch the machine: every
@@ -165,19 +168,57 @@ func Build(f host.Facts, o Options) (Plan, error) {
if o.Domain == "" && !o.Local {
return p, fmt.Errorf("a domain is needed (or --local for a loopback evaluation)")
}
chosen := 0
chosen, wantContainers, wantHostWebmail := 0, false, false
for _, c := range []Component{Server, Console, Webmail} {
switch o.Shape(c) {
case Skip:
case Container:
chosen++
wantContainers = true
case Host:
chosen++
if c == Webmail {
wantHostWebmail = true
}
}
}
if chosen == 0 {
return p, fmt.Errorf("nothing chosen: pick at least one component")
}
// What is missing is not the same as what is impossible. Docker absent on
// a Debian machine is one package and a service; Node too old is a pinned
// tarball. The installer says what it would do about each and does it when
// told, rather than handing the operator a chore and calling it an error.
p.Needs = deps.For(f, wantContainers, wantHostWebmail)
for _, c := range []Component{Server, Console, Webmail} {
s := o.Shape(c)
if s == Skip {
continue
}
chosen++
if a := Available(f, c, s); !a.OK {
return p, fmt.Errorf("%s as a %s install: %s", names[c], s, a.Why)
a := Available(f, c, s)
if a.OK {
continue
}
if covered(p.Needs, c, s) && o.InstallDeps {
continue
}
if covered(p.Needs, c, s) {
return p, fmt.Errorf("%s as a %s install: %s\n\nThe installer can fix that:\n%s\nPass --install-deps to let it, or choose another shape",
names[c], s, a.Why, deps.Describe(p.Needs))
}
return p, fmt.Errorf("%s as a %s install: %s", names[c], s, a.Why)
}
if chosen == 0 {
return p, fmt.Errorf("nothing chosen: pick at least one component")
if len(p.Needs) > 0 && o.InstallDeps {
var detail []string
for _, n := range p.Needs {
for _, a := range n.Actions {
detail = append(detail, n.Name+": "+a)
}
}
p.Steps = append(p.Steps, Step{Title: "Install what this machine is missing", Detail: detail})
}
if !f.Root {
@@ -318,6 +359,31 @@ func Build(f host.Facts, o Options) (Plan, error) {
return p, nil
}
// covered says whether a cell's unavailability is one of the things the
// installer offered to fix.
func covered(needs []deps.Need, c Component, s Shape) bool {
want := map[string]bool{}
switch {
case s == Container:
want["docker"], want["compose"] = true, true
case s == Host && c == Webmail:
want["node"] = true
default:
return false
}
found := false
for _, n := range needs {
if !want[n.Name] {
continue
}
if !n.Fixable {
return false
}
found = true
}
return found
}
func (o Options) hostnames() []string {
var names []string
if o.Shape(Server) != Skip {