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:
+100
-2
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user