Files
llmgw/internal/cli/serverinit_cluster_test.go
Ilker ManapandClaude Opus 5.5 6289b01ed6 The cluster's settings live in the store, and every change takes effect
Everything in server.yaml that is not one machine's own is now a versioned
document in the store (cluster_settings), the same on every control plane.
The first start against a store imports the file's cluster keys, with the
baseline guard policy when there is none; after that those keys in a file
are ignored, with a warning.

Each control plane polls the document's version and its own server.yaml's
hash. A change is validated and applied: guard live (Gate.Replace), anything
else by a graceful restart that systemd brings back, standbys first and the
leader 30s later. A version that does not load on a machine is not applied;
at start the newest one that loads is used, so a bad edit cannot keep every
control plane down.

llmgw ctl config show/get/set/unset/edit/history/rollback, the admin API
under /api/v1/settings, and the console's Settings screen, which layers the
store's document over the machine's file, write it. Commands beside the
control plane that read cluster keys (init, node add, server add, backup,
upgrade) read the layered configuration; init --bundle writes offline to
the store once it holds the settings.

A standby that becomes the leader re-reads models, engine choices and
facility holds from the store.

Retires appendGuardIfMissing: the import does it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 15:25:23 +02:00

210 lines
6.7 KiB
Go

package cli
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"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"}, true)
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, true); 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 := appendMirrorBlockFor(in.configPath(), "http://"+mirrorAPI, "# the mirror"); 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)
}
}
func TestWithNoNodeHereTheStarterGoesToTheFirstNodeThatJoins(t *testing.T) {
m := starterModel("http://x/q.gguf", nil, false)
for _, p := range m.Profiles {
if len(p.NodeSelector) > 0 {
t.Errorf("profile %s is pinned to a machine that is not a node: %v", p.Name, p.NodeSelector)
}
}
}
func TestTheMirrorAPIOnlyAnswersItsControlPlane(t *testing.T) {
allowed, err := parseAllow([]string{"10.0.0.140", "10.1.0.0/24"})
if err != nil {
t.Fatal(err)
}
h := onlyFrom(allowed, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
for addr, want := range map[string]int{
"10.0.0.140:5000": 200, "10.1.0.7:5000": 200, "127.0.0.1:5000": 200, "[::1]:5000": 200,
"10.0.0.141:5000": 403, "192.168.1.9:5000": 403,
} {
r := httptest.NewRequest("GET", "/api/v1/health", nil)
r.RemoteAddr = addr
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != want {
t.Errorf("%s: %d, want %d", addr, w.Code, want)
}
}
if _, err := parseAllow([]string{"not-an-address"}); err == nil {
t.Error("nonsense was accepted")
}
}
func TestTheInstalledConfigurationIsHardenedAndLoads(t *testing.T) {
for _, judge := range []bool{false, true} {
body := fmt.Sprintf(serverConfigTemplate, "/etc/llmgw", "/etc/llmgw", "/etc/llmgw",
"/var/lib/llmgw/llmgw.db", "ops@example.com", guardSection(judge))
path := filepath.Join(t.TempDir(), "server.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("judge=%v: %v", judge, err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("judge=%v: %v", judge, err)
}
if len(cfg.Guard.Default) != 1 || cfg.Guard.Default[0] != "baseline" {
t.Errorf("judge=%v: guard.default %v", judge, cfg.Guard.Default)
}
p := cfg.Guard.Policies["baseline"]
if p.Fail != "closed" {
t.Errorf("judge=%v: the baseline fails %q", judge, p.Fail)
}
if (p.Judge != nil) != judge {
t.Errorf("judge=%v: the policy's judge is %+v", judge, p.Judge)
}
if judge && (p.Judge.Model != guardModelName || p.Judge.Input != "block") {
t.Errorf("judge %+v", p.Judge)
}
if cfg.Auth.MaxKeyTTL != 90*24*time.Hour || cfg.Auth.SignupOpen || cfg.DebugPprof || cfg.AdminRemote {
t.Errorf("not hardened: ttl %v signup %v pprof %v remote %v",
cfg.Auth.MaxKeyTTL, cfg.Auth.SignupOpen, cfg.DebugPprof, cfg.AdminRemote)
}
}
}
func TestTheGuardModelIsAValidJudge(t *testing.T) {
m := guardModel("http://10.0.0.5:8081/llama-guard-3-1b/guard/g.gguf")
c, err := catalog.New([]catalog.Model{m})
if err != nil {
t.Fatalf("not a valid catalog entry: %v", err)
}
got, _ := c.Resolve(guardModelName)
if !got.IsGuard() || got.Guard.Format != "llamaguard3" || len(got.Guard.Categories) != 13 || got.Scaling.Min != 2 {
t.Errorf("%+v", got)
}
for _, p := range got.Profiles {
if p.GPUClass != "" {
t.Errorf("the judge takes a card: %+v", p)
}
}
}
func TestInitSaysAtOnceWhenAModelFitsNowhere(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/replicas":
w.Write([]byte(`[]`))
case "/api/v1/explain-placement":
w.Write([]byte(`{"model":"qwen","rejections":[{"profile":"cpu","node":"n1","reason":"has 3 GB of memory with 1 free, needs 2"}]}`))
default:
http.NotFound(w, r)
}
}))
defer api.Close()
dir := t.TempDir()
token := filepath.Join(dir, "token")
os.WriteFile(token, []byte(strings.Repeat("t", 40)), 0o600)
os.WriteFile(filepath.Join(dir, "server.yaml"),
[]byte("admin_listen: "+strings.TrimPrefix(api.URL, "http://")+"\nadmin_token_file: "+token+"\n"), 0o600)
defer func(g time.Duration) { unplacedGrace = g }(unplacedGrace)
unplacedGrace = 0
in := &serverInit{etcDir: dir, out: io.Discard}
start := time.Now()
err := in.waitServing(t.Context(), "qwen")
if err == nil || !strings.Contains(err.Error(), "needs 2") || !strings.Contains(err.Error(), "explain-placement") {
t.Fatalf("%v", err)
}
if time.Since(start) > 10*time.Second {
t.Errorf("took %s to say so", time.Since(start))
}
}