A bundle carries what a cluster would download — llmgw's release with its signed checksums, every llama.cpp build per architecture with GitHub's checksums, model files pinned and checked against Hugging Face's — into a directory or a tar whose manifest sha256 is carried apart. Import checks everything, the release against the signing key, before anything is put in the mirror. With offline.mirror set, nodes install llama.cpp from the mirror against checksums the control plane holds and sends over the agent stream; releases default to the mirror for node add, ctl upgrade and agent upgrades. server init and the setup form install from a bundle. The Helm chart runs one control plane with SQLite on a claim or an external PostgreSQL, Secrets for the agent certificate and admin token, Services, Ingress and ServiceMonitor; deploy/container/Dockerfile builds its image. GPU nodes stay outside Kubernetes, as docs/kubernetes.md explains. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
306 lines
9.3 KiB
Go
306 lines
9.3 KiB
Go
// Package release fetches and installs llmgw's own release binaries.
|
|
//
|
|
// One implementation for everywhere a binary is replaced — the control plane
|
|
// upgrading itself, and an agent told to upgrade by it — so the checks are
|
|
// the same on both: the release's own checksum is verified before anything is
|
|
// installed, the old binary is kept beside the new one, and the swap is a
|
|
// rename, so a machine is never left without a binary that runs.
|
|
package release
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DefaultBase is where CI publishes releases.
|
|
const DefaultBase = "https://gitea.mynodes.xyz/ilker/llmgw/releases/download"
|
|
|
|
// Tag is a version CI published: v0.10.4, or a release candidate v0.10.4-rc.2.
|
|
var Tag = regexp.MustCompile(`^v\d+\.\d+\.\d+(-rc\.\d+)?$`)
|
|
|
|
// AssetName is the release file for a platform.
|
|
func AssetName(version, goos, goarch string) string {
|
|
return "llmgw-" + version + "-" + goos + "-" + goarch
|
|
}
|
|
|
|
// Install fetches version for this platform from base, checks it against the
|
|
// release's checksums, and puts it at target, keeping the old one as
|
|
// target.bak-pre-<version>. It returns the backup's path.
|
|
func Install(ctx context.Context, client *http.Client, base, version, goos, goarch, target string) (string, error) {
|
|
if !Tag.MatchString(version) {
|
|
return "", fmt.Errorf("%q is not a release version", version)
|
|
}
|
|
if err := CheckBase(base); err != nil {
|
|
return "", err
|
|
}
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 10 * time.Minute}
|
|
}
|
|
target, err := filepath.EvalSymlinks(target)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir := filepath.Dir(target)
|
|
asset := AssetName(version, goos, goarch)
|
|
root := strings.TrimSuffix(base, "/") + "/" + version + "/"
|
|
|
|
want, err := checksum(ctx, client, root, asset)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Beside the target, so the final rename is on one filesystem and atomic.
|
|
tmp, err := os.CreateTemp(dir, ".llmgw-"+version+"-*")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
got, err := download(ctx, client, root+asset, tmp)
|
|
if cerr := tmp.Close(); err == nil {
|
|
err = cerr
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if got != want {
|
|
return "", fmt.Errorf("%s does not match the release's checksum (got %s, expected %s)",
|
|
asset, got[:12], want[:12])
|
|
}
|
|
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
backup := target + ".bak-pre-" + version
|
|
if err := copyFile(target, backup); err != nil {
|
|
return "", fmt.Errorf("keeping the old binary: %w", err)
|
|
}
|
|
if err := os.Rename(tmp.Name(), target); err != nil {
|
|
return "", err
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
// CheckBase refuses a release address a binary should not come from: one
|
|
// over plain HTTP, where anyone on the path can swap the bytes and, without a
|
|
// signing key, the checksums with them. Loopback is allowed, for a mirror on
|
|
// this machine and for tests.
|
|
//
|
|
// With a signing key built in, plain HTTP is allowed too: the checksums are
|
|
// then checked against a key no server holds, so what carries them adds
|
|
// nothing, and an offline site's mirror serves releases over the plain HTTP
|
|
// it serves weights on.
|
|
func CheckBase(base string) error {
|
|
u, err := url.Parse(base)
|
|
if err != nil || u.Host == "" {
|
|
return fmt.Errorf("%q is not a release address", base)
|
|
}
|
|
switch u.Scheme {
|
|
case "https":
|
|
return nil
|
|
case "http":
|
|
if ip := net.ParseIP(u.Hostname()); (ip != nil && ip.IsLoopback()) || u.Hostname() == "localhost" {
|
|
return nil
|
|
}
|
|
if TrustedKey() != "" {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("releases are fetched over https, and %s is not", base)
|
|
}
|
|
|
|
// maxBinary bounds a release binary read into memory.
|
|
const maxBinary = 512 << 20
|
|
|
|
// Fetch downloads a release binary for a platform and checks it exactly as
|
|
// Install does — signature when this build trusts a key, then checksum —
|
|
// returning its bytes: for a machine that installs it on another, which
|
|
// then runs nothing that was not checked here.
|
|
func Fetch(ctx context.Context, client *http.Client, base, version, goos, goarch string) ([]byte, error) {
|
|
if !Tag.MatchString(version) {
|
|
return nil, fmt.Errorf("%q is not a release version", version)
|
|
}
|
|
if err := CheckBase(base); err != nil {
|
|
return nil, err
|
|
}
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 10 * time.Minute}
|
|
}
|
|
asset := AssetName(version, goos, goarch)
|
|
root := strings.TrimSuffix(base, "/") + "/" + version + "/"
|
|
want, err := checksum(ctx, client, root, asset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var buf bytes.Buffer
|
|
got, err := download(ctx, client, root+asset, &limitWriter{w: &buf, n: maxBinary})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if got != want {
|
|
return nil, fmt.Errorf("%s does not match the release's checksum (got %s, expected %s)",
|
|
asset, got[:12], want[:12])
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// limitWriter refuses to grow past n bytes.
|
|
type limitWriter struct {
|
|
w io.Writer
|
|
n int64
|
|
}
|
|
|
|
func (l *limitWriter) Write(p []byte) (int, error) {
|
|
if int64(len(p)) > l.n {
|
|
return 0, fmt.Errorf("the release binary is larger than %d bytes", maxBinary)
|
|
}
|
|
l.n -= int64(len(p))
|
|
return l.w.Write(p)
|
|
}
|
|
|
|
// checksum reads the release's checksum for asset — after checking the
|
|
// checksums' signature, when this build trusts a signing key.
|
|
func checksum(ctx context.Context, client *http.Client, root, asset string) (string, error) {
|
|
c, err := fetchChecksums(ctx, client, root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return c.For(asset)
|
|
}
|
|
|
|
// Checksums are a release's checksums.txt and its signature, as published.
|
|
type Checksums struct {
|
|
Text []byte
|
|
// Signature is checksums.txt.sig; empty for a release read by a build
|
|
// that trusts no key.
|
|
Signature string
|
|
}
|
|
|
|
// FetchChecksums reads a release's checksums and their signature, checked
|
|
// as Install checks them: for a machine that keeps them — in an offline
|
|
// bundle — to be checked again where they are used.
|
|
func FetchChecksums(ctx context.Context, client *http.Client, base, version string) (Checksums, error) {
|
|
if !Tag.MatchString(version) {
|
|
return Checksums{}, fmt.Errorf("%q is not a release version", version)
|
|
}
|
|
if err := CheckBase(base); err != nil {
|
|
return Checksums{}, err
|
|
}
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 10 * time.Minute}
|
|
}
|
|
return fetchChecksums(ctx, client, strings.TrimSuffix(base, "/")+"/"+version+"/")
|
|
}
|
|
|
|
// fetchChecksums reads checksums.txt under root, and its signature when this
|
|
// build trusts a signing key. A release with no signature is then refused
|
|
// rather than taken on its checksums: a missing signature is exactly what
|
|
// replacing a release would look like.
|
|
func fetchChecksums(ctx context.Context, client *http.Client, root string) (Checksums, error) {
|
|
body, err := get(ctx, client, root+"checksums.txt")
|
|
if err != nil {
|
|
return Checksums{}, err
|
|
}
|
|
defer body.Close()
|
|
sums, err := io.ReadAll(io.LimitReader(body, 1<<20))
|
|
if err != nil {
|
|
return Checksums{}, err
|
|
}
|
|
c := Checksums{Text: sums}
|
|
if TrustedKey() == "" {
|
|
return c, nil
|
|
}
|
|
sigBody, err := get(ctx, client, root+SignatureName)
|
|
if err != nil {
|
|
return Checksums{}, fmt.Errorf("this build only installs signed releases, and %s could not be read: %w", SignatureName, err)
|
|
}
|
|
sig, err := io.ReadAll(io.LimitReader(sigBody, 4096))
|
|
sigBody.Close()
|
|
if err != nil {
|
|
return Checksums{}, err
|
|
}
|
|
c.Signature = string(sig)
|
|
return c, c.Verify()
|
|
}
|
|
|
|
// Verify checks the checksums against the key this build trusts. With none
|
|
// they are taken as they are, as every release before signing was.
|
|
func (c Checksums) Verify() error {
|
|
key := TrustedKey()
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(c.Signature) == "" {
|
|
return fmt.Errorf("this build only installs signed releases, and these checksums carry no %s", SignatureName)
|
|
}
|
|
return Verify(key, c.Text, c.Signature)
|
|
}
|
|
|
|
// For is the checksum the release gives for one asset.
|
|
func (c Checksums) For(asset string) (string, error) {
|
|
sc := bufio.NewScanner(bytes.NewReader(c.Text))
|
|
for sc.Scan() {
|
|
f := strings.Fields(sc.Text())
|
|
if len(f) == 2 && f[1] == asset && len(f[0]) == 64 {
|
|
return f[0], nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("the release's checksums list no %s", asset)
|
|
}
|
|
|
|
func download(ctx context.Context, client *http.Client, url string, dst io.Writer) (string, error) {
|
|
body, err := get(ctx, client, url)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer body.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(io.MultiWriter(dst, h), body); err != nil {
|
|
return "", fmt.Errorf("downloading %s: %w", url, err)
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
func get(ctx context.Context, client *http.Client, url string) (io.ReadCloser, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching %s: %w", url, err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("fetching %s: %s", url, resp.Status)
|
|
}
|
|
return resp.Body, nil
|
|
}
|
|
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src) //nolint:gosec // our own binary
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) //nolint:gosec // beside it
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|