Files
llmgw/internal/cli/serverinit_cluster_test.go
T
Ilker ManapandClaude Opus 5.5 94401e21e6 Setup lets every part be chosen, each with its settings; three machines each run the gateway
The setup screens list the parts of an install: control plane, store, model
mirror, this machine as a node, starter model, pictures and 3D, the judge,
firewall, notifications, limits, on-demand turns and an offline bundle. Each
says what it is or what is missing without it. Space turns a part on or off;
Enter opens its settings in a dialog that explains it. A plan page lists what
is installed and what is left out, and the same install as a command. Every
setting is a server init flag too, written into server.yaml when init writes
it.

With three machines, the two database machines become control planes too,
standing by (server add's steps), with an optional floating address. The
mirror's management API answers all three control planes, so a standby that
comes to lead can still fetch models.

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

220 lines
7.1 KiB
Go

package cli
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
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(starterChoices[0], defaultContext, "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(starterChoices[0], defaultContext, "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(starterChoices[0], defaultContext, "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 && (!slices.Equal(p.Judge.Judges(), []string{guardModelName}) || p.Judge.Input != "block") {
t.Errorf("judge %+v", p.Judge)
}
// A category the judge never names would never count.
if judge {
for _, c := range p.Judge.Categories {
if !slices.Contains(guard.DefaultCategories("qwen3guard"), c) {
t.Errorf("the baseline counts %q, which Qwen3Guard does not name", c)
}
}
}
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/qwen3guard-0.6b/guard/g.gguf", 2)
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 != "qwen3guard" || 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))
}
}