Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
323 lines
12 KiB
Go
323 lines
12 KiB
Go
package provision
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"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"
|
|
|
|
// pylockName is the lock of a build with a second index: PEP 751's, with
|
|
// every file's address.
|
|
const pylockName = "pylock.llmgw.toml"
|
|
|
|
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()
|
|
// The version this build is, which for a GPU older than every build of
|
|
// the one asked for is an older one: its publication date is the one
|
|
// the dependencies are resolved as of.
|
|
w.Version = b.versionOr(w.Version)
|
|
|
|
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")
|
|
|
|
reqs := b.Requirements
|
|
if b.Wheel != nil {
|
|
// The engine from its GitHub release, pinned to the digest GitHub
|
|
// publishes for the file: the same trust as an archive, and uv
|
|
// refuses a download that does not match it.
|
|
in.progress(st, "asking GitHub for %s %s's %s…%s", b.Wheel.Repo, b.Wheel.Tag, b.Wheel.Prefix, b.Wheel.Suffix)
|
|
sums, err := in.checksums(ctx, b.Wheel.Repo, b.Wheel.Tag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var asset, sum string
|
|
for name, s := range sums {
|
|
if strings.HasPrefix(name, b.Wheel.Prefix) && strings.HasSuffix(name, b.Wheel.Suffix) {
|
|
asset, sum = name, s
|
|
}
|
|
}
|
|
if asset == "" {
|
|
return fmt.Errorf("%s %s publishes no %s…%s", b.Wheel.Repo, b.Wheel.Tag, b.Wheel.Prefix, b.Wheel.Suffix)
|
|
}
|
|
url := strings.TrimSuffix(in.downloadBase(), "/") + "/" + b.Wheel.Repo + "/releases/download/" +
|
|
b.Wheel.Tag + "/" + asset
|
|
reqs = append([]string{b.Wheel.Project + " @ " + url + "#sha256=" + sum}, reqs...)
|
|
}
|
|
in.progress(st, "resolving %s against PyPI as of %s", strings.Join(reqs, " "), asOf[:10])
|
|
lock, err := in.resolve(ctx, env, uv.Binary, python, tmp, asOf, reqs, b)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
in.progress(st, "downloading and installing %s and its dependencies: several gigabytes, which takes minutes",
|
|
strings.Join(reqs, " "))
|
|
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(reqs, " "), err, tail(out))
|
|
}
|
|
|
|
in.progress(st, "checking that %s runs", w.Engine)
|
|
if _, _, err := in.verify(ctx, tmp, b); err != nil {
|
|
return err
|
|
}
|
|
if b.GPUCheck && in.Host.NVIDIADriver != "" {
|
|
in.progress(st, "checking that %s (%s) computes on this node's GPU", w.Engine, b.Variant)
|
|
if err := in.gpuCheck(ctx, python, w, 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: reqs,
|
|
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
|
|
}
|
|
|
|
// resolve pins every package the requirements need, each file by its hash,
|
|
// into a lock in the environment, and returns its path.
|
|
//
|
|
// With a second index — PyTorch's, for the torch a build was made against —
|
|
// the packages it is for come from it and nowhere else, and every other
|
|
// package from PyPI alone. Letting both indexes offer every package is how
|
|
// torchtriton was taken in 2022: a name one index had, registered on the
|
|
// other with a higher version. So the second index is explicit, named only
|
|
// for its packages, and the lock is PEP 751's, which records each file's
|
|
// address as well as its hash: installing it asks no index anything.
|
|
func (in *Installer) resolve(ctx context.Context, env []string, uv, python, tmp, asOf string,
|
|
reqs []string, b Build,
|
|
) (string, error) {
|
|
if b.ExtraIndex == "" {
|
|
lock := filepath.Join(tmp, lockName)
|
|
if out, err := in.runStdin(ctx, env, strings.Join(reqs, "\n")+"\n", uv, "pip", "compile",
|
|
"--python", python, "--exclude-newer", asOf, "--generate-hashes", "--quiet",
|
|
"--output-file", lock, "-"); err != nil {
|
|
return "", fmt.Errorf("resolving %s: %v: %s", strings.Join(reqs, " "), err, tail(out))
|
|
}
|
|
return lock, nil
|
|
}
|
|
|
|
project := tmp + ".resolve"
|
|
if err := os.MkdirAll(project, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(project) //nolint:errcheck // scratch
|
|
if err := os.WriteFile(filepath.Join(project, "pyproject.toml"), []byte(resolveProject(reqs, b)), 0o644); err != nil { //nolint:gosec // not secret
|
|
return "", err
|
|
}
|
|
lock := filepath.Join(tmp, pylockName)
|
|
if out, err := in.run(ctx, env, uv, "pip", "compile", "--python", python, "--exclude-newer", asOf,
|
|
"--format", "pylock.toml", "--quiet", "--output-file", lock,
|
|
filepath.Join(project, "pyproject.toml")); err != nil {
|
|
return "", fmt.Errorf("resolving %s: %v: %s", strings.Join(reqs, " "), err, tail(out))
|
|
}
|
|
return lock, nil
|
|
}
|
|
|
|
// resolveProject is the pyproject.toml a build with a second index is
|
|
// resolved from. Its packages are direct requirements too: an index is
|
|
// given to a package only where the project names it.
|
|
func resolveProject(reqs []string, b Build) string {
|
|
var p strings.Builder
|
|
p.WriteString("[project]\nname = \"llmgw-engine\"\nversion = \"0\"\ndependencies = [\n")
|
|
for _, r := range append(slices.Clone(reqs), b.IndexPackages...) {
|
|
fmt.Fprintf(&p, " %s,\n", strconv.Quote(r))
|
|
}
|
|
p.WriteString("]\n\n[[tool.uv.index]]\nname = \"extra\"\n")
|
|
fmt.Fprintf(&p, "url = %s\nexplicit = true\n\n[tool.uv.sources]\n", strconv.Quote(b.ExtraIndex))
|
|
for _, name := range b.IndexPackages {
|
|
fmt.Fprintf(&p, "%s = { index = \"extra\" }\n", name)
|
|
}
|
|
return p.String()
|
|
}
|
|
|
|
// 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, " / ")
|
|
}
|
|
|
|
// gpuProbe is what the environment is asked to run on the GPU: one small
|
|
// operation, which needs the torch to have kernels for the card.
|
|
const gpuProbe = `import torch
|
|
assert torch.cuda.is_available(), "torch sees no CUDA device"
|
|
x = torch.ones(64, device="cuda")
|
|
print(float((x * 2).sum()), torch.cuda.get_device_name(0), torch.cuda.get_device_capability(0))`
|
|
|
|
// gpuCheck runs gpuProbe in a freshly installed environment.
|
|
func (in *Installer) gpuCheck(ctx context.Context, python string, w Want, b Build) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
|
defer cancel()
|
|
out, err := in.run(ctx, nil, python, "-c", gpuProbe)
|
|
if err != nil {
|
|
hint := ""
|
|
if strings.Contains(string(out), "no kernel image") {
|
|
hint = "; this build has no kernels for this GPU, so try the other build for this node"
|
|
}
|
|
return fmt.Errorf("%s %s (%s) installed but cannot compute on this node's GPU: %s%s",
|
|
w.Engine, w.Version, b.Variant, tail(out), hint)
|
|
}
|
|
return nil
|
|
}
|