Ctrl K
ComunidadGuías…

internal/backends/manager.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package backends

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/quartermaster-labs/quartermaster/internal/apppaths"
)

// Job phases, in order. A job ends in "done" or "error".
const (
	PhaseResolving   = "resolving"
	PhaseDownloading = "downloading"
	PhaseExtracting  = "extracting"
	PhaseRegistering = "registering"
	PhaseDone        = "done"
	PhaseError       = "error"
)

// maxJobs bounds the job history the UI polls.
const maxJobs = 20

// Job tracks one install from resolve to registration. It is copied out under
// the manager lock, never handed out by pointer.
type Job struct {
	ID         string `json:"id"`
	Component  string `json:"component"`
	Variant    string `json:"variant"`
	Version    string `json:"version"` // resolved tag once known
	Phase      string `json:"phase"`
	Asset      string `json:"asset,omitempty"`
	Downloaded int64  `json:"downloaded"`
	Total      int64  `json:"total"`
	Err        string `json:"error,omitempty"`
	// Warning reports an install that succeeded but produced something that
	// cannot run — an upstream archive missing its GPU runtime, say. It is not
	// an error: the bits are on disk and a user who supplies the runtime gets a
	// working backend, so the install stands and the job says what is wrong.
	Warning  string    `json:"warning,omitempty"`
	Exe      string    `json:"exe,omitempty"`
	Started  time.Time `json:"started"`
	Finished time.Time `json:"finished,omitzero"`
}

// Done reports whether the job reached a terminal phase.
func (j Job) Done() bool { return j.Phase == PhaseDone || j.Phase == PhaseError }

// Manager owns the install directory, the GitHub client and the job list.
//
// One install runs per component at a time (a second request for the same
// component is refused, not queued) — different components install in parallel.
type Manager struct {
	root string // bundle root; installs land under <root>/bin/<component>/
	log  func(string)
	gh   *ghClient
	dl   *http.Client

	// GpuNames reports the host's GPU names for variant auto-selection. Optional.
	GpuNames func() []string
	// OnInstalled is called after a successful install so the caller can point
	// the backend registry at the new exe and regenerate the config. Optional.
	OnInstalled func(Installed) error
	// Preflight inspects a freshly installed executable and returns a
	// human-readable reason it cannot run, or "" when it looks fine. It is a hook
	// for the same reason Sources is: the check is platform-specific binary
	// inspection, and this package depends on the standard library alone.
	// Optional; nil => installs are not checked.
	Preflight func(exe string) string
	// Sources supplies the user's tracked repos, merged after the built-in
	// catalog by Catalog/Find. It is a hook rather than a field so this package
	// keeps depending on the standard library alone — persistence lives in the
	// autogen sidecar, which internal/server owns. Optional; nil => built-ins only.
	Sources func() []Component

	mu     sync.Mutex
	jobs   map[string]*Job
	order  []string
	active map[string]string // component -> job id currently running
	seq    int
}

// NewManager builds a manager rooted at the bundle directory (the folder holding
// the quartermaster executable, unless overridden).
func NewManager(root string, log func(string)) *Manager {
	if log == nil {
		log = func(string) {}
	}
	if strings.TrimSpace(root) == "" {
		root = defaultRoot()
	}
	return &Manager{
		root:   root,
		log:    log,
		gh:     newGHClient(),
		dl:     &http.Client{Timeout: 0}, // per-request context carries the deadline
		jobs:   map[string]*Job{},
		active: map[string]string{},
	}
}

// defaultRoot is the install directory for a packaged quartermaster - the same
// bundle-relative layout the Windows installer uses - and the per-user data
// directory for anything else (see internal/apppaths).
//
// The split matters most here: backends are the biggest thing this program
// writes, and a binary on $PATH cannot write beside itself. Resolving to the
// exe's directory turned "install llama.cpp from the Backends tab" into a
// permission error on every Linux install that was not the wizard's.
//
// QM_BACKENDS_DIR still overrides both, which is what makes the container image
// work: there the executable lives on a read-only image layer, so installs would
// land somewhere that vanishes on the next `docker run`. Pointing this at a
// mounted volume is the whole fix, and it costs no flag plumbing because every
// caller that wants the default passes "".
func defaultRoot() string {
	if v := strings.TrimSpace(os.Getenv("QM_BACKENDS_DIR")); v != "" {
		return v
	}
	return apppaths.DataDir()
}

