CI / test (push) Successful in 1m36s
The mirror was a directory and a discipline written down in a README. This makes the three operations that matter into commands, on the mirror host, where `llmgw keys` already sets the precedent of operator tooling that works on files rather than through the API. The gateway still never writes to the mirror. That property was chosen deliberately — a mirror writable by the thing reading from it is a way to replace a model with one every node then fetches without complaint — and a person running a command does not take it away. `serve` states it rather than inheriting it, so a handler added later cannot quietly make the mirror writable. `add` downloads to a temporary name and renames, for the reason the agent's fetcher does: a mirror is copied to every node, so a partial file here is a partial file everywhere. It verifies the length the server promised, reports a checksum — the mirror is the one place a corrupt file reaches every node rather than being caught once — and refuses to replace an existing version, which is the whole point of the layout. `unused` refuses to answer when it cannot read the catalog. An empty catalog and an unreachable one produce the same list, everything, and the obvious next step after that list is deletion. It compares by path rather than by spelling, because a base typed with a different scheme or a trailing slash would otherwise report a file in use as unused. `remove` checks the catalog first and says to retire the entry before deleting the file, because a node that has to refetch a deleted file cannot, and that failure arrives later, on the node, looking like something else. Verified end to end: Range requests answer 206, which is what makes an interrupted fetch on a node resume; writes answer 405; a traversal attempt answers 404. Note: internal/agent/supervisor's TestSidecarRunsBeforeEngine fails under full suite load on main as well as here — it asserts that one shell's echo lands before another's, which the supervisor does not promise. Everything else passes. Left alone rather than fixed in a commit about mirrors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
159 lines
4.4 KiB
Go
159 lines
4.4 KiB
Go
package mirror
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Added is what a download produced.
|
|
type Added struct {
|
|
Entry Entry
|
|
SHA256 string
|
|
}
|
|
|
|
// Add downloads a source into the mirror at model/version/file.
|
|
//
|
|
// Written to a temporary name and renamed when complete, for the same reason
|
|
// the agent's fetcher does it: a mirror is copied to every node, so a partial
|
|
// file here is a partial file everywhere, and a rename is the only step that
|
|
// is atomic. An interrupted download leaves a `.part` that List ignores rather
|
|
// than something a catalog could be pointed at.
|
|
//
|
|
// It refuses to replace an existing version. That refusal is the whole point of
|
|
// the layout — a version that can be overwritten takes away the ability to
|
|
// change the catalog back and still have the old weights.
|
|
func Add(ctx context.Context, root, source string, e Entry, progress func(done, total int64)) (Added, error) {
|
|
if e.Model == "" || e.Version == "" || e.File == "" {
|
|
return Added{}, fmt.Errorf("a model, a version and a filename are all required")
|
|
}
|
|
if strings.ContainsAny(e.Model+e.Version+e.File, `/\`) {
|
|
// Each is one path element. A slash would put the file somewhere the
|
|
// layout does not describe, and List would not find it again.
|
|
return Added{}, fmt.Errorf("model, version and filename are single names, not paths")
|
|
}
|
|
|
|
final := e.Path(root)
|
|
if _, err := os.Stat(final); err == nil {
|
|
return Added{}, fmt.Errorf("%s/%s/%s: %w", e.Model, e.Version, e.File, ErrExists)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(final), 0o755); err != nil {
|
|
return Added{}, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
|
|
if err != nil {
|
|
return Added{}, err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return Added{}, fmt.Errorf("fetching %s: %w", source, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Added{}, fmt.Errorf("%s answered %s", source, resp.Status)
|
|
}
|
|
|
|
part := final + partSuffix
|
|
f, err := os.OpenFile(part, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) //nolint:gosec // inside the mirror root
|
|
if err != nil {
|
|
return Added{}, err
|
|
}
|
|
written, err := copyWithProgress(f, resp.Body, resp.ContentLength, progress)
|
|
closeErr := f.Close()
|
|
if err != nil {
|
|
_ = os.Remove(part)
|
|
return Added{}, err
|
|
}
|
|
if closeErr != nil {
|
|
_ = os.Remove(part)
|
|
return Added{}, closeErr
|
|
}
|
|
// A truncated download that the server ended cleanly would otherwise be
|
|
// renamed into place and copied to every node.
|
|
if resp.ContentLength > 0 && written != resp.ContentLength {
|
|
_ = os.Remove(part)
|
|
return Added{}, fmt.Errorf("%s: got %d bytes of %d", source, written, resp.ContentLength)
|
|
}
|
|
|
|
sum, err := sha256File(part)
|
|
if err != nil {
|
|
_ = os.Remove(part)
|
|
return Added{}, err
|
|
}
|
|
if err := os.Rename(part, final); err != nil {
|
|
return Added{}, err
|
|
}
|
|
e.Bytes = written
|
|
return Added{Entry: e, SHA256: sum}, nil
|
|
}
|
|
|
|
// copyWithProgress copies and reports occasionally.
|
|
//
|
|
// Occasionally rather than continuously: a forty gigabyte download is a
|
|
// hundred thousand chunks, and a line each would bury whatever else the
|
|
// operator is watching.
|
|
func copyWithProgress(dst io.Writer, src io.Reader, total int64, report func(done, total int64)) (int64, error) {
|
|
buf := make([]byte, 1<<20)
|
|
var done int64
|
|
last := time.Now()
|
|
|
|
for {
|
|
n, err := src.Read(buf)
|
|
if n > 0 {
|
|
w, werr := dst.Write(buf[:n])
|
|
done += int64(w)
|
|
if werr != nil {
|
|
return done, werr
|
|
}
|
|
if report != nil && time.Since(last) > 2*time.Second {
|
|
report(done, total)
|
|
last = time.Now()
|
|
}
|
|
}
|
|
if err == io.EOF {
|
|
if report != nil {
|
|
report(done, total)
|
|
}
|
|
return done, nil
|
|
}
|
|
if err != nil {
|
|
return done, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove deletes one version, and the model directory if that was the last of
|
|
// it.
|
|
//
|
|
// Leaving an empty directory behind would make `list` show a model that is not
|
|
// there, which is the kind of small lie that costs somebody an afternoon.
|
|
func Remove(root string, e Entry) error {
|
|
path := e.Path(root)
|
|
if err := os.Remove(path); err != nil {
|
|
return err
|
|
}
|
|
versionDir := filepath.Dir(path)
|
|
if empty, _ := isEmpty(versionDir); empty {
|
|
_ = os.Remove(versionDir)
|
|
}
|
|
modelDir := filepath.Dir(versionDir)
|
|
if empty, _ := isEmpty(modelDir); empty {
|
|
_ = os.Remove(modelDir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isEmpty(dir string) (bool, error) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return len(entries) == 0, nil
|
|
}
|