The mirror holds the weights, so the mirror does the downloading. A control plane that had to pull the bytes itself and push them here would carry every model twice over the network, through the one machine least able to spare the bandwidth. Two listeners, and the split is the point. Weights stay on plain HTTP where every node can reach them: that path is eight nodes pulling forty gigabytes, and putting a client certificate in front of it would mean re-certifying every agent's fetcher in order to change how models are administered. Management gets its own port, which can be given the fleet's CA — the same tlsconf the agent stream uses, so this is a configuration rather than a new idea. A fetch answers 202, not 200. The bytes are not here yet, and a caller that read it as "done" would point a catalog at a file still arriving, which every node would then copy. Progress is asked for rather than waited on, because no client between here and the operator will hold a connection open for an hour. A failed fetch is remembered with its reason. Forgetting it would leave the control plane waiting on a model that is never coming, with nothing to say why. Status falls back to reading the disk, so a restart does not report a model that is plainly there as unknown. Removal takes the whole version: a model split across files is useless with one of them missing, and half a version on disk looks present and will not load. It refuses while a download is running, because the fetch would carry on writing and the file would reappear, looking like the delete had silently failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
304 lines
9.8 KiB
Go
304 lines
9.8 KiB
Go
package mirror
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testAPI is an API over an empty mirror, and the root it holds.
|
|
func testAPI(t *testing.T) (*API, string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
a := NewAPI(NewManager(root, discardLog()), "http://mirror:8081")
|
|
|
|
// A download outlives the request that started it — that is the point of
|
|
// the manager. So the temp directory must not be torn down while one is
|
|
// still writing into it. Registered after t.TempDir so it runs first.
|
|
t.Cleanup(func() {
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
all, err := a.m.All()
|
|
if err != nil {
|
|
return
|
|
}
|
|
busy := false
|
|
for _, st := range all {
|
|
busy = busy || st.State == Fetching
|
|
}
|
|
if !busy {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Error("a download was still running when the test ended")
|
|
})
|
|
return a, root
|
|
}
|
|
|
|
func call(t *testing.T, a *API, method, path, body string) (int, map[string]any) {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != "" {
|
|
r = strings.NewReader(body)
|
|
}
|
|
req := httptest.NewRequest(method, path, r)
|
|
w := httptest.NewRecorder()
|
|
a.Handler().ServeHTTP(w, req)
|
|
|
|
var out map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("%s %s returned %d with an unreadable body %q: %v",
|
|
method, path, w.Code, w.Body.String(), err)
|
|
}
|
|
return w.Code, out
|
|
}
|
|
|
|
// waitFor polls until the condition holds, because a download runs in its own
|
|
// goroutine and the test would otherwise be asserting on a race.
|
|
func waitFor(t *testing.T, what string, cond func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if cond() {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for %s", what)
|
|
}
|
|
|
|
func TestFetchAnswersBeforeTheBytesArrive(t *testing.T) {
|
|
a, root := testAPI(t)
|
|
source := serving(t, "weights")
|
|
|
|
code, body := call(t, a, "POST", "/v1/models", `{
|
|
"source": "`+source+`", "model": "llama", "version": "2026-01-01"}`)
|
|
|
|
// 202 rather than 200: a caller that reads this as "done" points a catalog
|
|
// at a file that is still arriving.
|
|
if code != http.StatusAccepted {
|
|
t.Fatalf("expected 202 while the download runs, got %d: %v", code, body)
|
|
}
|
|
if got := body["url"]; got != "http://mirror:8081/llama/2026-01-01/model.gguf" {
|
|
t.Errorf("the response should say where the catalog will point; got %v", got)
|
|
}
|
|
|
|
waitFor(t, "the download to land on disk", func() bool {
|
|
_, err := os.Stat(filepath.Join(root, "llama", "2026-01-01", "model.gguf"))
|
|
return err == nil
|
|
})
|
|
}
|
|
|
|
func TestTheFilenameComesFromTheSourceWhenNotGiven(t *testing.T) {
|
|
a, _ := testAPI(t)
|
|
_, body := call(t, a, "POST", "/v1/models",
|
|
`{"source": "`+serving(t, "w")+`", "model": "m", "version": "v"}`)
|
|
|
|
if got := body["url"]; !strings.HasSuffix(got.(string), "/m/v/model.gguf") {
|
|
t.Errorf("the filename should be taken from the source URL; got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchRefusesWithoutAModelAndVersion(t *testing.T) {
|
|
a, _ := testAPI(t)
|
|
code, body := call(t, a, "POST", "/v1/models", `{"source": "http://x/model.gguf"}`)
|
|
|
|
if code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 without a model and version, got %d", code)
|
|
}
|
|
// The reason belongs in the body: a bare 400 turns a typo into an hour of
|
|
// finding out which field was wrong.
|
|
if !strings.Contains(body["error"].(string), "version") {
|
|
t.Errorf("the error should name what is missing; got %q", body["error"])
|
|
}
|
|
}
|
|
|
|
func TestAskingTwiceDoesNotStartTwoDownloads(t *testing.T) {
|
|
a, _ := testAPI(t)
|
|
release := make(chan struct{})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
<-release
|
|
_, _ = w.Write([]byte("weights"))
|
|
}))
|
|
defer srv.Close()
|
|
defer close(release)
|
|
|
|
body := `{"source": "` + srv.URL + `/model.gguf", "model": "m", "version": "v"}`
|
|
if code, _ := call(t, a, "POST", "/v1/models", body); code != http.StatusAccepted {
|
|
t.Fatalf("the first fetch should be accepted, got %d", code)
|
|
}
|
|
waitFor(t, "the first download to register", func() bool {
|
|
st, ok := a.m.Status("m", "v")
|
|
return ok && st.State == Fetching
|
|
})
|
|
|
|
// The second must join the first rather than open a rival writer on the
|
|
// same .part file.
|
|
code, second := call(t, a, "POST", "/v1/models", body)
|
|
if code != http.StatusAccepted {
|
|
t.Fatalf("asking again should report the running download, got %d: %v", code, second)
|
|
}
|
|
st := second["status"].(map[string]any)
|
|
if st["state"] != string(Fetching) {
|
|
t.Errorf("the second answer should describe the download already running; got %v", st["state"])
|
|
}
|
|
}
|
|
|
|
func TestAFailedFetchIsRememberedWithItsReason(t *testing.T) {
|
|
a, _ := testAPI(t)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "gone", http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
call(t, a, "POST", "/v1/models",
|
|
`{"source": "`+srv.URL+`/model.gguf", "model": "m", "version": "v"}`)
|
|
|
|
// Remembered rather than forgotten: a control plane waiting on a model that
|
|
// is never coming must be able to find out why.
|
|
waitFor(t, "the failure to be recorded", func() bool {
|
|
st, ok := a.m.Status("m", "v")
|
|
return ok && st.State == Failed
|
|
})
|
|
code, body := call(t, a, "GET", "/v1/models/m/v", "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("a failed fetch should still be reportable, got %d", code)
|
|
}
|
|
st := body["status"].(map[string]any)
|
|
if !strings.Contains(st["error"].(string), "404") {
|
|
t.Errorf("the recorded error should say what upstream answered; got %q", st["error"])
|
|
}
|
|
}
|
|
|
|
func TestStatusReadsTheDiskForModelsThisProcessDidNotFetch(t *testing.T) {
|
|
a, root := testAPI(t)
|
|
// A restart forgets every download. A model that is present must not be
|
|
// reported as unknown merely because this process did not put it there.
|
|
writeFile(t, filepath.Join(root, "m", "v", "model.gguf"), "weights")
|
|
|
|
code, body := call(t, a, "GET", "/v1/models/m/v", "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("a model on disk should be found, got %d: %v", code, body)
|
|
}
|
|
if st := body["status"].(map[string]any); st["state"] != string(Present) {
|
|
t.Errorf("expected present, got %v", st["state"])
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownModelIsNotFound(t *testing.T) {
|
|
a, _ := testAPI(t)
|
|
if code, _ := call(t, a, "GET", "/v1/models/nope/v1", ""); code != http.StatusNotFound {
|
|
t.Errorf("expected 404 for a model the mirror does not hold, got %d", code)
|
|
}
|
|
}
|
|
|
|
func TestListReportsTheBaseSoCatalogsAgree(t *testing.T) {
|
|
a, root := testAPI(t)
|
|
writeFile(t, filepath.Join(root, "m", "v", "model.gguf"), "weights")
|
|
|
|
code, body := call(t, a, "GET", "/v1/models", "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", code)
|
|
}
|
|
// Returned rather than assumed: a caller reconstructing the URL with a
|
|
// different spelling is what makes a file look unused when it is not.
|
|
if body["base"] != "http://mirror:8081" {
|
|
t.Errorf("the listing should say where nodes fetch from; got %v", body["base"])
|
|
}
|
|
if n := len(body["models"].([]any)); n != 1 {
|
|
t.Errorf("expected the one model on disk, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestRemoveTakesEveryFileOfAVersion(t *testing.T) {
|
|
a, root := testAPI(t)
|
|
// A model split across files is useless with one of them missing, so the
|
|
// version is the unit of removal, not the file.
|
|
writeFile(t, filepath.Join(root, "m", "v", "part-1.safetensors"), "a")
|
|
writeFile(t, filepath.Join(root, "m", "v", "part-2.safetensors"), "b")
|
|
|
|
code, body := call(t, a, "DELETE", "/v1/models/m/v", "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %v", code, body)
|
|
}
|
|
if n := len(body["removed"].([]any)); n != 2 {
|
|
t.Errorf("both files should have gone; removed %v", body["removed"])
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "m")); !os.IsNotExist(err) {
|
|
t.Error("the emptied model directory should have gone with it")
|
|
}
|
|
}
|
|
|
|
func TestRemoveRefusesWhileADownloadIsRunning(t *testing.T) {
|
|
a, _ := testAPI(t)
|
|
release := make(chan struct{})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
<-release
|
|
_, _ = w.Write([]byte("weights"))
|
|
}))
|
|
defer srv.Close()
|
|
defer close(release)
|
|
|
|
call(t, a, "POST", "/v1/models",
|
|
`{"source": "`+srv.URL+`/model.gguf", "model": "m", "version": "v"}`)
|
|
waitFor(t, "the download to register", func() bool {
|
|
st, ok := a.m.Status("m", "v")
|
|
return ok && st.State == Fetching
|
|
})
|
|
|
|
// Deleting under a running fetch lets the file reappear when the download
|
|
// finishes, which looks like the delete silently failed.
|
|
code, body := call(t, a, "DELETE", "/v1/models/m/v", "")
|
|
if code != http.StatusConflict {
|
|
t.Fatalf("expected 409 while downloading, got %d: %v", code, body)
|
|
}
|
|
}
|
|
|
|
func TestRemovingSomethingAbsentIsNotFound(t *testing.T) {
|
|
a, _ := testAPI(t)
|
|
if code, _ := call(t, a, "DELETE", "/v1/models/m/v", ""); code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", code)
|
|
}
|
|
}
|
|
|
|
func TestHealthFailsWhenTheMirrorDirectoryIsGone(t *testing.T) {
|
|
a, root := testAPI(t)
|
|
if code, _ := call(t, a, "GET", "/v1/health", ""); code != http.StatusOK {
|
|
t.Fatalf("a readable mirror should be healthy, got %d", code)
|
|
}
|
|
|
|
// A mirror whose disk has gone away still accepts connections. A check that
|
|
// only proved the process was running would call that healthy right up
|
|
// until a node tried to fetch.
|
|
if err := os.RemoveAll(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code, _ := call(t, a, "GET", "/v1/health", ""); code != http.StatusServiceUnavailable {
|
|
t.Errorf("expected 503 once the directory is unreadable, got %d", code)
|
|
}
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// discardLog writes nothing, so a test that provokes a failure on purpose does
|
|
// not print an error line and make a passing run look broken.
|
|
func discardLog() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|