65f27ecbff
Co-authored-by: goose <goose@aaif.dev> Co-authored-by: Benjamin <benjaminchamwb@gmail.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com> Co-authored-by: João Salavisa <joao.salavisa@gmail.com>
835 lines
26 KiB
Go
835 lines
26 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
"github.com/kandev/kandev/internal/agent/hostutility"
|
|
"github.com/kandev/kandev/internal/agent/managedruntime"
|
|
"github.com/kandev/kandev/internal/agent/settings/dto"
|
|
ws "github.com/kandev/kandev/pkg/websocket"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type fakeRuntimeUpdater struct {
|
|
mu sync.Mutex
|
|
current hostutility.AgentCapabilities
|
|
currentFound bool
|
|
target string
|
|
resolveErr error
|
|
runErr error
|
|
runErrs []error
|
|
invalidateErr error
|
|
refreshCaps hostutility.AgentCapabilities
|
|
refreshErr error
|
|
runCommand []string
|
|
refreshCommand []string
|
|
updateOutput string
|
|
runStarted chan struct{}
|
|
releaseRun chan struct{}
|
|
runCalls int
|
|
invalidateCalls int
|
|
invalidatePkg string
|
|
refreshCalls int
|
|
resolvedPackage string
|
|
}
|
|
|
|
func TestAgentUpdateJobUsesNativeInstallAndRefresh(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, testExecutableName("opencode")), []byte("native"), 0o755); err != nil {
|
|
t.Fatalf("write native executable: %v", err)
|
|
}
|
|
t.Setenv("PATH", dir)
|
|
|
|
updater := &fakeRuntimeUpdater{
|
|
current: hostutility.AgentCapabilities{AgentVersion: "1.0.0"},
|
|
currentFound: true,
|
|
target: "1.1.0",
|
|
refreshCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.1.0"},
|
|
}
|
|
store, completed := newUpdateTestStore(updater, newMaintenanceCoordinator(), nil)
|
|
spec := agents.ManagedNPMRuntimeSpec{
|
|
Package: "opencode-ai",
|
|
ACPArgs: []string{"acp"},
|
|
NativeBinary: "opencode",
|
|
}
|
|
job, err := store.Enqueue("opencode-acp", spec)
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded)
|
|
|
|
updater.mu.Lock()
|
|
defer updater.mu.Unlock()
|
|
if got, want := updater.runCommand, []string{"npm", "install", "-g", "opencode-ai@1.1.0"}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("native update command = %#v, want %#v", got, want)
|
|
}
|
|
if got, want := updater.refreshCommand, []string{"opencode", "acp"}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("native refresh command = %#v, want %#v", got, want)
|
|
}
|
|
if updater.invalidateCalls != 0 {
|
|
t.Fatalf("native update invalidated npm cache %d times, want 0", updater.invalidateCalls)
|
|
}
|
|
}
|
|
|
|
func testExecutableName(name string) string {
|
|
if runtime.GOOS == "windows" {
|
|
return name + ".exe"
|
|
}
|
|
return name
|
|
}
|
|
|
|
type sequencedVersionUpdater struct {
|
|
fakeRuntimeUpdater
|
|
metadata RuntimeVersionMetadata
|
|
metadataErr error
|
|
metadataCall int
|
|
}
|
|
|
|
func (u *sequencedVersionUpdater) ResolveVersions(
|
|
_ context.Context,
|
|
_ string,
|
|
) (RuntimeVersionMetadata, error) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
u.metadataCall++
|
|
if u.metadataCall > 1 && u.metadataErr != nil {
|
|
return RuntimeVersionMetadata{}, u.metadataErr
|
|
}
|
|
return u.metadata, nil
|
|
}
|
|
|
|
type recordingCommandExecutor struct {
|
|
outputCommand []string
|
|
output string
|
|
}
|
|
|
|
func (e *recordingCommandExecutor) Output(
|
|
_ context.Context,
|
|
command agents.Command,
|
|
) (string, error) {
|
|
e.outputCommand = append([]string(nil), command.Args()...)
|
|
return e.output, nil
|
|
}
|
|
|
|
func (e *recordingCommandExecutor) Stream(
|
|
context.Context,
|
|
agents.Command,
|
|
func(string),
|
|
) error {
|
|
return nil
|
|
}
|
|
|
|
func TestHostRuntimeUpdaterResolvesTargetWithDirectNPMArgv(t *testing.T) {
|
|
executor := &recordingCommandExecutor{output: "\"1.2.3\"\n"}
|
|
updater := &hostRuntimeUpdater{executor: executor}
|
|
|
|
target, err := updater.ResolveTarget(context.Background(), "@example/managed-acp")
|
|
if err != nil {
|
|
t.Fatalf("ResolveTarget: %v", err)
|
|
}
|
|
if target != "1.2.3" {
|
|
t.Fatalf("target = %q, want 1.2.3", target)
|
|
}
|
|
want := []string{"npm", "view", "@example/managed-acp", "dist-tags.latest", "--json"}
|
|
if got := strings.Join(executor.outputCommand, "\x00"); got != strings.Join(want, "\x00") {
|
|
t.Fatalf("command = %v, want %v", executor.outputCommand, want)
|
|
}
|
|
}
|
|
|
|
func TestHostRuntimeUpdaterResolvesStableVersionCatalogue(t *testing.T) {
|
|
executor := &recordingCommandExecutor{
|
|
output: `{"versions":["1.0.1","1.0.2-beta.1","1.0.2"],"dist-tags":{"latest":"1.0.2"}}`,
|
|
}
|
|
updater := &hostRuntimeUpdater{executor: executor}
|
|
|
|
metadata, err := updater.ResolveVersions(context.Background(), "@example/managed-acp")
|
|
if err != nil {
|
|
t.Fatalf("ResolveVersions: %v", err)
|
|
}
|
|
if metadata.Latest != "1.0.2" || len(metadata.Versions) != 3 {
|
|
t.Fatalf("metadata = %#v", metadata)
|
|
}
|
|
want := []string{"npm", "view", "@example/managed-acp", "versions", "dist-tags", "--json"}
|
|
if got := strings.Join(executor.outputCommand, "\x00"); got != strings.Join(want, "\x00") {
|
|
t.Fatalf("command = %v, want %v", executor.outputCommand, want)
|
|
}
|
|
}
|
|
|
|
func TestHostRuntimeUpdaterInvalidatesOnlyManagedNPMExecutionTree(t *testing.T) {
|
|
cacheRoot := t.TempDir()
|
|
spec := agents.ManagedNPMRuntimeSpec{Package: "opencode-ai"}
|
|
target := filepath.Join(cacheRoot, "_npx", spec.ExecutionCacheKey())
|
|
other := filepath.Join(cacheRoot, "_npx", "keep-me")
|
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
|
t.Fatalf("mkdir target: %v", err)
|
|
}
|
|
if err := os.MkdirAll(other, 0o755); err != nil {
|
|
t.Fatalf("mkdir other: %v", err)
|
|
}
|
|
|
|
executor := &recordingCommandExecutor{output: cacheRoot + "\n"}
|
|
updater := &hostRuntimeUpdater{executor: executor}
|
|
if err := updater.InvalidateExecutionCache(context.Background(), spec.Package); err != nil {
|
|
t.Fatalf("InvalidateExecutionCache: %v", err)
|
|
}
|
|
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("target stat error = %v, want not exists", err)
|
|
}
|
|
if _, err := os.Stat(other); err != nil {
|
|
t.Fatalf("unrelated cache entry was removed: %v", err)
|
|
}
|
|
want := []string{"npm", "config", "get", "cache"}
|
|
if got := strings.Join(executor.outputCommand, "\x00"); got != strings.Join(want, "\x00") {
|
|
t.Fatalf("command = %v, want %v", executor.outputCommand, want)
|
|
}
|
|
}
|
|
|
|
func (f *fakeRuntimeUpdater) CurrentCapabilities(string) (hostutility.AgentCapabilities, bool) {
|
|
return f.current, f.currentFound
|
|
}
|
|
|
|
func (f *fakeRuntimeUpdater) ResolveTarget(_ context.Context, packageName string) (string, error) {
|
|
f.mu.Lock()
|
|
f.resolvedPackage = packageName
|
|
f.mu.Unlock()
|
|
return f.target, f.resolveErr
|
|
}
|
|
|
|
func (f *fakeRuntimeUpdater) RunUpdate(
|
|
ctx context.Context,
|
|
command agents.Command,
|
|
onChunk func(string),
|
|
) error {
|
|
f.mu.Lock()
|
|
f.runCalls++
|
|
f.runCommand = append([]string(nil), command.Args()...)
|
|
started := f.runStarted
|
|
release := f.releaseRun
|
|
output := f.updateOutput
|
|
err := f.runErr
|
|
if f.runCalls-1 < len(f.runErrs) {
|
|
err = f.runErrs[f.runCalls-1]
|
|
}
|
|
f.mu.Unlock()
|
|
if started != nil {
|
|
select {
|
|
case <-started:
|
|
default:
|
|
close(started)
|
|
}
|
|
}
|
|
if release != nil {
|
|
select {
|
|
case <-release:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
onChunk(output)
|
|
return err
|
|
}
|
|
|
|
func (f *fakeRuntimeUpdater) InvalidateExecutionCache(_ context.Context, packageName string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.invalidateCalls++
|
|
f.invalidatePkg = packageName
|
|
return f.invalidateErr
|
|
}
|
|
|
|
func (f *fakeRuntimeUpdater) Refresh(
|
|
_ context.Context,
|
|
_ string,
|
|
command agents.Command,
|
|
) (hostutility.AgentCapabilities, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.refreshCalls++
|
|
f.refreshCommand = append([]string(nil), command.Args()...)
|
|
return f.refreshCaps, f.refreshErr
|
|
}
|
|
|
|
type updateTerminalBroadcaster struct {
|
|
completed chan dto.AgentUpdateJobDTO
|
|
}
|
|
|
|
func newUpdateTerminalBroadcaster() *updateTerminalBroadcaster {
|
|
return &updateTerminalBroadcaster{completed: make(chan dto.AgentUpdateJobDTO, 2)}
|
|
}
|
|
|
|
func (b *updateTerminalBroadcaster) Broadcast(message *ws.Message) {
|
|
if message.Action != ws.ActionAgentUpdateFinished {
|
|
return
|
|
}
|
|
var job dto.AgentUpdateJobDTO
|
|
if json.Unmarshal(message.Payload, &job) != nil {
|
|
return
|
|
}
|
|
select {
|
|
case b.completed <- job:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func newUpdateTestStore(
|
|
updater RuntimeUpdater,
|
|
maintenance *maintenanceCoordinator,
|
|
onRefresh func(),
|
|
) (*AgentUpdateJobStore, <-chan dto.AgentUpdateJobDTO) {
|
|
hub := newUpdateTerminalBroadcaster()
|
|
return NewAgentUpdateJobStore(hub, zap.NewNop(), updater, maintenance, onRefresh), hub.completed
|
|
}
|
|
|
|
func waitForUpdateStatus(
|
|
t *testing.T,
|
|
completed <-chan dto.AgentUpdateJobDTO,
|
|
jobID string,
|
|
statuses ...dto.AgentUpdateJobStatus,
|
|
) *dto.AgentUpdateJobDTO {
|
|
t.Helper()
|
|
var snapshot dto.AgentUpdateJobDTO
|
|
select {
|
|
case snapshot = <-completed:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("job %s did not finish in time", jobID)
|
|
}
|
|
if snapshot.JobID != jobID {
|
|
t.Fatalf("finished job = %s, want %s", snapshot.JobID, jobID)
|
|
}
|
|
for _, status := range statuses {
|
|
if snapshot.Status == status {
|
|
return &snapshot
|
|
}
|
|
}
|
|
t.Fatalf("job %s finished with status %s, error %q, operation %q, want %v", jobID, snapshot.Status, snapshot.Error, snapshot.Operation, statuses)
|
|
return nil
|
|
}
|
|
|
|
func managedRuntimeSpec() agents.ManagedNPMRuntimeSpec {
|
|
return agents.ManagedNPMRuntimeSpec{
|
|
Package: "@example/managed-acp",
|
|
ACPArgs: []string{"--acp"},
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdatePreviewResolvesTrustedCommandWithoutStartingAJob(t *testing.T) {
|
|
updater := &fakeRuntimeUpdater{
|
|
current: hostutility.AgentCapabilities{AgentVersion: "1.0.0"},
|
|
currentFound: true,
|
|
target: "1.1.0",
|
|
}
|
|
ag := &managedTestAgent{
|
|
testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true},
|
|
spec: managedRuntimeSpec(),
|
|
}
|
|
ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag})
|
|
ctrl.SetRuntimeUpdater(updater)
|
|
|
|
preview, err := ctrl.PreviewAgentUpdate(context.Background(), ag.ID())
|
|
if err != nil {
|
|
t.Fatalf("PreviewAgentUpdate: %v", err)
|
|
}
|
|
if preview.CurrentVersion != "1.0.0" || preview.TargetVersion != "1.1.0" {
|
|
t.Fatalf("versions = %q -> %q", preview.CurrentVersion, preview.TargetVersion)
|
|
}
|
|
wantCommand := []string{
|
|
"npm", "exec", "--yes", "--prefer-online", "--package=@example/managed-acp", "--", "node", "-e", "",
|
|
}
|
|
if got := strings.Join(preview.Command, "\x00"); got != strings.Join(wantCommand, "\x00") {
|
|
t.Fatalf("command = %q, want %q", got, strings.Join(wantCommand, "\x00"))
|
|
}
|
|
if preview.CommandString != `npm exec --yes --prefer-online --package=@example/managed-acp -- node -e ""` {
|
|
t.Fatalf("command string = %q", preview.CommandString)
|
|
}
|
|
|
|
updater.mu.Lock()
|
|
defer updater.mu.Unlock()
|
|
if updater.resolvedPackage != "@example/managed-acp" {
|
|
t.Fatalf("resolved package = %q", updater.resolvedPackage)
|
|
}
|
|
if updater.runCalls != 0 || updater.refreshCalls != 0 {
|
|
t.Fatalf("preview mutated runtime: update=%d refresh=%d", updater.runCalls, updater.refreshCalls)
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdatePreviewUseDefaultClassifiesResetAndShowsEffectiveVersions(t *testing.T) {
|
|
updater := &recoveryRuntimeUpdater{
|
|
metadata: RuntimeVersionMetadata{Versions: []string{"1.0.0", "1.1.0"}, Latest: "1.1.0"},
|
|
current: hostutility.AgentCapabilities{
|
|
Status: hostutility.StatusOK,
|
|
AgentVersion: "1.0.0",
|
|
},
|
|
currentFound: true,
|
|
}
|
|
selectionStore := newRecoverySelectionStore()
|
|
selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{
|
|
Package: "@example/managed-acp",
|
|
Version: "1.0.0",
|
|
}
|
|
ag := &managedTestAgent{
|
|
testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true},
|
|
spec: agents.ManagedNPMRuntimeSpec{
|
|
Package: "@example/managed-acp",
|
|
DefaultVersion: "1.1.0",
|
|
},
|
|
}
|
|
ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag})
|
|
ctrl.SetRuntimeUpdater(updater)
|
|
ctrl.SetManagedRuntimeSelectionStore(selectionStore)
|
|
|
|
preview, err := ctrl.PreviewAgentUpdateUseDefault(context.Background(), ag.ID())
|
|
if err != nil {
|
|
t.Fatalf("PreviewAgentUpdateUseDefault: %v", err)
|
|
}
|
|
if preview.Operation != string(managedruntime.OperationUseDefault) {
|
|
t.Fatalf("operation = %q, want use_default", preview.Operation)
|
|
}
|
|
if preview.DefaultVersion != "1.1.0" || preview.EffectiveVersion != "1.0.0" || preview.TargetVersion != "1.1.0" {
|
|
t.Fatalf("versions = default %q, effective %q, target %q", preview.DefaultVersion, preview.EffectiveVersion, preview.TargetVersion)
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdatePreviewRejectsUnsupportedAndResolutionFailure(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
agent agents.Agent
|
|
updater *fakeRuntimeUpdater
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "unmanaged",
|
|
agent: &testAgent{id: "native", name: "Native", enabled: true},
|
|
updater: &fakeRuntimeUpdater{},
|
|
wantErr: ErrRuntimeUpdateUnsupported,
|
|
},
|
|
{
|
|
name: "registry failure",
|
|
agent: &managedTestAgent{
|
|
testAgent: testAgent{id: "managed", name: "Managed", enabled: true},
|
|
spec: managedRuntimeSpec(),
|
|
},
|
|
updater: &fakeRuntimeUpdater{resolveErr: errors.New("registry unavailable")},
|
|
wantErr: ErrRuntimeUpdatePreviewFailed,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
ctrl := newTestController(map[string]agents.Agent{test.agent.ID(): test.agent})
|
|
ctrl.SetRuntimeUpdater(test.updater)
|
|
_, err := ctrl.PreviewAgentUpdate(context.Background(), test.agent.ID())
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("PreviewAgentUpdate error = %v, want %v", err, test.wantErr)
|
|
}
|
|
test.updater.mu.Lock()
|
|
defer test.updater.mu.Unlock()
|
|
if test.updater.runCalls != 0 || test.updater.refreshCalls != 0 {
|
|
t.Fatalf("failed preview mutated runtime: update=%d refresh=%d", test.updater.runCalls, test.updater.refreshCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdatePreviewSurfacesSelectionStoreFailure(t *testing.T) {
|
|
updater := &fakeRuntimeUpdater{target: "1.1.0"}
|
|
selectionStore := newRecoverySelectionStore()
|
|
selectionStore.err = errors.New("selection store locked")
|
|
ag := &managedTestAgent{
|
|
testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true},
|
|
spec: managedRuntimeSpec(),
|
|
}
|
|
ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag})
|
|
ctrl.SetRuntimeUpdater(updater)
|
|
ctrl.SetManagedRuntimeSelectionStore(selectionStore)
|
|
|
|
_, err := ctrl.PreviewAgentUpdate(context.Background(), ag.ID())
|
|
if !errors.Is(err, ErrRuntimeUpdatePreviewFailed) {
|
|
t.Fatalf("PreviewAgentUpdate error = %v, want %v", err, ErrRuntimeUpdatePreviewFailed)
|
|
}
|
|
if !strings.Contains(err.Error(), "selection store locked") {
|
|
t.Fatalf("PreviewAgentUpdate error = %v, want selection error", err)
|
|
}
|
|
}
|
|
|
|
func TestEnqueueAgentUpdateReusesActiveJobBeforeMetadataResolution(t *testing.T) {
|
|
metadataErr := errors.New("registry unavailable")
|
|
updater := &sequencedVersionUpdater{
|
|
fakeRuntimeUpdater: fakeRuntimeUpdater{
|
|
current: hostutility.AgentCapabilities{AgentVersion: "1.0.0"},
|
|
currentFound: true,
|
|
refreshCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.1.0"},
|
|
runStarted: make(chan struct{}),
|
|
releaseRun: make(chan struct{}),
|
|
},
|
|
metadata: RuntimeVersionMetadata{Versions: []string{"1.0.0", "1.1.0"}, Latest: "1.1.0"},
|
|
metadataErr: metadataErr,
|
|
}
|
|
hub := newUpdateTerminalBroadcaster()
|
|
ag := &managedTestAgent{
|
|
testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true},
|
|
spec: managedRuntimeSpec(),
|
|
}
|
|
ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag})
|
|
ctrl.SetRuntimeUpdater(updater)
|
|
ctrl.updateJobStore = NewAgentUpdateJobStore(
|
|
hub,
|
|
zap.NewNop(),
|
|
updater,
|
|
newMaintenanceCoordinator(),
|
|
nil,
|
|
)
|
|
|
|
first, err := ctrl.EnqueueAgentUpdate(context.Background(), ag.ID(), "1.1.0")
|
|
if err != nil {
|
|
t.Fatalf("first EnqueueAgentUpdate: %v", err)
|
|
}
|
|
select {
|
|
case <-updater.runStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("first update did not reach the running phase")
|
|
}
|
|
|
|
second, err := ctrl.EnqueueAgentUpdate(context.Background(), ag.ID(), "1.1.0")
|
|
if err != nil {
|
|
t.Fatalf("second EnqueueAgentUpdate: %v", err)
|
|
}
|
|
if second.JobID != first.JobID {
|
|
t.Fatalf("job IDs differ: %s != %s", second.JobID, first.JobID)
|
|
}
|
|
updater.mu.Lock()
|
|
metadataCalls := updater.metadataCall
|
|
updater.mu.Unlock()
|
|
if metadataCalls != 1 {
|
|
t.Fatalf("metadata calls = %d, want one initial validation", metadataCalls)
|
|
}
|
|
|
|
close(updater.releaseRun)
|
|
waitForUpdateStatus(t, hub.completed, first.JobID, dto.AgentUpdateJobStatusSucceeded)
|
|
}
|
|
|
|
func TestEnqueueAgentUpdateDoesNotCreateJobForAlreadyActiveHealthyTarget(t *testing.T) {
|
|
selectionStore := newRecoverySelectionStore()
|
|
selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{
|
|
Package: "@example/managed-acp",
|
|
Version: "1.1.0",
|
|
}
|
|
updater := &recoveryRuntimeUpdater{
|
|
metadata: RuntimeVersionMetadata{Versions: []string{"1.1.0"}, Latest: "1.1.0"},
|
|
current: hostutility.AgentCapabilities{
|
|
Status: hostutility.StatusOK,
|
|
AgentVersion: "1.1.0",
|
|
},
|
|
currentFound: true,
|
|
}
|
|
ag := &managedTestAgent{
|
|
testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true},
|
|
spec: managedRuntimeSpec(),
|
|
}
|
|
ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag})
|
|
ctrl.SetManagedRuntimeSelectionStore(selectionStore)
|
|
ctrl.SetJobBroadcaster(newUpdateTerminalBroadcaster())
|
|
ctrl.SetRuntimeUpdater(updater)
|
|
|
|
result, err := ctrl.EnqueueAgentUpdate(context.Background(), ag.ID(), "1.1.0")
|
|
if err != nil {
|
|
t.Fatalf("EnqueueAgentUpdate: %v", err)
|
|
}
|
|
if result.JobID != "" {
|
|
t.Fatalf("no-op response job ID = %q, want no persisted job", result.JobID)
|
|
}
|
|
if result.Operation != string(managedruntime.OperationUpToDate) {
|
|
t.Fatalf("no-op operation = %q, want up_to_date", result.Operation)
|
|
}
|
|
if jobs := ctrl.ListAgentUpdateJobs(); len(jobs) != 0 {
|
|
t.Fatalf("retained jobs = %d, want none", len(jobs))
|
|
}
|
|
if updater.runCalls != 0 || len(updater.probe) != 0 {
|
|
t.Fatalf("no-op mutated updater: runs=%d probes=%d", updater.runCalls, len(updater.probe))
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdateJobResolvesUpdatesRefreshesAndStreams(t *testing.T) {
|
|
updater := &fakeRuntimeUpdater{
|
|
current: hostutility.AgentCapabilities{AgentVersion: "1.0.0"},
|
|
currentFound: true,
|
|
target: "1.1.0",
|
|
refreshCaps: hostutility.AgentCapabilities{
|
|
Status: hostutility.StatusOK,
|
|
AgentVersion: "1.1.0",
|
|
},
|
|
updateOutput: "npm prepared runtime\n",
|
|
}
|
|
refreshed := make(chan struct{}, 1)
|
|
store, completed := newUpdateTestStore(
|
|
updater, newMaintenanceCoordinator(), func() { refreshed <- struct{}{} },
|
|
)
|
|
|
|
job, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded)
|
|
if final.CurrentVersion != "1.1.0" || final.TargetVersion != "1.1.0" {
|
|
t.Fatalf("versions = %q -> %q, want 1.1.0 -> 1.1.0", final.CurrentVersion, final.TargetVersion)
|
|
}
|
|
if final.Output != "npm prepared runtime\n" {
|
|
t.Fatalf("Output = %q", final.Output)
|
|
}
|
|
select {
|
|
case <-refreshed:
|
|
default:
|
|
t.Fatal("successful refresh did not invoke catalogue callback")
|
|
}
|
|
|
|
updater.mu.Lock()
|
|
defer updater.mu.Unlock()
|
|
if updater.resolvedPackage != "@example/managed-acp" {
|
|
t.Fatalf("resolved package = %q", updater.resolvedPackage)
|
|
}
|
|
wantUpdate := "npm exec --yes --prefer-online --package=@example/managed-acp -- node -e "
|
|
if got := strings.Join(updater.runCommand, " "); got != wantUpdate {
|
|
t.Fatalf("update command = %q, want %q", got, wantUpdate)
|
|
}
|
|
wantRefresh := "npx --yes --prefer-offline @example/managed-acp@1.1.0 --acp"
|
|
if got := strings.Join(updater.refreshCommand, " "); got != wantRefresh {
|
|
t.Fatalf("refresh command = %q, want %q", got, wantRefresh)
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdateJobSkipsCommandWhenRuntimeIsAlreadyUpToDate(t *testing.T) {
|
|
updater := &fakeRuntimeUpdater{
|
|
current: hostutility.AgentCapabilities{AgentVersion: "1.1.0"},
|
|
currentFound: true,
|
|
target: "1.1.0",
|
|
refreshCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.1.0"},
|
|
}
|
|
store, completed := newUpdateTestStore(updater, newMaintenanceCoordinator(), nil)
|
|
|
|
job, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded)
|
|
if final.CurrentVersion != "1.1.0" || final.TargetVersion != "1.1.0" {
|
|
t.Fatalf("versions = %q -> %q, want 1.1.0 -> 1.1.0", final.CurrentVersion, final.TargetVersion)
|
|
}
|
|
if final.Output != "Runtime already up to date.\n" {
|
|
t.Fatalf("Output = %q, want no-op message", final.Output)
|
|
}
|
|
|
|
updater.mu.Lock()
|
|
defer updater.mu.Unlock()
|
|
if updater.runCalls != 0 {
|
|
t.Fatalf("update calls = %d, want 0", updater.runCalls)
|
|
}
|
|
if updater.refreshCalls != 0 {
|
|
t.Fatalf("refresh calls = %d, want 0", updater.refreshCalls)
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdateAuthRequiredIsPackageSuccessWithRefreshError(t *testing.T) {
|
|
updater := &fakeRuntimeUpdater{
|
|
target: "1.1.0",
|
|
refreshCaps: hostutility.AgentCapabilities{
|
|
Status: hostutility.StatusAuthRequired,
|
|
Error: "login required",
|
|
},
|
|
}
|
|
refreshed := false
|
|
store, completed := newUpdateTestStore(
|
|
updater, newMaintenanceCoordinator(), func() { refreshed = true },
|
|
)
|
|
|
|
job, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded)
|
|
if final.RefreshError != "login required" {
|
|
t.Fatalf("RefreshError = %q, want login required", final.RefreshError)
|
|
}
|
|
if final.Error != "" {
|
|
t.Fatalf("Error = %q, want empty", final.Error)
|
|
}
|
|
if refreshed {
|
|
t.Fatal("auth-required refresh must not broadcast a replacement catalogue")
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdateRegistryFailureStopsBeforeMutation(t *testing.T) {
|
|
updater := &fakeRuntimeUpdater{resolveErr: errors.New("registry unavailable")}
|
|
store, completed := newUpdateTestStore(updater, newMaintenanceCoordinator(), nil)
|
|
job, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusFailed)
|
|
if !strings.Contains(final.Error, "registry unavailable") {
|
|
t.Fatalf("Error = %q", final.Error)
|
|
}
|
|
updater.mu.Lock()
|
|
defer updater.mu.Unlock()
|
|
if updater.runCalls != 0 || updater.refreshCalls != 0 {
|
|
t.Fatalf("calls after registry failure: update=%d refresh=%d", updater.runCalls, updater.refreshCalls)
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdateRepairsExecutionCacheAndRetriesOnce(t *testing.T) {
|
|
updater := &fakeRuntimeUpdater{
|
|
target: "1.1.0",
|
|
runErrs: []error{errors.New("truncated npm execution tree"), nil},
|
|
refreshCaps: hostutility.AgentCapabilities{
|
|
Status: hostutility.StatusOK,
|
|
AgentVersion: "1.1.0",
|
|
},
|
|
}
|
|
store, completed := newUpdateTestStore(updater, newMaintenanceCoordinator(), nil)
|
|
|
|
job, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded)
|
|
if !strings.Contains(final.Output, "repairing execution cache") ||
|
|
!strings.Contains(final.Output, "retrying managed runtime update") {
|
|
t.Fatalf("recovery output = %q", final.Output)
|
|
}
|
|
|
|
updater.mu.Lock()
|
|
defer updater.mu.Unlock()
|
|
if updater.runCalls != 2 {
|
|
t.Fatalf("update calls = %d, want 2", updater.runCalls)
|
|
}
|
|
if updater.invalidateCalls != 1 || updater.invalidatePkg != managedRuntimeSpec().Package {
|
|
t.Fatalf("cache repair = %d calls for %q", updater.invalidateCalls, updater.invalidatePkg)
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdateHardFailuresRemainFailed(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
updater *fakeRuntimeUpdater
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "package update",
|
|
updater: &fakeRuntimeUpdater{
|
|
target: "1.1.0",
|
|
runErr: errors.New("npm exec failed"),
|
|
},
|
|
wantMessage: "npm exec failed",
|
|
},
|
|
{
|
|
name: "ACP initialization",
|
|
updater: &fakeRuntimeUpdater{
|
|
target: "1.1.0",
|
|
refreshCaps: hostutility.AgentCapabilities{
|
|
Status: hostutility.StatusFailed,
|
|
Error: "unsupported protocol version",
|
|
},
|
|
},
|
|
wantMessage: "unsupported protocol version",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
refreshed := false
|
|
store, completed := newUpdateTestStore(
|
|
test.updater, newMaintenanceCoordinator(), func() { refreshed = true },
|
|
)
|
|
job, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusFailed)
|
|
if !strings.Contains(final.Error, test.wantMessage) {
|
|
t.Fatalf("Error = %q, want %q", final.Error, test.wantMessage)
|
|
}
|
|
if refreshed {
|
|
t.Fatal("hard failure invoked catalogue refresh callback")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAgentUpdateDeduplicatesAndConflictsWithInstall(t *testing.T) {
|
|
coordinator := newMaintenanceCoordinator()
|
|
updater := &fakeRuntimeUpdater{
|
|
target: "1.1.0",
|
|
refreshCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK},
|
|
runStarted: make(chan struct{}),
|
|
releaseRun: make(chan struct{}),
|
|
}
|
|
store, completed := newUpdateTestStore(updater, coordinator, nil)
|
|
t.Cleanup(func() {
|
|
select {
|
|
case <-updater.releaseRun:
|
|
default:
|
|
close(updater.releaseRun)
|
|
}
|
|
})
|
|
|
|
first, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
|
if err != nil {
|
|
t.Fatalf("first enqueue: %v", err)
|
|
}
|
|
<-updater.runStarted
|
|
second, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
|
if err != nil {
|
|
t.Fatalf("second enqueue: %v", err)
|
|
}
|
|
if first.ID != second.ID {
|
|
t.Fatalf("job IDs differ: %s != %s", first.ID, second.ID)
|
|
}
|
|
|
|
installStore := NewJobStore(&captureBroadcaster{}, zap.NewNop(), nil, coordinator)
|
|
_, err = installStore.Enqueue("managed-acp", "echo install")
|
|
var conflict *MaintenanceConflictError
|
|
if !errors.As(err, &conflict) {
|
|
t.Fatalf("install conflict error = %v", err)
|
|
}
|
|
if conflict.Active.JobID != first.ID || conflict.Active.Kind != MaintenanceKindUpdate {
|
|
t.Fatalf("active conflict = %#v", conflict.Active)
|
|
}
|
|
|
|
close(updater.releaseRun)
|
|
waitForUpdateStatus(t, completed, first.ID, dto.AgentUpdateJobStatusSucceeded)
|
|
|
|
retry, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
|
if err != nil {
|
|
t.Fatalf("retry enqueue: %v", err)
|
|
}
|
|
if retry.ID == first.ID {
|
|
t.Fatal("retry reused a completed update job")
|
|
}
|
|
waitForUpdateStatus(t, completed, retry.ID, dto.AgentUpdateJobStatusSucceeded)
|
|
}
|
|
|
|
func TestAgentUpdateOutputIsBounded(t *testing.T) {
|
|
updater := &fakeRuntimeUpdater{
|
|
target: "1.1.0",
|
|
refreshCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK},
|
|
updateOutput: strings.Repeat("line contents\n", 7000),
|
|
}
|
|
store, completed := newUpdateTestStore(updater, newMaintenanceCoordinator(), nil)
|
|
|
|
job, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded)
|
|
if len(final.Output) > jobOutputRingSize {
|
|
t.Fatalf("output bytes = %d, limit = %d", len(final.Output), jobOutputRingSize)
|
|
}
|
|
}
|