CI / test (push) Failing after 6m44s
A sweep counted every file the nodes were asked to delete as freed as soon as it asked, and a node refused a set's directory as not empty, so tens of gigabytes were reported freed and nothing was. The agent removes a set's directory whole (a link only as a link), and the control plane waits for each node to list its files again, saying what it no longer lists and what it kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
4.1 KiB
Go
129 lines
4.1 KiB
Go
package agent
|
|
|
|
import (
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
|
|
)
|
|
|
|
// agentWithCache is an agent that only knows where its weights live, which is
|
|
// all removeWeights needs.
|
|
func agentWithCache(t *testing.T, dir string) *Agent {
|
|
t.Helper()
|
|
return &Agent{
|
|
cfg: Config{ModelDirs: []string{dir}},
|
|
log: slog.New(slog.DiscardHandler),
|
|
}
|
|
}
|
|
|
|
func write(t *testing.T, path string) string {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte("weights"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func TestTheAgentRefusesToDeleteWhatItDidNotFetch(t *testing.T) {
|
|
// The control plane computes these paths, but the node holds the files. A
|
|
// control plane that is wrong, newer than this agent, or tampered with must
|
|
// not be able to talk it into deleting a model an operator spent a week
|
|
// copying here. So the agent checks again, on its own.
|
|
dir := t.TempDir()
|
|
a := agentWithCache(t, dir)
|
|
|
|
operators := write(t, filepath.Join(dir, "qwen2.5-coder-14b-instruct-q8_0.gguf"))
|
|
fetched := write(t, weights.CachePathFor(dir, "https://models.internal/gone.gguf"))
|
|
|
|
a.removeWeights([]string{operators, fetched})
|
|
|
|
if _, err := os.Stat(operators); err != nil {
|
|
t.Errorf("a file llmgw never fetched was deleted: %v", err)
|
|
}
|
|
if _, err := os.Stat(fetched); !os.IsNotExist(err) {
|
|
t.Errorf("the fetched file was not removed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheAgentRefusesPathsOutsideItsCache(t *testing.T) {
|
|
// Even with a name that looks like one of ours. The cache directory is the
|
|
// boundary, and traversal out of it is the attack this closes.
|
|
dir := t.TempDir()
|
|
elsewhere := t.TempDir()
|
|
a := agentWithCache(t, dir)
|
|
|
|
// A correctly named cache file, in the wrong directory.
|
|
outside := write(t, weights.CachePathFor(elsewhere, "https://models.internal/x.gguf"))
|
|
// And the same thing reached by traversal from inside the cache.
|
|
traversal := filepath.Join(dir, "..", filepath.Base(elsewhere),
|
|
filepath.Base(outside))
|
|
|
|
a.removeWeights([]string{outside, traversal})
|
|
|
|
if _, err := os.Stat(outside); err != nil {
|
|
t.Errorf("a file outside the cache was deleted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemovingAFileThatIsAlreadyGoneIsNotAFailure(t *testing.T) {
|
|
// The expected outcome of a retry. It must not stop the rest of the batch.
|
|
dir := t.TempDir()
|
|
a := agentWithCache(t, dir)
|
|
|
|
missing := weights.CachePathFor(dir, "https://models.internal/never.gguf")
|
|
present := write(t, weights.CachePathFor(dir, "https://models.internal/here.gguf"))
|
|
|
|
a.removeWeights([]string{missing, present})
|
|
|
|
if _, err := os.Stat(present); !os.IsNotExist(err) {
|
|
t.Errorf("a missing file stopped the batch: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWithNoCacheConfiguredNothingIsDeleted(t *testing.T) {
|
|
dir := t.TempDir()
|
|
a := &Agent{cfg: Config{}, log: slog.New(slog.DiscardHandler)}
|
|
kept := write(t, weights.CachePathFor(dir, "https://models.internal/x.gguf"))
|
|
|
|
a.removeWeights([]string{kept})
|
|
|
|
if _, err := os.Stat(kept); err != nil {
|
|
t.Errorf("an agent with no cache directory deleted something: %v", err)
|
|
}
|
|
}
|
|
|
|
// A model of several files is fetched into a directory of its own, and an
|
|
// unused one goes whole: it was refused as not empty, and nothing was freed.
|
|
func TestAnUnusedSetIsRemovedWhole(t *testing.T) {
|
|
dir := t.TempDir()
|
|
a := agentWithCache(t, dir)
|
|
set := weights.CachePathFor(dir, "https://mirror/coder/v1")
|
|
if err := os.MkdirAll(set, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
write(t, filepath.Join(set, "model-00001-of-00002.gguf"))
|
|
write(t, filepath.Join(set, ".complete"))
|
|
// A link in the cache is taken away, not what it points at.
|
|
target := t.TempDir()
|
|
write(t, filepath.Join(target, "keep.gguf"))
|
|
link := weights.CachePathFor(dir, "https://mirror/linked/v1")
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
a.removeWeights([]string{set, link})
|
|
|
|
if _, err := os.Stat(set); !os.IsNotExist(err) {
|
|
t.Errorf("the set is still there: %v", err)
|
|
}
|
|
if _, err := os.Lstat(link); !os.IsNotExist(err) {
|
|
t.Errorf("the link is still there: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(target, "keep.gguf")); err != nil {
|
|
t.Errorf("what the link pointed at was deleted: %v", err)
|
|
}
|
|
}
|