Files
llmgw/internal/agent/provision/python.go
T
Ilker ManapandClaude Opus 5.5 4c5a2fd6da Install vLLM on every GPU node that can run it, as llama.cpp is
vLLM was installed by hand and named in each agent's configuration. The
agent now installs it: a Python 3.12 environment built by uv, which is
itself a GitHub release checked against its checksum, from PyPI resolved as
it stood a day after that vLLM was published and installed with
--require-hashes. The resolved list is kept in the environment. The
environment is built relocatable under a scratch name and renamed into place
once it runs, like an archive.

The default is 0.30.0, whose flags were checked against the ones llmgw
builds. It is built for CUDA 13, so it needs driver 580 and a Turing GPU or
newer, and 25 GB free while it installs; a node lacking one says which, and a
node with no GPU skips it without being shown as a fault. gcc comes with it
because Triton compiles its launcher at the first model load. SGLang
installs the same way when the control plane names a version. llmgw doctor
reports whether vLLM can be installed on the node.

Checked by installing 0.30.0 this way on a test node: 7.7 GB, torch 2.13 for
CUDA 13.0, importing cleanly; the node has no GPU, so vLLM was not started.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:15:54 +02:00

206 lines
7.3 KiB
Go

package provision
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"syscall"
"time"
)
// A Python engine is a virtual environment built by uv: an interpreter uv
// fetches, and the engine with every dependency from PyPI.
//
// Two things make it as trustworthy as an archive checked against its
// publisher's checksum. The dependencies are resolved as PyPI stood a day
// after the engine was published (--exclude-newer), so a node built next
// month gets what one built today got, not whatever a dependency released
// since — the usual way a working vLLM stops working. And the resolution is
// written out with every file's sha256 from PyPI and installed with
// --require-hashes, so a file that does not match what PyPI published is
// refused. The resolved list is kept in the environment, as the record of
// exactly what is installed.
// pythonInstallTimeout bounds one installation: several gigabytes, on a
// network that may be slow, but not forever.
const pythonInstallTimeout = time.Hour
// lockName is the resolved requirements, kept in the environment.
const lockName = "llmgw-requirements.txt"
func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Build, dir string, st *Status) error {
ctx, cancel := context.WithTimeout(ctx, pythonInstallTimeout)
defer cancel()
if err := in.packages(ctx, b.Packages, st); err != nil {
return err
}
if err := os.MkdirAll(in.root(), 0o755); err != nil {
return err
}
if b.FreeGB > 0 {
if free, err := in.free(in.root()); err == nil && free < float64(b.FreeGB) {
return fmt.Errorf("%s needs about %d GB free under %s and there are %.1f GB",
w.Engine, b.FreeGB, in.root(), free)
}
}
in.progress(st, "installing uv %s", UVVersion)
uv := in.one(ctx, Want{Engine: "uv", Version: UVVersion})
if uv.State != Ready {
return fmt.Errorf("installing uv, which installs %s: %s", w.Engine, uv.Detail)
}
in.progress(st, "asking PyPI when %s %s was published", r.PyPI, w.Version)
published, err := in.published(ctx, r.PyPI, w.Version)
if err != nil {
return err
}
asOf := published.Add(24 * time.Hour).UTC().Format(time.RFC3339)
cache := filepath.Join(in.root(), ".uv-cache")
env := []string{
"UV_CACHE_DIR=" + cache,
"UV_PYTHON_INSTALL_DIR=" + filepath.Join(in.root(), "python"),
// Only the interpreter uv fetched: the system's may be the wrong
// version, or change under the environment with the next upgrade.
"UV_PYTHON_PREFERENCE=only-managed",
// Nobody's uv.toml or pip.conf decides what is installed here.
"UV_NO_CONFIG=1",
}
tmp := dir + ".partial"
_ = os.RemoveAll(tmp)
defer os.RemoveAll(tmp) //nolint:errcheck // gone either way once renamed
in.progress(st, "creating a Python %s environment for %s", b.Python, w.Engine)
// Relocatable, because it is built under one name and renamed into place
// once it works: its scripts find their interpreter relative to
// themselves rather than by the path they were built at.
if out, err := in.run(ctx, env, uv.Binary, "venv", "--relocatable", "--python", b.Python, tmp); err != nil {
return fmt.Errorf("uv venv: %v: %s", err, tail(out))
}
python := filepath.Join(tmp, "bin", "python")
in.progress(st, "resolving %s against PyPI as of %s", strings.Join(b.Requirements, " "), asOf[:10])
lock := filepath.Join(tmp, lockName)
args := []string{"pip", "compile", "--python", python, "--exclude-newer", asOf,
"--generate-hashes", "--quiet", "--output-file", lock, "-"}
if out, err := in.runStdin(ctx, env, strings.Join(b.Requirements, "\n")+"\n", uv.Binary, args...); err != nil {
return fmt.Errorf("resolving %s: %v: %s", strings.Join(b.Requirements, " "), err, tail(out))
}
in.progress(st, "downloading and installing %s and its dependencies: several gigabytes, which takes minutes",
strings.Join(b.Requirements, " "))
if out, err := in.run(ctx, env, uv.Binary, "pip", "install", "--python", python,
"--require-hashes", "--no-deps", "-r", lock); err != nil {
return fmt.Errorf("installing %s: %v: %s", strings.Join(b.Requirements, " "), err, tail(out))
}
in.progress(st, "checking that %s runs", w.Engine)
if _, _, err := in.verify(ctx, tmp, b); err != nil {
return err
}
m, _ := json.MarshalIndent(markerFile{
Engine: w.Engine, Version: w.Version, Variant: b.Variant,
Binary: filepath.Join("bin", b.Binary), Requirements: b.Requirements,
ExcludeNewer: asOf, UV: UVVersion, Installed: time.Now().UTC(),
}, "", " ")
if err := os.WriteFile(filepath.Join(tmp, marker), m, 0o644); err != nil { //nolint:gosec // not secret
return err
}
_ = os.RemoveAll(dir)
if err := os.Rename(tmp, dir); err != nil {
return err
}
// The cache is as large as the environment. Kept after a failure, so the
// next attempt does not download it all again; dropped after success.
_ = os.RemoveAll(cache)
in.log().Info("installed an engine", "engine", w.Engine, "version", w.Version,
"variant", b.Variant, "dir", dir, "as_of", asOf)
return nil
}
// published is when PyPI received a release's last file.
func (in *Installer) published(ctx context.Context, project, version string) (time.Time, error) {
url := strings.TrimSuffix(in.pypiBase(), "/") + "/pypi/" + project + "/" + version + "/json"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return time.Time{}, err
}
resp, err := in.client().Do(req)
if err != nil {
return time.Time{}, fmt.Errorf("asking PyPI for %s %s: %w", project, version, err)
}
defer resp.Body.Close() //nolint:errcheck // read-only
if resp.StatusCode == http.StatusNotFound {
return time.Time{}, fmt.Errorf("PyPI has no %s %s", project, version)
}
if resp.StatusCode != http.StatusOK {
return time.Time{}, fmt.Errorf("asking PyPI for %s %s: %s", project, version, resp.Status)
}
var rel struct {
URLs []struct {
Uploaded time.Time `json:"upload_time_iso_8601"`
} `json:"urls"`
}
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return time.Time{}, fmt.Errorf("reading PyPI's answer for %s %s: %w", project, version, err)
}
var last time.Time
for _, u := range rel.URLs {
if u.Uploaded.After(last) {
last = u.Uploaded
}
}
if last.IsZero() {
return time.Time{}, errors.New("PyPI lists no files for " + project + " " + version)
}
return last, nil
}
func (in *Installer) free(dir string) (float64, error) {
if in.FreeSpace != nil {
return in.FreeSpace(dir)
}
return FreeGB(dir)
}
func (in *Installer) pypiBase() string {
if in.PyPIBase != "" {
return in.PyPIBase
}
return "https://pypi.org"
}
// runStdin runs a command with input on its standard input.
func (in *Installer) runStdin(ctx context.Context, env []string, stdin, name string, args ...string) ([]byte, error) {
if in.RunStdin != nil {
return in.RunStdin(ctx, env, stdin, name, args...)
}
return runWithStdin(ctx, env, stdin, name, args...)
}
// FreeGB is the space available to an unprivileged writer under dir.
func FreeGB(dir string) (float64, error) {
var s syscall.Statfs_t
if err := syscall.Statfs(dir, &s); err != nil {
return 0, err
}
return float64(s.Bavail) * float64(s.Bsize) / (1 << 30), nil
}
// tail is the last few lines of a command's output: uv explains a failed
// resolution over several, and the last alone is rarely the reason.
func tail(b []byte) string {
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
if len(lines) > 4 {
lines = lines[len(lines)-4:]
}
return strings.Join(lines, " / ")
}