mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-25 00:43:34 +00:00
363 lines
20 KiB
Go
363 lines
20 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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/aicontracts"
|
|
)
|
|
|
|
type fakeHostUpdateAgent struct {
|
|
connected bool
|
|
result *agentexec.HostUpdateResultPayload
|
|
err error
|
|
agentID string
|
|
requests []agentexec.HostUpdatePayload
|
|
receiptVersion int
|
|
receiptVersionSet bool
|
|
queryResult operationreceipt.QueryResult
|
|
queryErr error
|
|
queries []operationreceipt.Identity
|
|
}
|
|
|
|
func (f *fakeHostUpdateAgent) QueryAgentOperation(_ context.Context, _ string, identity operationreceipt.Identity) (operationreceipt.QueryResult, error) {
|
|
f.queries = append(f.queries, identity)
|
|
return f.queryResult, f.queryErr
|
|
}
|
|
|
|
var testHostPackageInventoryHash = "sha256:" + strings.Repeat("a", 64)
|
|
var testHostPackageEmptyInventoryHash = "sha256:" + strings.Repeat("b", 64)
|
|
|
|
func (f *fakeHostUpdateAgent) ExecuteHostUpdate(_ context.Context, agentID string, req agentexec.HostUpdatePayload) (*agentexec.HostUpdateResultPayload, error) {
|
|
f.agentID = agentID
|
|
f.requests = append(f.requests, req)
|
|
if f.result == nil {
|
|
return nil, f.err
|
|
}
|
|
result := *f.result
|
|
result.RequestID = req.RequestID
|
|
result.ActionID = req.ActionID
|
|
return &result, f.err
|
|
}
|
|
|
|
func (f *fakeHostUpdateAgent) IsAgentConnected(string) bool { return f.connected }
|
|
func (f *fakeHostUpdateAgent) AgentOperationReceiptVersion(string) int {
|
|
if f.receiptVersionSet {
|
|
return f.receiptVersion
|
|
}
|
|
return operationreceipt.ProtocolVersion
|
|
}
|
|
|
|
func hostUpdateDispatchTestContext(t *testing.T, actionID string) context.Context {
|
|
t.Helper()
|
|
attempt := hostUpdateDispatchAttempt(t, actionID, time.Now())
|
|
return actionlifecycle.ContextWithCommittedDispatchAttempt(context.Background(), attempt)
|
|
}
|
|
|
|
func hostUpdateDispatchAttempt(t *testing.T, actionID string, now time.Time) unified.ActionDispatchAttempt {
|
|
t.Helper()
|
|
attempt, err := unified.NewActionDispatchAttempt(actionID, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := agentexec.HostUpdatePayload{RequestID: attempt.ID, ActionID: actionID, Operation: agentexec.HostUpdateOperationInstall, ExpectedInventoryHash: testHostPackageInventoryHash}
|
|
if err := agentexec.BindHostUpdatePayload(&req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
attempt, err = unified.BindActionDispatchAttempt(attempt, unified.ActionDispatchBinding{OperationKind: req.Operation, OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest, AgentID: "agent-1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return attempt
|
|
}
|
|
|
|
func TestHostUpdateActionExecutorDispatchesTypedOperationAndProjectsVerification(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{hostUpdateActionResource(now)},
|
|
})
|
|
agents := &fakeHostUpdateAgent{connected: true, result: &agentexec.HostUpdateResultPayload{
|
|
RequestID: "action-host-update", Success: true, MutationStarted: true, ExecutionPhase: agentexec.HostUpdatePhaseComplete,
|
|
Before: agentexec.HostPackageUpdateSnapshot{Supported: true, Manager: "apt", InventoryHash: testHostPackageInventoryHash, PendingCount: 3, CheckedAt: now.Add(-time.Second)},
|
|
After: agentexec.HostPackageUpdateSnapshot{Supported: true, Manager: "apt", InventoryHash: testHostPackageEmptyInventoryHash, PendingCount: 0, RebootRequired: true, CheckedAt: now},
|
|
HealthChecked: true, PackageManagerHealthy: true, Verification: agentexec.HostUpdateVerificationVerified,
|
|
}}
|
|
executor := newHostUpdateActionExecutor(h, agents)
|
|
|
|
result, err := executor.ExecuteAction(hostUpdateDispatchTestContext(t, "action-host-update"), hostUpdateActionRecord("action-host-update"))
|
|
if err != nil {
|
|
t.Fatalf("ExecuteAction: %v", err)
|
|
}
|
|
if result == nil || !result.Success || result.Verification == nil || !result.Verification.Ran || !result.Verification.Success {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
if !strings.Contains(result.Output, "3 pending before, 0 pending after") || !strings.Contains(result.Output, "reboot required: true") {
|
|
t.Fatalf("output = %q", result.Output)
|
|
}
|
|
if len(agents.requests) != 1 || agents.agentID != "agent-1" {
|
|
t.Fatalf("agent calls = %#v, agentID=%q", agents.requests, agents.agentID)
|
|
}
|
|
request := agents.requests[0]
|
|
if request.ActionID != "action-host-update" || request.RequestID != "action-host-update.dispatch.1" || request.Operation != agentexec.HostUpdateOperationInstall || request.ExpectedInventoryHash != testHostPackageInventoryHash || request.Timeout != 900 {
|
|
t.Fatalf("typed request = %#v", request)
|
|
}
|
|
}
|
|
|
|
func TestHostUpdateActionExecutorReportsInconclusiveVerificationHonestly(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{hostUpdateActionResource(now)}})
|
|
agents := &fakeHostUpdateAgent{connected: true, result: &agentexec.HostUpdateResultPayload{
|
|
RequestID: "action-host-update", Success: true, ExecutionPhase: agentexec.HostUpdatePhaseComplete,
|
|
Before: agentexec.HostPackageUpdateSnapshot{PendingCount: 2},
|
|
After: agentexec.HostPackageUpdateSnapshot{PendingCount: 0},
|
|
Verification: agentexec.HostUpdateVerificationInconclusive,
|
|
Error: "package installation completed but verification was inconclusive",
|
|
}}
|
|
|
|
result, err := newHostUpdateActionExecutor(h, agents).ExecuteAction(hostUpdateDispatchTestContext(t, "action-host-update"), hostUpdateActionRecord("action-host-update"))
|
|
if err != nil {
|
|
t.Fatalf("ExecuteAction: %v", err)
|
|
}
|
|
if result == nil || !result.Success || result.Verification == nil || result.Verification.Ran || result.ActionResultV2 == nil || result.ActionResultV2.Verification.Status != unified.ActionVerificationInconclusive || result.ActionResultV2.Verification.ReasonCode != "agent_readback_inconclusive" {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
}
|
|
|
|
func TestHostUpdateReconcileDelayedTerminalReceiptPreservesAgentAttestedEvidenceWithoutResend(t *testing.T) {
|
|
terminalAt := time.Now().UTC().Add(-2 * time.Hour)
|
|
receivedAt := terminalAt.Add(2 * time.Hour)
|
|
attempt := hostUpdateDispatchAttempt(t, "action-host-update", terminalAt)
|
|
payload := agentexec.HostUpdateResultPayload{
|
|
RequestID: attempt.ID, ActionID: attempt.ActionID, Success: true, MutationStarted: true, ExecutionPhase: agentexec.HostUpdatePhaseComplete,
|
|
Before: agentexec.HostPackageUpdateSnapshot{Supported: true, Manager: "apt", InventoryHash: testHostPackageInventoryHash, PendingCount: 3, CheckedAt: terminalAt.Add(-2 * time.Second)},
|
|
After: agentexec.HostPackageUpdateSnapshot{Supported: true, Manager: "apt", InventoryHash: testHostPackageEmptyInventoryHash, PendingCount: 0, RebootRequired: true, CheckedAt: terminalAt.Add(-time.Second)},
|
|
HealthChecked: true, PackageManagerHealthy: true, Verification: agentexec.HostUpdateVerificationVerified,
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
identity := operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}
|
|
agents := &fakeHostUpdateAgent{queryResult: operationreceipt.QueryResult{Version: operationreceipt.ProtocolVersion, Status: operationreceipt.QueryFoundTerminal, Record: &operationreceipt.Record{
|
|
Identity: identity, State: operationreceipt.StateTerminal, AcceptedAt: terminalAt.Add(-4 * time.Second), StartedAt: terminalAt.Add(-3 * time.Second), TerminalAt: terminalAt,
|
|
ResultKind: agentexec.HostUpdateReceiptKind, ResultVersion: agentexec.HostAPTReceiptVersion, Result: raw,
|
|
}}}
|
|
executor := hostUpdateActionExecutor{agents: agents, now: func() time.Time { return receivedAt }}
|
|
|
|
result, receipt, found, err := executor.ReconcileActionDispatch(context.Background(), hostUpdateActionRecord(attempt.ActionID), attempt)
|
|
if err != nil || !found {
|
|
t.Fatalf("ReconcileActionDispatch: found=%v err=%v", found, err)
|
|
}
|
|
if len(agents.requests) != 0 || len(agents.queries) != 1 {
|
|
t.Fatalf("mutation requests=%d queries=%d", len(agents.requests), len(agents.queries))
|
|
}
|
|
if result == nil || result.ActionResultV2 == nil || result.ActionResultV2.Verification.Status != unified.ActionVerificationConfirmed || result.ActionResultV2.Verification.EvidenceClass != unified.ActionEvidenceAgentAttested {
|
|
t.Fatalf("result=%#v", result)
|
|
}
|
|
evidence := result.ActionResultV2.Verification.Evidence
|
|
if len(evidence) != 1 || !evidence[0].ObservedAt.Equal(payload.After.CheckedAt) || !evidence[0].ReceivedAt.Equal(receivedAt) {
|
|
t.Fatalf("evidence=%#v", evidence)
|
|
}
|
|
if !receipt.ReceivedAt.Equal(receivedAt) || receipt.TransportRequestID != attempt.ID {
|
|
t.Fatalf("receipt=%#v", receipt)
|
|
}
|
|
}
|
|
|
|
func TestHostUpdateReconcileLegacyTerminalReceiptDowngradesUnknownHealthWithoutResend(t *testing.T) {
|
|
terminalAt := time.Now().UTC().Add(-2 * time.Hour)
|
|
receivedAt := terminalAt.Add(2 * time.Hour)
|
|
attempt := hostUpdateDispatchAttempt(t, "action-host-update", terminalAt)
|
|
payload := agentexec.HostUpdateResultPayload{
|
|
RequestID: attempt.ID, ActionID: attempt.ActionID, Success: true, MutationStarted: true, ExecutionPhase: agentexec.HostUpdatePhaseComplete,
|
|
Before: agentexec.HostPackageUpdateSnapshot{Supported: true, Manager: "apt", InventoryHash: testHostPackageInventoryHash, PendingCount: 3, CheckedAt: terminalAt.Add(-2 * time.Second)},
|
|
After: agentexec.HostPackageUpdateSnapshot{Supported: true, Manager: "apt", InventoryHash: testHostPackageEmptyInventoryHash, CheckedAt: terminalAt.Add(-time.Second)},
|
|
Verification: agentexec.HostUpdateVerificationVerified,
|
|
}
|
|
raw, _ := json.Marshal(payload)
|
|
identity := operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}
|
|
agents := &fakeHostUpdateAgent{queryResult: terminalAPTQuery(identity, agentexec.HostUpdateReceiptKind, raw, terminalAt)}
|
|
executor := hostUpdateActionExecutor{agents: agents, now: func() time.Time { return receivedAt }}
|
|
result, _, found, err := executor.ReconcileActionDispatch(context.Background(), hostUpdateActionRecord(attempt.ActionID), attempt)
|
|
if err != nil || !found {
|
|
t.Fatalf("ReconcileActionDispatch: found=%v err=%v", found, err)
|
|
}
|
|
truth := result.ActionResultV2.Verification
|
|
if truth.Status != unified.ActionVerificationInconclusive || truth.EvidenceClass != unified.ActionEvidenceNone || truth.ReasonCode != "package_manager_health_unknown" || len(truth.Evidence) != 0 {
|
|
t.Fatalf("legacy verification truth=%#v", truth)
|
|
}
|
|
if len(agents.requests) != 0 || len(agents.queries) != 1 {
|
|
t.Fatalf("legacy receipt requests=%d queries=%d", len(agents.requests), len(agents.queries))
|
|
}
|
|
}
|
|
|
|
func TestPatrolFullModeRunsHostUpdateThroughCanonicalLifecycle(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{hostUpdateActionResource(now)}})
|
|
agents := &fakeHostUpdateAgent{connected: true, result: &agentexec.HostUpdateResultPayload{
|
|
RequestID: "filled-by-executor", Success: true, MutationStarted: true, ExecutionPhase: agentexec.HostUpdatePhaseComplete,
|
|
Before: agentexec.HostPackageUpdateSnapshot{Supported: true, Manager: "apt", InventoryHash: testHostPackageInventoryHash, PendingCount: 3, CheckedAt: now.Add(-time.Second)},
|
|
After: agentexec.HostPackageUpdateSnapshot{Supported: true, Manager: "apt", InventoryHash: testHostPackageEmptyInventoryHash, PendingCount: 0, CheckedAt: now},
|
|
HealthChecked: true, PackageManagerHealthy: true, Verification: agentexec.HostUpdateVerificationVerified,
|
|
}}
|
|
h.SetActionExecutor(newRoutedActionExecutor(h, newHostUpdateActionExecutor(h, agents)))
|
|
store, err := h.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
if err := store.SetResourceOperatorState(unified.ResourceOperatorState{
|
|
CanonicalID: "agent:host-1",
|
|
AutoRemediationPolicy: unified.AutoRemediationPolicy{
|
|
Enabled: true, CapabilityNames: []string{hostPackageUpdateCapability},
|
|
},
|
|
SetAt: now, SetBy: "operator@example.com",
|
|
}); err != nil {
|
|
t.Fatalf("SetResourceOperatorState: %v", err)
|
|
}
|
|
broker := NewPatrolActionBroker("default", h, func(context.Context, string) (PatrolActionPolicySnapshot, error) {
|
|
return PatrolActionPolicySnapshot{EffectiveAutonomyLevel: "full", FullModeUnlocked: true}, nil
|
|
})
|
|
disposition, err := broker.Submit(context.Background(), aicontracts.ActionProposal{
|
|
ProposalID: "proposal-host-update", FindingID: "finding-host-update", InvestigationID: "investigation-host-update",
|
|
ResourceID: "agent:host-1", CapabilityName: hostPackageUpdateCapability, Reason: "Install three pending OS updates",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Submit: %v", err)
|
|
}
|
|
if disposition.State != string(unified.ActionStateCompleted) || disposition.VerificationStatus != string(unified.VerificationVerified) {
|
|
t.Fatalf("disposition = %#v", disposition)
|
|
}
|
|
if len(agents.requests) != 1 || agents.requests[0].Operation != agentexec.HostUpdateOperationInstall {
|
|
t.Fatalf("typed agent requests = %#v", agents.requests)
|
|
}
|
|
audit, found, err := store.GetActionAudit(disposition.ActionID)
|
|
if err != nil || !found {
|
|
t.Fatalf("GetActionAudit: found=%v err=%v", found, err)
|
|
}
|
|
if len(audit.Approvals) != 1 || audit.Approvals[0].Actor != patrolActionPolicyActor || audit.Approvals[0].Method != unified.MethodPolicy || audit.Result == nil || audit.VerificationOutcome.Status != unified.VerificationVerified {
|
|
t.Fatalf("audit = %#v", audit)
|
|
}
|
|
}
|
|
|
|
func TestHostUpdateActionAvailabilityFailsClosed(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*unified.Resource)
|
|
connected bool
|
|
reasonCode string
|
|
}{
|
|
{name: "commands disabled", mutate: func(r *unified.Resource) { r.Agent.CommandsEnabled = false }, connected: true, reasonCode: "host_commands_disabled"},
|
|
{name: "missing receipt protocol", mutate: func(r *unified.Resource) { r.Agent.OperationReceiptVersion = 0 }, connected: true, reasonCode: "operation_receipt_unsupported"},
|
|
{name: "future receipt protocol", mutate: func(r *unified.Resource) { r.Agent.OperationReceiptVersion = operationreceipt.ProtocolVersion + 1 }, connected: true, reasonCode: "operation_receipt_unsupported"},
|
|
{name: "stale inventory", mutate: func(r *unified.Resource) { r.Agent.PackageUpdates.CheckedAt = now.Add(-time.Hour) }, connected: true, reasonCode: "stale_package_inventory"},
|
|
{name: "inventory error", mutate: func(r *unified.Resource) { r.Agent.PackageUpdates.Error = "inspection failed" }, connected: true, reasonCode: "package_inventory_error"},
|
|
{name: "invalid inventory fingerprint", mutate: func(r *unified.Resource) { r.Agent.PackageUpdates.InventoryHash = "sha256:bad" }, connected: true, reasonCode: "invalid_package_inventory"},
|
|
{name: "no pending", mutate: func(r *unified.Resource) { r.Agent.PackageUpdates.PendingCount = 0 }, connected: true, reasonCode: "no_pending_updates"},
|
|
{name: "disconnected", mutate: func(*unified.Resource) {}, connected: false, reasonCode: "command_agent_disconnected"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resource := hostUpdateActionResource(now)
|
|
tc.mutate(&resource)
|
|
executor := hostUpdateActionExecutor{agents: &fakeHostUpdateAgent{connected: tc.connected}, now: func() time.Time { return now }}
|
|
readiness := executor.CheckActionAvailable(context.Background(), unified.ActionRequest{CapabilityName: hostPackageUpdateCapability}, resource)
|
|
if readiness.Available || readiness.Name != hostPackageUpdateCapability || readiness.ReasonCode != tc.reasonCode {
|
|
t.Fatalf("readiness = %#v", readiness)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHostUpdateCompatibleProposalRefusesLiveAgentDowngradeBeforeExecuting(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
resource := hostUpdateActionResource(now)
|
|
resource.Capabilities[0].MinimumApprovalLevel = unified.ApprovalNone
|
|
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{resource}})
|
|
agents := &fakeHostUpdateAgent{connected: true}
|
|
h.SetActionExecutor(newRoutedActionExecutor(h, newHostUpdateActionExecutor(h, agents)))
|
|
service := h.ActionLifecycle()
|
|
actor := unified.ActionActor{SubjectID: "requester", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"}
|
|
plan, err := service.Plan(context.Background(), "default", unified.ActionRequest{RequestID: "downgrade", ResourceID: resource.ID, CapabilityName: hostPackageUpdateCapability, Reason: "test", RequestedBy: "requester", Actor: actor}, actor)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agents.receiptVersionSet = true
|
|
agents.receiptVersion = 0
|
|
if _, err := service.Execute(context.Background(), "default", plan.ActionID, actor, "downgrade"); err == nil {
|
|
t.Fatal("downgraded agent executed")
|
|
}
|
|
store, err := h.getStore("default")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
audit, found, err := store.GetActionAudit(plan.ActionID)
|
|
if err != nil || !found || audit.State == unified.ActionStateExecuting {
|
|
t.Fatalf("audit=%#v found=%v err=%v", audit, found, err)
|
|
}
|
|
if _, found, err := store.GetActionDispatchAttempt(plan.ActionID); err != nil || found {
|
|
t.Fatalf("dispatch found=%v err=%v", found, err)
|
|
}
|
|
if len(agents.requests) != 0 {
|
|
t.Fatalf("requests=%#v", agents.requests)
|
|
}
|
|
}
|
|
|
|
func TestHostUpdateSelfReportedReceiptSupportCannotBypassLiveServerVersion(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
resource := hostUpdateActionResource(now)
|
|
for _, version := range []int{0, operationreceipt.ProtocolVersion + 1} {
|
|
agents := &fakeHostUpdateAgent{connected: true, receiptVersion: version, receiptVersionSet: true}
|
|
readiness := hostUpdateActionExecutor{agents: agents, now: func() time.Time { return now }}.CheckActionAvailable(context.Background(), unified.ActionRequest{CapabilityName: hostPackageUpdateCapability}, resource)
|
|
if readiness.Available || readiness.ReasonCode != "operation_receipt_unsupported" {
|
|
t.Fatalf("version=%d readiness=%#v", version, readiness)
|
|
}
|
|
}
|
|
}
|
|
|
|
func hostUpdateActionResource(now time.Time) unified.Resource {
|
|
return unified.Resource{
|
|
ID: "agent:host-1", Type: unified.ResourceTypeAgent, Technology: "linux", Name: "host-1",
|
|
Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now,
|
|
Sources: []unified.DataSource{unified.SourceAgent},
|
|
SourceStatus: map[unified.DataSource]unified.SourceStatus{unified.SourceAgent: {Status: "online", LastSeen: now}},
|
|
Agent: &unified.AgentData{
|
|
AgentID: "agent-1", Platform: "linux", CommandsEnabled: true, OperationReceiptVersion: operationreceipt.ProtocolVersion,
|
|
PackageUpdates: &unified.AgentPackageUpdateMeta{Supported: true, Manager: "apt", InventoryHash: testHostPackageInventoryHash, PendingCount: 3, CheckedAt: now, ObservedAt: now},
|
|
},
|
|
Capabilities: []unified.ResourceCapability{{
|
|
Name: hostPackageUpdateCapability, Type: unified.CapabilityTypeCommon,
|
|
Description: "Install standard OS updates", MinimumApprovalLevel: unified.ApprovalAdmin,
|
|
AutoAuthorization: unified.AutoAuthorizeElevated, Platform: "linux", InternalHandler: hostPackageUpdateActionHandler,
|
|
}},
|
|
}
|
|
}
|
|
|
|
func hostUpdateActionRecord(actionID string) unified.ActionAuditRecord {
|
|
now := time.Now().UTC()
|
|
return unified.ActionAuditRecord{
|
|
ID: actionID, CreatedAt: now, UpdatedAt: now, State: unified.ActionStateExecuting,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "request-" + actionID, ResourceID: "agent:host-1", CapabilityName: hostPackageUpdateCapability,
|
|
Reason: "Install pending host updates", RequestedBy: "pulse_patrol", Params: map[string]any{},
|
|
},
|
|
Plan: unified.ActionPlan{
|
|
ActionID: actionID, RequestID: "request-" + actionID, Allowed: true, RequiresApproval: true,
|
|
ApprovalPolicy: unified.ApprovalAdmin, PlannedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Minute),
|
|
ResourceVersion: "resource:sha256:test", PolicyVersion: "policy:sha256:test", PlanHash: "sha256:test",
|
|
},
|
|
}
|
|
}
|