diff --git a/README.md b/README.md index 92aab32..b6685c1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/inbuxa/main.go b/cmd/inbuxa/main.go index 87f4c8e..eb69d26 100644 --- a/cmd/inbuxa/main.go +++ b/cmd/inbuxa/main.go @@ -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": diff --git a/e2e/cases/topology.sh b/e2e/cases/topology.sh new file mode 100644 index 0000000..f1ccb66 --- /dev/null +++ b/e2e/cases/topology.sh @@ -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 ] diff --git a/internal/apply/apply.go b/internal/apply/apply.go index e6f7490..8b63975 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -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 diff --git a/internal/compose/compose.go b/internal/compose/compose.go index f3e6be7..0b1938f 100644 --- a/internal/compose/compose.go +++ b/internal/compose/compose.go @@ -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 { diff --git a/internal/plan/plan.go b/internal/plan/plan.go index a3f0a3e..43ad468 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -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. diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..3d2f286 --- /dev/null +++ b/internal/state/state.go @@ -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 } diff --git a/internal/topology/diff.go b/internal/topology/diff.go new file mode 100644 index 0000000..546f4d4 --- /dev/null +++ b/internal/topology/diff.go @@ -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() +} diff --git a/internal/topology/topology.go b/internal/topology/topology.go new file mode 100644 index 0000000..eae4a17 --- /dev/null +++ b/internal/topology/topology.go @@ -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) +}