Files
llmgw/internal/bundle/create.go
Ilker ManapandClaude Opus 5.5 bae16426a2 Offline sites and Kubernetes: llmgw bundle create, import and trust, offline.mirror, server init --bundle; a Helm chart for the control plane
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>
2026-10-05 00:13:39 +02:00

440 lines
13 KiB
Go

package bundle
import (
"archive/tar"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
)
// EngineSpec is an engine version to carry, and which of its builds.
type EngineSpec struct {
Engine, Version string
// Variants are the builds to carry ("cpu", "cuda-13.3"); empty is every
// one, which is what a site whose nodes are not all known wants.
Variants []string
}
// ModelSpec is a model file to carry, and where the mirror will hold it.
type ModelSpec struct {
Name, Version string
// Source is hf:owner/repo/path/in/repo, or an http(s) URL.
Source string
}
// CreateOptions say what goes into a bundle.
type CreateOptions struct {
// Out is the directory the bundle is made in. A bundle made there
// before is added to: a file already there with the right checksum is
// not downloaded again.
Out string
// Release is llmgw's release to carry, v0.10.4; empty carries none.
Release string
// Arches are the architectures, of the releases and of the engines:
// amd64, arm64.
Arches []string
// Releases is where release files are published.
Releases string
Engines []EngineSpec
Models []ModelSpec
// HTTP fetches everything; nil is a client with no overall timeout,
// because a model file is tens of gigabytes.
HTTP *http.Client
// GitHubAPI, GitHubDownload and Registry default to GitHub and Hugging
// Face; tests point them at fakes.
GitHubAPI, GitHubDownload, Registry string
// Log hears what is happening, a line at a time.
Log func(format string, args ...any)
}
// Create downloads everything a bundle is asked to carry, checking each
// file as a node online would, and writes its manifest. It returns the
// manifest and its digest: the value to carry to the site separately and
// give to the import.
func Create(ctx context.Context, o CreateOptions) (Manifest, string, error) {
c := &creator{o: o, client: o.HTTP}
if c.client == nil {
c.client = &http.Client{}
}
if len(o.Arches) == 0 {
return Manifest{}, "", errors.New("give at least one architecture: amd64, arm64")
}
for _, a := range o.Arches {
if a != "amd64" && a != "arm64" {
return Manifest{}, "", fmt.Errorf("%q is not an architecture llmgw is built for: amd64 or arm64", a)
}
}
if err := os.MkdirAll(o.Out, 0o755); err != nil {
return Manifest{}, "", err
}
c.m = Manifest{Format: Format, Created: time.Now().UTC().Truncate(time.Second), MadeBy: version.Version}
if o.Release != "" {
if err := c.release(ctx); err != nil {
return Manifest{}, "", err
}
}
for _, e := range o.Engines {
if err := c.engine(ctx, e); err != nil {
return Manifest{}, "", err
}
}
for _, m := range o.Models {
if err := c.model(ctx, m); err != nil {
return Manifest{}, "", err
}
}
raw, err := json.MarshalIndent(c.m, "", " ")
if err != nil {
return Manifest{}, "", err
}
raw = append(raw, '\n')
if err := os.WriteFile(filepath.Join(o.Out, ManifestName), raw, 0o644); err != nil { //nolint:gosec // a list of checksums
return Manifest{}, "", err
}
return c.m, Digest(raw), nil
}
type creator struct {
o CreateOptions
client *http.Client
m Manifest
}
func (c *creator) log(format string, args ...any) {
if c.o.Log != nil {
c.o.Log(format, args...)
}
}
// release carries llmgw's release: its checksums and their signature,
// checked against the key this build trusts, and a binary per
// architecture checked against them.
func (c *creator) release(ctx context.Context) error {
v := c.o.Release
base := c.o.Releases
if base == "" {
base = release.DefaultBase
}
c.log("llmgw %s: its checksums and their signature", v)
sums, err := release.FetchChecksums(ctx, c.client, base, v)
if err != nil {
return fmt.Errorf("llmgw %s: %w", v, err)
}
dir := kindRelease + "/" + v + "/"
if err := c.writeSmall(dir+"checksums.txt", sums.Text); err != nil {
return err
}
if sums.Signature != "" {
if err := c.writeSmall(dir+release.SignatureName, []byte(sums.Signature)); err != nil {
return err
}
}
root := strings.TrimSuffix(base, "/") + "/" + v + "/"
for _, arch := range c.o.Arches {
asset := release.AssetName(v, "linux", arch)
want, err := sums.For(asset)
if err != nil {
return fmt.Errorf("llmgw %s: %w", v, err)
}
if err := c.fetch(ctx, root+asset, dir+asset, want); err != nil {
return err
}
}
c.m.Releases = append(c.m.Releases, Release{Version: v, Arches: slices.Clone(c.o.Arches)})
return nil
}
// engine carries an engine version's builds for each architecture, checked
// against the checksums its publisher gives — the same the nodes ask for
// online.
func (c *creator) engine(ctx context.Context, e EngineSpec) error {
in := &provision.Installer{HTTP: c.client, APIBase: c.o.GitHubAPI, DownloadBase: c.o.GitHubDownload}
c.log("%s %s: the checksums its publisher gives", e.Engine, e.Version)
sums, url, err := in.Published(ctx, e.Engine, e.Version)
if err != nil {
return fmt.Errorf("%s %s: %w", e.Engine, e.Version, err)
}
var variants []string
done := map[string]bool{}
for _, arch := range c.o.Arches {
builds, err := provision.Builds(e.Engine, e.Version, arch)
if err != nil {
return err
}
var names []string
for _, b := range builds {
names = append(names, b.Variant)
if len(e.Variants) > 0 && !slices.Contains(e.Variants, b.Variant) {
continue
}
if !slices.Contains(variants, b.Variant) {
variants = append(variants, b.Variant)
}
for _, asset := range b.Assets {
if done[asset] {
continue
}
want, ok := sums[asset]
if !ok {
return fmt.Errorf("%s %s publishes no checksum for %s, so it cannot be carried checked", e.Engine, e.Version, asset)
}
if err := c.fetch(ctx, url(asset), kindEngine+"/"+e.Engine+"/"+e.Version+"/"+asset, want); err != nil {
return err
}
done[asset] = true
}
}
for _, v := range e.Variants {
if !slices.Contains(names, v) && !anyArchHas(e, v, c.o.Arches) {
return fmt.Errorf("%s has no build %q; it has %s", e.Engine, v, strings.Join(names, ", "))
}
}
}
c.m.Engines = append(c.m.Engines, Engine{Engine: e.Engine, Version: e.Version,
Arches: slices.Clone(c.o.Arches), Variants: variants})
return nil
}
// anyArchHas says whether a variant is a build on any of the architectures:
// CUDA 12.8 is published for x64 alone, and asking for it with arm64 too is
// not a mistake.
func anyArchHas(e EngineSpec, variant string, arches []string) bool {
for _, a := range arches {
bs, _ := provision.Builds(e.Engine, e.Version, a)
for _, b := range bs {
if b.Variant == variant {
return true
}
}
}
return false
}
// model carries a model file, checked against the checksum its registry
// publishes when it publishes one, at the commit the registry had when it
// was asked — so the bundle says exactly which weights it carries.
func (c *creator) model(ctx context.Context, s ModelSpec) error {
for _, n := range []string{s.Name, s.Version} {
if err := mirror.ValidName(n); err != nil {
return fmt.Errorf("model %s/%s: %w", s.Name, s.Version, err)
}
}
if strings.HasPrefix(s.Name, "_") {
return fmt.Errorf("model %q: a name starting with an underscore is the mirror's own", s.Name)
}
var url, want, rev string
switch {
case strings.HasPrefix(s.Source, "hf:"):
parts := strings.SplitN(strings.TrimPrefix(s.Source, "hf:"), "/", 3)
if len(parts) != 3 || parts[2] == "" {
return fmt.Errorf("%s names a repository, not a file: hf:owner/repo/file", s.Source)
}
repoID, file := parts[0]+"/"+parts[1], parts[2]
reg := hf.New(c.o.Registry, time.Minute)
repo, err := reg.Repo(ctx, repoID)
if err != nil {
return fmt.Errorf("%s: %w", s.Source, err)
}
found := false
for _, f := range repo.Files {
if f.Path == file {
want, found = f.SHA256, true
}
}
if !found {
return fmt.Errorf("%s has no file %s at its latest commit", repoID, file)
}
url, rev = repo.DownloadURL(reg.Base(), file), repo.Revision
case strings.HasPrefix(s.Source, "https://"), strings.HasPrefix(s.Source, "http://"):
url = s.Source
default:
return fmt.Errorf("model source %q: hf:owner/repo/file or an http(s) URL", s.Source)
}
name := path.Base(strings.SplitN(url, "?", 2)[0])
if err := mirror.ValidName(name); err != nil {
return fmt.Errorf("%s: %w", s.Source, err)
}
if err := c.fetch(ctx, url, kindModel+"/"+s.Name+"/"+s.Version+"/"+name, want); err != nil {
return err
}
c.m.Models = append(c.m.Models, Model{Name: s.Name, Version: s.Version, Source: s.Source, Revision: rev})
return nil
}
// writeSmall puts a file the bundle makes itself, rather than downloads.
func (c *creator) writeSmall(rel string, b []byte) error {
dst := filepath.Join(c.o.Out, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.WriteFile(dst, b, 0o644); err != nil { //nolint:gosec // published files
return err
}
sum := sha256.Sum256(b)
c.add(File{Path: rel, Bytes: int64(len(b)), SHA256: hex.EncodeToString(sum[:])})
return nil
}
func (c *creator) add(f File) {
for i := range c.m.Files {
if c.m.Files[i].Path == f.Path {
c.m.Files[i] = f
return
}
}
c.m.Files = append(c.m.Files, f)
}
// fetch downloads url into the bundle at rel, checked against want when
// there is one; a file already there that matches is kept.
func (c *creator) fetch(ctx context.Context, url, rel, want string) error {
dst := filepath.Join(c.o.Out, filepath.FromSlash(rel))
if want != "" {
if got, err := fileSHA256(dst); err == nil && got == want {
info, _ := os.Stat(dst)
c.log(" have %s", rel)
c.add(File{Path: rel, Bytes: info.Size(), SHA256: want, Source: url})
return nil
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("fetching %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("fetching %s: %s", url, resp.Status)
}
if resp.ContentLength > 0 {
c.log(" fetching %s (%d MB)", rel, resp.ContentLength>>20)
} else {
c.log(" fetching %s", rel)
}
part := dst + partSuffix
f, err := os.Create(part) //nolint:gosec // inside the bundle
if err != nil {
return err
}
h := sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), &progressReader{r: resp.Body, total: resp.ContentLength, log: c.log, name: rel})
if cerr := f.Close(); err == nil {
err = cerr
}
if err == nil && resp.ContentLength > 0 && n != resp.ContentLength {
err = fmt.Errorf("got %d bytes of %d", n, resp.ContentLength)
}
if err != nil {
_ = os.Remove(part)
return fmt.Errorf("fetching %s: %w", url, err)
}
got := hex.EncodeToString(h.Sum(nil))
if want != "" && got != want {
_ = os.Remove(part)
return fmt.Errorf("%s does not match the checksum its publisher gave (got %s, expected %s)", url, got[:12], want[:12])
}
if err := os.Rename(part, dst); err != nil {
return err
}
c.add(File{Path: rel, Bytes: n, SHA256: got, Source: url})
return nil
}
// progressReader says how far a long download has got, every few seconds.
type progressReader struct {
r io.Reader
total int64
done int64
last time.Time
log func(string, ...any)
name string
}
func (p *progressReader) Read(b []byte) (int, error) {
n, err := p.r.Read(b)
p.done += int64(n)
if p.total > 0 && time.Since(p.last) > 10*time.Second {
if !p.last.IsZero() {
p.log(" %d%% of %s", p.done*100/p.total, p.name)
}
p.last = time.Now()
}
return n, err
}
// Pack writes a bundle directory as one tar file, manifest first, so it can
// be carried as one file and imported as a stream. Weights do not compress,
// so it is not compressed.
func Pack(dir, out string) error {
src, m, err := open(dir)
if err != nil {
return err
}
defer src.close() //nolint:errcheck // read only
f, err := os.Create(out + partSuffix) //nolint:gosec // the operator's path
if err != nil {
return err
}
defer os.Remove(out + partSuffix) //nolint:errcheck // gone once renamed
tw := tar.NewWriter(f)
now := time.Now()
raw := src.manifest()
if err := tw.WriteHeader(&tar.Header{Name: ManifestName, Mode: 0o644, Size: int64(len(raw)), ModTime: now, Typeflag: tar.TypeReg}); err != nil {
f.Close()
return err
}
if _, err := tw.Write(raw); err != nil {
f.Close()
return err
}
for _, mf := range m.Files {
in, err := os.Open(filepath.Join(dir, filepath.FromSlash(mf.Path))) //nolint:gosec // a checked path in the bundle
if err != nil {
f.Close()
return err
}
err = tw.WriteHeader(&tar.Header{Name: mf.Path, Mode: 0o644, Size: mf.Bytes, ModTime: now, Typeflag: tar.TypeReg})
if err == nil {
_, err = io.Copy(tw, in)
}
in.Close()
if err != nil {
f.Close()
return fmt.Errorf("packing %s: %w", mf.Path, err)
}
}
if err := tw.Close(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(out+partSuffix, out)
}