diff --git a/README.md b/README.md index b6685c1..f7bc85b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ This is early. What is built: 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. +- **`status`** -- what is installed, what is running, and where those two + disagree: a container stopped by hand, or a deployment nothing recorded + installing. It exits non-zero when they disagree, so a machine can be + asked in a script whether it still matches itself. Not built yet: host installs, the terminal interface, `join`, `status`, `upgrade`, `uninstall`. The design is in the inbuxa specification (ยง6.1 and @@ -76,6 +80,7 @@ is tested on a throwaway virtual machine rather than on anybody's desk: 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/run.sh e2e/cases/status-export.sh intent against reality, and the 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 eb69d26..191b35c 100644 --- a/cmd/inbuxa/main.go +++ b/cmd/inbuxa/main.go @@ -21,6 +21,7 @@ import ( "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/apply" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps" + "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/discover" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan" "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/state" @@ -38,6 +39,7 @@ const usage = `inbuxa -- install the inbuxa suite on this machine 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 status what is installed here, and whether it agrees inbuxa export [-o FILE] write a topology file from what is here inbuxa version this program's version @@ -101,6 +103,8 @@ func main() { os.Exit(topologyCmd(os.Args[2:], true)) case "export": os.Exit(exportCmd(os.Args[2:])) + case "status": + os.Exit(statusCmd()) case "version": fmt.Println(version) case "-h", "--help", "help": @@ -395,26 +399,51 @@ func exportCmd(args []string) int { if err := fs.Parse(args); err != nil { return 2 } + ctx := context.Background() st, err := state.Load() if err != nil { fmt.Fprintln(os.Stderr, err.Error()) return 1 } - if !st.Installed() { + f := host.Survey(ctx) + found := discover.Run(ctx, f) + + if !st.Installed() && found.Dir == "" { 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} + dir, domain := st.Dir, st.Domain + if dir == "" { + dir = found.Dir + } + if domain == "" { + domain = found.Domain + } + + // Intent first, because it knows the shapes; reality for anything intent + // does not mention, so a deployment made by an older version or by hand + // still describes itself. + m := topology.Machine{Name: name, Dir: 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}) + switch { + case st.Shapes[kind] != "": + m.Components = append(m.Components, topology.Component{Kind: kind, Shape: st.Shapes[kind]}) + case found.Services[kind] != "": + m.Components = append(m.Components, topology.Component{Kind: kind, Shape: "container"}) } } - t := &topology.Topology{Version: topology.Version, Domain: st.Domain, Machines: []topology.Machine{m}} + if !found.Proxy && found.Dir != "" { + m.Proxy = "none" + } + t := &topology.Topology{Version: topology.Version, Domain: domain, Machines: []topology.Machine{m}} + if found.MailHost != "" { + t.MailHost = found.MailHost + } t.Defaults() if err := t.Validate(); err != nil { fmt.Fprintln(os.Stderr, "what is installed here does not describe a whole installation: "+err.Error()) @@ -434,6 +463,72 @@ func exportCmd(args []string) int { return 0 } +// statusCmd is the same reading as export, shown to a person: what is +// installed, what is running, and where the two disagree. +func statusCmd() int { + ctx := context.Background() + st, err := state.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + return 1 + } + f := host.Survey(ctx) + found := discover.Run(ctx, f) + + if !st.Installed() && found.Dir == "" { + fmt.Println("Nothing is installed on this machine.") + return 0 + } + + name := st.Machine + if name == "" { + name, _ = os.Hostname() + } + fmt.Printf("This machine is %q", name) + if st.Topology != "" { + fmt.Printf(", from %s", st.Topology) + } + fmt.Println() + if found.Dir != "" { + fmt.Printf(" %-14s %s\n", "deployment", found.Dir) + } + if found.Domain != "" || st.Domain != "" { + domain := st.Domain + if domain == "" { + domain = found.Domain + } + fmt.Printf(" %-14s %s\n", "domain", domain) + } + if found.Runtime != "" { + fmt.Printf(" %-14s %s\n", "runtime", found.Runtime) + } + + fmt.Println("\nComponents") + for _, kind := range []string{"server", "console", "webmail"} { + shape, intended := st.Shapes[kind] + state, present := found.Services[kind] + switch { + case !intended && !present: + continue + case intended && present: + fmt.Printf(" %-8s installed as a %-9s %s\n", kind, shape, state) + case intended: + fmt.Printf(" %-8s installed as a %-9s missing from the deployment\n", kind, shape) + default: + fmt.Printf(" %-8s %-24s %s\n", kind, "in the deployment", state) + } + } + + if d := discover.Drift(st.Shapes, found); len(d) > 0 { + fmt.Println("\nWorth a look") + for _, line := range d { + fmt.Printf(" - %s\n", line) + } + return 1 + } + return 0 +} + func machineNames(t *topology.Topology) []string { var out []string for _, m := range t.Machines { diff --git a/e2e/cases/status-export.sh b/e2e/cases/status-export.sh new file mode 100644 index 0000000..31ed95e --- /dev/null +++ b/e2e/cases/status-export.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2026 Coffey Labs +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# What is here, as against what we wrote down that we did. +# +# The interesting cases are the ones where those two part company: a +# container stopped by hand, and a deployment with no state file at all -- +# installed by an older version, or copied from another machine. An installer +# that reports intent as though it were reality is worse than one that +# reports nothing, because the operator believes it. +# +# The export has to survive both, because a design that starts from a file +# that does not describe the machine is a design of somewhere else. +# +# Run from the host with: e2e/vm/run.sh e2e/cases/status-export.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 +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 +} + +echo "==> before anything is installed" +OUT="$(/tmp/inbuxa status 2>&1)"; rc=$? +has "$OUT" "Nothing is installed" "status says so" +[ $rc -eq 0 ] && ok "and that is not an error" || bad "exit $rc" +OUT="$(/tmp/inbuxa export 2>&1)"; rc=$? +[ $rc -ne 0 ] && ok "export refuses (exit $rc)" || bad "export invented something" + +echo +echo "==> install, then ask what is here" +/tmp/inbuxa install --local --domain example.test --install-deps --yes >/tmp/install.log 2>&1 || { bad "install failed"; tail -5 /tmp/install.log | sed 's/^/ /'; } +OUT="$(/tmp/inbuxa status 2>&1)"; rc=$? +echo "$OUT" | sed 's/^/ /' +[ $rc -eq 0 ] && ok "status is clean (exit 0)" || bad "exit $rc" +has "$OUT" "server installed as a container running" "the server is installed and running" +has "$OUT" "webmail installed as a container running" "so is the webmail" +has "$OUT" "example.test" "and it knows the domain" + +echo +echo "==> a container stopped by hand" +compose stop webmail >/dev/null 2>&1 +OUT="$(/tmp/inbuxa status 2>&1)"; rc=$? +has "$OUT" "webmail installed as a container stopped" "status shows it stopped" +has "$OUT" "webmail is installed and not running" "and calls it out" +[ $rc -ne 0 ] && ok "and says so in its exit code ($rc)" || bad "a machine that disagrees with itself exited 0" +compose start webmail >/dev/null 2>&1 + +echo +echo "==> export describes this machine" +/tmp/inbuxa export -o /tmp/exported.json >/dev/null 2>&1 || bad "export failed" +python3 - <<'PY' +import json +t = json.load(open('/tmp/exported.json')) +m = t['machines'][0] +kinds = sorted(c['kind'] for c in m['components']) +print(f" domain={t['domain']} machines={len(t['machines'])} components={','.join(kinds)} proxy={m.get('proxy','')}") +assert t['domain'] == 'example.test', t['domain'] +assert kinds == ['console', 'server', 'webmail'], kinds +PY +[ $? -eq 0 ] && ok "the file says what is here" || bad "the exported file is wrong" + +echo +echo "==> and the file it wrote is one it accepts back" +OUT="$(/tmp/inbuxa plan -f /tmp/exported.json 2>&1)"; rc=$? +[ $rc -eq 0 ] && ok "plan reads it (exit 0)" || { bad "exit $rc"; echo "$OUT" | sed 's/^/ /'; } +has "$OUT" "nothing to do" "and finds nothing to do, which is the point" + +echo +echo "==> a deployment nobody wrote down: the state file removed" +cp /etc/inbuxa/install.json /tmp/state.json.bak +rm -f /etc/inbuxa/install.json +OUT="$(/tmp/inbuxa status 2>&1)" +echo "$OUT" | sed 's/^/ /' +has "$OUT" "in the deployment" "status describes it from the deployment itself" +has "$OUT" "nothing recorded installing it" "and says nothing recorded it" +/tmp/inbuxa export -o /tmp/exported2.json >/dev/null 2>&1 || bad "export failed without state" +python3 - <<'PY' +import json +t = json.load(open('/tmp/exported2.json')) +kinds = sorted(c['kind'] for c in t['machines'][0]['components']) +print(f" rebuilt from the deployment: domain={t['domain']} components={','.join(kinds)}") +assert t['domain'] == 'example.test', t['domain'] +assert kinds == ['console', 'server', 'webmail'], kinds +PY +[ $? -eq 0 ] && ok "export rebuilds the file from the deployment" || bad "export could not describe an unrecorded deployment" +cp /tmp/state.json.bak /etc/inbuxa/install.json + +echo +echo "==> $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/internal/discover/discover.go b/internal/discover/discover.go new file mode 100644 index 0000000..0b4fa3b --- /dev/null +++ b/internal/discover/discover.go @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package discover reads what is actually on this machine, as opposed to +// what the installer wrote down that it did. +// +// The two are not the same thing, and the difference is the useful part. A +// container stopped by hand, a deployment directory copied from another +// machine, an installation made by an older version of this program, or a +// service someone removed with the runtime directly: in each case intent and +// reality have parted company, and an operator wants to be told, not to have +// one quietly reported as the other. +// +// So: state is intent, this is reality, and the two are shown side by side. +package discover + +import ( + "context" + "os" + "path/filepath" + "regexp" + "strings" + + "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/docker" + "git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host" +) + +// Found is what this machine turns out to be running. +type Found struct { + Dir string // the deployment directory, when there is one + Domain string // read back from the deployment + MailHost string // + Services map[string]string // component -> running|stopped|absent + Compose []string // services the compose file declares + Runtime string // what is running them + Proxy bool // the deployment includes a proxy + Credential bool // credentials.txt is there +} + +// Dirs are where a deployment may be. The first is what this installer +// writes; the second is oneshot's habit, and someone moving across will have +// one. +var Dirs = []string{"/var/lib/inbuxa", "/opt/inbuxa"} + +var serviceLine = regexp.MustCompile(`(?m)^ ([a-z][a-z0-9_-]*):\s*$`) +var hostnameLine = regexp.MustCompile(`(?m)^\s+hostname:\s*(\S+)\s*$`) + +// Run looks for a deployment and reports what it finds. It never changes +// anything, and a machine with nothing on it is not an error. +func Run(ctx context.Context, f host.Facts, dirs ...string) Found { + found := Found{Services: map[string]string{}, Runtime: f.Runtime.Kind} + if len(dirs) == 0 { + dirs = Dirs + } + for _, dir := range dirs { + if _, err := os.Stat(filepath.Join(dir, "compose.yaml")); err == nil { + found.Dir = dir + break + } + } + if found.Dir == "" { + return found + } + + if _, err := os.Stat(filepath.Join(found.Dir, "credentials.txt")); err == nil { + found.Credential = true + } + + b, err := os.ReadFile(filepath.Join(found.Dir, "compose.yaml")) + if err != nil { + return found + } + text := string(b) + for _, m := range serviceLine.FindAllStringSubmatch(text, -1) { + name := m[1] + if name == "caddy" { + found.Proxy = true + continue + } + if name == "server" || name == "console" || name == "webmail" { + found.Compose = append(found.Compose, name) + found.Services[name] = "stopped" + } + } + // The mail host is written into the server's hostname, and the domain + // follows from it. Read back rather than assumed, because this may be a + // deployment this program did not write. + if m := hostnameLine.FindStringSubmatch(text); m != nil { + found.MailHost = m[1] + if _, rest, ok := strings.Cut(m[1], "."); ok { + found.Domain = rest + } + } + + // What is actually up. A compose file that lists a service proves + // nothing about whether it is running. + if f.Runtime.Usable { + docker.UseRuntime(f.Runtime) + cmp := docker.Compose{Dir: found.Dir, Runtime: f.Runtime} + for _, name := range found.Compose { + if cmp.Running(ctx, name) { + found.Services[name] = "running" + } + } + } + return found +} + +// Components is what a topology file would say this machine runs. Everything +// the deployment declares counts, running or not: a stopped webmail is still +// installed here, and a file that left it out would be asking for it to be +// removed. +func (f Found) Components() []string { + var out []string + for _, name := range []string{"server", "console", "webmail"} { + if _, ok := f.Services[name]; ok { + out = append(out, name) + } + } + return out +} + +// Drift is where intent and reality disagree, in sentences fit to show +// someone. Empty means they agree. +func Drift(shapes map[string]string, f Found) []string { + var out []string + for _, kind := range []string{"server", "console", "webmail"} { + _, intended := shapes[kind] + state, present := f.Services[kind] + switch { + case intended && !present: + out = append(out, kind+" was installed here, and the deployment no longer has it") + case !intended && present: + out = append(out, kind+" is in the deployment, and nothing recorded installing it") + case intended && present && state != "running": + out = append(out, kind+" is installed and not running") + } + } + return out +}