Files
goclaw/internal/skills/apk_update_checker.go
Duy /zuey/ 425cecb9a3 feat(packages): Phase 2b — apk update flow + pkg-helper v2 protocol (#900) (#7)
* feat(packages): add apk update flow + pkg-helper v2 protocol

- APK update checker/executor via helper IPC (runtime detection, upgrade scan via apk list --upgradable)
- BREAKING: pkg-helper v2 protocol (5 actions: check_apk/check_pip/check_npm/exec_apk/exec_pip, code/data fields, renewable 10min deadline, apkMutex, 1MB scanner)
- Edition gating: SupportsApk + IsAlpineRuntime double-gate (Standard/Full only)
- Backend 3-branch wiring: alpine/apt/yum routes + update_registry, dep_installer helpers
- i18n: 5 apk keys (EN/VI/ZH catalogs)
- Frontend: source pill Alpine badge, APK in updates-list/summary-bar/update-all modal
- E2E tests: apk_e2e build tag covering checker/executor/helper protocol
- Docs: packages-apk.md, security/changelog updates
- Plans + reports under plans/260417-1500-packages-update-phase2b-apk-pkghelper/ + plans/reports/

* docs(packages): journal Phase 2b apk + pkg-helper v2
2026-05-11 15:41:27 +07:00

190 lines
6.3 KiB
Go

package skills
// apk_update_checker.go — ApkUpdateChecker polls apk for available package
// updates by invoking the pkg-helper Unix socket (actions: update-index,
// list-outdated). All apk invocations run via the privileged helper because the
// gateway runs unprivileged as `goclaw`. No direct exec.Command("apk", ...) here.
//
// Availability semantics:
// - Helper socket unreachable (dial fail) → Available:false, nil Err.
// - Helper reachable but action fails → Available:true, Err set.
// - Two round-trips per Check(): (1) update-index ~60s, (2) list-outdated ~30s.
import (
"context"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
)
const (
// apkCheckerUpdateIndexTimeout is the per-call budget for refreshing the
// remote index (network-bound: fetches index from Alpine mirrors).
apkCheckerUpdateIndexTimeout = 60 * time.Second
// apkCheckerListTimeout is the per-call budget for reading the outdated
// package list (local-only: reads cached index, no network).
apkCheckerListTimeout = 30 * time.Second
)
// apkNameVerBoundary matches a hyphen immediately followed by a digit.
// Used to locate the rightmost name/version boundary in Alpine package strings
// of the form "<name>-<ver>", where name itself may contain hyphens (e.g. py3-pip).
var apkNameVerBoundary = regexp.MustCompile(`-\d`)
// ApkUpdateChecker implements UpdateChecker for the "apk" source.
// It calls the pkg-helper Unix socket to refresh the Alpine index and enumerate
// outdated packages. Thread-safe: no mutable state; apkHelperCallFunc hook MUST
// only be mutated from single-goroutine test setup.
type ApkUpdateChecker struct{}
// NewApkUpdateChecker returns an ApkUpdateChecker ready for use.
func NewApkUpdateChecker() *ApkUpdateChecker { return &ApkUpdateChecker{} }
// Source returns "apk".
func (c *ApkUpdateChecker) Source() string { return "apk" }
// Check polls apk for outdated packages and returns UpdateCheckResult.
//
// Not on Alpine (IsAlpineRuntime=false) → Available:false, nil Err.
// Socket dial fail → Available:false, nil Err.
// update-index helper error → Available:true, Err set.
// list-outdated helper error → Available:true, Err set.
// Success → Available:true, Updates populated.
//
// knownETags is ignored: apk has no ETag / conditional-fetch mechanism.
func (c *ApkUpdateChecker) Check(ctx context.Context, _ map[string]string) UpdateCheckResult {
start := time.Now()
// Fast-fail: we are not on Alpine Linux.
if !IsAlpineRuntime() {
slog.Info("package.update.apk.unavailable", "reason", "not alpine")
return UpdateCheckResult{Source: "apk", Available: false}
}
// Round-trip 1: refresh the remote index (network-bound, 60s).
upCtx, upCancel := context.WithTimeout(ctx, apkCheckerUpdateIndexTimeout)
ok, code, _, errMsg := apkHelperCallFunc(upCtx, "update-index", "")
upCancel()
if !ok {
if code == "helper_unavailable" {
slog.Info("package.update.apk.unavailable", "reason", errMsg)
return UpdateCheckResult{Source: "apk", Available: false}
}
slog.Warn("package.update.apk.check",
"stage", "update-index", "code", code, "error", errMsg)
return UpdateCheckResult{
Source: "apk",
Available: true,
Err: fmt.Errorf("apk update-index: %s (code=%s)", errMsg, code),
}
}
// Round-trip 2: read outdated packages from the refreshed local index (30s).
lsCtx, lsCancel := context.WithTimeout(ctx, apkCheckerListTimeout)
ok, code, data, errMsg := apkHelperCallFunc(lsCtx, "list-outdated", "")
lsCancel()
if !ok {
slog.Warn("package.update.apk.check",
"stage", "list-outdated", "code", code, "error", errMsg)
return UpdateCheckResult{
Source: "apk",
Available: true,
Err: fmt.Errorf("apk list-outdated: %s (code=%s)", errMsg, code),
}
}
entries := parseApkOutdated(data)
infos := make([]UpdateInfo, 0, len(entries))
now := time.Now().UTC()
for _, e := range entries {
infos = append(infos, UpdateInfo{
Source: "apk",
Name: e.Name,
CurrentVersion: e.Version,
LatestVersion: e.Latest,
CheckedAt: now,
Meta: map[string]any{"source": "apk"},
})
}
slog.Info("package.update.apk.check",
"count", len(infos),
"duration_ms", time.Since(start).Milliseconds())
return UpdateCheckResult{Source: "apk", Available: true, Updates: infos}
}
// apkOutdatedEntry holds a single parsed result from `apk version -l '<'` output.
type apkOutdatedEntry struct {
Name string
Version string
Latest string
}
// parseApkOutdated parses `apk version -l '<'` text output into a slice of
// apkOutdatedEntry. Each line has the form:
//
// <name>-<installed_ver> < <available_ver>
//
// The name/version boundary is the rightmost "-<digit>" in the left-hand token,
// which correctly handles packages whose names contain hyphens (e.g. py3-pip).
// Malformed lines are skipped with slog.Warn; the caller receives whatever
// well-formed entries were parsed.
func parseApkOutdated(raw string) []apkOutdatedEntry {
lines := strings.Split(raw, "\n")
out := make([]apkOutdatedEntry, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Expect exactly one " < " separator (three bytes with surrounding spaces).
parts := strings.SplitN(line, " < ", 2)
if len(parts) != 2 {
slog.Warn("apk checker: malformed line", "line", line)
continue
}
lhs := strings.TrimSpace(parts[0])
latest := strings.TrimSpace(parts[1])
if lhs == "" || latest == "" {
slog.Warn("apk checker: malformed line", "line", line)
continue
}
// Find the rightmost "-<digit>" boundary in lhs to split name from version.
// FindAllStringIndex returns all match positions; we want the last one.
matches := apkNameVerBoundary.FindAllStringIndex(lhs, -1)
if len(matches) == 0 {
slog.Warn("apk checker: malformed line", "line", line)
continue
}
// The rightmost match gives us the split point: index of the '-'.
splitIdx := matches[len(matches)-1][0]
name := lhs[:splitIdx]
version := lhs[splitIdx+1:] // skip the '-' itself
if name == "" || version == "" {
slog.Warn("apk checker: malformed line", "line", line)
continue
}
out = append(out, apkOutdatedEntry{
Name: name,
Version: version,
Latest: latest,
})
}
return out
}