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
+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 {