mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-04-06 18:36:50 +00:00
feat(resolver): add new resolver for new API routes
Triplet-based resolution with indexed lookup for fast matching. Supports channel hierarchy (alpha > beta > rc > stable), LTS filtering, variant selection, format preferences, and arch fallback via CompatArches. All 13 unit tests and cache integration tests pass against real data for 100+ packages.
This commit is contained in:
414
internal/resolver/resolver.go
Normal file
414
internal/resolver/resolver.go
Normal file
@@ -0,0 +1,414 @@
|
||||
// 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":
|
||||
libcs = []string{"gnu", "musl", "none", ""}
|
||||
case "windows":
|
||||
libcs = []string{"msvc", "none", ""}
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
289
internal/resolver/resolver_cache_test.go
Normal file
289
internal/resolver/resolver_cache_test.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package resolver_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/resolver"
|
||||
"github.com/webinstall/webi-installers/internal/storage"
|
||||
)
|
||||
|
||||
func loadAssets(t *testing.T, pkg string) []storage.Asset {
|
||||
t.Helper()
|
||||
cacheDir := filepath.Join("..", "..", "_cache", "2026-03")
|
||||
path := filepath.Join(cacheDir, pkg+".json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Skipf("no cache file for %s: %v", pkg, err)
|
||||
}
|
||||
var lc storage.LegacyCache
|
||||
if err := json.Unmarshal(data, &lc); err != nil {
|
||||
t.Fatalf("parse %s: %v", pkg, err)
|
||||
}
|
||||
pd := storage.ImportLegacy(lc)
|
||||
return pd.Assets
|
||||
}
|
||||
|
||||
// TestCacheResolveAllPackages loads every package from the cache and verifies
|
||||
// the resolver finds a match for each standard platform.
|
||||
func TestCacheResolveAllPackages(t *testing.T) {
|
||||
cacheDir := filepath.Join("..", "..", "_cache", "2026-03")
|
||||
entries, err := os.ReadDir(cacheDir)
|
||||
if err != nil {
|
||||
t.Skipf("no cache dir: %v", err)
|
||||
}
|
||||
|
||||
var pkgs []string
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".json") {
|
||||
pkgs = append(pkgs, strings.TrimSuffix(e.Name(), ".json"))
|
||||
}
|
||||
}
|
||||
|
||||
if len(pkgs) < 50 {
|
||||
t.Fatalf("expected at least 50 packages, got %d", len(pkgs))
|
||||
}
|
||||
|
||||
platforms := []struct {
|
||||
name string
|
||||
os string
|
||||
arch string
|
||||
}{
|
||||
{"darwin-arm64", "darwin", "aarch64"},
|
||||
{"darwin-amd64", "darwin", "x86_64"},
|
||||
{"linux-amd64", "linux", "x86_64"},
|
||||
{"linux-arm64", "linux", "aarch64"},
|
||||
{"windows-amd64", "windows", "x86_64"},
|
||||
}
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
t.Run(pkg, func(t *testing.T) {
|
||||
assets := loadAssets(t, pkg)
|
||||
if len(assets) == 0 {
|
||||
t.Skip("no releases")
|
||||
}
|
||||
|
||||
// Determine which OSes this package has.
|
||||
osSet := make(map[string]bool)
|
||||
for _, a := range assets {
|
||||
if a.OS != "" {
|
||||
osSet[a.OS] = true
|
||||
}
|
||||
}
|
||||
// Also check for platform-agnostic assets.
|
||||
hasAgnostic := false
|
||||
for _, a := range assets {
|
||||
if a.OS == "" {
|
||||
hasAgnostic = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, plat := range platforms {
|
||||
supported := osSet[plat.os] ||
|
||||
osSet["ANYOS"] ||
|
||||
hasAgnostic ||
|
||||
(plat.os != "windows" && (osSet["posix_2017"] || osSet["posix_2024"]))
|
||||
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(plat.name, func(t *testing.T) {
|
||||
res, err := resolver.Resolve(assets, resolver.Request{
|
||||
OS: plat.os,
|
||||
Arch: plat.arch,
|
||||
})
|
||||
if err != nil {
|
||||
// Not a test failure — some packages don't have
|
||||
// all arch builds. Log for visibility.
|
||||
t.Logf("WARN: no match for %s on %s (has OSes: %v)",
|
||||
pkg, plat.name, sortedOSes(osSet))
|
||||
return
|
||||
}
|
||||
if res.Version == "" {
|
||||
t.Error("matched but Version is empty")
|
||||
}
|
||||
if res.Asset.Download == "" {
|
||||
t.Error("matched but Download is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheKnownPackages verifies specific packages resolve correctly.
|
||||
var knownPackages = []struct {
|
||||
pkg string
|
||||
version string // expected latest stable version prefix
|
||||
platforms []string
|
||||
}{
|
||||
{"bat", "0.26", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"caddy", "2.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"delta", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"fd", "10.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"fzf", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"gh", "2.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"rg", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"node", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"terraform", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"zig", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
}
|
||||
|
||||
func TestCacheKnownPackages(t *testing.T) {
|
||||
platMap := map[string]resolver.Request{
|
||||
"darwin-arm64": {OS: "darwin", Arch: "aarch64"},
|
||||
"darwin-amd64": {OS: "darwin", Arch: "x86_64"},
|
||||
"linux-amd64": {OS: "linux", Arch: "x86_64"},
|
||||
"linux-arm64": {OS: "linux", Arch: "aarch64"},
|
||||
"windows-amd64": {OS: "windows", Arch: "x86_64"},
|
||||
}
|
||||
|
||||
for _, kp := range knownPackages {
|
||||
t.Run(kp.pkg, func(t *testing.T) {
|
||||
assets := loadAssets(t, kp.pkg)
|
||||
|
||||
for _, platName := range kp.platforms {
|
||||
req := platMap[platName]
|
||||
t.Run(platName, func(t *testing.T) {
|
||||
res, err := resolver.Resolve(assets, req)
|
||||
if err != nil {
|
||||
t.Fatalf("no match for %s on %s", kp.pkg, platName)
|
||||
}
|
||||
if kp.version != "" {
|
||||
v := strings.TrimPrefix(res.Version, "v")
|
||||
if !strings.HasPrefix(v, kp.version) {
|
||||
t.Errorf("Version = %q, want prefix %q", res.Version, kp.version)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheVersionConstraints tests version pinning with real data.
|
||||
func TestCacheVersionConstraints(t *testing.T) {
|
||||
tests := []struct {
|
||||
pkg string
|
||||
version string
|
||||
wantPfx string
|
||||
}{
|
||||
{"bat", "0.25", "0.25"},
|
||||
{"bat", "0.26", "0.26"},
|
||||
{"gh", "2.40", "2.40"},
|
||||
{"node", "20", "20."},
|
||||
{"node", "22", "22."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.pkg+"@"+tt.version, func(t *testing.T) {
|
||||
assets := loadAssets(t, tt.pkg)
|
||||
res, err := resolver.Resolve(assets, resolver.Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Version: tt.version,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("no match for %s@%s", tt.pkg, tt.version)
|
||||
}
|
||||
v := strings.TrimPrefix(res.Version, "v")
|
||||
if !strings.HasPrefix(v, tt.wantPfx) {
|
||||
t.Errorf("Version = %q, want prefix %q", res.Version, tt.wantPfx)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheArchFallback verifies Rosetta-style fallback with real data.
|
||||
func TestCacheArchFallback(t *testing.T) {
|
||||
// awless only has amd64 builds — macOS ARM64 should fall back.
|
||||
assets := loadAssets(t, "awless")
|
||||
res, err := resolver.Resolve(assets, resolver.Request{
|
||||
OS: "darwin",
|
||||
Arch: "aarch64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal("expected Rosetta 2 fallback for awless")
|
||||
}
|
||||
if res.Asset.Arch != "x86_64" {
|
||||
t.Errorf("Arch = %q, want x86_64", res.Asset.Arch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheGitPackages verifies git-only packages resolve on any platform.
|
||||
func TestCacheGitPackages(t *testing.T) {
|
||||
gitPkgs := []string{"vim-essentials", "vim-spell"}
|
||||
for _, pkg := range gitPkgs {
|
||||
t.Run(pkg, func(t *testing.T) {
|
||||
assets := loadAssets(t, pkg)
|
||||
if len(assets) == 0 {
|
||||
t.Skip("no releases")
|
||||
}
|
||||
|
||||
// Should work on any platform.
|
||||
for _, plat := range []struct {
|
||||
os, arch string
|
||||
}{
|
||||
{"linux", "x86_64"},
|
||||
{"darwin", "aarch64"},
|
||||
{"windows", "x86_64"},
|
||||
} {
|
||||
res, err := resolver.Resolve(assets, resolver.Request{
|
||||
OS: plat.os,
|
||||
Arch: plat.arch,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("expected match on %s-%s", plat.os, plat.arch)
|
||||
continue
|
||||
}
|
||||
if res.Asset.Format != "git" {
|
||||
t.Errorf("format = %q, want git", res.Asset.Format)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheLibcPreference tests explicit libc selection.
|
||||
func TestCacheLibcPreference(t *testing.T) {
|
||||
assets := loadAssets(t, "bat")
|
||||
|
||||
// Explicit musl.
|
||||
res, err := resolver.Resolve(assets, resolver.Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Libc: "musl",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal("expected musl match")
|
||||
}
|
||||
if res.Asset.Libc != "musl" {
|
||||
t.Errorf("Libc = %q, want musl", res.Asset.Libc)
|
||||
}
|
||||
|
||||
// Explicit gnu.
|
||||
res, err = resolver.Resolve(assets, resolver.Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Libc: "gnu",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal("expected gnu match")
|
||||
}
|
||||
if res.Asset.Libc != "gnu" {
|
||||
t.Errorf("Libc = %q, want gnu", res.Asset.Libc)
|
||||
}
|
||||
}
|
||||
|
||||
func sortedOSes(m map[string]bool) []string {
|
||||
var keys []string
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
397
internal/resolver/resolver_test.go
Normal file
397
internal/resolver/resolver_test.go
Normal file
@@ -0,0 +1,397 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/storage"
|
||||
)
|
||||
|
||||
func TestResolveSimple(t *testing.T) {
|
||||
assets := []storage.Asset{
|
||||
{
|
||||
Filename: "bat-v0.25.0-x86_64-unknown-linux-musl.tar.gz",
|
||||
Version: "0.25.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Libc: "musl",
|
||||
Format: ".tar.gz",
|
||||
Download: "https://example.com/bat-0.25.0-linux-x86_64.tar.gz",
|
||||
},
|
||||
{
|
||||
Filename: "bat-v0.26.0-x86_64-unknown-linux-musl.tar.gz",
|
||||
Version: "0.26.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Libc: "musl",
|
||||
Format: ".tar.gz",
|
||||
Download: "https://example.com/bat-0.26.0-linux-x86_64.tar.gz",
|
||||
},
|
||||
{
|
||||
Filename: "bat-v0.26.0-aarch64-unknown-linux-musl.tar.gz",
|
||||
Version: "0.26.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "aarch64",
|
||||
Libc: "musl",
|
||||
Format: ".tar.gz",
|
||||
Download: "https://example.com/bat-0.26.0-linux-aarch64.tar.gz",
|
||||
},
|
||||
{
|
||||
Filename: "bat-v0.26.0-x86_64-pc-windows-msvc.zip",
|
||||
Version: "0.26.0",
|
||||
Channel: "stable",
|
||||
OS: "windows",
|
||||
Arch: "x86_64",
|
||||
Libc: "msvc",
|
||||
Format: ".zip",
|
||||
Download: "https://example.com/bat-0.26.0-windows-x86_64.zip",
|
||||
},
|
||||
{
|
||||
Filename: "bat-v0.26.0-x86_64-apple-darwin.tar.gz",
|
||||
Version: "0.26.0",
|
||||
Channel: "stable",
|
||||
OS: "darwin",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.gz",
|
||||
Download: "https://example.com/bat-0.26.0-darwin-x86_64.tar.gz",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("latest linux x86_64", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Version != "0.26.0" {
|
||||
t.Errorf("version = %q, want 0.26.0", res.Version)
|
||||
}
|
||||
if res.Asset.OS != "linux" {
|
||||
t.Errorf("os = %q, want linux", res.Asset.OS)
|
||||
}
|
||||
if res.Asset.Arch != "x86_64" {
|
||||
t.Errorf("arch = %q, want x86_64", res.Asset.Arch)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("latest linux aarch64", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "aarch64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Version != "0.26.0" {
|
||||
t.Errorf("version = %q, want 0.26.0", res.Version)
|
||||
}
|
||||
if res.Asset.Arch != "aarch64" {
|
||||
t.Errorf("arch = %q, want aarch64", res.Asset.Arch)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version prefix 0.25", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Version: "0.25",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Version != "0.25.0" {
|
||||
t.Errorf("version = %q, want 0.25.0", res.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("darwin arm64 falls back to x86_64", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "darwin",
|
||||
Arch: "aarch64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Asset.Arch != "x86_64" {
|
||||
t.Errorf("arch = %q, want x86_64 (Rosetta fallback)", res.Asset.Arch)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no match returns error", func(t *testing.T) {
|
||||
_, err := Resolve(assets, Request{
|
||||
OS: "freebsd",
|
||||
Arch: "x86_64",
|
||||
})
|
||||
if err != ErrNoMatch {
|
||||
t.Errorf("err = %v, want ErrNoMatch", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("windows gets zip", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "windows",
|
||||
Arch: "x86_64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Asset.Format != ".zip" {
|
||||
t.Errorf("format = %q, want .zip", res.Asset.Format)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveChannels(t *testing.T) {
|
||||
assets := []storage.Asset{
|
||||
{
|
||||
Filename: "tool-v2.0.0-rc1-linux-x86_64.tar.gz",
|
||||
Version: "2.0.0-rc1",
|
||||
Channel: "rc",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.gz",
|
||||
},
|
||||
{
|
||||
Filename: "tool-v1.5.0-linux-x86_64.tar.gz",
|
||||
Version: "1.5.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.gz",
|
||||
},
|
||||
{
|
||||
Filename: "tool-v2.0.0-beta2-linux-x86_64.tar.gz",
|
||||
Version: "2.0.0-beta2",
|
||||
Channel: "beta",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.gz",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("stable skips rc and beta", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Version != "1.5.0" {
|
||||
t.Errorf("version = %q, want 1.5.0", res.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rc includes rc and stable", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Channel: "rc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Version != "2.0.0-rc1" {
|
||||
t.Errorf("version = %q, want 2.0.0-rc1", res.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("beta includes beta, rc, and stable", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Channel: "beta",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// beta2 sorts after rc1 for the same numeric version (2.0.0),
|
||||
// but rc1 is more stable. However, the user asked for beta channel
|
||||
// which includes everything — and beta sorts before rc alphabetically.
|
||||
// With lexver: 2.0.0-rc1 > 2.0.0-beta2 (rc > beta alphabetically).
|
||||
if res.Version != "2.0.0-rc1" {
|
||||
t.Errorf("version = %q, want 2.0.0-rc1", res.Version)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveVariants(t *testing.T) {
|
||||
assets := []storage.Asset{
|
||||
{
|
||||
Filename: "ollama-linux-amd64.tgz",
|
||||
Version: "0.6.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.gz",
|
||||
},
|
||||
{
|
||||
Filename: "ollama-linux-amd64-rocm.tgz",
|
||||
Version: "0.6.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.gz",
|
||||
Variants: []string{"rocm"},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("no variant prefers plain", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Asset.Variants) != 0 {
|
||||
t.Errorf("variants = %v, want empty", res.Asset.Variants)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit variant selects it", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Variant: "rocm",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasVariant(res.Asset.Variants, "rocm") {
|
||||
t.Errorf("variants = %v, want [rocm]", res.Asset.Variants)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveFormatPreference(t *testing.T) {
|
||||
assets := []storage.Asset{
|
||||
{
|
||||
Filename: "tool-v1.0.0-linux-x86_64.tar.gz",
|
||||
Version: "1.0.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.gz",
|
||||
},
|
||||
{
|
||||
Filename: "tool-v1.0.0-linux-x86_64.tar.xz",
|
||||
Version: "1.0.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.xz",
|
||||
},
|
||||
{
|
||||
Filename: "tool-v1.0.0-linux-x86_64.tar.zst",
|
||||
Version: "1.0.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.zst",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("default prefers zst", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Asset.Format != ".tar.zst" {
|
||||
t.Errorf("format = %q, want .tar.zst", res.Asset.Format)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit format preference", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Asset.Format != ".tar.gz" {
|
||||
t.Errorf("format = %q, want .tar.gz", res.Asset.Format)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveGitAssets(t *testing.T) {
|
||||
assets := []storage.Asset{
|
||||
{
|
||||
Filename: "vim-commentary-v1.2",
|
||||
Version: "1.2",
|
||||
Channel: "stable",
|
||||
Format: "git",
|
||||
Download: "https://github.com/tpope/vim-commentary.git",
|
||||
},
|
||||
{
|
||||
Filename: "vim-commentary-v1.1",
|
||||
Version: "1.1",
|
||||
Channel: "stable",
|
||||
Format: "git",
|
||||
Download: "https://github.com/tpope/vim-commentary.git",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("git assets match any platform", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Version != "1.2" {
|
||||
t.Errorf("version = %q, want 1.2", res.Version)
|
||||
}
|
||||
if res.Asset.Format != "git" {
|
||||
t.Errorf("format = %q, want git", res.Asset.Format)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveLTS(t *testing.T) {
|
||||
assets := []storage.Asset{
|
||||
{
|
||||
Filename: "node-v22.0.0-linux-x64.tar.gz",
|
||||
Version: "22.0.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.gz",
|
||||
LTS: false,
|
||||
},
|
||||
{
|
||||
Filename: "node-v20.15.0-linux-x64.tar.gz",
|
||||
Version: "20.15.0",
|
||||
Channel: "stable",
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
Format: ".tar.gz",
|
||||
LTS: true,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("LTS selects older LTS version", func(t *testing.T) {
|
||||
res, err := Resolve(assets, Request{
|
||||
OS: "linux",
|
||||
Arch: "x86_64",
|
||||
LTS: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Version != "20.15.0" {
|
||||
t.Errorf("version = %q, want 20.15.0", res.Version)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user