Files
Ilker ManapandClaude Opus 5 7e6fb0059a Let the control plane call the mirror instead of somebody SSHing to it
The mirror has had a management API since it was built, and the only thing
driving it was curl on the mirror's own machine. This is the other half: a
client, so the operations belong to whatever has a reason to perform them.

The mirror's refusals travel as their own sentence rather than a status code.
Putting the reason in the body was deliberate on the server side, and a client
that kept only the number would undo it — "the mirror said 400" sends somebody
to the wrong machine, "the source URL answered 404" sends them to the right one.

NextProtos is cleared for the same reason the mirror's server clears it. That
configuration is shaped for the agent stream, which pins h2; a transport
carrying a pinned protocol it is not set up to speak negotiates h2 and then
talks HTTP/1.1 over it, and fails with an error mentioning neither ALPN nor
the mirror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 10:16:54 +02:00

170 lines
5.3 KiB
Go

package mirror
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// Client drives a mirror's management API from somewhere else.
//
// The mirror runs on its own machine with its own disk, so every operation the
// control plane wants — what is held, fetch this, how far along, remove that —
// is a request over the network rather than a function call. This is the half
// that makes the mirror something the control plane administers instead of
// something an operator has to SSH into.
type Client struct {
base string
hc *http.Client
}
// NewClient dials a mirror's management API, with mutual TLS when configured.
//
// NextProtos is cleared from what tlsconf builds, for the same reason the
// mirror's server clears it: that configuration is shaped for the agent stream,
// which needs HTTP/2 and pins "h2". A transport carrying a TLS config with a
// pinned protocol it is not itself set up to speak negotiates h2 and then talks
// HTTP/1.1 over it, which fails as a protocol error that mentions neither ALPN
// nor the mirror. Cleared here and ForceAttemptHTTP2 set, net/http negotiates
// whichever the two ends agree on and speaks it.
func NewClient(addr string, tc tlsconf.Config, timeout time.Duration) (*Client, error) {
u, err := url.Parse(addr)
if err != nil || u.Host == "" {
return nil, fmt.Errorf("the mirror address %q is not a URL with a host", addr)
}
if timeout <= 0 {
timeout = 15 * time.Second
}
hc := &http.Client{Timeout: timeout}
if tc.Enabled() {
cfg, err := tc.ClientConfig(u.Hostname())
if err != nil {
return nil, fmt.Errorf("the mirror's TLS configuration: %w", err)
}
cfg.NextProtos = nil
hc.Transport = &http.Transport{TLSClientConfig: cfg, ForceAttemptHTTP2: true}
}
return &Client{base: strings.TrimSuffix(addr, "/"), hc: hc}, nil
}
// Addr is where this client points, for a page that has to say so.
func (c *Client) Addr() string { return c.base }
// APIError is a refusal the mirror explained.
//
// The status and the mirror's own sentence, both: a page showing "the mirror
// said 400" sends somebody to the wrong machine, while "the source URL returned
// 404" sends them to the right one. The status is kept alongside so a caller
// can still branch on it without parsing prose.
type APIError struct {
Status int
Msg string
}
func (e *APIError) Error() string {
if e.Msg == "" {
return fmt.Sprintf("the mirror answered %d", e.Status)
}
return e.Msg
}
// Health reports whether the mirror is up and can read its own directory.
func (c *Client) Health(ctx context.Context) error {
var out map[string]any
return c.do(ctx, http.MethodGet, "/v1/health", nil, &out)
}
// List is everything the mirror holds or is fetching.
func (c *Client) List(ctx context.Context) (ModelsResponse, error) {
var out ModelsResponse
err := c.do(ctx, http.MethodGet, "/v1/models", nil, &out)
return out, err
}
// Fetch asks the mirror to download a model.
//
// It returns as soon as the download is under way, not when it finishes —
// that is the mirror's contract and it is the caller's too. Poll Status.
func (c *Client) Fetch(ctx context.Context, req FetchRequest) (FetchResponse, error) {
var out FetchResponse
err := c.do(ctx, http.MethodPost, "/v1/models", req, &out)
return out, err
}
// Status is where one model version stands.
func (c *Client) Status(ctx context.Context, model, version string) (FetchResponse, error) {
var out FetchResponse
err := c.do(ctx, http.MethodGet,
"/v1/models/"+url.PathEscape(model)+"/"+url.PathEscape(version), nil, &out)
return out, err
}
// Remove deletes a whole version and reports the files that went.
func (c *Client) Remove(ctx context.Context, model, version string) ([]string, error) {
var out struct {
Removed []string `json:"removed"`
}
err := c.do(ctx, http.MethodDelete,
"/v1/models/"+url.PathEscape(model)+"/"+url.PathEscape(version), nil, &out)
return out.Removed, err
}
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.hc.Do(req)
if err != nil {
// Wrapped with the address because the failure an operator sees on a
// page is otherwise "connection refused" with no clue which of the two
// machines refused it.
return fmt.Errorf("reaching the mirror at %s: %w", c.base, err)
}
defer resp.Body.Close()
// Capped: the mirror is trusted but it is still a separate process over a
// network, and a page that renders whatever it sends should not be able to
// be handed a gigabyte.
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return fmt.Errorf("reading the mirror's answer: %w", err)
}
if resp.StatusCode >= 300 {
var e struct {
Error string `json:"error"`
}
_ = json.Unmarshal(raw, &e)
return &APIError{Status: resp.StatusCode, Msg: e.Error}
}
if out == nil {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("the mirror's answer was not the JSON expected: %w", err)
}
return nil
}