mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-04-09 11:56:50 +00:00
Static (none) first on all platforms — no runtime dependencies. Linux: none, gnu, musl. Windows: none, msvc (vcredist not bundled).
417 lines
10 KiB
Go
417 lines
10 KiB
Go
// Package resolver selects the best release asset for a given platform
|
||
// and version constraint.
|
||
//
|
||
// The resolver takes a package's full asset list and a request describing
|
||
// what the client needs (OS, arch, libc, version prefix, channel, format
|
||
// preferences). It returns the single best matching asset or an error.
|
||
//
|
||
// Resolution order:
|
||
// 1. Filter assets by channel (inclusive: @stable includes stable+lts)
|
||
// 2. Sort versions descending, filter by version prefix if given
|
||
// 3. For each candidate version, try compatible platform triplets
|
||
// (OS × CompatArches fallback × libc) in preference order
|
||
// 4. Among platform matches, pick the best format
|
||
// 5. Among format matches, prefer assets without build variants
|
||
package resolver
|
||
|
||
import (
|
||
"errors"
|
||
"slices"
|
||
"strings"
|
||
|
||
"github.com/webinstall/webi-installers/internal/buildmeta"
|
||
"github.com/webinstall/webi-installers/internal/lexver"
|
||
"github.com/webinstall/webi-installers/internal/storage"
|
||
)
|
||
|
||
// ErrNoMatch is returned when no asset matches the request.
|
||
var ErrNoMatch = errors.New("resolver: no matching asset")
|
||
|
||
// Request describes what the client is looking for.
|
||
type Request struct {
|
||
// OS is the target operating system (e.g. "linux", "darwin", "windows").
|
||
OS string
|
||
|
||
// Arch is the target architecture (e.g. "aarch64", "x86_64").
|
||
Arch string
|
||
|
||
// Libc is the preferred C library (e.g. "gnu", "musl", "msvc").
|
||
// Empty means no preference — the resolver tries all libc values.
|
||
Libc string
|
||
|
||
// Version is a version prefix constraint (e.g. "1.20", "1", "").
|
||
// Empty means latest. Exact versions like "1.20.3" also work.
|
||
Version string
|
||
|
||
// Channel selects the release stability level. Values:
|
||
// ""/"stable" — stable and LTS only (default)
|
||
// "lts" — LTS releases only
|
||
// "rc" — rc + stable + LTS
|
||
// "beta" — beta + rc + stable + LTS
|
||
// "alpha" — everything (alpha + beta + rc + stable + LTS)
|
||
// "pre" — alias for beta (package-specific meaning)
|
||
Channel string
|
||
|
||
// LTS when true selects only LTS-flagged releases.
|
||
LTS bool
|
||
|
||
// Formats lists acceptable archive formats in preference order.
|
||
// If empty, a default preference order is used.
|
||
Formats []string
|
||
|
||
// Variant selects a specific build variant (e.g. "rocm", "jetpack6").
|
||
// If empty, assets with variants are deprioritized.
|
||
Variant string
|
||
}
|
||
|
||
// Result holds the resolved asset and metadata about the match.
|
||
type Result struct {
|
||
// Asset is the selected download.
|
||
Asset storage.Asset
|
||
|
||
// Version is the matched version string.
|
||
Version string
|
||
|
||
// Triplet is the matched platform triplet (os-arch-libc).
|
||
Triplet string
|
||
}
|
||
|
||
// Resolve finds the best matching asset for the given request.
|
||
func Resolve(assets []storage.Asset, req Request) (Result, error) {
|
||
if len(assets) == 0 {
|
||
return Result{}, ErrNoMatch
|
||
}
|
||
|
||
// Parse the version prefix for filtering.
|
||
var versionPrefix lexver.Version
|
||
hasPrefix := req.Version != ""
|
||
if hasPrefix {
|
||
versionPrefix = lexver.Parse(req.Version)
|
||
}
|
||
|
||
// Build the channel filter.
|
||
channelOK := channelFilter(req.Channel, req.LTS)
|
||
|
||
// Parse and sort all unique versions descending.
|
||
type versionEntry struct {
|
||
parsed lexver.Version
|
||
raw string
|
||
}
|
||
seen := make(map[string]bool)
|
||
var versions []versionEntry
|
||
for _, a := range assets {
|
||
if seen[a.Version] {
|
||
continue
|
||
}
|
||
seen[a.Version] = true
|
||
v := lexver.Parse(a.Version)
|
||
v.Raw = a.Version
|
||
versions = append(versions, versionEntry{parsed: v, raw: a.Version})
|
||
}
|
||
slices.SortFunc(versions, func(a, b versionEntry) int {
|
||
return lexver.Compare(b.parsed, a.parsed) // descending
|
||
})
|
||
|
||
// Build platform fallback list: ordered (os, arch, libc) combinations.
|
||
triplets := enumerateTriplets(req.OS, req.Arch, req.Libc)
|
||
|
||
// Build format preference list.
|
||
formats := req.Formats
|
||
if len(formats) == 0 {
|
||
formats = defaultFormats(req.OS)
|
||
}
|
||
|
||
// Index assets by version+triplet for fast lookup.
|
||
// Assets with empty OS/Arch (like git repos) use "" keys.
|
||
type tripletKey struct {
|
||
version string
|
||
os string
|
||
arch string
|
||
libc string
|
||
}
|
||
index := make(map[tripletKey][]storage.Asset)
|
||
for _, a := range assets {
|
||
key := tripletKey{
|
||
version: a.Version,
|
||
os: a.OS,
|
||
arch: a.Arch,
|
||
libc: a.Libc,
|
||
}
|
||
index[key] = append(index[key], a)
|
||
}
|
||
|
||
// Walk versions in descending order.
|
||
for _, ve := range versions {
|
||
// Check version prefix.
|
||
if hasPrefix && !ve.parsed.HasPrefix(versionPrefix) {
|
||
continue
|
||
}
|
||
|
||
// Check channel.
|
||
if !channelOK(ve.parsed.Channel, ve.raw) {
|
||
continue
|
||
}
|
||
|
||
// Try each compatible triplet.
|
||
for _, tri := range triplets {
|
||
key := tripletKey{
|
||
version: ve.raw,
|
||
os: tri.os,
|
||
arch: tri.arch,
|
||
libc: tri.libc,
|
||
}
|
||
candidates := index[key]
|
||
if len(candidates) == 0 {
|
||
continue
|
||
}
|
||
|
||
// Pick the best asset from candidates.
|
||
best, ok := pickBest(candidates, formats, req.Variant, req.LTS)
|
||
if !ok {
|
||
continue
|
||
}
|
||
|
||
triplet := tri.os + "-" + tri.arch + "-" + tri.libc
|
||
return Result{
|
||
Asset: best,
|
||
Version: ve.raw,
|
||
Triplet: triplet,
|
||
}, nil
|
||
}
|
||
}
|
||
|
||
return Result{}, ErrNoMatch
|
||
}
|
||
|
||
// channelFilter returns a function that checks whether a given channel
|
||
// is acceptable for the requested channel level.
|
||
func channelFilter(requested string, ltsOnly bool) func(channel string, version string) bool {
|
||
if ltsOnly {
|
||
return func(_ string, _ string) bool {
|
||
// LTS filtering happens at the asset level, not version level.
|
||
// We let all versions through and filter by LTS flag later.
|
||
// Actually, LTS is per-asset, so we handle it in pickBest.
|
||
return true
|
||
}
|
||
}
|
||
|
||
requested = strings.ToLower(requested)
|
||
if requested == "" {
|
||
requested = "stable"
|
||
}
|
||
if requested == "pre" {
|
||
requested = "beta"
|
||
}
|
||
if requested == "latest" {
|
||
requested = "stable"
|
||
}
|
||
|
||
// channelRank maps channel names to a numeric rank.
|
||
// Higher rank = less stable. A request for rank N accepts
|
||
// everything at rank N or below.
|
||
rank := func(ch string) int {
|
||
ch = strings.ToLower(ch)
|
||
switch ch {
|
||
case "", "stable":
|
||
return 0
|
||
case "rc":
|
||
return 1
|
||
case "beta", "preview":
|
||
return 2
|
||
case "alpha", "dev":
|
||
return 3
|
||
default:
|
||
return 2 // unknown pre-release channels default to beta-level
|
||
}
|
||
}
|
||
|
||
maxRank := rank(requested)
|
||
return func(channel string, _ string) bool {
|
||
return rank(channel) <= maxRank
|
||
}
|
||
}
|
||
|
||
type platformTriple struct {
|
||
os string
|
||
arch string
|
||
libc string
|
||
}
|
||
|
||
// enumerateTriplets builds the ordered list of platform combinations to try.
|
||
// It uses CompatArches for arch fallback and tries multiple libc values.
|
||
func enumerateTriplets(osStr, archStr, libcStr string) []platformTriple {
|
||
// OS candidates: specific OS first, then POSIX compat, then any.
|
||
var oses []string
|
||
switch osStr {
|
||
case "windows":
|
||
oses = []string{"windows", "ANYOS", ""}
|
||
case "android":
|
||
oses = []string{"android", "linux", "posix_2024", "posix_2017", "ANYOS", ""}
|
||
case "":
|
||
oses = []string{"ANYOS", ""}
|
||
default:
|
||
oses = []string{osStr, "posix_2024", "posix_2017", "ANYOS", ""}
|
||
}
|
||
|
||
// Arch candidates: use CompatArches for fallback chain.
|
||
arches := buildmeta.CompatArches(buildmeta.OS(osStr), buildmeta.Arch(archStr))
|
||
var archStrs []string
|
||
for _, a := range arches {
|
||
archStrs = append(archStrs, string(a))
|
||
}
|
||
// Also try ANYARCH and empty (for platform-agnostic assets like git repos).
|
||
archStrs = append(archStrs, "ANYARCH", "")
|
||
|
||
// Libc candidates.
|
||
var libcs []string
|
||
if libcStr != "" {
|
||
libcs = []string{libcStr, "none", ""}
|
||
} else {
|
||
// No preference: try all common options.
|
||
switch osStr {
|
||
case "linux":
|
||
// none first (static, no deps), then gnu, musl, empty.
|
||
libcs = []string{"none", "gnu", "musl", ""}
|
||
case "windows":
|
||
// none first (no deps), msvc last (needs vcredist).
|
||
libcs = []string{"none", "msvc", ""}
|
||
default:
|
||
libcs = []string{"none", ""}
|
||
}
|
||
}
|
||
|
||
var triplets []platformTriple
|
||
for _, os := range oses {
|
||
for _, arch := range archStrs {
|
||
for _, libc := range libcs {
|
||
triplets = append(triplets, platformTriple{
|
||
os: os,
|
||
arch: arch,
|
||
libc: libc,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
return triplets
|
||
}
|
||
|
||
// pickBest selects the best asset from a set of candidates for the same
|
||
// version and platform. Prefers the requested variant (or no-variant if
|
||
// none requested), then picks by format preference.
|
||
func pickBest(candidates []storage.Asset, formats []string, wantVariant string, ltsOnly bool) (storage.Asset, bool) {
|
||
// Filter by LTS if requested.
|
||
if ltsOnly {
|
||
var lts []storage.Asset
|
||
for _, a := range candidates {
|
||
if a.LTS {
|
||
lts = append(lts, a)
|
||
}
|
||
}
|
||
if len(lts) == 0 {
|
||
return storage.Asset{}, false
|
||
}
|
||
candidates = lts
|
||
}
|
||
|
||
// Separate into variant-matched and non-variant pools.
|
||
var preferred []storage.Asset
|
||
var fallback []storage.Asset
|
||
|
||
for _, a := range candidates {
|
||
if wantVariant != "" {
|
||
// User requested a specific variant.
|
||
if hasVariant(a.Variants, wantVariant) {
|
||
preferred = append(preferred, a)
|
||
} else if len(a.Variants) == 0 {
|
||
fallback = append(fallback, a)
|
||
}
|
||
} else {
|
||
// No variant requested: prefer no-variant assets.
|
||
if len(a.Variants) == 0 {
|
||
preferred = append(preferred, a)
|
||
} else {
|
||
fallback = append(fallback, a)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Try preferred pool first, then fallback.
|
||
for _, pool := range [][]storage.Asset{preferred, fallback} {
|
||
if len(pool) == 0 {
|
||
continue
|
||
}
|
||
if best, ok := pickByFormat(pool, formats); ok {
|
||
return best, true
|
||
}
|
||
}
|
||
|
||
return storage.Asset{}, false
|
||
}
|
||
|
||
// pickByFormat selects the asset with the most preferred format.
|
||
func pickByFormat(assets []storage.Asset, formats []string) (storage.Asset, bool) {
|
||
for _, fmt := range formats {
|
||
for _, a := range assets {
|
||
if a.Format == fmt {
|
||
return a, true
|
||
}
|
||
}
|
||
}
|
||
// No format match — return the first asset as last resort.
|
||
if len(assets) > 0 {
|
||
return assets[0], true
|
||
}
|
||
return storage.Asset{}, false
|
||
}
|
||
|
||
func hasVariant(variants []string, want string) bool {
|
||
for _, v := range variants {
|
||
if v == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// defaultFormats returns the format preference order for an OS.
|
||
// zst is preferred as the modern standard, but availability varies.
|
||
func defaultFormats(os string) []string {
|
||
switch os {
|
||
case "windows":
|
||
return []string{
|
||
".tar.zst",
|
||
".tar.xz",
|
||
".zip",
|
||
".tar.gz",
|
||
".exe.xz",
|
||
".7z",
|
||
".exe",
|
||
".msi",
|
||
"git",
|
||
}
|
||
case "darwin":
|
||
return []string{
|
||
".tar.zst",
|
||
".tar.xz",
|
||
".zip",
|
||
".tar.gz",
|
||
".gz",
|
||
".app.zip",
|
||
".dmg",
|
||
".pkg",
|
||
"git",
|
||
}
|
||
default:
|
||
// Linux and other POSIX.
|
||
return []string{
|
||
".tar.zst",
|
||
".tar.xz",
|
||
".tar.gz",
|
||
".gz",
|
||
".zip",
|
||
".xz",
|
||
"git",
|
||
}
|
||
}
|
||
}
|