Files
llmgw/internal/onboard/onboard.go
T
Ilker ManapandClaude Opus 5.5 ad04a78343 The mirror's address is one the nodes reach, and node add checks it
server init and mirror init took the first non-loopback address for the
mirror's --base, Docker's 172.17.0.1 on a machine with Docker; nodes asked
their own bridge for every model and were refused. primaryAddr takes the
default route's source, else the first address on an interface that is not
a container's, bridge's or overlay's. node add has the node reach the
mirror's files during the checks and fails, naming the address and the fix,
when it cannot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:36:09 +02:00

859 lines
29 KiB
Go

// Package onboard makes a machine a node of the cluster, over SSH, from the
// machine holding the certificate authority.
//
// It is what deploy/bootstrap/onboard-node.sh did, moved into the binary so
// there is nothing else to fetch, and extended by what a script could not
// easily do: it asks the machine its own name and architecture instead of
// being told, runs llmgw doctor there before changing anything, and suggests
// the fabric its adapters are on.
//
// It reports each step as an Event, so a plain terminal and a full-screen one
// show the same thing from the same code.
package onboard
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/doctor"
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// Fabric is one InfiniBand or RoCE fabric the node is on, and its adapters.
type Fabric struct {
Name string
HCAs []string
// Key is what the adapters report the fabric to be — its subnet manager,
// or a RoCE network — on a suggestion, so a name given for one node can
// be given again to the next on the same fabric.
Key string
}
// ParseFabric reads "name:hca1,hca2", or "name" alone.
func ParseFabric(s string) (Fabric, error) {
name, hcas, _ := strings.Cut(strings.TrimSpace(s), ":")
if name == "" {
return Fabric{}, fmt.Errorf("fabric %q has no name: give it as name:mlx5_0,mlx5_1", s)
}
f := Fabric{Name: name}
for _, h := range strings.Split(hcas, ",") {
if h = strings.TrimSpace(h); h != "" {
f.HCAs = append(f.HCAs, h)
}
}
return f, nil
}
// Options say which machine, and what to tell its agent.
type Options struct {
// NoNodeExporter leaves Prometheus' node exporter off the machine.
NoNodeExporter bool
// NoFirewall leaves the machine's firewall as it is. Otherwise its llmgw
// ports — FirewallPorts — answer only the networks it reaches the
// control planes on, and FirewallAllow.
NoFirewall bool
// MirrorBase is where the mirror serves model files, http://host:8081:
// the node is asked to reach it before it is added, since a node that
// cannot fetches no model.
MirrorBase string
FirewallPorts []firewall.Port
FirewallAllow []netip.Prefix
// Addr is how to reach the machine over SSH, and User who to log in as.
Addr, User string
// Node is its id in the cluster. Empty means its hostname, which is what
// it should be: the node id, the hostname and the certificate's name
// being one string saves an afternoon each time they would differ.
Node string
// Servers are the control planes the agent dials. Empty means this
// machine, at the address it has on the route to the node.
Servers []string
Fabrics []Fabric
MgmtAddr, MgmtIface string
NvidiaSMI, IBRoot string
LlamaCppBin string
VLLMBin string
SGLangPython string
// PKI is the authority's directory; EtcDir is where this machine's own
// server certificate is, to check the chosen address is named in it.
PKI, EtcDir string
// Binary is the llmgw to install when the node cannot download the
// release, and Version what it is.
Binary, Version string
// Releases is where release files are downloaded from.
Releases string
// ReleaseClient fetches them; nil is a default client.
ReleaseClient *http.Client
// IgnoreChecks goes on when doctor finds a problem.
IgnoreChecks bool
// DisableEngines are engines this node never installs or advertises,
// written into its agent.yaml, and not checked by doctor: vLLM on a node
// of cards it does not run on, meant for llama.cpp alone. Narrower than
// IgnoreChecks, which also passes over everything else.
DisableEngines []string
// VLLMVersion is the vLLM the control plane has nodes install, for
// doctor to check against; "none" when it has them install none, empty
// for doctor's own default.
VLLMVersion string
// SSHWait is how long an unreachable machine is waited for.
SSHWait time.Duration
// AskFabrics, when set, is asked which fabrics to declare for a node
// given none whose adapters are up, with the adapters grouped by the
// fabric they report. It returns the fabrics to use, none to go on
// without. Nil leaves it at a warning, as a script needs.
AskFabrics func(suggested []Fabric) []Fabric
// AskContinue, when set, is asked whether to go on after doctor found
// problems. Nil stops unless IgnoreChecks.
AskContinue func(fails int) bool
}
// Levels of an Event.
const (
Running = "running"
Done = "done"
Info = "info"
Warn = "warn"
Fail = "fail"
)
// Event is one thing that happened: a step starting or ending, or something
// worth saying along the way.
type Event struct {
Step string
Level string
Detail string
// Fix is what to do about a warning or a failure, when there is a known
// answer.
Fix string
}
// The steps, in order.
const (
StepConnect = "connect"
StepCheck = "check"
StepBinary = "binary"
StepCert = "certificate"
StepConfig = "configuration"
StepStart = "start"
// StepJoin and StepEngines are watched from the control plane once the
// agent runs; the caller does that, since it has the admin API.
StepJoin = "join"
StepEngines = "engines"
)
// Steps lists them for a display that shows what is still to come.
var Steps = []string{StepConnect, StepBinary, StepCheck, StepCert, StepConfig, StepStart, StepJoin, StepEngines}
// Result is what onboarding learned and did.
type Result struct {
Node string
Arch string
Hostname string
Servers []string
Facts doctor.Facts
Findings []doctor.Finding
// Suggested is the fabrics the node's active adapters are on, for a node
// given none: grouped by what the adapters report, named for the operator
// to choose.
Suggested []Fabric
// EngineProblems are engines named by path that will not run there.
EngineProblems []string
}
// Runner onboards one machine.
type Runner struct {
Opts Options
Remote Remote
// Emit hears every event. Nil discards them.
Emit func(Event)
ca *tlsconf.CA
}
// New makes a Runner that reaches the machine with the system's ssh.
func New(o Options, emit func(Event)) *Runner {
return &Runner{Opts: o, Remote: &SSH{Addr: o.Addr, User: o.User}, Emit: emit}
}
func (r *Runner) emit(step, level, detail, fix string) {
if r.Emit != nil {
r.Emit(Event{Step: step, Level: level, Detail: detail, Fix: fix})
}
}
// ErrChecks is returned when doctor found problems and IgnoreChecks is off.
var ErrChecks = errors.New("the node has problems to fix first")
// Run does every step, stopping at the first that fails.
func (r *Runner) Run(ctx context.Context) (*Result, error) {
res := &Result{}
// The authority first: without it nothing can be finished, and finding
// out after the node has been changed leaves it half done.
pki := r.Opts.PKI
ca, err := tlsconf.LoadCA(filepath.Join(pki, "ca.pem"), filepath.Join(pki, "ca-key.pem"))
if err != nil {
err = fmt.Errorf("cannot read the certificate authority in %s: %v. Run this on the machine that holds it, as root (sudo), or give --pki", pki, err)
r.emit(StepConnect, Fail, err.Error(), "")
return res, err
}
r.ca = ca
for _, step := range []struct {
name string
fn func(context.Context, *Result) error
}{
{StepConnect, r.connect},
{StepBinary, r.binary},
{StepCheck, r.check},
{StepCert, r.certificate},
{StepConfig, r.configure},
{StepStart, r.start},
} {
if err := step.fn(ctx, res); err != nil {
r.emit(step.name, Fail, err.Error(), "")
return res, err
}
}
return res, nil
}
// --- connect ---
var nodeName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$`)
func (r *Runner) connect(ctx context.Context, res *Result) error {
r.emit(StepConnect, Running, "connecting to "+r.target(), "")
wait := r.Opts.SSHWait
if wait == 0 {
wait = 3 * time.Minute
}
// Waited for rather than failed on: a machine a minute old restarts sshd
// after cloud-init regenerates its host keys, and refuses connections for
// a few seconds after answering.
deadline := time.Now().Add(wait)
var last error
for {
out, err := r.Remote.Run(ctx, "hostname && uname -m", nil)
if err == nil {
lines := strings.Fields(string(out))
if len(lines) >= 2 {
res.Hostname, res.Arch = lines[0], lines[1]
break
}
err = fmt.Errorf("unexpected answer: %q", out)
}
last = err
if time.Now().After(deadline) {
return fmt.Errorf("cannot log in to %s: %v. Check that `ssh %s true` works from this machine, as the user running this, without a password",
r.target(), last, r.target())
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
}
}
if _, err := r.Remote.Run(ctx, "sudo -n true", nil); err != nil {
return fmt.Errorf("%s can log in but cannot use sudo without a password, which installing the agent needs", r.target())
}
switch res.Arch {
case "x86_64", "amd64":
res.Arch = "amd64"
case "aarch64", "arm64":
res.Arch = "arm64"
default:
return fmt.Errorf("%s is %s; llmgw is built for x86-64 and ARM64", r.target(), res.Arch)
}
res.Node = r.Opts.Node
if res.Node == "" {
res.Node = res.Hostname
}
if !nodeName.MatchString(res.Node) {
return fmt.Errorf("%q cannot be a node id: letters, digits, dots, dashes and underscores; give one with --node", res.Node)
}
servers, err := r.servers()
if err != nil {
return err
}
res.Servers = servers
r.emit(StepConnect, Done, fmt.Sprintf("%s (%s), joining as %s, agent will dial %s",
res.Hostname, res.Arch, res.Node, strings.Join(servers, ", ")), "")
return nil
}
func (r *Runner) target() string {
if r.Opts.User == "" {
return r.Opts.Addr
}
return r.Opts.User + "@" + r.Opts.Addr
}
// servers are the given control planes, or this machine at the address it
// uses to reach the node — which is the one the node can reach it at, and
// is named in the server certificate when init issued it here.
func (r *Runner) servers() ([]string, error) {
if len(r.Opts.Servers) > 0 {
return r.Opts.Servers, nil
}
host := r.Opts.Addr
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
c, err := net.Dial("udp", net.JoinHostPort(host, "7443"))
if err != nil {
return nil, fmt.Errorf("cannot tell which address the node would reach this machine at: %v; give it with --server", err)
}
local := c.LocalAddr().(*net.UDPAddr).IP.String()
_ = c.Close()
if names, err := certNames(filepath.Join(r.etcDir(), "server.pem")); err == nil && !slices.Contains(names, local) {
return nil, fmt.Errorf("the node would reach this machine at %s, which the server certificate does not name (%s): "+
"run `sudo llmgw server init --host %s` to add it, or give the address with --server",
local, strings.Join(names, ", "), local)
}
return []string{"https://" + net.JoinHostPort(local, "7443")}, nil
}
func (r *Runner) etcDir() string {
if r.Opts.EtcDir != "" {
return r.Opts.EtcDir
}
return "/etc/llmgw"
}
func certNames(path string) ([]string, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
blk, _ := pem.Decode(b)
if blk == nil {
return nil, errors.New("no certificate in " + path)
}
c, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return nil, err
}
names := slices.Clone(c.DNSNames)
for _, ip := range c.IPAddresses {
names = append(names, ip.String())
}
return names, nil
}
// --- binary ---
// releaseTag is a version CI published: v0.10.4, or a release candidate of
// it, v0.10.4-rc.2. Anything else — a build with commits past its tag — has no
// release to download.
var releaseTag = release.Tag
func (r *Runner) binary(ctx context.Context, res *Result) error {
v := r.Opts.Version
if releaseTag.MatchString(v) {
// Fetched here, for the node's architecture, and checked here —
// signature and checksum, by the same code that upgrades this
// machine — then handed to the node. The node checking a release
// itself could only compare it with a checksum from the same
// server, which proves nothing about who put both there.
r.emit(StepBinary, Running, "downloading llmgw "+v+" for "+res.Arch+" and checking it", "")
b, err := release.Fetch(ctx, r.Opts.ReleaseClient, r.Opts.Releases, v, "linux", goArch(res.Arch))
if err == nil {
if err := r.installBinary(ctx, b); err != nil {
return err
}
what := "checksum verified"
if release.TrustedKey() != "" {
what = "signature and checksum verified"
}
r.emit(StepBinary, Done, "llmgw "+v+" from the release, "+what, "")
return nil
}
r.emit(StepBinary, Warn, "the release could not be fetched and checked here: "+err.Error(), "")
}
// A development build has no release to fetch, and a node that cannot
// reach the releases gets this machine's copy — when it can run it.
if res.Arch != hostArch() {
return fmt.Errorf("the node is %s and this machine %s, so its binary cannot be copied: "+
"let the node reach %s, or install llmgw there yourself", res.Arch, hostArch(), r.Opts.Releases)
}
r.emit(StepBinary, Running, "copying this machine's llmgw ("+orUnknown(v)+")", "")
b, err := os.ReadFile(r.Opts.Binary)
if err != nil {
return fmt.Errorf("reading %s: %w", r.Opts.Binary, err)
}
if err := r.installBinary(ctx, b); err != nil {
return err
}
r.emit(StepBinary, Done, "llmgw "+orUnknown(v)+", copied from this machine", "")
return nil
}
// installBinary puts bytes at /usr/local/bin/llmgw on the node.
func (r *Runner) installBinary(ctx context.Context, b []byte) error {
if out, err := r.Remote.Run(ctx, `set -e; t=$(mktemp); cat > "$t"; sudo install -m 0755 "$t" /usr/local/bin/llmgw; rm -f "$t"`, b); err != nil {
return fmt.Errorf("installing the binary: %v: %s", err, lastLine(out))
}
return nil
}
// goArch is Go's name for what uname -m says.
func goArch(uname string) string {
switch uname {
case "x86_64", "amd64":
return "amd64"
case "aarch64", "arm64":
return "arm64"
}
return uname
}
// --- check ---
func (r *Runner) check(ctx context.Context, res *Result) error {
r.emit(StepCheck, Running, "running llmgw doctor on the node", "")
args := "sudo llmgw doctor --json"
if r.Opts.NvidiaSMI != "" {
args += " --nvidia-smi " + shellQuote(r.Opts.NvidiaSMI)
}
if r.Opts.IBRoot != "" {
args += " --ib-root " + shellQuote(r.Opts.IBRoot)
}
// The fabrics given, checked as the ones they will be; any adapter they
// do not name is checked as one nothing uses.
for _, fab := range r.Opts.Fabrics {
args += " --fabric " + shellQuote(fab.Name+":"+strings.Join(fab.HCAs, ","))
}
switch {
case slices.Contains(r.Opts.DisableEngines, "vllm"):
args += " --vllm none"
r.emit(StepCheck, Info, "vLLM is disabled on this node: it joins with llama.cpp alone", "")
case r.Opts.VLLMVersion != "":
args += " --vllm " + shellQuote(r.Opts.VLLMVersion)
}
out, err := r.Remote.Run(ctx, args, nil)
var rep struct {
Findings []doctor.Finding `json:"findings"`
Facts doctor.Facts `json:"facts"`
}
if jerr := json.Unmarshal(out, &rep); jerr != nil {
if err == nil {
err = jerr
}
return fmt.Errorf("llmgw doctor did not run on the node: %v: %s", err, lastLine(out))
}
if !r.Opts.NoFirewall && len(r.Opts.FirewallPorts) > 0 {
// A missing firewall is what this run is about to write.
rep.Findings = slices.DeleteFunc(rep.Findings, func(f doctor.Finding) bool { return f.Area == "firewall" })
}
if f, ok := r.mirrorReach(ctx); ok {
rep.Findings = append(rep.Findings, f)
}
res.Facts, res.Findings = rep.Facts, rep.Findings
fails := 0
for _, f := range rep.Findings {
switch f.Level {
case doctor.Fail:
fails++
r.emit(StepCheck, Fail, f.Area+": "+f.Message, f.Fix)
case doctor.Warn:
r.emit(StepCheck, Warn, f.Area+": "+f.Message, f.Fix)
case doctor.OK:
r.emit(StepCheck, Info, f.Area+": "+f.Message, "")
}
}
res.Suggested = suggestFabrics(rep.Facts.HCAs)
if len(r.Opts.Fabrics) == 0 && len(res.Suggested) > 0 && r.Opts.AskFabrics != nil {
r.Opts.Fabrics = r.Opts.AskFabrics(res.Suggested)
for _, f := range r.Opts.Fabrics {
r.emit(StepCheck, Info, "fabric "+f.Name+": "+strings.Join(f.HCAs, ", "), "")
}
}
if len(r.Opts.Fabrics) == 0 && len(res.Suggested) > 0 {
var flags []string
for _, f := range res.Suggested {
flags = append(flags, "--fabric "+f.Name+":"+strings.Join(f.HCAs, ","))
}
r.emit(StepCheck, Warn, "InfiniBand or RoCE adapters are up, but no fabric was given: this node would never be given a replica spanning nodes",
"add "+strings.Join(flags, " ")+", naming each fabric the same on every node attached to it")
}
goOn := r.Opts.IgnoreChecks
if fails > 0 && !goOn && r.Opts.AskContinue != nil && r.Opts.AskContinue(fails) {
r.emit(StepCheck, Warn, fmt.Sprintf("going on despite %d problem(s)", fails), "")
goOn = true
}
if fails > 0 && !goOn {
return fmt.Errorf("%w: doctor found %d; fix them and run this again, or add --ignore-checks", ErrChecks, fails)
}
r.emit(StepCheck, Done, fmt.Sprintf("%d problem(s), %d warning(s)", fails, countLevel(rep.Findings, doctor.Warn)), "")
return nil
}
// mirrorReach asks the node to reach the mirror's files: a base address the
// node cannot reach (another machine's interface, Docker's bridge, a port
// closed to it) otherwise shows up as every model failing to download.
func (r *Runner) mirrorReach(ctx context.Context) (doctor.Finding, bool) {
if r.Opts.MirrorBase == "" {
return doctor.Finding{}, false
}
u, err := url.Parse(r.Opts.MirrorBase)
if err != nil || u.Hostname() == "" {
return doctor.Finding{}, false
}
port := u.Port()
if port == "" {
port = "80"
}
base := strings.TrimRight(r.Opts.MirrorBase, "/") + "/"
out, _ := r.Remote.Run(ctx, `if command -v curl >/dev/null 2>&1; then
curl -s -o /dev/null -w '%{http_code}' --max-time 5 `+shellQuote(base)+` || true
else
timeout 5 bash -c `+shellQuote("</dev/tcp/"+u.Hostname()+"/"+port)+` 2>/dev/null && echo 200 || echo 000
fi`, nil)
code := strings.TrimSpace(lastLine(out))
if code != "" && code != "000" {
return doctor.Finding{Level: doctor.OK, Area: "mirror", Message: "reaches the mirror's files at " + base}, true
}
return doctor.Finding{Level: doctor.Fail, Area: "mirror",
Message: "cannot reach the mirror's files at " + base + ": every model it is given would fail to download",
Fix: "on the mirror's machine, give llmgw-mirror a --base this node reaches (systemctl cat llmgw-mirror), " +
"and let this node through to port " + port}, true
}
// suggestFabrics groups the active adapters by the fabric they report being
// on: the subnet manager they see, or the network of their RoCE address.
// Names are placeholders for the operator: a name is what ties one node's
// adapters to another's, and nothing on one node can know what the others
// were called.
func suggestFabrics(hcas []discovery.HCA) []Fabric {
byKey := map[string][]string{}
var keys []string
for _, h := range hcas {
for _, p := range h.ActivePorts() {
k := p.FabricKey()
if k == "" {
k = "unknown"
}
if _, ok := byKey[k]; !ok {
keys = append(keys, k)
}
if !slices.Contains(byKey[k], h.Name) {
byKey[k] = append(byKey[k], h.Name)
}
}
}
var out []Fabric
for i, k := range keys {
name := "fabric"
if len(keys) > 1 {
name = fmt.Sprintf("fabric%d", i+1)
}
out = append(out, Fabric{Name: name, HCAs: byKey[k], Key: k})
}
return out
}
// --- certificate ---
func (r *Runner) certificate(ctx context.Context, res *Result) error {
r.emit(StepCert, Running, "issuing a certificate for "+res.Node, "")
ca := r.ca
// Issued fresh every time: onboarding again should not depend on finding
// an old key, and the old one keeps working until revoked.
leaf, err := ca.IssueClient(res.Node)
if err != nil {
return err
}
for _, f := range []struct {
path, mode string
body []byte
}{
{"/etc/llmgw/ca.pem", "0644", ca.PEM},
{"/etc/llmgw/agent.pem", "0644", leaf.CertPEM},
{"/etc/llmgw/agent-key.pem", "0600", leaf.KeyPEM},
} {
if err := r.put(ctx, f.path, f.mode, f.body); err != nil {
return err
}
}
// The agent trusts the cluster's root through its own configuration only.
// Not system-wide: that root signs whatever the control plane asks it to,
// and every TLS client on the machine would then take it for any site.
// A node onboarded by an older version had it added; take it back out.
_, _ = r.Remote.Run(ctx, `if [ -f /usr/local/share/ca-certificates/llmgw-ca.crt ]; then
sudo rm -f /usr/local/share/ca-certificates/llmgw-ca.crt && sudo update-ca-certificates --fresh >/dev/null 2>&1
fi
if [ -f /etc/pki/ca-trust/source/anchors/llmgw-ca.pem ]; then
sudo rm -f /etc/pki/ca-trust/source/anchors/llmgw-ca.pem && sudo update-ca-trust >/dev/null 2>&1
fi; true`, nil)
r.emit(StepCert, Done, "certificate for "+res.Node+" installed", "")
return nil
}
// put writes a file on the node as root, never readable by others on the
// way when its mode says it should not be.
func (r *Runner) put(ctx context.Context, path, mode string, body []byte) error {
script := fmt.Sprintf(`set -e
sudo install -d -m 0755 %[1]s
sudo sh -c 'umask 077; cat > %[2]s.new'
sudo chmod %[3]s %[2]s.new
sudo chown root:root %[2]s.new
sudo mv -f %[2]s.new %[2]s`, filepath.Dir(path), path, mode)
if out, err := r.Remote.Run(ctx, script, body); err != nil {
return fmt.Errorf("writing %s: %v: %s", path, err, lastLine(out))
}
return nil
}
// --- configuration ---
// AgentUnit is the agent's systemd unit, the same one server init writes.
var AgentUnit string
func (r *Runner) configure(ctx context.Context, res *Result) error {
r.emit(StepConfig, Running, "writing /etc/llmgw/agent.yaml", "")
if err := r.put(ctx, "/etc/llmgw/agent.yaml", "0644", []byte(AgentYAML(r.Opts, res))); err != nil {
return err
}
if AgentUnit == "" {
return errors.New("onboard: AgentUnit is not set")
}
if err := r.put(ctx, "/etc/systemd/system/llmgw-agent.service", "0644", []byte(AgentUnit)); err != nil {
return err
}
res.EngineProblems = r.checkEngines(ctx)
for _, p := range res.EngineProblems {
r.emit(StepConfig, Warn, p, "")
}
r.emit(StepConfig, Done, "agent.yaml and the systemd unit", "")
return nil
}
// AgentYAML is the node's configuration: only what the node cannot find out
// for itself. Its address, GPUs, adapters and engines it discovers, and every
// line written here is one somebody has to keep true by hand.
func AgentYAML(o Options, res *Result) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s, written by 'llmgw node add'.\n", res.Node)
if len(res.Servers) == 1 {
fmt.Fprintf(&b, "server: %q\n", res.Servers[0])
} else {
// Every control plane it may fail over to; a standby refuses it and
// it moves on to the next.
b.WriteString("servers:\n")
for _, s := range res.Servers {
fmt.Fprintf(&b, " - %q\n", s)
}
}
b.WriteString("\ntls:\n ca: /etc/llmgw/ca.pem\n cert: /etc/llmgw/agent.pem\n key: /etc/llmgw/agent-key.pem\n")
// The control plane refuses a node whose id differs from its
// certificate's name; said only when the hostname would say otherwise.
if res.Node != res.Hostname {
fmt.Fprintf(&b, "node_id: %q\n", res.Node)
}
if o.MgmtAddr != "" {
fmt.Fprintf(&b, "mgmt_addr: %q\n", o.MgmtAddr)
}
if o.MgmtIface != "" {
fmt.Fprintf(&b, "mgmt_interface: %q\n", o.MgmtIface)
}
if len(o.Fabrics) > 0 {
// The one thing a node cannot find out: which name its fabric goes
// by on every other node.
b.WriteString("fabrics:\n")
for _, f := range o.Fabrics {
fmt.Fprintf(&b, " - name: %q\n hcas: [%s]\n", f.Name, strings.Join(f.HCAs, ", "))
}
}
if o.NvidiaSMI != "" || o.IBRoot != "" {
b.WriteString("discovery:\n")
if o.NvidiaSMI != "" {
fmt.Fprintf(&b, " nvidia_smi: %q\n", o.NvidiaSMI)
}
if o.IBRoot != "" {
fmt.Fprintf(&b, " ib_root: %q\n", o.IBRoot)
}
}
if o.LlamaCppBin != "" || o.VLLMBin != "" || o.SGLangPython != "" || len(o.DisableEngines) > 0 {
b.WriteString("engines:\n")
if len(o.DisableEngines) > 0 {
fmt.Fprintf(&b, " disabled: [%s]\n", strings.Join(o.DisableEngines, ", "))
}
if o.LlamaCppBin != "" {
fmt.Fprintf(&b, " llamacpp:\n bin: %q\n", o.LlamaCppBin)
}
if o.VLLMBin != "" {
fmt.Fprintf(&b, " vllm:\n bin: %q\n", o.VLLMBin)
}
if o.SGLangPython != "" {
fmt.Fprintf(&b, " sglang:\n python: %q\n", o.SGLangPython)
}
}
return b.String()
}
// checkEngines runs each engine named by path. A wrong one registers as
// healthy and fails every shard placed on it, with the reason three logs
// away: llama-server on node05 was present, executable, and missing libgomp.
func (r *Runner) checkEngines(ctx context.Context) []string {
var problems []string
for _, e := range []struct{ name, path string }{
{"llama.cpp", r.Opts.LlamaCppBin}, {"vLLM", r.Opts.VLLMBin}, {"SGLang", r.Opts.SGLangPython},
} {
if e.path == "" {
continue
}
q := shellQuote(e.path)
if _, err := r.Remote.Run(ctx, "test -x "+q, nil); err != nil {
problems = append(problems, fmt.Sprintf("%s is set to %s, which is not an executable on the node", e.name, e.path))
continue
}
if _, err := r.Remote.Run(ctx, q+" --version >/dev/null 2>&1 || "+q+" --help >/dev/null 2>&1", nil); err != nil {
problems = append(problems, fmt.Sprintf("%s at %s exists but will not run; usually a missing library: ldd %s | grep 'not found'",
e.name, e.path, e.path))
}
}
return problems
}
// --- start ---
func (r *Runner) start(ctx context.Context, res *Result) error {
// The user engines run as, so the agent drops root before starting one:
// in the groups that own the GPU devices.
if out, err := r.Remote.Run(ctx, `set -e
id llmgw-engine >/dev/null 2>&1 || sudo useradd --system --no-create-home --shell /usr/sbin/nologin llmgw-engine
for g in video render; do getent group "$g" >/dev/null && sudo usermod -aG "$g" llmgw-engine; done
true`, nil); err != nil {
r.emit(StepStart, Warn, "the user engines run as could not be made, so they run as root: "+lastLine(out), "")
}
r.emit(StepStart, Running, "starting the agent", "")
if out, err := r.Remote.Run(ctx, `set -e
sudo systemctl daemon-reload
sudo systemctl enable llmgw-agent >/dev/null 2>&1
sudo systemctl restart llmgw-agent
sleep 2
systemctl is-active llmgw-agent`, nil); err != nil {
return fmt.Errorf("the agent did not start: %s; see journalctl -u llmgw-agent on the node", lastLine(out))
}
r.emit(StepStart, Done, "llmgw-agent is running", "")
if !r.Opts.NoNodeExporter {
r.nodeExporter(ctx)
}
if !r.Opts.NoFirewall && len(r.Opts.FirewallPorts) > 0 {
r.firewall(ctx, res)
}
return nil
}
// firewall keeps the node's llmgw ports — its engines, its metrics — to the
// networks it reaches the control planes on. A warning when it cannot: the
// node serves either way, and says how to see why.
func (r *Runner) firewall(ctx context.Context, res *Result) {
var toward []string
for _, s := range res.Servers {
u, err := url.Parse(s)
if err != nil {
continue
}
if _, err := netip.ParseAddr(u.Hostname()); err == nil {
toward = append(toward, u.Hostname())
} else if ips, err := net.LookupIP(u.Hostname()); err == nil && len(ips) > 0 {
toward = append(toward, ips[0].String())
}
}
nets, err := firewall.Networks(ctx, r.Remote, toward)
if err == nil {
nets = append(nets, r.Opts.FirewallAllow...)
_, err = firewall.Install(ctx, r.Remote, firewall.Spec{Ports: r.Opts.FirewallPorts, Allow: nets})
}
if err != nil {
r.emit(StepStart, Warn, "the firewall was not set: "+err.Error()+
"; the node's engine ports answer every network it is on", "")
return
}
var shown []string
for _, n := range nets {
shown = append(shown, n.String())
}
r.emit(StepStart, Info, "llmgw's ports on the node answer "+strings.Join(shown, ", ")+" only ("+firewall.RulesFile+")", "")
}
// nodeExporter installs Prometheus' node exporter from the distribution, for
// what llmgw does not measure: network and disk traffic, filesystems. Best
// effort: a node without it serves as well, so a failure is a warning.
func (r *Runner) nodeExporter(ctx context.Context) {
out, err := r.Remote.Run(ctx, `set -e
if systemctl is-active --quiet prometheus-node-exporter 2>/dev/null || systemctl is-active --quiet node_exporter 2>/dev/null; then
echo already
elif command -v apt-get >/dev/null 2>&1; then
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq prometheus-node-exporter >/dev/null
sudo systemctl enable --now prometheus-node-exporter >/dev/null 2>&1
echo installed
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y -q golang-github-prometheus-node-exporter >/dev/null
sudo systemctl enable --now node_exporter >/dev/null 2>&1
echo installed
else
echo "no apt-get or dnf"; exit 1
fi`, nil)
switch {
case err != nil:
r.emit(StepStart, Warn, "the Prometheus node exporter was not installed: "+lastLine(out)+
"; the node serves without it, and its network and disk are not charted", "")
case strings.Contains(string(out), "already"):
r.emit(StepStart, Info, "the Prometheus node exporter was already running", "")
default:
r.emit(StepStart, Info, "the Prometheus node exporter is installed, on port 9100", "")
}
}
// --- helpers ---
func lastLine(b []byte) string {
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
return strings.TrimSpace(lines[len(lines)-1])
}
func orUnknown(s string) string {
if s == "" {
return "unversioned"
}
return s
}
func countLevel(fs []doctor.Finding, l doctor.Level) int {
n := 0
for _, f := range fs {
if f.Level == l {
n++
}
}
return n
}
// shellQuote quotes a string for a POSIX shell.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}