Files
llmgw/internal/agent/provision/install_test.go
T
Ilker ManapandClaude Opus 5.5 a40fc53e09 Wait for a new machine's own apt, and retry a failed install sooner
Found by building a node from nothing. Its agent tried to install libgomp1
while cloud-init and unattended-upgrades were running apt, and failed with
"Unable to lock directory /var/lib/apt/lists/": DPkg::Lock::Timeout covers
dpkg's lock, not the one `apt-get update` takes on its lists. The node was
correctly kept out of placement and said why, and then sat idle for the ten
minutes until the next pass.

A package command that fails only on a held lock is now retried for up to
ten minutes, reported as "waiting for another apt-get to finish" rather than
as a failure. A pass that does fail is retried after a minute, backing off to
ten.

The weights directory is created when the agent starts rather than by the
first download, so a new node reports its disk from its first heartbeat.

Onboarding takes several control plane addresses, comma separated, so a new
node gets the same failover list as the rest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:14:22 +02:00

333 lines
11 KiB
Go

package provision
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
var ubuntu = discovery.Host{OSID: "ubuntu", OSVersion: "24.04", Arch: "amd64", CPUFlags: []string{"avx2"}}
// release is a fake GitHub: one release, its assets and their digests.
type release struct {
assets map[string][]byte
digests map[string]string
gets int
mu sync.Mutex
}
func tarGz(t *testing.T, files map[string]string, links map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for name, body := range files {
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg})
_, _ = tw.Write([]byte(body))
}
for name, target := range links {
_ = tw.WriteHeader(&tar.Header{Name: name, Linkname: target, Typeflag: tar.TypeSymlink})
}
_ = tw.Close()
_ = gz.Close()
return buf.Bytes()
}
func (r *release) serve(t *testing.T) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
r.mu.Lock()
r.gets++
r.mu.Unlock()
if strings.HasPrefix(req.URL.Path, "/repos/") {
var out struct {
Assets []map[string]string `json:"assets"`
}
for name := range r.assets {
out.Assets = append(out.Assets, map[string]string{"name": name, "digest": r.digests[name]})
}
_ = json.NewEncoder(w).Encode(out)
return
}
body, ok := r.assets[filepath.Base(req.URL.Path)]
if !ok {
http.NotFound(w, req)
return
}
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
return srv
}
func newRelease(assets map[string][]byte) *release {
r := &release{assets: assets, digests: map[string]string{}}
for name, b := range assets {
sum := sha256.Sum256(b)
r.digests[name] = "sha256:" + hex.EncodeToString(sum[:])
}
return r
}
// fakeSystem answers the package tools, ldd and the engine itself.
type fakeSystem struct {
mu sync.Mutex
installed map[string]bool
tools map[string]bool
lddOut string
calls []string
// locked is how many more package commands find the lock held.
locked int
}
var errExit = errors.New("exit 1")
func (f *fakeSystem) run(_ context.Context, _ []string, name string, args ...string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, name+" "+strings.Join(args, " "))
switch {
case name == "sh" && strings.Contains(args[1], "dpkg-query"),
name == "rpm" && args[0] == "-q":
if f.installed[args[len(args)-1]] {
return nil, nil
}
return nil, errExit
case name == "sh" && strings.Contains(args[1], "command -v"):
if f.tools[args[2]] {
return nil, nil
}
return nil, errExit
case name == "apt-get" || name == "dnf":
if f.locked > 0 {
f.locked--
return []byte("E: Unable to lock directory /var/lib/apt/lists/\n"), errExit
}
for i, a := range args {
if a == "install" {
for _, p := range args[i+1:] {
if !strings.HasPrefix(p, "-") {
f.installed[p] = true
}
}
}
}
return nil, nil
case name == "ldd":
return []byte(f.lddOut), nil
case strings.HasSuffix(name, "llama-server"):
return []byte("version: 10991 (abc)\n"), nil
}
return nil, errors.New("unexpected command " + name)
}
func installer(t *testing.T, srv *httptest.Server, sys *fakeSystem, host discovery.Host) (*Installer, *[]Status) {
var seen []Status
var mu sync.Mutex
return &Installer{
Root: t.TempDir(), Host: host, Run: sys.run,
APIBase: srv.URL, DownloadBase: srv.URL, HTTP: srv.Client(),
OnStatus: func(s Status) { mu.Lock(); seen = append(seen, s); mu.Unlock() },
}, &seen
}
func llamaRelease(t *testing.T) *release {
return newRelease(map[string][]byte{
"llama-b1-bin-ubuntu-x64.tar.gz": tarGz(t,
map[string]string{"llama-b1/llama-server": "#!/bin/sh\n", "llama-b1/libllama.so.0": "x"},
map[string]string{"llama-b1/libllama.so": "libllama.so.0"}),
})
}
func TestAnEngineIsInstalledFromNothing(t *testing.T) {
// A node rebuilt from a bare image: no engine, no OpenMP runtime. What it
// ends with has to have come from the publisher, checked, and been run.
rel := llamaRelease(t)
sys := &fakeSystem{installed: map[string]bool{}}
in, seen := installer(t, rel.serve(t), sys, ubuntu)
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
if len(got) != 1 || got[0].State != Ready {
t.Fatalf("not ready: %+v", got)
}
if !sys.installed["libgomp1"] {
t.Error("the OpenMP runtime the build links against was not installed")
}
if !strings.HasPrefix(got[0].Binary, in.Root) {
t.Errorf("binary %s is not the one installed under %s", got[0].Binary, in.Root)
}
if _, err := os.Lstat(filepath.Join(filepath.Dir(got[0].Binary), "libllama.so")); err != nil {
t.Errorf("the library symlink was not unpacked: %v", err)
}
var states []string
for _, s := range *seen {
states = append(states, s.State+": "+s.Detail)
}
if !strings.Contains(strings.Join(states, "\n"), "downloading") {
t.Errorf("a download was not reported while it ran:\n%s", strings.Join(states, "\n"))
}
}
func TestAnInstalledEngineIsCheckedNotFetchedAgain(t *testing.T) {
rel := llamaRelease(t)
sys := &fakeSystem{installed: map[string]bool{}}
in, _ := installer(t, rel.serve(t), sys, ubuntu)
w := []Want{{Engine: "llamacpp", Version: "b1"}}
in.Converge(t.Context(), w)
before := rel.gets
got := in.Converge(t.Context(), w)
if got[0].State != Ready {
t.Fatalf("second pass: %+v", got[0])
}
if rel.gets != before {
t.Errorf("a ready engine was downloaded again (%d requests)", rel.gets-before)
}
}
func TestAnEngineThatCannotLoadALibraryIsNotReady(t *testing.T) {
// Found on a node before: llama-server present, executable, and unable to
// start for want of a library — while the node reported ready.
rel := llamaRelease(t)
sys := &fakeSystem{installed: map[string]bool{"libgomp1": true},
lddOut: "\tlibcuda.so.1 => not found\n"}
in, _ := installer(t, rel.serve(t), sys, ubuntu)
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
if got[0].State != Failed || !strings.Contains(got[0].Detail, "libcuda.so.1") {
t.Fatalf("expected a failure naming the library: %+v", got[0])
}
if _, err := os.Stat(filepath.Join(in.Root, "llamacpp", "b1-cpu", marker)); err == nil {
t.Error("a build that does not run was marked installed")
}
}
func TestADownloadThatDoesNotMatchItsChecksumIsRefused(t *testing.T) {
rel := llamaRelease(t)
for name := range rel.digests {
rel.digests[name] = "sha256:" + strings.Repeat("0", 64)
}
sys := &fakeSystem{installed: map[string]bool{"libgomp1": true}}
in, _ := installer(t, rel.serve(t), sys, ubuntu)
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
if got[0].State != Failed || !strings.Contains(got[0].Detail, "checksum") {
t.Fatalf("a tampered download was not refused: %+v", got[0])
}
}
func TestAnArchiveCannotWriteOutsideItsDirectory(t *testing.T) {
rel := newRelease(map[string][]byte{
"llama-b1-bin-ubuntu-x64.tar.gz": tarGz(t,
map[string]string{"../../escaped": "x", "llama-server": ""}, nil),
})
sys := &fakeSystem{installed: map[string]bool{"libgomp1": true}}
in, _ := installer(t, rel.serve(t), sys, ubuntu)
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
if got[0].State != Failed {
t.Fatalf("an archive escaping its directory was installed: %+v", got[0])
}
}
func TestTheBuildFollowsTheHardware(t *testing.T) {
gpu := ubuntu
gpu.NVIDIADriver = "570.86.15"
b, err := chooseLlamaCpp("b1", gpu)
if err != nil || !strings.HasPrefix(b.Variant, "cuda") || len(b.Assets) != 2 {
t.Errorf("an NVIDIA node got %+v, %v; expected the CUDA build and its runtime", b, err)
}
if _, err := chooseLlamaCpp("b1", discovery.Host{OSID: "ubuntu", Arch: "riscv64"}); err == nil {
t.Error("a build was chosen for an architecture llama.cpp does not publish for")
}
slow := ubuntu
slow.CPUFlags = nil
st := (&Installer{Host: slow}).ready(Status{}, Build{CPUFlags: []string{"avx2"}}, "/x/llama-server", "")
if !strings.Contains(st.Detail, "avx2") {
t.Errorf("a CPU without AVX2 was not called out: %q", st.Detail)
}
}
func TestAnEngineTheAgentCannotInstallSaysSo(t *testing.T) {
in := &Installer{Root: t.TempDir(), Host: ubuntu}
got := in.Converge(t.Context(), []Want{{Engine: "vllm", Version: "0.29.0"}})
if got[0].State != Unsupported {
t.Errorf("got %+v", got[0])
}
}
func TestARedHatFamilyNodeInstallsWithItsOwnPackageManager(t *testing.T) {
// The build is not Ubuntu's in any way that matters; the package that
// carries OpenMP's runtime is named differently, and installed by dnf.
rocky := discovery.Host{OSID: "rocky", OSVersion: "10.0", OSLike: []string{"rhel", "centos", "fedora"},
Arch: "amd64", CPUFlags: []string{"avx2"}}
rel := llamaRelease(t)
sys := &fakeSystem{installed: map[string]bool{}, tools: map[string]bool{"dnf": true}}
in, _ := installer(t, rel.serve(t), sys, rocky)
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
if got[0].State != Ready {
t.Fatalf("not ready: %+v", got[0])
}
if !sys.installed["libgomp"] || sys.installed["libgomp1"] {
t.Errorf("installed %v; expected the rpm name, libgomp", sys.installed)
}
}
func TestASystemTooOldForTheBuildSaysWhatItLacks(t *testing.T) {
// RHEL 9: glibc is new enough, libstdc++ is one GCC release short. ldd
// finds the library and reports the symbol, and "libstdc++ not found"
// would send somebody looking for a package that is installed.
rel := llamaRelease(t)
sys := &fakeSystem{installed: map[string]bool{"libgomp1": true},
lddOut: "\t/lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by /x/libllama.so)\n" +
"\t/lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by /x/llama-server)\n"}
in, _ := installer(t, rel.serve(t), sys, ubuntu)
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
if got[0].State != Failed || !strings.Contains(got[0].Detail, "libstdc++.so.6 is too old (needs GLIBCXX_3.4.30)") {
t.Fatalf("detail %q", got[0].Detail)
}
if strings.Count(got[0].Detail, "GLIBCXX") != 1 {
t.Errorf("the same shortfall was listed more than once: %q", got[0].Detail)
}
}
func TestAPackageManagerBusyWithItsOwnUpdatesIsWaitedFor(t *testing.T) {
// A new node's first minutes: cloud-init and unattended-upgrades hold
// apt's lists lock, which DPkg::Lock::Timeout does not cover.
lockRetry = time.Millisecond
t.Cleanup(func() { lockRetry = 10 * time.Second })
rel := llamaRelease(t)
sys := &fakeSystem{installed: map[string]bool{}, locked: 3}
in, seen := installer(t, rel.serve(t), sys, ubuntu)
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
if got[0].State != Ready {
t.Fatalf("gave up on a lock another apt held: %+v", got[0])
}
waited := false
for _, s := range *seen {
waited = waited || strings.Contains(s.Detail, "waiting for another apt-get")
}
if !waited {
t.Error("the wait was not reported")
}
}