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
+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)"
}