Files
Ilker ManapandClaude Opus 5.5 38f2671ed9 Open setup screens or a console when llmgw runs bare in a terminal
With nothing installed, sudo llmgw asks for one machine or three, takes how
to reach the other two (ssh agent, key or password, and a sudo password used
only for the install), checks every machine first, shows the plan and its
command line, and installs. On a control plane it opens a console with the
cluster's state and the next steps. Machines are reached with Go's ssh so a
password works; the store's etcd and access rules use this machine's routed
address rather than a client-facing name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 21:33:59 +02:00

361 lines
10 KiB
Go

// Package preflight checks machines before anything is installed on them:
// that they can be logged into and administered, run a supported system, have
// room for their role, keep time, have the ports free, and can reach the
// internet and each other. Everything wrong is found at once, with what to
// do about it, rather than one failure at a time halfway through an install.
package preflight
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
)
// Role is what a machine is going to be.
type Role string
// The roles a setup gives machines.
const (
RoleControl Role = "control plane"
RoleDatabase Role = "database"
RoleWitness Role = "etcd witness"
RoleNode Role = "node"
)
// Need is the least a role asks of a machine.
type Need struct {
CPUs int
MemoryMB int
DiskGB int
Ports []int
}
// Needs are what each role asks for; a machine with several roles needs the
// largest of each and the ports of all.
var Needs = map[Role]Need{
RoleControl: {CPUs: 2, MemoryMB: 3500, DiskGB: 15, Ports: []int{7443, 8080}},
RoleDatabase: {CPUs: 2, MemoryMB: 3500, DiskGB: 30, Ports: []int{5432, 8008, 2379, 2380}},
RoleWitness: {CPUs: 1, MemoryMB: 900, DiskGB: 5, Ports: []int{2379, 2380}},
RoleNode: {CPUs: 2, MemoryMB: 3500, DiskGB: 20},
}
// Machine is one machine to check.
type Machine struct {
Name string // how the screen calls it
Addr string // the address the others will use
Roles []Role
Remote onboard.Remote
// Local is this machine, where sudo is not needed (setup runs as root).
Local bool
}
// Level is how bad a finding is.
type Level int
// Levels, mildest first.
const (
OK Level = iota
Warn
Fail
)
func (l Level) String() string { return [...]string{"ok", "warn", "fail"}[l] }
// Finding is one thing checked on one machine.
type Finding struct {
Machine string
Check string
Level Level
Detail string
// Fix says what to do, for anything that is not OK.
Fix string
}
// Report is everything found.
type Report struct {
Findings []Finding
}
// Worst is the most serious level found.
func (r Report) Worst() Level {
w := OK
for _, f := range r.Findings {
if f.Level > w {
w = f.Level
}
}
return w
}
// For is one machine's findings, in check order.
func (r Report) For(machine string) []Finding {
var out []Finding
for _, f := range r.Findings {
if f.Machine == machine {
out = append(out, f)
}
}
return out
}
// probeScript gathers everything in one round trip, as key=value lines, so
// the checks are decided here and can be tested without a machine.
const probeScript = `
. /etc/os-release 2>/dev/null; echo "os=$ID"; echo "version=$VERSION_ID"; echo "pretty=$PRETTY_NAME"
echo "arch=$(uname -m)"
echo "cpus=$(nproc)"
echo "mem_mb=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo)"
echo "disk_gb=$(df -BG --output=avail / | tail -1 | tr -dc 0-9)"
echo "now=$(date +%s.%N)"
echo "ntp=$(timedatectl show -p NTPSynchronized --value 2>/dev/null)"
echo "listening=$(ss -Hltn 2>/dev/null | awk '{n=split($4,a,":"); print a[n]}' | sort -un | tr '\n' ' ')"
if sudo -n true 2>/dev/null; then echo "sudo=yes"; else echo "sudo=no"; fi
echo "apt=$(command -v apt-get >/dev/null && echo yes || echo no)"
echo "internet=$(timeout 6 bash -c 'exec 3<>/dev/tcp/deb.debian.org/80' 2>/dev/null && echo yes || (command -v curl >/dev/null && curl -s -m 6 -o /dev/null http://deb.debian.org && echo yes) || echo no)"
`
// reachScript tries one TCP port on each address: whether the machines can
// reach one another at all. Port 22 because it is open everywhere before
// anything is installed; the ports llmgw will use have nothing listening yet.
func reachScript(addrs []string) string {
var b strings.Builder
for _, a := range addrs {
fmt.Fprintf(&b, "if timeout 4 bash -c 'exec 3<>/dev/tcp/%s/22' 2>/dev/null; then echo \"reach %s yes\"; else echo \"reach %s no\"; fi\n", a, a, a)
}
return b.String()
}
// Run checks every machine, concurrently.
func Run(ctx context.Context, machines []Machine) Report {
var (
mu sync.Mutex
rep Report
wg sync.WaitGroup
)
add := func(fs ...Finding) {
mu.Lock()
rep.Findings = append(rep.Findings, fs...)
mu.Unlock()
}
var addrs []string
for _, m := range machines {
addrs = append(addrs, m.Addr)
}
order := map[string]int{}
for i, m := range machines {
order[m.Name] = i
wg.Add(1)
go func(m Machine) {
defer wg.Done()
add(check(ctx, m, addrs)...)
}(m)
}
wg.Wait()
// Machines in the order given, checks in the order made.
sort.SliceStable(rep.Findings, func(i, j int) bool {
return order[rep.Findings[i].Machine] < order[rep.Findings[j].Machine]
})
return rep
}
func check(ctx context.Context, m Machine, all []string) []Finding {
sent := time.Now()
out, err := m.Remote.Run(ctx, probeScript, nil)
took := time.Since(sent)
if err != nil {
return []Finding{{Machine: m.Name, Check: "login", Level: Fail,
Detail: strings.TrimSpace(firstLine(err.Error() + " " + string(out))),
Fix: "check the address, the user and the key or password given for it"}}
}
facts := parse(out)
fs := Evaluate(m, facts, sent.Add(took/2))
var others []string
for _, a := range all {
if a != m.Addr {
others = append(others, a)
}
}
if len(others) > 0 {
rout, _ := m.Remote.Run(ctx, reachScript(others), nil)
fs = append(fs, evaluateReach(m, string(rout))...)
}
return fs
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func parse(out []byte) map[string]string {
facts := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok {
facts[k] = strings.TrimSpace(v)
}
}
return facts
}
// supported are the systems the setup installs packages on with apt.
var supported = map[string][]string{
"ubuntu": {"22.04", "24.04"},
"debian": {"12"},
}
// Evaluate turns one machine's facts into findings. at is when the machine's
// clock was read, by this machine's clock.
func Evaluate(m Machine, facts map[string]string, at time.Time) []Finding {
var fs []Finding
f := func(check string, l Level, detail, fix string) {
fs = append(fs, Finding{Machine: m.Name, Check: check, Level: l, Detail: detail, Fix: fix})
}
f("login", OK, "logged in", "")
// The system.
os, ver := facts["os"], facts["version"]
switch vers, ok := supported[os]; {
case !ok:
f("system", Fail, facts["pretty"], "use Ubuntu 22.04 or 24.04, or Debian 12")
case !contains(vers, ver):
f("system", Warn, facts["pretty"]+" (tried on "+strings.Join(vers, ", ")+")", "it may work; Ubuntu 24.04 is what this is tested on")
default:
f("system", OK, facts["pretty"], "")
}
switch facts["arch"] {
case "x86_64", "aarch64":
f("architecture", OK, facts["arch"], "")
default:
f("architecture", Fail, facts["arch"], "llmgw is built for amd64 and arm64")
}
// Administering it.
if !m.Local {
if facts["sudo"] == "yes" {
f("sudo", OK, "without a password", "")
} else {
// Not a failure: the setup asks for the password and grants
// itself sudo for the length of the install.
f("sudo", Warn, "asks for a password", "give the sudo password on the access screen")
}
}
if facts["apt"] != "yes" {
f("packages", Fail, "no apt-get", "the setup installs PostgreSQL, Patroni and etcd with apt")
}
// Room for its roles.
need := needOf(m.Roles)
cpus, _ := strconv.Atoi(facts["cpus"])
mem, _ := strconv.Atoi(facts["mem_mb"])
disk, _ := strconv.Atoi(facts["disk_gb"])
room := func(check string, have, want int, unit string) {
d := fmt.Sprintf("%d %s (needs %d)", have, unit, want)
switch {
case have >= want:
f(check, OK, d, "")
case have*2 >= want:
f(check, Warn, d, "it will run, short of what the role is sized for")
default:
f(check, Fail, d, "give the machine more")
}
}
room("cpus", cpus, need.CPUs, "CPUs")
room("memory", mem, need.MemoryMB, "MB")
room("disk", disk, need.DiskGB, "GB free")
// Time. Leases and etcd's elections go wrong on clocks that disagree.
if now, err := strconv.ParseFloat(facts["now"], 64); err == nil {
skew := time.Duration((now - float64(at.UnixNano())/1e9) * float64(time.Second))
if skew < 0 {
skew = -skew
}
switch {
case skew > 5*time.Second:
f("clock", Fail, fmt.Sprintf("%s off from this machine", skew.Round(time.Second)), "turn on NTP (timedatectl set-ntp true) on both")
case skew > time.Second:
f("clock", Warn, fmt.Sprintf("%s off from this machine", skew.Round(100*time.Millisecond)), "turn on NTP on both")
default:
f("clock", OK, "within a second of this machine", "")
}
}
if facts["ntp"] != "yes" {
f("time sync", Warn, "NTP is not synchronised", "timedatectl set-ntp true")
}
// Ports its roles will listen on.
listening := map[string]bool{}
for _, p := range strings.Fields(facts["listening"]) {
listening[p] = true
}
var busy []string
for _, p := range need.Ports {
if listening[strconv.Itoa(p)] {
busy = append(busy, strconv.Itoa(p))
}
}
if len(busy) > 0 {
// A warning: a re-run finds its own services listening.
f("ports", Warn, "already listening: "+strings.Join(busy, ", "),
"fine if it is llmgw's own from an earlier run; otherwise stop what holds them")
} else if len(need.Ports) > 0 {
f("ports", OK, "free", "")
}
if facts["internet"] == "yes" {
f("internet", OK, "reaches the package mirrors", "")
} else {
f("internet", Fail, "cannot reach deb.debian.org", "packages and releases are downloaded; allow outbound HTTP/HTTPS or set a proxy")
}
return fs
}
func evaluateReach(m Machine, out string) []Finding {
var no []string
for _, line := range strings.Split(out, "\n") {
parts := strings.Fields(line)
if len(parts) == 3 && parts[0] == "reach" && parts[2] == "no" {
no = append(no, parts[1])
}
}
if len(no) > 0 {
return []Finding{{Machine: m.Name, Check: "network", Level: Fail,
Detail: "cannot reach " + strings.Join(no, ", "),
Fix: "the machines must reach one another; check routes and firewalls"}}
}
return []Finding{{Machine: m.Name, Check: "network", Level: OK, Detail: "reaches every other machine"}}
}
func needOf(roles []Role) Need {
var n Need
seen := map[int]bool{}
for _, r := range roles {
x := Needs[r]
n.CPUs, n.MemoryMB, n.DiskGB = max(n.CPUs, x.CPUs), max(n.MemoryMB, x.MemoryMB), max(n.DiskGB, x.DiskGB)
for _, p := range x.Ports {
if !seen[p] {
seen[p] = true
n.Ports = append(n.Ports, p)
}
}
}
return n
}
func contains(xs []string, x string) bool {
for _, y := range xs {
if y == x {
return true
}
}
return false
}