// Root returns the install root (bin/ hangs off this).
func (m *Manager) Root() string { return m.root }

// Catalog returns the built-in components followed by the user's tracked
// sources. A source whose id collides with a built-in is dropped rather than
// shadowing it — ids are prefixed on creation, so a collision means a
// hand-edited sidecar, and letting it through would let a tracked repo take over
// a built-in's registry row.
func (m *Manager) Catalog() []Component {
	base := Catalog()
	if m.Sources == nil {
		return base
	}
	seen := make(map[string]bool, len(base))
	for _, c := range base {
		seen[strings.ToLower(c.ID)] = true
	}
	out := append([]Component(nil), base...)
	for _, c := range m.Sources() {
		id := strings.ToLower(strings.TrimSpace(c.ID))
		if id == "" || seen[id] {
			continue
		}
		seen[id] = true
		c.Custom = true
		out = append(out, c)
	}
	return out
}

// Find resolves a component id across built-ins and tracked sources. Every
// caller that used to reach for the package-level Find must use this, or a
// tracked repo becomes invisible the moment it is asked about by id.
func (m *Manager) Find(id string) (Component, bool) {
	for _, c := range m.Catalog() {
		if strings.EqualFold(c.ID, id) {
			return c, true
		}
	}
	return Component{}, false
}

// Releases lists a component's recent releases (cached; force refetches).
func (m *Manager) Releases(ctx context.Context, comp string, force bool) ([]Release, error) {
	c, ok := m.Find(comp)
	if !ok {
		return nil, fmt.Errorf("unknown component %q", comp)
	}
	return m.gh.Releases(ctx, c.Repo, force)
}

// ReleasesForRepo lists releases for a repo that is not (yet) a component — the
// asset picker in the "track a repo" form, which runs before anything is saved.
func (m *Manager) ReleasesForRepo(ctx context.Context, repo string, force bool) ([]Release, error) {
	return m.gh.Releases(ctx, repo, force)
}

// Resolve reports what an install would actually fetch right now: the release it
// would resolve to and the asset name it would download. It is the preview the
// UI shows in place of the derived pattern — a file name is something a user can
// judge, a regex is not.
//
// closest/score are filled only when nothing matched, so the UI can say "assets
// look renamed, the nearest is X" instead of a bare failure.
func (m *Manager) Resolve(ctx context.Context, comp, variant, version string) (rel Release, asset string, closest string, score int, err error) {
	c, ok := m.Find(comp)
	if !ok {
		return Release{}, "", "", 0, fmt.Errorf("unknown component %q", comp)
	}
	if c.Manual {
		return Release{}, "", "", 0, fmt.Errorf("%s is not an installable component", c.Name)
	}
	if variant == "" {
		variant = c.DefaultVariant(m.gpuNames(), runtime.GOOS)
	}
	rels, err := m.gh.Releases(ctx, c.Repo, false)
	if err != nil {
		return Release{}, "", "", 0, err
	}
	rel, ok = pickRelease(rels, version, c.AllowPrerelease, func(r Release) bool {
		_, _, err := c.MatchAssets(variant, runtime.GOOS, r.AssetNames())
		return err == nil
	})
	if !ok {
		// No release ships a matching asset — for a tracked repo that usually
		// means upstream renamed things, which is exactly when the closest-asset
		// hint is worth the most. Fall back to the newest release so the caller
		// gets something concrete to compare against instead of "not found".
		if rel, ok = pickRelease(rels, version, true, nil); !ok {
			return Release{}, "", "", 0, noReleaseError(c, rels, version)
		}
	}
	names := rel.AssetNames()
	asset, _, err = c.MatchAssets(variant, runtime.GOOS, names)
	if err != nil {
		if v, ok := c.Variant(variant); ok && v.Exemplar[runtime.GOOS] != "" {
			closest, score = ClosestAsset(v.Exemplar[runtime.GOOS], names)
		}
		return rel, "", closest, score, err
	}
	return rel, asset, "", 0, nil
}

