diff --git a/README.md b/README.md index 880e2e4..b5ebff3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/inbuxa/main.go b/cmd/inbuxa/main.go index 1440500..97ff0c7 100644 --- a/cmd/inbuxa/main.go +++ b/cmd/inbuxa/main.go @@ -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)" } diff --git a/e2e/cases/deps.sh b/e2e/cases/deps.sh new file mode 100644 index 0000000..b7a448a --- /dev/null +++ b/e2e/cases/deps.sh @@ -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 ] diff --git a/internal/deps/deps.go b/internal/deps/deps.go new file mode 100644 index 0000000..eb3e1b0 --- /dev/null +++ b/internal/deps/deps.go @@ -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" +} diff --git a/internal/host/host.go b/internal/host/host.go index 590e37c..5c289c0 100644 --- a/internal/host/host.go +++ b/internal/host/host.go @@ -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() diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 5a2f754..c1675ca 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -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 {