Files
inbuxa-installer/internal/topology/topology.go
T
jcoffey-dev a90df656e7 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.
2026-09-24 09:51:16 -07:00

206 lines
6.2 KiB
Go

// 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)
}