Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c22c3a309b | ||
|
|
42fa602247 |
No files matched your search
@@ -19,6 +19,20 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
||||
APIs change between minors without a deprecation period.
|
||||
|
||||
## v0.9.2 — 2026-09-28
|
||||
|
||||
### A new control plane serves a model by itself
|
||||
|
||||
`llmgw server init` now also runs a model mirror on the same machine, makes the
|
||||
machine a node of its own control plane, and fetches Qwen2.5 0.5B into the
|
||||
mirror and serves it there — on the machine's NVIDIA GPU when `nvidia-smi` finds
|
||||
one (the node installs the CUDA build of llama.cpp), on CPU otherwise, with a
|
||||
CPU profile as the fallback. An NVIDIA card on the PCI bus with no working
|
||||
driver is reported. `--no-mirror`, `--no-node` and `--no-model` leave these out,
|
||||
and every step is skipped on a rerun when it is already done.
|
||||
|
||||
Not yet run end to end on a real machine.
|
||||
|
||||
## v0.9.1 — 2026-09-28
|
||||
|
||||
### The mirror page is one line per model
|
||||
|
||||
@@ -104,6 +104,17 @@ That is the whole step. In order, it:
|
||||
and promoted to administrator — the password is generated and **printed
|
||||
once**
|
||||
6. installs the systemd unit, starts it, and waits until `/health` answers
|
||||
7. runs a model mirror beside it (`/srv/models`, bytes on `:8081`, its
|
||||
management API on loopback) and points the control plane at it
|
||||
8. makes this machine a node of its own control plane — on its GPUs when
|
||||
`nvidia-smi` finds any, on CPU otherwise — and waits for it to install its
|
||||
engine
|
||||
9. fetches Qwen2.5 0.5B into the mirror and serves it on this node, so the
|
||||
first thing anyone opens `/chat` to find is a model that answers
|
||||
|
||||
`--no-mirror`, `--no-node` and `--no-model` leave the last three out. An NVIDIA
|
||||
card with no working driver is reported rather than silently ignored; install
|
||||
the driver and run init again to serve on it.
|
||||
|
||||
Add `--postgres` to keep accounts and keys in PostgreSQL rather than SQLite:
|
||||
init installs it and sets it up, and on a machine already running on SQLite it
|
||||
|
||||
@@ -69,6 +69,10 @@ type serverInit struct {
|
||||
// postgres keeps the store in a PostgreSQL on this machine, installed if
|
||||
// need be, moving whatever SQLite already holds.
|
||||
postgres bool
|
||||
// noMirror, noNode and noModel turn off the parts of a first install that
|
||||
// make it serve something: a mirror, this machine as a node, and a small
|
||||
// model on it.
|
||||
noMirror, noNode, noModel bool
|
||||
// pgSetup prepares PostgreSQL and returns the store address; a seam for
|
||||
// tests. Nil does it for real.
|
||||
pgSetup func(context.Context) (string, error)
|
||||
@@ -95,6 +99,11 @@ func newServerInitCmd() *cobra.Command {
|
||||
"control plane's certificate for every address this machine has, a\n" +
|
||||
"configuration, an administrator account, and the systemd unit — then\n" +
|
||||
"starts it and waits until it answers.\n\n" +
|
||||
"Then it makes the machine serve something: a model mirror beside the\n" +
|
||||
"control plane, this machine as a node of it (on its GPUs when nvidia-smi\n" +
|
||||
"finds any, on CPU otherwise), and " + starterName + " fetched into the\n" +
|
||||
"mirror and served on this node. --no-mirror, --no-node and --no-model\n" +
|
||||
"leave those out.\n\n" +
|
||||
"With --postgres the store is a PostgreSQL database on this machine:\n" +
|
||||
"installed if there is none, with its own role and a generated password\n" +
|
||||
"kept in /etc/llmgw/secrets. An existing SQLite store is copied into it,\n" +
|
||||
@@ -121,6 +130,9 @@ func newServerInitCmd() *cobra.Command {
|
||||
"an address agents dial besides the ones found on this machine; repeatable")
|
||||
f.StringVar(&in.admin, "admin", "", "email of the first administrator; a password is generated and printed once")
|
||||
f.StringVar(&in.pkiDir, "pki", in.pkiDir, "directory holding the certificate authority")
|
||||
f.BoolVar(&in.noMirror, "no-mirror", false, "do not run a model mirror on this machine")
|
||||
f.BoolVar(&in.noNode, "no-node", false, "do not make this machine a node (and serve no model on it)")
|
||||
f.BoolVar(&in.noModel, "no-model", false, "do not fetch and serve the starter model ("+starterName+")")
|
||||
f.BoolVar(&in.postgres, "postgres", false,
|
||||
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
|
||||
return cmd
|
||||
@@ -146,9 +158,33 @@ func (in *serverInit) run(ctx context.Context) error {
|
||||
if in.unitPath == "" {
|
||||
return nil
|
||||
}
|
||||
hasSystemd := true
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
hasSystemd = false
|
||||
}
|
||||
// Before the service starts, because the mirror goes into server.yaml.
|
||||
if hasSystemd && !in.noMirror {
|
||||
if err := in.ensureMirror(ctx); err != nil {
|
||||
return fmt.Errorf("mirror: %w", err)
|
||||
}
|
||||
}
|
||||
if err := in.ensureService(ctx); err != nil {
|
||||
return fmt.Errorf("service: %w", err)
|
||||
}
|
||||
if hasSystemd && !in.noNode {
|
||||
if _, err := in.ensureNode(ctx); err != nil {
|
||||
return fmt.Errorf("node: %w", err)
|
||||
}
|
||||
switch {
|
||||
case in.noModel:
|
||||
case in.noMirror:
|
||||
fmt.Fprintln(in.out, " skip no starter model without the mirror")
|
||||
default:
|
||||
if err := in.ensureStarter(ctx); err != nil {
|
||||
return fmt.Errorf("starter model: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
in.nextSteps()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/sourcecheck"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
)
|
||||
|
||||
// A control plane on its own serves nothing: it needs a node, weights, and a
|
||||
// model, and the install guide made each of those a separate afternoon. init
|
||||
// makes the machine all three — a mirror, a node, and a small model served on
|
||||
// it — so the first thing anyone sees is an answer. Each is skipped when it is
|
||||
// already there and can be turned off.
|
||||
|
||||
const (
|
||||
mirrorDir = "/srv/models"
|
||||
mirrorAPI = "127.0.0.1:8082"
|
||||
mirrorUnitPath = "/etc/systemd/system/llmgw-mirror.service"
|
||||
agentUnitPath = "/etc/systemd/system/llmgw-agent.service"
|
||||
|
||||
// The starter: small enough to download in a minute and run on any CPU,
|
||||
// real enough to answer a question. Q4_K_M is about 400 MB.
|
||||
starterName = "qwen2.5-0.5b-instruct"
|
||||
starterAlias = "qwen-0.5b"
|
||||
starterSource = "hf:Qwen/Qwen2.5-0.5B-Instruct-GGUF/qwen2.5-0.5b-instruct-q4_k_m.gguf"
|
||||
starterVersion = "starter"
|
||||
|
||||
// localLabel marks the control plane's own node, so the starter model is
|
||||
// served where it was asked for and does not wander to the first GPU node
|
||||
// that joins later.
|
||||
localLabelKey = "llmgw.control-plane"
|
||||
localLabelValue = "true"
|
||||
)
|
||||
|
||||
// agentUnit is deploy/systemd/llmgw-agent.service; a test holds them together.
|
||||
const agentUnit = `[Unit]
|
||||
Description=llmgw node agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
|
||||
# Root, because the agent launches engines and needs device access. Engines
|
||||
# outlive a restart of this unit and are adopted again on the next start, so
|
||||
# KillMode must not take the whole process group with it.
|
||||
KillMode=process
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
StateDirectory=llmgw-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
|
||||
// reachableAddr is the address other machines use for this one: the first
|
||||
// name given with --host, else the first address it has, else its hostname.
|
||||
// It is in the server certificate either way.
|
||||
func (in *serverInit) reachableAddr() string {
|
||||
if len(in.hosts) > 0 {
|
||||
return in.hosts[0]
|
||||
}
|
||||
if ips := localAddrs(); len(ips) > 0 {
|
||||
return ips[0].String()
|
||||
}
|
||||
name, _ := os.Hostname()
|
||||
return name
|
||||
}
|
||||
|
||||
func (in *serverInit) systemctl(ctx context.Context, args ...string) error {
|
||||
out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeUnit installs a unit when it differs from what is there, keeping
|
||||
// somebody's edited copy beside it, and says whether it changed.
|
||||
func (in *serverInit) writeUnit(path, body string) (bool, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil && string(b) == body {
|
||||
return false, nil
|
||||
}
|
||||
if err == nil {
|
||||
_ = os.WriteFile(path+".bak", b, 0o644)
|
||||
}
|
||||
return true, os.WriteFile(path, []byte(body), 0o644)
|
||||
}
|
||||
|
||||
// --- the mirror ---
|
||||
|
||||
func (in *serverInit) mirrorUnit() string {
|
||||
base := "http://" + net.JoinHostPort(in.reachableAddr(), "8081")
|
||||
return `[Unit]
|
||||
Description=llmgw model mirror
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
# Written by 'llmgw server init'. Bytes on :8081 for every node; the
|
||||
# management API on loopback only, for the control plane beside it.
|
||||
ExecStart=/usr/local/bin/llmgw mirror serve --dir ` + mirrorDir + ` --listen :8081 --api-listen ` + mirrorAPI + ` --base ` + base + `
|
||||
User=llmgw
|
||||
Group=llmgw
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=` + mirrorDir + `
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
}
|
||||
|
||||
// ensureMirror runs a mirror on this machine and points the control plane at
|
||||
// it. Its management API is on loopback, which is why it needs no certificate:
|
||||
// nothing but this machine can reach it.
|
||||
func (in *serverInit) ensureMirror(ctx context.Context) error {
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Mirror.API != "" && cfg.Mirror.API != "http://"+mirrorAPI {
|
||||
in.kept("mirror at %s, as server.yaml says", cfg.Mirror.API)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.chown(mirrorDir, true); err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := in.writeUnit(mirrorUnitPath, in.mirrorUnit())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.systemctl(ctx, "daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.systemctl(ctx, "enable", "--now", "llmgw-mirror"); err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if err := in.systemctl(ctx, "restart", "llmgw-mirror"); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("model mirror in %s, serving on :8081", mirrorDir)
|
||||
} else {
|
||||
in.kept("model mirror in %s", mirrorDir)
|
||||
}
|
||||
|
||||
if cfg.Mirror.API == "" {
|
||||
if err := appendMirrorBlock(in.configPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("server.yaml points at the mirror")
|
||||
}
|
||||
|
||||
m, err := mirror.NewClient("http://"+mirrorAPI, tlsconf.Config{}, 5*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return waitFor(ctx, 30*time.Second, "the mirror to answer", func() (bool, error) {
|
||||
return m.Health(ctx) == nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// appendMirrorBlock adds the mirror to server.yaml without touching the rest,
|
||||
// keeping the file as it was beside it.
|
||||
func appendMirrorBlock(path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backup := fmt.Sprintf("%s.bak-%s", path, time.Now().Format("20060102-150405"))
|
||||
if err := os.WriteFile(backup, b, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
block := "\n# The model mirror on this machine, added by 'llmgw server init'. Its\n" +
|
||||
"# management API is on loopback, so it needs no certificate.\n" +
|
||||
"mirror:\n api: http://" + mirrorAPI + "\n"
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(b, []byte(block)...), info.Mode().Perm())
|
||||
}
|
||||
|
||||
// --- this machine as a node ---
|
||||
|
||||
// gpuClasses asks nvidia-smi what cards this machine has. None is an answer,
|
||||
// not an error: the node then serves on CPU.
|
||||
func gpuClasses(ctx context.Context) ([]string, int) {
|
||||
gpus, err := discovery.DiscoverGPUsSMI(ctx, "")
|
||||
if err != nil || len(gpus) == 0 {
|
||||
return nil, 0
|
||||
}
|
||||
return discovery.Inventory{GPUs: gpus}.Classes(), len(gpus)
|
||||
}
|
||||
|
||||
// ensureNode makes this machine an agent of its own control plane.
|
||||
func (in *serverInit) ensureNode(ctx context.Context) (string, error) {
|
||||
nodeID, _ := os.Hostname()
|
||||
if nodeID == "" {
|
||||
return "", errors.New("this machine has no hostname to use as its node id")
|
||||
}
|
||||
|
||||
if classes, n := gpuClasses(ctx); n > 0 {
|
||||
in.kept("%d NVIDIA GPU(s) here: %s — the node will use them", n, strings.Join(classes, ", "))
|
||||
} else if hasNVIDIADevice() {
|
||||
fmt.Fprintf(in.out, " !! an NVIDIA card is on the PCI bus but nvidia-smi does not answer: "+
|
||||
"install the driver and run init again to serve on it; until then this node is CPU only\n")
|
||||
} else {
|
||||
in.kept("no NVIDIA GPU here; the node serves on CPU")
|
||||
}
|
||||
|
||||
certPath := filepath.Join(in.etcDir, "agent.pem")
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
in.kept("agent certificate for %s", nodeID)
|
||||
} else {
|
||||
if _, err := os.Stat(filepath.Join(in.pkiDir, "ca-key.pem")); err != nil {
|
||||
return "", fmt.Errorf("the authority is not on this machine, so init cannot give it a node " +
|
||||
"identity; onboard it from where the authority is, or pass --no-node")
|
||||
}
|
||||
ca, err := loadCA(in.pkiDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
leaf, err := ca.IssueClient(nodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := leaf.Write(in.etcDir, "agent"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("agent certificate for %s", nodeID)
|
||||
}
|
||||
|
||||
agentConf := filepath.Join(in.etcDir, "agent.yaml")
|
||||
if _, err := os.Stat(agentConf); err == nil {
|
||||
in.kept("%s (never overwritten)", agentConf)
|
||||
} else {
|
||||
server := "https://" + net.JoinHostPort(in.reachableAddr(), "7443")
|
||||
body := fmt.Sprintf(`# This machine as a node of its own control plane, written by 'llmgw server init'.
|
||||
server: %q
|
||||
node_id: %q
|
||||
tls:
|
||||
ca: %s/ca.pem
|
||||
cert: %s/agent.pem
|
||||
key: %s/agent-key.pem
|
||||
labels:
|
||||
%s: %q
|
||||
`, server, nodeID, in.etcDir, in.etcDir, in.etcDir, localLabelKey, localLabelValue)
|
||||
if err := os.WriteFile(agentConf, []byte(body), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("%s", agentConf)
|
||||
}
|
||||
|
||||
changed, err := in.writeUnit(agentUnitPath, agentUnit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := in.systemctl(ctx, "daemon-reload"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := in.systemctl(ctx, "enable", "--now", "llmgw-agent"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if changed {
|
||||
_ = in.systemctl(ctx, "restart", "llmgw-agent")
|
||||
in.done("llmgw-agent started")
|
||||
}
|
||||
|
||||
// Registered is quick. Having an engine is not: the node downloads and
|
||||
// checks its own llama.cpp, the CUDA build when it has a GPU, and a new
|
||||
// machine is often busy with its own package updates first.
|
||||
client := in.adminAPI()
|
||||
last := ""
|
||||
err = waitFor(ctx, 20*time.Minute, "this node to be ready with an engine", func() (bool, error) {
|
||||
var nodes []adminapi.NodeView
|
||||
if err := client.get(ctx, "/api/v1/nodes", &nodes); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if n.ID != nodeID {
|
||||
continue
|
||||
}
|
||||
if n.State == "ready" && slices.Contains(n.Engines, "llamacpp") {
|
||||
return true, nil
|
||||
}
|
||||
for _, p := range n.Provision {
|
||||
if line := fmt.Sprintf("%s %s: %s %s", p.Engine, p.Version, p.State, p.Detail); line != last {
|
||||
fmt.Fprintf(in.out, " .. %s\n", strings.TrimSpace(line))
|
||||
last = line
|
||||
}
|
||||
if p.State == "failed" || p.State == "unsupported" {
|
||||
return false, fmt.Errorf("installing %s on this node: %s", p.Engine, p.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.kept("node %s is ready", nodeID)
|
||||
return nodeID, nil
|
||||
}
|
||||
|
||||
// hasNVIDIADevice reports an NVIDIA card on the PCI bus, driver or not, so a
|
||||
// machine whose GPU is invisible for want of a driver says so instead of
|
||||
// quietly serving on CPU.
|
||||
func hasNVIDIADevice() bool {
|
||||
vendors, _ := filepath.Glob("/sys/bus/pci/devices/*/vendor")
|
||||
for _, v := range vendors {
|
||||
b, err := os.ReadFile(v)
|
||||
if err == nil && strings.TrimSpace(string(b)) == "0x10de" {
|
||||
class, _ := os.ReadFile(filepath.Join(filepath.Dir(v), "class"))
|
||||
// 0x03xxxx is a display or 3D controller; NVIDIA also makes bridges.
|
||||
if strings.HasPrefix(strings.TrimSpace(string(class)), "0x03") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- the starter model ---
|
||||
|
||||
// starterModel is the catalog entry: a GPU profile first when there is a card,
|
||||
// and a CPU profile always, both pinned to this machine.
|
||||
func starterModel(source string, gpuClasses []string) catalog.Model {
|
||||
pin := map[string]string{localLabelKey: localLabelValue}
|
||||
var profiles []catalog.Profile
|
||||
for _, class := range gpuClasses {
|
||||
profiles = append(profiles, catalog.Profile{
|
||||
Name: "gpu-" + class, Engine: "llamacpp", GPUClass: class,
|
||||
Priority: 200, MinMemoryGB: 2, NodeSelector: pin,
|
||||
Args: []string{"--ctx-size", "32768"},
|
||||
})
|
||||
}
|
||||
profiles = append(profiles, catalog.Profile{
|
||||
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: 2, NodeSelector: pin,
|
||||
Args: []string{"--ctx-size", "16384"},
|
||||
})
|
||||
return catalog.Model{
|
||||
Name: starterName,
|
||||
Aliases: []string{starterAlias},
|
||||
Description: "A small model init put here so there is something to try; fast, not clever.",
|
||||
Source: source,
|
||||
RoutingProfile: "chat",
|
||||
ContextLength: 4096,
|
||||
MaxOutput: 1024,
|
||||
Scaling: catalog.Scaling{Min: 1, Max: 1},
|
||||
Profiles: profiles,
|
||||
}
|
||||
}
|
||||
|
||||
// ensureStarter fetches the starter model into the mirror and serves it here.
|
||||
func (in *serverInit) ensureStarter(ctx context.Context) error {
|
||||
client := in.adminAPI()
|
||||
var models []adminapi.ModelView
|
||||
if err := client.get(ctx, "/api/v1/models", &models); err != nil {
|
||||
return fmt.Errorf("reading the catalog: %w", err)
|
||||
}
|
||||
for _, m := range models {
|
||||
if m.Name == starterName {
|
||||
in.kept("%s in the catalog", starterName)
|
||||
return in.waitForStarter(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
upstream, ok := sourcecheck.HTTPSource(starterSource)
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot turn %s into a download", starterSource)
|
||||
}
|
||||
m, err := mirror.NewClient("http://"+mirrorAPI, tlsconf.Config{}, 15*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := m.Status(ctx, starterName, starterVersion)
|
||||
var apiErr *mirror.APIError
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.As(err, &apiErr) && apiErr.Status == 404:
|
||||
if st, err = m.Fetch(ctx, mirror.FetchRequest{
|
||||
Source: upstream, Model: starterName, Version: starterVersion,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asking the mirror for %s: %w", starterName, err)
|
||||
}
|
||||
fmt.Fprintf(in.out, " .. downloading %s into the mirror\n", starterName)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
|
||||
lastPct := -10
|
||||
err = waitFor(ctx, 30*time.Minute, starterName+" to download", func() (bool, error) {
|
||||
cur, err := m.Status(ctx, starterName, starterVersion)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
st = cur
|
||||
switch cur.Status.State {
|
||||
case mirror.Present:
|
||||
return true, nil
|
||||
case mirror.Failed:
|
||||
return false, fmt.Errorf("the mirror could not download %s: %s", starterName, cur.Status.Err)
|
||||
}
|
||||
if pct, ok := cur.Status.Percent(); ok && pct >= lastPct+10 {
|
||||
fmt.Fprintf(in.out, " .. %d%%\n", pct)
|
||||
lastPct = pct
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("%s in the mirror", starterName)
|
||||
|
||||
classes, _ := gpuClasses(ctx)
|
||||
body, err := json.Marshal(starterModel(st.URL, classes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.post(ctx, "/api/v1/models", body, &map[string]string{}); err != nil {
|
||||
return fmt.Errorf("adding %s to the catalog: %w", starterName, err)
|
||||
}
|
||||
where := "on CPU"
|
||||
if len(classes) > 0 {
|
||||
where = "on " + strings.Join(classes, ", ") + ", or CPU if the card is busy"
|
||||
}
|
||||
in.done("%s in the catalog, served here %s", starterName, where)
|
||||
return in.waitForStarter(ctx)
|
||||
}
|
||||
|
||||
func (in *serverInit) waitForStarter(ctx context.Context) error {
|
||||
client := in.adminAPI()
|
||||
last := ""
|
||||
return waitFor(ctx, 10*time.Minute, starterName+" to start", func() (bool, error) {
|
||||
var reps []adminapi.ReplicaView
|
||||
if err := client.get(ctx, "/api/v1/replicas?model="+starterName, &reps); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
for _, r := range reps {
|
||||
if r.State == "ready" {
|
||||
in.kept("%s is serving (profile %s)", starterName, r.Profile)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
for _, r := range reps {
|
||||
line := r.State
|
||||
if r.Err != "" {
|
||||
line += ": " + r.Err
|
||||
}
|
||||
if line != last {
|
||||
fmt.Fprintf(in.out, " .. %s %s\n", starterName, line)
|
||||
last = line
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (in *serverInit) adminAPI() *ctlClient {
|
||||
addr := "127.0.0.1:9090"
|
||||
if cfg, err := config.Load(in.configPath()); err == nil && cfg.AdminListen != "" {
|
||||
addr = cfg.AdminListen
|
||||
}
|
||||
return newCtlClient(addr)
|
||||
}
|
||||
|
||||
// waitFor polls until done says so, fails, or the time runs out.
|
||||
func waitFor(ctx context.Context, limit time.Duration, what string, done func() (bool, error)) error {
|
||||
deadline := time.Now().Add(limit)
|
||||
for {
|
||||
ok, err := done()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("gave up waiting for %s after %s; run init again to keep waiting", what, limit)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
)
|
||||
|
||||
func TestTheInitAgentUnitIsTheShippedUnit(t *testing.T) {
|
||||
b, err := os.ReadFile("../../deploy/systemd/llmgw-agent.service")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != agentUnit {
|
||||
t.Error("deploy/systemd/llmgw-agent.service and the unit 'server init' writes have drifted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStarterUsesTheGPUAndFallsBackToCPUOnThisMachine(t *testing.T) {
|
||||
m := starterModel("http://10.0.0.5:8081/qwen2.5-0.5b-instruct/starter/q.gguf", []string{"4090-24g"})
|
||||
c, err := catalog.New([]catalog.Model{m})
|
||||
if err != nil {
|
||||
t.Fatalf("the starter is not a valid catalog entry: %v", err)
|
||||
}
|
||||
got, _ := c.Resolve(starterName)
|
||||
if len(got.Profiles) != 2 {
|
||||
t.Fatalf("profiles = %+v", got.Profiles)
|
||||
}
|
||||
gpu, cpu := got.Profiles[0], got.Profiles[1]
|
||||
if gpu.GPUClass != "4090-24g" || gpu.Priority <= cpu.Priority {
|
||||
t.Errorf("the GPU profile should come first: %+v", gpu)
|
||||
}
|
||||
if cpu.GPUClass != "" || cpu.Engine != "llamacpp" {
|
||||
t.Errorf("the fallback should be llama.cpp on CPU: %+v", cpu)
|
||||
}
|
||||
for _, p := range got.Profiles {
|
||||
if p.NodeSelector[localLabelKey] != localLabelValue {
|
||||
t.Errorf("profile %s is not pinned to this machine", p.Name)
|
||||
}
|
||||
}
|
||||
if got.Scaling.Min != 1 {
|
||||
t.Error("the starter should be running, not waiting to be asked for")
|
||||
}
|
||||
|
||||
if m := starterModel("http://x/q.gguf", nil); len(m.Profiles) != 1 || m.Profiles[0].Name != "cpu" {
|
||||
t.Errorf("without a GPU only the CPU profile: %+v", m.Profiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMirrorBlockIsAddedAndReads(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
if err := in.run(t.Context()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if err := appendMirrorBlock(in.configPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Mirror.API != "http://"+mirrorAPI {
|
||||
t.Errorf("mirror.api = %q", cfg.Mirror.API)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
backups, _ := filepath.Glob(in.configPath() + ".bak-*")
|
||||
if len(backups) != 1 {
|
||||
t.Errorf("backups = %v", backups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMirrorUnitAdvertisesAnAddressNodesCanReach(t *testing.T) {
|
||||
in := &serverInit{hosts: []string{"10.0.0.140"}}
|
||||
u := in.mirrorUnit()
|
||||
if !strings.Contains(u, "--base http://10.0.0.140:8081") {
|
||||
t.Errorf("base: %s", u)
|
||||
}
|
||||
// The management port can delete models; nothing but this machine may
|
||||
// reach it, which is what lets it go without a certificate.
|
||||
if !strings.Contains(u, "--api-listen 127.0.0.1:8082") {
|
||||
t.Errorf("the management API is not on loopback: %s", u)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user