mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-25 17:34:22 +00:00
- store checkupdates' private DB in the agent data dir, since ProtectSystem=strict makes /tmp read-only - sync the private DB every 12h and use `checkupdates -n` in between, forcing a sync when the DB is missing so -n never reports a false 0 - set WaitDelay so a timed-out command can't hang on child processes
311 lines
9.5 KiB
Go
311 lines
9.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/henrygd/beszel/agent/utils"
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
// packageUpdatesCheck returns [total] or [total, security] pending package updates.
|
|
type packageUpdatesCheck func(ctx context.Context) ([]uint16, error)
|
|
|
|
// packageUpdatesManager periodically checks the host package manager for pending
|
|
// updates in the background and caches the result, so checks never delay metrics.
|
|
type packageUpdatesManager struct {
|
|
sync.Mutex
|
|
check packageUpdatesCheck
|
|
interval time.Duration
|
|
counts []uint16
|
|
checkedAt time.Time
|
|
running bool
|
|
}
|
|
|
|
// newPackageUpdatesManager returns nil if disabled or no supported package manager
|
|
// is found. Agents running in a container are skipped because the container's
|
|
// package database is not the host's. dataDir holds pacman's private sync databases.
|
|
func newPackageUpdatesManager(dataDir string) *packageUpdatesManager {
|
|
if runtime.GOOS != "linux" || runningInContainer() {
|
|
return nil
|
|
}
|
|
interval := defaultPackageUpdatesInterval
|
|
if env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL"); exists {
|
|
duration, err := time.ParseDuration(env)
|
|
switch {
|
|
case err == nil && duration == 0:
|
|
return nil
|
|
case err == nil && duration > 0:
|
|
interval = duration
|
|
default:
|
|
slog.Warn("Invalid PACKAGE_UPDATES_INTERVAL", "value", env)
|
|
}
|
|
}
|
|
name, check := detectPackageManager(dataDir)
|
|
if check == nil {
|
|
return nil
|
|
}
|
|
slog.Debug("Package updates", "manager", name, "interval", interval)
|
|
return &packageUpdatesManager{check: check, interval: interval}
|
|
}
|
|
|
|
// get returns the last cached counts and starts a background check if they are stale.
|
|
func (pm *packageUpdatesManager) get(now time.Time) []uint16 {
|
|
pm.Lock()
|
|
defer pm.Unlock()
|
|
if !pm.running && (pm.checkedAt.IsZero() || now.Sub(pm.checkedAt) >= pm.interval) {
|
|
pm.running = true
|
|
go pm.refresh()
|
|
}
|
|
return pm.counts
|
|
}
|
|
|
|
func (pm *packageUpdatesManager) refresh() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), packageUpdatesTimeout)
|
|
defer cancel()
|
|
counts, err := pm.check(ctx)
|
|
if err != nil {
|
|
slog.Debug("Package updates check failed", "err", err)
|
|
counts = nil
|
|
}
|
|
pm.Lock()
|
|
pm.counts = counts
|
|
pm.checkedAt = time.Now()
|
|
pm.running = false
|
|
pm.Unlock()
|
|
}
|
|
|
|
func runningInContainer() bool {
|
|
for _, path := range []string{"/.dockerenv", "/run/.containerenv"} {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
|
|
switch {
|
|
case commandExists("apt-get"):
|
|
return "apt", checkApt
|
|
case commandExists("dnf"):
|
|
return "dnf", checkDnf
|
|
case commandExists("zypper"):
|
|
return "zypper", checkZypper
|
|
case commandExists("checkupdates"):
|
|
return "pacman", newPacmanCheck(dataDir)
|
|
case commandExists("apk"):
|
|
return "apk", checkApk
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func commandExists(name string) bool {
|
|
_, err := exec.LookPath(name)
|
|
return err == nil
|
|
}
|
|
|
|
// runPackageCommand runs a read-only package manager command and returns stdout.
|
|
// okCodes lists non-zero exit codes that still mean success.
|
|
func runPackageCommand(ctx context.Context, okCodes []int, name string, args ...string) (string, error) {
|
|
return runPackageCommandEnv(ctx, nil, okCodes, name, args...)
|
|
}
|
|
|
|
// runPackageCommandEnv is runPackageCommand with extra environment variables.
|
|
func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
|
cmd.Env = append(cmd.Env, env...)
|
|
// 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 && slices.Contains(okCodes, exitErr.ExitCode()) {
|
|
return string(out), nil
|
|
}
|
|
return string(out), err
|
|
}
|
|
|
|
// checkApt simulates a full upgrade against the current package lists.
|
|
// It never refreshes the lists; apt-daily or the user does that.
|
|
func checkApt(ctx context.Context) ([]uint16, error) {
|
|
out, err := runPackageCommand(ctx, nil, "apt-get", "-s", "dist-upgrade")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
total, security := parseAptSimulate(out)
|
|
return []uint16{total, security}, nil
|
|
}
|
|
|
|
// checkDnf uses the system metadata cache only (-C), so it never downloads metadata.
|
|
func checkDnf(ctx context.Context) ([]uint16, error) {
|
|
out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
total := parseDnfCheckUpdate(out)
|
|
out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security")
|
|
if err != nil {
|
|
return []uint16{total}, nil
|
|
}
|
|
return []uint16{total, parseDnfCheckUpdate(out)}, nil
|
|
}
|
|
|
|
func checkZypper(ctx context.Context) ([]uint16, error) {
|
|
out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
total := parseZypperTable(out)
|
|
out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security")
|
|
if err != nil {
|
|
return []uint16{total}, nil
|
|
}
|
|
return []uint16{total, parseZypperTable(out)}, 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")
|
|
}
|
|
// checks never overlap (packageUpdatesManager.running), so no lock is needed
|
|
var lastSync time.Time
|
|
return func(ctx context.Context) ([]uint16, 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 nil, err
|
|
}
|
|
if sync {
|
|
lastSync = time.Now()
|
|
}
|
|
return []uint16{parsePacmanCheckUpdates(out)}, nil
|
|
}
|
|
}
|
|
|
|
func checkApk(ctx context.Context) ([]uint16, error) {
|
|
out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []uint16{parseApkUpgradable(out)}, nil
|
|
}
|
|
|
|
// parseAptSimulate counts upgrades in `apt-get -s` output. Upgrade lines look like
|
|
// "Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])".
|
|
// New dependencies have no "[old version]" and are not counted.
|
|
func parseAptSimulate(out string) (total, security uint16) {
|
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 4 || fields[0] != "Inst" || !strings.HasPrefix(fields[2], "[") {
|
|
continue
|
|
}
|
|
total++
|
|
start := strings.IndexByte(line, '(')
|
|
end := strings.IndexByte(line, ')')
|
|
if start >= 0 && end > start && strings.Contains(line[start:end], "-security") {
|
|
security++
|
|
}
|
|
}
|
|
return total, security
|
|
}
|
|
|
|
// parseDnfCheckUpdate counts "name.arch version repo" lines, stopping at the
|
|
// obsoletes section so obsoleted packages are not counted twice.
|
|
func parseDnfCheckUpdate(out string) (count uint16) {
|
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "Obsoleting") {
|
|
break
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 3 && strings.Contains(fields[0], ".") {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// parseZypperTable counts the data rows of a zypper table (the lines after the
|
|
// "---+---" separator).
|
|
func parseZypperTable(out string) (count uint16) {
|
|
inTable := false
|
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
switch {
|
|
case !inTable:
|
|
inTable = strings.HasPrefix(line, "--") && strings.Contains(line, "-+-")
|
|
case strings.Contains(line, "|"):
|
|
count++
|
|
default:
|
|
return count
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// parsePacmanCheckUpdates counts "name old -> new" lines.
|
|
func parsePacmanCheckUpdates(out string) (count uint16) {
|
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
for scanner.Scan() {
|
|
if strings.Contains(scanner.Text(), " -> ") {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// parseApkUpgradable counts lines of `apk -u list`, which look like
|
|
// "musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]".
|
|
func parseApkUpgradable(out string) (count uint16) {
|
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
for scanner.Scan() {
|
|
if strings.Contains(scanner.Text(), "[upgradable from:") {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|