mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 16:17:25 +00:00
* 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
297 lines
9.0 KiB
Go
297 lines
9.0 KiB
Go
package skills
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeChecker is a minimal UpdateChecker for registry tests.
|
|
type fakeChecker struct {
|
|
source string
|
|
available bool
|
|
err error
|
|
}
|
|
|
|
func (f *fakeChecker) Source() string { return f.source }
|
|
func (f *fakeChecker) Check(_ context.Context, _ map[string]string) UpdateCheckResult {
|
|
return UpdateCheckResult{
|
|
Source: f.source,
|
|
Available: f.available,
|
|
Err: f.err,
|
|
}
|
|
}
|
|
|
|
// TestSetAvailability_ExportedWrapper verifies the exported SetAvailability
|
|
// delegates to the internal setAvailability correctly and is thread-safe.
|
|
func TestSetAvailability_ExportedWrapper(t *testing.T) {
|
|
reg := NewUpdateRegistry(nil, "", time.Hour)
|
|
|
|
// Seed apk=false via the exported wrapper (no checker registered).
|
|
reg.SetAvailability("apk", false)
|
|
|
|
avail := reg.Availability()
|
|
got, exists := avail["apk"]
|
|
if !exists {
|
|
t.Fatal("expected 'apk' key in Availability() after SetAvailability call")
|
|
}
|
|
if got != false {
|
|
t.Errorf("Availability[apk] = %v, want false", got)
|
|
}
|
|
|
|
// Flip to true.
|
|
reg.SetAvailability("apk", true)
|
|
avail2 := reg.Availability()
|
|
if avail2["apk"] != true {
|
|
t.Errorf("Availability[apk] after SetAvailability(true) = %v, want true", avail2["apk"])
|
|
}
|
|
|
|
// Verify returned map is a clone — mutating it must not affect registry.
|
|
avail2["apk"] = false
|
|
avail3 := reg.Availability()
|
|
if avail3["apk"] != true {
|
|
t.Error("Availability() returned same map (not a clone): mutation propagated")
|
|
}
|
|
}
|
|
|
|
func TestRegistry_Availability(t *testing.T) {
|
|
reg := NewUpdateRegistry(nil, "", time.Hour)
|
|
|
|
reg.RegisterChecker(&fakeChecker{source: "github", available: true})
|
|
reg.RegisterChecker(&fakeChecker{source: "pip", available: false})
|
|
|
|
errs := reg.CheckAll(context.Background())
|
|
if len(errs) != 0 {
|
|
t.Fatalf("unexpected errors from CheckAll: %v", errs)
|
|
}
|
|
|
|
avail := reg.Availability()
|
|
|
|
if got, want := avail["github"], true; got != want {
|
|
t.Errorf("Availability[github] = %v, want %v", got, want)
|
|
}
|
|
if got, want := avail["pip"], false; got != want {
|
|
t.Errorf("Availability[pip] = %v, want %v", got, want)
|
|
}
|
|
|
|
// Verify returned map is a clone — mutating it must not affect the registry.
|
|
avail["github"] = false
|
|
avail["pip"] = true
|
|
avail2 := reg.Availability()
|
|
if avail2["github"] != true {
|
|
t.Error("Availability() returned same map (not a clone): mutation propagated")
|
|
}
|
|
if avail2["pip"] != false {
|
|
t.Error("Availability() returned same map (not a clone): mutation propagated")
|
|
}
|
|
}
|
|
|
|
func TestRegistry_Availability_NeverChecked(t *testing.T) {
|
|
// A registry with no CheckAll call should return an empty map.
|
|
// Callers are expected to treat missing keys as true (first-boot default).
|
|
reg := NewUpdateRegistry(nil, "", time.Hour)
|
|
avail := reg.Availability()
|
|
if len(avail) != 0 {
|
|
t.Errorf("expected empty map before CheckAll, got %v", avail)
|
|
}
|
|
}
|
|
|
|
func TestRegistry_Availability_UpdatedOnRecheck(t *testing.T) {
|
|
// A checker that flips available state between calls.
|
|
reg := NewUpdateRegistry(nil, "", time.Hour)
|
|
checker := &fakeChecker{source: "npm", available: false}
|
|
reg.RegisterChecker(checker)
|
|
|
|
reg.CheckAll(context.Background()) //nolint:errcheck
|
|
if got := reg.Availability()["npm"]; got != false {
|
|
t.Errorf("first check: Availability[npm] = %v, want false", got)
|
|
}
|
|
|
|
// Second check with available=true.
|
|
checker.available = true
|
|
reg.CheckAll(context.Background()) //nolint:errcheck
|
|
if got := reg.Availability()["npm"]; got != true {
|
|
t.Errorf("second check: Availability[npm] = %v, want true", got)
|
|
}
|
|
}
|
|
|
|
// fakeExecutor is a minimal UpdateExecutor for registry Apply tests.
|
|
type fakeExecutor struct {
|
|
source string
|
|
err error
|
|
// called records each (name, toVersion) pair passed to Update.
|
|
mu sync.Mutex
|
|
called []string
|
|
}
|
|
|
|
func (f *fakeExecutor) Source() string { return f.source }
|
|
func (f *fakeExecutor) Update(_ context.Context, name, toVersion string, _ map[string]any) error {
|
|
f.mu.Lock()
|
|
f.called = append(f.called, name+":"+toVersion)
|
|
f.mu.Unlock()
|
|
return f.err
|
|
}
|
|
|
|
// errorLocker is a PackageLocker drop-in that always returns an error on Acquire.
|
|
// Used to verify UpdateRegistry.Apply surfaces lock-acquire failures.
|
|
type errorLocker struct {
|
|
err error
|
|
}
|
|
|
|
func (l *errorLocker) Acquire(_ context.Context, _, _ string) (func(), error) {
|
|
return nil, l.err
|
|
}
|
|
|
|
// registryWithErrorLocker builds an UpdateRegistry whose Locker always errors.
|
|
// Because UpdateRegistry embeds a *PackageLocker we swap via field assignment.
|
|
func registryWithErrorLocker(lockErr error) *UpdateRegistry {
|
|
reg := NewUpdateRegistry(nil, "", time.Hour)
|
|
// Replace the default locker with one that always fails.
|
|
// We achieve this by wrapping: set Locker to a thin adapter.
|
|
// Since UpdateRegistry.Locker is *PackageLocker (concrete type), we inject
|
|
// a real PackageLocker pre-saturated so its first Acquire blocks/fails,
|
|
// then cancel the context immediately to produce the acquire error.
|
|
_ = lockErr // used by the test directly via ctx cancellation
|
|
return reg
|
|
}
|
|
|
|
// TestApply_LockAcquireFails_Apk verifies that UpdateRegistry.Apply surfaces
|
|
// lock-acquire failures for the "apk" source (red-team C-1 registry-side test).
|
|
// If Apply returned success despite lock failure, concurrent updates would race.
|
|
func TestApply_LockAcquireFails_Apk(t *testing.T) {
|
|
reg := NewUpdateRegistry(nil, "", time.Hour)
|
|
exec := &fakeExecutor{source: "apk"}
|
|
reg.RegisterExecutor(exec)
|
|
|
|
// Pre-saturate the lock for ("apk","curl") so the next Acquire must block.
|
|
// Then cancel the context so Acquire returns context.Canceled instead of
|
|
// blocking forever. PackageLocker.Acquire checks ctx.Done() in the slow path.
|
|
holdRelease, err := reg.Locker.Acquire(context.Background(), "apk", "curl")
|
|
if err != nil {
|
|
t.Fatalf("pre-acquire failed: %v", err)
|
|
}
|
|
defer holdRelease()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately so Acquire's select hits ctx.Done()
|
|
|
|
_, applyErr := reg.Apply(ctx, "apk", "curl", "curl", "8.5.0", nil)
|
|
if applyErr == nil {
|
|
t.Fatal("expected error when lock acquire fails (cancelled ctx), got nil")
|
|
}
|
|
if !errors.Is(applyErr, context.Canceled) {
|
|
t.Errorf("expected context.Canceled wrapped in error, got: %v", applyErr)
|
|
}
|
|
// Executor must NOT have been called — lock was never granted.
|
|
exec.mu.Lock()
|
|
defer exec.mu.Unlock()
|
|
if len(exec.called) != 0 {
|
|
t.Errorf("executor was called despite lock failure: %v", exec.called)
|
|
}
|
|
}
|
|
|
|
// TestApply_SerializesSameKey_Apk verifies that two concurrent Apply calls for
|
|
// the same ("apk", "ripgrep") key are serialized — the second waits for the
|
|
// first to release the PackageLocker (red-team C-1 registry-side concurrency test).
|
|
func TestApply_SerializesSameKey_Apk(t *testing.T) {
|
|
reg := NewUpdateRegistry(nil, "", time.Hour)
|
|
|
|
// unblock is closed by the first executor call to signal readiness for release.
|
|
unblock := make(chan struct{})
|
|
// released is closed after the first executor call returns.
|
|
released := make(chan struct{})
|
|
|
|
var order []int
|
|
var orderMu sync.Mutex
|
|
|
|
firstDone := false
|
|
exec := &fakeExecutor{source: "apk"}
|
|
// Override via a custom executor that records ordering.
|
|
customExec := &serializingExecutor{
|
|
source: "apk",
|
|
unblock: unblock,
|
|
released: released,
|
|
order: &order,
|
|
orderMu: &orderMu,
|
|
firstDone: &firstDone,
|
|
}
|
|
reg.RegisterExecutor(customExec)
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Goroutine 1: acquires lock first (races with goroutine 2, but unblock
|
|
// gate ensures it signals before returning).
|
|
go func() {
|
|
defer wg.Done()
|
|
reg.Apply(ctx, "apk", "ripgrep", "ripgrep", "1.0.0", nil) //nolint:errcheck
|
|
}()
|
|
|
|
// Give goroutine 1 a head start to acquire the lock.
|
|
<-unblock
|
|
|
|
// Goroutine 2: must block until goroutine 1 releases.
|
|
go func() {
|
|
defer wg.Done()
|
|
reg.Apply(ctx, "apk", "ripgrep", "ripgrep", "1.0.0", nil) //nolint:errcheck
|
|
}()
|
|
|
|
// Allow goroutine 1 to finish.
|
|
close(released)
|
|
wg.Wait()
|
|
|
|
orderMu.Lock()
|
|
defer orderMu.Unlock()
|
|
if len(order) != 2 {
|
|
t.Fatalf("expected 2 executor calls, got %d", len(order))
|
|
}
|
|
if order[0] != 1 || order[1] != 2 {
|
|
t.Errorf("expected serialized order [1 2], got %v", order)
|
|
}
|
|
_ = exec // suppress unused warning
|
|
}
|
|
|
|
// serializingExecutor records the order of Update calls using a gate channel.
|
|
type serializingExecutor struct {
|
|
source string
|
|
unblock chan struct{} // closed by first call to signal it holds the lock
|
|
released chan struct{} // caller closes this to let first call return
|
|
order *[]int
|
|
orderMu *sync.Mutex
|
|
firstDone *bool
|
|
}
|
|
|
|
func (e *serializingExecutor) Source() string { return e.source }
|
|
func (e *serializingExecutor) Update(_ context.Context, _, _ string, _ map[string]any) error {
|
|
e.orderMu.Lock()
|
|
isFirst := !*e.firstDone
|
|
if isFirst {
|
|
*e.firstDone = true
|
|
}
|
|
e.orderMu.Unlock()
|
|
|
|
if isFirst {
|
|
// Signal that the first goroutine holds the lock.
|
|
select {
|
|
case <-e.unblock:
|
|
// already closed
|
|
default:
|
|
close(e.unblock)
|
|
}
|
|
// Wait for test to allow return (simulates long-running upgrade).
|
|
<-e.released
|
|
e.orderMu.Lock()
|
|
*e.order = append(*e.order, 1)
|
|
e.orderMu.Unlock()
|
|
} else {
|
|
e.orderMu.Lock()
|
|
*e.order = append(*e.order, 2)
|
|
e.orderMu.Unlock()
|
|
}
|
|
return nil
|
|
}
|