Files
goclaw/internal/http/packages_updates.go
Duy /zuey/andGitHub 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

535 lines
16 KiB
Go

package http
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/skills"
)
// ---- Event name constants ----
// Package update event names used by the WS event filter and subscribers.
const (
eventPackageUpdateChecked = "package.update.checked"
eventPackageUpdateStarted = "package.update.started"
eventPackageUpdateSucceeded = "package.update.succeeded"
eventPackageUpdateFailed = "package.update.failed"
)
// ---- Event payload types ----
// PackageUpdateCheckedPayload is broadcast after a refresh completes.
type PackageUpdateCheckedPayload struct {
Count int `json:"count"`
CheckedAt time.Time `json:"checked_at"`
}
// PackageUpdateStartedPayload is broadcast before Apply is called.
type PackageUpdateStartedPayload struct {
Source string `json:"source"`
Name string `json:"name"`
FromVersion string `json:"from_version"`
ToVersion string `json:"to_version"`
}
// PackageUpdateSucceededPayload is broadcast after a successful Apply.
type PackageUpdateSucceededPayload struct {
Source string `json:"source"`
Name string `json:"name"`
FromVersion string `json:"from_version"`
ToVersion string `json:"to_version"`
DurationMs int64 `json:"duration_ms"`
}
// PackageUpdateFailedPayload is broadcast when Apply returns an error.
type PackageUpdateFailedPayload struct {
Source string `json:"source"`
Name string `json:"name"`
Reason string `json:"reason"`
}
// ---- handleListUpdates ----
// handleListUpdates returns the current update cache.
// If the cache is stale, triggers a background refresh (non-blocking).
// Auth: operator+ (readAuth in RegisterRoutes).
func (h *PackagesHandler) handleListUpdates(w http.ResponseWriter, r *http.Request) {
if h.Registry == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "update registry not configured"})
return
}
updates, checkedAt := h.Registry.Cache.Snapshot()
ttl := h.Registry.TTL
age := time.Duration(0)
stale := true
if !checkedAt.IsZero() {
age = time.Since(checkedAt)
stale = age > ttl
}
// Non-blocking background refresh when stale.
if stale {
h.Registry.RefreshInBackground(r.Context(), 30*time.Second)
}
writeJSON(w, http.StatusOK, map[string]any{
"updates": updates,
"checkedAt": checkedAt,
"ageSeconds": int64(age.Seconds()),
"ttlSeconds": int64(ttl.Seconds()),
"stale": stale,
"sources": h.Registry.Sources(),
"availability": h.Registry.Availability(),
})
}
// ---- handleRefreshUpdates ----
// handleRefreshUpdates runs a synchronous CheckAll and returns the fresh cache.
// Auth: admin + master-scope (adminAuth + requireMasterScope).
func (h *PackagesHandler) handleRefreshUpdates(w http.ResponseWriter, r *http.Request) {
// red-team H5: master-scope guard first, then write limit.
if !requireMasterScope(w, r) {
return
}
if !enforcePackagesWriteLimit(w, r, "/v1/packages/updates/refresh") {
return
}
if h.Registry == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "update registry not configured"})
return
}
errs := h.Registry.CheckAll(r.Context())
if len(errs) > 0 {
// Log per-source errors but still return whatever partial data was cached.
for _, e := range errs {
slog.Warn("packages: refresh partial error", "error", e)
}
}
updates, checkedAt := h.Registry.Cache.Snapshot()
// Publish checked event (TenantID=Nil → only Owner clients receive).
if h.Publisher != nil {
h.Publisher.Broadcast(bus.Event{
Name: eventPackageUpdateChecked,
Payload: PackageUpdateCheckedPayload{Count: len(updates), CheckedAt: checkedAt},
TenantID: uuid.Nil,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"updates": updates,
"checkedAt": checkedAt,
"sources": h.Registry.Sources(),
})
}
// ---- handleUpdatePackage ----
// updatePackageRequest is the body for POST /v1/packages/update.
type updatePackageRequest struct {
Package string `json:"package"` // "github:<name>" form; full spec also accepted
ToVersion string `json:"toVersion"` // optional; uses cache entry's LatestVersion if empty
}
// handleUpdatePackage applies a single package update.
// Auth: admin + master-scope.
func (h *PackagesHandler) handleUpdatePackage(w http.ResponseWriter, r *http.Request) {
// red-team H5: master-scope guard first.
if !requireMasterScope(w, r) {
return
}
if !enforcePackagesWriteLimit(w, r, "/v1/packages/update") {
return
}
if h.Registry == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "update registry not configured"})
return
}
locale := extractLocale(r)
r.Body = http.MaxBytesReader(w, r.Body, 4096)
var req updatePackageRequest
if !bindJSON(w, r, locale, &req) {
return
}
source, name, ok := resolveUpdateSpec(req.Package)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": i18n.T(locale, i18n.MsgInvalidRequest, "package must be github:<name>, pip:<name>, or npm:<name>"),
})
return
}
// Locate cache entry for meta + fromVersion.
updates, _ := h.Registry.Cache.Snapshot()
var entry *skills.UpdateInfo
for i := range updates {
if updates[i].Source == source && updates[i].Name == name {
entry = &updates[i]
break
}
}
toVersion := req.ToVersion
fromVersion := ""
var meta map[string]any
if entry != nil {
fromVersion = entry.CurrentVersion
meta = entry.Meta
if toVersion == "" {
toVersion = entry.LatestVersion
}
} else if toVersion == "" {
// Cache stale/empty and no explicit version — can't proceed.
writeJSON(w, http.StatusConflict, map[string]string{
"error": i18n.T(locale, i18n.MsgUpdateCacheStale),
})
return
}
// Publish started event.
if h.Publisher != nil {
h.Publisher.Broadcast(bus.Event{
Name: eventPackageUpdateStarted,
Payload: PackageUpdateStartedPayload{
Source: source, Name: name,
FromVersion: fromVersion, ToVersion: toVersion,
},
TenantID: uuid.Nil,
})
}
slog.Info("packages: applying update", "source", source, "name", name, "to", toVersion)
// Lock key MUST match the installer's key for the same package (CRIT-2).
// For github source, installer locks on parsed.Repo (repo-portion only,
// e.g. "lazygit"). Derive the same from entry meta.repo ("owner/repo").
lockKey := lockKeyForSource(source, name, meta)
elapsed, err := h.Registry.Apply(r.Context(), source, lockKey, name, toVersion, meta)
if err != nil {
if h.Publisher != nil {
h.Publisher.Broadcast(bus.Event{
Name: eventPackageUpdateFailed,
Payload: PackageUpdateFailedPayload{Source: source, Name: name, Reason: err.Error()},
TenantID: uuid.Nil,
})
}
slog.Error("packages: update failed", "source", source, "name", name, "error", err)
// red-team C4: detect manifest desync and surface it explicitly.
manifestDesynced := errors.Is(err, skills.ErrUpdateManifestDesync)
writeJSON(w, http.StatusInternalServerError, map[string]any{
"ok": false,
"fromVersion": fromVersion,
"toVersion": toVersion,
"error": err.Error(),
"manifestDesynced": manifestDesynced, // red-team C4: manifest retry desync
})
return
}
if h.Publisher != nil {
h.Publisher.Broadcast(bus.Event{
Name: eventPackageUpdateSucceeded,
Payload: PackageUpdateSucceededPayload{
Source: source, Name: name,
FromVersion: fromVersion, ToVersion: toVersion,
DurationMs: elapsed.Milliseconds(),
},
TenantID: uuid.Nil,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"fromVersion": fromVersion,
"toVersion": toVersion,
})
}
// ---- handleApplyAllUpdates ----
// applyAllRequest is the optional body for POST /v1/packages/updates/apply-all.
// Empty packages array or omitted = apply all cache entries.
type applyAllRequest struct {
Packages []string `json:"packages"` // "github:<name>" specs; empty = all
}
// applyAllResult accumulates per-package outcomes.
type applyAllSucceeded struct {
Package string `json:"package"`
FromVersion string `json:"fromVersion"`
ToVersion string `json:"toVersion"`
}
type applyAllFailed struct {
Package string `json:"package"`
Reason string `json:"reason"`
}
// handleApplyAllUpdates applies updates for all (or a subset) of cached entries.
// Always returns HTTP 200; caller inspects failed[] length (red-team M2).
func (h *PackagesHandler) handleApplyAllUpdates(w http.ResponseWriter, r *http.Request) {
// red-team H5: master-scope guard first.
if !requireMasterScope(w, r) {
return
}
if !enforcePackagesWriteLimit(w, r, "/v1/packages/updates/apply-all") {
return
}
if h.Registry == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "update registry not configured"})
return
}
locale := extractLocale(r)
r.Body = http.MaxBytesReader(w, r.Body, 16384)
// Body is optional. Peek for empty body; if present, bindJSON with strict
// success (bindJSON writes 400 + returns false on parse failure — must NOT
// be ignored, or we'd emit double HTTP responses on malformed JSON).
var req applyAllRequest
buf, berr := io.ReadAll(r.Body)
if berr != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read body: " + berr.Error()})
return
}
if trimmed := strings.TrimSpace(string(buf)); trimmed != "" && trimmed != "{}" {
if derr := json.Unmarshal(buf, &req); derr != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json: " + derr.Error()})
return
}
}
_ = locale // reserved for future i18n error messages
updates, _ := h.Registry.Cache.Snapshot()
start := time.Now()
// Build index of cache entries by "source:name" for O(1) lookup.
cacheIndex := make(map[string]skills.UpdateInfo, len(updates))
for _, u := range updates {
cacheIndex[u.Source+":"+u.Name] = u
}
// Resolve which entries to apply.
type target struct {
spec string // "github:name" for output
source, name string
entry skills.UpdateInfo
}
var targets []target
if len(req.Packages) == 0 {
// Apply all cached entries.
for _, u := range updates {
targets = append(targets, target{
spec: u.Source + ":" + u.Name,
source: u.Source,
name: u.Name,
entry: u,
})
}
} else {
// Resolve each caller-supplied spec.
for _, spec := range req.Packages {
src, nm, ok := resolveUpdateSpec(spec)
if !ok {
// Invalid spec → immediate failed entry, continue.
targets = append(targets, target{spec: spec})
continue
}
key := src + ":" + nm
entry, _ := cacheIndex[key] // red-team C6: comma-ok; zero value used if absent
targets = append(targets, target{
spec: spec,
source: src,
name: nm,
entry: entry,
})
}
}
var succeeded []applyAllSucceeded
var failed []applyAllFailed
for _, t := range targets {
if t.source == "" {
failed = append(failed, applyAllFailed{Package: t.spec, Reason: "invalid package spec"})
continue
}
entry := t.entry
fromVersion := entry.CurrentVersion
toVersion := entry.LatestVersion
if toVersion == "" {
failed = append(failed, applyAllFailed{Package: t.spec, Reason: "no update available in cache"})
continue
}
// Publish started.
if h.Publisher != nil {
h.Publisher.Broadcast(bus.Event{
Name: eventPackageUpdateStarted,
Payload: PackageUpdateStartedPayload{
Source: t.source, Name: t.name,
FromVersion: fromVersion, ToVersion: toVersion,
},
TenantID: uuid.Nil,
})
}
slog.Info("packages: apply-all applying", "source", t.source, "name", t.name, "to", toVersion)
lockKey := lockKeyForSource(t.source, t.name, entry.Meta)
elapsed, err := h.Registry.Apply(r.Context(), t.source, lockKey, t.name, toVersion, entry.Meta)
if err != nil {
if h.Publisher != nil {
h.Publisher.Broadcast(bus.Event{
Name: eventPackageUpdateFailed,
Payload: PackageUpdateFailedPayload{Source: t.source, Name: t.name, Reason: err.Error()},
TenantID: uuid.Nil,
})
}
slog.Warn("packages: apply-all item failed", "name", t.name, "error", err)
failed = append(failed, applyAllFailed{Package: t.spec, Reason: err.Error()})
// red-team M2: no context cancel on item failure — continue with remaining.
continue
}
if h.Publisher != nil {
h.Publisher.Broadcast(bus.Event{
Name: eventPackageUpdateSucceeded,
Payload: PackageUpdateSucceededPayload{
Source: t.source, Name: t.name,
FromVersion: fromVersion, ToVersion: toVersion,
DurationMs: elapsed.Milliseconds(),
},
TenantID: uuid.Nil,
})
}
succeeded = append(succeeded, applyAllSucceeded{
Package: t.spec, FromVersion: fromVersion, ToVersion: toVersion,
})
}
// red-team M2: always 200; caller inspects failed[] for partial failures.
writeJSON(w, http.StatusOK, map[string]any{
"succeeded": nonNilSlice(succeeded),
"failed": nonNilSlice(failed),
"durationMs": time.Since(start).Milliseconds(),
})
}
// ---- helpers ----
// resolveUpdateSpec parses a package spec and returns (source, name, ok).
// Supported prefixes: "github:<name>", "pip:<name>", "npm:<name>".
//
// github: bare name "github:<name>" or full "github:owner/repo[@tag]".
// Bare github names are validated against validGitHubBareName; full specs
// are resolved via the manifest (repo may differ, e.g. cli/cli → gh).
// pip/npm: name is validated via the strict whitelist validators.
// Bare-name fallback (without colon) is NOT supported — all sources require
// an explicit "source:" prefix.
func resolveUpdateSpec(pkg string) (source, name string, ok bool) {
prefix, rest, found := strings.Cut(pkg, ":")
if !found || rest == "" {
return "", "", false
}
switch prefix {
case "github":
// Full spec "github:owner/repo[@tag]" — extract bare name = repo component.
if spec, err := skills.ParseGitHubSpec(pkg); err == nil {
// Resolve name via manifest (repo may differ from binary name, e.g. cli/cli → gh).
if installer := skills.DefaultGitHubInstaller(); installer != nil {
if entries, lerr := installer.List(); lerr == nil {
for _, e := range entries {
if strings.EqualFold(e.Repo, spec.Owner+"/"+spec.Repo) {
return "github", e.Name, true
}
}
}
}
// Fallback: use repo name directly.
return "github", spec.Repo, true
}
// Bare name form "github:<name>".
if validGitHubBareName.MatchString(rest) {
return "github", rest, true
}
return "", "", false
case "pip":
if err := skills.ValidatePipPackageName(rest); err != nil {
return "", "", false
}
return "pip", rest, true
case "npm":
if err := skills.ValidateNpmPackageName(rest); err != nil {
return "", "", false
}
return "npm", rest, true
case "apk":
if err := skills.ValidateApkPackageName(rest); err != nil {
return "", "", false
}
return "apk", rest, true
default:
return "", "", false
}
}
// nonNilSlice returns an empty non-nil slice when s is nil, so JSON encodes
// [] instead of null (red-team M7: frontend null-check safety).
func nonNilSlice[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}
// lockKeyForSource returns the canonical PackageLocker key for a given
// (source, name, meta) tuple. MUST match the key used by the installer for
// the same package (review CRIT-2).
//
// For github source: installer locks on parsed.Repo (repo-portion only,
// e.g. "lazygit"). Meta carries repo as "owner/repo" — extract the portion
// after "/". Fallback to name when meta is nil/missing (stale cache).
//
// For pip/npm: PackageLocker internally prefixes by source, so we return
// name directly (NOT "pip:name" or "npm:name").
func lockKeyForSource(source, name string, meta map[string]any) string {
switch source {
case "pip", "npm", "apk":
return name
case "github":
if meta != nil {
if v, ok := meta["repo"].(string); ok && v != "" {
if i := strings.IndexByte(v, '/'); i > 0 && i < len(v)-1 {
return v[i+1:]
}
return v
}
}
return name
default:
return name
}
}