Files
goclaw/docs/packages-apk.md

13 KiB
Raw Permalink Blame History

apk (Alpine Package Keeper) Updates (Phase 2b)

Extends the Phase 2a pip + npm update flow to Alpine Linux system packages. GoClaw manages system packages via a privileged pkg-helper sidecar over a Unix socket. This document covers how apk updates are detected, applied, and what to do when things go wrong.

See also: GitHub binary updates · pip + npm updates


1. Overview

When the gateway runs inside an Alpine-based Docker image (latest, full, base, otel variants) in Standard edition, GET /v1/packages/updates includes system package updates alongside GitHub binaries, pip, and npm.

Two gates must both pass for apk to appear in the availability map:

  1. Runtime check: /etc/alpine-release is present at startup. On Debian, Ubuntu, or macOS desktop images, apk is silently omitted — no error, no update results, availability.apk = false.
  2. Edition check: edition.Current().SupportsApk == true. Standard edition: always true. Lite desktop (macOS/Windows): always false — system package management is not available outside containers.

Architecture note: the gateway process runs as uid 1000 (goclaw) and never calls apk directly. All apk operations are delegated to /app/pkg-helper (root-owned), which listens on /tmp/pkg.sock (0600, accessible only to goclaw). This keeps the main process unprivileged.


2. Command Matrix

Commands are executed inside pkg-helper (not by the gateway directly).

Operation Command inside helper Timeout
Refresh index apk update 60 s
List outdated apk version -l '<' 30 s
Upgrade one package apk add -u <name> 5 min
Install new (dep install) apk add <name> 5 min
Remove apk del <name> 5 min

The checker runs apk update + apk version -l '<' on every Check() call. The executor runs apk add -u <name> on POST /v1/packages/update.


3. Behavior

How the checker works

  1. GET /v1/packages/updates triggers ApkUpdateChecker.Check().
  2. The checker sends an update-index action to pkg-helper (runs apk update inside the container — refreshes the remote index from Alpine mirrors).
  3. On success, it sends a list-outdated action (runs apk version -l '<').
  4. Output is parsed line-by-line. Each line has the form:
    <name>-<installed_ver> < <available_ver>
    
    The parser uses the rightmost -<digit> boundary to split name from version, correctly handling names that contain hyphens (e.g. py3-pip, ca-certificates).
  5. Malformed lines are skipped with a warning log; well-formed entries produce UpdateInfo structs with Source="apk".
  6. Results are cached with the global UpdatesCheckTTL (default 1 hour). The cache is invalidated on successful upgrade.

Output parsing

apk version -l '<' format:

bash-5.2.21-r6 < 5.2.26-r0
py3-pip-22.0.4-r0 < 22.3-r0
ca-certificates-20230506-r0 < 20240226-r0

Name/version split uses the rightmost hyphen-digit boundary:

  • py3-pip-22.0.4-r0 → name=py3-pip, version=22.0.4-r0
  • ca-certificates-20230506-r0 → name=ca-certificates, version=20230506-r0

How the executor works

POST /v1/packages/update with body {"package": "apk:<name>"}:

  1. HTTP handler validates the package name (strict regex — no metacharacters).
  2. UpdateRegistry.Apply() acquires a PackageLocker lock on ("apk", name).
  3. ApkUpdateExecutor.Update() sends an upgrade action to pkg-helper.
  4. pkg-helper acquires an in-process sync.Mutex (serializes all apk ops).
  5. pkg-helper runs apk add -u <name>. On success, returns {"ok":true}.
  6. On success, the cache entry for the package is removed; HTTP returns 200.

The per-source PackageLocker and the in-process apkMutex in pkg-helper form a two-layer serialization guard:

  • PackageLocker: prevents concurrent gateway-level operations on the same (source, name) pair (e.g., dep install + update-apply racing).
  • apkMutex: prevents concurrent apk database access from any code path inside the helper process.

pkg-helper v2 protocol

The helper uses a JSON line-oriented protocol over /tmp/pkg.sock:

Request:

