mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Restore release-blocking backend contracts
This commit is contained in:
@@ -3644,13 +3644,16 @@ may keep first-party model-facing descriptions, but it must not re-declare
|
||||
required arguments such as `resolution_note` or dismissal `note` when the
|
||||
canonical Pulse Intelligence manifest and API contract mark those fields
|
||||
optional.
|
||||
Legacy native Assistant utility provider aliases for `run_command`, `fetch_url`,
|
||||
and `set_resource_url`, plus their provider JSON schemas, are owned by
|
||||
Legacy native Assistant utility provider aliases for `fetch_url` and
|
||||
`set_resource_url`, plus their provider JSON schemas, are owned by
|
||||
`agentcapabilities.LegacyAssistantUtilityProviderTools`. The older native
|
||||
Assistant service may continue to expose those compatibility aliases while the
|
||||
execution migration proceeds, but it must consume the shared provider projection
|
||||
and shared argument constants rather than carrying inline schema maps or local
|
||||
tool-input string keys.
|
||||
tool-input string keys. The legacy `run_command` alias is retired: the Assistant
|
||||
must not offer it, and a fabricated invocation must fail closed before any
|
||||
command dispatch. Raw command execution remains available only through the
|
||||
governed capability and action-lifecycle path.
|
||||
Native Assistant registry tools that operate on Patrol finding lifecycle state
|
||||
must also consume the same `agentcapabilities` argument vocabulary for
|
||||
`finding_id`, `resolution_note`, `reason`, and `note` instead of repeating
|
||||
@@ -5855,6 +5858,15 @@ biometric claims never satisfy an MFA floor. Until the core step-up verifier
|
||||
accepts action-bound cryptographic evidence, the honest runtime outcome is
|
||||
step-up unavailable, not MFA approved.
|
||||
|
||||
The legacy Patrol approval bridge must project stored approvals into that same
|
||||
server-owned model before a lifecycle decision is appended. It binds a scoped
|
||||
service requester, the deciding human `ActorBinding`, and method/session
|
||||
`ApprovalEvidence` to the exact action, plan hash, outcome, organization, and
|
||||
decision time. A zero-version legacy approval requirement is upgraded to the
|
||||
current canonical floor before evaluation; missing or inconsistent authority
|
||||
fails closed. A dry-run plan is never executable and must persist refusal
|
||||
without minting decision approval authority.
|
||||
|
||||
Patrol action proposals persist the exact bounded policy authorities consulted
|
||||
at planning time through the canonical unified-resource `policyDecision`
|
||||
object. Capability, tenant Patrol, and resource operator factors retain typed
|
||||
@@ -5907,6 +5919,10 @@ exercised through an exact empty-parameter proposal, shared planning/policy and
|
||||
human approval, durable typed dispatch, canonical terminal audit, and finding
|
||||
resolution for both workflows. Finding evidence stays bounded and contains no
|
||||
command, path, package selector, raw APT output, stderr, or reboot authority.
|
||||
The APT verifier claims only canonical APT finding keys. It must return
|
||||
unhandled for CPU, memory, disk, and every other non-APT key so the owning
|
||||
deterministic verifier can evaluate that finding; APT resource lookup failure
|
||||
must never intercept unrelated verification.
|
||||
|
||||
Fake-only callback-loss tests reopen the server-side action store and consume
|
||||
Task 07 terminal receipts without resending either typed mutation, then
|
||||
|
||||
@@ -945,6 +945,12 @@ so the Kubernetes platform-page Configuration tab exercises the same
|
||||
RBAC summary-count contract live agents use. The
|
||||
`TestKubernetesDemoClustersTellDistinctStories` test in
|
||||
`internal/mock/demo_scenarios_test.go` guards that distribution.
|
||||
Generic Kubernetes fixture synthesis must also preserve at least one ready,
|
||||
schedulable node whenever a cluster has nodes. Randomized readiness and cordon
|
||||
stories may degrade the remaining nodes, but they must not accidentally create
|
||||
a total outage that erases running-pod metrics or makes the demo and its proof
|
||||
nondeterministic; explicit curated outage scenarios remain the owner of
|
||||
cluster-wide unavailability.
|
||||
Bumps to those defaults must keep the
|
||||
curated demo scenario's per-node hostname seasoning in
|
||||
`demo_scenarios.go` aligned (today: pve1..pve6 with regional labels,
|
||||
|
||||
@@ -1819,7 +1819,10 @@ and malformed evidence remain explicitly inconclusive. Legacy `Success` and
|
||||
Canonical evidence is bounded before redaction and digested as SHA-256 over
|
||||
the canonical redacted envelope. Redaction deep-copies nested evidence and
|
||||
fails closed to `redaction_contract_violation`; it never preserves invalid or
|
||||
unredacted input. A present but malformed stored V2 also fails closed to
|
||||
unredacted input. Result normalization is copy-on-normalize across evidence,
|
||||
verification, and nested compensation, so callers may safely normalize shared
|
||||
immutable snapshots without mutating their stored input or racing other
|
||||
readers. A present but malformed stored V2 also fails closed to
|
||||
`result_v2_invalid`; legacy booleans can never override it. Compensation truth
|
||||
never rewrites the primary result and carries declared trigger, durable
|
||||
attempt/step identity, timing, nested execution and verification, and restored
|
||||
|
||||
@@ -188,6 +188,9 @@ func formatAPTBytes(value int64) string {
|
||||
}
|
||||
|
||||
func verifyAPTWorkflowFinding(state patrolRuntimeState, findingKey, resourceID string, now time.Time) (bool, bool, error) {
|
||||
if findingKey != aptHostUpdateFindingKey && findingKey != aptPackageCacheFindingKey {
|
||||
return false, false, nil
|
||||
}
|
||||
if state.readState == nil {
|
||||
state = state.withDerivedProviders()
|
||||
}
|
||||
|
||||
@@ -142,17 +142,13 @@ func TestService_GetTools(t *testing.T) {
|
||||
t.Error("Expected tools")
|
||||
}
|
||||
|
||||
// Verify some common tools are present
|
||||
foundRunCommand := false
|
||||
// The retired raw-command alias must not be projected even when the legacy
|
||||
// control level is configured to allow governed actions.
|
||||
for _, tool := range tools {
|
||||
if tool.Name == agentcapabilities.LegacyAssistantRunCommandToolName {
|
||||
foundRunCommand = true
|
||||
break
|
||||
t.Fatal("retired run_command alias was projected to the provider")
|
||||
}
|
||||
}
|
||||
if !foundRunCommand {
|
||||
t.Error("Expected run_command tool to be available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetToolsUsesSharedLegacyUtilitySchemas(t *testing.T) {
|
||||
@@ -815,10 +811,15 @@ func TestService_GetModelForRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_ExecuteTool_RunCommand(t *testing.T) {
|
||||
dispatched := false
|
||||
mockAgentServer := &mockAgentServer{
|
||||
agents: []agentexec.ConnectedAgent{
|
||||
{Hostname: "node-1", AgentID: "agent-1"},
|
||||
},
|
||||
executeFunc: func(context.Context, string, agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) {
|
||||
dispatched = true
|
||||
return &agentexec.CommandResultPayload{Success: true}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(nil, mockAgentServer)
|
||||
svc.cfg = &config.AIConfig{ControlLevel: config.ControlLevelControlled}
|
||||
@@ -841,57 +842,12 @@ func TestService_ExecuteTool_RunCommand(t *testing.T) {
|
||||
Context: map[string]interface{}{"node": "node-1"},
|
||||
}
|
||||
|
||||
// 1. Allowed command
|
||||
result, exec := svc.executeTool(ctx, req, tc)
|
||||
if !exec.Success {
|
||||
t.Errorf("Expected success, got failure: %s", result)
|
||||
if exec.Success || !strings.Contains(result, "Invocation blocked") {
|
||||
t.Fatalf("retired run_command did not fail closed: success=%v result=%q", exec.Success, result)
|
||||
}
|
||||
|
||||
// 2. Blocked command
|
||||
mockPolicy.decision = agentexec.PolicyBlock
|
||||
result, exec = svc.executeTool(ctx, req, tc)
|
||||
if exec.Success {
|
||||
t.Error("Expected failure for blocked command")
|
||||
}
|
||||
if !strings.Contains(result, "blocked") {
|
||||
t.Errorf("Expected blocked message, got %s", result)
|
||||
}
|
||||
|
||||
// 3. Approval required (non-autonomous)
|
||||
mockPolicy.decision = agentexec.PolicyRequireApproval
|
||||
svc.cfg = &config.AIConfig{ControlLevel: config.ControlLevelControlled}
|
||||
result, exec = svc.executeTool(ctx, req, tc)
|
||||
if !exec.Success {
|
||||
t.Errorf("Expected success (not an error to need approval), got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "APPROVAL_REQUIRED") {
|
||||
t.Errorf("Expected APPROVAL_REQUIRED message, got %s", result)
|
||||
}
|
||||
|
||||
// 4. Request-scoped approval mode must clamp an autonomous default.
|
||||
autonomousMode := false
|
||||
svc.cfg = &config.AIConfig{ControlLevel: config.ControlLevelAutonomous}
|
||||
req.AutonomousMode = &autonomousMode
|
||||
result, exec = svc.executeTool(ctx, req, tc)
|
||||
if !exec.Success {
|
||||
t.Errorf("Expected success (not an error to need approval), got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "APPROVAL_REQUIRED") {
|
||||
t.Errorf("Expected request-scoped approval mode to require approval, got %s", result)
|
||||
}
|
||||
|
||||
// 5. Approval-bound handoffs require approval even for policy-allowed commands.
|
||||
mockPolicy.decision = agentexec.PolicyAllow
|
||||
req.RequireCommandApproval = true
|
||||
result, exec = svc.executeTool(ctx, req, tc)
|
||||
if !exec.Success {
|
||||
t.Errorf("Expected success (not an error to need approval), got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "APPROVAL_REQUIRED") {
|
||||
t.Errorf("Expected approval-bound handoff to require approval, got %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "handoff requires operator approval") {
|
||||
t.Errorf("Expected handoff approval reason, got %s", result)
|
||||
if dispatched {
|
||||
t.Fatal("retired run_command reached the agent transport")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1282,25 +1238,16 @@ func TestService_ExecuteTool_RunCommand_WithTargetHost(t *testing.T) {
|
||||
}
|
||||
|
||||
result, exec := svc.executeTool(context.Background(), req, tc)
|
||||
if !exec.Success {
|
||||
t.Errorf("Expected success, got failure: %s", result)
|
||||
}
|
||||
if exec.Input != "[target-node] uptime" {
|
||||
t.Errorf("Expected input to show target-node, got: %s", exec.Input)
|
||||
if exec.Success || !strings.Contains(result, "Invocation blocked") {
|
||||
t.Fatalf("retired run_command did not fail closed: success=%v result=%q", exec.Success, result)
|
||||
}
|
||||
|
||||
changes, err := canonicalStore.GetRecentChanges("vm-100", time.Time{}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentChanges: %v", err)
|
||||
}
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("expected 1 canonical command change, got %d", len(changes))
|
||||
}
|
||||
if changes[0].Kind != unifiedresources.ChangeCommandExecuted {
|
||||
t.Fatalf("Kind = %q, want %q", changes[0].Kind, unifiedresources.ChangeCommandExecuted)
|
||||
}
|
||||
if got := changes[0].Metadata["alert_identifier"]; got != "alert-123" {
|
||||
t.Fatalf("alert_identifier = %#v, want alert-123", got)
|
||||
if len(changes) != 0 {
|
||||
t.Fatalf("retired run_command wrote canonical change history: %#v", changes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3244,12 +3191,11 @@ func TestService_FindingContextCommandRouting(t *testing.T) {
|
||||
|
||||
result, exec := svc.executeTool(context.Background(), req, tc)
|
||||
|
||||
if !exec.Success {
|
||||
t.Errorf("Command execution failed: %s", result)
|
||||
if exec.Success || !strings.Contains(result, "Invocation blocked") {
|
||||
t.Fatalf("retired run_command did not fail closed: success=%v result=%q", exec.Success, result)
|
||||
}
|
||||
|
||||
// Verify it routed to the correct agent (minipc, not pve-node)
|
||||
if routedToAgent != "agent-minipc" {
|
||||
t.Errorf("Expected command to route to agent-minipc, but went to: %s", routedToAgent)
|
||||
if routedToAgent != "" {
|
||||
t.Fatalf("retired run_command reached agent routing: %s", routedToAgent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1184,8 +1184,8 @@ func TestService_ExecuteTool(t *testing.T) {
|
||||
}
|
||||
|
||||
output, exec := svc.executeTool(ctx, req, tc)
|
||||
if !containsString(output, "agent server not available") {
|
||||
t.Errorf("Expected agent server error, got: %s", output)
|
||||
if !containsString(output, "Invocation blocked") {
|
||||
t.Errorf("expected retired run_command to fail closed, got: %s", output)
|
||||
}
|
||||
if exec.Success {
|
||||
t.Error("Expected failure")
|
||||
|
||||
@@ -84,7 +84,7 @@ func (e *PulseToolExecutor) executeCommandWithAudit(
|
||||
if planFromApproval {
|
||||
plan = mergeApprovedActionPlan(*approvalReq.Plan, plan)
|
||||
}
|
||||
approvalRecords := approvalRecordsForID(approvalID)
|
||||
approvalRecords := approvalRecordsForID(approvalID, &plan)
|
||||
|
||||
record := unifiedresources.ActionAuditRecord{
|
||||
ID: actionID,
|
||||
@@ -293,7 +293,7 @@ func (e *PulseToolExecutor) executeNativeActionWithAudit(
|
||||
if planFromApproval {
|
||||
plan = mergeApprovedActionPlan(*approvalReq.Plan, plan)
|
||||
}
|
||||
approvalRecords := approvalRecordsForID(approvalID)
|
||||
approvalRecords := approvalRecordsForID(approvalID, &plan)
|
||||
|
||||
record := unifiedresources.ActionAuditRecord{
|
||||
ID: actionID,
|
||||
@@ -371,6 +371,15 @@ func (e *PulseToolExecutor) recordActionExecutionStart(record unifiedresources.A
|
||||
actor = approvalAuditActor
|
||||
}
|
||||
if planFromApproval {
|
||||
if record.Plan.ApprovalPolicy == unifiedresources.ApprovalDryRun {
|
||||
refusalRecord := record
|
||||
if e != nil && e.actionAuditStore != nil {
|
||||
if current, found, err := e.actionAuditStore.GetActionAudit(record.ID); err == nil && found {
|
||||
refusalRecord = current
|
||||
}
|
||||
}
|
||||
return e.recordActionExecutionRefusal(refusalRecord, unifiedresources.ErrActionDryRunOnly, actor, now)
|
||||
}
|
||||
var err error
|
||||
approvalRecord := record
|
||||
record, err = e.ensureApprovalDecisionBeforeExecution(record, approvalID, actor, now)
|
||||
@@ -490,7 +499,7 @@ func (e *PulseToolExecutor) ensureApprovalDecisionBeforeExecution(record unified
|
||||
return current, nil
|
||||
}
|
||||
|
||||
approvalRecord := actionApprovalRecordForExecution(approvalID, actor, now, unifiedresources.OutcomeApproved)
|
||||
approvalRecord := actionApprovalRecordForExecution(current, approvalID, actor, now, unifiedresources.OutcomeApproved)
|
||||
updated, event, err := unifiedresources.ApplyActionDecision(current, approvalRecord, approvalRecord.Timestamp)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to normalize approval decision before execution")
|
||||
@@ -503,11 +512,11 @@ func (e *PulseToolExecutor) ensureApprovalDecisionBeforeExecution(record unified
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func actionApprovalRecordForExecution(approvalID, actor string, now time.Time, fallbackOutcome unifiedresources.ApprovalOutcome) unifiedresources.ActionApprovalRecord {
|
||||
func actionApprovalRecordForExecution(action unifiedresources.ActionAuditRecord, approvalID, actor string, now time.Time, fallbackOutcome unifiedresources.ApprovalOutcome) unifiedresources.ActionApprovalRecord {
|
||||
if fallbackOutcome == "" {
|
||||
fallbackOutcome = unifiedresources.OutcomeApproved
|
||||
}
|
||||
records := approvalRecordsForID(approvalID)
|
||||
records := approvalRecordsForID(approvalID, &action.Plan)
|
||||
if len(records) > 0 {
|
||||
record := records[len(records)-1]
|
||||
if record.Outcome == "" {
|
||||
@@ -521,11 +530,15 @@ func actionApprovalRecordForExecution(approvalID, actor string, now time.Time, f
|
||||
}
|
||||
return record
|
||||
}
|
||||
actorBinding := legacyApprovalActorBinding(approvalID, actor, action.Request.Actor.OrgID)
|
||||
evidence := legacyApprovalEvidence(actorBinding, &action.Plan, fallbackOutcome, now)
|
||||
return unifiedresources.ActionApprovalRecord{
|
||||
Actor: actor,
|
||||
Method: unifiedresources.MethodAPI,
|
||||
Timestamp: now,
|
||||
Outcome: fallbackOutcome,
|
||||
Actor: actorBinding.SubjectID,
|
||||
ActorBinding: actorBinding,
|
||||
Method: evidence.Method,
|
||||
Timestamp: now,
|
||||
Outcome: fallbackOutcome,
|
||||
Evidence: &evidence,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +630,7 @@ func RecordApprovalDecision(store unifiedresources.ResourceStore, approvalID str
|
||||
message = string(state)
|
||||
}
|
||||
record := actionAuditRecordFromApproval(req, state, actor)
|
||||
record.Approvals = approvalRecordsForID(req.ID)
|
||||
record.Approvals = approvalRecordsForID(req.ID, req.Plan)
|
||||
if recordApprovalDecisionAtomically(store, req.ID, record, actor) {
|
||||
return
|
||||
}
|
||||
@@ -665,7 +678,7 @@ func recordApprovalDecisionAtomically(store unifiedresources.ResourceStore, appr
|
||||
if record.State == unifiedresources.ActionStateRejected {
|
||||
outcome = unifiedresources.OutcomeRejected
|
||||
}
|
||||
approvalRecord := actionApprovalRecordForExecution(approvalID, actor, time.Now().UTC(), outcome)
|
||||
approvalRecord := actionApprovalRecordForExecution(current, approvalID, actor, time.Now().UTC(), outcome)
|
||||
approvalRecord.Outcome = outcome
|
||||
updated, event, err := unifiedresources.ApplyActionDecision(current, approvalRecord, approvalRecord.Timestamp)
|
||||
if err != nil {
|
||||
@@ -803,9 +816,15 @@ func mergeApprovedActionPlan(approved unifiedresources.ActionPlan, fallback unif
|
||||
return approved
|
||||
}
|
||||
|
||||
func actionAuditRecordFromApproval(req *approval.ApprovalRequest, state unifiedresources.ActionState, actor string) unifiedresources.ActionAuditRecord {
|
||||
func actionAuditRecordFromApproval(req *approval.ApprovalRequest, state unifiedresources.ActionState, _ string) unifiedresources.ActionAuditRecord {
|
||||
now := time.Now().UTC()
|
||||
plan := *req.Plan
|
||||
if plan.ApprovalPolicy == "" {
|
||||
plan.ApprovalPolicy = unifiedresources.ApprovalAdmin
|
||||
}
|
||||
if plan.ApprovalRequirement.Version == 0 {
|
||||
plan.ApprovalRequirement = unifiedresources.ApprovalRequirementForFloor(plan.ApprovalPolicy)
|
||||
}
|
||||
createdAt := plan.PlannedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = now
|
||||
@@ -824,6 +843,13 @@ func actionAuditRecordFromApproval(req *approval.ApprovalRequest, state unifiedr
|
||||
if orgID := strings.TrimSpace(req.OrgID); orgID != "" {
|
||||
params["orgId"] = orgID
|
||||
}
|
||||
requestedBy := approval.RequesterForRequest(req)
|
||||
requestActor := unifiedresources.ActionActor{
|
||||
SubjectID: requestedBy,
|
||||
Kind: unifiedresources.ActionActorService,
|
||||
CredentialID: "approval-request:" + strings.TrimSpace(req.ID),
|
||||
OrgID: approval.NormalizeOrgID(req.OrgID),
|
||||
}
|
||||
return unifiedresources.ActionAuditRecord{
|
||||
ID: plan.ActionID,
|
||||
CreatedAt: createdAt,
|
||||
@@ -835,7 +861,8 @@ func actionAuditRecordFromApproval(req *approval.ApprovalRequest, state unifiedr
|
||||
CapabilityName: approvalCapabilityForTargetType(req.TargetType),
|
||||
Params: params,
|
||||
Reason: strings.TrimSpace(req.Context),
|
||||
RequestedBy: actor,
|
||||
RequestedBy: requestedBy,
|
||||
Actor: requestActor,
|
||||
},
|
||||
Plan: plan,
|
||||
}
|
||||
@@ -989,7 +1016,7 @@ func actionAuditPreflight(resourceID, reason string, generatedAt time.Time) *uni
|
||||
}, unifiedresources.ActionRequest{ResourceID: resourceID, Reason: reason}, unifiedresources.ActionPlan{PlannedAt: generatedAt, Message: reason})
|
||||
}
|
||||
|
||||
func approvalRecordsForID(approvalID string) []unifiedresources.ActionApprovalRecord {
|
||||
func approvalRecordsForID(approvalID string, plan *unifiedresources.ActionPlan) []unifiedresources.ActionApprovalRecord {
|
||||
approvalID = strings.TrimSpace(approvalID)
|
||||
if approvalID == "" {
|
||||
return nil
|
||||
@@ -1003,19 +1030,49 @@ func approvalRecordsForID(approvalID string) []unifiedresources.ActionApprovalRe
|
||||
return nil
|
||||
}
|
||||
|
||||
record := unifiedresources.ActionApprovalRecord{
|
||||
Actor: strings.TrimSpace(req.DecidedBy),
|
||||
Method: unifiedresources.MethodAPI,
|
||||
Timestamp: approvalTimestamp(req),
|
||||
Outcome: unifiedresources.OutcomeApproved,
|
||||
Reason: strings.TrimSpace(req.Context),
|
||||
}
|
||||
outcome := unifiedresources.OutcomeApproved
|
||||
if req.Status == approval.StatusDenied {
|
||||
record.Outcome = unifiedresources.OutcomeRejected
|
||||
outcome = unifiedresources.OutcomeRejected
|
||||
}
|
||||
actorBinding := legacyApprovalActorBinding(approvalID, req.DecidedBy, req.OrgID)
|
||||
evidence := legacyApprovalEvidence(actorBinding, plan, outcome, approvalTimestamp(req))
|
||||
record := unifiedresources.ActionApprovalRecord{
|
||||
Actor: actorBinding.SubjectID,
|
||||
ActorBinding: actorBinding,
|
||||
Method: evidence.Method,
|
||||
Timestamp: evidence.IssuedAt,
|
||||
Outcome: outcome,
|
||||
Reason: strings.TrimSpace(req.Context),
|
||||
Evidence: &evidence,
|
||||
}
|
||||
return []unifiedresources.ActionApprovalRecord{record}
|
||||
}
|
||||
|
||||
func legacyApprovalActorBinding(approvalID, subject, orgID string) unifiedresources.ActionActor {
|
||||
return unifiedresources.ActionActor{
|
||||
SubjectID: strings.TrimSpace(subject),
|
||||
Kind: unifiedresources.ActionActorUser,
|
||||
CredentialID: "approval-session:" + strings.TrimSpace(approvalID),
|
||||
OrgID: approval.NormalizeOrgID(orgID),
|
||||
}
|
||||
}
|
||||
|
||||
func legacyApprovalEvidence(actor unifiedresources.ActionActor, plan *unifiedresources.ActionPlan, outcome unifiedresources.ApprovalOutcome, issuedAt time.Time) unifiedresources.ApprovalEvidence {
|
||||
evidence := unifiedresources.ApprovalEvidence{
|
||||
Version: 1,
|
||||
Method: unifiedresources.MethodSession,
|
||||
Actor: actor,
|
||||
OrgID: actor.OrgID,
|
||||
Outcome: outcome,
|
||||
IssuedAt: issuedAt.UTC(),
|
||||
}
|
||||
if plan != nil {
|
||||
evidence.ActionID = strings.TrimSpace(plan.ActionID)
|
||||
evidence.PlanHash = strings.TrimSpace(plan.PlanHash)
|
||||
}
|
||||
return evidence
|
||||
}
|
||||
|
||||
func approvalTimestamp(req *approval.ApprovalRequest) time.Time {
|
||||
if req == nil || req.DecidedAt == nil {
|
||||
return time.Now().UTC()
|
||||
|
||||
@@ -461,7 +461,7 @@ func TestRecordApprovalDecisionDoesNotRegressExecutingAudit(t *testing.T) {
|
||||
}
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: actionStore})
|
||||
record := actionAuditRecordFromApproval(req, unifiedresources.ActionStateExecuting, "pulse_control")
|
||||
record.Approvals = approvalRecordsForID(req.ID)
|
||||
record.Approvals = approvalRecordsForID(req.ID, req.Plan)
|
||||
if err := actionStore.RecordActionAudit(record); err != nil {
|
||||
t.Fatalf("RecordActionAudit: %v", err)
|
||||
}
|
||||
@@ -1104,7 +1104,11 @@ func TestExecuteCommandWithAuditRefusesApprovedDryRunOnlyAndExpiredPlans(t *test
|
||||
if !strings.HasPrefix(audit.Result.ErrorMessage, tc.wantPrefix) {
|
||||
t.Fatalf("ErrorMessage = %q, want prefix %q", audit.Result.ErrorMessage, tc.wantPrefix)
|
||||
}
|
||||
if len(audit.Approvals) != 1 || audit.Approvals[0].Outcome != unifiedresources.OutcomeApproved {
|
||||
if errors.Is(tc.wantErr, unifiedresources.ErrActionDryRunOnly) {
|
||||
if len(audit.Approvals) != 0 {
|
||||
t.Fatalf("dry-run-only refusal minted executable approval authority: %#v", audit.Approvals)
|
||||
}
|
||||
} else if len(audit.Approvals) != 1 || audit.Approvals[0].Outcome != unifiedresources.OutcomeApproved {
|
||||
t.Fatalf("expected approved audit record to be preserved, got %#v", audit.Approvals)
|
||||
}
|
||||
|
||||
|
||||
@@ -367,7 +367,7 @@ func TestHandleListPendingActionsReturnsOnlyCanonicalDecisionQueue(t *testing.T)
|
||||
}
|
||||
|
||||
func TestHandleListAndDetailActionsProjectCanonicalResourcePresentation(t *testing.T) {
|
||||
now := time.Date(2026, 7, 13, 15, 0, 0, 0, time.UTC)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
@@ -386,11 +386,15 @@ func TestHandleListAndDetailActionsProjectCanonicalResourcePresentation(t *testi
|
||||
}
|
||||
record := unified.ActionAuditRecord{
|
||||
ID: "act-resource-presentation", CreatedAt: now, UpdatedAt: now, State: unified.ActionStatePending,
|
||||
Request: unified.ActionRequest{RequestID: "req-resource-presentation", ResourceID: "vm:42", CapabilityName: "restart", Reason: "Recover checkout", RequestedBy: "pulse_patrol"},
|
||||
Request: unified.ActionRequest{
|
||||
RequestID: "req-resource-presentation", ResourceID: "vm:42", CapabilityName: "restart", Reason: "Recover checkout", RequestedBy: "pulse_patrol",
|
||||
Actor: unified.ActionActor{SubjectID: "pulse_patrol", Kind: unified.ActionActorService, CredentialID: "service:test-patrol", OrgID: "default"},
|
||||
},
|
||||
Plan: unified.ActionPlan{
|
||||
ActionID: "act-resource-presentation", RequestID: "req-resource-presentation",
|
||||
Allowed: true, RequiresApproval: true, ApprovalPolicy: unified.ApprovalAdmin,
|
||||
PlannedAt: now, ExpiresAt: now.Add(4 * time.Hour), PlanHash: "sha256:resource-presentation",
|
||||
ApprovalRequirement: unified.ApprovalRequirementForFloor(unified.ApprovalAdmin),
|
||||
PlannedAt: now, ExpiresAt: now.Add(4 * time.Hour), PlanHash: "sha256:resource-presentation",
|
||||
},
|
||||
}
|
||||
if err := store.RecordActionAudit(record); err != nil {
|
||||
|
||||
@@ -2132,6 +2132,22 @@ func generateKubernetesNodes(clusterID string, count int) []models.KubernetesNod
|
||||
nodes[idx].Ready = false
|
||||
}
|
||||
|
||||
// A non-empty demo cluster must retain at least one node that can run
|
||||
// workloads. Independent readiness and scheduling rolls can otherwise
|
||||
// produce a fully unavailable estate, making pod placement and the metric
|
||||
// story nondeterministic even when no outage scenario was requested.
|
||||
hasSchedulableReadyNode := false
|
||||
for _, node := range nodes {
|
||||
if node.Ready && !node.Unschedulable {
|
||||
hasSchedulableReadyNode = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSchedulableReadyNode {
|
||||
nodes[0].Ready = true
|
||||
nodes[0].Unschedulable = false
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
|
||||
@@ -380,6 +380,24 @@ func TestBuildFixtureStatePopulatesKubernetesUsageMetrics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateKubernetesNodesAlwaysRetainsSchedulableReadyNode(t *testing.T) {
|
||||
for count := 1; count <= 8; count++ {
|
||||
for iteration := 0; iteration < 100; iteration++ {
|
||||
nodes := generateKubernetesNodes("fixture-cluster", count)
|
||||
hasSchedulableReadyNode := false
|
||||
for _, node := range nodes {
|
||||
if node.Ready && !node.Unschedulable {
|
||||
hasSchedulableReadyNode = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSchedulableReadyNode {
|
||||
t.Fatalf("node count %d iteration %d produced no schedulable ready node: %+v", count, iteration, nodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFixtureStateIncludesKubernetesDeploymentAPIMetadata(t *testing.T) {
|
||||
cfg := DefaultConfig
|
||||
cfg.K8sClusterCount = 1
|
||||
|
||||
@@ -33,6 +33,7 @@ var infrastructureRouteCatalog = map[string]routeClassification{
|
||||
"/api/updates/plan": {},
|
||||
"/api/updates/history": {},
|
||||
"/api/updates/history/entry": {},
|
||||
"/api/updates/release-notes": {},
|
||||
"/api/ai/run-command": {MutationID: "legacy.api.run-command"},
|
||||
"/api/ai/remediation/plans": {},
|
||||
"/api/ai/remediation/plan": {},
|
||||
|
||||
@@ -493,6 +493,10 @@ func normalizeRestoredStateTruth(truth ActionRestoredStateTruth) (ActionRestored
|
||||
}
|
||||
|
||||
func NormalizeActionResultV2(result ActionResultV2) (ActionResultV2, error) {
|
||||
// Normalization is routinely applied to shared store and mock-fixture
|
||||
// snapshots. Clone every nested truth surface before canonicalizing it so
|
||||
// digest calculation and UTC normalization never mutate the caller's copy.
|
||||
result = cloneActionResultV2(result)
|
||||
if result.Version == 0 {
|
||||
result.Version = ActionResultV2Version
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -26,6 +27,40 @@ func actionResultTestEvidence(class ActionEvidenceClass) []ActionEvidence {
|
||||
}}
|
||||
}
|
||||
|
||||
func TestNormalizeActionResultV2DoesNotMutateSharedInput(t *testing.T) {
|
||||
input := ActionResultV2{
|
||||
Version: ActionResultV2Version,
|
||||
Execution: ActionExecutionTruth{Status: ActionExecutionSucceeded},
|
||||
Verification: ActionVerificationTruth{Status: ActionVerificationConfirmed, EvidenceClass: ActionEvidenceIndependent, Evidence: actionResultTestEvidence(ActionEvidenceIndependent)},
|
||||
Compensation: actionResultTestCompensation(),
|
||||
}
|
||||
|
||||
const callers = 16
|
||||
errs := make(chan error, callers)
|
||||
var wg sync.WaitGroup
|
||||
for range callers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
normalized, err := NormalizeActionResultV2(input)
|
||||
if err == nil && normalized.Verification.Evidence[0].Digest == "" {
|
||||
err = errors.New("normalized evidence is missing its digest")
|
||||
}
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if input.Verification.Evidence[0].Digest != "" {
|
||||
t.Fatalf("normalization mutated shared input digest: %q", input.Verification.Evidence[0].Digest)
|
||||
}
|
||||
}
|
||||
|
||||
func actionResultTestCompensation() ActionCompensationTruth {
|
||||
return ActionCompensationTruth{Support: ActionCompensationUnavailable, Status: ActionCompensationNotAvailable}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,9 @@ func TestCanonicalActionPlanConstructionCannotBypassPolicyProvenancePlanner(t *t
|
||||
// audit history remain readable but are not canonical action producers.
|
||||
"../api/router_routes_ai_relay.go": true,
|
||||
"../ai/tools/action_audit.go": true,
|
||||
// Graph-owned mock records are immutable presentation fixtures. They do
|
||||
// not admit, approve, or dispatch executable actions.
|
||||
"../mock/action_fixtures.go": true,
|
||||
}
|
||||
pattern := regexp.MustCompile(`(?s)\bActionPlan\s*\{\s*[A-Za-z_][A-Za-z0-9_]*\s*:`)
|
||||
err := filepath.Walk("..", func(path string, info os.FileInfo, err error) error {
|
||||
|
||||
Reference in New Issue
Block a user