Describe an installation in a file, and converge to it

One file describes the whole installation: which machine runs what, under
which names. Each machine acts on its own part of it and prints the command
to run on the others, which nobody but their operator runs. Emit, never
execute -- no agent, no console-held credential, no machine reaching
another.

  inbuxa plan  -f topology.json     what would change here; changes nothing
  inbuxa apply -f topology.json     make this machine match it
  inbuxa export                     the file, from what is already here

plan diffs the file against what is installed rather than against what
happens to be running: a container stopped by hand is still installed, and
offering to install it again would be a lie about what is about to happen.
The state that makes that possible -- intent, which the machine itself
cannot tell you -- is /etc/inbuxa/install.json.

Front ends across machines, not a clustered mail server. Two machines each
running one is refused, and the refusal says why: a second node needs a
shared store and cluster configuration, which this does not set up. Two of
the same component on one machine is refused too -- two webmails need two
ports and two names, and the file says neither.

The shrink is the half worth proving, and it found the bug that mattered:
apply ran first boot every time, so the second one asked a configured server
for bootstrap credentials it had stopped accepting, and adding or removing a
front end could not work at all. An installed machine now converges instead:
the deployment is rewritten from the shapes asked for, --remove-orphans
takes away what the file no longer lists, data volumes are left alone, and
the secrets generated the first time are kept rather than rolled.

Two more found the same way:

- Certificates were turned on even where nothing holds port 80. On a machine
  with no proxy the order can only fail, and it stopped the install over it.
  It now says whose job they are instead.
- A converge that restarts the server reported "Done" while it was still
  coming back. It waits.

