mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-06-04 23:12:47 +00:00
Compare commits
14 Commits
feat/webi-
...
feat/webi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac59e4728e | ||
|
|
59b2956d60 | ||
|
|
75bd1a3cf9 | ||
|
|
89e12d22f3 | ||
|
|
23064a6db7 | ||
|
|
bf5cafac18 | ||
|
|
1e499ed6c8 | ||
|
|
f638a25529 | ||
|
|
95418b1023 | ||
|
|
f66822295b | ||
|
|
c538942392 | ||
|
|
af28ddb686 | ||
|
|
631147901a | ||
|
|
b3375d0e24 |
Submodule _webi/build-classifier updated: 9f87804eb4...f9cc9f3e19
@@ -621,13 +621,15 @@ BuildsCacher.create = function ({ ALL_TERMS, installers }) {
|
||||
let arches = waterfall[hostTarget.arch] ||
|
||||
HostTargets.WATERFALL.ANYOS[hostTarget.arch] || [hostTarget.arch];
|
||||
arches = arches.concat(['ANYARCH']);
|
||||
let libcs = waterfall[hostTarget.libc] ||
|
||||
HostTargets.WATERFALL.ANYOS[hostTarget.libc] || [hostTarget.libc];
|
||||
// termsToTarget omits libc for plain UAs; 'libc' → waterfall ['none','libc',...]
|
||||
let libc = hostTarget.libc || 'libc';
|
||||
let libcs = waterfall[libc] ||
|
||||
HostTargets.WATERFALL.ANYOS[libc] || [libc];
|
||||
|
||||
// Extend the glibc-host waterfall: the table only lists [none, libc]
|
||||
// but Rust projects (bat, rg) and node ship libc='gnu' builds, and
|
||||
// static musl builds also run on glibc hosts.
|
||||
if (hostTarget.libc === 'libc' && !libcs.includes('gnu')) {
|
||||
if (libc === 'libc' && !libcs.includes('gnu')) {
|
||||
libcs = ['none', 'gnu', 'musl', 'libc'];
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ $Env:WEBI_HOST = 'https://webinstall.dev'
|
||||
#$Env:PKG_NAME = node
|
||||
#$Env:WEBI_VERSION = v12.16.2
|
||||
#$Env:WEBI_GIT_TAG = 12.16.2
|
||||
#$Env:WEBI_GIT_COMMIT_HASH =
|
||||
#$Env:WEBI_PKG_URL = "https://.../node-....zip"
|
||||
#$Env:WEBI_PKG_FILE = "node-v12.16.2-win-x64.zip"
|
||||
#$Env:WEBI_PKG_PATHNAME = "node-v12.16.2-win-x64.zip"
|
||||
|
||||
@@ -15,6 +15,7 @@ __bootstrap_webi() {
|
||||
# TODO not sure if BUILD is the best name for this
|
||||
#WEBI_BUILD=
|
||||
#WEBI_GIT_TAG=
|
||||
#WEBI_GIT_COMMIT_HASH=
|
||||
#WEBI_LTS=
|
||||
#WEBI_CHANNEL=
|
||||
#WEBI_EXT=
|
||||
|
||||
@@ -38,9 +38,8 @@ function getOs(ua) {
|
||||
// It's the year of the Linux Desktop!
|
||||
// See also http://www.mslinux.org/
|
||||
// 'linux' must be tested before 'Microsoft' because WSL
|
||||
// (TODO: does this affect cygwin / msysgit?)
|
||||
return 'linux';
|
||||
} else if (/^ms$|Microsoft|Windows|win32|win|PowerShell/i.test(ua)) {
|
||||
} else if (/^ms$|Microsoft|Windows|win32|win|PowerShell|CYGWIN|MINGW/i.test(ua)) {
|
||||
// 'win' must be tested after 'darwin'
|
||||
return 'windows';
|
||||
} else if (/Linux|curl|wget/i.test(ua)) {
|
||||
|
||||
1293
cmd/classify/main.go
1293
cmd/classify/main.go
File diff suppressed because it is too large
Load Diff
@@ -1,914 +0,0 @@
|
||||
// Command comparecache compares Go-generated cache output against the
|
||||
// Node.js LIVE_cache. It identifies categorical differences in asset
|
||||
// selection — which filenames appear in one cache but not the other.
|
||||
//
|
||||
// The comparison is done at the filename level (not OS/arch/ext fields)
|
||||
// because the Node.js cache leaves those empty (normalize.js fills them
|
||||
// at serve time), while the Go pipeline classifies at write time.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/comparecache -live ./LIVE_cache -go ./_cache
|
||||
// go run ./cmd/comparecache -live ./LIVE_cache -go ./_cache bat jq
|
||||
// go run ./cmd/comparecache -live ./LIVE_cache -go ./_cache -summary
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/classify"
|
||||
"github.com/webinstall/webi-installers/internal/lexver"
|
||||
)
|
||||
|
||||
type cacheEntry struct {
|
||||
Releases []cacheRelease `json:"releases"`
|
||||
}
|
||||
|
||||
type cacheRelease struct {
|
||||
Name string `json:"name"`
|
||||
Filename string `json:"_filename"` // Node.js uses _filename for some sources
|
||||
Version string `json:"version"`
|
||||
Download string `json:"download"`
|
||||
Channel string `json:"channel"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Libc string `json:"libc"`
|
||||
Ext string `json:"ext"`
|
||||
}
|
||||
|
||||
// fieldDiff records a field-level difference for an asset that exists
|
||||
// in both caches (same filename) but has different classification.
|
||||
type fieldDiff struct {
|
||||
Filename string
|
||||
Field string // "os", "arch", "libc", "ext", "channel"
|
||||
Live string
|
||||
Go string
|
||||
BothSet bool // true when both live and go have non-empty values
|
||||
}
|
||||
|
||||
type packageDiff struct {
|
||||
Name string
|
||||
LiveCount int
|
||||
GoCount int
|
||||
OnlyInLive []string // filenames only in Node.js cache
|
||||
OnlyInGo []string // filenames only in Go cache
|
||||
FieldDiffs []fieldDiff // classification differences on shared assets
|
||||
VersionsLive []string // unique versions in live
|
||||
VersionsGo []string // unique versions in go
|
||||
GoMissing bool // true if Go didn't produce output for this package
|
||||
LiveMissing bool // true if no live cache for this package
|
||||
Categories []string // categorical difference labels
|
||||
}
|
||||
|
||||
func main() {
|
||||
liveDir := flag.String("live", "./LIVE_cache", "path to Node.js LIVE_cache directory")
|
||||
goDir := flag.String("go", "./_cache", "path to Go cache directory")
|
||||
summary := flag.Bool("summary", false, "only print summary, not per-package details")
|
||||
diffsOnly := flag.Bool("diffs", false, "only show packages with asset differences (skip matches)")
|
||||
latest := flag.Bool("latest", false, "only compare latest version in each cache")
|
||||
windowed := flag.Bool("windowed", false, "limit Go versions to the Node.js version range (2nd to 2nd-to-last)")
|
||||
sample := flag.Int("sample", 0, "for each package diff, show N randomly sampled assets (implies -windowed -diffs)")
|
||||
flag.Parse()
|
||||
filterPkgs := flag.Args()
|
||||
|
||||
// -sample implies -windowed and -diffs so we focus on real classification
|
||||
// differences, not version-depth noise.
|
||||
if *sample > 0 {
|
||||
*windowed = true
|
||||
*diffsOnly = true
|
||||
}
|
||||
|
||||
totalStart := time.Now()
|
||||
|
||||
// Find the most recent month directory in each cache.
|
||||
liveMonth := findLatestMonth(*liveDir)
|
||||
goMonth := findLatestMonth(*goDir)
|
||||
if liveMonth == "" {
|
||||
log.Fatalf("no month directories found in %s", *liveDir)
|
||||
}
|
||||
|
||||
livePath := filepath.Join(*liveDir, liveMonth)
|
||||
goPath := ""
|
||||
if goMonth != "" {
|
||||
goPath = filepath.Join(*goDir, goMonth)
|
||||
}
|
||||
|
||||
// Discover all packages across both caches.
|
||||
discoverStart := time.Now()
|
||||
allPkgs := discoverPackages(livePath, goPath)
|
||||
if len(filterPkgs) > 0 {
|
||||
nameSet := make(map[string]bool, len(filterPkgs))
|
||||
for _, n := range filterPkgs {
|
||||
nameSet[n] = true
|
||||
}
|
||||
var filtered []string
|
||||
for _, p := range allPkgs {
|
||||
if nameSet[p] {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
allPkgs = filtered
|
||||
}
|
||||
log.Printf("discovered %d packages in %s", len(allPkgs), time.Since(discoverStart))
|
||||
|
||||
compareStart := time.Now()
|
||||
var diffs []packageDiff
|
||||
for _, pkg := range allPkgs {
|
||||
d := compare(livePath, goPath, pkg, *latest, *windowed)
|
||||
categorize(&d)
|
||||
diffs = append(diffs, d)
|
||||
}
|
||||
log.Printf("compared %d packages in %s", len(diffs), time.Since(compareStart))
|
||||
|
||||
if *summary {
|
||||
printSummary(diffs)
|
||||
} else {
|
||||
printDetails(diffs, *diffsOnly, *sample)
|
||||
}
|
||||
|
||||
log.Printf("total: %s", time.Since(totalStart))
|
||||
}
|
||||
|
||||
func findLatestMonth(dir string) string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var months []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && len(e.Name()) == 7 && e.Name()[4] == '-' {
|
||||
months = append(months, e.Name())
|
||||
}
|
||||
}
|
||||
if len(months) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(months)
|
||||
return months[len(months)-1]
|
||||
}
|
||||
|
||||
func discoverPackages(livePath, goPath string) []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, dir := range []string{livePath, goPath} {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasSuffix(name, ".json") && !strings.HasSuffix(name, ".updated.txt") {
|
||||
pkg := strings.TrimSuffix(name, ".json")
|
||||
seen[pkg] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var pkgs []string
|
||||
for p := range seen {
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
sort.Strings(pkgs)
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func loadCache(dir, pkg string) *cacheEntry {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, pkg+".json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var entry cacheEntry
|
||||
if err := json.Unmarshal(data, &entry); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &entry
|
||||
}
|
||||
|
||||
// effectiveName returns the best available filename for a release entry.
|
||||
// Node.js sometimes uses _filename (a path) instead of name.
|
||||
func effectiveName(name, filename, download string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
if filename != "" {
|
||||
// _filename may be a path like "stable/macos/flutter_macos_3.41.4.zip"
|
||||
if i := strings.LastIndex(filename, "/"); i >= 0 {
|
||||
return filename[i+1:]
|
||||
}
|
||||
return filename
|
||||
}
|
||||
// Last resort: basename of download URL.
|
||||
if download != "" {
|
||||
if i := strings.LastIndex(download, "/"); i >= 0 {
|
||||
return download[i+1:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// versionWindow returns the 2nd and 2nd-to-last versions from a sorted
|
||||
// version list. This trims the edges where Node.js may have a newer fetch
|
||||
// or Go may have deeper history, focusing on the overlapping middle.
|
||||
func versionWindow(versions []string) (low, high string) {
|
||||
if len(versions) <= 2 {
|
||||
// Too few versions to window — use all.
|
||||
if len(versions) > 0 {
|
||||
return versions[0], versions[len(versions)-1]
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
// 2nd version (skip oldest) and 2nd-to-last (skip newest).
|
||||
return versions[1], versions[len(versions)-2]
|
||||
}
|
||||
|
||||
// filterVersionRange returns only the versions in sorted order that fall
|
||||
// within [low, high] inclusive (by lexver comparison).
|
||||
func filterVersionRange(vf map[string]map[string]bool, versions []string, low, high string) (map[string]bool, []string) {
|
||||
lowV := lexver.Parse(low)
|
||||
highV := lexver.Parse(high)
|
||||
|
||||
files := make(map[string]bool)
|
||||
var kept []string
|
||||
for _, v := range versions {
|
||||
pv := lexver.Parse(v)
|
||||
if lexver.Compare(pv, lowV) >= 0 && lexver.Compare(pv, highV) <= 0 {
|
||||
kept = append(kept, v)
|
||||
for f := range vf[v] {
|
||||
files[f] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return files, kept
|
||||
}
|
||||
|
||||
func compare(livePath, goPath, pkg string, latestOnly, windowed bool) packageDiff {
|
||||
live := loadCache(livePath, pkg)
|
||||
goCache := loadCache(goPath, pkg)
|
||||
|
||||
d := packageDiff{Name: pkg}
|
||||
|
||||
if live == nil {
|
||||
d.LiveMissing = true
|
||||
}
|
||||
if goCache == nil {
|
||||
d.GoMissing = true
|
||||
}
|
||||
if d.LiveMissing && d.GoMissing {
|
||||
return d
|
||||
}
|
||||
|
||||
normVersion := normalizeVersionFunc(pkg)
|
||||
|
||||
// Collect filenames by version. If filter is non-nil, skip filenames it rejects.
|
||||
extractVersionFiles := func(ce *cacheEntry, filter func(string) bool) (map[string]map[string]bool, []string) {
|
||||
vf := make(map[string]map[string]bool)
|
||||
for _, r := range ce.Releases {
|
||||
name := effectiveName(r.Name, r.Filename, r.Download)
|
||||
if filter != nil && !filter(name) {
|
||||
continue
|
||||
}
|
||||
ver := normVersion(r.Version)
|
||||
if vf[ver] == nil {
|
||||
vf[ver] = make(map[string]bool)
|
||||
}
|
||||
vf[ver][name] = true
|
||||
}
|
||||
var versions []string
|
||||
for v := range vf {
|
||||
versions = append(versions, v)
|
||||
}
|
||||
slices.SortFunc(versions, func(a, b string) int {
|
||||
return lexver.Compare(lexver.Parse(a), lexver.Parse(b))
|
||||
})
|
||||
return vf, versions
|
||||
}
|
||||
notNoise := func(name string) bool { return !isLiveNoise(name) }
|
||||
|
||||
var liveFiles, goFiles map[string]bool
|
||||
|
||||
// Parse live cache.
|
||||
var liveVF map[string]map[string]bool
|
||||
var liveVersions []string
|
||||
if live != nil {
|
||||
liveVF, liveVersions = extractVersionFiles(live, notNoise)
|
||||
d.VersionsLive = liveVersions
|
||||
d.LiveCount = len(live.Releases)
|
||||
}
|
||||
|
||||
// Parse Go cache.
|
||||
var goVF map[string]map[string]bool
|
||||
var goVersions []string
|
||||
if goCache != nil {
|
||||
goVF, goVersions = extractVersionFiles(goCache, notNoise)
|
||||
d.VersionsGo = goVersions
|
||||
d.GoCount = len(goCache.Releases)
|
||||
}
|
||||
|
||||
// Determine which files to compare based on mode.
|
||||
if latestOnly {
|
||||
// Compare only the latest version from each cache.
|
||||
if live != nil && len(liveVersions) > 0 {
|
||||
liveFiles = liveVF[liveVersions[len(liveVersions)-1]]
|
||||
}
|
||||
if goCache != nil && len(goVersions) > 0 {
|
||||
goFiles = goVF[goVersions[len(goVersions)-1]]
|
||||
}
|
||||
} else if windowed && live != nil && len(liveVersions) > 0 {
|
||||
// Use the Node.js version range (2nd to 2nd-to-last) to establish
|
||||
// the window. Include ALL Node.js versions in the window (so missing
|
||||
// Go versions are visible), but exclude Go-only versions (those are
|
||||
// just deeper history, not real gaps).
|
||||
low, high := versionWindow(liveVersions)
|
||||
lowV := lexver.Parse(low)
|
||||
highV := lexver.Parse(high)
|
||||
|
||||
// Collect all live files in the window.
|
||||
liveFiles = make(map[string]bool)
|
||||
liveInWindow := make(map[string]bool)
|
||||
for _, v := range liveVersions {
|
||||
pv := lexver.Parse(v)
|
||||
if lexver.Compare(pv, lowV) >= 0 && lexver.Compare(pv, highV) <= 0 {
|
||||
liveInWindow[v] = true
|
||||
for f := range liveVF[v] {
|
||||
liveFiles[f] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For Go, only include versions that Node.js also has in the window.
|
||||
// Go-only versions are hidden (deeper history, not gaps).
|
||||
goFiles = make(map[string]bool)
|
||||
for _, v := range goVersions {
|
||||
if !liveInWindow[v] {
|
||||
continue
|
||||
}
|
||||
for f := range goVF[v] {
|
||||
goFiles[f] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Compare all versions — use pre-filtered version maps.
|
||||
if live != nil {
|
||||
liveFiles = make(map[string]bool)
|
||||
for _, files := range liveVF {
|
||||
for f := range files {
|
||||
liveFiles[f] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if goCache != nil {
|
||||
goFiles = make(map[string]bool)
|
||||
for _, files := range goVF {
|
||||
for f := range files {
|
||||
goFiles[f] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if liveFiles == nil {
|
||||
liveFiles = make(map[string]bool)
|
||||
}
|
||||
if goFiles == nil {
|
||||
goFiles = make(map[string]bool)
|
||||
}
|
||||
|
||||
for f := range liveFiles {
|
||||
if !goFiles[f] {
|
||||
d.OnlyInLive = append(d.OnlyInLive, f)
|
||||
}
|
||||
}
|
||||
for f := range goFiles {
|
||||
if !liveFiles[f] {
|
||||
d.OnlyInGo = append(d.OnlyInGo, f)
|
||||
}
|
||||
}
|
||||
sort.Strings(d.OnlyInLive)
|
||||
sort.Strings(d.OnlyInGo)
|
||||
|
||||
// Field-level comparison on assets that exist in both caches.
|
||||
// Build version+filename → fields maps from each cache.
|
||||
if live != nil && goCache != nil {
|
||||
type assetKey struct {
|
||||
version string
|
||||
filename string
|
||||
}
|
||||
liveByKey := make(map[assetKey]cacheRelease)
|
||||
for _, r := range live.Releases {
|
||||
name := effectiveName(r.Name, r.Filename, r.Download)
|
||||
ver := normVersion(r.Version)
|
||||
liveByKey[assetKey{ver, name}] = r
|
||||
}
|
||||
|
||||
for _, r := range goCache.Releases {
|
||||
name := effectiveName(r.Name, r.Filename, r.Download)
|
||||
ver := normVersion(r.Version)
|
||||
lr, ok := liveByKey[assetKey{ver, name}]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare classification fields.
|
||||
// Use equivalence checks for os/arch/ext so naming
|
||||
// convention differences don't mask real classification bugs.
|
||||
for _, cmp := range []struct {
|
||||
field string
|
||||
live string
|
||||
go_ string
|
||||
equiv bool
|
||||
}{
|
||||
{"os", lr.OS, r.OS, equivOS(lr.OS, r.OS)},
|
||||
{"arch", lr.Arch, r.Arch, equivArch(lr.Arch, r.Arch)},
|
||||
{"libc", lr.Libc, r.Libc, lr.Libc == r.Libc},
|
||||
{"ext", lr.Ext, r.Ext, equivExt(lr.Ext, r.Ext)},
|
||||
{"channel", lr.Channel, r.Channel, lr.Channel == r.Channel},
|
||||
} {
|
||||
if cmp.equiv {
|
||||
continue
|
||||
}
|
||||
d.FieldDiffs = append(d.FieldDiffs, fieldDiff{
|
||||
Filename: name,
|
||||
Field: cmp.field,
|
||||
Live: cmp.live,
|
||||
Go: cmp.go_,
|
||||
BothSet: cmp.live != "" && cmp.go_ != "",
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(d.FieldDiffs, func(i, j int) bool {
|
||||
if d.FieldDiffs[i].Field != d.FieldDiffs[j].Field {
|
||||
return d.FieldDiffs[i].Field < d.FieldDiffs[j].Field
|
||||
}
|
||||
return d.FieldDiffs[i].Filename < d.FieldDiffs[j].Filename
|
||||
})
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// equivOS returns true if two OS values are equivalent across naming conventions.
|
||||
func equivOS(a, b string) bool {
|
||||
return a == b || canonicalOS(a) == canonicalOS(b)
|
||||
}
|
||||
|
||||
func canonicalOS(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "darwin", "macos", "mac", "osx":
|
||||
return "darwin"
|
||||
case "win", "windows":
|
||||
return "windows"
|
||||
default:
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
}
|
||||
|
||||
// equivArch returns true if two arch values are equivalent.
|
||||
func equivArch(a, b string) bool {
|
||||
return a == b || canonicalArch(a) == canonicalArch(b)
|
||||
}
|
||||
|
||||
func canonicalArch(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "x86_64", "amd64", "x64":
|
||||
return "x86_64"
|
||||
case "aarch64", "arm64":
|
||||
return "aarch64"
|
||||
case "armv7", "armv7l":
|
||||
return "armv7"
|
||||
case "armv6", "armv6l":
|
||||
return "armv6"
|
||||
case "x86", "i386", "i686", "386":
|
||||
return "x86"
|
||||
default:
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
}
|
||||
|
||||
// equivExt returns true if two extension values are equivalent.
|
||||
func equivExt(a, b string) bool {
|
||||
// Normalize: strip leading dot, handle common aliases.
|
||||
return a == b || canonicalExt(a) == canonicalExt(b)
|
||||
}
|
||||
|
||||
func canonicalExt(s string) string {
|
||||
s = strings.TrimPrefix(s, ".")
|
||||
switch s {
|
||||
case "tgz":
|
||||
return "tar.gz"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func categorize(d *packageDiff) {
|
||||
if d.GoMissing {
|
||||
d.Categories = append(d.Categories, "go-missing")
|
||||
return
|
||||
}
|
||||
if d.LiveMissing {
|
||||
d.Categories = append(d.Categories, "live-missing")
|
||||
return
|
||||
}
|
||||
|
||||
if len(d.OnlyInLive) == 0 && len(d.OnlyInGo) == 0 && len(d.FieldDiffs) == 0 {
|
||||
d.Categories = append(d.Categories, "match")
|
||||
return
|
||||
}
|
||||
if len(d.OnlyInLive) == 0 && len(d.OnlyInGo) == 0 && len(d.FieldDiffs) > 0 {
|
||||
d.Categories = append(d.Categories, "fields-only")
|
||||
}
|
||||
|
||||
// Check if differences are only version depth (Go has more history).
|
||||
liveVersionSet := make(map[string]bool, len(d.VersionsLive))
|
||||
for _, v := range d.VersionsLive {
|
||||
liveVersionSet[v] = true
|
||||
}
|
||||
goVersionSet := make(map[string]bool, len(d.VersionsGo))
|
||||
for _, v := range d.VersionsGo {
|
||||
goVersionSet[v] = true
|
||||
}
|
||||
|
||||
goExtraVersions := 0
|
||||
for _, v := range d.VersionsGo {
|
||||
if !liveVersionSet[v] {
|
||||
goExtraVersions++
|
||||
}
|
||||
}
|
||||
liveExtraVersions := 0
|
||||
for _, v := range d.VersionsLive {
|
||||
if !goVersionSet[v] {
|
||||
liveExtraVersions++
|
||||
}
|
||||
}
|
||||
|
||||
if goExtraVersions > 0 {
|
||||
d.Categories = append(d.Categories, fmt.Sprintf("go-extra-versions(%d)", goExtraVersions))
|
||||
}
|
||||
if liveExtraVersions > 0 {
|
||||
d.Categories = append(d.Categories, fmt.Sprintf("live-extra-versions(%d)", liveExtraVersions))
|
||||
}
|
||||
|
||||
// Check for meta-asset filtering differences.
|
||||
metaOnlyInLive := 0
|
||||
nonMetaOnlyInLive := 0
|
||||
for _, f := range d.OnlyInLive {
|
||||
if classify.IsMetaAsset(f) {
|
||||
metaOnlyInLive++
|
||||
} else {
|
||||
nonMetaOnlyInLive++
|
||||
}
|
||||
}
|
||||
metaOnlyInGo := 0
|
||||
nonMetaOnlyInGo := 0
|
||||
for _, f := range d.OnlyInGo {
|
||||
if classify.IsMetaAsset(f) {
|
||||
metaOnlyInGo++
|
||||
} else {
|
||||
nonMetaOnlyInGo++
|
||||
}
|
||||
}
|
||||
|
||||
if metaOnlyInLive > 0 {
|
||||
d.Categories = append(d.Categories, fmt.Sprintf("live-has-meta(%d)", metaOnlyInLive))
|
||||
}
|
||||
if metaOnlyInGo > 0 {
|
||||
d.Categories = append(d.Categories, fmt.Sprintf("go-has-meta(%d)", metaOnlyInGo))
|
||||
}
|
||||
|
||||
// Check for source tarball differences.
|
||||
srcOnlyInGo := 0
|
||||
for _, f := range d.OnlyInGo {
|
||||
if strings.HasSuffix(f, ".tar.gz") || strings.HasSuffix(f, ".zip") {
|
||||
if strings.HasPrefix(f, "v") || strings.HasPrefix(f, "refs/") {
|
||||
srcOnlyInGo++
|
||||
}
|
||||
}
|
||||
}
|
||||
if srcOnlyInGo > 0 {
|
||||
d.Categories = append(d.Categories, fmt.Sprintf("go-has-source-tarballs(%d)", srcOnlyInGo))
|
||||
}
|
||||
|
||||
if nonMetaOnlyInLive > 0 {
|
||||
d.Categories = append(d.Categories, fmt.Sprintf("live-extra-assets(%d)", nonMetaOnlyInLive))
|
||||
}
|
||||
if nonMetaOnlyInGo > 0 {
|
||||
d.Categories = append(d.Categories, fmt.Sprintf("go-extra-assets(%d)", nonMetaOnlyInGo))
|
||||
}
|
||||
|
||||
// Count field diffs by field name, separating real disagreements
|
||||
// from expected "live empty, Go classified" differences.
|
||||
type fieldCount struct {
|
||||
bothSet int // both caches have a value but they disagree
|
||||
oneEmpty int // one side is empty (typically live — normalize.js fills at serve time)
|
||||
}
|
||||
fieldCounts := make(map[string]fieldCount)
|
||||
for _, fd := range d.FieldDiffs {
|
||||
fc := fieldCounts[fd.Field]
|
||||
if fd.BothSet {
|
||||
fc.bothSet++
|
||||
} else {
|
||||
fc.oneEmpty++
|
||||
}
|
||||
fieldCounts[fd.Field] = fc
|
||||
}
|
||||
for _, field := range []string{"os", "arch", "libc", "ext", "channel"} {
|
||||
fc := fieldCounts[field]
|
||||
if fc.bothSet > 0 {
|
||||
d.Categories = append(d.Categories, fmt.Sprintf("diff-%s(%d)", field, fc.bothSet))
|
||||
}
|
||||
if fc.oneEmpty > 0 {
|
||||
d.Categories = append(d.Categories, fmt.Sprintf("fill-%s(%d)", field, fc.oneEmpty))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isLiveNoise returns true for filenames that the Node.js cache keeps
|
||||
// but Go intentionally filters out. Pre-filtering these from the live
|
||||
// side prevents them from appearing as live-extra-assets noise.
|
||||
//
|
||||
// This includes everything classify.IsMetaAsset catches plus formats
|
||||
// that Go's legacy export strips (.deb, .rpm, etc.).
|
||||
func isLiveNoise(name string) bool {
|
||||
if classify.IsMetaAsset(name) {
|
||||
return true
|
||||
}
|
||||
|
||||
lower := strings.ToLower(name)
|
||||
|
||||
// Formats Go filters from legacy export but Node.js keeps.
|
||||
for _, suffix := range []string{
|
||||
".deb", ".rpm", ".gpg",
|
||||
} {
|
||||
if strings.HasSuffix(lower, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Source tarballs (e.g. gitea-src-1.25.4.tar.gz, caddy_2.10.0_src.tar.gz, go1.26.1.src.tar.gz).
|
||||
if strings.Contains(lower, "-src-") || strings.Contains(lower, "_src.") || strings.Contains(lower, ".src.") || strings.HasPrefix(lower, "src-") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Docs tarballs (e.g. gitea-docs-1.22.3.tar.gz).
|
||||
if strings.Contains(lower, "-docs-") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Bare executables without any extension — typically legacy shell scripts
|
||||
// uploaded alongside proper archives (e.g. kubectx, kubens).
|
||||
if !strings.Contains(name, ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
// GPU accelerator / hardware variants that Go tags as variants
|
||||
// but Node.js keeps with special arch names.
|
||||
for _, v := range []string{"-rocm", "-jetpack"} {
|
||||
if strings.Contains(lower, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Linux binaries for packages where Node.js only kept macOS .app.zip.
|
||||
// Go correctly includes these as installable on Linux.
|
||||
if strings.HasPrefix(lower, "fish-") && strings.Contains(lower, "-linux-") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeVersionFunc returns a version normalizer for a given package.
|
||||
// Most packages return the identity function. Some (like git) need
|
||||
// version string normalization to match across Go and Node.js caches.
|
||||
func normalizeVersionFunc(pkg string) func(string) string {
|
||||
switch pkg {
|
||||
case "git":
|
||||
return func(v string) string {
|
||||
// Git for Windows: v2.53.0.windows.1 → v2.53.0
|
||||
// v2.53.0.windows.2 → v2.53.0.2
|
||||
idx := strings.Index(v, ".windows.")
|
||||
if idx < 0 {
|
||||
return v
|
||||
}
|
||||
suffix := v[idx+len(".windows."):]
|
||||
base := v[:idx]
|
||||
if suffix == "1" {
|
||||
return base
|
||||
}
|
||||
return base + "." + suffix
|
||||
}
|
||||
case "lf":
|
||||
return func(v string) string {
|
||||
// lf: r21 → 0.21.0
|
||||
if strings.HasPrefix(v, "r") {
|
||||
return "0." + v[1:] + ".0"
|
||||
}
|
||||
return v
|
||||
}
|
||||
case "bun":
|
||||
return func(v string) string {
|
||||
// bun: bun-v1.3.9 → v1.3.9
|
||||
return strings.TrimPrefix(v, "bun-")
|
||||
}
|
||||
case "watchexec":
|
||||
return func(v string) string {
|
||||
// watchexec monorepo: cli-v1.20.5 → v1.20.5
|
||||
return strings.TrimPrefix(v, "cli-")
|
||||
}
|
||||
case "go":
|
||||
return func(v string) string {
|
||||
// Go: go1.10 → 1.10.0 (pad to 3 parts)
|
||||
v = strings.TrimPrefix(v, "go")
|
||||
parts := strings.SplitN(v, ".", 3)
|
||||
for len(parts) < 3 {
|
||||
parts = append(parts, "0")
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
default:
|
||||
return func(v string) string { return v }
|
||||
}
|
||||
}
|
||||
|
||||
func printSummary(diffs []packageDiff) {
|
||||
// Count by category.
|
||||
categoryCounts := make(map[string]int)
|
||||
for _, d := range diffs {
|
||||
for _, c := range d.Categories {
|
||||
// Strip the count suffix for grouping.
|
||||
base := c
|
||||
if idx := strings.Index(c, "("); idx != -1 {
|
||||
base = c[:idx]
|
||||
}
|
||||
categoryCounts[base]++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== COMPARISON SUMMARY ===")
|
||||
fmt.Printf("Total packages: %d\n\n", len(diffs))
|
||||
|
||||
var cats []string
|
||||
for c := range categoryCounts {
|
||||
cats = append(cats, c)
|
||||
}
|
||||
sort.Strings(cats)
|
||||
for _, c := range cats {
|
||||
fmt.Printf(" %-30s %d\n", c, categoryCounts[c])
|
||||
}
|
||||
|
||||
fmt.Println("\n=== PER-PACKAGE CATEGORIES ===")
|
||||
for _, d := range diffs {
|
||||
fmt.Printf("%-25s %s\n", d.Name, strings.Join(d.Categories, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func printDetails(diffs []packageDiff, diffsOnly bool, sampleN int) {
|
||||
for _, d := range diffs {
|
||||
if diffsOnly && len(d.OnlyInLive) == 0 && len(d.OnlyInGo) == 0 && len(d.FieldDiffs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("=== %s ===\n", d.Name)
|
||||
fmt.Printf(" Categories: %s\n", strings.Join(d.Categories, ", "))
|
||||
fmt.Printf(" Live: %d assets, %d versions | Go: %d assets, %d versions\n",
|
||||
d.LiveCount, len(d.VersionsLive), d.GoCount, len(d.VersionsGo))
|
||||
|
||||
printAssetList("Only in LIVE", d.OnlyInLive, sampleN)
|
||||
printAssetList("Only in Go", d.OnlyInGo, sampleN)
|
||||
printFieldDiffs(d.FieldDiffs, sampleN)
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printFieldDiffs shows classification differences on shared assets.
|
||||
// Shows "real" diffs (both sides non-empty) first, then "fill" diffs
|
||||
// (one side empty) as a summary count only.
|
||||
func printFieldDiffs(diffs []fieldDiff, sampleN int) {
|
||||
if len(diffs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Separate real disagreements from fill diffs.
|
||||
var real, fill []fieldDiff
|
||||
for _, fd := range diffs {
|
||||
if fd.BothSet {
|
||||
real = append(real, fd)
|
||||
} else {
|
||||
fill = append(fill, fd)
|
||||
}
|
||||
}
|
||||
|
||||
// Show real disagreements in detail.
|
||||
if len(real) > 0 {
|
||||
byField := make(map[string][]fieldDiff)
|
||||
for _, fd := range real {
|
||||
byField[fd.Field] = append(byField[fd.Field], fd)
|
||||
}
|
||||
|
||||
for _, field := range []string{"os", "arch", "libc", "ext", "channel"} {
|
||||
fds := byField[field]
|
||||
if len(fds) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" DISAGREE %s (%d):\n", field, len(fds))
|
||||
printFieldDiffItems(fds, sampleN)
|
||||
}
|
||||
}
|
||||
|
||||
// Summarize fill diffs (live empty, Go classified) as counts.
|
||||
if len(fill) > 0 {
|
||||
byField := make(map[string]int)
|
||||
for _, fd := range fill {
|
||||
byField[fd.Field]++
|
||||
}
|
||||
var parts []string
|
||||
for _, field := range []string{"os", "arch", "libc", "ext", "channel"} {
|
||||
if n := byField[field]; n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%s(%d)", field, n))
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
fmt.Printf(" Go fills empty: %s\n", strings.Join(parts, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printFieldDiffItems(fds []fieldDiff, sampleN int) {
|
||||
items := fds
|
||||
if sampleN > 0 && len(items) > sampleN {
|
||||
sampled := make([]fieldDiff, len(items))
|
||||
copy(sampled, items)
|
||||
rand.Shuffle(len(sampled), func(i, j int) {
|
||||
sampled[i], sampled[j] = sampled[j], sampled[i]
|
||||
})
|
||||
items = sampled[:sampleN]
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].Filename < items[j].Filename
|
||||
})
|
||||
}
|
||||
|
||||
limit := 20
|
||||
for i, fd := range items {
|
||||
if sampleN == 0 && i >= limit {
|
||||
fmt.Printf(" ... and %d more\n", len(fds)-limit)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" - %s: live=%q go=%q\n", fd.Filename, fd.Live, fd.Go)
|
||||
}
|
||||
if sampleN > 0 && len(fds) > sampleN {
|
||||
fmt.Printf(" ... sampled %d of %d\n", sampleN, len(fds))
|
||||
}
|
||||
}
|
||||
|
||||
// printAssetList prints a list of asset filenames, optionally sampling N at
|
||||
// random. When sampleN > 0 and the list is longer, it picks N random items
|
||||
// so you can spot classification bugs across the full range instead of only
|
||||
// seeing the first alphabetical entries.
|
||||
func printAssetList(label string, items []string, sampleN int) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" %s (%d):\n", label, len(items))
|
||||
|
||||
if sampleN > 0 && len(items) > sampleN {
|
||||
// Shuffle a copy, take first N, then sort for readable output.
|
||||
sampled := make([]string, len(items))
|
||||
copy(sampled, items)
|
||||
rand.Shuffle(len(sampled), func(i, j int) {
|
||||
sampled[i], sampled[j] = sampled[j], sampled[i]
|
||||
})
|
||||
picked := sampled[:sampleN]
|
||||
sort.Strings(picked)
|
||||
for _, f := range picked {
|
||||
fmt.Printf(" - %s\n", f)
|
||||
}
|
||||
fmt.Printf(" ... sampled %d of %d (run again for different sample)\n", sampleN, len(items))
|
||||
return
|
||||
}
|
||||
|
||||
limit := 20
|
||||
for i, f := range items {
|
||||
if i >= limit {
|
||||
fmt.Printf(" ... and %d more\n", len(items)-limit)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" - %s\n", f)
|
||||
}
|
||||
}
|
||||
@@ -1,846 +0,0 @@
|
||||
// Command e2etest runs the full release pipeline for selected packages
|
||||
// and compares results against the live webi.sh API.
|
||||
//
|
||||
// It fetches from upstream, classifies assets, resolves the best match
|
||||
// for a set of test queries, then fetches the same queries from the live
|
||||
// API and reports any differences.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/e2etest
|
||||
// go run ./cmd/e2etest -packages goreleaser,ollama,node
|
||||
// go run ./cmd/e2etest -cache ./_cache/raw # reuse existing cache
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/buildmeta"
|
||||
"github.com/webinstall/webi-installers/internal/installerconf"
|
||||
"github.com/webinstall/webi-installers/internal/lexver"
|
||||
"github.com/webinstall/webi-installers/internal/rawcache"
|
||||
"github.com/webinstall/webi-installers/internal/releases/github"
|
||||
"github.com/webinstall/webi-installers/internal/releases/githubish"
|
||||
"github.com/webinstall/webi-installers/internal/releases/nodedist"
|
||||
"github.com/webinstall/webi-installers/internal/resolve"
|
||||
)
|
||||
|
||||
// testCase is one query to resolve and compare against the live API.
|
||||
type testCase struct {
|
||||
Name string
|
||||
Package string
|
||||
OS buildmeta.OS
|
||||
Arch buildmeta.Arch
|
||||
Libc buildmeta.Libc
|
||||
Formats []string
|
||||
UA string // User-Agent for live API query
|
||||
}
|
||||
|
||||
// liveResult holds parsed fields from the live webi API response.
|
||||
type liveResult struct {
|
||||
Version string
|
||||
OS string
|
||||
Arch string
|
||||
Libc string
|
||||
Ext string
|
||||
PkgURL string
|
||||
PkgFile string
|
||||
Channel string
|
||||
Stable string
|
||||
Latest string
|
||||
Oses string
|
||||
Arches string
|
||||
Libcs string
|
||||
Formats string
|
||||
}
|
||||
|
||||
// UA format from webi.sh bootstrap: "curl {uname -s}/{uname -r} {uname -m}/unknown {libc}"
|
||||
// libc is "gnu", "musl", or "libc" (for darwin/other)
|
||||
var cases = []testCase{
|
||||
{
|
||||
Name: "goreleaser/linux/x86_64", Package: "goreleaser",
|
||||
OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU,
|
||||
Formats: []string{".tar.gz", ".tar.xz", ".zip"},
|
||||
UA: "curl Linux/6.6.123 x86_64/unknown gnu",
|
||||
},
|
||||
{
|
||||
Name: "goreleaser/darwin/arm64", Package: "goreleaser",
|
||||
OS: buildmeta.OSDarwin, Arch: buildmeta.ArchARM64, Libc: "",
|
||||
Formats: []string{".tar.gz", ".tar.xz", ".zip"},
|
||||
UA: "curl Darwin/25.2.0 arm64/unknown libc",
|
||||
},
|
||||
{
|
||||
Name: "goreleaser/windows/x86_64", Package: "goreleaser",
|
||||
OS: buildmeta.OSWindows, Arch: buildmeta.ArchAMD64, Libc: "",
|
||||
Formats: []string{".zip", ".exe"},
|
||||
UA: "PowerShell/7.0 Windows/10.0 x86_64/unknown msvc",
|
||||
},
|
||||
{
|
||||
Name: "ollama/linux/x86_64", Package: "ollama",
|
||||
OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU,
|
||||
Formats: []string{".tar.gz", ".tar.xz", ".tar.zst", ".zip"},
|
||||
UA: "curl Linux/6.6.123 x86_64/unknown gnu",
|
||||
},
|
||||
{
|
||||
Name: "ollama/darwin/arm64", Package: "ollama",
|
||||
OS: buildmeta.OSDarwin, Arch: buildmeta.ArchARM64, Libc: "",
|
||||
Formats: []string{".tar.gz", ".tar.xz", ".tar.zst", ".zip", ".dmg"},
|
||||
UA: "curl Darwin/25.2.0 arm64/unknown libc",
|
||||
},
|
||||
{
|
||||
Name: "ollama/linux/arm64", Package: "ollama",
|
||||
OS: buildmeta.OSLinux, Arch: buildmeta.ArchARM64, Libc: buildmeta.LibcGNU,
|
||||
Formats: []string{".tar.gz", ".tar.xz", ".tar.zst", ".zip"},
|
||||
UA: "curl Linux/6.6.123 aarch64/unknown gnu",
|
||||
},
|
||||
{
|
||||
Name: "node/linux/x86_64", Package: "node",
|
||||
OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU,
|
||||
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
|
||||
UA: "curl Linux/6.6.123 x86_64/unknown gnu",
|
||||
},
|
||||
{
|
||||
Name: "node/darwin/arm64", Package: "node",
|
||||
OS: buildmeta.OSDarwin, Arch: buildmeta.ArchARM64, Libc: "",
|
||||
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
|
||||
UA: "curl Darwin/25.2.0 arm64/unknown libc",
|
||||
},
|
||||
{
|
||||
Name: "node/linux/arm64", Package: "node",
|
||||
OS: buildmeta.OSLinux, Arch: buildmeta.ArchARM64, Libc: buildmeta.LibcGNU,
|
||||
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
|
||||
UA: "curl Linux/6.6.123 aarch64/unknown gnu",
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
cacheDir := flag.String("cache", "_cache/raw", "root directory for raw cache")
|
||||
confDir := flag.String("conf", ".", "root directory containing {pkg}/releases.conf files")
|
||||
token := flag.String("token", os.Getenv("GITHUB_TOKEN"), "GitHub API token")
|
||||
skipFetch := flag.Bool("skip-fetch", false, "skip fetching, use existing cache")
|
||||
skipLive := flag.Bool("skip-live", false, "skip live API comparison")
|
||||
packages := flag.String("packages", "goreleaser,ollama,node", "comma-separated packages to test")
|
||||
flag.Parse()
|
||||
|
||||
pkgList := strings.Split(*packages, ",")
|
||||
pkgSet := make(map[string]bool, len(pkgList))
|
||||
for _, p := range pkgList {
|
||||
pkgSet[strings.TrimSpace(p)] = true
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
var auth *githubish.Auth
|
||||
if *token != "" {
|
||||
auth = &githubish.Auth{Token: *token}
|
||||
}
|
||||
|
||||
// Step 1: Fetch raw releases.
|
||||
if !*skipFetch {
|
||||
log.Println("=== Step 1: Fetching releases ===")
|
||||
for _, pkg := range pkgList {
|
||||
if err := fetchPackage(ctx, client, *cacheDir, *confDir, pkg, auth); err != nil {
|
||||
log.Fatalf("fetch %s: %v", pkg, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Println("=== Step 1: Skipping fetch (using cache) ===")
|
||||
}
|
||||
|
||||
// Step 2: Classify releases.
|
||||
log.Println("=== Step 2: Classifying releases ===")
|
||||
allDists := make(map[string][]resolve.Dist)
|
||||
for _, pkg := range pkgList {
|
||||
conf, err := installerconf.Read(filepath.Join(*confDir, pkg, "releases.conf"))
|
||||
if err != nil {
|
||||
log.Fatalf("read conf %s: %v", pkg, err)
|
||||
}
|
||||
d, err := rawcache.Open(filepath.Join(*cacheDir, pkg))
|
||||
if err != nil {
|
||||
log.Fatalf("open cache %s: %v", pkg, err)
|
||||
}
|
||||
dists, err := classifyFromCache(pkg, conf, d)
|
||||
if err != nil {
|
||||
log.Fatalf("classify %s: %v", pkg, err)
|
||||
}
|
||||
allDists[pkg] = dists
|
||||
log.Printf(" %s: %d distributables", pkg, len(dists))
|
||||
|
||||
// Show catalog.
|
||||
cat := resolve.Survey(dists)
|
||||
log.Printf(" oses=%v arches=%v libcs=%v formats=%v", cat.OSes, cat.Arches, cat.Libcs, cat.Formats)
|
||||
log.Printf(" latest=%s stable=%s", cat.Latest, cat.Stable)
|
||||
}
|
||||
|
||||
// Step 3: Resolve best match for each test case.
|
||||
log.Println("=== Step 3: Resolving best matches ===")
|
||||
type result struct {
|
||||
tc testCase
|
||||
match *resolve.Match
|
||||
live *liveResult
|
||||
}
|
||||
var results []result
|
||||
for _, tc := range cases {
|
||||
if !pkgSet[tc.Package] {
|
||||
continue
|
||||
}
|
||||
dists := allDists[tc.Package]
|
||||
q := resolve.Query{
|
||||
OS: tc.OS,
|
||||
Arch: tc.Arch,
|
||||
Libc: tc.Libc,
|
||||
Formats: tc.Formats,
|
||||
Channel: "stable",
|
||||
}
|
||||
m := resolve.Best(dists, q)
|
||||
results = append(results, result{tc: tc, match: m})
|
||||
}
|
||||
|
||||
// Step 4: Compare with live API.
|
||||
if !*skipLive {
|
||||
log.Println("=== Step 4: Comparing with live API ===")
|
||||
for i := range results {
|
||||
tc := results[i].tc
|
||||
live, err := queryLiveAPI(client, tc)
|
||||
if err != nil {
|
||||
log.Printf(" %s: live API error: %v", tc.Name, err)
|
||||
continue
|
||||
}
|
||||
results[i].live = live
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Report.
|
||||
log.Println("")
|
||||
log.Println("=== Results ===")
|
||||
log.Println("")
|
||||
|
||||
pass, fail, warn := 0, 0, 0
|
||||
for _, r := range results {
|
||||
tc := r.tc
|
||||
m := r.match
|
||||
live := r.live
|
||||
|
||||
if m == nil {
|
||||
log.Printf("FAIL %s: no match found", tc.Name)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("--- %s ---", tc.Name)
|
||||
log.Printf(" Go: version=%s file=%s ext=%s url=%s", m.Version, m.Filename, m.Format, m.Download)
|
||||
|
||||
if live != nil {
|
||||
log.Printf(" Live: version=%s file=%s ext=%s url=%s", live.Version, live.PkgFile, live.Ext, live.PkgURL)
|
||||
|
||||
if live.Version == "0.0.0" {
|
||||
log.Printf(" WARN: live API returned error (no match)")
|
||||
warn++
|
||||
} else if m.Version == live.Version && m.Filename == live.PkgFile {
|
||||
log.Printf(" PASS: exact match")
|
||||
pass++
|
||||
} else if m.Version == live.Version && m.Download == live.PkgURL {
|
||||
log.Printf(" PASS: same URL (filename display differs: go=%s live=%s)", m.Filename, live.PkgFile)
|
||||
pass++
|
||||
} else if m.Version == live.Version {
|
||||
log.Printf(" WARN: same version, different file (go=%s live=%s)", m.Filename, live.PkgFile)
|
||||
warn++
|
||||
} else {
|
||||
log.Printf(" DIFF: version mismatch (go=%s live=%s)", m.Version, live.Version)
|
||||
fail++
|
||||
}
|
||||
} else {
|
||||
log.Printf(" (no live comparison)")
|
||||
pass++
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("")
|
||||
log.Printf("Summary: %d pass, %d fail, %d warn (live API errors)", pass, fail, warn)
|
||||
|
||||
if fail > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchPackage fetches raw releases for one package.
|
||||
func fetchPackage(ctx context.Context, client *http.Client, cacheRoot, confDir, pkg string, auth *githubish.Auth) error {
|
||||
conf, err := installerconf.Read(filepath.Join(confDir, pkg, "releases.conf"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read conf: %w", err)
|
||||
}
|
||||
|
||||
source := conf.Source
|
||||
log.Printf(" %s: source=%s", pkg, source)
|
||||
|
||||
switch source {
|
||||
case "github":
|
||||
return fetchGitHub(ctx, client, cacheRoot, pkg, conf, auth)
|
||||
case "nodedist":
|
||||
return fetchNodeDist(ctx, client, cacheRoot, pkg, conf)
|
||||
default:
|
||||
return fmt.Errorf("unsupported source %q (only github and nodedist for e2e test)", source)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGitHub(ctx context.Context, client *http.Client, cacheRoot, pkg string, conf *installerconf.Conf, auth *githubish.Auth) error {
|
||||
owner := conf.Owner
|
||||
repo := conf.Repo
|
||||
tagPrefix := conf.TagPrefix
|
||||
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range github.Fetch(ctx, client, owner, repo, auth) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("github %s/%s: %w", owner, repo, err)
|
||||
}
|
||||
for _, rel := range batch {
|
||||
if rel.Draft {
|
||||
continue
|
||||
}
|
||||
|
||||
tag := rel.TagName
|
||||
if tagPrefix != "" {
|
||||
if !strings.HasPrefix(tag, tagPrefix) {
|
||||
continue
|
||||
}
|
||||
tag = strings.TrimPrefix(tag, tagPrefix)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(rel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if latest == "" && !rel.Prerelease {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if latest != "" {
|
||||
current := d.Latest()
|
||||
if current == "" || lexver.Compare(lexver.Parse(latest), lexver.Parse(current)) > 0 {
|
||||
d.SetLatest(latest)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf(" +%d ~%d =%d latest=%s", added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchNodeDist(ctx context.Context, client *http.Client, cacheRoot, pkg string, conf *installerconf.Conf) error {
|
||||
baseURL := conf.BaseURL
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range nodedist.Fetch(ctx, client, baseURL) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("nodedist: %w", err)
|
||||
}
|
||||
for _, entry := range batch {
|
||||
tag := entry.Version
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if latest == "" {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if latest != "" {
|
||||
current := d.Latest()
|
||||
if current == "" || lexver.Compare(lexver.Parse(latest), lexver.Parse(current)) > 0 {
|
||||
d.SetLatest(latest)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf(" +%d ~%d =%d latest=%s", added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyFromCache reads the raw cache and produces classified dists.
|
||||
func classifyFromCache(pkg string, conf *installerconf.Conf, d *rawcache.Dir) ([]resolve.Dist, error) {
|
||||
source := conf.Source
|
||||
switch source {
|
||||
case "github":
|
||||
return classifyGitHub(pkg, conf, d)
|
||||
case "nodedist":
|
||||
return classifyNodeDist(pkg, conf, d)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported source %q", source)
|
||||
}
|
||||
}
|
||||
|
||||
func classifyGitHub(pkg string, conf *installerconf.Conf, d *rawcache.Dir) ([]resolve.Dist, error) {
|
||||
tagPrefix := conf.TagPrefix
|
||||
releases, err := readAllReleases(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var dists []resolve.Dist
|
||||
for _, data := range releases {
|
||||
var rel struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &rel); err != nil {
|
||||
continue
|
||||
}
|
||||
if rel.Draft {
|
||||
continue
|
||||
}
|
||||
|
||||
version := rel.TagName
|
||||
if tagPrefix != "" {
|
||||
version = strings.TrimPrefix(version, tagPrefix)
|
||||
}
|
||||
// Strip leading "v" for version normalization.
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
|
||||
channel := "stable"
|
||||
if rel.Prerelease {
|
||||
channel = "beta"
|
||||
}
|
||||
|
||||
date := ""
|
||||
if len(rel.PublishedAt) >= 10 {
|
||||
date = rel.PublishedAt[:10]
|
||||
}
|
||||
|
||||
for _, asset := range rel.Assets {
|
||||
if isMetaAsset(asset.Name) {
|
||||
continue
|
||||
}
|
||||
|
||||
r := classifyFilename(asset.Name)
|
||||
extra := detectExtra(asset.Name)
|
||||
dists = append(dists, resolve.Dist{
|
||||
Package: pkg,
|
||||
Version: version,
|
||||
Channel: channel,
|
||||
OS: r.os,
|
||||
Arch: r.arch,
|
||||
Libc: r.libc,
|
||||
Format: r.format,
|
||||
Download: asset.BrowserDownloadURL,
|
||||
Filename: asset.Name,
|
||||
Size: asset.Size,
|
||||
Date: date,
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
}
|
||||
return dists, nil
|
||||
}
|
||||
|
||||
func classifyNodeDist(pkg string, conf *installerconf.Conf, d *rawcache.Dir) ([]resolve.Dist, error) {
|
||||
baseURL := conf.BaseURL
|
||||
releases, err := readAllReleases(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var dists []resolve.Dist
|
||||
for _, data := range releases {
|
||||
var entry struct {
|
||||
Version string `json:"version"`
|
||||
Date string `json:"date"`
|
||||
Files []string `json:"files"`
|
||||
LTS json.RawMessage `json:"lts"`
|
||||
Security bool `json:"security"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lts := string(entry.LTS) != "false" && string(entry.LTS) != ""
|
||||
version := strings.TrimPrefix(entry.Version, "v")
|
||||
|
||||
// Webi treats even major versions as "stable" (LTS-eligible).
|
||||
channel := "stable"
|
||||
parts := strings.SplitN(version, ".", 2)
|
||||
if len(parts) > 0 {
|
||||
var major int
|
||||
fmt.Sscanf(parts[0], "%d", &major)
|
||||
if major%2 != 0 {
|
||||
channel = "beta"
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range entry.Files {
|
||||
if file == "src" || file == "headers" {
|
||||
continue
|
||||
}
|
||||
fileDists := expandNodeFile(pkg, entry.Version, version, channel, entry.Date, lts, baseURL, file)
|
||||
dists = append(dists, fileDists...)
|
||||
}
|
||||
}
|
||||
return dists, nil
|
||||
}
|
||||
|
||||
func expandNodeFile(pkg, rawVersion, version, channel, date string, lts bool, baseURL, file string) []resolve.Dist {
|
||||
parts := strings.Split(file, "-")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
osMap := map[string]string{
|
||||
"osx": "darwin", "linux": "linux", "win": "windows",
|
||||
"sunos": "sunos", "aix": "aix",
|
||||
}
|
||||
archMap := map[string]string{
|
||||
"x64": "x86_64", "x86": "x86", "arm64": "aarch64",
|
||||
"armv7l": "armv7", "armv6l": "armv6",
|
||||
"ppc64": "ppc64", "ppc64le": "ppc64le", "s390x": "s390x",
|
||||
"loong64": "loong64", "riscv64": "riscv64",
|
||||
}
|
||||
|
||||
os_ := osMap[parts[0]]
|
||||
arch := archMap[parts[1]]
|
||||
if os_ == "" || arch == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
libc := ""
|
||||
pkgType := ""
|
||||
if len(parts) > 2 {
|
||||
pkgType = parts[2]
|
||||
}
|
||||
|
||||
var formats []string
|
||||
switch pkgType {
|
||||
case "musl":
|
||||
libc = "musl"
|
||||
formats = []string{".tar.gz", ".tar.xz"}
|
||||
case "tar":
|
||||
formats = []string{".tar.gz", ".tar.xz"}
|
||||
case "zip":
|
||||
formats = []string{".zip"}
|
||||
case "7z":
|
||||
formats = []string{".7z"}
|
||||
case "pkg":
|
||||
formats = []string{".pkg"}
|
||||
case "msi":
|
||||
formats = []string{".msi"}
|
||||
case "exe":
|
||||
formats = []string{".exe"}
|
||||
case "":
|
||||
formats = []string{".tar.gz", ".tar.xz"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if libc == "" && os_ == "linux" {
|
||||
libc = "gnu"
|
||||
}
|
||||
|
||||
osPart := parts[0]
|
||||
if osPart == "osx" {
|
||||
osPart = "darwin"
|
||||
}
|
||||
archPart := parts[1]
|
||||
muslExtra := ""
|
||||
if libc == "musl" {
|
||||
muslExtra = "-musl"
|
||||
}
|
||||
|
||||
var dists []resolve.Dist
|
||||
for _, format := range formats {
|
||||
var filename string
|
||||
if format == ".msi" {
|
||||
filename = fmt.Sprintf("node-%s-%s%s%s", rawVersion, archPart, muslExtra, format)
|
||||
} else {
|
||||
filename = fmt.Sprintf("node-%s-%s-%s%s%s", rawVersion, osPart, archPart, muslExtra, format)
|
||||
}
|
||||
|
||||
dists = append(dists, resolve.Dist{
|
||||
Package: pkg,
|
||||
Version: version,
|
||||
Channel: channel,
|
||||
OS: os_,
|
||||
Arch: arch,
|
||||
Libc: libc,
|
||||
Format: format,
|
||||
Download: fmt.Sprintf("%s/%s/%s", baseURL, rawVersion, filename),
|
||||
Filename: filename,
|
||||
LTS: lts,
|
||||
Date: date,
|
||||
})
|
||||
}
|
||||
return dists
|
||||
}
|
||||
|
||||
// queryLiveAPI queries the live webi.sh API and parses the response header.
|
||||
func queryLiveAPI(client *http.Client, tc testCase) (*liveResult, error) {
|
||||
// Build format string matching what the webi.sh bootstrap sends.
|
||||
// Order: tar,exe,zip,xz,dmg,git (least to most favorable in bootstrap,
|
||||
// but the API doesn't care about order).
|
||||
fmtParam := "tar,exe,zip,xz,dmg"
|
||||
|
||||
url := fmt.Sprintf("https://webi.sh/api/installers/%s@stable.sh?formats=%s", tc.Package, fmtParam)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", tc.UA)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parseLiveResponse(string(body)), nil
|
||||
}
|
||||
|
||||
// parseLiveResponse extracts WEBI_* and PKG_* variables from the shell script.
|
||||
func parseLiveResponse(body string) *liveResult {
|
||||
vars := make(map[string]string)
|
||||
scanner := bufio.NewScanner(strings.NewReader(body))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
for _, prefix := range []string{"WEBI_", "PKG_"} {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
if eq := strings.IndexByte(line, '='); eq > 0 {
|
||||
key := line[:eq]
|
||||
val := line[eq+1:]
|
||||
val = strings.Trim(val, "'\"")
|
||||
vars[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &liveResult{
|
||||
Version: vars["WEBI_VERSION"],
|
||||
OS: vars["WEBI_OS"],
|
||||
Arch: vars["WEBI_ARCH"],
|
||||
Libc: vars["WEBI_LIBC"],
|
||||
Ext: vars["WEBI_EXT"],
|
||||
PkgURL: vars["WEBI_PKG_URL"],
|
||||
PkgFile: vars["WEBI_PKG_FILE"],
|
||||
Channel: vars["WEBI_CHANNEL"],
|
||||
Stable: vars["PKG_STABLE"],
|
||||
Latest: vars["PKG_LATEST"],
|
||||
Oses: vars["PKG_OSES"],
|
||||
Arches: vars["PKG_ARCHES"],
|
||||
Libcs: vars["PKG_LIBCS"],
|
||||
Formats: vars["PKG_FORMATS"],
|
||||
}
|
||||
}
|
||||
|
||||
// readAllReleases reads all cached release files.
|
||||
func readAllReleases(d *rawcache.Dir) (map[string][]byte, error) {
|
||||
active, err := d.ActivePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(active)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string][]byte, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || strings.HasPrefix(e.Name(), "_") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(active, e.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[e.Name()] = data
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type classResult struct {
|
||||
os, arch, libc, format string
|
||||
}
|
||||
|
||||
func classifyFilename(name string) classResult {
|
||||
// Use the classify package.
|
||||
// Import it indirectly to avoid circular deps — inline the logic
|
||||
// we need for the e2e test.
|
||||
lower := strings.ToLower(name)
|
||||
|
||||
var r classResult
|
||||
r.format = detectFormat(name)
|
||||
|
||||
// OS detection
|
||||
switch {
|
||||
case strings.Contains(lower, "linux"):
|
||||
r.os = "linux"
|
||||
case strings.Contains(lower, "darwin") || strings.Contains(lower, "macos") || strings.Contains(lower, "apple"):
|
||||
r.os = "darwin"
|
||||
case strings.Contains(lower, "windows") || strings.Contains(lower, "win64") || strings.Contains(lower, "win32"):
|
||||
r.os = "windows"
|
||||
case strings.HasSuffix(lower, ".dmg") || strings.HasSuffix(lower, ".app.zip"):
|
||||
r.os = "darwin"
|
||||
case strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".msi"):
|
||||
r.os = "windows"
|
||||
case strings.Contains(lower, "freebsd"):
|
||||
r.os = "freebsd"
|
||||
}
|
||||
|
||||
// Arch detection
|
||||
switch {
|
||||
case strings.Contains(lower, "x86_64") || strings.Contains(lower, "amd64") || strings.Contains(lower, "x64"):
|
||||
r.arch = "x86_64"
|
||||
case strings.Contains(lower, "aarch64") || strings.Contains(lower, "arm64"):
|
||||
r.arch = "aarch64"
|
||||
case strings.Contains(lower, "armv7") || strings.Contains(lower, "armhf"):
|
||||
r.arch = "armv7"
|
||||
case strings.Contains(lower, "armv6"):
|
||||
r.arch = "armv6"
|
||||
case strings.Contains(lower, "i686") || strings.Contains(lower, "i386") || strings.Contains(lower, "x86") || strings.Contains(lower, "386"):
|
||||
r.arch = "x86"
|
||||
case strings.Contains(lower, "ppc64le") || strings.Contains(lower, "powerpc64le"):
|
||||
r.arch = "ppc64le"
|
||||
case strings.Contains(lower, "ppc64") || strings.Contains(lower, "powerpc64"):
|
||||
r.arch = "ppc64"
|
||||
case strings.Contains(lower, "riscv64"):
|
||||
r.arch = "riscv64"
|
||||
case strings.Contains(lower, "s390x"):
|
||||
r.arch = "s390x"
|
||||
case strings.Contains(lower, "loong64"):
|
||||
r.arch = "loong64"
|
||||
}
|
||||
|
||||
// Libc detection
|
||||
switch {
|
||||
case strings.Contains(lower, "musl"):
|
||||
r.libc = "musl"
|
||||
case strings.Contains(lower, "gnu"):
|
||||
r.libc = "gnu"
|
||||
case strings.Contains(lower, "msvc"):
|
||||
r.libc = "msvc"
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func detectFormat(name string) string {
|
||||
lower := strings.ToLower(name)
|
||||
for _, ext := range []string{".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".exe.xz", ".app.zip"} {
|
||||
if strings.HasSuffix(lower, ext) {
|
||||
return ext
|
||||
}
|
||||
}
|
||||
// .tgz is a common alias for .tar.gz
|
||||
if strings.HasSuffix(lower, ".tgz") {
|
||||
return ".tar.gz"
|
||||
}
|
||||
return filepath.Ext(lower)
|
||||
}
|
||||
|
||||
// detectExtra identifies GPU/vendor-specific variant suffixes in filenames
|
||||
// like "ollama-linux-amd64-rocm.tar.zst" or "ollama-linux-arm64-jetpack5.tar.zst".
|
||||
func detectExtra(name string) string {
|
||||
lower := strings.ToLower(name)
|
||||
for _, variant := range []string{
|
||||
"-rocm", "-jetpack", "-cuda", "-vulkan", "-metal",
|
||||
"-extended", "-static", "-debug", "-nightly",
|
||||
} {
|
||||
if strings.Contains(lower, variant) {
|
||||
return strings.TrimPrefix(variant, "-")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isMetaAsset(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
for _, suffix := range []string{
|
||||
".sha256", ".sha256sum", ".sha512", ".sha512sum",
|
||||
".md5", ".md5sum", ".sig", ".asc", ".pem",
|
||||
"checksums.txt", "sha256sums", "sha512sums",
|
||||
".sbom", ".spdx", ".json.sig", ".sigstore",
|
||||
".d.ts", ".pub",
|
||||
} {
|
||||
if strings.HasSuffix(lower, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, contains := range []string{
|
||||
"checksums", "sha256sum", "sha512sum",
|
||||
"buildable-artifact",
|
||||
} {
|
||||
if strings.Contains(lower, contains) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, exact := range []string{
|
||||
"install.sh", "install.ps1", "compat.json",
|
||||
} {
|
||||
if lower == exact {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,862 +0,0 @@
|
||||
// Command fetchraw fetches release histories from upstream APIs and
|
||||
// merges them into rawcache. Safe to run repeatedly — unchanged releases
|
||||
// are skipped, new/changed ones are recorded in the audit log.
|
||||
//
|
||||
// Reads releases.conf files from package directories to discover what
|
||||
// to fetch. Adding a new package is just creating a conf file.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/fetchraw -cache ./_cache/raw
|
||||
// go run ./cmd/fetchraw -cache ./_cache/raw hugo caddy
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/installerconf"
|
||||
"github.com/webinstall/webi-installers/internal/lexver"
|
||||
"github.com/webinstall/webi-installers/internal/rawcache"
|
||||
"github.com/webinstall/webi-installers/internal/releases/chromedist"
|
||||
"github.com/webinstall/webi-installers/internal/releases/flutterdist"
|
||||
"github.com/webinstall/webi-installers/internal/releases/gitea"
|
||||
"github.com/webinstall/webi-installers/internal/releases/github"
|
||||
"github.com/webinstall/webi-installers/internal/releases/githubish"
|
||||
"github.com/webinstall/webi-installers/internal/releases/gittag"
|
||||
"github.com/webinstall/webi-installers/internal/releases/golang"
|
||||
"github.com/webinstall/webi-installers/internal/releases/gpgdist"
|
||||
"github.com/webinstall/webi-installers/internal/releases/hashicorp"
|
||||
"github.com/webinstall/webi-installers/internal/releases/iterm2dist"
|
||||
"github.com/webinstall/webi-installers/internal/releases/juliadist"
|
||||
"github.com/webinstall/webi-installers/internal/releases/mariadbdist"
|
||||
"github.com/webinstall/webi-installers/internal/releases/nodedist"
|
||||
"github.com/webinstall/webi-installers/internal/releases/zigdist"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cacheDir := flag.String("cache", "_cache/raw", "root directory for raw cache")
|
||||
confDir := flag.String("conf", ".", "root directory containing {pkg}/releases.conf files")
|
||||
token := flag.String("token", os.Getenv("GITHUB_TOKEN"), "GitHub API token")
|
||||
flag.Parse()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
var auth *githubish.Auth
|
||||
if *token != "" {
|
||||
auth = &githubish.Auth{Token: *token}
|
||||
}
|
||||
|
||||
// Discover packages from releases.conf files.
|
||||
packages, err := discover(*confDir)
|
||||
if err != nil {
|
||||
log.Fatalf("discover: %v", err)
|
||||
}
|
||||
|
||||
// Filter to requested packages if args given.
|
||||
args := flag.Args()
|
||||
if len(args) > 0 {
|
||||
nameSet := make(map[string]bool, len(args))
|
||||
for _, a := range args {
|
||||
nameSet[a] = true
|
||||
}
|
||||
var filtered []pkgConf
|
||||
for _, p := range packages {
|
||||
if nameSet[p.name] {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
packages = filtered
|
||||
}
|
||||
|
||||
log.Printf("found %d packages", len(packages))
|
||||
|
||||
for _, pkg := range packages {
|
||||
// Aliases share cache with their target — skip fetching.
|
||||
if alias := pkg.conf.Extra["alias_of"]; alias != "" {
|
||||
log.Printf(" %s: alias of %s, skipping", pkg.name, alias)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("fetching %s...", pkg.name)
|
||||
var err error
|
||||
switch pkg.conf.Source {
|
||||
case "github":
|
||||
err = fetchGitHub(ctx, client, *cacheDir, pkg.name, pkg.conf, auth)
|
||||
case "nodedist":
|
||||
err = fetchNodeDist(ctx, client, *cacheDir, pkg.name, pkg.conf)
|
||||
case "golang":
|
||||
err = fetchGolang(ctx, client, *cacheDir, pkg.name)
|
||||
case "zigdist":
|
||||
err = fetchZig(ctx, client, *cacheDir, pkg.name)
|
||||
case "flutterdist":
|
||||
err = fetchFlutter(ctx, client, *cacheDir, pkg.name)
|
||||
case "iterm2dist":
|
||||
err = fetchITerm2(ctx, client, *cacheDir, pkg.name)
|
||||
case "hashicorp":
|
||||
err = fetchHashiCorp(ctx, client, *cacheDir, pkg.name, pkg.conf)
|
||||
case "juliadist":
|
||||
err = fetchJulia(ctx, client, *cacheDir, pkg.name)
|
||||
case "gittag":
|
||||
err = fetchGitTag(ctx, *cacheDir, pkg.name, pkg.conf)
|
||||
case "gitea":
|
||||
err = fetchGitea(ctx, client, *cacheDir, pkg.name, pkg.conf)
|
||||
case "chromedist":
|
||||
err = fetchChrome(ctx, client, *cacheDir, pkg.name)
|
||||
case "gpgdist":
|
||||
err = fetchGPG(ctx, client, *cacheDir, pkg.name)
|
||||
case "mariadbdist":
|
||||
err = fetchMariaDB(ctx, client, *cacheDir, pkg.name)
|
||||
default:
|
||||
log.Printf(" %s: unknown source %q, skipping", pkg.name, pkg.conf.Source)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf(" ERROR: %s: %v", pkg.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type pkgConf struct {
|
||||
name string
|
||||
conf *installerconf.Conf
|
||||
}
|
||||
|
||||
// discover finds all {dir}/*/releases.conf files and returns them sorted.
|
||||
func discover(dir string) ([]pkgConf, error) {
|
||||
pattern := filepath.Join(dir, "*", "releases.conf")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var packages []pkgConf
|
||||
for _, path := range matches {
|
||||
name := filepath.Base(filepath.Dir(path))
|
||||
// Skip infrastructure dirs (_example, _webi, _common, etc.)
|
||||
if strings.HasPrefix(name, "_") {
|
||||
continue
|
||||
}
|
||||
conf, err := installerconf.Read(path)
|
||||
if err != nil {
|
||||
log.Printf("warning: %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
packages = append(packages, pkgConf{name: name, conf: conf})
|
||||
}
|
||||
|
||||
sort.Slice(packages, func(i, j int) bool {
|
||||
return packages[i].name < packages[j].name
|
||||
})
|
||||
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func fetchNodeDist(ctx context.Context, client *http.Client, cacheRoot, pkgName string, conf *installerconf.Conf) error {
|
||||
baseURL := conf.BaseURL
|
||||
if baseURL == "" {
|
||||
return fmt.Errorf("missing url in releases.conf")
|
||||
}
|
||||
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range nodedist.Fetch(ctx, client, baseURL) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s fetch: %w", pkgName, err)
|
||||
}
|
||||
for _, entry := range batch {
|
||||
tag := entry.Version
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s marshal %s: %w", pkgName, tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if latest == "" {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGitHub(ctx context.Context, client *http.Client, cacheRoot, pkgName string, conf *installerconf.Conf, auth *githubish.Auth) error {
|
||||
owner := conf.Owner
|
||||
repo := conf.Repo
|
||||
tagPrefix := conf.TagPrefix
|
||||
|
||||
if owner == "" || repo == "" {
|
||||
return fmt.Errorf("missing owner or repo in releases.conf")
|
||||
}
|
||||
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range github.Fetch(ctx, client, owner, repo, auth) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("github %s/%s: %w", owner, repo, err)
|
||||
}
|
||||
for _, rel := range batch {
|
||||
if rel.Draft {
|
||||
continue
|
||||
}
|
||||
|
||||
tag := rel.TagName
|
||||
|
||||
if tagPrefix != "" {
|
||||
if !strings.HasPrefix(tag, tagPrefix) {
|
||||
continue
|
||||
}
|
||||
tag = strings.TrimPrefix(tag, tagPrefix)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(rel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if latest == "" && !rel.Prerelease {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateLatest(d *rawcache.Dir, candidate string) error {
|
||||
if candidate == "" {
|
||||
return nil
|
||||
}
|
||||
current := d.Latest()
|
||||
if current == "" || lexver.Compare(lexver.Parse(candidate), lexver.Parse(current)) > 0 {
|
||||
return d.SetLatest(candidate)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGolang(ctx context.Context, client *http.Client, cacheRoot, pkgName string) error {
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range golang.Fetch(ctx, client) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("golang: %w", err)
|
||||
}
|
||||
for _, rel := range batch {
|
||||
tag := rel.Version // "go1.24.1"
|
||||
data, err := json.Marshal(rel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("golang marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if latest == "" && rel.Stable {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchZig(ctx context.Context, client *http.Client, cacheRoot, pkgName string) error {
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range zigdist.Fetch(ctx, client) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("zigdist: %w", err)
|
||||
}
|
||||
for _, rel := range batch {
|
||||
tag := rel.Version
|
||||
data, err := json.Marshal(rel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zigdist marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
// Stable versions have dots and no dev/pre markers.
|
||||
isStable := strings.Contains(tag, ".") && !strings.ContainsAny(tag, "+-")
|
||||
if isStable {
|
||||
if latest == "" || lexver.Compare(lexver.Parse(tag), lexver.Parse(latest)) > 0 {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchFlutter(ctx context.Context, client *http.Client, cacheRoot, pkgName string) error {
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range flutterdist.Fetch(ctx, client) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("flutterdist: %w", err)
|
||||
}
|
||||
for _, rel := range batch {
|
||||
// Use version+channel+os+arch as the tag. The arch is embedded
|
||||
// in the archive path (e.g. flutter_macos_arm64_3.0.0-stable.zip
|
||||
// vs flutter_macos_3.0.0-stable.zip for universal/x64).
|
||||
arch := ""
|
||||
base := filepath.Base(rel.Archive)
|
||||
prefix := "flutter_" + rel.OS + "_"
|
||||
if after, ok := strings.CutPrefix(base, prefix); ok {
|
||||
if !strings.HasPrefix(after, rel.Version) {
|
||||
// There's an arch segment between OS and version.
|
||||
if idx := strings.Index(after, "_"); idx > 0 {
|
||||
arch = after[:idx]
|
||||
}
|
||||
}
|
||||
}
|
||||
tag := fmt.Sprintf("%s-%s-%s", rel.Version, rel.Channel, rel.OS)
|
||||
if arch != "" {
|
||||
tag += "-" + arch
|
||||
}
|
||||
data, err := json.Marshal(rel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("flutterdist marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if latest == "" && rel.Channel == "stable" {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchITerm2(ctx context.Context, client *http.Client, cacheRoot, pkgName string) error {
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range iterm2dist.Fetch(ctx, client) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("iterm2dist: %w", err)
|
||||
}
|
||||
for _, entry := range batch {
|
||||
tag := entry.Version
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("iterm2dist marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if latest == "" && entry.Channel == "stable" {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchHashiCorp(ctx context.Context, client *http.Client, cacheRoot, pkgName string, conf *installerconf.Conf) error {
|
||||
product := conf.Extra["product"]
|
||||
if product == "" {
|
||||
return fmt.Errorf("missing product in releases.conf")
|
||||
}
|
||||
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for idx, err := range hashicorp.Fetch(ctx, client, product) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("hashicorp %s: %w", product, err)
|
||||
}
|
||||
for tag, ver := range idx.Versions {
|
||||
data, err := json.Marshal(ver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hashicorp marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
// Stable = no prerelease markers. Compare all to find highest.
|
||||
isStable := !strings.ContainsAny(tag, "-+")
|
||||
if isStable {
|
||||
if latest == "" || lexver.Compare(lexver.Parse(tag), lexver.Parse(latest)) > 0 {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchJulia(ctx context.Context, client *http.Client, cacheRoot, pkgName string) error {
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range juliadist.Fetch(ctx, client) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("juliadist: %w", err)
|
||||
}
|
||||
for _, rel := range batch {
|
||||
tag := rel.Version
|
||||
data, err := json.Marshal(rel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("juliadist marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if rel.Stable {
|
||||
if latest == "" || lexver.Compare(lexver.Parse(tag), lexver.Parse(latest)) > 0 {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGitTag(ctx context.Context, cacheRoot, pkgName string, conf *installerconf.Conf) error {
|
||||
gitURL := conf.BaseURL
|
||||
if gitURL == "" {
|
||||
return fmt.Errorf("missing url in releases.conf")
|
||||
}
|
||||
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoDir := filepath.Join(cacheRoot, "_repos")
|
||||
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range gittag.Fetch(ctx, gitURL, repoDir) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("gittag %s: %w", pkgName, err)
|
||||
}
|
||||
for _, entry := range batch {
|
||||
tag := entry.Version
|
||||
if tag == "" {
|
||||
tag = "HEAD-" + entry.CommitHash
|
||||
}
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gittag marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if entry.GitTag != "" && entry.GitTag != "HEAD" {
|
||||
if latest == "" || lexver.Compare(lexver.Parse(tag), lexver.Parse(latest)) > 0 {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGitea(ctx context.Context, client *http.Client, cacheRoot, pkgName string, conf *installerconf.Conf) error {
|
||||
baseURL := conf.BaseURL
|
||||
owner := conf.Owner
|
||||
repo := conf.Repo
|
||||
|
||||
if baseURL == "" || owner == "" || repo == "" {
|
||||
return fmt.Errorf("missing base_url, owner, or repo in releases.conf")
|
||||
}
|
||||
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range gitea.Fetch(ctx, client, baseURL, owner, repo, nil) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea %s/%s: %w", owner, repo, err)
|
||||
}
|
||||
for _, rel := range batch {
|
||||
if rel.Draft {
|
||||
continue
|
||||
}
|
||||
|
||||
tag := rel.TagName
|
||||
data, err := json.Marshal(rel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if latest == "" && !rel.Prerelease {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchChrome(ctx context.Context, client *http.Client, cacheRoot, pkgName string) error {
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range chromedist.Fetch(ctx, client) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("chromedist: %w", err)
|
||||
}
|
||||
for _, ver := range batch {
|
||||
tag := ver.Version
|
||||
data, err := json.Marshal(ver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("chromedist marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if latest == "" || lexver.Compare(lexver.Parse(tag), lexver.Parse(latest)) > 0 {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGPG(ctx context.Context, client *http.Client, cacheRoot, pkgName string) error {
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range gpgdist.Fetch(ctx, client) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("gpgdist: %w", err)
|
||||
}
|
||||
for _, entry := range batch {
|
||||
tag := entry.Version
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gpgdist marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
if latest == "" || lexver.Compare(lexver.Parse(tag), lexver.Parse(latest)) > 0 {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchMariaDB(ctx context.Context, client *http.Client, cacheRoot, pkgName string) error {
|
||||
d, err := rawcache.Open(filepath.Join(cacheRoot, pkgName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added, changed, skipped int
|
||||
var latest string
|
||||
for batch, err := range mariadbdist.Fetch(ctx, client) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("mariadbdist: %w", err)
|
||||
}
|
||||
for _, rel := range batch {
|
||||
tag := rel.ReleaseID
|
||||
data, err := json.Marshal(rel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mariadbdist marshal %s: %w", tag, err)
|
||||
}
|
||||
|
||||
action, err := d.Merge(tag, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "added":
|
||||
added++
|
||||
case "changed":
|
||||
changed++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
|
||||
isStable := rel.MajorStatus == "Stable"
|
||||
if isStable {
|
||||
if latest == "" || lexver.Compare(lexver.Parse(tag), lexver.Parse(latest)) > 0 {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateLatest(d, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" %s: +%d ~%d =%d latest=%s", pkgName, added, changed, skipped, d.Latest())
|
||||
return nil
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
// Command inspect downloads release archives, unpacks them, and reports
|
||||
// their internal structure. This helps discover how packages are laid out
|
||||
// and whether the layout changes across versions.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/inspect -csv distributables.csv -cache ./_cache/downloads ollama sd
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/httpclient"
|
||||
)
|
||||
|
||||
// Row is one CSV row from distributables.csv.
|
||||
type Row struct {
|
||||
Package string
|
||||
Version string
|
||||
Channel string
|
||||
Date string
|
||||
OS string
|
||||
Arch string
|
||||
Libc string
|
||||
Format string
|
||||
Download string
|
||||
Filename string
|
||||
Extra string
|
||||
}
|
||||
|
||||
// archiveFormats are the formats we download and unpack.
|
||||
var archiveFormats = map[string]bool{
|
||||
".tar.gz": true,
|
||||
".tar.xz": true,
|
||||
".tar.bz2": true,
|
||||
".tar.zst": true,
|
||||
".zip": true,
|
||||
".dmg": true,
|
||||
".gz": true,
|
||||
".xz": true,
|
||||
}
|
||||
|
||||
// inspectOSes are the OSes we inspect.
|
||||
var inspectOSes = map[string]bool{
|
||||
"linux": true,
|
||||
"darwin": true,
|
||||
"windows": true,
|
||||
"": true, // source-only packages
|
||||
}
|
||||
|
||||
// preferredArch picks one arch per OS to download.
|
||||
func preferredArch(os_ string) string {
|
||||
switch os_ {
|
||||
case "darwin":
|
||||
return "aarch64"
|
||||
default:
|
||||
return "x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
csvFile := flag.String("csv", "distributables.csv", "path to distributables CSV")
|
||||
cacheDir := flag.String("cache", "_cache/downloads", "download cache directory")
|
||||
flag.Parse()
|
||||
|
||||
packages := flag.Args()
|
||||
if len(packages) == 0 {
|
||||
log.Fatal("usage: inspect [-csv FILE] [-cache DIR] PACKAGE [PACKAGE...]")
|
||||
}
|
||||
|
||||
rows, err := readCSV(*csvFile)
|
||||
if err != nil {
|
||||
log.Fatalf("read csv: %v", err)
|
||||
}
|
||||
|
||||
client := httpclient.New()
|
||||
// Override timeout for large downloads.
|
||||
client.Timeout = 10 * time.Minute
|
||||
|
||||
for _, pkg := range packages {
|
||||
log.Printf("=== %s ===", pkg)
|
||||
if err := inspectPackage(client, rows, pkg, *cacheDir); err != nil {
|
||||
log.Printf("ERROR: %s: %v", pkg, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readCSV(path string) ([]Row, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
r := csv.NewReader(f)
|
||||
header, err := r.Read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build column index.
|
||||
idx := make(map[string]int, len(header))
|
||||
for i, col := range header {
|
||||
idx[col] = i
|
||||
}
|
||||
|
||||
var rows []Row
|
||||
for {
|
||||
record, err := r.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
get := func(col string) string {
|
||||
if i, ok := idx[col]; ok && i < len(record) {
|
||||
return record[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rows = append(rows, Row{
|
||||
Package: get("package"),
|
||||
Version: get("version"),
|
||||
Channel: get("channel"),
|
||||
Date: get("date"),
|
||||
OS: get("os"),
|
||||
Arch: get("arch"),
|
||||
Libc: get("libc"),
|
||||
Format: get("format"),
|
||||
Download: get("download"),
|
||||
Filename: get("filename"),
|
||||
Extra: get("extra"),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func inspectPackage(client *http.Client, allRows []Row, pkg, cacheDir string) error {
|
||||
// Filter rows for this package.
|
||||
var pkgRows []Row
|
||||
for _, r := range allRows {
|
||||
if r.Package == pkg {
|
||||
pkgRows = append(pkgRows, r)
|
||||
}
|
||||
}
|
||||
if len(pkgRows) == 0 {
|
||||
return fmt.Errorf("no rows found")
|
||||
}
|
||||
|
||||
// Find latest stable version, fall back to any version.
|
||||
versions := findVersionsByDate(pkgRows)
|
||||
if len(versions) == 0 {
|
||||
return fmt.Errorf("no versions found")
|
||||
}
|
||||
|
||||
latestVer := versions[0]
|
||||
log.Printf(" latest version: %s", latestVer)
|
||||
|
||||
// Check if latest has assets uploaded (more than just source tarballs).
|
||||
latestRows := filterVersion(pkgRows, latestVer)
|
||||
hasRealAssets := false
|
||||
for _, r := range latestRows {
|
||||
if r.Extra != "source" && archiveFormats[r.Format] {
|
||||
hasRealAssets = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If latest looks empty, step back one version.
|
||||
if !hasRealAssets && len(versions) > 1 {
|
||||
latestVer = versions[1]
|
||||
latestRows = filterVersion(pkgRows, latestVer)
|
||||
log.Printf(" latest has no assets, using: %s", latestVer)
|
||||
}
|
||||
|
||||
// Inspect the latest version.
|
||||
if err := inspectVersion(client, pkg, latestVer, latestRows, cacheDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find versions roughly a year apart going back.
|
||||
yearVersions := findYearlyVersions(pkgRows, latestVer)
|
||||
for _, v := range yearVersions {
|
||||
log.Printf(" --- checking %s ---", v)
|
||||
vRows := filterVersion(pkgRows, v)
|
||||
if err := inspectVersion(client, pkg, v, vRows, cacheDir); err != nil {
|
||||
log.Printf(" ERROR: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findVersionsByDate returns versions sorted newest first, preferring stable.
|
||||
func findVersionsByDate(rows []Row) []string {
|
||||
type vInfo struct {
|
||||
version string
|
||||
date string
|
||||
stable bool
|
||||
}
|
||||
seen := map[string]*vInfo{}
|
||||
for _, r := range rows {
|
||||
if _, ok := seen[r.Version]; !ok {
|
||||
seen[r.Version] = &vInfo{
|
||||
version: r.Version,
|
||||
date: r.Date,
|
||||
stable: r.Channel == "stable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var vs []*vInfo
|
||||
for _, v := range seen {
|
||||
vs = append(vs, v)
|
||||
}
|
||||
|
||||
// Sort: stable first, then by date descending, then version descending.
|
||||
sort.Slice(vs, func(i, j int) bool {
|
||||
if vs[i].stable != vs[j].stable {
|
||||
return vs[i].stable
|
||||
}
|
||||
if vs[i].date != vs[j].date {
|
||||
return vs[i].date > vs[j].date
|
||||
}
|
||||
return vs[i].version > vs[j].version
|
||||
})
|
||||
|
||||
result := make([]string, len(vs))
|
||||
for i, v := range vs {
|
||||
result[i] = v.version
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// findYearlyVersions picks versions roughly a year apart before the given version.
|
||||
func findYearlyVersions(rows []Row, latestVer string) []string {
|
||||
// Find the date of latest.
|
||||
var latestDate string
|
||||
for _, r := range rows {
|
||||
if r.Version == latestVer && r.Date != "" {
|
||||
latestDate = r.Date
|
||||
break
|
||||
}
|
||||
}
|
||||
if latestDate == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
latestTime, err := time.Parse("2006-01-02", latestDate)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect all stable versions with dates.
|
||||
type vd struct {
|
||||
version string
|
||||
date time.Time
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var all []vd
|
||||
for _, r := range rows {
|
||||
if seen[r.Version] || r.Date == "" || r.Channel != "stable" {
|
||||
continue
|
||||
}
|
||||
seen[r.Version] = true
|
||||
t, err := time.Parse("2006-01-02", r.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if t.Before(latestTime) {
|
||||
all = append(all, vd{r.Version, t})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].date.After(all[j].date)
|
||||
})
|
||||
|
||||
// Pick versions roughly a year apart.
|
||||
var result []string
|
||||
nextTarget := latestTime.AddDate(-1, 0, 0)
|
||||
for _, v := range all {
|
||||
if v.date.Before(nextTarget) || v.date.Equal(nextTarget) {
|
||||
result = append(result, v.version)
|
||||
nextTarget = v.date.AddDate(-1, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func filterVersion(rows []Row, version string) []Row {
|
||||
var result []Row
|
||||
for _, r := range rows {
|
||||
if r.Version == version {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// inspectVersion downloads and inspects archives for one version.
|
||||
func inspectVersion(client *http.Client, pkg, version string, rows []Row, cacheDir string) error {
|
||||
// Group by OS, pick one arch per OS, pick distinct formats.
|
||||
type dlKey struct {
|
||||
os_ string
|
||||
format string
|
||||
}
|
||||
selected := map[dlKey]*Row{}
|
||||
|
||||
for i := range rows {
|
||||
r := &rows[i]
|
||||
if !inspectOSes[r.OS] {
|
||||
continue
|
||||
}
|
||||
if !archiveFormats[r.Format] {
|
||||
continue
|
||||
}
|
||||
|
||||
key := dlKey{r.OS, r.Format}
|
||||
existing := selected[key]
|
||||
if existing == nil {
|
||||
selected[key] = r
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefer the preferred arch.
|
||||
pref := preferredArch(r.OS)
|
||||
if r.Arch == pref && existing.Arch != pref {
|
||||
selected[key] = r
|
||||
}
|
||||
// Skip rocm/jetpack variants.
|
||||
if strings.Contains(r.Filename, "rocm") || strings.Contains(r.Filename, "jetpack") {
|
||||
if !strings.Contains(existing.Filename, "rocm") && !strings.Contains(existing.Filename, "jetpack") {
|
||||
continue // keep existing non-special variant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(selected) == 0 {
|
||||
log.Printf(" %s: no downloadable archives", version)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort keys for deterministic output.
|
||||
var keys []dlKey
|
||||
for k := range selected {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].os_ != keys[j].os_ {
|
||||
return keys[i].os_ < keys[j].os_
|
||||
}
|
||||
return keys[i].format < keys[j].format
|
||||
})
|
||||
|
||||
for _, key := range keys {
|
||||
r := selected[key]
|
||||
os_ := r.OS
|
||||
if os_ == "" {
|
||||
os_ = "any"
|
||||
}
|
||||
log.Printf(" [%s] %s %s → %s", version, os_, r.Format, r.Filename)
|
||||
|
||||
dlPath, err := download(client, r.Download, r.Filename, filepath.Join(cacheDir, pkg, version))
|
||||
if err != nil {
|
||||
log.Printf(" download error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
contents, err := unpackAndList(dlPath, r.Format)
|
||||
if err != nil {
|
||||
log.Printf(" unpack error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
printContents(contents)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// download fetches a URL to the cache dir. Returns the path to the cached file.
|
||||
// Skips download if the file already exists.
|
||||
func download(client *http.Client, url, hintFilename, dir string) (string, error) {
|
||||
// Check if already cached by hint filename.
|
||||
cached := filepath.Join(dir, hintFilename)
|
||||
if _, err := os.Stat(cached); err == nil {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
resp, err := httpclient.Get(ctx, client, url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
|
||||
// Determine filename from Content-Disposition or hint.
|
||||
filename := hintFilename
|
||||
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
|
||||
_, params, err := mime.ParseMediaType(cd)
|
||||
if err == nil {
|
||||
if fn, ok := params["filename"]; ok && fn != "" {
|
||||
filename = fn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outPath := filepath.Join(dir, filename)
|
||||
|
||||
// Atomic write: temp file + rename.
|
||||
tmp := outPath + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
n, err := io.Copy(f, resp.Body)
|
||||
if closeErr := f.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", fmt.Errorf("download %s: %w", url, err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, outPath); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Printf(" downloaded %s (%d bytes)", filename, n)
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
// FileEntry describes one file inside an archive.
|
||||
type FileEntry struct {
|
||||
Path string
|
||||
Size int64
|
||||
Mode os.FileMode
|
||||
IsDir bool
|
||||
IsExec bool
|
||||
IsSymlink bool
|
||||
LinkTarget string
|
||||
}
|
||||
|
||||
// unpackAndList extracts an archive to a temp dir and lists contents.
|
||||
func unpackAndList(archivePath, format string) ([]FileEntry, error) {
|
||||
tmpDir, err := os.MkdirTemp("", "webi-inspect-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
switch format {
|
||||
case ".tar.gz":
|
||||
err = run("tar", "xzf", archivePath, "-C", tmpDir)
|
||||
case ".tar.xz":
|
||||
err = run("tar", "xJf", archivePath, "-C", tmpDir)
|
||||
case ".tar.bz2":
|
||||
err = run("tar", "xjf", archivePath, "-C", tmpDir)
|
||||
case ".tar.zst":
|
||||
err = run("tar", "--zstd", "-xf", archivePath, "-C", tmpDir)
|
||||
case ".zip":
|
||||
err = run("unzip", "-q", "-o", archivePath, "-d", tmpDir)
|
||||
case ".dmg":
|
||||
err = extractDMG(archivePath, tmpDir)
|
||||
case ".gz":
|
||||
// Single file gzip.
|
||||
base := filepath.Base(archivePath)
|
||||
base = strings.TrimSuffix(base, ".gz")
|
||||
outPath := filepath.Join(tmpDir, base)
|
||||
err = run("sh", "-c", fmt.Sprintf("gunzip -c %q > %q", archivePath, outPath))
|
||||
case ".xz":
|
||||
base := filepath.Base(archivePath)
|
||||
base = strings.TrimSuffix(base, ".xz")
|
||||
outPath := filepath.Join(tmpDir, base)
|
||||
err = run("sh", "-c", fmt.Sprintf("xz -dc %q > %q", archivePath, outPath))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported format: %s", format)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract %s: %w", format, err)
|
||||
}
|
||||
|
||||
return listDir(tmpDir, "")
|
||||
}
|
||||
|
||||
func extractDMG(dmgPath, outDir string) error {
|
||||
// Try 7z first (doesn't require mounting).
|
||||
if _, err := exec.LookPath("7z"); err == nil {
|
||||
return run("7z", "x", "-o"+outDir, dmgPath)
|
||||
}
|
||||
|
||||
// Fall back to hdiutil mount + copy + unmount.
|
||||
mountPoint, err := os.MkdirTemp("", "webi-dmg-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(mountPoint)
|
||||
|
||||
if err := run("hdiutil", "attach", dmgPath, "-mountpoint", mountPoint, "-nobrowse", "-quiet"); err != nil {
|
||||
return fmt.Errorf("mount dmg: %w", err)
|
||||
}
|
||||
defer run("hdiutil", "detach", mountPoint, "-quiet")
|
||||
|
||||
// Copy contents.
|
||||
return run("cp", "-R", mountPoint+"/.", outDir)
|
||||
}
|
||||
|
||||
func run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func listDir(root, prefix string) ([]FileEntry, error) {
|
||||
entries, err := os.ReadDir(filepath.Join(root, prefix))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []FileEntry
|
||||
for _, e := range entries {
|
||||
relPath := filepath.Join(prefix, e.Name())
|
||||
fullPath := filepath.Join(root, relPath)
|
||||
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := FileEntry{
|
||||
Path: relPath,
|
||||
Size: info.Size(),
|
||||
Mode: info.Mode(),
|
||||
IsDir: e.IsDir(),
|
||||
}
|
||||
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
entry.IsSymlink = true
|
||||
target, _ := os.Readlink(fullPath)
|
||||
entry.LinkTarget = target
|
||||
}
|
||||
|
||||
if !e.IsDir() && info.Mode()&0o111 != 0 {
|
||||
entry.IsExec = true
|
||||
}
|
||||
|
||||
result = append(result, entry)
|
||||
|
||||
if e.IsDir() {
|
||||
sub, err := listDir(root, relPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, sub...)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func printContents(entries []FileEntry) {
|
||||
for _, e := range entries {
|
||||
marker := " "
|
||||
if e.IsExec {
|
||||
marker = "* "
|
||||
}
|
||||
if e.IsDir {
|
||||
marker = "d "
|
||||
}
|
||||
if e.IsSymlink {
|
||||
marker = "→ "
|
||||
}
|
||||
|
||||
size := ""
|
||||
if !e.IsDir {
|
||||
size = formatSize(e.Size)
|
||||
}
|
||||
|
||||
line := fmt.Sprintf(" %s%-50s %8s", marker, e.Path, size)
|
||||
if e.IsSymlink {
|
||||
line += " → " + e.LinkTarget
|
||||
}
|
||||
log.Print(line)
|
||||
}
|
||||
}
|
||||
|
||||
func formatSize(n int64) string {
|
||||
switch {
|
||||
case n >= 1<<30:
|
||||
return fmt.Sprintf("%.1fG", float64(n)/float64(1<<30))
|
||||
case n >= 1<<20:
|
||||
return fmt.Sprintf("%.1fM", float64(n)/float64(1<<20))
|
||||
case n >= 1<<10:
|
||||
return fmt.Sprintf("%.1fK", float64(n)/float64(1<<10))
|
||||
default:
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
// Command uaparse analyzes User-Agent strings from webi.sh logs.
|
||||
//
|
||||
// It reads UA strings (one per line) from stdin or a file, parses each
|
||||
// through uadetect, and produces summary output showing:
|
||||
// - unique platform tuples (os, arch, libc) with counts
|
||||
// - platform hints extracted from kernel version strings (cloud provider,
|
||||
// container runtime, device info)
|
||||
// - detection failures and malformed UAs
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// uaparse < LIVE-UAS.txt
|
||||
// uaparse LIVE-UAS.txt
|
||||
// uaparse -json LIVE-UAS.txt
|
||||
// uaparse -fixtures LIVE-UAS.txt # output Go test fixtures
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/uadetect"
|
||||
)
|
||||
|
||||
// PlatformKey is the resolution-relevant tuple — everything else is noise
|
||||
// for artifact selection.
|
||||
type PlatformKey struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Libc string `json:"libc"`
|
||||
}
|
||||
|
||||
func (k PlatformKey) String() string {
|
||||
return fmt.Sprintf("%-10s %-10s %s", k.OS, k.Arch, k.Libc)
|
||||
}
|
||||
|
||||
// PlatformEntry holds a unique platform and its metadata.
|
||||
type PlatformEntry struct {
|
||||
Key PlatformKey `json:"key"`
|
||||
Count int `json:"count"`
|
||||
Examples []string `json:"examples"` // up to 3 representative UAs
|
||||
Hints []string `json:"hints"` // unique platform hints seen
|
||||
}
|
||||
|
||||
// UAIssue records a malformed or undetectable UA.
|
||||
type UAIssue struct {
|
||||
Line int `json:"line"`
|
||||
UA string `json:"ua"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Hint is a platform detail extracted from the kernel version string.
|
||||
type Hint struct {
|
||||
Tag string // short label: "amzn", "azure", "gcp", "wsl", etc.
|
||||
Count int
|
||||
}
|
||||
|
||||
func main() {
|
||||
jsonOut := flag.Bool("json", false, "output as JSON")
|
||||
fixtures := flag.Bool("fixtures", false, "output Go test fixture table")
|
||||
flag.Parse()
|
||||
|
||||
var scanner *bufio.Scanner
|
||||
if flag.NArg() > 0 {
|
||||
f, err := os.Open(flag.Arg(0))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "uaparse: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
scanner = bufio.NewScanner(f)
|
||||
} else {
|
||||
scanner = bufio.NewScanner(os.Stdin)
|
||||
}
|
||||
|
||||
platforms := make(map[PlatformKey]*PlatformEntry)
|
||||
hints := make(map[string]int)
|
||||
var issues []UAIssue
|
||||
lineNum := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
ua := strings.TrimSpace(scanner.Text())
|
||||
if ua == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect corruption: truncated/double-pasted lines.
|
||||
if isMalformed(ua) {
|
||||
issues = append(issues, UAIssue{
|
||||
Line: lineNum,
|
||||
UA: ua,
|
||||
Reason: "malformed (truncated or corrupted)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse through uadetect.
|
||||
result := uadetect.Parse(ua)
|
||||
|
||||
// Check for detection failures.
|
||||
if result.OS == "" {
|
||||
issues = append(issues, UAIssue{
|
||||
Line: lineNum,
|
||||
UA: ua,
|
||||
Reason: "OS not detected",
|
||||
})
|
||||
}
|
||||
if result.Arch == "" {
|
||||
issues = append(issues, UAIssue{
|
||||
Line: lineNum,
|
||||
UA: ua,
|
||||
Reason: "arch not detected",
|
||||
})
|
||||
}
|
||||
|
||||
key := PlatformKey{
|
||||
OS: string(result.OS),
|
||||
Arch: string(result.Arch),
|
||||
Libc: string(result.Libc),
|
||||
}
|
||||
|
||||
entry, ok := platforms[key]
|
||||
if !ok {
|
||||
entry = &PlatformEntry{Key: key}
|
||||
platforms[key] = entry
|
||||
}
|
||||
entry.Count++
|
||||
if len(entry.Examples) < 3 {
|
||||
entry.Examples = append(entry.Examples, ua)
|
||||
}
|
||||
|
||||
// Extract platform hints from kernel version.
|
||||
for _, h := range extractHints(ua) {
|
||||
if !containsStr(entry.Hints, h) {
|
||||
entry.Hints = append(entry.Hints, h)
|
||||
}
|
||||
hints[h]++
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "uaparse: read error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Sort platforms by count descending.
|
||||
entries := make([]*PlatformEntry, 0, len(platforms))
|
||||
for _, e := range platforms {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Count > entries[j].Count
|
||||
})
|
||||
|
||||
if *jsonOut {
|
||||
outputJSON(entries, issues, hints)
|
||||
} else if *fixtures {
|
||||
outputFixtures(entries)
|
||||
} else {
|
||||
outputTable(entries, issues, hints, lineNum)
|
||||
}
|
||||
}
|
||||
|
||||
func outputTable(entries []*PlatformEntry, issues []UAIssue, hints map[string]int, total int) {
|
||||
fmt.Printf("=== UA Analysis: %d lines → %d unique platforms ===\n\n", total, len(entries))
|
||||
|
||||
fmt.Printf("%-10s %-10s %-6s %6s %s\n", "OS", "ARCH", "LIBC", "COUNT", "HINTS")
|
||||
fmt.Println(strings.Repeat("-", 72))
|
||||
for _, e := range entries {
|
||||
hintStr := ""
|
||||
if len(e.Hints) > 0 {
|
||||
hintStr = strings.Join(e.Hints, ", ")
|
||||
}
|
||||
fmt.Printf("%-10s %-10s %-6s %6d %s\n",
|
||||
displayOS(e.Key.OS), e.Key.Arch, displayLibc(e.Key.Libc),
|
||||
e.Count, hintStr)
|
||||
}
|
||||
|
||||
if len(hints) > 0 {
|
||||
fmt.Printf("\n=== Platform Hints (environment signals from kernel strings) ===\n\n")
|
||||
sortedHints := make([]Hint, 0, len(hints))
|
||||
for tag, count := range hints {
|
||||
sortedHints = append(sortedHints, Hint{tag, count})
|
||||
}
|
||||
sort.Slice(sortedHints, func(i, j int) bool {
|
||||
return sortedHints[i].Count > sortedHints[j].Count
|
||||
})
|
||||
for _, h := range sortedHints {
|
||||
fmt.Printf(" %-20s %d\n", h.Tag, h.Count)
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
fmt.Printf("\n=== Issues (%d) ===\n\n", len(issues))
|
||||
for _, iss := range issues {
|
||||
fmt.Printf(" line %d: %s\n %s\n", iss.Line, iss.Reason, iss.UA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func outputJSON(entries []*PlatformEntry, issues []UAIssue, hints map[string]int) {
|
||||
out := struct {
|
||||
Platforms []*PlatformEntry `json:"platforms"`
|
||||
Issues []UAIssue `json:"issues"`
|
||||
Hints map[string]int `json:"hints"`
|
||||
}{entries, issues, hints}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(out)
|
||||
}
|
||||
|
||||
func outputFixtures(entries []*PlatformEntry) {
|
||||
fmt.Println("// Generated by cmd/uaparse from live UA data.")
|
||||
fmt.Println("// Each entry represents a unique (os, arch, libc) platform seen in production.")
|
||||
fmt.Println("var liveUAPlatforms = []struct {")
|
||||
fmt.Println("\tua string")
|
||||
fmt.Println("\tos buildmeta.OS")
|
||||
fmt.Println("\tarch buildmeta.Arch")
|
||||
fmt.Println("\tlibc buildmeta.Libc")
|
||||
fmt.Println("}{")
|
||||
|
||||
for _, e := range entries {
|
||||
if e.Key.OS == "" || e.Key.Arch == "" {
|
||||
continue // skip undetectable
|
||||
}
|
||||
ua := e.Examples[0]
|
||||
fmt.Printf("\t{%q, %s, %s, %s},\n",
|
||||
ua, goConst("OS", e.Key.OS), goConst("Arch", e.Key.Arch), goConst("Libc", e.Key.Libc))
|
||||
}
|
||||
|
||||
fmt.Println("}")
|
||||
}
|
||||
|
||||
// isMalformed checks for genuinely corrupted UA strings (network truncation).
|
||||
func isMalformed(ua string) bool {
|
||||
// Extremely short (less than 10 chars) suggests truncation.
|
||||
if len(ua) < 10 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractHints finds environment signals in a UA string.
|
||||
func extractHints(ua string) []string {
|
||||
lower := strings.ToLower(ua)
|
||||
var out []string
|
||||
|
||||
patterns := []struct {
|
||||
substr string
|
||||
tag string
|
||||
}{
|
||||
{"amzn", "amzn"}, // Amazon Linux
|
||||
{"-azure", "azure"}, // Azure VM
|
||||
{"-gcp", "gcp"}, // Google Cloud
|
||||
{"-aws", "aws"}, // AWS kernel
|
||||
{"-oracle", "oracle"}, // Oracle Cloud
|
||||
{"el7", "rhel7"}, // RHEL/CentOS 7
|
||||
{"el8", "rhel8"}, // RHEL/CentOS 8
|
||||
{"el9", "rhel9"}, // RHEL/CentOS 9
|
||||
{".fc", "fedora"}, // Fedora
|
||||
{"+deb", "debian"}, // Debian
|
||||
{"-generic", "ubuntu"}, // Ubuntu generic kernel
|
||||
{"-pve", "proxmox"}, // Proxmox VE
|
||||
{"linuxkit", "docker"}, // Docker Desktop / linuxkit
|
||||
{"orbstack", "orbstack"},
|
||||
{"microsoft-standard-wsl", "wsl"},
|
||||
{"android", "android"},
|
||||
{"+rpt-rpi", "rpi"}, // Raspberry Pi
|
||||
{"cygwin", "cygwin"},
|
||||
{"mingw", "mingw"},
|
||||
{"msys", "msys"},
|
||||
{"freebsd", "freebsd"},
|
||||
{"-nvidia", "nvidia"},
|
||||
{"gentoo", "gentoo"},
|
||||
{"coreweave", "coreweave"},
|
||||
}
|
||||
|
||||
for _, p := range patterns {
|
||||
if strings.Contains(lower, p.substr) {
|
||||
out = append(out, p.tag)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// androidDeviceRe extracts device/build info from Android kernel strings.
|
||||
var androidDeviceRe = regexp.MustCompile(`ab[A-Z0-9]+`)
|
||||
|
||||
func displayOS(os string) string {
|
||||
if os == "" {
|
||||
return "(none)"
|
||||
}
|
||||
return os
|
||||
}
|
||||
|
||||
func displayLibc(libc string) string {
|
||||
if libc == "" {
|
||||
return "(none)"
|
||||
}
|
||||
return libc
|
||||
}
|
||||
|
||||
func goConst(prefix, val string) string {
|
||||
m := map[string]map[string]string{
|
||||
"OS": {
|
||||
"darwin": "buildmeta.OSDarwin",
|
||||
"linux": "buildmeta.OSLinux",
|
||||
"windows": "buildmeta.OSWindows",
|
||||
"freebsd": "buildmeta.OSFreeBSD",
|
||||
"android": "buildmeta.OSAndroid",
|
||||
"": `""`,
|
||||
},
|
||||
"Arch": {
|
||||
"aarch64": "buildmeta.ArchARM64",
|
||||
"x86_64": "buildmeta.ArchAMD64",
|
||||
"armv7": "buildmeta.ArchARMv7",
|
||||
"armv6": "buildmeta.ArchARMv6",
|
||||
"x86": "buildmeta.ArchX86",
|
||||
"ppc64le": "buildmeta.ArchPPC64LE",
|
||||
"ppc64": "buildmeta.ArchPPC64",
|
||||
"s390x": "buildmeta.ArchS390X",
|
||||
"riscv64": "buildmeta.ArchRISCV64",
|
||||
"": `""`,
|
||||
},
|
||||
"Libc": {
|
||||
"gnu": "buildmeta.LibcGNU",
|
||||
"musl": "buildmeta.LibcMusl",
|
||||
"msvc": "buildmeta.LibcMSVC",
|
||||
"none": "buildmeta.LibcNone",
|
||||
"": `""`,
|
||||
},
|
||||
}
|
||||
if v, ok := m[prefix][val]; ok {
|
||||
return v
|
||||
}
|
||||
return fmt.Sprintf("%q /* unmapped */", val)
|
||||
}
|
||||
|
||||
func containsStr(ss []string, s string) bool {
|
||||
for _, v := range ss {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/webinstall/webi-installers/internal/classifypkg"
|
||||
"github.com/webinstall/webi-installers/internal/httpclient"
|
||||
"github.com/webinstall/webi-installers/internal/installerconf"
|
||||
"github.com/webinstall/webi-installers/internal/rawcache"
|
||||
"github.com/webinstall/webi-installers/internal/releases/chromedist"
|
||||
@@ -54,7 +55,6 @@ import (
|
||||
"github.com/webinstall/webi-installers/internal/releases/zigdist"
|
||||
"github.com/webinstall/webi-installers/internal/storage"
|
||||
"github.com/webinstall/webi-installers/internal/storage/fsstore"
|
||||
"github.com/webinstall/webi-installers/internal/storage/pgstore"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -78,7 +78,6 @@ type MainConfig struct {
|
||||
envFile string
|
||||
confDir string
|
||||
cacheDir string
|
||||
pgDSN string
|
||||
rawDir string
|
||||
token string
|
||||
once bool
|
||||
@@ -92,7 +91,7 @@ type MainConfig struct {
|
||||
// WebiCache holds the configuration for the cache daemon.
|
||||
type WebiCache struct {
|
||||
ConfDir string // root directory with {pkg}/releases.conf files
|
||||
Store storage.Store // classified asset storage (fsstore or pgstore)
|
||||
Store storage.Store // classified asset storage (fsstore)
|
||||
RawDir string // raw upstream response cache
|
||||
Client *http.Client // HTTP client for upstream calls
|
||||
Auth *githubish.Auth // GitHub API auth (optional)
|
||||
@@ -157,30 +156,21 @@ func main() {
|
||||
cfg.token = os.Getenv("GITHUB_TOKEN")
|
||||
}
|
||||
|
||||
var store storage.Store
|
||||
if cfg.pgDSN != "" {
|
||||
pg, err := pgstore.New(context.Background(), cfg.pgDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("pgstore: %v", err)
|
||||
}
|
||||
store = pg
|
||||
} else {
|
||||
fs, err := fsstore.New(cfg.cacheDir)
|
||||
if err != nil {
|
||||
log.Fatalf("fsstore: %v", err)
|
||||
}
|
||||
store = fs
|
||||
fss, err := fsstore.New(cfg.cacheDir)
|
||||
if err != nil {
|
||||
log.Fatalf("fsstore: %v", err)
|
||||
}
|
||||
var store storage.Store = fss
|
||||
|
||||
var auth *githubish.Auth
|
||||
if cfg.token != "" {
|
||||
auth = &githubish.Auth{Token: cfg.token}
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
client := httpclient.New()
|
||||
if cfg.pageDelay > 0 {
|
||||
client.Transport = &delayTransport{
|
||||
base: http.DefaultTransport,
|
||||
base: client.Transport,
|
||||
delay: cfg.pageDelay,
|
||||
}
|
||||
}
|
||||
@@ -217,11 +207,11 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("discover: %v", err)
|
||||
}
|
||||
nameSet := make(map[string]bool, len(filterPkgs))
|
||||
for _, a := range filterPkgs {
|
||||
nameSet[a] = true
|
||||
}
|
||||
if len(filterPkgs) > 0 {
|
||||
nameSet := make(map[string]bool, len(filterPkgs))
|
||||
for _, a := range filterPkgs {
|
||||
nameSet[a] = true
|
||||
}
|
||||
var filtered []pkgConf
|
||||
for _, p := range packages {
|
||||
if nameSet[p.name] {
|
||||
@@ -238,8 +228,41 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// rescanNew appends any conf files added since the last scan.
|
||||
// Returns true when at least one new package was added so the caller
|
||||
// can restart the batch loop and process new packages immediately.
|
||||
rescanNew := func() bool {
|
||||
discovered, err := discover(wc.ConfDir)
|
||||
if err != nil {
|
||||
log.Printf("rescan: %v", err)
|
||||
return false
|
||||
}
|
||||
known := make(map[string]bool, len(real))
|
||||
for _, p := range real {
|
||||
known[p.name] = true
|
||||
}
|
||||
added := false
|
||||
for _, p := range discovered {
|
||||
if p.conf.AliasOf != "" || known[p.name] {
|
||||
continue
|
||||
}
|
||||
if len(filterPkgs) > 0 && !nameSet[p.name] {
|
||||
continue
|
||||
}
|
||||
log.Printf("discovered new package: %s (source=%s)", p.name, p.conf.Source)
|
||||
real = append(real, p)
|
||||
added = true
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
log.Printf("refreshing %d packages, interval %s, batch size 20 (ctrl-c to stop)", len(real), cfg.interval)
|
||||
for {
|
||||
// Rescan before computing staleness so newly added conf files are
|
||||
// included immediately. New packages have a zero timestamp and sort
|
||||
// to the front of the stale list, so they are processed next.
|
||||
rescanNew()
|
||||
|
||||
stale := wc.stalest(real)
|
||||
if len(stale) == 0 {
|
||||
log.Printf("all packages fresh, sleeping %s", cfg.interval)
|
||||
@@ -263,6 +286,10 @@ func main() {
|
||||
}
|
||||
cancel()
|
||||
time.Sleep(cfg.interval)
|
||||
// Rescan mid-batch so new packages preempt remaining batch items.
|
||||
if rescanNew() {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,7 +298,6 @@ func registerFlags(fs *flag.FlagSet, cfg *MainConfig) {
|
||||
fs.StringVar(&cfg.envFile, "envfile", "", "path to .env file to load before running")
|
||||
fs.StringVar(&cfg.confDir, "conf", ".", "root directory containing {pkg}/releases.conf files")
|
||||
fs.StringVar(&cfg.cacheDir, "legacy", "~/.cache/webi/legacy", "legacy cache directory (fsstore root)")
|
||||
fs.StringVar(&cfg.pgDSN, "pg", "", "PostgreSQL DSN (enables pgstore; mutually exclusive with -legacy)")
|
||||
fs.StringVar(&cfg.rawDir, "raw", "~/.cache/webi/raw", "raw cache directory for upstream responses")
|
||||
fs.StringVar(&cfg.token, "token", "", "GitHub API token (or set $GITHUB_TOKEN)")
|
||||
fs.BoolVar(&cfg.once, "once", false, "run once then exit (no periodic refresh)")
|
||||
@@ -306,14 +332,13 @@ func (wc *WebiCache) stalest(packages []pkgConf) []pkgConf {
|
||||
for _, pkg := range packages {
|
||||
data, err := wc.Store.Load(ctx, pkg.name)
|
||||
var t time.Time
|
||||
hasAssets := false
|
||||
if err == nil && data != nil {
|
||||
t = data.UpdatedAt
|
||||
hasAssets = len(data.Assets) > 0
|
||||
}
|
||||
// Never fetched, or has no assets despite having a timestamp
|
||||
// (e.g. classified from empty rawcache), or older than 10 minutes.
|
||||
if t.IsZero() || !hasAssets || time.Since(t) > 10*time.Minute {
|
||||
// Never fetched, or older than 10 minutes.
|
||||
// 0-asset results are not treated as perpetually stale — packages that
|
||||
// produce no classifiable assets (e.g. galera) respect the timestamp.
|
||||
if t.IsZero() || time.Since(t) > 10*time.Minute {
|
||||
stale = append(stale, stamped{pkg: pkg, updatedAt: t})
|
||||
}
|
||||
}
|
||||
|
||||
146
cmd/webid/bootstrap_test.go
Normal file
146
cmd/webid/bootstrap_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBootstrapCurlPipe verifies the /{pkg} route returns the curl-pipe bootstrap.
|
||||
func TestBootstrapCurlPipe(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
pkg := "bat"
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
code, body := get(t, ts, "/bat@stable")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d: %s", code, body[:min(len(body), 200)])
|
||||
}
|
||||
|
||||
// Should contain the bootstrap env vars.
|
||||
if !strings.Contains(body, "WEBI_PKG=") {
|
||||
t.Error("missing WEBI_PKG= in bootstrap")
|
||||
}
|
||||
if !strings.Contains(body, "WEBI_HOST=") {
|
||||
t.Error("missing WEBI_HOST= in bootstrap")
|
||||
}
|
||||
if !strings.Contains(body, "WEBI_CHECKSUM=") {
|
||||
t.Error("missing WEBI_CHECKSUM= in bootstrap")
|
||||
}
|
||||
// Should NOT contain the full installer (install.sh content).
|
||||
// The bootstrap just downloads and runs webi.
|
||||
if strings.Contains(body, "pkg_install()") {
|
||||
t.Error("bootstrap should not contain pkg_install — that's the full installer")
|
||||
}
|
||||
|
||||
t.Logf("bootstrap size: %d bytes", len(body))
|
||||
}
|
||||
|
||||
// TestInstallerFull verifies /api/installers/{pkg}.sh returns the full installer.
|
||||
func TestInstallerFull(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
pkg := "bat"
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
// Use a webi-style User-Agent so the server can detect platform.
|
||||
code, body := getWithUA(t, ts, "/api/installers/bat@stable.sh", "aarch64/unknown Darwin/24.2.0 libc")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d: %s", code, body[:min(len(body), 500)])
|
||||
}
|
||||
|
||||
// Should contain resolved release info.
|
||||
if !strings.Contains(body, "WEBI_VERSION=") {
|
||||
t.Error("missing WEBI_VERSION= in installer")
|
||||
}
|
||||
if !strings.Contains(body, "WEBI_PKG_URL=") {
|
||||
t.Error("missing WEBI_PKG_URL= in installer")
|
||||
}
|
||||
if !strings.Contains(body, "PKG_NAME=") {
|
||||
t.Error("missing PKG_NAME= in installer")
|
||||
}
|
||||
|
||||
// Should contain the package's install.sh content (embedded).
|
||||
if !strings.Contains(body, "pkg_") {
|
||||
t.Error("installer should contain pkg_ functions from install.sh")
|
||||
}
|
||||
|
||||
t.Logf("installer size: %d bytes", len(body))
|
||||
}
|
||||
|
||||
// TestInstallerPowerShell verifies /api/installers/{pkg}.ps1 returns a PowerShell installer.
|
||||
func TestInstallerPowerShell(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
pkg := "node"
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
code, body := getWithUA(t, ts, "/api/installers/node@stable.ps1", "AMD64/unknown Windows/10.0.19045 msvc")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d: %s", code, body[:min(len(body), 500)])
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "$Env:WEBI_VERSION") {
|
||||
t.Error("missing $Env:WEBI_VERSION in PS1 installer")
|
||||
}
|
||||
if !strings.Contains(body, "$Env:WEBI_PKG_URL") {
|
||||
t.Error("missing $Env:WEBI_PKG_URL in PS1 installer")
|
||||
}
|
||||
if !strings.Contains(body, "$Env:PKG_NAME") {
|
||||
t.Error("missing $Env:PKG_NAME in PS1 installer")
|
||||
}
|
||||
|
||||
t.Logf("PS1 installer size: %d bytes", len(body))
|
||||
}
|
||||
|
||||
// TestInstallerSelfHosted verifies selfhosted packages get a script without resolution.
|
||||
func TestInstallerSelfHosted(t *testing.T) {
|
||||
_, ts := newTestServer(t)
|
||||
|
||||
// ssh-utils is selfhosted — has install.sh but no releases.conf.
|
||||
code, body := getWithUA(t, ts, "/api/installers/ssh-utils.sh", "aarch64/unknown Darwin/24.2.0 libc")
|
||||
if code == 404 {
|
||||
t.Skip("ssh-utils not available as installer")
|
||||
}
|
||||
if code != 200 {
|
||||
t.Skipf("status %d (selfhosted may not render without cache): %s", code, body[:min(len(body), 200)])
|
||||
}
|
||||
|
||||
t.Logf("selfhosted installer size: %d bytes", len(body))
|
||||
}
|
||||
|
||||
// TestBootstrapUnknownPackage verifies 404 for unknown packages.
|
||||
func TestBootstrapUnknownPackage(t *testing.T) {
|
||||
_, ts := newTestServer(t)
|
||||
|
||||
code, _ := get(t, ts, "/nonexistent-package-xyz")
|
||||
if code != 404 {
|
||||
t.Errorf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// getWithUA fetches a URL with a custom User-Agent header.
|
||||
func getWithUA(t *testing.T, ts *httptest.Server, path, ua string) (int, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest("GET", ts.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", ua)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(body)
|
||||
}
|
||||
989
cmd/webid/main.go
Normal file
989
cmd/webid/main.go
Normal file
@@ -0,0 +1,989 @@
|
||||
// Command webid is the webi HTTP API server. It reads cached release
|
||||
// data from the filesystem and serves release metadata, installer
|
||||
// scripts, and bootstrap dispatches.
|
||||
//
|
||||
// It never fetches from upstream APIs — that's webicached's job.
|
||||
// This server is stateless and fast: load from cache, resolve, render.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/webid
|
||||
// go run ./cmd/webid -addr :3001 -cache ~/.cache/webi/legacy
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/buildmeta"
|
||||
"github.com/webinstall/webi-installers/internal/lexver"
|
||||
"github.com/webinstall/webi-installers/internal/render"
|
||||
"github.com/webinstall/webi-installers/internal/resolve"
|
||||
"github.com/webinstall/webi-installers/internal/resolver"
|
||||
middleware "github.com/therootcompany/golib/http/middleware/v2"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/storage"
|
||||
"github.com/webinstall/webi-installers/internal/storage/fsstore"
|
||||
"github.com/webinstall/webi-installers/internal/uadetect"
|
||||
)
|
||||
|
||||
var (
|
||||
name = "webid"
|
||||
version = "0.0.0-dev"
|
||||
commit = "0000000"
|
||||
date = "0001-01-01"
|
||||
licenseYear = "2024"
|
||||
licenseOwner = "AJ ONeal"
|
||||
licenseType = "MPL-2.0"
|
||||
)
|
||||
|
||||
func printVersion(w io.Writer) {
|
||||
v := strings.TrimPrefix(version, "v")
|
||||
_, _ = fmt.Fprintf(w, "%s v%s %s (%s)\n", name, v, commit[:7], date)
|
||||
_, _ = fmt.Fprintf(w, "Copyright (C) %s %s\n", licenseYear, licenseOwner)
|
||||
_, _ = fmt.Fprintf(w, "Licensed under %s\n", licenseType)
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":3001", "listen address")
|
||||
cacheDir := flag.String("legacy", "~/.cache/webi/legacy", "legacy cache directory")
|
||||
installersDir := flag.String("installers", ".", "installers repo root (for install.sh/ps1)")
|
||||
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "-V", "-version", "--version", "version":
|
||||
printVersion(os.Stdout)
|
||||
os.Exit(0)
|
||||
case "help", "-help", "--help":
|
||||
printVersion(os.Stdout)
|
||||
fmt.Fprintln(os.Stdout, "")
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
cachePath := expandHome(*cacheDir)
|
||||
|
||||
fss, err := fsstore.New(cachePath)
|
||||
if err != nil {
|
||||
log.Fatalf("fsstore: %v", err)
|
||||
}
|
||||
var store storage.Store = fss
|
||||
|
||||
srv := &server{
|
||||
store: store,
|
||||
installersDir: *installersDir,
|
||||
packages: make(map[string]*packageCache),
|
||||
}
|
||||
|
||||
// Pre-load all cached packages.
|
||||
srv.loadAll()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mmux := middleware.WithMux(mux, requestLogger)
|
||||
|
||||
// Legacy API routes (Node.js compat).
|
||||
mmux.HandleFunc("GET /api/releases/{rest...}", srv.handleReleasesAPI)
|
||||
|
||||
// New API routes (v1).
|
||||
mmux.HandleFunc("GET /v1/releases/{rest...}", srv.handleV1Releases)
|
||||
mmux.HandleFunc("GET /v1/resolve/{rest...}", srv.handleV1Resolve)
|
||||
|
||||
// Full installer script (package-install.tpl.sh + install.sh).
|
||||
mmux.HandleFunc("GET /api/installers/{rest...}", srv.handleInstaller)
|
||||
|
||||
// Debug endpoint.
|
||||
mmux.HandleFunc("GET /api/debug", srv.handleDebug)
|
||||
|
||||
// Health check (no logging — too noisy).
|
||||
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
// Bootstrap route: /{package} and /{package}@{version}
|
||||
// Detects UA and returns rendered installer script.
|
||||
mmux.HandleFunc("GET /{pkgSpec}", srv.handleBootstrap)
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: mux,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// Graceful shutdown.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
log.Printf("webid listening on %s", *addr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("shutting down...")
|
||||
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
httpSrv.Shutdown(shutCtx)
|
||||
}
|
||||
|
||||
// requestLogger is a middleware that logs each request with status and duration.
|
||||
func requestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rw := &statusWriter{ResponseWriter: w, code: http.StatusOK}
|
||||
next.ServeHTTP(rw, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.code, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// statusWriter wraps ResponseWriter to capture the HTTP status code.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
code int
|
||||
}
|
||||
|
||||
func (sw *statusWriter) WriteHeader(code int) {
|
||||
sw.code = code
|
||||
sw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// server holds the shared state for all HTTP handlers.
|
||||
type server struct {
|
||||
store storage.Store
|
||||
installersDir string
|
||||
|
||||
mu sync.RWMutex
|
||||
packages map[string]*packageCache
|
||||
webiCksum string // cached sha1[:8] of webi.sh
|
||||
}
|
||||
|
||||
// packageCache holds a loaded package's assets and catalog.
|
||||
type packageCache struct {
|
||||
assets []storage.Asset
|
||||
dists []resolve.Dist
|
||||
catalog resolve.Catalog
|
||||
}
|
||||
|
||||
// loadAll pre-loads all packages from the store.
|
||||
func (s *server) loadAll() {
|
||||
ctx := context.Background()
|
||||
|
||||
pkgs, err := s.store.ListPackages(ctx)
|
||||
if err != nil {
|
||||
log.Printf("warn: list packages: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, pkg := range pkgs {
|
||||
pd, err := s.store.Load(ctx, pkg)
|
||||
if err != nil {
|
||||
log.Printf("warn: load %s: %v", pkg, err)
|
||||
continue
|
||||
}
|
||||
if pd == nil || len(pd.Assets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
pc := &packageCache{
|
||||
assets: pd.Assets,
|
||||
dists: assetsToDists(pd.Assets),
|
||||
}
|
||||
pc.catalog = resolve.Survey(pc.dists)
|
||||
|
||||
s.mu.Lock()
|
||||
s.packages[pkg] = pc
|
||||
s.mu.Unlock()
|
||||
count++
|
||||
}
|
||||
log.Printf("loaded %d packages from store", count)
|
||||
}
|
||||
|
||||
// getPackage returns the cached package data, or nil if not found.
|
||||
func (s *server) getPackage(pkg string) *packageCache {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.packages[pkg]
|
||||
}
|
||||
|
||||
// assetsToDists converts storage.Asset slice to resolve.Dist slice.
|
||||
func assetsToDists(assets []storage.Asset) []resolve.Dist {
|
||||
dists := make([]resolve.Dist, len(assets))
|
||||
for i, a := range assets {
|
||||
dists[i] = resolve.Dist{
|
||||
Filename: a.Filename,
|
||||
Version: a.Version,
|
||||
LTS: a.LTS,
|
||||
Channel: a.Channel,
|
||||
Date: a.Date,
|
||||
OS: a.OS,
|
||||
Arch: a.Arch,
|
||||
Libc: a.Libc,
|
||||
Format: a.Format,
|
||||
Download: a.Download,
|
||||
Extra: a.Extra,
|
||||
GitTag: a.GitTag,
|
||||
GitCommitHash: a.GitCommitHash,
|
||||
Variants: a.Variants,
|
||||
}
|
||||
}
|
||||
return dists
|
||||
}
|
||||
|
||||
// handleReleasesAPI serves /api/releases/{package}@{version}.{format}
|
||||
func (s *server) handleReleasesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
rest := r.PathValue("rest")
|
||||
|
||||
// Parse: {package}@{version}.{json|tab} or {package}.{json|tab}
|
||||
pkg, version, format, err := parseReleasePath(rest)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pc := s.getPackage(pkg)
|
||||
if pc == nil {
|
||||
// Check if it's a selfhosted package.
|
||||
if s.isSelfHosted(pkg) {
|
||||
s.serveEmptyReleases(w, format)
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters.
|
||||
q := r.URL.Query()
|
||||
osStr := q.Get("os")
|
||||
archStr := q.Get("arch")
|
||||
libcStr := q.Get("libc")
|
||||
ltsStr := q.Get("lts")
|
||||
channelStr := q.Get("channel")
|
||||
formatsStr := q.Get("formats")
|
||||
limitStr := q.Get("limit")
|
||||
|
||||
// Normalize wildcard "-" to empty (means "any").
|
||||
if osStr == "-" {
|
||||
osStr = ""
|
||||
}
|
||||
if archStr == "-" {
|
||||
archStr = ""
|
||||
}
|
||||
if libcStr == "-" {
|
||||
libcStr = ""
|
||||
}
|
||||
|
||||
// Map Node.js OS/arch names to our canonical names.
|
||||
osStr = normalizeQueryOS(osStr)
|
||||
archStr = normalizeQueryArch(archStr)
|
||||
|
||||
// Parse LTS.
|
||||
lts := ltsStr == "true" || ltsStr == "1"
|
||||
|
||||
// Handle channel selectors in the version field: @stable, @lts, @beta, etc.
|
||||
switch strings.ToLower(version) {
|
||||
case "stable", "latest":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "stable"
|
||||
}
|
||||
case "lts":
|
||||
version = ""
|
||||
lts = true
|
||||
case "beta", "pre", "preview":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "beta"
|
||||
}
|
||||
case "rc":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "rc"
|
||||
}
|
||||
case "alpha", "dev":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "alpha"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse formats list.
|
||||
var formats []string
|
||||
if formatsStr != "" {
|
||||
formats = strings.Split(formatsStr, ",")
|
||||
}
|
||||
|
||||
// Parse limit.
|
||||
limit := 100
|
||||
if limitStr != "" {
|
||||
fmt.Sscanf(limitStr, "%d", &limit)
|
||||
}
|
||||
|
||||
// Filter matching releases, sort by specificity, then apply limit.
|
||||
filtered := filterDists(pc.dists, osStr, archStr, libcStr, channelStr, version, formats, lts)
|
||||
sortDistsDescending(filtered, osStr, archStr)
|
||||
if len(filtered) > limit {
|
||||
filtered = filtered[:limit]
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
s.serveJSON(w, r, pc, filtered)
|
||||
case "tab":
|
||||
s.serveTab(w, r, filtered)
|
||||
default:
|
||||
http.Error(w, "unsupported format: "+format, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeQueryOS maps Node.js OS names to our canonical names.
|
||||
func normalizeQueryOS(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "macos", "mac":
|
||||
return "darwin"
|
||||
case "win":
|
||||
return "windows"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeQueryArch maps Node.js arch names to our canonical names.
|
||||
func normalizeQueryArch(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "amd64":
|
||||
return string(buildmeta.ArchAMD64) // "x86_64"
|
||||
case "arm64":
|
||||
return string(buildmeta.ArchARM64) // "aarch64"
|
||||
case "armv7l":
|
||||
return string(buildmeta.ArchARMv7)
|
||||
case "armv6l":
|
||||
return string(buildmeta.ArchARMv6)
|
||||
case "x86", "i386", "i686":
|
||||
return string(buildmeta.ArchX86)
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// parseReleasePath parses "{pkg}@{version}.{format}" or "{pkg}.{format}".
|
||||
func parseReleasePath(rest string) (pkg, version, format string, err error) {
|
||||
if strings.HasSuffix(rest, ".json") {
|
||||
format = "json"
|
||||
rest = strings.TrimSuffix(rest, ".json")
|
||||
} else if strings.HasSuffix(rest, ".tab") {
|
||||
format = "tab"
|
||||
rest = strings.TrimSuffix(rest, ".tab")
|
||||
} else {
|
||||
return "", "", "", fmt.Errorf("unsupported format (use .json or .tab)")
|
||||
}
|
||||
|
||||
if idx := strings.IndexByte(rest, '@'); idx >= 0 {
|
||||
pkg = rest[:idx]
|
||||
version = rest[idx+1:]
|
||||
} else {
|
||||
pkg = rest
|
||||
}
|
||||
|
||||
if pkg == "" {
|
||||
return "", "", "", fmt.Errorf("package name required")
|
||||
}
|
||||
|
||||
return pkg, version, format, nil
|
||||
}
|
||||
|
||||
// filterDists filters dists by query parameters, returning all matches
|
||||
// up to limit. This is for the API listing, not single-best resolution.
|
||||
func filterDists(dists []resolve.Dist, osStr, archStr, libcStr, channel, version string, formats []string, lts bool) []resolve.Dist {
|
||||
var result []resolve.Dist
|
||||
|
||||
archSet := make(map[string]bool)
|
||||
if archStr != "" {
|
||||
for _, a := range buildmeta.CompatArches(buildmeta.OS(osStr), buildmeta.Arch(archStr)) {
|
||||
archSet[string(a)] = true
|
||||
}
|
||||
if len(archSet) == 0 {
|
||||
archSet[archStr] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, d := range dists {
|
||||
if osStr != "" && d.OS != osStr && d.OS != "*" && d.OS != "ANYOS" && d.OS != "" &&
|
||||
!(d.OS == "posix_2017" && osStr != "windows") {
|
||||
continue
|
||||
}
|
||||
|
||||
if archStr != "" && !archSet[d.Arch] && d.Arch != "*" && d.Arch != "ANYARCH" && d.Arch != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if libcStr != "" && d.Libc != "none" && d.Libc != "" && d.Libc != libcStr {
|
||||
continue
|
||||
}
|
||||
|
||||
if lts && !d.LTS {
|
||||
continue
|
||||
}
|
||||
|
||||
if channel != "" && d.Channel != channel {
|
||||
continue
|
||||
}
|
||||
|
||||
if version != "" {
|
||||
// Match with or without "v" prefix:
|
||||
// query "0.25" should match version "v0.25.0".
|
||||
v := strings.TrimPrefix(d.Version, "v")
|
||||
vq := strings.TrimPrefix(version, "v")
|
||||
if !strings.HasPrefix(v, vq) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(formats) > 0 {
|
||||
matched := false
|
||||
for _, f := range formats {
|
||||
if strings.Contains(d.Format, f) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, d)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// legacyRelease matches the Node.js JSON response format.
|
||||
// Production returns a bare JSON array of these objects.
|
||||
type legacyRelease struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
GitTag string `json:"git_tag,omitempty"`
|
||||
GitCommitHash string `json:"git_commit_hash,omitempty"`
|
||||
LTS bool `json:"lts"`
|
||||
Channel string `json:"channel"`
|
||||
Date string `json:"date"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Ext string `json:"ext"`
|
||||
Download string `json:"download"`
|
||||
Libc string `json:"libc"`
|
||||
}
|
||||
|
||||
// legacyOS maps Go canonical OS names to Node.js legacy names.
|
||||
func legacyOS(s string) string {
|
||||
switch s {
|
||||
case "darwin":
|
||||
return "macos"
|
||||
case "":
|
||||
return "*"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// legacyArch maps Go canonical arch names to Node.js legacy names.
|
||||
func legacyArch(s string) string {
|
||||
switch s {
|
||||
case "x86_64":
|
||||
return "amd64"
|
||||
case "aarch64":
|
||||
return "arm64"
|
||||
case "armv7":
|
||||
return "armv7l"
|
||||
case "armv6":
|
||||
return "armv6l"
|
||||
case "armv5":
|
||||
return "arm"
|
||||
case "":
|
||||
return "*"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// legacyExt strips the leading "." from format strings.
|
||||
func legacyExt(s string) string {
|
||||
s = strings.TrimPrefix(s, ".")
|
||||
if s == "" {
|
||||
return "exe"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// legacyVersion strips the leading "v" from version strings.
|
||||
func legacyVersion(s string) string {
|
||||
return strings.TrimPrefix(s, "v")
|
||||
}
|
||||
|
||||
// legacyLibc returns "none" for empty libc values.
|
||||
func legacyLibc(s string) string {
|
||||
if s == "" {
|
||||
return "none"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func distsToLegacy(dists []resolve.Dist) []legacyRelease {
|
||||
releases := make([]legacyRelease, len(dists))
|
||||
for i, d := range dists {
|
||||
releases[i] = legacyRelease{
|
||||
Name: d.Filename,
|
||||
Version: legacyVersion(d.Version),
|
||||
GitTag: d.GitTag,
|
||||
GitCommitHash: d.GitCommitHash,
|
||||
LTS: d.LTS,
|
||||
Channel: d.Channel,
|
||||
Date: d.Date,
|
||||
OS: legacyOS(d.OS),
|
||||
Arch: legacyArch(d.Arch),
|
||||
Ext: legacyExt(d.Format),
|
||||
Download: d.Download,
|
||||
Libc: legacyLibc(d.Libc),
|
||||
}
|
||||
}
|
||||
return releases
|
||||
}
|
||||
|
||||
func (s *server) serveJSON(w http.ResponseWriter, r *http.Request, pc *packageCache, filtered []resolve.Dist) {
|
||||
// Production returns a bare JSON array, not wrapped in an object.
|
||||
releases := distsToLegacy(filtered)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
pretty := r.URL.Query().Get("pretty")
|
||||
if pretty == "true" || pretty == "1" {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(releases)
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(releases)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) serveTab(w http.ResponseWriter, r *http.Request, filtered []resolve.Dist) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
|
||||
// Production only shows header row with ?pretty=true.
|
||||
pretty := r.URL.Query().Get("pretty")
|
||||
if pretty != "" && pretty != "false" {
|
||||
fmt.Fprintln(w, "VERSION\tLTS\tCHANNEL\tRELEASE_DATE\tOS\tARCH\tEXT\tHASH\tURL\t_\tLIBC")
|
||||
}
|
||||
|
||||
// Tab format matches Node.js production:
|
||||
// version \t lts \t channel \t date \t os \t arch \t ext \t hash \t download \t comment \t libc
|
||||
for _, d := range filtered {
|
||||
lts := "-"
|
||||
if d.LTS {
|
||||
lts = "lts"
|
||||
}
|
||||
channel := d.Channel
|
||||
if channel == "" {
|
||||
channel = "-"
|
||||
}
|
||||
date := d.Date
|
||||
if date == "" {
|
||||
date = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t-\t%s\t\t%s\n",
|
||||
legacyVersion(d.Version),
|
||||
lts,
|
||||
channel,
|
||||
date,
|
||||
legacyOS(d.OS),
|
||||
legacyArch(d.Arch),
|
||||
legacyExt(d.Format),
|
||||
d.Download,
|
||||
legacyLibc(d.Libc),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// sortDistsDescending sorts dists newest-first by version.
|
||||
func sortDistsDescending(dists []resolve.Dist, queryOS, queryArch string) {
|
||||
slices.SortStableFunc(dists, func(a, b resolve.Dist) int {
|
||||
va := lexver.Parse(strings.TrimPrefix(a.Version, "v"))
|
||||
vb := lexver.Parse(strings.TrimPrefix(b.Version, "v"))
|
||||
if cmp := lexver.Compare(vb, va); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
if cmp := osSpecificity(a.OS, queryOS) - osSpecificity(b.OS, queryOS); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
if cmp := archSpecificity(a.Arch, queryArch) - archSpecificity(b.Arch, queryArch); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
return libcRank(a.Libc) - libcRank(b.Libc)
|
||||
})
|
||||
}
|
||||
|
||||
func osSpecificity(distOS, queryOS string) int {
|
||||
switch {
|
||||
case distOS == queryOS:
|
||||
return 0
|
||||
case distOS == "posix_2017":
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func archSpecificity(distArch, queryArch string) int {
|
||||
switch {
|
||||
case distArch == queryArch:
|
||||
return 0
|
||||
case distArch == "" || distArch == "*":
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func libcRank(libc string) int {
|
||||
switch libc {
|
||||
case "none", "":
|
||||
return 0
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// serveEmptyReleases returns an empty release list for selfhosted packages.
|
||||
func (s *server) serveEmptyReleases(w http.ResponseWriter, format string) {
|
||||
switch format {
|
||||
case "json":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Production returns an empty array.
|
||||
json.NewEncoder(w).Encode([]legacyRelease{})
|
||||
case "tab":
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
// isSelfHosted checks if a package has install.sh but no releases.conf.
|
||||
func (s *server) isSelfHosted(pkg string) bool {
|
||||
installPath := filepath.Join(s.installersDir, pkg, "install.sh")
|
||||
if _, err := os.Stat(installPath); err != nil {
|
||||
return false
|
||||
}
|
||||
confPath := filepath.Join(s.installersDir, pkg, "releases.conf")
|
||||
if _, err := os.Stat(confPath); err == nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleDebug returns UA detection info for the requesting client.
|
||||
func (s *server) handleDebug(w http.ResponseWriter, r *http.Request) {
|
||||
result := uadetect.FromRequest(r)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"user_agent": r.Header.Get("User-Agent"),
|
||||
"os": string(result.OS),
|
||||
"arch": string(result.Arch),
|
||||
"libc": string(result.Libc),
|
||||
})
|
||||
}
|
||||
|
||||
// handleBootstrap serves /{package} and /{package}@{version}.
|
||||
// This is the curl-pipe bootstrap: a minimal script that sets
|
||||
// WEBI_PKG/WEBI_HOST/WEBI_CHECKSUM and downloads+runs webi.
|
||||
func (s *server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
pkgSpec := r.PathValue("pkgSpec")
|
||||
|
||||
// Parse package@version.
|
||||
pkg, tag := pkgSpec, ""
|
||||
if idx := strings.IndexByte(pkgSpec, '@'); idx >= 0 {
|
||||
pkg = pkgSpec[:idx]
|
||||
tag = pkgSpec[idx+1:]
|
||||
}
|
||||
|
||||
if pkg == "" {
|
||||
http.Error(w, "package name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify package exists.
|
||||
if s.getPackage(pkg) == nil && !s.isSelfHosted(pkg) {
|
||||
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := baseURLFromRequest(r)
|
||||
webiPkg := pkg
|
||||
if tag != "" {
|
||||
webiPkg = pkg + "@" + tag
|
||||
}
|
||||
|
||||
// Read and inject the curl-pipe bootstrap template.
|
||||
tplPath := filepath.Join(s.installersDir, "_webi", "curl-pipe-bootstrap.tpl.sh")
|
||||
tpl, err := os.ReadFile(tplPath)
|
||||
if err != nil {
|
||||
log.Printf("bootstrap: read template: %v", err)
|
||||
http.Error(w, "bootstrap template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
script := string(tpl)
|
||||
script = render.InjectVar(script, "WEBI_PKG", webiPkg)
|
||||
script = render.InjectVar(script, "WEBI_HOST", baseURL)
|
||||
script = render.InjectVar(script, "WEBI_CHECKSUM", s.webiChecksum())
|
||||
|
||||
// text/html so browsers see the meta redirect to cheat sheet.
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, script)
|
||||
}
|
||||
|
||||
// handleInstaller serves /api/installers/{pkg}@{version}.sh
|
||||
// This is the full installer script with release resolution and
|
||||
// embedded install.sh.
|
||||
func (s *server) handleInstaller(w http.ResponseWriter, r *http.Request) {
|
||||
rest := r.PathValue("rest")
|
||||
|
||||
// Parse: {pkg}@{version}.sh or {pkg}.sh
|
||||
ext := ""
|
||||
if strings.HasSuffix(rest, ".sh") {
|
||||
ext = "sh"
|
||||
rest = strings.TrimSuffix(rest, ".sh")
|
||||
} else if strings.HasSuffix(rest, ".ps1") {
|
||||
ext = "ps1"
|
||||
rest = strings.TrimSuffix(rest, ".ps1")
|
||||
} else {
|
||||
http.Error(w, "unsupported format (use .sh or .ps1)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pkg, tag := rest, ""
|
||||
if idx := strings.IndexByte(rest, '@'); idx >= 0 {
|
||||
pkg = rest[:idx]
|
||||
tag = rest[idx+1:]
|
||||
}
|
||||
|
||||
if pkg == "" {
|
||||
http.Error(w, "package name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Detect platform from User-Agent.
|
||||
ua := uadetect.FromRequest(r)
|
||||
if ua.OS == "" {
|
||||
http.Error(w, "could not detect OS from User-Agent", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
isSelfHosted := s.isSelfHosted(pkg)
|
||||
pc := s.getPackage(pkg)
|
||||
|
||||
if pc == nil && !isSelfHosted {
|
||||
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := baseURLFromRequest(r)
|
||||
|
||||
p := render.Params{
|
||||
Host: baseURL,
|
||||
PkgName: pkg,
|
||||
Tag: tag,
|
||||
OS: string(ua.OS),
|
||||
Arch: string(ua.Arch),
|
||||
Libc: string(ua.Libc),
|
||||
}
|
||||
|
||||
// Resolve the best release (if not selfhosted).
|
||||
if pc != nil {
|
||||
req := resolver.Request{
|
||||
OS: string(ua.OS),
|
||||
Arch: string(ua.Arch),
|
||||
Libc: string(ua.Libc),
|
||||
}
|
||||
|
||||
switch strings.ToLower(tag) {
|
||||
case "stable", "latest", "":
|
||||
// Default.
|
||||
case "lts":
|
||||
req.LTS = true
|
||||
case "beta", "pre", "preview":
|
||||
req.Channel = "beta"
|
||||
case "rc":
|
||||
req.Channel = "rc"
|
||||
case "alpha", "dev":
|
||||
req.Channel = "alpha"
|
||||
default:
|
||||
req.Version = tag
|
||||
}
|
||||
|
||||
res, err := resolver.Resolve(pc.assets, req)
|
||||
if err != nil {
|
||||
p.Version = "0.0.0"
|
||||
p.Channel = "error"
|
||||
p.Ext = "err"
|
||||
p.PkgURL = "https://example.com/doesntexist.ext"
|
||||
p.PkgFile = "doesntexist.ext"
|
||||
p.CSV = buildCSV(p)
|
||||
} else {
|
||||
v := strings.TrimPrefix(res.Version, "v")
|
||||
parts := splitVersion(v)
|
||||
p.Version = v
|
||||
p.Major = parts[0]
|
||||
p.Minor = parts[1]
|
||||
p.Patch = parts[2]
|
||||
p.Build = parts[3]
|
||||
if res.Asset.GitTag != "" {
|
||||
p.GitTag = res.Asset.GitTag
|
||||
} else {
|
||||
p.GitTag = "v" + v
|
||||
}
|
||||
p.GitBranch = p.GitTag
|
||||
p.GitCommitHash = res.Asset.GitCommitHash
|
||||
p.LTS = fmt.Sprintf("%v", res.Asset.LTS)
|
||||
p.Channel = res.Asset.Channel
|
||||
if p.Channel == "" {
|
||||
p.Channel = "stable"
|
||||
}
|
||||
p.Ext = strings.TrimPrefix(res.Asset.Format, ".")
|
||||
if p.Ext == "" {
|
||||
p.Ext = "exe"
|
||||
}
|
||||
p.PkgURL = res.Asset.Download
|
||||
p.PkgFile = res.Asset.Filename
|
||||
p.CSV = buildCSV(p)
|
||||
}
|
||||
|
||||
p.PkgStable = pc.catalog.Stable
|
||||
p.PkgLatest = pc.catalog.Latest
|
||||
p.PkgOSes = strings.Join(pc.catalog.OSes, " ")
|
||||
p.PkgArches = strings.Join(pc.catalog.Arches, " ")
|
||||
p.PkgLibcs = strings.Join(pc.catalog.Libcs, " ")
|
||||
p.PkgFormats = strings.Join(pc.catalog.Formats, " ")
|
||||
}
|
||||
|
||||
p.ReleasesURL = fmt.Sprintf("%s/api/releases/%s@%s.tab?os=%s&arch=%s&libc=%s&formats=tar&pretty=true",
|
||||
baseURL, pkg, tag, p.OS, p.Arch, p.Libc)
|
||||
|
||||
var script string
|
||||
var renderErr error
|
||||
if ext == "ps1" {
|
||||
tplPath := filepath.Join(s.installersDir, "_webi", "package-install.tpl.ps1")
|
||||
script, renderErr = render.PowerShell(tplPath, s.installersDir, pkg, p)
|
||||
} else {
|
||||
tplPath := filepath.Join(s.installersDir, "_webi", "package-install.tpl.sh")
|
||||
script, renderErr = render.Bash(tplPath, s.installersDir, pkg, p)
|
||||
}
|
||||
if renderErr != nil {
|
||||
log.Printf("render %s: %v", pkg, renderErr)
|
||||
http.Error(w, fmt.Sprintf("failed to render installer for %q: %v", pkg, renderErr), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
fmt.Fprint(w, script)
|
||||
}
|
||||
|
||||
// baseURLFromRequest builds the base URL from the request.
|
||||
func baseURLFromRequest(r *http.Request) string {
|
||||
if r.TLS != nil || strings.Contains(r.Host, "webinstall") || strings.Contains(r.Host, "webi.") {
|
||||
return "https://" + r.Host
|
||||
}
|
||||
return "http://" + r.Host
|
||||
}
|
||||
|
||||
// webiChecksum returns the checksum of the webi.sh bootstrap script.
|
||||
func (s *server) webiChecksum() string {
|
||||
s.mu.RLock()
|
||||
cksum := s.webiCksum
|
||||
s.mu.RUnlock()
|
||||
if cksum != "" {
|
||||
return cksum
|
||||
}
|
||||
|
||||
// Calculate checksum from webi.sh file.
|
||||
webiPath := filepath.Join(s.installersDir, "webi", "webi.sh")
|
||||
data, err := os.ReadFile(webiPath)
|
||||
if err != nil {
|
||||
return "00000000"
|
||||
}
|
||||
|
||||
h := sha1.New()
|
||||
h.Write(data)
|
||||
cksum = fmt.Sprintf("%x", h.Sum(nil))[:8]
|
||||
|
||||
s.mu.Lock()
|
||||
s.webiCksum = cksum
|
||||
s.mu.Unlock()
|
||||
return cksum
|
||||
}
|
||||
|
||||
// buildCSV creates the WEBI_CSV line in the Node.js format.
|
||||
func buildCSV(p render.Params) string {
|
||||
return strings.Join([]string{
|
||||
p.Version,
|
||||
p.LTS,
|
||||
p.Channel,
|
||||
"", // date
|
||||
p.OS,
|
||||
p.Arch,
|
||||
p.Ext,
|
||||
"-",
|
||||
p.PkgURL,
|
||||
p.PkgFile,
|
||||
"",
|
||||
}, ",")
|
||||
}
|
||||
|
||||
// splitVersion splits a version string into [major, minor, patch, build].
|
||||
func splitVersion(v string) [4]string {
|
||||
// Strip pre-release suffix for splitting.
|
||||
base := v
|
||||
build := ""
|
||||
if idx := strings.IndexByte(v, '-'); idx >= 0 {
|
||||
base = v[:idx]
|
||||
build = v[idx+1:]
|
||||
}
|
||||
|
||||
parts := strings.SplitN(base, ".", 4)
|
||||
var result [4]string
|
||||
for i := 0; i < len(parts) && i < 3; i++ {
|
||||
result[i] = parts[i]
|
||||
}
|
||||
result[3] = build
|
||||
return result
|
||||
}
|
||||
|
||||
func expandHome(path string) string {
|
||||
if !strings.HasPrefix(path, "~/") {
|
||||
return path
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(home, path[2:])
|
||||
}
|
||||
298
cmd/webid/main_test.go
Normal file
298
cmd/webid/main_test.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/resolve"
|
||||
"github.com/webinstall/webi-installers/internal/storage"
|
||||
"github.com/webinstall/webi-installers/internal/storage/fsstore"
|
||||
)
|
||||
|
||||
// newTestServer creates a server backed by the real _cache directory
|
||||
// and returns an httptest.Server with proper routing (so PathValue works).
|
||||
func newTestServer(t *testing.T) (*server, *httptest.Server) {
|
||||
t.Helper()
|
||||
|
||||
cacheDir := filepath.Join("..", "..", "_cache")
|
||||
if _, err := os.Stat(cacheDir); err != nil {
|
||||
t.Skipf("no cache dir at %s", cacheDir)
|
||||
}
|
||||
|
||||
store, err := fsstore.New(cacheDir)
|
||||
if err != nil {
|
||||
t.Fatalf("fsstore: %v", err)
|
||||
}
|
||||
|
||||
srv := &server{
|
||||
store: store,
|
||||
installersDir: filepath.Join("..", ".."),
|
||||
packages: make(map[string]*packageCache),
|
||||
}
|
||||
|
||||
// Load packages.
|
||||
monthDir := time.Now().Format("2006-01")
|
||||
dir := filepath.Join(store.Root(), monthDir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readdir: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
pkg := strings.TrimSuffix(e.Name(), ".json")
|
||||
pd, err := store.Load(context.Background(), pkg)
|
||||
if err != nil || pd == nil || len(pd.Assets) == 0 {
|
||||
continue
|
||||
}
|
||||
pc := &packageCache{
|
||||
assets: pd.Assets,
|
||||
dists: assetsToDists(pd.Assets),
|
||||
}
|
||||
pc.catalog = resolve.Survey(pc.dists)
|
||||
srv.packages[pkg] = pc
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/releases/{rest...}", srv.handleReleasesAPI)
|
||||
mux.HandleFunc("GET /v1/releases/{rest...}", srv.handleV1Releases)
|
||||
mux.HandleFunc("GET /v1/resolve/{rest...}", srv.handleV1Resolve)
|
||||
mux.HandleFunc("GET /api/installers/{rest...}", srv.handleInstaller)
|
||||
mux.HandleFunc("GET /api/debug", srv.handleDebug)
|
||||
mux.HandleFunc("GET /{pkgSpec}", srv.handleBootstrap)
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
return srv, ts
|
||||
}
|
||||
|
||||
// get fetches a URL from the test server and returns the body.
|
||||
func get(t *testing.T, ts *httptest.Server, path string) (int, string) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(body)
|
||||
}
|
||||
|
||||
// TestLegacyJSONFormat verifies our JSON output matches the production format.
|
||||
func TestLegacyJSONFormat(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
packages := []string{"bat", "node", "go", "jq"}
|
||||
for _, pkg := range packages {
|
||||
t.Run(pkg, func(t *testing.T) {
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
code, body := get(t, ts, "/api/releases/"+pkg+".json?limit=5")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", code, body)
|
||||
}
|
||||
|
||||
body = strings.TrimSpace(body)
|
||||
|
||||
// Must be a JSON array, not an object.
|
||||
if !strings.HasPrefix(body, "[") {
|
||||
t.Fatalf("expected JSON array, got: %.100s", body)
|
||||
}
|
||||
|
||||
var releases []legacyRelease
|
||||
if err := json.Unmarshal([]byte(body), &releases); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(releases) == 0 {
|
||||
t.Fatal("no releases returned")
|
||||
}
|
||||
|
||||
// Check field format conventions.
|
||||
for i, r := range releases {
|
||||
if strings.HasPrefix(r.Version, "v") {
|
||||
t.Errorf("release[%d]: version %q should not have v prefix", i, r.Version)
|
||||
}
|
||||
if strings.HasPrefix(r.Ext, ".") {
|
||||
t.Errorf("release[%d]: ext %q should not have . prefix", i, r.Ext)
|
||||
}
|
||||
if r.OS == "darwin" {
|
||||
t.Errorf("release[%d]: os should be 'macos' not 'darwin'", i)
|
||||
}
|
||||
if r.Arch == "x86_64" {
|
||||
t.Errorf("release[%d]: arch should be 'amd64' not 'x86_64'", i)
|
||||
}
|
||||
if r.Arch == "aarch64" {
|
||||
t.Errorf("release[%d]: arch should be 'arm64' not 'aarch64'", i)
|
||||
}
|
||||
if r.Libc == "" {
|
||||
t.Errorf("release[%d]: libc should be 'none' not empty", i)
|
||||
}
|
||||
if r.Download == "" {
|
||||
t.Errorf("release[%d]: download URL is empty", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyTabFormat verifies our .tab output uses real TSV.
|
||||
func TestLegacyTabFormat(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
packages := []string{"bat", "node", "go"}
|
||||
for _, pkg := range packages {
|
||||
t.Run(pkg, func(t *testing.T) {
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
code, body := get(t, ts, "/api/releases/"+pkg+".tab?limit=3")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", code, body)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(body), "\n")
|
||||
if len(lines) == 0 {
|
||||
t.Fatal("no lines returned")
|
||||
}
|
||||
|
||||
for i, line := range lines {
|
||||
fields := strings.Split(line, "\t")
|
||||
// Expect 11 tab-separated fields:
|
||||
// version, lts, channel, date, os, arch, ext, hash, download, (empty), libc
|
||||
if len(fields) != 11 {
|
||||
t.Errorf("line[%d]: expected 11 tab fields, got %d: %q", i, len(fields), line)
|
||||
continue
|
||||
}
|
||||
|
||||
version := fields[0]
|
||||
lts := fields[1]
|
||||
ext := fields[6]
|
||||
|
||||
if strings.HasPrefix(version, "v") {
|
||||
t.Errorf("line[%d]: version %q should not have v prefix", i, version)
|
||||
}
|
||||
if lts != "-" && lts != "lts" {
|
||||
t.Errorf("line[%d]: lts should be '-' or 'lts', got %q", i, lts)
|
||||
}
|
||||
if strings.HasPrefix(ext, ".") {
|
||||
t.Errorf("line[%d]: ext %q should not have . prefix", i, ext)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyJSONAgainstProduction compares our output against live production.
|
||||
// Run with: WEBI_TEST_PROD=1 go test -run TestLegacyJSONAgainstProduction
|
||||
func TestLegacyJSONAgainstProduction(t *testing.T) {
|
||||
if os.Getenv("WEBI_TEST_PROD") == "" {
|
||||
t.Skip("set WEBI_TEST_PROD=1 to compare against production")
|
||||
}
|
||||
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
packages := []string{"bat", "node", "go", "jq", "rg"}
|
||||
for _, pkg := range packages {
|
||||
t.Run(pkg, func(t *testing.T) {
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
// Fetch from production.
|
||||
prodURL := fmt.Sprintf("https://webinstall.dev/api/releases/%s.json?limit=3", pkg)
|
||||
prodResp, err := http.Get(prodURL)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch production: %v", err)
|
||||
}
|
||||
defer prodResp.Body.Close()
|
||||
prodBody, _ := io.ReadAll(prodResp.Body)
|
||||
|
||||
var prodReleases []legacyRelease
|
||||
if err := json.Unmarshal(prodBody, &prodReleases); err != nil {
|
||||
t.Fatalf("decode production: %v\nbody: %.500s", err, string(prodBody))
|
||||
}
|
||||
|
||||
// Fetch from local.
|
||||
_, localBody := get(t, ts, "/api/releases/"+pkg+".json?limit=3")
|
||||
|
||||
var localReleases []legacyRelease
|
||||
if err := json.Unmarshal([]byte(localBody), &localReleases); err != nil {
|
||||
t.Fatalf("decode local: %v", err)
|
||||
}
|
||||
|
||||
if len(prodReleases) == 0 || len(localReleases) == 0 {
|
||||
t.Skip("empty releases")
|
||||
}
|
||||
|
||||
// Compare the first release's format.
|
||||
prod := prodReleases[0]
|
||||
local := localReleases[0]
|
||||
|
||||
if strings.HasPrefix(local.Version, "v") != strings.HasPrefix(prod.Version, "v") {
|
||||
t.Errorf("version prefix mismatch: prod=%q local=%q", prod.Version, local.Version)
|
||||
}
|
||||
if strings.HasPrefix(local.Ext, ".") != strings.HasPrefix(prod.Ext, ".") {
|
||||
t.Errorf("ext prefix mismatch: prod=%q local=%q", prod.Ext, local.Ext)
|
||||
}
|
||||
if prod.OS == "macos" && local.OS == "darwin" {
|
||||
t.Error("OS: prod uses 'macos', local uses 'darwin'")
|
||||
}
|
||||
if prod.Arch == "amd64" && local.Arch == "x86_64" {
|
||||
t.Error("Arch: prod uses 'amd64', local uses 'x86_64'")
|
||||
}
|
||||
if prod.Arch == "arm64" && local.Arch == "aarch64" {
|
||||
t.Error("Arch: prod uses 'arm64', local uses 'aarch64'")
|
||||
}
|
||||
|
||||
t.Logf("prod[0]: version=%q os=%q arch=%q ext=%q libc=%q",
|
||||
prod.Version, prod.OS, prod.Arch, prod.Ext, prod.Libc)
|
||||
t.Logf("local[0]: version=%q os=%q arch=%q ext=%q libc=%q",
|
||||
local.Version, local.OS, local.Arch, local.Ext, local.Libc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortOrder verifies releases come back newest-first.
|
||||
func TestSortOrder(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
pkg := "bat"
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
_, body := get(t, ts, "/api/releases/"+pkg+".json?limit=20")
|
||||
|
||||
var releases []legacyRelease
|
||||
if err := json.Unmarshal([]byte(body), &releases); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if len(releases) < 2 {
|
||||
t.Skip("need at least 2 releases")
|
||||
}
|
||||
|
||||
// First release should be newest (or equal) version.
|
||||
first := releases[0].Date
|
||||
last := releases[len(releases)-1].Date
|
||||
if first < last {
|
||||
t.Errorf("not newest-first: first=%q last=%q", first, last)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure imports are used.
|
||||
var _ = storage.Asset{}
|
||||
459
cmd/webid/v1api.go
Normal file
459
cmd/webid/v1api.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/jszwec/csvutil"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/buildmeta"
|
||||
"github.com/webinstall/webi-installers/internal/lexver"
|
||||
"github.com/webinstall/webi-installers/internal/resolver"
|
||||
"github.com/webinstall/webi-installers/internal/storage"
|
||||
)
|
||||
|
||||
// v1Release is a single release in the new API TSV format.
|
||||
// Field order matters for csvutil — it determines column order.
|
||||
// Fields are designed to be easy to consume with cut/grep/sort.
|
||||
type v1Release struct {
|
||||
Version string `csv:"version"`
|
||||
Channel string `csv:"channel"`
|
||||
LTS string `csv:"lts"`
|
||||
Date string `csv:"date"`
|
||||
OS string `csv:"os"`
|
||||
Arch string `csv:"arch"`
|
||||
Libc string `csv:"libc"`
|
||||
Format string `csv:"format"`
|
||||
Variants string `csv:"variants"` // space-separated
|
||||
Download string `csv:"download"`
|
||||
Filename string `csv:"filename"`
|
||||
}
|
||||
|
||||
// v1ResolveResult is the response for /v1/resolve/{pkg}.
|
||||
type v1ResolveResult struct {
|
||||
Version string `csv:"version" json:"version"`
|
||||
Channel string `csv:"channel" json:"channel"`
|
||||
LTS string `csv:"lts" json:"lts"`
|
||||
Date string `csv:"date" json:"date"`
|
||||
OS string `csv:"os" json:"os"`
|
||||
Arch string `csv:"arch" json:"arch"`
|
||||
Libc string `csv:"libc" json:"libc"`
|
||||
Format string `csv:"format" json:"format"`
|
||||
Variants string `csv:"variants" json:"variants"`
|
||||
Download string `csv:"download" json:"download"`
|
||||
Filename string `csv:"filename" json:"filename"`
|
||||
Triplet string `csv:"triplet" json:"triplet"`
|
||||
}
|
||||
|
||||
// handleV1Releases serves /v1/releases/{pkg}.tsv (or .json)
|
||||
// with Go-native naming and TSV-first format.
|
||||
//
|
||||
// Query params:
|
||||
//
|
||||
// os — filter by OS (darwin, linux, windows)
|
||||
// arch — filter by arch (aarch64, x86_64, armv7l)
|
||||
// libc — filter by libc (gnu, musl, msvc)
|
||||
// channel — release channel (stable, beta, rc, alpha)
|
||||
// version — version prefix filter (e.g. "1.20")
|
||||
// lts — if "true", only LTS releases
|
||||
// format — filter by format (e.g. "tar.gz")
|
||||
// variant — filter by variant (e.g. "rocm")
|
||||
// limit — max results (default 1000)
|
||||
func (s *server) handleV1Releases(w http.ResponseWriter, r *http.Request) {
|
||||
rest := r.PathValue("rest")
|
||||
|
||||
pkg, version, format, err := parseReleasePath(rest)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pc := s.getPackage(pkg)
|
||||
if pc == nil {
|
||||
if s.isSelfHosted(pkg) {
|
||||
s.v1ServeEmpty(w, format)
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
osStr := q.Get("os")
|
||||
archStr := q.Get("arch")
|
||||
libcStr := q.Get("libc")
|
||||
channelStr := q.Get("channel")
|
||||
ltsStr := q.Get("lts")
|
||||
formatFilter := q.Get("format")
|
||||
variantStr := q.Get("variant")
|
||||
limitStr := q.Get("limit")
|
||||
|
||||
// Use version from URL path or query.
|
||||
if version == "" {
|
||||
version = q.Get("version")
|
||||
}
|
||||
|
||||
// Handle channel selectors in version field.
|
||||
switch strings.ToLower(version) {
|
||||
case "stable", "latest":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "stable"
|
||||
}
|
||||
case "lts":
|
||||
version = ""
|
||||
ltsStr = "true"
|
||||
case "beta", "pre", "preview":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "beta"
|
||||
}
|
||||
case "rc":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "rc"
|
||||
}
|
||||
case "alpha", "dev":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "alpha"
|
||||
}
|
||||
}
|
||||
|
||||
lts := ltsStr == "true" || ltsStr == "1"
|
||||
|
||||
limit := 1000
|
||||
if limitStr != "" {
|
||||
fmt.Sscanf(limitStr, "%d", &limit)
|
||||
}
|
||||
|
||||
// Filter assets directly (not via resolve.Dist).
|
||||
filtered := filterAssets(pc.assets, osStr, archStr, libcStr, channelStr, version, formatFilter, variantStr, lts, limit)
|
||||
|
||||
// Sort newest-first.
|
||||
sortAssetsDescending(filtered)
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
s.v1ServeJSON(w, filtered)
|
||||
case "tab":
|
||||
s.v1ServeTSV(w, filtered)
|
||||
default:
|
||||
http.Error(w, "unsupported format: "+format+" (use .json or .tab)", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleV1Resolve serves /v1/resolve/{pkg}.tsv (or .json)
|
||||
// It resolves the single best asset for a given platform.
|
||||
//
|
||||
// Query params:
|
||||
//
|
||||
// os — target OS (required)
|
||||
// arch — target arch (required)
|
||||
// libc — target libc
|
||||
// version — version prefix
|
||||
// channel — release channel
|
||||
// lts — if "true", only LTS
|
||||
// format — preferred formats (comma-separated, in preference order)
|
||||
// variant — preferred variant
|
||||
func (s *server) handleV1Resolve(w http.ResponseWriter, r *http.Request) {
|
||||
rest := r.PathValue("rest")
|
||||
|
||||
pkg, version, format, err := parseReleasePath(rest)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pc := s.getPackage(pkg)
|
||||
if pc == nil {
|
||||
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
osStr := q.Get("os")
|
||||
archStr := q.Get("arch")
|
||||
libcStr := q.Get("libc")
|
||||
channelStr := q.Get("channel")
|
||||
ltsStr := q.Get("lts")
|
||||
formatsStr := q.Get("format")
|
||||
variantStr := q.Get("variant")
|
||||
|
||||
if version == "" {
|
||||
version = q.Get("version")
|
||||
}
|
||||
|
||||
// Handle channel selectors in version field.
|
||||
switch strings.ToLower(version) {
|
||||
case "stable", "latest":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "stable"
|
||||
}
|
||||
case "lts":
|
||||
version = ""
|
||||
ltsStr = "true"
|
||||
case "beta", "pre", "preview":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "beta"
|
||||
}
|
||||
case "rc":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "rc"
|
||||
}
|
||||
case "alpha", "dev":
|
||||
version = ""
|
||||
if channelStr == "" {
|
||||
channelStr = "alpha"
|
||||
}
|
||||
}
|
||||
|
||||
lts := ltsStr == "true" || ltsStr == "1"
|
||||
|
||||
var formats []string
|
||||
if formatsStr != "" {
|
||||
formats = strings.Split(formatsStr, ",")
|
||||
}
|
||||
|
||||
req := resolver.Request{
|
||||
OS: osStr,
|
||||
Arch: archStr,
|
||||
Libc: libcStr,
|
||||
Version: version,
|
||||
Channel: channelStr,
|
||||
LTS: lts,
|
||||
Formats: formats,
|
||||
Variant: variantStr,
|
||||
}
|
||||
|
||||
res, err := resolver.Resolve(pc.assets, req)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("no match for %s: %v", pkg, err), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
result := assetToV1Resolve(res)
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(result)
|
||||
case "tab":
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
data, err := marshalTSV([]v1ResolveResult{result})
|
||||
if err != nil {
|
||||
http.Error(w, "encode error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
default:
|
||||
http.Error(w, "unsupported format: "+format, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func assetToV1Release(a storage.Asset) v1Release {
|
||||
lts := "-"
|
||||
if a.LTS {
|
||||
lts = "lts"
|
||||
}
|
||||
channel := a.Channel
|
||||
if channel == "" {
|
||||
channel = "stable"
|
||||
}
|
||||
libc := a.Libc
|
||||
if libc == "" {
|
||||
libc = "-"
|
||||
}
|
||||
return v1Release{
|
||||
Version: a.Version,
|
||||
Channel: channel,
|
||||
LTS: lts,
|
||||
Date: a.Date,
|
||||
OS: a.OS,
|
||||
Arch: a.Arch,
|
||||
Libc: libc,
|
||||
Format: a.Format,
|
||||
Variants: strings.Join(a.Variants, " "),
|
||||
Download: a.Download,
|
||||
Filename: a.Filename,
|
||||
}
|
||||
}
|
||||
|
||||
func assetToV1Resolve(res resolver.Result) v1ResolveResult {
|
||||
a := res.Asset
|
||||
lts := "-"
|
||||
if a.LTS {
|
||||
lts = "lts"
|
||||
}
|
||||
channel := a.Channel
|
||||
if channel == "" {
|
||||
channel = "stable"
|
||||
}
|
||||
libc := a.Libc
|
||||
if libc == "" {
|
||||
libc = "-"
|
||||
}
|
||||
return v1ResolveResult{
|
||||
Version: a.Version,
|
||||
Channel: channel,
|
||||
LTS: lts,
|
||||
Date: a.Date,
|
||||
OS: a.OS,
|
||||
Arch: a.Arch,
|
||||
Libc: libc,
|
||||
Format: a.Format,
|
||||
Variants: strings.Join(a.Variants, " "),
|
||||
Download: a.Download,
|
||||
Filename: a.Filename,
|
||||
Triplet: res.Triplet,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) v1ServeTSV(w http.ResponseWriter, assets []storage.Asset) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
|
||||
releases := make([]v1Release, len(assets))
|
||||
for i, a := range assets {
|
||||
releases[i] = assetToV1Release(a)
|
||||
}
|
||||
|
||||
data, err := marshalTSV(releases)
|
||||
if err != nil {
|
||||
http.Error(w, "encode error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *server) v1ServeJSON(w http.ResponseWriter, assets []storage.Asset) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
releases := make([]v1Release, len(assets))
|
||||
for i, a := range assets {
|
||||
releases[i] = assetToV1Release(a)
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(releases)
|
||||
}
|
||||
|
||||
func (s *server) v1ServeEmpty(w http.ResponseWriter, format string) {
|
||||
switch format {
|
||||
case "json":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte("[]\n"))
|
||||
case "tab":
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
// Just the header.
|
||||
data, _ := marshalTSV([]v1Release{})
|
||||
w.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
// filterAssets filters storage.Asset slices directly.
|
||||
func filterAssets(assets []storage.Asset, osStr, archStr, libcStr, channel, version, formatFilter, variant string, lts bool, limit int) []storage.Asset {
|
||||
var result []storage.Asset
|
||||
|
||||
for _, a := range assets {
|
||||
if osStr != "" && a.OS != osStr && a.OS != "ANYOS" && a.OS != "" {
|
||||
continue
|
||||
}
|
||||
if archStr != "" && a.Arch != archStr && a.Arch != "ANYARCH" && a.Arch != "" {
|
||||
continue
|
||||
}
|
||||
if libcStr != "" && a.Libc != "" && a.Libc != "none" && a.Libc != libcStr {
|
||||
continue
|
||||
}
|
||||
if lts && !a.LTS {
|
||||
continue
|
||||
}
|
||||
if channel != "" && a.Channel != channel {
|
||||
continue
|
||||
}
|
||||
if version != "" {
|
||||
v := strings.TrimPrefix(a.Version, "v")
|
||||
vq := strings.TrimPrefix(version, "v")
|
||||
if !strings.HasPrefix(v, vq) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if formatFilter != "" && !strings.Contains(a.Format, formatFilter) {
|
||||
continue
|
||||
}
|
||||
if variant != "" {
|
||||
if !hasVariant(a.Variants, variant) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, a)
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// sortAssetsDescending sorts assets newest-first by version.
|
||||
func sortAssetsDescending(assets []storage.Asset) {
|
||||
slices.SortStableFunc(assets, func(a, b storage.Asset) int {
|
||||
va := lexver.Parse(strings.TrimPrefix(a.Version, "v"))
|
||||
vb := lexver.Parse(strings.TrimPrefix(b.Version, "v"))
|
||||
return lexver.Compare(vb, va) // descending
|
||||
})
|
||||
}
|
||||
|
||||
// hasVariant checks if the variant list contains the wanted variant.
|
||||
// This is a copy of resolver.hasVariant since it's unexported.
|
||||
func hasVariant(variants []string, want string) bool {
|
||||
for _, v := range variants {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// marshalTSV encodes a slice of structs as tab-separated values with a header.
|
||||
// Uses csvutil for struct-to-CSV mapping, with csv.Writer set to tab delimiter.
|
||||
func marshalTSV[T any](records []T) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
w.Comma = '\t'
|
||||
|
||||
enc := csvutil.NewEncoder(w)
|
||||
for _, r := range records {
|
||||
if err := enc.Encode(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
if err := w.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// normalizeV1Arch maps query arch names to canonical Go names.
|
||||
func normalizeV1Arch(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "amd64":
|
||||
return string(buildmeta.ArchAMD64) // "x86_64"
|
||||
case "arm64":
|
||||
return string(buildmeta.ArchARM64) // "aarch64"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
273
cmd/webid/v1api_test.go
Normal file
273
cmd/webid/v1api_test.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestV1ReleasesTSV verifies the v1 releases endpoint returns proper TSV.
|
||||
func TestV1ReleasesTSV(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
packages := []string{"bat", "node", "go"}
|
||||
for _, pkg := range packages {
|
||||
t.Run(pkg, func(t *testing.T) {
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
code, body := get(t, ts, "/v1/releases/"+pkg+".tab?limit=5")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d: %s", code, body)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(body), "\n")
|
||||
if len(lines) < 2 {
|
||||
t.Fatal("expected header + data rows")
|
||||
}
|
||||
|
||||
// First line should be header.
|
||||
header := lines[0]
|
||||
fields := strings.Split(header, "\t")
|
||||
expectedHeaders := []string{
|
||||
"version",
|
||||
"channel",
|
||||
"lts",
|
||||
"date",
|
||||
"os",
|
||||
"arch",
|
||||
"libc",
|
||||
"format",
|
||||
"variants",
|
||||
"download",
|
||||
"filename",
|
||||
}
|
||||
if len(fields) != len(expectedHeaders) {
|
||||
t.Fatalf("expected %d columns, got %d: %q", len(expectedHeaders), len(fields), header)
|
||||
}
|
||||
for i, want := range expectedHeaders {
|
||||
if fields[i] != want {
|
||||
t.Errorf("column[%d]: want %q, got %q", i, want, fields[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Data rows should have same number of fields.
|
||||
for i, line := range lines[1:] {
|
||||
dataFields := strings.Split(line, "\t")
|
||||
if len(dataFields) != len(expectedHeaders) {
|
||||
t.Errorf("row[%d]: expected %d fields, got %d: %q", i, len(expectedHeaders), len(dataFields), line)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestV1ReleasesJSON verifies the v1 releases JSON format.
|
||||
func TestV1ReleasesJSON(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
pkg := "bat"
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
code, body := get(t, ts, "/v1/releases/"+pkg+".json?limit=3")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d: %s", code, body)
|
||||
}
|
||||
|
||||
var releases []v1Release
|
||||
if err := json.Unmarshal([]byte(body), &releases); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if len(releases) == 0 {
|
||||
t.Fatal("no releases")
|
||||
}
|
||||
|
||||
// v1 API uses Go-native naming — no mapping.
|
||||
for i, r := range releases {
|
||||
if r.Version == "" {
|
||||
t.Errorf("release[%d]: empty version", i)
|
||||
}
|
||||
if r.Download == "" {
|
||||
t.Errorf("release[%d]: empty download", i)
|
||||
}
|
||||
if r.Channel == "" {
|
||||
t.Errorf("release[%d]: empty channel (should be 'stable' or similar)", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestV1Resolve verifies the v1 resolve endpoint.
|
||||
func TestV1Resolve(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
pkg := "bat"
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantOS string
|
||||
}{
|
||||
{
|
||||
name: "linux amd64",
|
||||
query: "?os=linux&arch=x86_64",
|
||||
wantOS: "linux",
|
||||
},
|
||||
{
|
||||
name: "darwin arm64",
|
||||
query: "?os=darwin&arch=aarch64",
|
||||
wantOS: "darwin",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
code, body := get(t, ts, "/v1/resolve/"+pkg+".json"+tt.query)
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d: %s", code, body)
|
||||
}
|
||||
|
||||
var result v1ResolveResult
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if result.Version == "" {
|
||||
t.Error("empty version")
|
||||
}
|
||||
if result.Download == "" {
|
||||
t.Error("empty download")
|
||||
}
|
||||
if result.OS != tt.wantOS {
|
||||
t.Errorf("os: want %q, got %q", tt.wantOS, result.OS)
|
||||
}
|
||||
if result.Triplet == "" {
|
||||
t.Error("empty triplet")
|
||||
}
|
||||
|
||||
t.Logf("resolved: %s %s %s %s → %s", result.Version, result.OS, result.Arch, result.Format, result.Download)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestV1ResolveTSV verifies the TSV format for resolve.
|
||||
func TestV1ResolveTSV(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
pkg := "bat"
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
code, body := get(t, ts, "/v1/resolve/"+pkg+".tab?os=linux&arch=x86_64")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d: %s", code, body)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(body), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 lines (header + result), got %d", len(lines))
|
||||
}
|
||||
|
||||
header := strings.Split(lines[0], "\t")
|
||||
data := strings.Split(lines[1], "\t")
|
||||
|
||||
if len(header) != len(data) {
|
||||
t.Fatalf("header has %d fields, data has %d", len(header), len(data))
|
||||
}
|
||||
|
||||
// Should have a "triplet" column.
|
||||
hasTriplet := false
|
||||
for _, h := range header {
|
||||
if h == "triplet" {
|
||||
hasTriplet = true
|
||||
}
|
||||
}
|
||||
if !hasTriplet {
|
||||
t.Error("missing triplet column in header")
|
||||
}
|
||||
}
|
||||
|
||||
// TestV1ResolveJQ verifies jq resolves to binaries, not git.
|
||||
func TestV1ResolveJQ(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
pkg := "jq"
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
code, body := get(t, ts, "/v1/resolve/"+pkg+".json?os=darwin&arch=aarch64")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d: %s", code, body)
|
||||
}
|
||||
|
||||
var result v1ResolveResult
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if result.Format == "git" {
|
||||
t.Errorf("resolved to git instead of binary: %+v", result)
|
||||
}
|
||||
if result.OS == "" {
|
||||
t.Errorf("resolved to empty OS (git asset): %+v", result)
|
||||
}
|
||||
|
||||
t.Logf("jq resolved: version=%s os=%s arch=%s format=%s → %s",
|
||||
result.Version, result.OS, result.Arch, result.Format, result.Download)
|
||||
}
|
||||
|
||||
// TestV1ReleasesFilterOS verifies OS filtering works.
|
||||
func TestV1ReleasesFilterOS(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
pkg := "bat"
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
code, body := get(t, ts, "/v1/releases/"+pkg+".json?os=darwin&limit=10")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d: %s", code, body)
|
||||
}
|
||||
|
||||
var releases []v1Release
|
||||
if err := json.Unmarshal([]byte(body), &releases); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
for i, r := range releases {
|
||||
if r.OS != "darwin" && r.OS != "ANYOS" && r.OS != "" {
|
||||
t.Errorf("release[%d]: os=%q, expected darwin", i, r.OS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestV1NoQuotedFields verifies TSV output has no quoted fields.
|
||||
func TestV1NoQuotedFields(t *testing.T) {
|
||||
srv, ts := newTestServer(t)
|
||||
|
||||
pkg := "bat"
|
||||
if srv.getPackage(pkg) == nil {
|
||||
t.Skipf("package %s not in cache", pkg)
|
||||
}
|
||||
|
||||
code, body := get(t, ts, "/v1/releases/"+pkg+".tab?limit=20")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d: %s", code, body)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(body), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, "\"") {
|
||||
t.Errorf("line[%d] contains quotes: %s", i, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
go.mod
12
go.mod
@@ -2,14 +2,4 @@ module github.com/webinstall/webi-installers
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/jszwec/csvutil v1.10.0 // indirect
|
||||
github.com/therootcompany/golib/http/middleware/v2 v2.0.1 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
require github.com/joho/godotenv v1.5.1
|
||||
|
||||
23
go.sum
23
go.sum
@@ -1,25 +1,2 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI=
|
||||
github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/therootcompany/golib/http/middleware/v2 v2.0.1 h1:VNKpHcwyEW7cMct7/eO4fyrxwIQk2ycb6juVXSPs2Sk=
|
||||
github.com/therootcompany/golib/http/middleware/v2 v2.0.1/go.mod h1:g5gb9qBidw74nW6/mwIauTKMpOKchiN2l0gt5qzJ2aQ=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// Package platlatest tracks the newest release version per build target.
|
||||
//
|
||||
// After classification determines which OS/arch/libc targets a release
|
||||
// covers, this package records the latest version for each target. This
|
||||
// handles the common case where Windows or macOS releases lag behind
|
||||
// Linux by several versions.
|
||||
//
|
||||
// Storage is a single JSON file per package:
|
||||
//
|
||||
// {
|
||||
// "linux-x86_64-gnu": "v0.145.0",
|
||||
// "darwin-aarch64-none": "v0.144.1",
|
||||
// "windows-x86_64-msvc": "v0.143.0"
|
||||
// }
|
||||
package platlatest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/buildmeta"
|
||||
)
|
||||
|
||||
// Index tracks the latest version for each build target of a package.
|
||||
type Index struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
m map[string]string // triplet → version
|
||||
}
|
||||
|
||||
// Open loads or creates a per-platform latest index at the given path.
|
||||
func Open(path string) (*Index, error) {
|
||||
idx := &Index{
|
||||
path: path,
|
||||
m: make(map[string]string),
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return idx, nil
|
||||
}
|
||||
return nil, fmt.Errorf("platlatest: read %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &idx.m); err != nil {
|
||||
return nil, fmt.Errorf("platlatest: parse %s: %w", path, err)
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// Get returns the latest version for a target, or "" if unknown.
|
||||
func (idx *Index) Get(t buildmeta.Target) string {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
return idx.m[t.Triplet()]
|
||||
}
|
||||
|
||||
// Set records a version as the latest for a target. Does not persist
|
||||
// to disk — call Save after all updates.
|
||||
func (idx *Index) Set(t buildmeta.Target, version string) {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
idx.m[t.Triplet()] = version
|
||||
}
|
||||
|
||||
// All returns a copy of the full triplet→version map.
|
||||
func (idx *Index) All() map[string]string {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
out := make(map[string]string, len(idx.m))
|
||||
for k, v := range idx.m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Save persists the index to disk (atomic write).
|
||||
func (idx *Index) Save() error {
|
||||
idx.mu.RLock()
|
||||
data, err := json.MarshalIndent(idx.m, "", " ")
|
||||
idx.mu.RUnlock()
|
||||
if err != nil {
|
||||
return fmt.Errorf("platlatest: marshal: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(idx.path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("platlatest: mkdir: %w", err)
|
||||
}
|
||||
|
||||
tmp := idx.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return fmt.Errorf("platlatest: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, idx.path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("platlatest: rename %s: %w", idx.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package platlatest_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/buildmeta"
|
||||
"github.com/webinstall/webi-installers/internal/platlatest"
|
||||
)
|
||||
|
||||
var (
|
||||
linuxAMD64 = buildmeta.Target{
|
||||
OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU,
|
||||
}
|
||||
darwinARM64 = buildmeta.Target{
|
||||
OS: buildmeta.OSDarwin, Arch: buildmeta.ArchARM64, Libc: buildmeta.LibcNone,
|
||||
}
|
||||
windowsAMD64 = buildmeta.Target{
|
||||
OS: buildmeta.OSWindows, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcMSVC,
|
||||
}
|
||||
)
|
||||
|
||||
func TestSetAndGet(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "latest.json")
|
||||
idx, err := platlatest.Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := idx.Get(linuxAMD64); got != "" {
|
||||
t.Errorf("Get before Set = %q, want empty", got)
|
||||
}
|
||||
|
||||
idx.Set(linuxAMD64, "v0.145.0")
|
||||
idx.Set(darwinARM64, "v0.144.1")
|
||||
idx.Set(windowsAMD64, "v0.143.0")
|
||||
|
||||
if got := idx.Get(linuxAMD64); got != "v0.145.0" {
|
||||
t.Errorf("linux = %q, want v0.145.0", got)
|
||||
}
|
||||
if got := idx.Get(darwinARM64); got != "v0.144.1" {
|
||||
t.Errorf("darwin = %q, want v0.144.1", got)
|
||||
}
|
||||
if got := idx.Get(windowsAMD64); got != "v0.143.0" {
|
||||
t.Errorf("windows = %q, want v0.143.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndReload(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "latest.json")
|
||||
|
||||
idx1, err := platlatest.Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx1.Set(linuxAMD64, "v0.145.0")
|
||||
idx1.Set(darwinARM64, "v0.144.1")
|
||||
if err := idx1.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Reload from disk.
|
||||
idx2, err := platlatest.Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := idx2.Get(linuxAMD64); got != "v0.145.0" {
|
||||
t.Errorf("after reload: linux = %q, want v0.145.0", got)
|
||||
}
|
||||
if got := idx2.Get(darwinARM64); got != "v0.144.1" {
|
||||
t.Errorf("after reload: darwin = %q, want v0.144.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAll(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "latest.json")
|
||||
idx, err := platlatest.Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idx.Set(linuxAMD64, "v1.0.0")
|
||||
idx.Set(darwinARM64, "v0.9.0")
|
||||
|
||||
all := idx.All()
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("All() returned %d entries, want 2", len(all))
|
||||
}
|
||||
if all[linuxAMD64.Triplet()] != "v1.0.0" {
|
||||
t.Error("missing linux entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenNonexistent(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "does-not-exist.json")
|
||||
idx, err := platlatest.Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Should be empty, not nil.
|
||||
if all := idx.All(); len(all) != 0 {
|
||||
t.Errorf("new index should be empty, got %v", all)
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,9 @@ type Params struct {
|
||||
Minor string
|
||||
Patch string
|
||||
Build string
|
||||
GitTag string
|
||||
GitBranch string
|
||||
GitTag string
|
||||
GitBranch string
|
||||
GitCommitHash string
|
||||
LTS string // "true" or "false"
|
||||
Channel string
|
||||
Ext string // archive extension (e.g. "tar.gz", "zip")
|
||||
@@ -106,6 +107,7 @@ func Bash(tplPath, installersDir, pkgName string, p Params) (string, error) {
|
||||
{"WEBI_BUILD", p.Build},
|
||||
{"WEBI_GIT_BRANCH", p.GitBranch},
|
||||
{"WEBI_GIT_TAG", p.GitTag},
|
||||
{"WEBI_GIT_COMMIT_HASH", p.GitCommitHash},
|
||||
{"WEBI_LTS", p.LTS},
|
||||
{"WEBI_CHANNEL", p.Channel},
|
||||
{"WEBI_EXT", p.Ext},
|
||||
@@ -160,6 +162,7 @@ func PowerShell(tplPath, installersDir, pkgName string, p Params) (string, error
|
||||
{"WEBI_HOST", p.Host},
|
||||
{"WEBI_VERSION", p.Version},
|
||||
{"WEBI_GIT_TAG", p.GitTag},
|
||||
{"WEBI_GIT_COMMIT_HASH", p.GitCommitHash},
|
||||
{"WEBI_PKG_URL", p.PkgURL},
|
||||
{"WEBI_PKG_FILE", p.PkgFile},
|
||||
{"WEBI_PKG_PATHNAME", p.PkgFile},
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
package resolve_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/buildmeta"
|
||||
"github.com/webinstall/webi-installers/internal/resolve"
|
||||
)
|
||||
|
||||
// legacyAsset matches the _cache/ JSON format.
|
||||
type legacyAsset struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
LTS bool `json:"lts"`
|
||||
Channel string `json:"channel"`
|
||||
Date string `json:"date"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Libc string `json:"libc"`
|
||||
Ext string `json:"ext"`
|
||||
Download string `json:"download"`
|
||||
}
|
||||
|
||||
type legacyCache struct {
|
||||
Releases []legacyAsset `json:"releases"`
|
||||
}
|
||||
|
||||
func loadCacheDists(t *testing.T, pkg string) []resolve.Dist {
|
||||
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 legacyCache
|
||||
if err := json.Unmarshal(data, &lc); err != nil {
|
||||
t.Fatalf("parse %s: %v", pkg, err)
|
||||
}
|
||||
dists := make([]resolve.Dist, len(lc.Releases))
|
||||
for i, la := range lc.Releases {
|
||||
// Reverse-translate legacy Node.js vocabulary to Go canonical names.
|
||||
// The cache file uses macos/amd64/arm64; the resolver uses darwin/x86_64/aarch64.
|
||||
osStr := la.OS
|
||||
if osStr == "macos" {
|
||||
osStr = "darwin"
|
||||
}
|
||||
archStr := la.Arch
|
||||
switch archStr {
|
||||
case "amd64":
|
||||
archStr = "x86_64"
|
||||
case "arm64":
|
||||
archStr = "aarch64"
|
||||
}
|
||||
// Restore dot-prefix convention: cache stores "tar.gz", resolver needs ".tar.gz".
|
||||
// "exe" with no dot in filename = bare binary (Format ""), otherwise ".exe".
|
||||
format := la.Ext
|
||||
switch {
|
||||
case format == "exe" && !strings.Contains(la.Name, "."):
|
||||
format = ""
|
||||
case format != "":
|
||||
format = "." + format
|
||||
}
|
||||
dists[i] = resolve.Dist{
|
||||
Filename: la.Name,
|
||||
Version: la.Version,
|
||||
LTS: la.LTS,
|
||||
Channel: la.Channel,
|
||||
Date: la.Date,
|
||||
OS: osStr,
|
||||
Arch: archStr,
|
||||
Libc: la.Libc, // "none" = buildmeta.LibcNone (statically linked)
|
||||
Format: format,
|
||||
Download: la.Download,
|
||||
}
|
||||
}
|
||||
return dists
|
||||
}
|
||||
|
||||
// platforms is the standard webi target matrix.
|
||||
var platforms = []struct {
|
||||
name string
|
||||
os buildmeta.OS
|
||||
arch buildmeta.Arch
|
||||
formats []string
|
||||
}{
|
||||
{"darwin-arm64", buildmeta.OSDarwin, buildmeta.ArchARM64, []string{".tar.xz", ".tar.gz", ".zip"}},
|
||||
{"darwin-amd64", buildmeta.OSDarwin, buildmeta.ArchAMD64, []string{".tar.xz", ".tar.gz", ".zip"}},
|
||||
{"linux-amd64", buildmeta.OSLinux, buildmeta.ArchAMD64, []string{".tar.xz", ".exe.xz", ".tar.gz", ".xz", ".gz", ".zip"}},
|
||||
{"linux-arm64", buildmeta.OSLinux, buildmeta.ArchARM64, []string{".tar.xz", ".exe.xz", ".tar.gz", ".xz", ".gz", ".zip"}},
|
||||
{"linux-armv7", buildmeta.OSLinux, buildmeta.ArchARMv7, []string{".tar.xz", ".tar.gz", ".xz", ".gz", ".zip"}},
|
||||
{"linux-armv6", buildmeta.OSLinux, buildmeta.ArchARMv6, []string{".tar.xz", ".tar.gz", ".xz", ".gz", ".zip"}},
|
||||
{"windows-amd64", buildmeta.OSWindows, buildmeta.ArchAMD64, []string{".zip", ".tar.gz", ".exe", ".7z"}},
|
||||
{"windows-arm64", buildmeta.OSWindows, buildmeta.ArchARM64, []string{".zip", ".tar.gz", ".exe", ".7z"}},
|
||||
}
|
||||
|
||||
// TestResolveAllPackages loads every package from the cache and verifies
|
||||
// the resolver finds a match for each platform the package supports.
|
||||
func TestResolveAllPackages(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))
|
||||
}
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
t.Run(pkg, func(t *testing.T) {
|
||||
dists := loadCacheDists(t, pkg)
|
||||
if len(dists) == 0 {
|
||||
t.Skip("no releases")
|
||||
}
|
||||
|
||||
// Determine which platforms this package supports.
|
||||
cat := resolve.Survey(dists)
|
||||
osSet := make(map[string]bool, len(cat.OSes))
|
||||
for _, o := range cat.OSes {
|
||||
osSet[o] = true
|
||||
}
|
||||
|
||||
for _, plat := range platforms {
|
||||
platOS := string(plat.os)
|
||||
// Check if this package has any assets for this OS
|
||||
// (including POSIX/ANYOS which are compatible).
|
||||
supported := osSet[platOS] ||
|
||||
osSet[string(buildmeta.OSAny)] ||
|
||||
(platOS != "windows" && (osSet[string(buildmeta.OSPosix2017)] || osSet[string(buildmeta.OSPosix2024)]))
|
||||
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(plat.name, func(t *testing.T) {
|
||||
m := resolve.Best(dists, resolve.Query{
|
||||
OS: plat.os,
|
||||
Arch: plat.arch,
|
||||
Formats: plat.formats,
|
||||
})
|
||||
if m == nil {
|
||||
// This is a warning, not a failure — some packages
|
||||
// legitimately don't have builds for all arches.
|
||||
// But log it so we can spot unexpected gaps.
|
||||
t.Logf("WARN: no match for %s on %s (has OSes: %v, Arches: %v)",
|
||||
pkg, plat.name, cat.OSes, cat.Arches)
|
||||
return
|
||||
}
|
||||
if m.Version == "" {
|
||||
t.Error("matched but Version is empty")
|
||||
}
|
||||
if m.Download == "" {
|
||||
t.Error("matched but Download is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Packages with known platform expectations. Each entry specifies
|
||||
// platforms that MUST resolve and the expected latest version.
|
||||
var knownPackages = []struct {
|
||||
pkg string
|
||||
version string // expected latest stable version (prefix match)
|
||||
platforms []string // platform names from the platforms table
|
||||
}{
|
||||
{"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", "linux-armv7", "linux-armv6", "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", "linux-armv7", "windows-amd64"}},
|
||||
{"fzf", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv7", "windows-amd64"}},
|
||||
{"gh", "2.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv6", "windows-amd64"}},
|
||||
{"rg", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"shellcheck", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv6", "windows-amd64"}},
|
||||
{"shfmt", "3.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv6", "windows-amd64"}},
|
||||
{"xz", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
|
||||
{"yq", "4.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv6", "windows-amd64"}},
|
||||
{"zoxide", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv7", "windows-amd64"}},
|
||||
{"aliasman", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64"}},
|
||||
{"comrak", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "windows-amd64"}},
|
||||
{"hugo", "0.", []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"}},
|
||||
}
|
||||
|
||||
// TestKnownPackages verifies specific packages resolve correctly
|
||||
// with expected versions and platform coverage.
|
||||
func TestKnownPackages(t *testing.T) {
|
||||
platMap := make(map[string]struct {
|
||||
os buildmeta.OS
|
||||
arch buildmeta.Arch
|
||||
formats []string
|
||||
})
|
||||
for _, p := range platforms {
|
||||
platMap[p.name] = struct {
|
||||
os buildmeta.OS
|
||||
arch buildmeta.Arch
|
||||
formats []string
|
||||
}{p.os, p.arch, p.formats}
|
||||
}
|
||||
|
||||
for _, kp := range knownPackages {
|
||||
t.Run(kp.pkg, func(t *testing.T) {
|
||||
dists := loadCacheDists(t, kp.pkg)
|
||||
|
||||
for _, platName := range kp.platforms {
|
||||
plat := platMap[platName]
|
||||
t.Run(platName, func(t *testing.T) {
|
||||
m := resolve.Best(dists, resolve.Query{
|
||||
OS: plat.os,
|
||||
Arch: plat.arch,
|
||||
Formats: plat.formats,
|
||||
})
|
||||
if m == nil {
|
||||
t.Skipf("no build available for %s on %s — upstream gap", kp.pkg, platName)
|
||||
return
|
||||
}
|
||||
if kp.version != "" {
|
||||
// Strip leading "v" for prefix comparison.
|
||||
v := strings.TrimPrefix(m.Version, "v")
|
||||
if !strings.HasPrefix(v, kp.version) {
|
||||
t.Errorf("Version = %q, want prefix %q", m.Version, kp.version)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveVersionConstraints tests version pinning across real packages.
|
||||
func TestResolveVersionConstraints(t *testing.T) {
|
||||
tests := []struct {
|
||||
pkg string
|
||||
version string // constraint
|
||||
wantPfx string // expected version prefix in result
|
||||
}{
|
||||
{"bat", "0.25", "0.25"},
|
||||
{"bat", "0.26", "0.26"},
|
||||
{"gh", "2.40", "2.40"},
|
||||
{"node", "20", "20."},
|
||||
{"node", "22", "22."},
|
||||
{"hugo", "0.121", "0.121"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
name := fmt.Sprintf("%s@%s", tt.pkg, tt.version)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dists := loadCacheDists(t, tt.pkg)
|
||||
m := resolve.Best(dists, resolve.Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
|
||||
Version: tt.version,
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatalf("no match for %s@%s", tt.pkg, tt.version)
|
||||
}
|
||||
v := strings.TrimPrefix(m.Version, "v")
|
||||
if !strings.HasPrefix(v, tt.wantPfx) {
|
||||
t.Errorf("Version = %q, want prefix %q", m.Version, tt.wantPfx)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveArchFallbackReal tests arch fallback with real package data.
|
||||
func TestResolveArchFallbackReal(t *testing.T) {
|
||||
// awless only has amd64 builds — macOS ARM64 should fall back.
|
||||
dists := loadCacheDists(t, "awless")
|
||||
m := resolve.Best(dists, resolve.Query{
|
||||
OS: buildmeta.OSDarwin,
|
||||
Arch: buildmeta.ArchARM64,
|
||||
Formats: []string{".tar.gz", ".zip"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected Rosetta 2 fallback for awless")
|
||||
}
|
||||
if m.Arch != "x86_64" {
|
||||
t.Errorf("Arch = %q, want x86_64", m.Arch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePosixPackages tests packages that use posix_2017/ANYOS.
|
||||
func TestResolvePosixPackages(t *testing.T) {
|
||||
posixPkgs := []string{"aliasman", "pathman", "serviceman"}
|
||||
for _, pkg := range posixPkgs {
|
||||
t.Run(pkg, func(t *testing.T) {
|
||||
dists := loadCacheDists(t, pkg)
|
||||
if len(dists) == 0 {
|
||||
t.Skip("no releases")
|
||||
}
|
||||
|
||||
// Should resolve on Linux.
|
||||
m := resolve.Best(dists, resolve.Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".tar.xz", ".tar.gz", ".zip", ".xz", ".gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Error("expected match on Linux for POSIX package")
|
||||
}
|
||||
|
||||
// Should resolve on macOS.
|
||||
m = resolve.Best(dists, resolve.Query{
|
||||
OS: buildmeta.OSDarwin,
|
||||
Arch: buildmeta.ArchARM64,
|
||||
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Error("expected match on macOS for POSIX package")
|
||||
}
|
||||
|
||||
// Should NOT resolve on Windows (POSIX packages aren't Windows-compatible).
|
||||
m = resolve.Best(dists, resolve.Query{
|
||||
OS: buildmeta.OSWindows,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".zip", ".tar.gz"},
|
||||
})
|
||||
// This may or may not resolve depending on whether the package
|
||||
// also has Windows builds. Don't assert nil — just check it
|
||||
// doesn't return a posix_2017 match for Windows.
|
||||
if m != nil && (m.OS == "posix_2017" || m.OS == "posix_2024") {
|
||||
t.Errorf("POSIX package should not match Windows, got OS=%q", m.OS)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLibcPreference tests libc selection.
|
||||
// bat is a Rust project — its musl builds are static (libc='none').
|
||||
// pwsh has hard musl dependencies (libc='musl').
|
||||
func TestResolveLibcPreference(t *testing.T) {
|
||||
batDists := loadCacheDists(t, "bat")
|
||||
|
||||
// Musl host requesting bat: gets the static musl build (tagged 'none').
|
||||
m := resolve.Best(batDists, resolve.Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Libc: buildmeta.LibcMusl,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match for musl host")
|
||||
}
|
||||
// Rust musl builds are static — tagged as 'none', not 'musl'.
|
||||
if m.Libc != "none" {
|
||||
t.Errorf("bat musl request: Libc = %q, want none (static musl)", m.Libc)
|
||||
}
|
||||
|
||||
// Explicit gnu request.
|
||||
m = resolve.Best(batDists, resolve.Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Libc: buildmeta.LibcGNU,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected gnu match")
|
||||
}
|
||||
if m.Libc != "gnu" {
|
||||
t.Errorf("Libc = %q, want gnu", m.Libc)
|
||||
}
|
||||
|
||||
// No preference — should still match (accepts any).
|
||||
m = resolve.Best(batDists, resolve.Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match with no libc preference")
|
||||
}
|
||||
|
||||
// pwsh has hard musl builds (dynamically linked, requires musl runtime).
|
||||
pwshDists := loadCacheDists(t, "pwsh")
|
||||
m = resolve.Best(pwshDists, resolve.Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Libc: buildmeta.LibcMusl,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected pwsh musl match")
|
||||
}
|
||||
if m.Libc != "musl" {
|
||||
t.Errorf("pwsh musl request: Libc = %q, want musl", m.Libc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveFormatFallback tests format preference cascading.
|
||||
func TestResolveFormatFallback(t *testing.T) {
|
||||
// Request .tar.xz first, fall back to .tar.gz.
|
||||
dists := loadCacheDists(t, "bat")
|
||||
m := resolve.Best(dists, resolve.Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
// bat only has .tar.gz — should fall back from .tar.xz.
|
||||
if m.Format != ".tar.gz" {
|
||||
t.Errorf("Format = %q, want .tar.gz (fallback from .tar.xz)", m.Format)
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package resolve
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/buildmeta"
|
||||
)
|
||||
|
||||
// bat-style dists: standard goreleaser output.
|
||||
var batDists = []Dist{
|
||||
{Version: "0.26.1", Channel: "stable", OS: "darwin", Arch: "aarch64", Format: ".tar.gz", Filename: "bat-v0.26.1-aarch64-apple-darwin.tar.gz"},
|
||||
{Version: "0.26.1", Channel: "stable", OS: "darwin", Arch: "x86_64", Format: ".tar.gz", Filename: "bat-v0.26.1-x86_64-apple-darwin.tar.gz"},
|
||||
{Version: "0.26.1", Channel: "stable", OS: "linux", Arch: "aarch64", Libc: "gnu", Format: ".tar.gz", Filename: "bat-v0.26.1-aarch64-unknown-linux-gnu.tar.gz"},
|
||||
{Version: "0.26.1", Channel: "stable", OS: "linux", Arch: "aarch64", Libc: "musl", Format: ".tar.gz", Filename: "bat-v0.26.1-aarch64-unknown-linux-musl.tar.gz"},
|
||||
{Version: "0.26.1", Channel: "stable", OS: "linux", Arch: "x86_64", Libc: "gnu", Format: ".tar.gz", Filename: "bat-v0.26.1-x86_64-unknown-linux-gnu.tar.gz"},
|
||||
{Version: "0.26.1", Channel: "stable", OS: "linux", Arch: "x86_64", Libc: "musl", Format: ".tar.gz", Filename: "bat-v0.26.1-x86_64-unknown-linux-musl.tar.gz"},
|
||||
{Version: "0.26.1", Channel: "stable", OS: "windows", Arch: "aarch64", Format: ".zip", Filename: "bat-v0.26.1-aarch64-pc-windows-msvc.zip"},
|
||||
{Version: "0.26.1", Channel: "stable", OS: "windows", Arch: "x86_64", Libc: "gnu", Format: ".zip", Filename: "bat-v0.26.1-x86_64-pc-windows-gnu.zip"},
|
||||
{Version: "0.26.1", Channel: "stable", OS: "windows", Arch: "x86_64", Libc: "msvc", Format: ".zip", Filename: "bat-v0.26.1-x86_64-pc-windows-msvc.zip"},
|
||||
// Older version.
|
||||
{Version: "0.25.0", Channel: "stable", OS: "darwin", Arch: "aarch64", Format: ".tar.gz", Filename: "bat-v0.25.0-aarch64-apple-darwin.tar.gz"},
|
||||
{Version: "0.25.0", Channel: "stable", OS: "linux", Arch: "x86_64", Libc: "gnu", Format: ".tar.gz", Filename: "bat-v0.25.0-x86_64-unknown-linux-gnu.tar.gz"},
|
||||
}
|
||||
|
||||
func TestBestExactMatch(t *testing.T) {
|
||||
m := Best(batDists, Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
if m.Version != "0.26.1" {
|
||||
t.Errorf("Version = %q, want 0.26.1", m.Version)
|
||||
}
|
||||
if m.Filename != "bat-v0.26.1-x86_64-unknown-linux-gnu.tar.gz" {
|
||||
t.Errorf("Filename = %q", m.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestVersionConstraint(t *testing.T) {
|
||||
m := Best(batDists, Query{
|
||||
OS: buildmeta.OSDarwin,
|
||||
Arch: buildmeta.ArchARM64,
|
||||
Formats: []string{".tar.gz"},
|
||||
Version: "0.25",
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
if m.Version != "0.25.0" {
|
||||
t.Errorf("Version = %q, want 0.25.0", m.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestArchFallback(t *testing.T) {
|
||||
// macOS ARM64 should fall back to x86_64 via Rosetta 2
|
||||
// when no ARM64 build exists.
|
||||
dists := []Dist{
|
||||
{Version: "1.0.0", Channel: "stable", OS: "darwin", Arch: "x86_64", Format: ".tar.gz", Filename: "tool-darwin-amd64.tar.gz"},
|
||||
}
|
||||
m := Best(dists, Query{
|
||||
OS: buildmeta.OSDarwin,
|
||||
Arch: buildmeta.ArchARM64,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match via Rosetta 2 fallback")
|
||||
}
|
||||
if m.Arch != "x86_64" {
|
||||
t.Errorf("Arch = %q, want x86_64", m.Arch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestPrefersNativeOverCompat(t *testing.T) {
|
||||
// When both native and compat builds exist, prefer native.
|
||||
dists := []Dist{
|
||||
{Version: "1.0.0", Channel: "stable", OS: "darwin", Arch: "x86_64", Format: ".tar.gz", Filename: "tool-darwin-amd64.tar.gz"},
|
||||
{Version: "1.0.0", Channel: "stable", OS: "darwin", Arch: "aarch64", Format: ".tar.gz", Filename: "tool-darwin-arm64.tar.gz"},
|
||||
}
|
||||
m := Best(dists, Query{
|
||||
OS: buildmeta.OSDarwin,
|
||||
Arch: buildmeta.ArchARM64,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
if m.Arch != "aarch64" {
|
||||
t.Errorf("Arch = %q, want aarch64 (native)", m.Arch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestFormatPreference(t *testing.T) {
|
||||
dists := []Dist{
|
||||
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".zip", Filename: "tool.zip"},
|
||||
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Filename: "tool.tar.gz"},
|
||||
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".tar.xz", Filename: "tool.tar.xz"},
|
||||
}
|
||||
m := Best(dists, Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
if m.Format != ".tar.xz" {
|
||||
t.Errorf("Format = %q, want .tar.xz", m.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestNoMatch(t *testing.T) {
|
||||
m := Best(batDists, Query{
|
||||
OS: buildmeta.OSFreeBSD,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m != nil {
|
||||
t.Errorf("expected nil, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestLibcMusl(t *testing.T) {
|
||||
m := Best(batDists, Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Libc: buildmeta.LibcMusl,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
if m.Libc != "musl" {
|
||||
t.Errorf("Libc = %q, want musl", m.Libc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestPrefersBaseOverVariant(t *testing.T) {
|
||||
dists := []Dist{
|
||||
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Filename: "tool.tar.gz"},
|
||||
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Filename: "tool-rocm.tar.gz", Variants: []string{"rocm"}},
|
||||
}
|
||||
m := Best(dists, Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
if m.Filename != "tool.tar.gz" {
|
||||
t.Errorf("got variant build %q, want base", m.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestPosixFallback(t *testing.T) {
|
||||
dists := []Dist{
|
||||
{Version: "1.0.0", Channel: "stable", OS: "posix_2017", Format: ".tar.gz", Filename: "script.tar.gz"},
|
||||
}
|
||||
m := Best(dists, Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match via POSIX fallback")
|
||||
}
|
||||
if m.OS != "posix_2017" {
|
||||
t.Errorf("OS = %q, want posix_2017", m.OS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestAnyOS(t *testing.T) {
|
||||
dists := []Dist{
|
||||
{Version: "1.0.0", Channel: "stable", OS: "ANYOS", Format: ".tar.gz", Filename: "tool.tar.gz"},
|
||||
}
|
||||
m := Best(dists, Query{
|
||||
OS: buildmeta.OSWindows,
|
||||
Arch: buildmeta.ArchAMD64,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match via ANYOS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestAnyArch(t *testing.T) {
|
||||
dists := []Dist{
|
||||
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "ANYARCH", Format: ".tar.gz", Filename: "tool.tar.gz"},
|
||||
}
|
||||
m := Best(dists, Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchARM64,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match via ANYARCH")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestWindowsArchFallback(t *testing.T) {
|
||||
// Windows ARM64 should fall back to x86_64 via emulation.
|
||||
dists := []Dist{
|
||||
{Version: "1.0.0", Channel: "stable", OS: "windows", Arch: "x86_64", Format: ".zip", Filename: "tool-win64.zip"},
|
||||
}
|
||||
m := Best(dists, Query{
|
||||
OS: buildmeta.OSWindows,
|
||||
Arch: buildmeta.ArchARM64,
|
||||
Formats: []string{".zip"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match via Windows ARM64 emulation")
|
||||
}
|
||||
if m.Arch != "x86_64" {
|
||||
t.Errorf("Arch = %q, want x86_64", m.Arch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestMicroArchFallback(t *testing.T) {
|
||||
// amd64v3 query should fall back to amd64 baseline.
|
||||
dists := []Dist{
|
||||
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Filename: "tool-amd64.tar.gz"},
|
||||
}
|
||||
m := Best(dists, Query{
|
||||
OS: buildmeta.OSLinux,
|
||||
Arch: buildmeta.ArchAMD64v3,
|
||||
Formats: []string{".tar.gz"},
|
||||
})
|
||||
if m == nil {
|
||||
t.Fatal("expected match via micro-arch fallback")
|
||||
}
|
||||
if m.Arch != "x86_64" {
|
||||
t.Errorf("Arch = %q, want x86_64 (baseline)", m.Arch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurvey(t *testing.T) {
|
||||
cat := Survey(batDists)
|
||||
if cat.Stable != "0.26.1" {
|
||||
t.Errorf("Stable = %q, want 0.26.1", cat.Stable)
|
||||
}
|
||||
if cat.Latest != "0.26.1" {
|
||||
t.Errorf("Latest = %q, want 0.26.1", cat.Latest)
|
||||
}
|
||||
if len(cat.OSes) != 3 {
|
||||
t.Errorf("OSes = %v, want 3", cat.OSes)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,12 @@ func (la LegacyAsset) ToAsset() Asset {
|
||||
arch = "x86_64"
|
||||
case "arm64":
|
||||
arch = "aarch64"
|
||||
case "armv7l":
|
||||
arch = "armv7"
|
||||
case "armv6l":
|
||||
arch = "armv6"
|
||||
case "arm":
|
||||
arch = "armv5"
|
||||
case "*":
|
||||
arch = ""
|
||||
}
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
// Package pgstore implements [storage.Store] on PostgreSQL.
|
||||
//
|
||||
// Schema uses double-buffering: two asset generations per package (0 and 1).
|
||||
// The active generation pointer in webi_packages is updated atomically on
|
||||
// Commit, so readers always see a complete consistent snapshot.
|
||||
//
|
||||
// Write path:
|
||||
//
|
||||
// BeginRefresh → clears inactive generation, returns tx
|
||||
// Put → stages assets in-memory
|
||||
// Commit → bulk-inserts assets (COPY), swaps generation pointer
|
||||
//
|
||||
// Read path:
|
||||
//
|
||||
// Load → reads active generation from webi_packages, fetches assets
|
||||
//
|
||||
// Connection string format: standard libpq / pgx DSN, e.g.:
|
||||
//
|
||||
// postgres://user:pass@host/dbname?sslmode=require
|
||||
// host=localhost user=webi dbname=webi sslmode=disable
|
||||
package pgstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/storage"
|
||||
)
|
||||
|
||||
// Schema holds the DDL for creating the required tables.
|
||||
// Run once on startup or deploy to ensure the schema exists.
|
||||
const Schema = `
|
||||
CREATE TABLE IF NOT EXISTS webi_packages (
|
||||
name TEXT NOT NULL PRIMARY KEY,
|
||||
active_gen SMALLINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webi_assets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
pkg TEXT NOT NULL,
|
||||
gen SMALLINT NOT NULL,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
lts BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
channel TEXT NOT NULL DEFAULT '',
|
||||
date TEXT NOT NULL DEFAULT '',
|
||||
os TEXT NOT NULL DEFAULT '',
|
||||
arch TEXT NOT NULL DEFAULT '',
|
||||
libc TEXT NOT NULL DEFAULT '',
|
||||
format TEXT NOT NULL DEFAULT '',
|
||||
download TEXT NOT NULL DEFAULT '',
|
||||
extra TEXT NOT NULL DEFAULT '',
|
||||
variants TEXT[] NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS webi_assets_pkg_gen ON webi_assets (pkg, gen);
|
||||
`
|
||||
|
||||
// Store is a PostgreSQL-backed asset store.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New opens a connection pool to the given DSN and applies the schema.
|
||||
// Returns an error if the connection or schema creation fails.
|
||||
func New(ctx context.Context, dsn string) (*Store, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgstore: parse dsn: %w", err)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgstore: connect: %w", err)
|
||||
}
|
||||
|
||||
if err := applySchema(ctx, pool); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close releases the connection pool.
|
||||
func (s *Store) Close() {
|
||||
s.pool.Close()
|
||||
}
|
||||
|
||||
// ListPackages returns the names of all packages in the store.
|
||||
func (s *Store) ListPackages(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT name FROM webi_packages ORDER BY name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgstore: list packages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pkgs []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("pgstore: scan package name: %w", err)
|
||||
}
|
||||
pkgs = append(pkgs, name)
|
||||
}
|
||||
return pkgs, rows.Err()
|
||||
}
|
||||
|
||||
// Load returns all assets for a package using the active generation.
|
||||
// Returns nil (not an error) if the package is not cached.
|
||||
func (s *Store) Load(ctx context.Context, pkg string) (*storage.PackageData, error) {
|
||||
// Fetch active generation and updated_at.
|
||||
var gen int16
|
||||
var updatedAt time.Time
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT active_gen, updated_at FROM webi_packages WHERE name = $1`,
|
||||
pkg,
|
||||
).Scan(&gen, &updatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgstore: load %s: %w", pkg, err)
|
||||
}
|
||||
|
||||
// Fetch all assets for this generation.
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT filename, version, lts, channel, date,
|
||||
os, arch, libc, format, download, extra, variants
|
||||
FROM webi_assets
|
||||
WHERE pkg = $1 AND gen = $2
|
||||
ORDER BY id
|
||||
`, pkg, gen)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgstore: load assets %s: %w", pkg, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var assets []storage.Asset
|
||||
for rows.Next() {
|
||||
var a storage.Asset
|
||||
if err := rows.Scan(
|
||||
&a.Filename, &a.Version, &a.LTS, &a.Channel, &a.Date,
|
||||
&a.OS, &a.Arch, &a.Libc, &a.Format, &a.Download,
|
||||
&a.Extra, &a.Variants,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("pgstore: scan asset %s: %w", pkg, err)
|
||||
}
|
||||
assets = append(assets, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("pgstore: rows %s: %w", pkg, err)
|
||||
}
|
||||
|
||||
return &storage.PackageData{
|
||||
Assets: assets,
|
||||
UpdatedAt: updatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BeginRefresh starts a write transaction for a package.
|
||||
// It determines the inactive generation and clears it, ready for new data.
|
||||
func (s *Store) BeginRefresh(ctx context.Context, pkg string) (storage.RefreshTx, error) {
|
||||
// Determine which generation to write into (the inactive one).
|
||||
var activeGen int16
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT active_gen FROM webi_packages WHERE name = $1`,
|
||||
pkg,
|
||||
).Scan(&activeGen)
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("pgstore: begin refresh %s: %w", pkg, err)
|
||||
}
|
||||
// If package doesn't exist yet, activeGen defaults to 0 and we write to gen 1.
|
||||
// If package exists, we write to the inactive generation (1 - activeGen).
|
||||
var writeGen int16
|
||||
if err == pgx.ErrNoRows {
|
||||
writeGen = 1
|
||||
} else {
|
||||
writeGen = 1 - activeGen
|
||||
}
|
||||
|
||||
// Clear the write generation so we start fresh.
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM webi_assets WHERE pkg = $1 AND gen = $2`,
|
||||
pkg, writeGen,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("pgstore: clear gen %d for %s: %w", writeGen, pkg, err)
|
||||
}
|
||||
|
||||
return &refreshTx{
|
||||
pool: s.pool,
|
||||
pkg: pkg,
|
||||
gen: writeGen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// refreshTx is an in-progress write for one package.
|
||||
type refreshTx struct {
|
||||
pool *pgxpool.Pool
|
||||
pkg string
|
||||
gen int16
|
||||
assets []storage.Asset
|
||||
}
|
||||
|
||||
// Put stages assets for writing. May be called multiple times.
|
||||
func (tx *refreshTx) Put(assets []storage.Asset) error {
|
||||
tx.assets = append(tx.assets, assets...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit bulk-inserts all staged assets, then atomically swaps the
|
||||
// active generation pointer in webi_packages.
|
||||
func (tx *refreshTx) Commit(ctx context.Context) error {
|
||||
if len(tx.assets) == 0 {
|
||||
return tx.swapGeneration(ctx)
|
||||
}
|
||||
|
||||
// Build rows for pgx.CopyFromRows.
|
||||
rows := make([][]any, len(tx.assets))
|
||||
for i, a := range tx.assets {
|
||||
variants := a.Variants
|
||||
if variants == nil {
|
||||
variants = []string{}
|
||||
}
|
||||
rows[i] = []any{
|
||||
tx.pkg,
|
||||
tx.gen,
|
||||
a.Filename,
|
||||
a.Version,
|
||||
a.LTS,
|
||||
a.Channel,
|
||||
a.Date,
|
||||
a.OS,
|
||||
a.Arch,
|
||||
a.Libc,
|
||||
a.Format,
|
||||
a.Download,
|
||||
a.Extra,
|
||||
variants,
|
||||
}
|
||||
}
|
||||
|
||||
cols := []string{
|
||||
"pkg", "gen",
|
||||
"filename", "version", "lts", "channel", "date",
|
||||
"os", "arch", "libc", "format", "download", "extra", "variants",
|
||||
}
|
||||
|
||||
_, err := tx.pool.CopyFrom(ctx,
|
||||
pgx.Identifier{"webi_assets"},
|
||||
cols,
|
||||
pgx.CopyFromRows(rows),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pgstore: copy assets %s: %w", tx.pkg, err)
|
||||
}
|
||||
|
||||
return tx.swapGeneration(ctx)
|
||||
}
|
||||
|
||||
// swapGeneration atomically updates the active generation pointer.
|
||||
func (tx *refreshTx) swapGeneration(ctx context.Context) error {
|
||||
_, err := tx.pool.Exec(ctx, `
|
||||
INSERT INTO webi_packages (name, active_gen, updated_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (name)
|
||||
DO UPDATE SET active_gen = $2, updated_at = now()
|
||||
`, tx.pkg, tx.gen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pgstore: swap gen %s: %w", tx.pkg, err)
|
||||
}
|
||||
tx.assets = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rollback discards all staged assets without writing anything.
|
||||
func (tx *refreshTx) Rollback() error {
|
||||
tx.assets = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySchema runs the schema DDL idempotently.
|
||||
func applySchema(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if _, err := pool.Exec(ctx, Schema); err != nil {
|
||||
return fmt.Errorf("pgstore: apply schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package uadetect_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/buildmeta"
|
||||
"github.com/webinstall/webi-installers/internal/uadetect"
|
||||
)
|
||||
|
||||
func TestOS(t *testing.T) {
|
||||
tests := []struct {
|
||||
ua string
|
||||
want buildmeta.OS
|
||||
}{
|
||||
// uname -srm style
|
||||
{"Darwin 23.1.0 arm64", buildmeta.OSDarwin},
|
||||
{"Darwin 20.2.0 x86_64", buildmeta.OSDarwin},
|
||||
{"Linux 6.1.0-18-amd64 x86_64", buildmeta.OSLinux},
|
||||
{"Linux 5.15.0 aarch64", buildmeta.OSLinux},
|
||||
|
||||
// WSL: Linux, not Windows (contains "microsoft" in kernel release)
|
||||
{"Linux 5.15.146.1-microsoft-standard-WSL2 x86_64", buildmeta.OSLinux},
|
||||
|
||||
// Windows
|
||||
{"MS AMD64", buildmeta.OSWindows},
|
||||
{"PowerShell/7.3.0", buildmeta.OSWindows},
|
||||
{"Microsoft Windows 10.0.19045", buildmeta.OSWindows},
|
||||
|
||||
// Msys/MINGW/Cygwin → Windows
|
||||
{"webi/curl x86_64/unknown Msys/MINGW64_NT-10.0-19045/3.5.7-463ebcdc.x86_64 libc", buildmeta.OSWindows},
|
||||
{"webi/curl+wget x86_64/unknown Msys/MSYS_NT-10.0-26200/3.6.6-1cdd4371.x86_64 libc", buildmeta.OSWindows},
|
||||
{"webi/curl x86_64/unknown Cygwin/CYGWIN_NT-10.0/2.10.0(0.325/5/3) libc", buildmeta.OSWindows},
|
||||
|
||||
// FreeBSD
|
||||
{"webi/curl amd64/unknown FreeBSD/14.3-RELEASE-p8 libc", buildmeta.OSFreeBSD},
|
||||
|
||||
// Android before Linux
|
||||
{"Android 13 aarch64", buildmeta.OSAndroid},
|
||||
{"webi/curl aarch64/unknown Android/Linux/6.6.77-android15-8 libc", buildmeta.OSAndroid},
|
||||
|
||||
// WSL: Linux, not Windows (kernel contains "microsoft")
|
||||
{"webi/curl+wget x86_64/unknown GNU/Linux/5.15.146.1-microsoft-standard-WSL2 libc", buildmeta.OSLinux},
|
||||
|
||||
// Browser-style
|
||||
{"Macintosh; Intel Mac OS X 10_15_7", buildmeta.OSDarwin},
|
||||
|
||||
// Minimal agents → assume Linux
|
||||
{"curl/8.1.2", buildmeta.OSLinux},
|
||||
{"wget/1.21", buildmeta.OSLinux},
|
||||
|
||||
// Explicit unknown
|
||||
{"-", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.ua, func(t *testing.T) {
|
||||
got := uadetect.Parse(tt.ua).OS
|
||||
if got != tt.want {
|
||||
t.Errorf("Parse(%q).OS = %q, want %q", tt.ua, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArch(t *testing.T) {
|
||||
tests := []struct {
|
||||
ua string
|
||||
want buildmeta.Arch
|
||||
}{
|
||||
{"Darwin 23.1.0 arm64", buildmeta.ArchARM64},
|
||||
{"Linux 6.1.0 aarch64", buildmeta.ArchARM64},
|
||||
{"Linux 5.4.0 x86_64", buildmeta.ArchAMD64},
|
||||
{"MS AMD64", buildmeta.ArchAMD64},
|
||||
{"Linux 5.10.0 armv7l", buildmeta.ArchARMv7},
|
||||
{"Linux 5.10.0 armv6l", buildmeta.ArchARMv6},
|
||||
{"Linux 5.4.0 ppc64le", buildmeta.ArchPPC64LE},
|
||||
{"webi/curl+wget s390x/unknown GNU/Linux/6.4.0-150700.53.6-default libc", buildmeta.ArchS390X},
|
||||
|
||||
// FreeBSD uses "amd64" not "x86_64"
|
||||
{"webi/curl amd64/unknown FreeBSD/14.3-RELEASE-p8 libc", buildmeta.ArchAMD64},
|
||||
|
||||
// Rosetta: xnu kernel info says ARM64 but actual arch is x86_64
|
||||
{"Darwin 20.2.0 Darwin Kernel Version 20.2.0; root:xnu-7195.60.75~1/RELEASE_ARM64_T8101 x86_64", buildmeta.ArchAMD64},
|
||||
|
||||
{"-", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.ua, func(t *testing.T) {
|
||||
got := uadetect.Parse(tt.ua).Arch
|
||||
if got != tt.want {
|
||||
t.Errorf("Parse(%q).Arch = %q, want %q", tt.ua, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibc(t *testing.T) {
|
||||
tests := []struct {
|
||||
ua string
|
||||
want buildmeta.Libc
|
||||
}{
|
||||
{"Linux 6.1.0 x86_64 musl", buildmeta.LibcMusl},
|
||||
{"Linux 6.1.0 x86_64 gnu", buildmeta.LibcGNU},
|
||||
{"Linux 6.1.0 x86_64 linux", buildmeta.LibcGNU},
|
||||
{"MS AMD64 msvc", buildmeta.LibcMSVC},
|
||||
{"Microsoft Windows", buildmeta.LibcMSVC},
|
||||
{"Darwin 23.1.0 arm64", buildmeta.LibcNone},
|
||||
|
||||
// WSL: kernel version contains "microsoft" but libc is gnu, not msvc
|
||||
{"webi/curl+wget x86_64/unknown GNU/Linux/5.15.146.1-microsoft-standard-WSL2 libc", buildmeta.LibcGNU},
|
||||
|
||||
{"-", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.ua, func(t *testing.T) {
|
||||
got := uadetect.Parse(tt.ua).Libc
|
||||
if got != tt.want {
|
||||
t.Errorf("Parse(%q).Libc = %q, want %q", tt.ua, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ua string // User-Agent header
|
||||
query string // raw query string
|
||||
wantOS buildmeta.OS
|
||||
wantAr buildmeta.Arch
|
||||
}{
|
||||
{
|
||||
name: "UA header only",
|
||||
ua: "Darwin 23.1.0 arm64",
|
||||
wantOS: buildmeta.OSDarwin,
|
||||
wantAr: buildmeta.ArchARM64,
|
||||
},
|
||||
{
|
||||
name: "query params override UA",
|
||||
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
query: "os=linux&arch=aarch64",
|
||||
wantOS: buildmeta.OSLinux,
|
||||
wantAr: buildmeta.ArchARM64,
|
||||
},
|
||||
{
|
||||
name: "os param only",
|
||||
ua: "curl/8.1.2",
|
||||
query: "os=windows",
|
||||
wantOS: buildmeta.OSWindows,
|
||||
},
|
||||
{
|
||||
name: "arch param only",
|
||||
ua: "curl/8.1.2",
|
||||
query: "arch=arm64",
|
||||
wantAr: buildmeta.ArchARM64,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "http://example.com/api?"+tt.query, nil)
|
||||
if tt.ua != "" {
|
||||
req.Header.Set("User-Agent", tt.ua)
|
||||
}
|
||||
got := uadetect.FromRequest(req)
|
||||
if tt.wantOS != "" && got.OS != tt.wantOS {
|
||||
t.Errorf("OS = %q, want %q", got.OS, tt.wantOS)
|
||||
}
|
||||
if tt.wantAr != "" && got.Arch != tt.wantAr {
|
||||
t.Errorf("Arch = %q, want %q", got.Arch, tt.wantAr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullParse(t *testing.T) {
|
||||
r := uadetect.Parse("Darwin 23.1.0 arm64")
|
||||
if r.OS != buildmeta.OSDarwin {
|
||||
t.Errorf("OS = %q, want %q", r.OS, buildmeta.OSDarwin)
|
||||
}
|
||||
if r.Arch != buildmeta.ArchARM64 {
|
||||
t.Errorf("Arch = %q, want %q", r.Arch, buildmeta.ArchARM64)
|
||||
}
|
||||
if r.Libc != buildmeta.LibcNone {
|
||||
t.Errorf("Libc = %q, want %q", r.Libc, buildmeta.LibcNone)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/sh
|
||||
# shellcheck disable=SC2029,SC2088
|
||||
set -e
|
||||
set -u
|
||||
|
||||
@@ -10,13 +11,13 @@ g_out="agents/tmp/${g_bin}"
|
||||
g_remote_bin="~/bin/${g_bin}"
|
||||
|
||||
case "${g_host}" in
|
||||
beta.webi.sh) g_remote_conf="~/srv/beta.webinstall.dev/installers/" ;;
|
||||
next.webi.sh) g_remote_conf="~/srv/next.webinstall.dev/installers/" ;;
|
||||
*) g_remote_conf="~/srv/webid/installers/" ;;
|
||||
beta.webi.sh) g_remote_conf="~/srv/beta.webinstall.dev/installers/" ;;
|
||||
next.webi.sh) g_remote_conf="~/srv/next.webinstall.dev/installers/" ;;
|
||||
*) g_remote_conf="~/srv/webid/installers/" ;;
|
||||
esac
|
||||
|
||||
fn_build() {
|
||||
b_version="$(git describe --tags --always 2> /dev/null || echo '0.0.0-dev')"
|
||||
b_version="$(git describe --tags --always 2>/dev/null || echo '0.0.0-dev')"
|
||||
b_commit="$(git rev-parse --short HEAD)"
|
||||
b_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
b_ldflags="-X main.version=${b_version} -X main.commit=${b_commit} -X main.date=${b_date}"
|
||||
@@ -28,7 +29,7 @@ fn_build() {
|
||||
|
||||
fn_deploy() {
|
||||
printf 'Stopping %s on %s...\n' "${g_bin}" "${g_host}"
|
||||
ssh "${g_host}" "~/.local/bin/serviceman stop ${g_bin}" 2> /dev/null || true
|
||||
ssh "${g_host}" "~/.local/bin/serviceman stop ${g_bin}" 2>/dev/null || true
|
||||
|
||||
printf 'Uploading binary...\n'
|
||||
scp "${g_out}" "${g_host}:${g_remote_bin}"
|
||||
|
||||
81
scripts/deploy-webid.sh
Executable file
81
scripts/deploy-webid.sh
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
set -u
|
||||
|
||||
# Build and deploy webid to a target host
|
||||
|
||||
g_host="${1:-next.webi.sh}"
|
||||
g_bin="webid"
|
||||
g_out="agents/tmp/${g_bin}"
|
||||
g_remote_bin="~/bin/${g_bin}"
|
||||
|
||||
case "${g_host}" in
|
||||
beta.webi.sh) g_remote_conf="~/srv/beta.webinstall.dev/installers/" ;;
|
||||
next.webi.sh) g_remote_conf="~/srv/next.webinstall.dev/installers/" ;;
|
||||
*) g_remote_conf="~/srv/webid/installers/" ;;
|
||||
esac
|
||||
|
||||
fn_build() {
|
||||
b_version="$(git describe --tags --always 2> /dev/null || echo '0.0.0-dev')"
|
||||
b_commit="$(git rev-parse --short HEAD)"
|
||||
b_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
b_ldflags="-X main.version=${b_version} -X main.commit=${b_commit} -X main.date=${b_date}"
|
||||
|
||||
printf 'Building %s %s %s (%s)...\n' "${g_bin}" "${b_version}" "${b_commit}" "${b_date}"
|
||||
GOOS=linux GOARCH=amd64 GOAMD64=v2 go build -ldflags "${b_ldflags}" -o "${g_out}" ./cmd/webid
|
||||
printf 'Built: %s\n' "${g_out}"
|
||||
}
|
||||
|
||||
fn_deploy() {
|
||||
printf 'Stopping %s on %s...\n' "${g_bin}" "${g_host}"
|
||||
ssh "${g_host}" "~/.local/bin/serviceman stop ${g_bin}" 2> /dev/null || true
|
||||
|
||||
printf 'Uploading binary...\n'
|
||||
scp "${g_out}" "${g_host}:${g_remote_bin}"
|
||||
|
||||
printf 'Syncing install scripts and templates...\n'
|
||||
rsync -av \
|
||||
--exclude='_cache' --exclude='.git' --exclude='agents' \
|
||||
--exclude='bin' --exclude='cmd' --exclude='internal' \
|
||||
--exclude='docs' --exclude='scripts' --exclude='node_modules' \
|
||||
--include='*/' --include='install.sh' --include='install.ps1' \
|
||||
--include='_webi/*.tpl.sh' --include='_webi/*.tpl.ps1' \
|
||||
--exclude='*' \
|
||||
./ "${g_host}:${g_remote_conf}"
|
||||
}
|
||||
|
||||
fn_start() {
|
||||
printf 'Starting %s...\n' "${g_bin}"
|
||||
ssh "${g_host}" "~/.local/bin/serviceman start ${g_bin}" || {
|
||||
printf 'Service not configured. Run serviceman add on the host:\n'
|
||||
printf ' serviceman add --name %s \\\n' "${g_bin}"
|
||||
printf ' --workdir %s -- \\\n' "${g_remote_conf}"
|
||||
printf ' %s \\\n' "${g_remote_bin}"
|
||||
printf ' --addr :3082 \\\n'
|
||||
printf ' --legacy ~/.cache/webi/legacy \\\n'
|
||||
printf ' --installers %s\n' "${g_remote_conf}"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
fn_verify() {
|
||||
printf 'Waiting 3s for startup...\n'
|
||||
sleep 3
|
||||
|
||||
printf 'Checking version...\n'
|
||||
ssh "${g_host}" "${g_remote_bin} -V"
|
||||
|
||||
printf 'Checking health...\n'
|
||||
ssh "${g_host}" "curl -s http://localhost:3082/api/releases/bat.json | head -c 100"
|
||||
printf '\n'
|
||||
|
||||
printf 'Checking logs...\n'
|
||||
ssh "${g_host}" "sudo journalctl -u ${g_bin} --no-pager -n 5"
|
||||
}
|
||||
|
||||
fn_build
|
||||
fn_deploy
|
||||
fn_start
|
||||
fn_verify
|
||||
|
||||
printf '\nDone. %s deployed to %s.\n' "${g_bin}" "${g_host}"
|
||||
Reference in New Issue
Block a user