feat(agent): Handle update package check for xbps package manager (#2522)

Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
Tomasz Machalski
2026-10-06 12:10:12 -04:00
committed by GitHub
co-authored by hank
parent 4fe1a0a7a4
commit bc2278e7e9
7 changed files with 201 additions and 5 deletions
+90
View File
@@ -24,6 +24,9 @@ const (
// pacmanSyncInterval limits how often checkupdates downloads fresh sync
// databases. Checks in between reuse the last synced copy.
pacmanSyncInterval = 12 * time.Hour
// xbpsSyncInterval limits how often xbps-install fetches remote repository
// data. Checks in between reuse the last fetched update list.
xbpsSyncInterval = 12 * time.Hour
)
// packageUpdatesResult is the outcome of one package manager check.
@@ -154,6 +157,8 @@ func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
return "pacman", newPacmanCheck(dataDir)
case commandExists("apk"):
return "apk", checkApk
case commandExists("xbps-install"):
return "xbps", newXbpsCheck()
}
return "", nil
}
@@ -509,3 +514,88 @@ func splitApkNameVersion(s string) (name, version string) {
}
return s[:ver], s[ver+1:]
}
// newXbpsCheck fetches remote repository data into memory (-M) every
// xbpsSyncInterval. The system's on-disk copy is only refreshed by xbps-install -S,
// so it is often stale, and xbps cannot keep the fetched data. Checks in between
// reuse the last fetched update list and drop packages whose installed version
// changed since, so local upgrades show up right away. xbps has no security
// metadata, so the security count is omitted.
func newXbpsCheck() packageUpdatesCheck {
// checks never overlap (packageUpdatesManager.running), so no lock is needed
var lastSync time.Time
var synced []system.PackageUpdate
return func(ctx context.Context) (packageUpdatesResult, error) {
sync := lastSync.IsZero() || time.Since(lastSync) >= xbpsSyncInterval
var updates []system.PackageUpdate
if sync {
out, err := runPackageCommand(ctx, nil, "xbps-install", "-Mun")
if err != nil {
return packageUpdatesResult{}, err
}
updates = parseXbpsSimulate(out)
}
out, err := runPackageCommand(ctx, nil, "xbps-query", "-l")
if err != nil {
return packageUpdatesResult{}, err
}
installed := parseXbpsInstalled(out)
if sync {
for i := range updates {
updates[i].Current = installed[updates[i].Name]
}
synced = updates
lastSync = time.Now()
}
var packages []system.PackageUpdate
for _, pkg := range synced {
// skip packages upgraded or removed since the last sync
if ver, ok := installed[pkg.Name]; ok && ver == pkg.Current {
packages = append(packages, pkg)
}
}
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
}
}
// parseXbpsSimulate parses upgrades in `xbps-install -Mun` output. Upgrade lines look like
// "libgbm-26.2.4_1 update x86_64 https://void.sakamoto.pl/current 18488 6762"
func parseXbpsSimulate(out string) (packages []system.PackageUpdate) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 2 || fields[1] != "update" {
continue
}
name, ver := parseXbpsNameVersion(fields[0])
packages = append(packages, system.PackageUpdate{Name: name, Available: ver})
}
return packages
}
// parseXbpsInstalled maps package names to installed versions from `xbps-query -l`
// output. Lines look like "ii xbps-0.60.7_1 XBPS package system utilities".
func parseXbpsInstalled(out string) map[string]string {
installed := make(map[string]string)
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 {
continue
}
name, ver := parseXbpsNameVersion(fields[1])
installed[name] = ver
}
return installed
}
// parseXbpsNameVersion splits name-version to tuple (name, version)
// Names may contain dashes, but versions do not.
func parseXbpsNameVersion(s string) (name, ver string) {
i := strings.LastIndexByte(s, '-')
if i < 0 {
return s, ""
}
return s[:i], s[i+1:]
}
+61
View File
@@ -191,6 +191,66 @@ func TestParseRpmInstalled(t *testing.T) {
assert.Equal(t, "5.14.0-503.el9", installed["kernel.x86_64"])
}
func TestParseXbpsSimulate(t *testing.T) {
packages := parseXbpsSimulate(readPackageUpdatesTestData(t, "xbps_void_check_update.txt"))
assert.Len(t, packages, 3)
assert.Equal(t, system.PackageUpdate{Name: "libmariadbclient", Available: "12.3.2_2"}, findPackage(t, packages, "libmariadbclient"))
packages = parseXbpsSimulate(readPackageUpdatesTestData(t, "xbps_void_install_update.txt"))
assert.Len(t, packages, 7)
assert.Equal(t, system.PackageUpdate{Name: "ca-certificates", Available: "20250419+3.127_1"}, findPackage(t, packages, "ca-certificates"))
assert.Equal(t, system.PackageUpdate{Name: "libcrypto3", Available: "3.6.5_1"}, findPackage(t, packages, "libcrypto3"))
// new dependencies are not updates
assert.Empty(t, parseXbpsSimulate("libfoo-1.0_1 install x86_64 https://repo-default.voidlinux.org/current 100 50\n"))
assert.Empty(t, parseXbpsSimulate("\n"))
}
func TestParseXbpsInstalled(t *testing.T) {
installed := parseXbpsInstalled(readPackageUpdatesTestData(t, "xbps_void_query_installed.txt"))
assert.Len(t, installed, 29)
assert.Equal(t, "0.60.7_1", installed["xbps"])
assert.Equal(t, "0.131_1", installed["xbps-triggers"])
assert.Equal(t, "20250419+3.127_1", installed["ca-certificates"])
}
func TestXbpsCheckSync(t *testing.T) {
binDir := t.TempDir()
logFile := filepath.Join(binDir, "calls.log")
installedFile := filepath.Join(binDir, "installed.txt")
writeInstalled := func(lines string) {
require.NoError(t, os.WriteFile(installedFile, []byte(lines), 0o644))
}
writeInstalled("ii bash-5.2.021_1 GNU Bourne Again Shell\nii libgbm-26.2.3_1 Mesa gbm library\n")
fakeCommands(t, map[string]string{
"xbps-install": `echo "xbps-install $*" >> ` + logFile + `
echo "bash-5.3.9_1 update x86_64 https://repo-default.voidlinux.org/current 100 50"
echo "libgbm-26.2.4_1 update x86_64 https://repo-default.voidlinux.org/current 100 50"`,
"xbps-query": `cat ` + installedFile,
})
check := newXbpsCheck()
// first check fetches remote data
result, err := check(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{2}, result.counts)
assert.False(t, result.securityKnown)
assert.Equal(t, []system.PackageUpdate{
{Name: "bash", Current: "5.2.021_1", Available: "5.3.9_1"},
{Name: "libgbm", Current: "26.2.3_1", Available: "26.2.4_1"},
}, result.packages)
// later checks reuse the fetched list and drop locally upgraded packages
writeInstalled("ii bash-5.3.9_1 GNU Bourne Again Shell\nii libgbm-26.2.3_1 Mesa gbm library\n")
result, err = check(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{1}, result.counts)
assert.Equal(t, []system.PackageUpdate{{Name: "libgbm", Current: "26.2.3_1", Available: "26.2.4_1"}}, result.packages)
data, err := os.ReadFile(logFile)
require.NoError(t, err)
assert.Equal(t, "xbps-install -Mun\n", string(data))
}
func TestCheckDnf(t *testing.T) {
tests := []struct {
name, updates, security, installed string
@@ -255,6 +315,7 @@ func TestDetectPackageManager(t *testing.T) {
{"zypper-aptitude", map[string]string{"apt-get": "", "zypper": ""}, "zypper"},
{"zypper-aptitude with dpkg", map[string]string{"apt-get": "", "dpkg-query": "exit 1", "zypper": ""}, "zypper"},
{"dnf", map[string]string{"dnf": "", "rpm": ""}, "dnf"},
{"xbps", map[string]string{"xbps-install": ""}, "xbps"},
{"none", nil, ""},
}
for _, tt := range tests {
@@ -0,0 +1,3 @@
libgbm-26.2.4_1 update x86_64 https://void.sakamoto.pl/current 18488 6762
libmariadbclient-12.3.2_2 update x86_64 https://void.sakamoto.pl/current 27749752 7203074
libpipewire-1.6.9_1 update x86_64 https://void.sakamoto.pl/current 4895536 1155261
@@ -0,0 +1,7 @@
acl-2.4.0_1 update x86_64 https://repo-default.voidlinux.org/current 43080 20653
attr-2.6.0_1 update x86_64 https://repo-default.voidlinux.org/current 27348 9495
ca-certificates-20250419+3.127_1 update x86_64 https://repo-default.voidlinux.org/current 273335 164765
libarchive-3.8.9_1 update x86_64 https://repo-default.voidlinux.org/current 817232 380316
libcrypto3-3.6.5_1 update x86_64 https://repo-default.voidlinux.org/current 6443040 2193502
libssl3-3.6.5_1 update x86_64 https://repo-default.voidlinux.org/current 1065048 444864
openssl-3.6.5_1 update x86_64 https://repo-default.voidlinux.org/current 2270125 635762
@@ -0,0 +1,29 @@
ii acl-2.4.0_1 Access Control List filesystem support
ii attr-2.6.0_1 Extended attribute support library for ACL support
ii base-files-0.146_1 Void Linux base system files
ii bzip2-1.0.8_2 Freely available, patent free, high-quality data compressor
ii ca-certificates-20250419+3.127_1 Common CA certificates for SSL/TLS from Mozilla
ii coreutils-9.4_3 GNU core utilities
ii dash-0.5.12_3 POSIX-compliant Unix shell, much smaller than GNU bash
ii gawk-5.3.2_1 GNU awk utility
ii glibc-2.41_1 GNU C library
ii gmp-6.3.0_1 Library for arbitrary precision arithmetic
ii grep-3.12_1 GNU grep utility
ii libarchive-3.8.9_1 Library to read/write several different streaming archive formats
ii libcap-2.78_1 POSIX.1e capabilities suite
ii libcrypto3-3.6.5_1 Toolkit for Secure Sockets Layer and Transport Layer Security - crypto library
ii liblz4-1.10.0_1 LZ4 compression library
ii liblzma-5.8.3_1 XZ-format compression library
ii libpcre2-10.47_1 Perl Compatible Regular Expressions (2nd version) - shared libraries
ii libreadline8-8.2.010_1 GNU Readline Library
ii libssl3-3.6.5_1 Toolkit for Secure Sockets Layer and Transport Layer Security - SSL/TLS library
ii libxbps-0.60.7_1 XBPS package system utilities - runtime library
ii libxcrypt-compat-4.4.38_1 Modern library for one-way hashing of passwords - legacy compatibility
ii libzstd-1.5.7_1 Fast real-time compression algorithm
ii ncurses-libs-6.6_1 System V Release 4.0 curses emulation library -- shared libraries
ii openssl-3.6.5_1 Toolkit for Secure Sockets Layer and Transport Layer Security
ii run-parts-5.23.2_1 Run scripts or programs in a directory
ii sed-4.10_1 GNU stream editor
ii xbps-0.60.7_1 XBPS package system utilities
ii xbps-triggers-0.131_1 XBPS triggers for Void Linux
ii zlib-1.3.2_1 Compression/decompression Library
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { classifyVersionChange } from "./package-updates"
// version pairs are taken from real apt, dnf, zypper, pacman and apk output
// version pairs are taken from real apt, dnf, zypper, pacman, apk and xbps output
test("major, minor and patch use the first differing upstream component", () => {
expect(classifyVersionChange("1.10.7-1", "2.0.0-1")).toBe("major")
expect(classifyVersionChange("1.10.7-1", "1.11.0-1")).toBe("minor")
@@ -10,6 +10,7 @@ test("major, minor and patch use the first differing upstream component", () =>
expect(classifyVersionChange("0.21.7-1", "0.21.8.2-1")).toBe("patch")
expect(classifyVersionChange("3.3.0-r2", "3.3.7-r0")).toBe("patch")
expect(classifyVersionChange("0.7.36-2.fc42", "0.7.37-2.fc42")).toBe("patch")
expect(classifyVersionChange("26.2.3_1", "26.2.4_1")).toBe("patch")
// missing components count as zero
expect(classifyVersionChange("1.2", "1.2.1")).toBe("patch")
expect(classifyVersionChange("1.2", "1.3.0")).toBe("minor")
@@ -32,6 +33,7 @@ test("revision-only changes", () => {
expect(classifyVersionChange("1.36.1-r28", "1.36.1-r31")).toBe("revision")
expect(classifyVersionChange("4.4-150400.25.22", "4.4-150400.27.3.2")).toBe("revision")
expect(classifyVersionChange("42-30", "42-31")).toBe("revision")
expect(classifyVersionChange("12.3.2_1", "12.3.2_2")).toBe("revision")
expect(
classifyVersionChange("84.87+git20180409.04c9dae-150300.10.20.1", "84.87+git20180409.04c9dae-150300.10.23.1")
).toBe("revision")
+8 -4
View File
@@ -16,7 +16,8 @@ interface SplitVersion {
/**
* Splits a distro version string into epoch, upstream version and packaging revision.
* Works for the Debian ("1:2.3.4-1ubuntu1"), RPM ("2:9.2.390-1.fc42"), pacman ("1.3.7-1")
* and apk ("1.2.5-r3") formats. The revision follows the last "-".
* apk ("1.2.5-r3") and xbps ("26.2.4_1") formats. The revision follows the last "-",
* or the last "_" for xbps, whose versions have no "-".
*/
function splitVersion(version: string): SplitVersion {
let epoch = 0
@@ -25,9 +26,12 @@ function splitVersion(version: string): SplitVersion {
epoch = Number(epochMatch[1])
version = version.slice(epochMatch[0].length)
}
const dash = version.lastIndexOf("-")
if (dash > 0) {
return { epoch, upstream: version.slice(0, dash), revision: version.slice(dash + 1) }
let sep = version.lastIndexOf("-")
if (sep < 0) {
sep = version.lastIndexOf("_")
}
if (sep > 0) {
return { epoch, upstream: version.slice(0, sep), revision: version.slice(sep + 1) }
}
return { epoch, upstream: version, revision: "" }
}