// Jobs returns the job history, newest first.
func (m *Manager) Jobs() []Job {
	m.mu.Lock()
	defer m.mu.Unlock()
	out := make([]Job, 0, len(m.order))
	for _, id := range m.order {
		if j, ok := m.jobs[id]; ok {
			out = append(out, *j)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Started.After(out[j].Started) })
	return out
}

// Job returns one job by id.
func (m *Manager) Job(id string) (Job, bool) {
	m.mu.Lock()
	defer m.mu.Unlock()
	j, ok := m.jobs[id]
	if !ok {
		return Job{}, false
	}
	return *j, true
}

// update mutates a job under the lock.
func (m *Manager) update(id string, fn func(*Job)) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if j, ok := m.jobs[id]; ok {
		fn(j)
	}
}

// Install starts a background install of one component build and returns the
// job id. version "" or "latest" resolves to the newest non-prerelease.
func (m *Manager) Install(comp, variant, version string) (string, error) {
	c, ok := m.Find(comp)
	if !ok {
		return "", fmt.Errorf("unknown component %q", comp)
	}
	if c.Manual {
		return "", fmt.Errorf("%s cannot be installed automatically: %s", c.Name, c.Setup)
	}
	if variant == "" {
		variant = c.DefaultVariant(m.gpuNames(), runtime.GOOS)
	}
	if _, ok := c.Variant(variant); !ok {
		return "", fmt.Errorf("%s: unknown variant %q", comp, variant)
	}

	m.mu.Lock()
	if prev, busy := m.active[c.ID]; busy {
		m.mu.Unlock()
		return "", fmt.Errorf("%s is already installing (job %s)", c.Name, prev)
	}
	m.seq++
	id := c.ID + "-" + strconv.Itoa(m.seq)
	m.jobs[id] = &Job{
		ID: id, Component: c.ID, Variant: variant, Version: version,
		Phase: PhaseResolving, Started: time.Now(),
	}
	m.order = append(m.order, id)
	m.active[c.ID] = id
	// Trim history, never dropping a running job.
	for len(m.order) > maxJobs {
		old := m.order[0]
		if j, ok := m.jobs[old]; ok && !j.Done() {
			break
		}
		delete(m.jobs, old)
		m.order = m.order[1:]
	}
	m.mu.Unlock()

	go m.run(id, c, variant, version)
	return id, nil
}

func (m *Manager) gpuNames() []string {
	if m.GpuNames == nil {
		return nil
	}
	return m.GpuNames()
}

// Suggest returns the variant a fresh install of comp should preselect.
func (m *Manager) Suggest(comp string) string {
	c, ok := m.Find(comp)
	if !ok {
		return ""
	}
	return c.DefaultVariant(m.gpuNames(), runtime.GOOS)
}