Twenty-eight checks, on Debian 13 and Fedora 43: install from a file, plan
the same file and be told there is nothing to do, remove the webmail and
watch it go while the mail stays, put it back.
This commit is contained in:
2026-09-24 09:51:16 -07:00
parent b6bc126651
commit a90df656e7
9 changed files with 893 additions and 6 deletions
+8
View File
@@ -43,6 +43,13 @@ This is early. What is built:
creates the first mailbox, writes `credentials.txt` and `dns.zone`, and
then checks that all three answer.
- **`plan -f` / `apply -f` / `export`** -- a whole installation described in
one file, however many machines. Each machine acts on its own part and
prints the command to run on the others; it never reaches them. `plan`
diffs the file against what is actually installed here and changes
nothing; `apply` converges to it, adding and removing components;
`export` writes the file from what is already here.
Not built yet: host installs, the terminal interface, `join`, `status`,
`upgrade`, `uninstall`. The design is in the inbuxa specification (§6.1 and
the installer draft); the phases are there too.
@@ -68,6 +75,7 @@ is tested on a throwaway virtual machine rather than on anybody's desk:
e2e/vm/run.sh e2e/cases/deps.sh the offer, and taking it
e2e/vm/run.sh e2e/cases/install-local.sh a whole suite, and signing in to it
e2e/vm/run.sh e2e/cases/install-public.sh the same with real ports and certificates
e2e/vm/run.sh e2e/cases/topology.sh growing and shrinking from a file
e2e/vm/down.sh remove it
Each case starts from a copy of the machine taken when it was new, so a run
+173
View File
@@ -10,6 +10,7 @@ package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
@@ -22,6 +23,8 @@ import (
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/state"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/topology"
)
// version is stamped by the release build; a build from a working tree says
@@ -33,6 +36,9 @@ 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 plan -f FILE what a topology file would change here
inbuxa apply -f FILE make this machine match that file
inbuxa export [-o FILE] write a topology file from what is here
inbuxa version this program's version
install flags:
@@ -89,6 +95,12 @@ func main() {
os.Exit(survey())
case "deps":
os.Exit(depsCmd(os.Args[2:]))
case "plan":
os.Exit(topologyCmd(os.Args[2:], false))
case "apply":
os.Exit(topologyCmd(os.Args[2:], true))
case "export":
os.Exit(exportCmd(os.Args[2:]))
case "version":
fmt.Println(version)
case "-h", "--help", "help":
@@ -269,6 +281,167 @@ func depsCmd(args []string) int {
return 0
}
// topologyCmd is plan and apply: the same reading of the same file, one of
// which stops after printing.
//
// A machine acts on its own part of the file and prints what the others have
// to run. It never reaches them -- the file is copied across by whoever owns
// those machines, and run there. That is the whole security posture of the
// designer this is the executor for: emit, never execute.
func topologyCmd(args []string, doIt bool) int {
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
fs.Usage = func() { fmt.Print(usage) }
var (
file = fs.String("f", "", "")
machineName = fs.String("machine", "", "")
yes = fs.Bool("yes", false, "")
installDeps = fs.Bool("install-deps", false, "")
)
if err := fs.Parse(args); err != nil {
return 2
}
if *file == "" {
fmt.Fprintln(os.Stderr, "which file? pass -f topology.json")
return 2
}
t, err := topology.Load(*file)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
return 1
}
name := *machineName
if name == "" {
h, _ := os.Hostname()
name = h
}
m, ok := t.Machine(name)
if !ok {
fmt.Fprintf(os.Stderr, "this machine is %q, which the file does not mention.\n", name)
fmt.Fprintf(os.Stderr, "It describes: %s\n", strings.Join(machineNames(t), ", "))
fmt.Fprintln(os.Stderr, "Pass --machine to say which one this is.")
return 1
}
st, err := state.Load()
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
return 1
}
d := topology.Compare(st, m)
fmt.Print(d.String())
fmt.Print(topology.Elsewhere(t, *file, name))
if !doIt {
return 0
}
if d.Empty() {
return 0
}
if !*yes {
fmt.Fprintln(os.Stderr, "\nNothing has happened yet. Pass --yes to carry this out.")
return 1
}
// Removals are the half that can lose something. Naming them again here,
// after the diff and before the work, is the last chance to read them.
if rm := d.Removals(); len(rm) > 0 {
fmt.Println()
for _, c := range rm {
fmt.Printf(" removing %s from this machine; its data volumes are left in place\n", c.Component)
}
}
o := plan.Options{
Domain: t.Domain, MailHost: t.MailHost, ConsoleHost: t.ConsoleHost,
WebmailHost: t.WebmailHost, ACMEEmail: t.ACMEEmail,
Dir: m.Dir, Proxy: m.Proxy, InstallDeps: *installDeps,
Machine: name, TopologyPath: *file,
}
for _, kind := range []plan.Component{plan.Server, plan.Console, plan.Webmail} {
sh := plan.Skip
if s := m.Shape(string(kind)); s != "" {
sh = plan.Shape(s)
}
o.Choices = append(o.Choices, plan.Choice{Component: kind, Shape: sh})
}
f := host.Survey(context.Background())
p, err := plan.Build(f, o)
if err != nil {
fmt.Fprintln(os.Stderr, "\ncannot apply this file here: "+err.Error())
return 1
}
fmt.Println("\nApplying:")
log := &printer{}
res, err := apply.Run(context.Background(), p, f, log)
if err != nil {
fmt.Fprintln(os.Stderr, "\nstopped: "+err.Error())
return 1
}
fmt.Printf("\nDone. %s\n", res.Dir)
if res.AdminUser != "" {
fmt.Printf(" administrator %s\n", res.AdminUser)
}
return 0
}
// exportCmd writes what is on this machine as a topology file, so a design
// starts from a system that exists rather than a blank page.
func exportCmd(args []string) int {
fs := flag.NewFlagSet("export", flag.ContinueOnError)
fs.Usage = func() { fmt.Print(usage) }
out := fs.String("o", "", "")
if err := fs.Parse(args); err != nil {
return 2
}
st, err := state.Load()
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
return 1
}
if !st.Installed() {
fmt.Fprintln(os.Stderr, "nothing is installed here, so there is nothing to describe")
return 1
}
name := st.Machine
if name == "" {
name, _ = os.Hostname()
}
m := topology.Machine{Name: name, Dir: st.Dir}
for _, kind := range []string{"server", "console", "webmail"} {
if sh, ok := st.Shapes[kind]; ok {
m.Components = append(m.Components, topology.Component{Kind: kind, Shape: sh})
}
}
t := &topology.Topology{Version: topology.Version, Domain: st.Domain, Machines: []topology.Machine{m}}
t.Defaults()
if err := t.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "what is installed here does not describe a whole installation: "+err.Error())
fmt.Fprintln(os.Stderr, "(a machine running only front ends is one part of a file, not all of it)")
return 1
}
if *out == "" {
b, _ := json.MarshalIndent(t, "", " ")
fmt.Println(string(b))
return 0
}
if err := t.Save(*out); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
return 1
}
fmt.Printf("wrote %s\n", *out)
return 0
}
func machineNames(t *topology.Topology) []string {
var out []string
for _, m := range t.Machines {
out = append(out, m.Name)
}
return out
}
func shape(s string) (plan.Shape, error) {
switch strings.ToLower(s) {
case "skip", "no", "none":
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# SPDX-FileCopyrightText: 2026 Coffey Labs
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# The topology file, and the half of it that can lose something: shrinking.
#
# Growing is easy to believe. What has to be proved is that taking a
# component out of the file takes it off the machine, leaves everything else
# working, and does not quietly delete anyone's mail on the way. And that a
# plan against a file this machine already matches says so rather than
# inventing work.
#
# Run from the host with: e2e/vm/run.sh e2e/cases/topology.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/^/ /'; }; }
DIR=/var/lib/inbuxa
F=/tmp/topology.json
rt() { command -v docker >/dev/null 2>&1 && echo docker || echo podman; }
compose() {
if [ "$(rt)" = docker ]; then
docker compose -f "$DIR/compose.yaml" "$@"
else
DOCKER_HOST=unix:///run/podman/podman.sock \
/usr/local/lib/docker/cli-plugins/docker-compose -f "$DIR/compose.yaml" "$@"
fi
}
cat > "$F" <<'JSON'
{
"version": 1,
"domain": "example.test",
"machines": [
{
"name": "one",
"components": [
{ "kind": "server", "shape": "container" },
{ "kind": "console", "shape": "container" },
{ "kind": "webmail", "shape": "container" }
],
"proxy": "none"
}
]
}
JSON
echo "==> a file this machine has never seen"
OUT="$(/tmp/inbuxa plan -f "$F" --machine one 2>&1)"; rc=$?
echo "$OUT" | sed 's/^/ /'
[ $rc -eq 0 ] && ok "planning changes nothing (exit 0)" || bad "exit $rc"
has "$OUT" "add server" "says it would add the server"
has "$OUT" "add webmail" "and the webmail"
[ ! -e /etc/inbuxa/install.json ] && ok "and nothing was recorded" || bad "state appeared from a plan"
echo
echo "==> applying it"
OUT="$(/tmp/inbuxa apply -f "$F" --machine one --install-deps --yes 2>&1)"; rc=$?
echo "$OUT" | grep -v '^ |' | tail -6 | sed 's/^/ /'
[ $rc -eq 0 ] && ok "applied (exit 0)" || bad "exit $rc"
for s in server console webmail; do
compose ps --format '{{.Service}} {{.State}}' | grep -q "^$s running" && ok "$s is running" || bad "$s is not running"
done
grep -q '"webmail": "container"' /etc/inbuxa/install.json && ok "the state file records what it installed" || { bad "state does not record the webmail"; cat /etc/inbuxa/install.json | sed 's/^/ /'; }
grep -q '"machine": "one"' /etc/inbuxa/install.json && ok "and which machine this is" || bad "state does not name the machine"
echo
echo "==> planning the same file again"
OUT="$(/tmp/inbuxa plan -f "$F" --machine one 2>&1)"
has "$OUT" "nothing to do" "says there is nothing to do"
echo
echo "==> now take the webmail out of the file"
python3 - "$F" <<'PY'
import json, sys
t = json.load(open(sys.argv[1]))
m = t["machines"][0]
m["components"] = [c for c in m["components"] if c["kind"] != "webmail"]
json.dump(t, open(sys.argv[1], "w"), indent=2)
PY
OUT="$(/tmp/inbuxa plan -f "$F" --machine one 2>&1)"
echo "$OUT" | sed 's/^/ /'
has "$OUT" "remove webmail" "the plan says it would remove the webmail"
has "$OUT" "keep server" "and keep the server"
compose ps --format '{{.Service}}' | grep -q webmail && ok "which has not happened yet" || bad "the webmail went away during a plan"
echo
echo "==> shrinking"
OUT="$(/tmp/inbuxa apply -f "$F" --machine one --yes 2>&1)"; rc=$?
echo "$OUT" | grep -v '^ |' | tail -8 | sed 's/^/ /'
[ $rc -eq 0 ] && ok "applied (exit 0)" || bad "exit $rc"
compose ps --format '{{.Service}}' | grep -q webmail && bad "the webmail is still running" || ok "the webmail is gone"
curl -fsS -o /dev/null http://127.0.0.1:8080/api/health 2>/dev/null && bad "something still answers on the webmail's port" || ok "and nothing answers on its port"
grep -q '"webmail"' /etc/inbuxa/install.json && bad "state still claims the webmail" || ok "the state file agrees"
echo
echo "==> and what is left still works"
for s in server console; do
compose ps --format '{{.Service}} {{.State}}' | grep -q "^$s running" && ok "$s is still running" || bad "$s is not running"
done
curl -fsS http://127.0.0.1:8082/ 2>/dev/null | grep -q 'api-base-url' && ok "the console still answers" || bad "the console does not answer"
ADMIN=$(awk '/^administrator/ {print $2}' "$DIR/credentials.txt")
ADMINPW=$(awk '/^administrator/{getline; print $2}' "$DIR/credentials.txt")
answered=
for _ in $(seq 1 30); do
curl -fsS -u "$ADMIN:$ADMINPW" http://127.0.0.1:8081/.well-known/jmap >/dev/null 2>&1 && { answered=1; break; }
sleep 2
done
[ -n "$answered" ] && ok "the mail server still answers for its administrator" || bad "the mail server does not answer"
$(rt) volume ls --format '{{.Name}}' | grep -q inbuxa-data && ok "the mail is still there: the data volume was not removed" || bad "the data volume is gone"
echo
echo "==> growing it back"
python3 - "$F" <<'PY'
import json, sys
t = json.load(open(sys.argv[1]))
t["machines"][0]["components"].append({"kind": "webmail", "shape": "container"})
json.dump(t, open(sys.argv[1], "w"), indent=2)
PY
OUT="$(/tmp/inbuxa apply -f "$F" --machine one --yes 2>&1)"; rc=$?
[ $rc -eq 0 ] && ok "applied (exit 0)" || { bad "exit $rc"; echo "$OUT" | tail -6 | sed 's/^/ /'; }
compose ps --format '{{.Service}} {{.State}}' | grep -q "^webmail running" && ok "the webmail is back" || bad "the webmail did not come back"
curl -fsS http://127.0.0.1:8080/api/health 2>/dev/null | grep -q '"ok":true' && ok "and it is healthy" || bad "it is not healthy"
echo
echo "==> a file that describes an installation this cannot build"
cat > /tmp/bad.json <<'JSON'
{
"version": 1,
"domain": "example.test",
"machines": [
{ "name": "a", "components": [ { "kind": "server", "shape": "container" } ] },
{ "name": "b", "components": [ { "kind": "server", "shape": "container" } ] }
]
}
JSON
OUT="$(/tmp/inbuxa plan -f /tmp/bad.json --machine a 2>&1)"; rc=$?
[ $rc -ne 0 ] && ok "two mail servers is refused (exit $rc)" || bad "two mail servers was accepted"
has "$OUT" "shared store" "and the refusal says why"
echo
echo "==> $pass passed, $fail failed"
[ "$fail" -eq 0 ]
+100 -2
View File
@@ -33,6 +33,7 @@ import (
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/jmap"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/state"
)
// Images are the defaults. They are tags rather than digests for now: the
@@ -185,6 +186,16 @@ func Run(ctx context.Context, p plan.Plan, f host.Facts, log Log) (*Result, erro
return nil, err
}
// A machine that already runs this installation is converged, not set up
// again. First boot happens once: bootstrap, the first administrator, the
// first account, the ACME account. Running it a second time asks a
// configured server for bootstrap credentials it stopped accepting the
// moment it was configured -- which is exactly what adding or removing a
// front end used to do, and why it could not.
if existing, err := state.Load(); err == nil && existing.Installed() {
return converge(ctx, o, stack, cmp, existing, f, log)
}
// The bootstrap credential lives in an override file for this step only,
// and its password only in this process. Bringing the stack up afterwards
// without the override recreates the server without the variable, so no
@@ -237,7 +248,11 @@ func Run(ctx context.Context, p plan.Plan, f host.Facts, log Log) (*Result, erro
log.Info("administrator %s, password in %s", admin.Username, filepath.Join(dir, "credentials.txt"))
log.Step("starting the rest of the stack")
if err := cmp.Run(ctx, "up", "-d"); err != nil {
// --remove-orphans: the compose file is rendered from the shapes asked
// for, so a component the topology no longer lists is simply not in it
// any more. Without this its container would keep running, belonging to
// a project that no longer describes it.
if err := cmp.Run(ctx, "up", "-d", "--remove-orphans"); err != nil {
return res, err
}
@@ -286,7 +301,15 @@ func Run(ctx context.Context, p plan.Plan, f host.Facts, log Log) (*Result, erro
}
}
if !o.Local {
// Certificates need something holding port 80 for the HTTP-01 challenge,
// which is the proxy. Asked for on a machine with no proxy, the order
// can only fail -- so this says whose job it is instead of leaving a
// failed ACME account behind and stopping the install over it.
if !o.Local && !stack.Proxy {
log.Info("no proxy here, so certificates are yours to arrange: the server's own names are %s",
strings.Join(stack.ServerNames(), ", "))
}
if !o.Local && stack.Proxy {
log.Step("turning on certificates")
if _, err := srv.EnableACME(ctx, domainID, o.ACMEDirectory, o.ACMEEmail); err != nil {
return res, fmt.Errorf("enabling ACME: %w", err)
@@ -319,6 +342,23 @@ func Run(ctx context.Context, p plan.Plan, f host.Facts, log Log) (*Result, erro
return res, err
}
// What this machine now runs, written down so the next run diffs against
// intent rather than guessing from what happens to be up.
st, err := state.Load()
if err == nil {
st.Machine, st.Dir, st.Domain = o.Machine, dir, o.Domain
st.Runtime, st.Topology = f.Runtime.Kind, o.TopologyPath
st.Shapes = map[string]string{}
for _, c := range []plan.Component{plan.Server, plan.Console, plan.Webmail} {
if sh := o.Shape(c); sh != plan.Skip {
st.Shapes[string(c)] = string(sh)
}
}
if err := st.Save(); err != nil {
log.Info("could not write %s: %v", state.Path, err)
}
}
zone, err := srv.DNSZone(ctx, domainID)
if err == nil && zone != "" {
res.ZoneFile = filepath.Join(dir, "dns.zone")
@@ -360,6 +400,64 @@ func waitForCertificate(ctx context.Context, srv *jmap.Client, log Log, domainID
return ""
}
// converge brings an installed machine in line with what it is now asked to
// run: the deployment is rewritten from the shapes chosen, the stack is
// brought up, and anything no longer in the file goes with --remove-orphans.
// Nothing touches the mail: data volumes are left alone, and a component
// that is removed can be added back with what it had.
func converge(ctx context.Context, o plan.Options, stack compose.Stack, cmp docker.Compose,
st *state.State, f host.Facts, log Log) (*Result, error) {
res := &Result{
Dir: o.Dir, ConsoleURL: stack.ConsoleURL, WebmailURL: stack.WebmailURL,
ServerURL: stack.ServerPublicURL,
}
if b, err := os.ReadFile(filepath.Join(o.Dir, "credentials.txt")); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if fields := strings.Fields(line); len(fields) == 2 && fields[0] == "administrator" {
res.AdminUser = fields[1]
}
}
}
log.Step("bringing the stack in line with the file")
if err := cmp.Run(ctx, "up", "-d", "--remove-orphans"); err != nil {
return res, withLogs(ctx, err, cmp, "server")
}
// The server reads the front-end URLs from its environment, so a front
// end that was added or removed changes what it was started with. Only
// restart when that actually changed.
if st.Shapes["console"] != string(o.Shape(plan.Console)) || st.Shapes["webmail"] != string(o.Shape(plan.Webmail)) {
log.Info("the front ends changed, so the server is restarted to see them")
if err := cmp.Run(ctx, "restart", "server"); err != nil {
return res, err
}
// Do not report a finished converge over a server that is still
// coming back: a few seconds of refused connections is the one
// moment of this that looks like an outage, and it should be over
// before the command returns.
if err := waitFor(ctx, log, "the server to answer again", 120*time.Second, func(ctx context.Context) error {
return live(ctx, "http://"+stack.ServerBind)
}); err != nil {
return res, withLogs(ctx, err, cmp, "server")
}
}
st.Machine, st.Dir, st.Domain = o.Machine, o.Dir, o.Domain
st.Runtime, st.Topology = f.Runtime.Kind, o.TopologyPath
st.Shapes = map[string]string{}
for _, c := range []plan.Component{plan.Server, plan.Console, plan.Webmail} {
if sh := o.Shape(c); sh != plan.Skip {
st.Shapes[string(c)] = string(sh)
}
}
if err := st.Save(); err != nil {
log.Info("could not write %s: %v", state.Path, err)
}
return res, nil
}
// Verify is the last step: does what was installed actually answer?
func Verify(ctx context.Context, res *Result, stackConsole, stackWebmail bool, log Log) []string {
var problems []string
+36 -4
View File
@@ -133,12 +133,21 @@ func Write(dir string, s *Stack) (Secrets, error) {
return sec, err
}
// Secrets are generated once and kept. Rewriting the deployment to add
// or remove a component must not roll the webmail's session key or the
// OAuth client secret the server was told: the point of converging is
// that everything not being changed stays as it was.
sec = readSecrets(filepath.Join(dir, ".env"))
var err error
if sec.AppSecret, err = secret(); err != nil {
return sec, err
if sec.AppSecret == "" {
if sec.AppSecret, err = secret(); err != nil {
return sec, err
}
}
if sec.WebmailOAuth, err = secret(); err != nil {
return sec, err
if sec.WebmailOAuth == "" {
if sec.WebmailOAuth, err = secret(); err != nil {
return sec, err
}
}
funcs := template.FuncMap{"join": strings.Join}
@@ -171,6 +180,29 @@ func Write(dir string, s *Stack) (Secrets, error) {
return sec, nil
}
// readSecrets recovers what a previous run generated, so a converge keeps
// them. A missing or unreadable file means a first install, and new ones.
func readSecrets(path string) Secrets {
var sec Secrets
b, err := os.ReadFile(path)
if err != nil {
return sec
}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
switch k {
case "APP_SECRET":
sec.AppSecret = v
case "WEBMAIL_CLIENT_SECRET":
sec.WebmailOAuth = v
}
}
return sec
}
func secret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
+6
View File
@@ -67,6 +67,12 @@ type Options struct {
// Let's Encrypt, which is what an install should use.
ACMEDirectory string
ACMECARoot string
// Where this came from, when it came from a topology file: the machine's
// name in it, and the path, both recorded in the state file so a later
// run knows which part of which file this machine is.
Machine string
TopologyPath string
}
// Shape returns what was chosen for a component.
+76
View File
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package state is what this machine remembers about what was installed on
// it, so a second run converges instead of building a second one.
//
// It is deliberately small, and deliberately not the truth: the truth is the
// machine, and a plan is built by reading both. What this adds is intent --
// which components this installer put here, in which shape, from which
// topology -- which the machine itself cannot tell you. A container that was
// stopped by hand is still ours; a container we never installed is not.
package state
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// Path is where it lives. /etc, not the deployment directory: it survives
// the deployment directory being moved or rebuilt, and an operator looking
// for "what did this thing do to my machine" looks in /etc.
const Path = "/etc/inbuxa/install.json"
// State is the file.
type State struct {
Version int `json:"version"`
Machine string `json:"machine"` // its name in the topology
Dir string `json:"dir"` // the deployment directory
Runtime string `json:"runtime,omitempty"` // docker or podman
Domain string `json:"domain,omitempty"` //
Shapes map[string]string `json:"shapes"` // component -> container|host
Topology string `json:"topology,omitempty"` // the file this came from, if any
Updated time.Time `json:"updated"` //
Installer string `json:"installer,omitempty"` // the version that wrote this
}
const Version = 1
// Load reads the state, returning an empty one when there is none. A machine
// with nothing installed is not an error; it is the ordinary first case.
func Load() (*State, error) {
b, err := os.ReadFile(Path)
if os.IsNotExist(err) {
return &State{Version: Version, Shapes: map[string]string{}}, nil
}
if err != nil {
return nil, err
}
var s State
if err := json.Unmarshal(b, &s); err != nil {
return nil, err
}
if s.Shapes == nil {
s.Shapes = map[string]string{}
}
return &s, nil
}
// Save writes it, creating /etc/inbuxa if it is not there.
func (s *State) Save() error {
s.Version = Version
s.Updated = time.Now().UTC().Truncate(time.Second)
if err := os.MkdirAll(filepath.Dir(Path), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(Path, append(b, '\n'), 0o644)
}
// Installed is whether anything is recorded here at all.
func (s *State) Installed() bool { return len(s.Shapes) > 0 }
+142
View File
@@ -0,0 +1,142 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
package topology
import (
"fmt"
"sort"
"strings"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/state"
)
// Change is one difference between what a machine runs and what the topology
// says it should.
type Change struct {
Component string
Was string // shape it is in now, "" for absent
Wants string // shape the file asks for, "" for removed
}
// What it is, in a word, for the line that prints it.
func (c Change) Verb() string {
switch {
case c.Was == "" && c.Wants != "":
return "add"
case c.Was != "" && c.Wants == "":
return "remove"
case c.Was != c.Wants:
return "change"
default:
return "keep"
}
}
// Diff compares what this machine has against what the file asks of it.
//
// The comparison is against recorded intent and not only against what is
// running: a container that is stopped is still installed, and a plan that
// offered to install it again would be lying about what it is doing.
type Diff struct {
Machine string
Changes []Change
}
// Compare works out the difference for one machine.
func Compare(s *state.State, m Machine) Diff {
d := Diff{Machine: m.Name}
seen := map[string]bool{}
for _, kind := range []string{"server", "console", "webmail"} {
was, wants := s.Shapes[kind], m.Shape(kind)
seen[kind] = true
if was == "" && wants == "" {
continue
}
d.Changes = append(d.Changes, Change{Component: kind, Was: was, Wants: wants})
}
// Anything recorded that is not a component this version knows about:
// report it rather than silently leaving it, because an older or newer
// installer put it here and this one is about to rewrite the deployment.
var extra []string
for kind := range s.Shapes {
if !seen[kind] {
extra = append(extra, kind)
}
}
sort.Strings(extra)
for _, kind := range extra {
d.Changes = append(d.Changes, Change{Component: kind, Was: s.Shapes[kind]})
}
return d
}
// Empty is whether anything would happen.
func (d Diff) Empty() bool {
for _, c := range d.Changes {
if c.Verb() != "keep" {
return false
}
}
return true
}
// Removals is what would be taken off this machine.
func (d Diff) Removals() []Change {
var out []Change
for _, c := range d.Changes {
if c.Verb() == "remove" {
out = append(out, c)
}
}
return out
}
// String is the diff as the plan shows it: what is here, what is asked for,
// and nothing has happened yet.
func (d Diff) String() string {
var b strings.Builder
fmt.Fprintf(&b, "On %s\n", d.Machine)
if d.Empty() {
for _, c := range d.Changes {
fmt.Fprintf(&b, " keep %-8s %s\n", c.Component, c.Wants)
}
b.WriteString(" nothing to do: this machine already matches the file\n")
return b.String()
}
for _, c := range d.Changes {
switch c.Verb() {
case "add":
fmt.Fprintf(&b, " add %-8s as a %s install\n", c.Component, c.Wants)
case "remove":
fmt.Fprintf(&b, " remove %-8s (installed as a %s)\n", c.Component, c.Was)
case "change":
fmt.Fprintf(&b, " change %-8s %s -> %s\n", c.Component, c.Was, c.Wants)
default:
fmt.Fprintf(&b, " keep %-8s %s\n", c.Component, c.Wants)
}
}
return b.String()
}
// Elsewhere is the part of the plan this machine will not carry out: what
// every other machine in the file has to run, as the command to run there.
// Printed, never executed -- no machine in this design reaches another.
func Elsewhere(t *Topology, path, self string) string {
others := t.Others(self)
if len(others) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\nOn the other machines in this file, run there:\n")
for _, m := range others {
var parts []string
for _, c := range m.Components {
parts = append(parts, c.Kind+" as a "+c.Shape)
}
fmt.Fprintf(&b, " %-10s %s\n", m.Name, strings.Join(parts, ", "))
fmt.Fprintf(&b, " inbuxa apply -f %s --machine %s\n", path, m.Name)
}
b.WriteString("\nNothing here touches them. Copy the file across and run it there.\n")
return b.String()
}
+205
View File
@@ -0,0 +1,205 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package topology is the description of a whole installation: which machine
// runs what, under which names, reachable from where.
//
// One file, however many machines. Each machine reads the same file and acts
// on its own part of it -- so the file is the thing an operator keeps, diffs
// and reviews, and no machine ever reaches another. That is the whole design:
// the designer, whatever draws it, emits this; `inbuxa apply -f` on each
// machine is the only thing with privileges.
//
// It describes front ends scaled out across machines, not a clustered mail
// server: one server, as many webmails and consoles as there are machines to
// put them on. A second mail node needs a shared store and cluster
// configuration, which is a larger product; the file's shape leaves room for
// it -- components on machines, with relationships -- so that version adds a
// kind rather than inventing a new file.
package topology
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
)
// Topology is the file.
type Topology struct {
Version int `json:"version"`
Domain string `json:"domain"`
MailHost string `json:"mail_host,omitempty"`
ConsoleHost string `json:"console_host,omitempty"`
WebmailHost string `json:"webmail_host,omitempty"`
ACMEEmail string `json:"acme_email,omitempty"`
Machines []Machine `json:"machines"`
}
// Machine is one host, named by the operator. The name is how a machine
// recognises its own part of the file; it defaults to the hostname, which is
// what most people will call it anyway.
type Machine struct {
Name string `json:"name"`
Components []Component `json:"components"`
Proxy string `json:"proxy,omitempty"` // caddy, snippets, none
Dir string `json:"dir,omitempty"`
}
// Component is one program on one machine.
type Component struct {
Kind string `json:"kind"` // server, console, webmail
Shape string `json:"shape"` // container, host
}
const Version = 1
var kinds = map[string]bool{"server": true, "console": true, "webmail": true}
var shapes = map[string]bool{"container": true, "host": true}
// Load reads a topology file and checks that it describes something that
// could exist. A file that cannot be installed should fail here, where the
// message can name the line's subject, rather than half way through an apply.
func Load(path string) (*Topology, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var t Topology
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.DisallowUnknownFields()
if err := dec.Decode(&t); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if err := t.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &t, nil
}
// Validate is the rules. They are few on purpose: this describes front ends
// spread over machines, and the interesting refusals are about arrangements
// that cannot work rather than about syntax.
func (t *Topology) Validate() error {
if t.Version != Version {
return fmt.Errorf("version %d: this installer writes and reads version %d", t.Version, Version)
}
if t.Domain == "" {
return fmt.Errorf("no domain")
}
t.Defaults()
if len(t.Machines) == 0 {
return fmt.Errorf("no machines")
}
seen := map[string]bool{}
servers := 0
for _, m := range t.Machines {
if m.Name == "" {
return fmt.Errorf("a machine has no name")
}
if seen[m.Name] {
return fmt.Errorf("two machines are called %q", m.Name)
}
seen[m.Name] = true
if len(m.Components) == 0 {
return fmt.Errorf("machine %q runs nothing", m.Name)
}
kindsHere := map[string]bool{}
for _, c := range m.Components {
if !kinds[c.Kind] {
return fmt.Errorf("machine %q: %q is not a component (server, console, webmail)", m.Name, c.Kind)
}
if !shapes[c.Shape] {
return fmt.Errorf("machine %q: %q is not a shape (container, host)", m.Name, c.Shape)
}
if kindsHere[c.Kind] {
// Two webmails on one machine would need two ports and two
// names, and nothing here says which. Two machines is the
// supported way to have two.
return fmt.Errorf("machine %q runs two %ss; put the second on another machine", m.Name, c.Kind)
}
kindsHere[c.Kind] = true
if c.Kind == "server" {
servers++
}
}
}
switch servers {
case 1:
case 0:
return fmt.Errorf("no machine runs the mail server")
default:
return fmt.Errorf("%d machines run the mail server: one mail server for now, because a second "+
"node needs a shared store and cluster configuration, which this installer does not set up", servers)
}
return nil
}
// Defaults fills in the names that follow from the domain.
func (t *Topology) Defaults() {
if t.MailHost == "" {
t.MailHost = "mail." + t.Domain
}
if t.ConsoleHost == "" {
t.ConsoleHost = "admin." + t.Domain
}
if t.WebmailHost == "" {
t.WebmailHost = "webmail." + t.Domain
}
if t.ACMEEmail == "" {
t.ACMEEmail = "postmaster@" + t.Domain
}
for i := range t.Machines {
if t.Machines[i].Dir == "" {
t.Machines[i].Dir = "/var/lib/inbuxa"
}
if t.Machines[i].Proxy == "" {
t.Machines[i].Proxy = "caddy"
}
}
}
// Machine finds one by name.
func (t *Topology) Machine(name string) (Machine, bool) {
for _, m := range t.Machines {
if m.Name == name {
return m, true
}
}
return Machine{}, false
}
// Others is every machine but this one, for the part of a plan that says what
// has to be run elsewhere. Nothing here reaches them: it prints the command
// and leaves it to the operator, which is the point.
func (t *Topology) Others(name string) []Machine {
var out []Machine
for _, m := range t.Machines {
if m.Name != name {
out = append(out, m)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Shape is what this machine runs a component as, or "" for not at all.
func (m Machine) Shape(kind string) string {
for _, c := range m.Components {
if c.Kind == kind {
return c.Shape
}
}
return ""
}
// Save writes a topology file, formatted to be read and diffed by people.
func (t *Topology) Save(path string) error {
b, err := json.MarshalIndent(t, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o644)
}