mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-08-23 22:16:48 +00:00
feat: add Go release cache daemon (webicached) and HTTP API server (webid)
Rewrites the Node.js release classification pipeline in Go. webicached fetches upstream releases, classifies assets, and writes legacy-format JSON caches. webid serves the HTTP API (releases, resolve, bootstrap scripts). Git-clone packages emit git_tag and git_commit_hash from real repo clones — no fabricated refs.
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user