{"action": "upgrade", "package": "curl"}

Success response:

{"ok": true, "data": ""}

Error response:

{"ok": false, "error": "ERROR: unable to select packages", "code": "not_found"}

New v2 fields compared to v1:

  • code — typed error classification (see Error Classes section)
  • data — opaque payload for list-outdated results
  • New actions: upgrade, update-index, list-outdated

v1 callers that omit code on error responses receive system_error by default in the client — backward-compat for split deployments where helper is not yet rebuilt. However, new actions (upgrade, update-index, list-outdated) return unknown action on a v1 helper — feature is degraded, not crashed.


4. Pre-Release Handling

Not applicable. Alpine repositories do not distinguish stable vs pre-release in the apk version output. apk version -l '<' lists all packages where the installed version is older than the repository version. There is no pre-release channel concept in the Alpine package ecosystem.

The apk checker always reports available upgrades without pre-release filtering.


5. Availability — Edition × Runtime Truth Table

Edition Runtime availability.apk apk checker registered?
Standard Alpine (/etc/alpine-release present) true Yes
Standard Debian / Ubuntu false No (runtime gate)
Standard macOS (dev / testing) false No (runtime gate)
Lite (desktop) Any false No (edition gate)

When availability.apk = false:

  • GET /v1/packages/updates response includes "availability": {"apk": false}.
  • The frontend hides the apk source from the filter bar.
  • POST /v1/packages/update with apk:<name> returns 503 (source not registered) or 409 (Lite edition gate — source never wired).

The runtime check (/etc/alpine-release stat) is performed once at checker initialization and cached. It does not re-probe on subsequent calls.


6. Error Classes

Sentinel errors are defined in internal/skills/pkg_update_helpers.go. The code field in pkg-helper responses maps to these sentinels.

Sentinel code value Trigger
ErrInvalidApkPackageName validation Package name fails regex (metacharacter, uppercase, etc.)
ErrUpdateApkNotFound not_found apk add -u <name> reports "unable to select"
ErrUpdateApkConflict conflict or constraint Dependency conflict / unsatisfiable constraints
ErrUpdateApkLocked locked /var/lib/apk/db.lock held by another process
ErrUpdateApkNetwork network Mirror fetch timeout, DNS failure
ErrUpdateApkPermission permission Write permission denied in /var/lib/apk
ErrUpdateApkDiskFull disk_full No space left on /var/cache/apk or /
ErrUpdateApkHelperUnavail helper_unavailable Socket dial failure (helper not running)

Unclassified errors (code="" or system_error) fall back to ClassifyApkStderr pattern matching, then to a generic wrapped error with truncated stderr (≤ 500 chars, ANSI-stripped before logging).

HTTP status mapping (via packages_updates.go):

Sentinel HTTP status
ErrInvalidApkPackageName 400 Bad Request
ErrUpdateApkNotFound 404 Not Found
ErrUpdateApkConflict 409 Conflict
ErrUpdateApkLocked 409 Conflict
ErrUpdateApkNetwork 502 Bad Gateway
ErrUpdateApkPermission 500 Internal Server Error
ErrUpdateApkDiskFull 500 Internal Server Error
ErrUpdateApkHelperUnavail 503 Service Unavailable

7. Runbook

"pkg-helper unavailable" (503)

/app/pkg-helper is not running, or /tmp/pkg.sock does not exist.

For Docker Alpine deployments this is an error. For bare-metal Ubuntu/Debian deployments, /tmp/pkg.sock is expected to be absent; package install should use the apt path instead of apk/pkg-helper.

  1. Check container logs: docker logs <container> 2>&1 | grep pkg-helper
  2. Verify the binary exists: docker exec <container> ls -la /app/pkg-helper
  3. If missing, the Docker image was NOT rebuilt after the pkg-helper v2 upgrade. Pull the new image and recreate the container.
  4. If the binary exists but the socket is missing, check that the container entrypoint starts the helper before the gateway: ENTRYPOINT ["/app/entrypoint.sh"].

