diff --git a/agent/package_updates.go b/agent/package_updates.go index b9afc1a99..97382a2a3 100644 --- a/agent/package_updates.go +++ b/agent/package_updates.go @@ -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()) diff --git a/agent/package_updates_test.go b/agent/package_updates_test.go index ead5b80fa..eed8a93bf 100644 --- a/agent/package_updates_test.go +++ b/agent/package_updates_test.go @@ -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()) } diff --git a/agent/test-data/package_updates/pacman_checkupdates.txt b/agent/test-data/package_updates/pacman_checkupdates.txt new file mode 100644 index 000000000..8a3ccd922 --- /dev/null +++ b/agent/test-data/package_updates/pacman_checkupdates.txt @@ -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 diff --git a/agent/test-data/package_updates/pacman_query_upgrades.txt b/agent/test-data/package_updates/pacman_query_upgrades.txt deleted file mode 100644 index b0491df52..000000000 --- a/agent/test-data/package_updates/pacman_query_upgrades.txt +++ /dev/null @@ -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 \ No newline at end of file