Survey the machine, and say what an install would do to it

The first two pieces of the installer, and deliberately the two that change
nothing: what this machine is, and what would happen to it.

The survey is the floor under every later choice -- a component can only
offer a container shape if Docker answers for this user, or a host shape on
a systemd machine with what that shape needs. It reports what it could not
establish as unknown rather than guessing: an unprivileged probe of port 25
means "I may not bind this", not "something is listening", and reporting
the first as the second is a lie an operator would act on.

The plan is one list, and it will have three readers: --dry-run prints it,
the interface will show it before anything happens, and apply will walk it.
What you are shown is what runs.

Tested on a fresh Debian 13 in qemu, which has neither Docker nor Node and
so exercises every unavailable shape: 23 checks, including that nothing was
created by any of it. The lab that machine runs in is in e2e/vm.
This commit is contained in:
2026-09-22 17:56:20 -07:00
commit f97fd12556
13 changed files with 2046 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
// Command inbuxa installs the inbuxa suite on the machine it is run on.
//
// Run with no arguments it opens the interface; run with flags it does the
// same work without asking. The flags are not a second implementation: the
// interface fills them in with the machine's own facts in front of you.
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan"
)
// version is stamped by the release build; a build from a working tree says
// so rather than claiming a release it is not.
var version = "dev"
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 version this program's version
install flags:
--domain NAME the mail domain (required unless --local)
--mail-host NAME default: mail.DOMAIN
--console-host NAME default: admin.DOMAIN
--webmail-host NAME default: webmail.DOMAIN
--email ADDRESS ACME contact; default: postmaster@DOMAIN
--server SHAPE skip | container | host (default: container)
--console SHAPE skip | container | host (default: container)
--webmail SHAPE skip | container | host (default: container)
--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
--dry-run print the plan and stop
--yes do not ask for confirmation
`
func main() {
if len(os.Args) < 2 {
// The interface is the no-argument case. Until it lands, say so
// plainly rather than pretending: a half-built screen is worse than
// a sentence telling you which flags to use.
fmt.Fprint(os.Stderr, "the interface is not built yet -- run `inbuxa install --help` for the flags\n")
os.Exit(2)
}
switch os.Args[1] {
case "install":
os.Exit(install(os.Args[2:]))
case "survey":
os.Exit(survey())
case "version":
fmt.Println(version)
case "-h", "--help", "help":
fmt.Print(usage)
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usage)
os.Exit(2)
}
}
func survey() int {
f := host.Survey(context.Background())
fmt.Print(render(f))
return 0
}
func install(args []string) int {
fs := flag.NewFlagSet("install", flag.ContinueOnError)
fs.Usage = func() { fmt.Print(usage) }
var (
o plan.Options
server = fs.String("server", "container", "")
console = fs.String("console", "container", "")
webmail = fs.String("webmail", "container", "")
dryRun = fs.Bool("dry-run", false, "")
yes = fs.Bool("yes", false, "")
)
fs.StringVar(&o.Domain, "domain", "", "")
fs.StringVar(&o.MailHost, "mail-host", "", "")
fs.StringVar(&o.ConsoleHost, "console-host", "", "")
fs.StringVar(&o.WebmailHost, "webmail-host", "", "")
fs.StringVar(&o.ACMEEmail, "email", "", "")
fs.StringVar(&o.Proxy, "proxy", "", "")
fs.StringVar(&o.Dir, "dir", "", "")
fs.BoolVar(&o.Local, "local", false, "")
if err := fs.Parse(args); err != nil {
return 2
}
for _, c := range []struct {
comp plan.Component
val string
}{{plan.Server, *server}, {plan.Console, *console}, {plan.Webmail, *webmail}} {
s, err := shape(c.val)
if err != nil {
fmt.Fprintf(os.Stderr, "--%s: %v\n", c.comp, err)
return 2
}
o.Choices = append(o.Choices, plan.Choice{Component: c.comp, Shape: s})
}
f := host.Survey(context.Background())
p, err := plan.Build(f, o)
if err != nil {
fmt.Fprintln(os.Stderr, "cannot install as asked: "+err.Error())
fmt.Fprint(os.Stderr, "\n"+render(f))
return 1
}
fmt.Print(p.String())
if *dryRun {
return 0
}
if !*yes {
fmt.Fprintln(os.Stderr, "\nnothing has happened yet: applying is not built in this build (pass --dry-run to silence this)")
return 1
}
fmt.Fprintln(os.Stderr, "\napply is not built yet")
return 1
}
func shape(s string) (plan.Shape, error) {
switch strings.ToLower(s) {
case "skip", "no", "none":
return plan.Skip, nil
case "container", "docker":
return plan.Container, nil
case "host", "bare", "baremetal", "bare-metal":
return plan.Host, nil
}
return "", fmt.Errorf("%q is not a shape: skip, container or host", s)
}
// render is the first screen, as text: what this machine is, and what that
// allows. Every line is something a later choice depends on.
func render(f host.Facts) string {
var b strings.Builder
b.WriteString("This machine\n")
os_ := f.OS.Pretty
if os_ == "" {
os_ = "an unrecognized Linux"
}
fmt.Fprintf(&b, " %-22s %s\n", "system", os_)
fmt.Fprintf(&b, " %-22s %s\n", "init", yes(f.Systemd, "systemd", "not systemd -- host installs are unavailable"))
fmt.Fprintf(&b, " %-22s %s\n", "running as", yes(f.Root, "root", "an ordinary user -- installing will fail"))
if f.MemoryMB > 0 {
fmt.Fprintf(&b, " %-22s %d MB\n", "memory", f.MemoryMB)
}
if f.DiskFreeGB > 0 {
fmt.Fprintf(&b, " %-22s %d GB free\n", "disk", f.DiskFreeGB)
}
docker := "not installed"
switch {
case f.Docker.Usable:
docker = "usable, server " + f.Docker.Version + ", compose " + f.Docker.Compose
case f.Docker.Present:
docker = "installed but not usable: " + f.Docker.Why
}
fmt.Fprintf(&b, " %-22s %s\n", "docker", docker)
node := "not installed"
switch {
case f.Node.Present && f.Node.Major >= 22:
node = f.Node.Version
case f.Node.Present:
node = f.Node.Version + " (too old for a host install of the webmail)"
}
fmt.Fprintf(&b, " %-22s %s\n", "node", node)
if f.Existing != "" {
fmt.Fprintf(&b, " %-22s %s\n", "existing install", f.Existing)
}
b.WriteString("\nPorts\n")
for _, p := range f.Ports {
state := "free"
switch {
case p.Free:
case p.Unknown:
state = "cannot tell without root"
default:
state = "held"
if p.Holder != "" {
state += " by " + p.Holder
}
}
fmt.Fprintf(&b, " %-6d %-42s %s\n", p.Number, p.For, state)
}
b.WriteString("\nWhat this machine allows\n")
for _, c := range []plan.Component{plan.Server, plan.Console, plan.Webmail} {
var shapes []string
for _, s := range []plan.Shape{plan.Container, plan.Host} {
if a := plan.Available(f, c, s); a.OK {
shapes = append(shapes, string(s))
} else {
shapes = append(shapes, fmt.Sprintf("%s (no: %s)", s, a.Why))
}
}
fmt.Fprintf(&b, " %-10s %s\n", c, strings.Join(shapes, " "))
}
return b.String()
}
func yes(b bool, t, f string) string {
if b {
return t
}
return f
}