// run performs the install. Errors land on the job, never on a caller — the UI
// polls for them.
func (m *Manager) run(id string, c Component, variant, version string) {
	defer func() {
		m.mu.Lock()
		delete(m.active, c.ID)
		m.mu.Unlock()
	}()

	fail := func(err error) {
		m.log(fmt.Sprintf("backend install %s failed: %v", id, err))
		m.update(id, func(j *Job) {
			j.Phase, j.Err, j.Finished = PhaseError, err.Error(), time.Now()
		})
	}

	ctx := context.Background()
	rels, err := m.gh.Releases(ctx, c.Repo, false)
	if err != nil {
		fail(err)
		return
	}
	rel, ok := pickRelease(rels, version, c.AllowPrerelease, func(r Release) bool {
		_, _, err := c.MatchAssets(variant, runtime.GOOS, r.AssetNames())
		return err == nil
	})
	if !ok {
		fail(noReleaseError(c, rels, version))
		return
	}
	primary, extras, err := c.MatchAssets(variant, runtime.GOOS, rel.AssetNames())
	if err != nil {
		fail(err)
		return
	}
	asset, _ := rel.AssetByName(primary)
	m.update(id, func(j *Job) {
		j.Version, j.Asset, j.Total, j.Phase = rel.Tag, primary, asset.Size, PhaseDownloading
	})

	// Stage into a sibling ".tmp" directory so a failed download or extract can
	// never leave a half-built version that readManifest would accept.
	final := m.InstallDir(c.ID, rel.Tag, variant)
	staging := final + ".tmp"
	if err := os.RemoveAll(staging); err != nil {
		fail(err)
		return
	}
	if err := os.MkdirAll(staging, 0o755); err != nil {
		fail(err)
		return
	}
	defer os.RemoveAll(staging) // no-op once renamed away

	tmpDir, err := os.MkdirTemp("", "qm-backend-")
	if err != nil {
		fail(err)
		return
	}
	defer os.RemoveAll(tmpDir)

	fetch := func(a Asset) (string, error) {
		dst := filepath.Join(tmpDir, sanitizeSeg(a.Name))
		err := m.download(ctx, a.URL, dst, func(done, total int64) {
			m.update(id, func(j *Job) {
				j.Downloaded = done
				if total > 0 {
					j.Total = total
				}
			})
		})
		return dst, err
	}

	dl, err := fetch(asset)
	if err != nil {
		fail(err)
		return
	}

	m.update(id, func(j *Job) { j.Phase = PhaseExtracting })
	var exe string
	if c.Bare {
		// The asset IS the executable — no archive, nothing to search for.
		exe = filepath.Join(staging, c.ExeName())
		if err := copyFile(dl, exe); err != nil {
			fail(err)
			return
		}
		if runtime.GOOS != "windows" {
			_ = os.Chmod(exe, 0o755)
		}
	} else {
		if err := extract(dl, staging); err != nil {
			fail(err)
			return
		}
		// Extras (the CUDA runtime) go next to the primary build. Best-effort:
		// a missing cudart shouldn't discard an otherwise-good llama-server.
		for _, name := range extras {
			ea, ok := rel.AssetByName(name)
			if !ok {
				continue
			}
			ed, err := fetch(ea)
			if err != nil {
				m.log(fmt.Sprintf("backend install %s: extra asset %s failed: %v", id, name, err))
				continue
			}
			if err := extract(ed, staging); err != nil {
				m.log(fmt.Sprintf("backend install %s: extracting %s failed: %v", id, name, err))
			}
		}
		exe, err = findExe(staging, c.ExeName())
		if err != nil {
			fail(err)
			return
		}
	}

	relExe, err := filepath.Rel(staging, exe)
	if err != nil {
		fail(err)
		return
	}
	// Whole install, not just the executable: the DLLs beside it are most of the
	// download. Extras (cudart) are already unpacked at this point.
	size, _ := dirSize(staging)
	if err := writeManifest(staging, manifest{
		Component: c.ID, Version: rel.Tag, Variant: variant,
		Exe: filepath.ToSlash(relExe), Asset: primary,
		InstalledAt: time.Now(), SizeBytes: size,
	}); err != nil {
		fail(err)
		return
	}

	// Reinstalling the same version+variant replaces it wholesale.
	if err := os.RemoveAll(final); err != nil {
		fail(err)
		return
	}
	if err := os.MkdirAll(filepath.Dir(final), 0o755); err != nil {
		fail(err)
		return
	}
	if err := os.Rename(staging, final); err != nil {
		fail(err)
		return
	}

	inst, err := readManifest(final)
	if err != nil {
		fail(err)
		return
	}
	// Check the unpacked build before registering it. Upstream archives are not
	// always self-contained (stable-diffusion.cpp's Windows ROCm zip ships no HIP
	// runtime at all), and a backend that cannot load dies before it can log —
	// so if we do not say this here, the user finds out as an unexplained
	// "upstream command exited prematurely" at generation time.
	var warning string
	if m.Preflight != nil {
		if warning = m.Preflight(inst.Exe); warning != "" {
			m.log(fmt.Sprintf("backend install %s: %s", id, warning))
		}
	}
	m.update(id, func(j *Job) { j.Phase, j.Exe, j.Warning = PhaseRegistering, inst.Exe, warning })
	if m.OnInstalled != nil {
		if err := m.OnInstalled(inst); err != nil {
			fail(fmt.Errorf("installed to %s but registering it failed: %w", inst.Dir, err))
			return
		}
	}
	m.log(fmt.Sprintf("installed %s %s (%s) -> %s", c.ID, rel.Tag, variant, inst.Exe))
	m.update(id, func(j *Job) { j.Phase, j.Finished = PhaseDone, time.Now() })
}

func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	return writeFile(dst, in, 0o755)
}