Files
goclaw/internal/skills/apk_update_executor.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

117 lines
4.1 KiB
Go

package skills
import (
"context"
"fmt"
"log/slog"
"time"
)
// ApkUpdateExecutor implements UpdateExecutor for the "apk" source.
// It upgrades a single Alpine package by calling the pkg-helper v2
// `upgrade` action over the privileged Unix socket.
//
// Thread-safe: no mutable state; concurrent package serialization is
// handled upstream by PackageLocker (injected via UpdateRegistry.Apply).
// Process-level apk serialization is handled downstream by apkMutex
// inside pkg-helper. The executor itself acquires NO locks. A second
// PackageLocker.Acquire from this goroutine would deadlock (non-reentrant
// chan struct{} — see update_registry.go:284 and package_lock.go:49-73).
type ApkUpdateExecutor struct{}
// NewApkUpdateExecutor returns an ApkUpdateExecutor ready for use.
func NewApkUpdateExecutor() *ApkUpdateExecutor { return &ApkUpdateExecutor{} }
// Source returns "apk".
func (e *ApkUpdateExecutor) Source() string { return "apk" }
// Update upgrades `name` to the latest available version using the pkg-helper v2
// `upgrade` action over the Unix socket at /tmp/pkg.sock.
//
// Argument ordering matches UpdateExecutor interface: (ctx, name, toVersion, meta).
// `name` is validated via ValidateApkPackageName before any socket dial.
// `toVersion` is used for logging only — apk always upgrades to the latest
// available version from repositories (no pinned-version upgrade in Phase 2b).
// `meta` is accepted for interface symmetry; apk has no pre-release concept.
// On success, cleanCaches is called for disk symmetry with dep_installer.go.
// On failure, resp.Code is mapped via mapApkHelperCodeToSentinel; if the code
// is unrecognized or empty, ClassifyApkStderr is tried; finally a generic error.
//
// IMPORTANT: This method acquires NO PackageLocker. UpdateRegistry.Apply
// (update_registry.go:284) already holds the lock on ("apk", name) before
// invoking Update. PackageLocker is non-reentrant — a second Acquire from
// this goroutine deadlocks until the 5-minute context timeout fires.
func (e *ApkUpdateExecutor) Update(ctx context.Context, name, toVersion string, meta map[string]any) error {
// Defense-in-depth validation; pkg-helper also validates on its side.
if err := ValidateApkPackageName(name); err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
start := time.Now()
// DO NOT acquire sharedPackageLocker() here. See docstring above.
ok, code, _, errMsg := apkHelperCallFunc(cctx, "upgrade", name)
durationMs := time.Since(start).Milliseconds()
if ok {
// Success: purge caches for disk symmetry with dep_installer.go.
cleanCaches(cctx)
slog.Info("package.update.apk.outcome",
"name", name,
"to", toVersion,
"status", "success",
"duration_ms", durationMs)
return nil
}
// Failure: classify the error code into a sentinel, falling back to stderr.
sentinel := mapApkHelperCodeToSentinel(code)
if sentinel == nil {
sentinel, _ = ClassifyApkStderr(errMsg)
}
if sentinel == nil {
sentinel = fmt.Errorf("apk upgrade failed: %s", errMsg)
}
slog.Warn("package.update.apk.outcome",
"name", name,
"status", "failed",
"code", code,
"err_class", fmt.Sprintf("%T:%v", sentinel, sentinel),
"reason", truncateStderr(errMsg, 500),
"duration_ms", durationMs)
return fmt.Errorf("%w: %s", sentinel, truncateStderr(errMsg, 500))
}
// mapApkHelperCodeToSentinel maps pkg-helper v2 `code` field values to
// Phase 1 apk update sentinels. Returns nil when code is empty or
// unrecognized, delegating to ClassifyApkStderr as the next fallback.
func mapApkHelperCodeToSentinel(code string) error {
switch code {
case "validation":
return ErrInvalidApkPackageName
case "not_found":
return ErrUpdateApkNotFound
case "conflict", "constraint":
return ErrUpdateApkConflict
case "locked":
return ErrUpdateApkLocked
case "network":
return ErrUpdateApkNetwork
case "permission":
return ErrUpdateApkPermission
case "disk_full":
return ErrUpdateApkDiskFull
case "helper_unavailable":
return ErrUpdateApkHelperUnavail
case "helper_error", "system_error", "":
return nil // fall through to ClassifyApkStderr
}
return nil // unrecognized code — fall through
}