In a terminal and without --admin, server init shows a short form: the administrator's email, the other names nodes will use (with the addresses it found), SQLite or PostgreSQL, and the mirror, node and starter model. Its own lines then fill in as a list — read from the writer init already uses, so nothing in init changed — with downloads updating in place and the password in a box until the end. The whole run, password included, is printed again when the screen closes, so it is in the terminal's history. The last screen says how to add a node and to back up the authority's key. node add takes several addresses and adds them in turn, with a summary. Suggested fabrics carry what the adapters report them to be, so a name given for one node is used for the next on the same fabric without asking. Tried on empty VMs: init from the form through to the starter model serving, and two nodes added to that cluster in one command. The first attempt at the form lost its keystrokes to the terminal colour query in a pseudo-terminal that never answers it; a real terminal does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
441 lines
15 KiB
Go
441 lines
15 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
|
)
|
|
|
|
func newNodeCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "node",
|
|
Short: "Add machines to the cluster",
|
|
}
|
|
cmd.AddCommand(newNodeAddCmd(), newNodeRemoveCmd())
|
|
return cmd
|
|
}
|
|
|
|
func newNodeRemoveCmd() *cobra.Command {
|
|
var admin, serverCfg string
|
|
cmd := &cobra.Command{
|
|
Use: "remove <node>",
|
|
Short: "Forget a node that is gone for good",
|
|
Long: `For a machine retired, or rebuilt under another name, which would otherwise
|
|
stay listed as lost until the control plane restarts.
|
|
|
|
Only a node that is not there is removed: stop its agent first, and wait for
|
|
it to be lost, a minute later. Its certificate stays valid, since the
|
|
authority keeps no revocation list: a machine that still has it can join
|
|
again, which is how a node rebuilt under the same name comes back.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
na := nodeAdd{admin: admin, serverCfg: serverCfg}
|
|
var out map[string]string
|
|
if err := na.adminAPI().delete(cmd.Context(), "/api/v1/nodes/"+args[0], &out); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s removed\n", args[0])
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&admin, "admin", "", "the control plane's admin API (default: from server.yaml, else 127.0.0.1:9090)")
|
|
cmd.Flags().StringVar(&serverCfg, "config", "/etc/llmgw/server.yaml", "the control plane's configuration")
|
|
return cmd
|
|
}
|
|
|
|
// nodeAdd is what `llmgw node add` was asked to do.
|
|
type nodeAdd struct {
|
|
opts onboard.Options
|
|
fabrics []string
|
|
servers []string
|
|
admin string
|
|
waitAll bool
|
|
noWait bool
|
|
plain bool
|
|
serverCfg string
|
|
// named are the fabric names given in this run, by what the adapters
|
|
// report the fabric to be.
|
|
named map[string]string
|
|
}
|
|
|
|
func newNodeAddCmd() *cobra.Command {
|
|
var na nodeAdd
|
|
cmd := &cobra.Command{
|
|
Use: "add [user@]address...",
|
|
Short: "Make a machine a node: check it, give it an identity, start its agent",
|
|
Long: `Run on the machine holding the certificate authority — the control plane,
|
|
after 'llmgw server init' — as root. It logs in to the machine with ssh,
|
|
as you would by hand, and:
|
|
|
|
1. installs llmgw there: the release for its architecture, checksum verified
|
|
2. runs 'llmgw doctor' there, and stops if it finds a problem
|
|
3. issues the node's certificate and installs it
|
|
4. writes agent.yaml and the systemd unit, and starts the agent
|
|
5. waits for the node to join and install llama.cpp
|
|
|
|
The node's id is its hostname, and the address its agent dials is this
|
|
machine's on the route to it, unless --node or --server say otherwise.
|
|
Nothing else needs giving, except --fabric for InfiniBand or RoCE: which
|
|
fabric an adapter is on has a name only you can choose, and it must be the
|
|
same on every node attached to it. Doctor suggests one when it sees adapters.
|
|
|
|
Several addresses add several nodes, one after another; a fabric named for
|
|
one is recognised on the next and not asked again.
|
|
|
|
The user needs sudo on the machine without a password. Run it again at any
|
|
time: the node gets a fresh certificate and configuration, and its running
|
|
engines are kept.`,
|
|
Example: ` sudo llmgw node add 10.0.0.21
|
|
sudo llmgw node add ubuntu@10.0.0.21 ubuntu@10.0.0.22 ubuntu@10.0.0.23
|
|
sudo llmgw node add ubuntu@gpu01 --fabric ib-a:mlx5_0,mlx5_1,mlx5_2,mlx5_3`,
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return na.run(cmd.Context(), args, cmd.OutOrStdout())
|
|
},
|
|
}
|
|
f := cmd.Flags()
|
|
f.StringVar(&na.opts.Node, "node", "", "node id (default: the machine's hostname)")
|
|
f.StringVar(&na.opts.User, "ssh-user", "", "user to log in as (default: the one before @ in the address, else whoever ran sudo, else you)")
|
|
f.StringArrayVar(&na.servers, "server", nil, "control plane address the agent dials, https://host:7443; repeatable for failover (default: this machine)")
|
|
f.StringArrayVar(&na.fabrics, "fabric", nil, "InfiniBand or RoCE fabric and its adapters, name:mlx5_0,mlx5_1; repeatable")
|
|
f.StringVar(&na.opts.MgmtAddr, "mgmt-addr", "", "address engines are reached at (default: found by the agent)")
|
|
f.StringVar(&na.opts.MgmtIface, "mgmt-if", "", "interface NCCL bootstraps over (default: the one holding that address)")
|
|
f.StringVar(&na.opts.LlamaCppBin, "llamacpp-bin", "", "a llama-server you installed, instead of the one the agent installs")
|
|
f.StringVar(&na.opts.VLLMBin, "vllm-bin", "", "a vllm you installed; also set engines.vllm: \"\" in server.yaml")
|
|
f.StringVar(&na.opts.SGLangPython, "sglang-python", "", "python of an SGLang virtualenv you installed")
|
|
f.StringVar(&na.opts.NvidiaSMI, "nvidia-smi", "", "where nvidia-smi is, when it is not on PATH")
|
|
f.StringVar(&na.opts.IBRoot, "ib-root", "", "the sysfs InfiniBand directory, when not /sys/class/infiniband")
|
|
f.StringVar(&na.opts.PKI, "pki", defaultPKIDir, "directory holding the certificate authority")
|
|
f.StringVar(&na.opts.Releases, "releases", "https://gitea.mynodes.xyz/ilker/llmgw/releases/download", "where release files are downloaded from")
|
|
f.BoolVar(&na.opts.IgnoreChecks, "ignore-checks", false, "add the node even when doctor finds a problem")
|
|
f.StringVar(&na.admin, "admin", "", "the control plane's admin API, to watch the node join (default: from server.yaml, else 127.0.0.1:9090)")
|
|
f.StringVar(&na.serverCfg, "config", "/etc/llmgw/server.yaml", "the control plane's configuration, for the admin API's address")
|
|
f.BoolVar(&na.waitAll, "wait", false, "wait for every engine, vLLM included, not only llama.cpp")
|
|
f.BoolVar(&na.noWait, "no-wait", false, "do not wait for the node to join")
|
|
f.BoolVar(&na.plain, "plain", false, "plain output, even in a terminal")
|
|
return cmd
|
|
}
|
|
|
|
// prepare works out what every node added in this run shares.
|
|
func (na *nodeAdd) prepare() error {
|
|
o := &na.opts
|
|
for _, s := range na.fabrics {
|
|
fab, err := onboard.ParseFabric(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(fab.HCAs) == 0 {
|
|
return fmt.Errorf("--fabric %s names no adapters: give them as %s:mlx5_0,mlx5_1", s, fab.Name)
|
|
}
|
|
o.Fabrics = append(o.Fabrics, fab)
|
|
}
|
|
for _, s := range na.servers {
|
|
for _, one := range strings.Split(s, ",") {
|
|
if one = strings.TrimSpace(one); one != "" {
|
|
o.Servers = append(o.Servers, one)
|
|
}
|
|
}
|
|
}
|
|
o.Version = version.Version
|
|
if o.Binary == "" {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o.Binary = exe
|
|
}
|
|
onboard.AgentUnit = agentUnit
|
|
na.named = map[string]string{}
|
|
return nil
|
|
}
|
|
|
|
// target is the options for one address, [user@]host.
|
|
func (na *nodeAdd) target(addr string) onboard.Options {
|
|
o := na.opts
|
|
o.Fabrics = slices.Clone(na.opts.Fabrics)
|
|
o.Addr = addr
|
|
if user, host, ok := strings.Cut(addr, "@"); ok {
|
|
o.User, o.Addr = user, host
|
|
}
|
|
if o.User == "" {
|
|
// Under sudo, the person who ran it — their account is usually the
|
|
// one that exists on the nodes too. root's own keys still do the
|
|
// logging in, since that is who is running ssh.
|
|
o.User = os.Getenv("SUDO_USER")
|
|
}
|
|
return o
|
|
}
|
|
|
|
// rememberFabrics wraps the question of which fabric a node's adapters are
|
|
// on, so a name given for one node is used for the next whose adapters
|
|
// report the same fabric, without asking again. ask nil is a script, which
|
|
// is asked nothing and gets only what an earlier answer said.
|
|
func (na *nodeAdd) rememberFabrics(ask func([]onboard.Fabric) []onboard.Fabric) func([]onboard.Fabric) []onboard.Fabric {
|
|
return func(s []onboard.Fabric) []onboard.Fabric {
|
|
var known []onboard.Fabric
|
|
all := true
|
|
for i, f := range s {
|
|
if name, ok := na.named[f.Key]; ok && f.Key != "" && f.Key != "unknown" {
|
|
f.Name = name
|
|
s[i].Name = name
|
|
known = append(known, f)
|
|
} else {
|
|
all = false
|
|
}
|
|
}
|
|
if all || ask == nil {
|
|
return known
|
|
}
|
|
got := ask(s)
|
|
for _, f := range got {
|
|
if f.Key != "" && f.Key != "unknown" {
|
|
na.named[f.Key] = f.Name
|
|
}
|
|
}
|
|
return got
|
|
}
|
|
}
|
|
|
|
func (na *nodeAdd) run(ctx context.Context, addrs []string, out io.Writer) error {
|
|
if len(addrs) > 1 && na.opts.Node != "" {
|
|
return errors.New("--node names one node; give one address with it")
|
|
}
|
|
if err := na.prepare(); err != nil {
|
|
return err
|
|
}
|
|
tui := !na.plain && isTerminal(out)
|
|
type outcome struct {
|
|
addr, node string
|
|
err error
|
|
}
|
|
var results []outcome
|
|
for i, addr := range addrs {
|
|
o := na.target(addr)
|
|
title := "llmgw · adding " + o.Addr
|
|
if len(addrs) > 1 {
|
|
title += fmt.Sprintf(" (%d of %d)", i+1, len(addrs))
|
|
}
|
|
var node string
|
|
var err error
|
|
if tui {
|
|
node, err = na.runTUI(ctx, o, title)
|
|
} else {
|
|
node, err = na.runPlain(ctx, o, title, out)
|
|
}
|
|
results = append(results, outcome{addr, node, err})
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
}
|
|
if len(addrs) == 1 {
|
|
return results[0].err
|
|
}
|
|
fmt.Fprintln(out)
|
|
failed := 0
|
|
for _, r := range results {
|
|
if r.err != nil {
|
|
failed++
|
|
fmt.Fprintf(out, " FAIL %-20s %s\n", r.addr, r.err)
|
|
} else {
|
|
fmt.Fprintf(out, " ok %-20s %s\n", r.addr, r.node)
|
|
}
|
|
}
|
|
if failed > 0 {
|
|
return fmt.Errorf("%d of %d not added; running the same command again finishes them and leaves the rest as they are", failed, len(addrs))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (na *nodeAdd) runPlain(ctx context.Context, o onboard.Options, title string, out io.Writer) (string, error) {
|
|
p := &plainProgress{out: out}
|
|
fmt.Fprintf(out, "%s\n", title)
|
|
o.AskFabrics = na.rememberFabrics(nil)
|
|
res, err := onboard.New(o, p.event).Run(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if na.noWait {
|
|
fmt.Fprintf(out, "\n%s is added. 'llmgw ctl nodes' shows it once it joins.\n\n", res.Node)
|
|
return res.Node, nil
|
|
}
|
|
if err := na.watch(ctx, res.Node, p.event); err != nil {
|
|
return res.Node, err
|
|
}
|
|
fmt.Fprintf(out, "\n%s is in the cluster.\n\n", res.Node)
|
|
return res.Node, nil
|
|
}
|
|
|
|
// adminAPI is the control plane's admin API, from its configuration when
|
|
// this machine has it.
|
|
func (na *nodeAdd) adminAPI() *ctlClient {
|
|
addr := na.admin
|
|
if addr == "" {
|
|
addr = "127.0.0.1:9090"
|
|
if cfg, err := config.Load(na.serverCfg); err == nil && cfg.AdminListen != "" {
|
|
addr = cfg.AdminListen
|
|
if strings.HasPrefix(addr, ":") {
|
|
addr = "127.0.0.1" + addr
|
|
}
|
|
}
|
|
}
|
|
return newCtlClient(addr)
|
|
}
|
|
|
|
// errNoAdmin is the watch giving up because the admin API is not here, which
|
|
// is not a failure of onboarding.
|
|
var errNoAdmin = errors.New("no admin API")
|
|
|
|
// watch follows the node from the control plane: joining, then installing
|
|
// its engines. llama.cpp is waited for, since without it the node serves
|
|
// nothing; vLLM takes minutes more and is waited for only with --wait.
|
|
func (na *nodeAdd) watch(ctx context.Context, node string, emit func(onboard.Event)) error {
|
|
client := na.adminAPI()
|
|
var nodes []adminapi.NodeView
|
|
if err := client.get(ctx, "/api/v1/nodes", &nodes); err != nil {
|
|
emit(onboard.Event{Step: onboard.StepJoin, Level: onboard.Warn,
|
|
Detail: "cannot reach the control plane's admin API here, so the node's joining is not followed",
|
|
Fix: "llmgw ctl nodes on the control plane shows it"})
|
|
return nil
|
|
}
|
|
emit(onboard.Event{Step: onboard.StepJoin, Level: onboard.Running, Detail: "waiting for " + node + " to join"})
|
|
var view adminapi.NodeView
|
|
started := time.Now()
|
|
err := waitFor(ctx, 2*time.Minute, node+" to join", func() (bool, error) {
|
|
if err := client.get(ctx, "/api/v1/nodes", &nodes); err != nil {
|
|
return false, nil
|
|
}
|
|
for _, n := range nodes {
|
|
if n.ID == node && heardSince(n, started) {
|
|
view = n
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("%s did not join: its agent's log says why (journalctl -u llmgw-agent on the node); a certificate whose name differs from the node id is refused", node)
|
|
}
|
|
emit(onboard.Event{Step: onboard.StepJoin, Level: onboard.Done, Detail: node + " joined: " + describeNode(view)})
|
|
|
|
emit(onboard.Event{Step: onboard.StepEngines, Level: onboard.Running, Detail: "installing engines"})
|
|
seen := map[string]string{}
|
|
limit := 20 * time.Minute
|
|
if na.waitAll {
|
|
limit = time.Hour
|
|
}
|
|
err = waitFor(ctx, limit, "the engines on "+node, func() (bool, error) {
|
|
if err := client.get(ctx, "/api/v1/nodes", &nodes); err != nil {
|
|
return false, nil
|
|
}
|
|
for _, n := range nodes {
|
|
if n.ID != node {
|
|
continue
|
|
}
|
|
finished := true
|
|
for _, p := range n.Provision {
|
|
line := p.State + " " + p.Detail
|
|
if seen[p.Engine] != line {
|
|
seen[p.Engine] = line
|
|
emit(engineEvent(p.Engine, p.Version, p.State, p.Detail))
|
|
}
|
|
switch p.State {
|
|
case "installing":
|
|
if p.Engine == "llamacpp" || na.waitAll {
|
|
finished = false
|
|
}
|
|
case "failed":
|
|
if p.Engine == "llamacpp" {
|
|
return false, fmt.Errorf("llama.cpp did not install: %s", p.Detail)
|
|
}
|
|
}
|
|
}
|
|
return finished && len(n.Provision) > 0, nil
|
|
}
|
|
return false, nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg := "engines ready"
|
|
if v, ok := seen["vllm"]; ok && strings.HasPrefix(v, "installing") && !na.waitAll {
|
|
msg = "llama.cpp ready; vLLM is still installing, which takes a few minutes more (watch /admin, or add --wait)"
|
|
}
|
|
emit(onboard.Event{Step: onboard.StepEngines, Level: onboard.Done, Detail: msg})
|
|
return nil
|
|
}
|
|
|
|
// heardSince reports whether the node has been heard from since t, by the
|
|
// agent just started. A node added again is already listed, from its agent
|
|
// before the restart, and a cordoned one is not "ready": neither says whether
|
|
// the new agent connected.
|
|
func heardSince(n adminapi.NodeView, t time.Time) bool {
|
|
switch n.State {
|
|
case "unreachable", "lost", "unknown":
|
|
return false
|
|
}
|
|
ago, err := time.ParseDuration(n.LastSeenAgo)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// A second's margin: the age is rounded to one.
|
|
return ago+time.Second < time.Since(t)
|
|
}
|
|
|
|
func engineEvent(engine, version, state, detail string) onboard.Event {
|
|
level := onboard.Info
|
|
switch state {
|
|
case "failed", "unsupported":
|
|
level = onboard.Warn
|
|
}
|
|
return onboard.Event{Step: onboard.StepEngines, Level: level,
|
|
Detail: fmt.Sprintf("%s %s: %s%s", engine, version, state, prefixed(": ", detail))}
|
|
}
|
|
|
|
func prefixed(p, s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
return p + s
|
|
}
|
|
|
|
func describeNode(n adminapi.NodeView) string {
|
|
parts := []string{}
|
|
if n.GPUTotal > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d GPU(s) %s", n.GPUTotal, strings.Join(n.Classes, ", ")))
|
|
} else {
|
|
parts = append(parts, "no GPU")
|
|
}
|
|
if len(n.Fabrics) > 0 {
|
|
parts = append(parts, "fabrics "+strings.Join(n.Fabrics, ", "))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// plainProgress prints events as lines, for a log or a pipe.
|
|
type plainProgress struct {
|
|
out io.Writer
|
|
}
|
|
|
|
func (p *plainProgress) event(e onboard.Event) {
|
|
mark := map[string]string{
|
|
onboard.Running: " .. ", onboard.Done: " ok ", onboard.Info: " ",
|
|
onboard.Warn: " !! ", onboard.Fail: " FAIL",
|
|
}[e.Level]
|
|
fmt.Fprintf(p.out, "%s %-13s %s\n", mark, e.Step, e.Detail)
|
|
if e.Fix != "" {
|
|
fmt.Fprintf(p.out, " %-13s → %s\n", "", e.Fix)
|
|
}
|
|
}
|