Bare-metal Ubuntu/Debian package table

On bare-metal Ubuntu/Debian, system package install does not write the Alpine apk-packages persist file. GoClaw records successful apt installs in {runtimeDir}/system-packages.json and lists versions via dpkg-query.

Alias examples:

  • Installing pip3 records display name pip3, apt package python3-pip.
  • Installing github-cli records display name github-cli, apt package gh.

The System Packages table should show the display name users installed, not the underlying Debian package alias.

Bare-metal npm global prefix

On bare-metal Ubuntu/Debian, Node packages installed from the Packages page use {runtimeDir}/npm-global as NPM_CONFIG_PREFIX. This avoids writing to /usr/lib/node_modules, which is root-owned on standard Ubuntu installs.

Logging: the gateway emits slog.Info("package.update.apk.unavailable") when the helper socket is unreachable. Grep for this key to confirm the symptom.

"Package database is locked" (409)

/var/lib/apk/db.lock is held by another apk process.

  1. Wait ~10 seconds and retry — an in-progress apk add from the dep-installer may still be running (the apkMutex serializes gateway operations, but manual docker exec apk add from outside the gateway bypasses it).
  2. If the lock persists: docker exec <container> ls -la /var/lib/apk/db.lock — if the owning PID is dead, the lock is stale. Restart the container.
  3. Do NOT run rm /var/lib/apk/db.lock manually — apk may be mid-write.

Logging: slog.Warn("package.update.apk.outcome", "code", "locked").

"Disk full" (500)

/var/cache/apk or / is out of space.

  1. Check disk: docker exec <container> df -h /
  2. Clean cache: docker exec <container> apk cache clean
  3. Expand the container volume or prune unused images on the host.

"Dependency conflict" (409)

apk cannot resolve dependencies for the requested upgrade.

  1. SSH into the container: docker exec -it <container> sh
  2. Run manually: apk add -u <name> --simulate to see the conflict details.
  3. Resolution typically requires upgrading a conflicting package first, or accepting cascade upgrades. The GoClaw UI warns about cascade risk for system packages.
  4. If unresolvable, the package must be pinned via Dockerfile RUN apk add.

Debugging helper protocol issues

The helper logs all actions to stderr (docker logs <container>). To trace a specific action:

# Manual socket test (requires jq on PATH):
echo '{"action":"list-outdated","package":""}' | \
  nc -U /tmp/pkg.sock | jq .

Expected response shape:

{"ok": true, "data": "bash-5.2.21-r6 < 5.2.26-r0\n"}

8. Minimum Versions

Component Minimum Notes
Alpine Linux 3.19 apk version -l '<' output format stable since 3.12; 3.19 tested
apk-tools 2.14 Bundled with Alpine 3.19+; older versions may have different version -l output
pkg-helper v2 (Phase 2b) v1 helpers lack upgrade / update-index / list-outdated actions
Docker image Phase 2b build Image must be rebuilt to include the new pkg-helper binary

9. Fixture Regeneration

Test fixtures for the apk parser live in internal/skills/testdata/. When the Alpine version is upgraded and apk version -l '<' output format changes:

# Capture live output from a running container:
docker exec <container> apk update && \
  docker exec <container> apk version -l '<' \
  > internal/skills/testdata/apk_outdated_alpine319.txt

# Verify the parser handles the new format:
go test -run TestParseApkOutdated ./internal/skills/...

# Update test cases in apk_update_checker_test.go to reference the new fixture
# and expected name/version values.

Fixture files are named with the Alpine version (alpine319) so drift between CI environments is detectable by git diff.

Updating pkg-helper v2 protocol tests

If the helper wire format changes (new fields, action names):

  1. Update apk_helper_call_test.goservePkgHelper / dialHelper helpers.
  2. Update apk_update_checker_test.go and apk_update_executor_test.go — canned response maps.
  3. Update cmd/pkg-helper/main_test.go — v2 protocol action dispatch tests.
  4. Run: go test ./internal/skills/... ./cmd/pkg-helper/... to verify.