mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-24 08:23:31 +00:00
v6.1.0-rc.1 retired the legacy update endpoints before a replacement existed, so the UI's Update button failed with an internal-jargon 410 (issue #1564). This lands the replacement end to end: update_container is a typed agentexec operation with its own strict codec, durable receipts, and a request digest bound to the image digest the plan observed; the unified agent bridges execution to the Docker module's existing pull/backup/recreate/verify/rollback implementation (which now reports rollback attempt and outcome); and the container action executor plans, dispatches, and reconciles the operation with declared backup/rollback compensation truth. Containers advertise an admin-approval update capability while an image update with a stated current digest is detected. The legacy endpoints stay retired but return actionable copy. Proven live against a Colima daemon: single-container update, the issue-1564 shared-network-namespace update, and the full UI journey (Update button, governed review, approve, run) all completed with the namespace preserved and the backup retained.
621 lines
28 KiB
Go
621 lines
28 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
|
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
)
|
|
|
|
type fakeDockerActionAgentCommander struct {
|
|
results []*agentexec.CommandResultPayload
|
|
calls []agentexec.ExecuteCommandPayload
|
|
callAgents []string
|
|
typedCalls []agentexec.DockerContainerLifecyclePayload
|
|
typedUpdateCalls []agentexec.DockerContainerUpdatePayload
|
|
connected map[string]bool
|
|
agentByHost map[string]string
|
|
queryResult operationreceipt.QueryResult
|
|
queries []operationreceipt.Identity
|
|
}
|
|
|
|
func dockerActionDispatchContext(t *testing.T, executor dockerContainerActionExecutor, record unified.ActionAuditRecord) context.Context {
|
|
t.Helper()
|
|
attempt, err := unified.NewActionDispatchAttempt(record.ID, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
attempt, err = executor.BindActionDispatch(context.Background(), record, attempt)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return actionlifecycle.ContextWithCommittedDispatchAttempt(context.Background(), attempt)
|
|
}
|
|
|
|
func (f *fakeDockerActionAgentCommander) AgentOperationReceiptVersion(string) int { return 1 }
|
|
|
|
func (f *fakeDockerActionAgentCommander) QueryAgentOperation(_ context.Context, _ string, identity operationreceipt.Identity) (operationreceipt.QueryResult, error) {
|
|
f.queries = append(f.queries, identity)
|
|
return f.queryResult, nil
|
|
}
|
|
|
|
func (f *fakeDockerActionAgentCommander) ExecuteDockerContainerLifecycle(_ context.Context, agentID string, req agentexec.DockerContainerLifecyclePayload) (*agentexec.DockerContainerLifecycleResultPayload, error) {
|
|
f.callAgents = append(f.callAgents, agentID)
|
|
f.typedCalls = append(f.typedCalls, req)
|
|
now := time.Now().UTC()
|
|
return &agentexec.DockerContainerLifecycleResultPayload{
|
|
RequestID: req.RequestID, ActionID: req.ActionID, Operation: req.Operation, OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest, ContainerID: req.ContainerID,
|
|
ExecutionPhase: agentexec.DockerContainerPhaseComplete, MutationStarted: true, MutationCompleted: true, ReadbackRan: true,
|
|
Before: agentexec.DockerContainerLifecycleSnapshot{ContainerID: req.ContainerID, State: req.ExpectedState, Running: true, StartedAt: req.ExpectedStartedAt, ObservedAt: now.Add(-time.Second)},
|
|
After: agentexec.DockerContainerLifecycleSnapshot{ContainerID: req.ContainerID, State: "running", Running: true, StartedAt: now, ObservedAt: now},
|
|
}, nil
|
|
}
|
|
|
|
func (f *fakeDockerActionAgentCommander) ExecuteDockerContainerUpdate(_ context.Context, agentID string, req agentexec.DockerContainerUpdatePayload) (*agentexec.DockerContainerUpdateResultPayload, error) {
|
|
f.callAgents = append(f.callAgents, agentID)
|
|
f.typedUpdateCalls = append(f.typedUpdateCalls, req)
|
|
now := time.Now().UTC()
|
|
newID := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
|
return &agentexec.DockerContainerUpdateResultPayload{
|
|
RequestID: req.RequestID, ActionID: req.ActionID, Operation: req.Operation, OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest, ContainerID: req.ContainerID,
|
|
ExecutionPhase: agentexec.DockerContainerPhaseComplete, MutationStarted: true, MutationCompleted: true, ReadbackRan: true,
|
|
NewContainerID: newID, ContainerName: "api",
|
|
OldImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
|
NewImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
|
BackupCreated: true, BackupContainer: "api_pulse_backup_20260714_000000",
|
|
After: agentexec.DockerContainerLifecycleSnapshot{ContainerID: newID, State: "running", Running: true, StartedAt: now, ObservedAt: now},
|
|
}, nil
|
|
}
|
|
|
|
func (f *fakeDockerActionAgentCommander) ExecuteCommand(_ context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) {
|
|
f.callAgents = append(f.callAgents, agentID)
|
|
f.calls = append(f.calls, cmd)
|
|
if len(f.results) == 0 {
|
|
return &agentexec.CommandResultPayload{RequestID: cmd.RequestID, Success: true, ExitCode: 0}, nil
|
|
}
|
|
result := f.results[0]
|
|
f.results = f.results[1:]
|
|
return result, nil
|
|
}
|
|
|
|
func (f *fakeDockerActionAgentCommander) GetAgentForHost(hostname string) (string, bool) {
|
|
if f.agentByHost == nil {
|
|
return "", false
|
|
}
|
|
agentID, ok := f.agentByHost[strings.TrimSpace(hostname)]
|
|
return agentID, ok
|
|
}
|
|
|
|
func (f *fakeDockerActionAgentCommander) IsAgentConnected(agentID string) bool {
|
|
if f.connected == nil {
|
|
return true
|
|
}
|
|
return f.connected[agentID]
|
|
}
|
|
|
|
func dockerActionCapabilityNames(capabilities []unified.ResourceCapability) []string {
|
|
names := make([]string, 0, len(capabilities))
|
|
for _, capability := range capabilities {
|
|
names = append(names, capability.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func dockerActionReadinessByName(readinesses []unified.ResourceActionReadiness, name string) (unified.ResourceActionReadiness, bool) {
|
|
for _, readiness := range readinesses {
|
|
if readiness.Name == name {
|
|
return readiness, true
|
|
}
|
|
}
|
|
return unified.ResourceActionReadiness{}, false
|
|
}
|
|
|
|
func TestDockerContainerActionExecutorDispatchesPodmanRestartAndVerification(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{
|
|
dockerContainerActionResource("app-container:api", "podman", "running", now),
|
|
},
|
|
})
|
|
agents := &fakeDockerActionAgentCommander{}
|
|
executor := newDockerContainerActionExecutor(h, agents).(dockerContainerActionExecutor)
|
|
record := dockerContainerActionRecord("act_container", "app-container:api", "restart")
|
|
|
|
result, err := executor.ExecuteAction(dockerActionDispatchContext(t, executor, record), record)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteAction: %v", err)
|
|
}
|
|
if result == nil || !result.Success || result.Verification == nil || !result.Verification.Success {
|
|
t.Fatalf("result = %#v, want successful execution and verification", result)
|
|
}
|
|
if len(agents.typedCalls) != 1 || len(agents.calls) != 0 {
|
|
t.Fatalf("typed/raw agent calls = %d/%d, want one typed dispatch and zero raw commands", len(agents.typedCalls), len(agents.calls))
|
|
}
|
|
if got := agents.typedCalls[0].Operation; got != agentexec.DockerContainerOperationRestart {
|
|
t.Fatalf("typed operation = %q", got)
|
|
}
|
|
if agents.typedCalls[0].Runtime != "podman" {
|
|
t.Fatalf("runtime = %q", agents.typedCalls[0].Runtime)
|
|
}
|
|
if agents.typedCalls[0].RequestID != "act_container.dispatch.1" {
|
|
t.Fatalf("dispatch request identity = %q", agents.typedCalls[0].RequestID)
|
|
}
|
|
}
|
|
|
|
func TestDockerContainerActionExecutorResolvesCommandAgentByDockerHostname(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
resource := dockerContainerActionResource("app-container:api", "docker", "running", now)
|
|
resource.Docker.AgentID = "docker-source-1"
|
|
resource.Docker.Hostname = "tower"
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{resource},
|
|
})
|
|
agents := &fakeDockerActionAgentCommander{
|
|
connected: map[string]bool{
|
|
"docker-source-1": false,
|
|
"command-agent-1": true,
|
|
},
|
|
agentByHost: map[string]string{"tower": "command-agent-1"},
|
|
}
|
|
executor := newDockerContainerActionExecutor(h, agents).(dockerContainerActionExecutor)
|
|
|
|
readiness := executor.CheckActionAvailable(context.Background(), unified.ActionRequest{
|
|
RequestID: "req-availability",
|
|
ResourceID: "app-container:api",
|
|
CapabilityName: "restart",
|
|
Reason: "operator requested restart",
|
|
RequestedBy: "operator",
|
|
}, resource)
|
|
if !readiness.Available {
|
|
t.Fatalf("CheckActionAvailable readiness = %#v, want available through hostname-resolved command agent", readiness)
|
|
}
|
|
|
|
record := dockerContainerActionRecord("act_container", "app-container:api", "restart")
|
|
result, err := executor.ExecuteAction(dockerActionDispatchContext(t, executor, record), record)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteAction: %v", err)
|
|
}
|
|
if result == nil || !result.Success {
|
|
t.Fatalf("result = %#v, want successful execution", result)
|
|
}
|
|
if len(agents.callAgents) != 1 {
|
|
t.Fatalf("call agents = %#v, want one typed dispatch through hostname-resolved agent", agents.callAgents)
|
|
}
|
|
for _, agentID := range agents.callAgents {
|
|
if agentID != "command-agent-1" {
|
|
t.Fatalf("called agent %q, want command-agent-1; all calls %#v", agentID, agents.callAgents)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDockerContainerActionExecutorFailsWhenCapabilityNoLongerAdvertised(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
resource := dockerContainerActionResource("app-container:api", "docker", "running", now)
|
|
resource.Capabilities = nil
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{resource},
|
|
})
|
|
agents := &fakeDockerActionAgentCommander{}
|
|
executor := newDockerContainerActionExecutor(h, agents)
|
|
|
|
result, err := executor.ExecuteAction(actionDispatchTestContext(t, "act_container"), dockerContainerActionRecord("act_container", "app-container:api", "restart"))
|
|
if err == nil || !strings.Contains(err.Error(), "does not currently advertise restart capability") {
|
|
t.Fatalf("ExecuteAction err = %v, result = %#v", err, result)
|
|
}
|
|
if len(agents.calls) != 0 {
|
|
t.Fatalf("agent calls = %#v, want none", agents.calls)
|
|
}
|
|
}
|
|
|
|
func TestDockerContainerActionExecutorAvailabilityRequiresConnectedAgent(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
resource := dockerContainerActionResource("app-container:api", "docker", "running", now)
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{resource},
|
|
})
|
|
agents := &fakeDockerActionAgentCommander{connected: map[string]bool{"agent-1": false}}
|
|
executor := newDockerContainerActionExecutor(h, agents).(dockerContainerActionExecutor)
|
|
|
|
readiness := executor.CheckActionAvailable(context.Background(), unified.ActionRequest{
|
|
RequestID: "req-availability",
|
|
ResourceID: "app-container:api",
|
|
CapabilityName: "restart",
|
|
Reason: "operator requested restart",
|
|
RequestedBy: "operator",
|
|
}, resource)
|
|
if readiness.Available || readiness.ReasonCode != "command_agent_disconnected" || readiness.Reason != "Docker / Podman command agent is not connected." {
|
|
t.Fatalf("CheckActionAvailable readiness = %#v, want disconnected agent", readiness)
|
|
}
|
|
if len(agents.calls) != 0 {
|
|
t.Fatalf("agent calls = %#v, want none", agents.calls)
|
|
}
|
|
}
|
|
|
|
func TestDockerContainerActionExecutorStaleFixtureIsTripleZero(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
resource := dockerContainerActionResource("app-container:api", "docker", "running", now)
|
|
resource.SourceStatus[unified.SourceDocker] = unified.SourceStatus{Status: "stale", LastSeen: now.Add(-time.Hour)}
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{resource}})
|
|
agents := &fakeDockerActionAgentCommander{}
|
|
executor := newDockerContainerActionExecutor(h, agents).(dockerContainerActionExecutor)
|
|
readiness := executor.CheckActionAvailable(context.Background(), unified.ActionRequest{ResourceID: resource.ID, CapabilityName: "restart"}, resource)
|
|
if readiness.Available || readiness.ReasonCode != "stale_inventory" || len(agents.typedCalls) != 0 || len(agents.calls) != 0 {
|
|
t.Fatalf("stale readiness=%#v typed=%d raw=%d", readiness, len(agents.typedCalls), len(agents.calls))
|
|
}
|
|
}
|
|
|
|
func TestDockerContainerActionExecutorCallbackLossReconcilesReceiptWithoutRedispatch(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
resource := dockerContainerActionResource("app-container:api", "docker", "running", now)
|
|
startedAt := now.Add(-time.Minute)
|
|
resource.Docker.StartedAt = &startedAt
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{resource}})
|
|
agents := &fakeDockerActionAgentCommander{}
|
|
executor := newDockerContainerActionExecutor(h, agents).(dockerContainerActionExecutor)
|
|
record := dockerContainerActionRecord("act_container", resource.ID, "restart")
|
|
attempt, err := unified.NewActionDispatchAttempt(record.ID, now.Add(-5*time.Second))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
attempt, err = executor.BindActionDispatch(context.Background(), record, attempt)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
facts := agentexec.DockerContainerLifecycleResultPayload{
|
|
RequestID: attempt.ID, ActionID: record.ID, Operation: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, ContainerID: resource.Docker.ContainerID,
|
|
ExecutionPhase: agentexec.DockerContainerPhaseComplete, MutationStarted: true, MutationCompleted: true, ReadbackRan: true,
|
|
Before: agentexec.DockerContainerLifecycleSnapshot{ContainerID: resource.Docker.ContainerID, State: "running", Running: true, StartedAt: startedAt, ObservedAt: now.Add(-2 * time.Second)},
|
|
After: agentexec.DockerContainerLifecycleSnapshot{ContainerID: resource.Docker.ContainerID, State: "running", Running: true, StartedAt: now.Add(-time.Second), ObservedAt: now.Add(-time.Second)},
|
|
}
|
|
raw, _ := json.Marshal(facts)
|
|
identity := operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}
|
|
agents.queryResult = operationreceipt.QueryResult{Version: operationreceipt.ProtocolVersion, Status: operationreceipt.QueryFoundTerminal, Record: &operationreceipt.Record{Identity: identity, State: operationreceipt.StateTerminal, AcceptedAt: now.Add(-4 * time.Second), StartedAt: now.Add(-3 * time.Second), TerminalAt: now, ResultKind: agentexec.DockerContainerLifecycleReceiptKind, ResultVersion: agentexec.DockerContainerLifecycleReceiptVersion, Result: raw}}
|
|
result, _, found, err := executor.ReconcileActionDispatch(context.Background(), record, attempt)
|
|
if err != nil || !found || result == nil || result.ActionResultV2.Execution.Status != unified.ActionExecutionSucceeded || len(agents.typedCalls) != 0 || len(agents.queries) != 1 {
|
|
t.Fatalf("reconcile result=%#v found=%v err=%v typed=%d queries=%d", result, found, err, len(agents.typedCalls), len(agents.queries))
|
|
}
|
|
}
|
|
|
|
func TestHandlePlanActionRejectsDisconnectedDockerContainerAgent(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{
|
|
dockerContainerActionResource("app-container:api", "docker", "running", now),
|
|
},
|
|
})
|
|
h.SetActionExecutor(newDockerContainerActionExecutor(h, &fakeDockerActionAgentCommander{
|
|
connected: map[string]bool{"agent-1": false},
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/actions/plan", bytes.NewBufferString(`{
|
|
"requestId":"req-disconnected-agent",
|
|
"resourceId":"app-container:api",
|
|
"capabilityName":"restart",
|
|
"reason":"operator requested restart",
|
|
"requestedBy":"operator"
|
|
}`))
|
|
h.HandlePlanAction(rec, actionHandlerTestRequest(req, ""))
|
|
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("plan status = %d, want %d, body=%s", rec.Code, http.StatusConflict, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"error":"action_execution_unavailable"`) ||
|
|
!strings.Contains(rec.Body.String(), `"reason":"Docker / Podman command agent is not connected."`) ||
|
|
!strings.Contains(rec.Body.String(), `"reasonCode":"command_agent_disconnected"`) {
|
|
t.Fatalf("unexpected response body: %s", rec.Body.String())
|
|
}
|
|
store, err := h.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("get store: %v", err)
|
|
}
|
|
audits, err := store.GetActionAudits("app-container:api", time.Time{}, 10)
|
|
if err != nil {
|
|
t.Fatalf("GetActionAudits: %v", err)
|
|
}
|
|
if len(audits) != 0 {
|
|
t.Fatalf("audits = %#v, want none for refused plan", audits)
|
|
}
|
|
}
|
|
|
|
func TestResourceResponsesFilterDisconnectedDockerLifecycleCapabilities(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{
|
|
dockerContainerActionResource("app-container:api", "docker", "running", now),
|
|
},
|
|
})
|
|
h.SetActionExecutor(newDockerContainerActionExecutor(h, &fakeDockerActionAgentCommander{
|
|
connected: map[string]bool{"agent-1": false},
|
|
}))
|
|
|
|
listRec := httptest.NewRecorder()
|
|
listReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=app-container", nil)
|
|
h.HandleListResources(listRec, listReq)
|
|
if listRec.Code != http.StatusOK {
|
|
t.Fatalf("list status = %d, body=%s", listRec.Code, listRec.Body.String())
|
|
}
|
|
var list ResourcesResponse
|
|
if err := json.Unmarshal(listRec.Body.Bytes(), &list); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
if len(list.Data) != 1 {
|
|
t.Fatalf("list data len = %d, want 1", len(list.Data))
|
|
}
|
|
if got := dockerActionCapabilityNames(list.Data[0].Capabilities); len(got) != 0 {
|
|
t.Fatalf("list capabilities = %#v, want none", got)
|
|
}
|
|
readiness, ok := dockerActionReadinessByName(list.Data[0].ActionReadiness, "restart")
|
|
if !ok || readiness.Available || readiness.ReasonCode != "command_agent_disconnected" {
|
|
t.Fatalf("list action readiness = %#v, ok=%v; want disconnected restart", list.Data[0].ActionReadiness, ok)
|
|
}
|
|
|
|
detailRec := httptest.NewRecorder()
|
|
detailReq := httptest.NewRequest(http.MethodGet, "/api/resources/app-container:api", nil)
|
|
h.HandleGetResource(detailRec, detailReq)
|
|
if detailRec.Code != http.StatusOK {
|
|
t.Fatalf("detail status = %d, body=%s", detailRec.Code, detailRec.Body.String())
|
|
}
|
|
var detail unified.Resource
|
|
if err := json.Unmarshal(detailRec.Body.Bytes(), &detail); err != nil {
|
|
t.Fatalf("decode detail: %v", err)
|
|
}
|
|
if got := dockerActionCapabilityNames(detail.Capabilities); len(got) != 0 {
|
|
t.Fatalf("detail capabilities = %#v, want none", got)
|
|
}
|
|
readiness, ok = dockerActionReadinessByName(detail.ActionReadiness, "restart")
|
|
if !ok || readiness.Available || readiness.Reason != "Docker / Podman command agent is not connected." {
|
|
t.Fatalf("detail action readiness = %#v, ok=%v; want disconnected restart", detail.ActionReadiness, ok)
|
|
}
|
|
}
|
|
|
|
func TestHandleExecuteActionRejectsNeverAutoRemediateBeforeExecutor(t *testing.T) {
|
|
now := time.Date(2026, 5, 4, 14, 0, 0, 0, time.UTC)
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{
|
|
{
|
|
ID: "vm:42",
|
|
Type: unified.ResourceTypeVM,
|
|
Name: "web-42",
|
|
Status: unified.StatusWarning,
|
|
LastSeen: now,
|
|
UpdatedAt: now,
|
|
Sources: []unified.DataSource{unified.SourceProxmox},
|
|
Capabilities: []unified.ResourceCapability{
|
|
{
|
|
Name: "restart",
|
|
Type: unified.CapabilityTypeCommon,
|
|
Description: "Restart the VM",
|
|
MinimumApprovalLevel: unified.ApprovalAdmin,
|
|
InternalHandler: "proxmox.vm.restart",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
executor := &stubActionExecutor{result: &unified.ExecutionResult{Success: true, Output: "should not run"}}
|
|
h.SetActionExecutor(executor)
|
|
|
|
planRec := httptest.NewRecorder()
|
|
planReq := httptest.NewRequest(http.MethodPost, "/api/actions/plan", bytes.NewBufferString(`{
|
|
"requestId":"agent-run-locked",
|
|
"resourceId":"vm:42",
|
|
"capabilityName":"restart",
|
|
"reason":"Recover after confirmed outage",
|
|
"requestedBy":"agent:oncall-helper"
|
|
}`))
|
|
h.HandlePlanAction(planRec, actionHandlerTestRequest(planReq, ""))
|
|
if planRec.Code != http.StatusOK {
|
|
t.Fatalf("plan status = %d, body=%s", planRec.Code, planRec.Body.String())
|
|
}
|
|
var plan unified.ActionPlan
|
|
if err := json.Unmarshal(planRec.Body.Bytes(), &plan); err != nil {
|
|
t.Fatalf("decode plan: %v", err)
|
|
}
|
|
|
|
decisionRec := httptest.NewRecorder()
|
|
decisionReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+plan.ActionID+"/decision", bytes.NewBufferString(`{"outcome":"approved"}`))
|
|
decisionReq.SetPathValue("id", plan.ActionID)
|
|
decisionReq = decisionReq.WithContext(auth.WithUser(decisionReq.Context(), "operator@example.com"))
|
|
h.HandleDecideAction(decisionRec, actionHandlerTestRequest(decisionReq, ""))
|
|
if decisionRec.Code != http.StatusOK {
|
|
t.Fatalf("decision status = %d, body=%s", decisionRec.Code, decisionRec.Body.String())
|
|
}
|
|
|
|
store, err := h.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("get store: %v", err)
|
|
}
|
|
if err := store.SetResourceOperatorState(unified.ResourceOperatorState{
|
|
CanonicalID: "vm:42",
|
|
NeverAutoRemediate: true,
|
|
SetAt: now,
|
|
SetBy: "operator@example.com",
|
|
}); err != nil {
|
|
t.Fatalf("SetResourceOperatorState: %v", err)
|
|
}
|
|
|
|
executeRec := httptest.NewRecorder()
|
|
executeReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+plan.ActionID+"/execute", bytes.NewBufferString(`{}`))
|
|
executeReq.SetPathValue("id", plan.ActionID)
|
|
executeReq = executeReq.WithContext(auth.WithUser(executeReq.Context(), "operator@example.com"))
|
|
h.HandleExecuteAction(executeRec, actionHandlerTestRequest(executeReq, ""))
|
|
if executeRec.Code != http.StatusConflict {
|
|
t.Fatalf("execute status = %d, body=%s", executeRec.Code, executeRec.Body.String())
|
|
}
|
|
if executor.calls != 0 {
|
|
t.Fatalf("executor calls = %d, want none", executor.calls)
|
|
}
|
|
audit, ok, err := store.GetActionAudit(plan.ActionID)
|
|
if err != nil {
|
|
t.Fatalf("GetActionAudit: %v", err)
|
|
}
|
|
if !ok || audit.State != unified.ActionStateFailed || audit.Result == nil || !strings.HasPrefix(audit.Result.ErrorMessage, "resource_remediation_locked:") {
|
|
t.Fatalf("locked audit = %#v, ok=%v", audit, ok)
|
|
}
|
|
}
|
|
|
|
func dockerContainerActionResource(id, runtime, state string, now time.Time) unified.Resource {
|
|
return unified.Resource{
|
|
ID: id,
|
|
Type: unified.ResourceTypeAppContainer,
|
|
Technology: runtime,
|
|
Name: "api",
|
|
Status: unified.StatusOnline,
|
|
LastSeen: now,
|
|
UpdatedAt: now,
|
|
Sources: []unified.DataSource{unified.SourceDocker},
|
|
SourceStatus: map[unified.DataSource]unified.SourceStatus{
|
|
unified.SourceDocker: {Status: "online", LastSeen: now},
|
|
},
|
|
Docker: &unified.DockerData{
|
|
AgentID: "agent-1",
|
|
ContainerID: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
ContainerState: state,
|
|
Runtime: runtime,
|
|
},
|
|
Capabilities: []unified.ResourceCapability{
|
|
{
|
|
Name: "restart",
|
|
Type: unified.CapabilityTypeCommon,
|
|
Description: "Restart this container",
|
|
MinimumApprovalLevel: unified.ApprovalAdmin,
|
|
Platform: runtime,
|
|
InternalHandler: dockerContainerLifecycleHandler,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func dockerContainerActionRecord(actionID, resourceID, operation string) unified.ActionAuditRecord {
|
|
return unified.ActionAuditRecord{
|
|
ID: actionID,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
State: unified.ActionStateExecuting,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-" + actionID,
|
|
ResourceID: resourceID,
|
|
CapabilityName: operation,
|
|
Reason: "test execution",
|
|
RequestedBy: "agent:oncall-helper",
|
|
Params: map[string]any{},
|
|
},
|
|
Plan: unified.ActionPlan{
|
|
ActionID: actionID,
|
|
RequestID: "req-" + actionID,
|
|
Allowed: true,
|
|
RequiresApproval: true,
|
|
ApprovalPolicy: unified.ApprovalAdmin,
|
|
PlannedAt: time.Now().UTC().Add(-time.Minute),
|
|
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
ResourceVersion: "resource:sha256:test",
|
|
PolicyVersion: "policy:sha256:test",
|
|
PlanHash: "sha256:test",
|
|
},
|
|
}
|
|
}
|
|
|
|
func dockerContainerUpdateActionResource(id, runtime string, now time.Time) unified.Resource {
|
|
resource := dockerContainerActionResource(id, runtime, "running", now)
|
|
resource.Docker.Image = "ghcr.io/example/api:latest"
|
|
resource.Docker.UpdateStatus = &unified.DockerUpdateStatusMeta{
|
|
UpdateAvailable: true,
|
|
CurrentDigest: "sha256:" + strings.Repeat("9", 64),
|
|
LatestDigest: "sha256:" + strings.Repeat("a", 64),
|
|
}
|
|
resource.Capabilities = append(resource.Capabilities, unified.ResourceCapability{
|
|
Name: "update",
|
|
Type: unified.CapabilityTypeCommon,
|
|
Description: "Update this container",
|
|
MinimumApprovalLevel: unified.ApprovalAdmin,
|
|
Platform: runtime,
|
|
InternalHandler: dockerContainerUpdateHandler,
|
|
})
|
|
return resource
|
|
}
|
|
|
|
func TestDockerContainerActionExecutorDispatchesTypedUpdate(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{
|
|
dockerContainerUpdateActionResource("app-container:api", "docker", now),
|
|
},
|
|
})
|
|
agents := &fakeDockerActionAgentCommander{}
|
|
executor := newDockerContainerActionExecutor(h, agents).(dockerContainerActionExecutor)
|
|
record := dockerContainerActionRecord("act_update", "app-container:api", "update")
|
|
|
|
result, err := executor.ExecuteAction(dockerActionDispatchContext(t, executor, record), record)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteAction: %v", err)
|
|
}
|
|
if result == nil || !result.Success || result.Verification == nil || !result.Verification.Success {
|
|
t.Fatalf("result = %#v, want successful execution and verification", result)
|
|
}
|
|
if len(agents.typedUpdateCalls) != 1 || len(agents.typedCalls) != 0 || len(agents.calls) != 0 {
|
|
t.Fatalf("update/lifecycle/raw agent calls = %d/%d/%d, want exactly one typed update dispatch", len(agents.typedUpdateCalls), len(agents.typedCalls), len(agents.calls))
|
|
}
|
|
call := agents.typedUpdateCalls[0]
|
|
if call.Operation != agentexec.DockerContainerOperationUpdate || call.ExpectedImageDigest != "sha256:"+strings.Repeat("9", 64) || call.Runtime != "docker" {
|
|
t.Fatalf("typed update payload = %+v", call)
|
|
}
|
|
if result.ActionResultV2 == nil || result.ActionResultV2.Compensation.Support != unified.ActionCompensationDeclared || result.ActionResultV2.Compensation.Status != unified.ActionCompensationNotNeeded {
|
|
t.Fatalf("compensation truth = %+v, want declared/not_needed", result.ActionResultV2)
|
|
}
|
|
}
|
|
|
|
func TestDockerContainerActionExecutorRefusesUpdateThroughLifecycleHandler(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
resource := dockerContainerActionResource("app-container:api", "docker", "running", now)
|
|
resource.Docker.Image = "ghcr.io/example/api:latest"
|
|
resource.Capabilities = append(resource.Capabilities, unified.ResourceCapability{
|
|
Name: "update",
|
|
Type: unified.CapabilityTypeCommon,
|
|
MinimumApprovalLevel: unified.ApprovalAdmin,
|
|
Platform: "docker",
|
|
InternalHandler: dockerContainerLifecycleHandler,
|
|
})
|
|
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{resource},
|
|
})
|
|
agents := &fakeDockerActionAgentCommander{}
|
|
executor := newDockerContainerActionExecutor(h, agents).(dockerContainerActionExecutor)
|
|
record := dockerContainerActionRecord("act_update_wrong", "app-container:api", "update")
|
|
|
|
if readiness := executor.CheckActionAvailable(context.Background(), record.Request, resource); readiness.Available || readiness.Name != "" {
|
|
t.Fatalf("readiness = %+v, want fail-closed for wrong handler", readiness)
|
|
}
|
|
}
|