fix(agent): restore checkupdates for pacman

This commit is contained in:
sveng93
2026-10-12 20:36:50 +02:00
parent deec1d0b51
commit 32080cd200
4 changed files with 94 additions and 144 deletions
+46 -18
View File
@@ -2,7 +2,6 @@ package agent
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
@@ -23,6 +22,9 @@ import (
const (
defaultPackageUpdatesInterval = time.Hour
packageUpdatesTimeout = 5 * time.Minute
// pacmanSyncInterval limits how often checkupdates downloads fresh sync
// databases. Checks in between reuse the last synced copy.
pacmanSyncInterval = 12 * time.Hour
)
// packageUpdatesResult is the outcome of one package manager check.
@@ -150,8 +152,8 @@ func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
return "dnf", newDnfCheck(dataDir)
case commandExists("zypper"):
return "zypper", newZypperCheck(dataDir)
case commandExists("pacman"):
return "pacman", checkPacman
case commandExists("checkupdates"):
return "pacman", newPacmanCheck(dataDir)
case commandExists("apk"):
return "apk", checkApk
}
@@ -174,8 +176,8 @@ func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C")
cmd.Env = append(cmd.Env, env...)
// a timeout kills only the command itself, and children it started can keep
// stdout open. WaitDelay stops Output from waiting on them.
// checkupdates is a shell script, so a timeout kills only the script and its
// children can keep stdout open. WaitDelay stops Output from waiting on them.
cmd.WaitDelay = 10 * time.Second
out, err := cmd.Output()
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
@@ -302,19 +304,45 @@ func newZypperCheck(dataDir string) packageUpdatesCheck {
}
}
// checkPacman compares installed packages against the system sync databases.
// It never syncs them; pacman -Sy(u) by the user or a timer does that.
func checkPacman(ctx context.Context) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, nil, "pacman", "-Qu")
// -Qu exits 1 with no output when nothing is upgradable; errors print to stderr
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 && len(bytes.TrimSpace(exitErr.Stderr)) == 0 {
err = nil
// newPacmanCheck uses checkupdates (pacman-contrib), which syncs a private copy of
// the databases and never touches pacman's own. The copy lives in dataDir because
// the systemd unit's ProtectSystem=strict makes the default /tmp location read-only.
// It syncs every pacmanSyncInterval and uses the existing copy (-n) in between.
// Local upgrades show up right away since checkupdates links the live local DB.
// Exit code 2 means no updates.
func newPacmanCheck(dataDir string) packageUpdatesCheck {
var env []string
var syncDir string
if dataDir != "" {
dbPath := filepath.Join(dataDir, "checkup-db")
env = []string{"CHECKUPDATES_DB=" + dbPath}
syncDir = filepath.Join(dbPath, "sync")
}
if err != nil {
return packageUpdatesResult{}, err
// checks never overlap (packageUpdatesManager.running), so no lock is needed
var lastSync time.Time
return func(ctx context.Context) (packageUpdatesResult, error) {
// -n with a missing database reports no updates rather than failing,
// so always sync first and whenever the private copy is missing
sync := lastSync.IsZero() || time.Since(lastSync) >= pacmanSyncInterval
if !sync && syncDir != "" {
if _, err := os.Stat(syncDir); err != nil {
sync = true
}
}
var args []string
if !sync {
args = append(args, "-n")
}
out, err := runPackageCommandEnv(ctx, env, []int{2}, "checkupdates", args...)
if err != nil {
return packageUpdatesResult{}, err
}
if sync {
lastSync = time.Now()
}
packages := parsePacmanCheckUpdates(out)
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
}
packages := parsePacmanQueryUpgrades(out)
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
}
func checkApk(ctx context.Context) (packageUpdatesResult, error) {
@@ -469,8 +497,8 @@ func parseZypperListUpdates(out string) (packages []system.PackageUpdate) {
return packages
}
// parsePacmanQueryUpgrades parses "name old -> new" lines, which may end in "[ignored]".
func parsePacmanQueryUpgrades(out string) (packages []system.PackageUpdate) {
// parsePacmanCheckUpdates parses "name old -> new" lines.
func parsePacmanCheckUpdates(out string) (packages []system.PackageUpdate) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
+44 -26
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -340,15 +341,14 @@ case "$*" in *list-patches*) cat "` + testDataPath(t, "zypper_leap155_list_patch
"--no-refresh -q --solv-cache-dir "+solvDir+" list-patches --category security\n", string(args))
}
func TestParsePacmanQueryUpgrades(t *testing.T) {
func TestParsePacmanCheckUpdates(t *testing.T) {
assert.Equal(t, []system.PackageUpdate{
{Name: "libpcap", Current: "1.10.7-1", Available: "1.11.0-1"},
{Name: "libsecret", Current: "0.21.7-1", Available: "0.21.8.2-1"},
{Name: "libtirpc", Current: "1.3.7-1", Available: "1.3.8-1"},
{Name: "tzdata", Current: "2026c-1", Available: "2026d-1"},
}, parsePacmanQueryUpgrades(readPackageUpdatesTestData(t, "pacman_query_upgrades.txt")))
assert.Empty(t, parsePacmanQueryUpgrades(""))
assert.Equal(t, []system.PackageUpdate{{Name: "linux", Current: "6.1-1", Available: "6.2-1"}}, parsePacmanQueryUpgrades("linux 6.1-1 -> 6.2-1 [ignored]\n"))
}, parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
assert.Empty(t, parsePacmanCheckUpdates(""))
}
func TestParseApkUpgradable(t *testing.T) {
@@ -457,28 +457,46 @@ func TestGetPackageUpdatesHandler(t *testing.T) {
assert.Equal(t, system.PackageUpdates{Manager: "apk", CheckedAt: 1700000000, Packages: packages}, sent)
}
func TestCheckPacman(t *testing.T) {
tests := []struct {
name, script string
counts []uint16
err string
}{
{"updates", `echo "linux 6.1-1 -> 6.2-1"`, []uint16{1}, ""},
{"no updates", "exit 1", []uint16{0}, ""},
{"error", `echo "error: failed to initialize alpm library" >&2
exit 1`, nil, "pacman: exit status 1: error: failed to initialize alpm library"},
func TestPacmanCheckSync(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("requires a shell script on PATH")
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeCommands(t, map[string]string{"pacman": `[ "$*" = "-Qu" ] || exit 9
` + tt.script})
result, err := checkPacman(context.Background())
if tt.err != "" {
require.EqualError(t, err, tt.err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.counts, result.counts)
})
binDir := t.TempDir()
dataDir := t.TempDir()
logFile := filepath.Join(binDir, "calls.log")
// fake checkupdates logs its args and db path, and creates the sync dir when syncing
script := `#!/bin/sh
echo "args=[$*] db=$CHECKUPDATES_DB" >> ` + logFile + `
[ "$1" = "-n" ] || mkdir -p "$CHECKUPDATES_DB/sync"
echo "linux 6.1-1 -> 6.2-1"
`
require.NoError(t, os.WriteFile(filepath.Join(binDir, "checkupdates"), []byte(script), 0o755))
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
check := newPacmanCheck(dataDir)
dbPath := filepath.Join(dataDir, "checkup-db")
readCalls := func() []string {
data, err := os.ReadFile(logFile)
require.NoError(t, err)
return strings.Split(strings.TrimSpace(string(data)), "\n")
}
// first check syncs
result, err := check(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{1}, result.counts)
assert.Equal(t, []system.PackageUpdate{{Name: "linux", Current: "6.1-1", Available: "6.2-1"}}, result.packages)
// later checks reuse the synced copy
_, err = check(context.Background())
require.NoError(t, err)
// a missing private copy forces a sync
require.NoError(t, os.RemoveAll(dbPath))
_, err = check(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{
"args=[] db=" + dbPath,
"args=[-n] db=" + dbPath,
"args=[] db=" + dbPath,
}, readCalls())
}
@@ -0,0 +1,4 @@
libpcap 1.10.7-1 -> 1.11.0-1
libsecret 0.21.7-1 -> 0.21.8.2-1
libtirpc 1.3.7-1 -> 1.3.8-1
tzdata 2026c-1 -> 2026d-1
@@ -1,100 +0,0 @@
acl 2.3.2-2 -> 2.4.0-1
archlinux-keyring 20260409-1 -> 20260909-1
attr 2.5.2-2 -> 2.6.0-1
audit 4.1.4-2 -> 4.2.1-1
bash 5.3.9-1 -> 5.3.20-1
binutils 2.46-1 -> 2.47-4
ca-certificates-mozilla 3.123-1 -> 3.130-1
coreutils 9.10-1 -> 9.12-2
cryptsetup 2.8.6-1 -> 2.8.8-1
curl 8.19.0-1 -> 8.22.0-1
device-mapper 2.03.39-1 -> 2.03.43-2
ex-vi-compat 2-1 -> 3-1
expat 2.7.5-1 -> 2.8.5-1
file 5.47-3 -> 5.48-1
findutils 4.10.0-3 -> 4.11.0-1
gawk 5.4.0-1 -> 5.4.1-1
gcc-libs 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
glib2 2.88.0-1 -> 2.88.3-1
glibc 2.43+r5+g856c426a7534-1 -> 2.44+r50+g1848099f063e-1
gnupg 2.4.9-1 -> 2.4.9-3
gnutls 3.8.12-2 -> 3.8.13-3
gpgme 2.0.1-3 -> 2.2.0-1
gpm 1.20.7.r38.ge82d1a6-6 -> 1.20.7.r38.ge82d1a6-7
gzip 1.14-2 -> 1.15-1
hwdata 0.406-1 -> 0.411-1
iana-etc 20260409-1 -> 20260617-1
inetutils 2.7-2 -> 2.8-1
iproute2 7.0.0-1 -> 7.2.0-1
jansson 2.15.0-1 -> 2.15.1-1
json-c 0.18-2 -> 0.19-1
kbd 2.9.0-1 -> 2.10.0-1
krb5 1.21.3-2 -> 1.22.2-1
leancrypto 1.7.2-1 -> 1.9.1-1
libarchive 3.8.7-1 -> 3.8.9-1
libasan 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libatomic 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libbpf 1.6.2-1 -> 1.7.0-1
libcap-ng 0.9.3-1 -> 0.9.6-1
libedit 20251016_3.1-1 -> 20260512_3.1-1
libelf 0.194-2 -> 0.196-1
libevent 2.1.12-5 -> 2.1.13-2
libffi 3.5.2-1 -> 3.8.0-1
libgcc 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libgcrypt 1.12.2-1 -> 1.12.4-1
libgfortran 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libgomp 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libgpg-error 1.59-1 -> 1.61-1
libksba 1.6.8-1 -> 1.8.1-1
libldap 2.6.13-1 -> 2.7.1-1
liblsan 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libmakepkg-dropins 20-1 -> 20-2
libnetfilter_conntrack 1.0.9-2 -> 1.1.1-1
libnftnl 1.3.1-1 -> 1.3.2-1
libnghttp2 1.69.0-1 -> 1.70.0-1
libnghttp3 1.15.0-1 -> 1.18.0-1
libngtcp2 1.22.1-1 -> 1.25.0-1
libnsl 2.0.1-1 -> 2.0.1-2
libobjc 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libp11-kit 0.26.2-1 -> 0.26.5-1
libpcap 1.10.6-1 -> 1.11.0-1
libquadmath 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libsecret 0.21.7-1 -> 0.21.8.2-1
libssh2 1.11.1-1 -> 1.11.1-7
libstdc++ 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libsysprof-capture 50.0-1 -> 50.0-6
libtirpc 1.3.7-1 -> 1.3.8-1
libtsan 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libubsan 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1
libunistring 1.4.1-1 -> 1.4.2-1
libusb 1.0.29-1 -> 1.0.30-1
libxml2 2.15.3-1 -> 2.15.4-1
linux-api-headers 6.19-1 -> 7.2-1
nano 9.0-1 -> 9.2-1
nettle 3.10.2-1 -> 4.0-1
nftables 1:1.1.6-3 -> 1:1.1.7-3
openssh 10.3p1-1 -> 10.5p1-1
openssl 3.6.2-1 -> 3.6.5-1
p11-kit 0.26.2-1 -> 0.26.5-1
pacman 7.1.0.r9.g54d9411-1 -> 7.1.0.r9.g54d9411-2
pacman-mirrorlist 20260406-1 -> 20260610-1
pam 1.7.2-2 -> 1.7.3-1
pambase 20250719-1 -> 20260616-1
pcre2 10.47-1 -> 10.49-1
pinentry 1.3.2-2 -> 1.3.3-1
procps-ng 4.0.6-1 -> 4.0.7-1
readline 8.3.003-1 -> 8.3.6-1
sed 4.9-3 -> 4.10-2
shadow 4.18.0-1 -> 4.20.0.arch1-1
sqlite 3.53.0-2 -> 3.53.4-1
systemd 260.1-1 -> 262-1
systemd-libs 260.1-1 -> 262-1
systemd-sysvcompat 260.1-1 -> 262-1
tar 1.35-2 -> 1.35-5
tpm2-tss 4.1.3-1 -> 4.2.0-2
tzdata 2026a-1 -> 2026e-1
util-linux 2.42-1 -> 2.42.4-1
util-linux-libs 2.42-1 -> 2.42.4-1
vim 9.2.0357-1 -> 9.2.1046-1
vim-runtime 9.2.0357-1 -> 9.2.1046-1
xz 5.8.3-1 -> 5.8.4-1