mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:22:17 +00:00
098ba4eaa9
Docker adapters restamp relationship ObservedAt/LastSeenAt on every ~15s report, and the action planner folded those stamps into the plan's resource version, so any reviewed action against a relationship-bearing container (start, stop, restart, and the restored update) drifted to a 409 action_plan_drift before a human could read the review dialog and click approve. Relationship edges now count by identity (source, target, type, active, discoverer, metadata), the same identity-versus-timestamp boundary change emission drew for issue #1496. Found live: the UI update journey failed with plan drift on every attempt slower than one report cycle.
359 lines
14 KiB
Go
359 lines
14 KiB
Go
package actionplanner
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
func TestPlannerBuildsDeterministicGovernedPlan(t *testing.T) {
|
|
now := time.Date(2026, 5, 3, 9, 30, 0, 0, time.UTC)
|
|
parentID := "agent:node-1"
|
|
resource := unified.Resource{
|
|
ID: "vm:42",
|
|
Type: unified.ResourceTypeVM,
|
|
Name: "web-42",
|
|
Status: unified.StatusWarning,
|
|
LastSeen: now.Add(-time.Minute),
|
|
UpdatedAt: now.Add(-30 * time.Second),
|
|
ParentID: &parentID,
|
|
Capabilities: []unified.ResourceCapability{
|
|
{
|
|
Name: "restart",
|
|
Type: unified.CapabilityTypeCommon,
|
|
Description: "Restart the VM",
|
|
MinimumApprovalLevel: unified.ApprovalAdmin,
|
|
InternalHandler: "proxmox.vm.restart",
|
|
Params: []unified.CapabilityParam{
|
|
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
|
|
},
|
|
},
|
|
},
|
|
Relationships: []unified.ResourceRelationship{
|
|
{
|
|
SourceID: "vm:42",
|
|
TargetID: "service:web",
|
|
Type: unified.RelDependsOn,
|
|
Active: true,
|
|
},
|
|
},
|
|
}
|
|
req := unified.ActionRequest{
|
|
RequestID: "agent-run-123",
|
|
ResourceID: " vm:42 ",
|
|
CapabilityName: "restart",
|
|
Params: map[string]any{"mode": "graceful"},
|
|
Reason: "Recover after confirmed outage",
|
|
RequestedBy: "agent:oncall-helper",
|
|
Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"},
|
|
}
|
|
|
|
planner := Planner{Now: func() time.Time { return now }}
|
|
plan, err := planner.Plan(req, resource)
|
|
if err != nil {
|
|
t.Fatalf("Plan() error = %v", err)
|
|
}
|
|
second, err := planner.Plan(req, resource)
|
|
if err != nil {
|
|
t.Fatalf("Plan() second error = %v", err)
|
|
}
|
|
|
|
if plan.ActionID == "" || plan.ActionID != second.ActionID {
|
|
t.Fatalf("action id is not deterministic: first=%q second=%q", plan.ActionID, second.ActionID)
|
|
}
|
|
if plan.PlanHash == "" || plan.PlanHash != second.PlanHash {
|
|
t.Fatalf("plan hash is not deterministic: first=%q second=%q", plan.PlanHash, second.PlanHash)
|
|
}
|
|
if !plan.Allowed {
|
|
t.Fatalf("Allowed = false, want true")
|
|
}
|
|
if !plan.RequiresApproval {
|
|
t.Fatalf("RequiresApproval = false, want true")
|
|
}
|
|
if plan.ApprovalPolicy != unified.ApprovalAdmin {
|
|
t.Fatalf("ApprovalPolicy = %q, want %q", plan.ApprovalPolicy, unified.ApprovalAdmin)
|
|
}
|
|
if !plan.PlannedAt.Equal(now) {
|
|
t.Fatalf("PlannedAt = %s, want %s", plan.PlannedAt, now)
|
|
}
|
|
if !plan.ExpiresAt.Equal(now.Add(DefaultPlanTTL)) {
|
|
t.Fatalf("ExpiresAt = %s, want %s", plan.ExpiresAt, now.Add(DefaultPlanTTL))
|
|
}
|
|
if plan.PolicyDecision.Version != unified.ActionPolicyDecisionVersion || plan.PolicyDecision.Status != unified.ActionPolicyDecisionResolved || len(plan.PolicyDecision.Authorities) != 1 || plan.PolicyDecision.Authorities[0].Kind != unified.ActionPolicyAuthorityCapability {
|
|
t.Fatalf("direct plan must record only its consulted capability authority: %#v", plan.PolicyDecision)
|
|
}
|
|
if len(plan.PredictedBlastRadius) != 3 ||
|
|
plan.PredictedBlastRadius[0] != "vm:42" ||
|
|
plan.PredictedBlastRadius[1] != "agent:node-1" ||
|
|
plan.PredictedBlastRadius[2] != "service:web" {
|
|
t.Fatalf("PredictedBlastRadius = %#v", plan.PredictedBlastRadius)
|
|
}
|
|
if plan.Preflight == nil {
|
|
t.Fatalf("Preflight is nil")
|
|
}
|
|
if plan.Preflight.Target != "vm:42" {
|
|
t.Fatalf("Preflight.Target = %q, want vm:42", plan.Preflight.Target)
|
|
}
|
|
if plan.Preflight.DryRunAvailable {
|
|
t.Fatalf("DryRunAvailable = true, want false without provider dry-run contract")
|
|
}
|
|
}
|
|
|
|
func TestPlannerPolicyDecisionChangesPlanHashWithoutReusingExecutionAuthority(t *testing.T) {
|
|
now := time.Date(2026, 7, 11, 20, 30, 0, 0, time.UTC)
|
|
resource := unified.Resource{ID: "vm:42", Type: unified.ResourceTypeVM, Name: "web", Status: unified.StatusOnline, Capabilities: []unified.ResourceCapability{{Name: "restart", MinimumApprovalLevel: unified.ApprovalAdmin}}}
|
|
req := unified.ActionRequest{RequestID: "proposal-1", ResourceID: "vm:42", CapabilityName: "restart", Reason: "recover", Actor: unified.ActionActor{SubjectID: "pulse_patrol", Kind: unified.ActionActorService, CredentialID: "service:patrol", OrgID: "default"}}
|
|
factor := unified.ActionPolicyAuthorityFactor{Kind: unified.ActionPolicyAuthorityTenant, SourceID: "patrol-tenant-policy", Revision: "tenant-policy:sha256:0123456789abcdef01234567", Status: unified.ActionPolicyAuthorityConsulted, ReasonCodes: []unified.ActionPolicyReasonCode{unified.PolicyReasonTenantModeAssisted, unified.PolicyReasonTenantFullLocked}}
|
|
planner := Planner{Now: func() time.Time { return now }}
|
|
first, err := planner.PlanWithPolicyFactors(req, resource, unified.ApprovalRequirement{}, []unified.ActionPolicyAuthorityFactor{factor})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
factor.Revision = "tenant-policy:sha256:1123456789abcdef01234567"
|
|
second, err := planner.PlanWithPolicyFactors(req, resource, unified.ApprovalRequirement{}, []unified.ActionPolicyAuthorityFactor{factor})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.ActionID != second.ActionID {
|
|
t.Fatalf("descriptive tenant snapshot must not replace request identity: %q != %q", first.ActionID, second.ActionID)
|
|
}
|
|
if first.PlanHash == second.PlanHash || first.PolicyDecision.DecisionID == second.PolicyDecision.DecisionID {
|
|
t.Fatalf("policy provenance revision must change plan hash and decision digest")
|
|
}
|
|
}
|
|
|
|
func TestPlannerBuildsDryRunOnlyPlanWithoutExecutionApproval(t *testing.T) {
|
|
now := time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC)
|
|
resource := unified.Resource{
|
|
ID: "vm:42",
|
|
Type: unified.ResourceTypeVM,
|
|
Name: "web-42",
|
|
Status: unified.StatusOnline,
|
|
Capabilities: []unified.ResourceCapability{
|
|
{
|
|
Name: "restart",
|
|
Type: unified.CapabilityTypeCommon,
|
|
Description: "Restart the VM",
|
|
MinimumApprovalLevel: unified.ApprovalDryRun,
|
|
},
|
|
},
|
|
}
|
|
req := unified.ActionRequest{
|
|
RequestID: "agent-run-dry-run",
|
|
ResourceID: "vm:42",
|
|
CapabilityName: "restart",
|
|
Reason: "Validate restart path without execution",
|
|
RequestedBy: "agent:oncall-helper",
|
|
Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"},
|
|
}
|
|
|
|
plan, err := (Planner{Now: func() time.Time { return now }}).Plan(req, resource)
|
|
if err != nil {
|
|
t.Fatalf("Plan() error = %v", err)
|
|
}
|
|
if plan.ApprovalPolicy != unified.ApprovalDryRun {
|
|
t.Fatalf("ApprovalPolicy = %q, want %q", plan.ApprovalPolicy, unified.ApprovalDryRun)
|
|
}
|
|
if plan.RequiresApproval {
|
|
t.Fatalf("RequiresApproval = true, want false because dry-run-only plans cannot be executed")
|
|
}
|
|
if plan.Preflight == nil || !strings.Contains(strings.Join(plan.Preflight.SafetyChecks, " "), "dry-run-only") {
|
|
t.Fatalf("dry-run-only safety checks missing: %#v", plan.Preflight)
|
|
}
|
|
if !strings.Contains(plan.Message, "dry-run only") {
|
|
t.Fatalf("plan message = %q", plan.Message)
|
|
}
|
|
}
|
|
|
|
func TestPlannerRejectsUndeclaredParams(t *testing.T) {
|
|
resource := unified.Resource{
|
|
ID: "vm:42",
|
|
Type: unified.ResourceTypeVM,
|
|
Capabilities: []unified.ResourceCapability{
|
|
{Name: "restart", Type: unified.CapabilityTypeCommon, MinimumApprovalLevel: unified.ApprovalAdmin},
|
|
},
|
|
}
|
|
req := unified.ActionRequest{
|
|
RequestID: "agent-run-123",
|
|
ResourceID: "vm:42",
|
|
CapabilityName: "restart",
|
|
Params: map[string]any{"force": true},
|
|
Reason: "Recover after confirmed outage",
|
|
RequestedBy: "agent:oncall-helper",
|
|
Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"},
|
|
}
|
|
|
|
_, err := Planner{}.Plan(req, resource)
|
|
validationErr, ok := AsValidationError(err)
|
|
if !ok {
|
|
t.Fatalf("Plan() error = %v, want validation error", err)
|
|
}
|
|
if validationErr.Field != "params.force" {
|
|
t.Fatalf("validation field = %q, want params.force", validationErr.Field)
|
|
}
|
|
}
|
|
|
|
func TestResourceVersionIgnoresObservationOnlyTimestamps(t *testing.T) {
|
|
base := unified.Resource{
|
|
ID: "vm:42",
|
|
Type: unified.ResourceTypeVM,
|
|
Name: "web-42",
|
|
Status: unified.StatusOnline,
|
|
LastSeen: time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC),
|
|
UpdatedAt: time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC),
|
|
Identity: unified.ResourceIdentity{
|
|
Hostnames: []string{"web-42"},
|
|
IPAddresses: []string{"192.0.2.10"},
|
|
},
|
|
SourceStatus: map[unified.DataSource]unified.SourceStatus{
|
|
unified.SourceProxmox: {
|
|
Status: "online",
|
|
LastSeen: time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC),
|
|
},
|
|
},
|
|
}
|
|
refreshed := base
|
|
refreshed.LastSeen = base.LastSeen.Add(time.Minute)
|
|
refreshed.UpdatedAt = base.UpdatedAt.Add(time.Minute)
|
|
refreshed.Identity = unified.ResourceIdentity{
|
|
Hostnames: []string{"web-42", "web-42.local"},
|
|
IPAddresses: []string{"192.0.2.10", "192.0.2.11"},
|
|
}
|
|
refreshed.SourceStatus = map[unified.DataSource]unified.SourceStatus{
|
|
unified.SourceProxmox: {
|
|
Status: "online",
|
|
LastSeen: base.SourceStatus[unified.SourceProxmox].LastSeen.Add(time.Minute),
|
|
},
|
|
}
|
|
|
|
if got, want := ResourceVersion(refreshed), ResourceVersion(base); got != want {
|
|
t.Fatalf("ResourceVersion changed for observation-only timestamp drift: got %q want %q", got, want)
|
|
}
|
|
|
|
changed := base
|
|
changed.Status = unified.StatusWarning
|
|
if got, unchanged := ResourceVersion(changed), ResourceVersion(base); got == unchanged {
|
|
t.Fatalf("ResourceVersion did not change for status drift: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPlannerReturnsCapabilityNotFound(t *testing.T) {
|
|
resource := unified.Resource{ID: "vm:42", Type: unified.ResourceTypeVM}
|
|
req := unified.ActionRequest{
|
|
RequestID: "agent-run-123",
|
|
ResourceID: "vm:42",
|
|
CapabilityName: "restart",
|
|
Reason: "Recover after confirmed outage",
|
|
RequestedBy: "agent:oncall-helper",
|
|
Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"},
|
|
}
|
|
|
|
_, err := Planner{}.Plan(req, resource)
|
|
if !errors.Is(err, ErrCapabilityNotFound) {
|
|
t.Fatalf("Plan() error = %v, want ErrCapabilityNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestPlannerIdentityChangesWhenAutoAuthorizationEligibilityChanges(t *testing.T) {
|
|
resource := unified.Resource{
|
|
ID: "app-container:demo",
|
|
Type: unified.ResourceTypeAppContainer,
|
|
Name: "demo",
|
|
Status: unified.StatusOnline,
|
|
Capabilities: []unified.ResourceCapability{{
|
|
Name: "restart",
|
|
MinimumApprovalLevel: unified.ApprovalAdmin,
|
|
}},
|
|
}
|
|
req := unified.ActionRequest{
|
|
RequestID: "proposal-1",
|
|
ResourceID: resource.ID,
|
|
CapabilityName: "restart",
|
|
Reason: "recover service",
|
|
RequestedBy: "pulse_patrol",
|
|
Actor: unified.ActionActor{SubjectID: "pulse_patrol", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"},
|
|
}
|
|
withoutEligibility, err := (Planner{}).Plan(req, resource)
|
|
if err != nil {
|
|
t.Fatalf("plan without eligibility: %v", err)
|
|
}
|
|
resource.Capabilities[0].AutoAuthorization = unified.AutoAuthorizeLowRisk
|
|
withEligibility, err := (Planner{}).Plan(req, resource)
|
|
if err != nil {
|
|
t.Fatalf("plan with eligibility: %v", err)
|
|
}
|
|
if withoutEligibility.PolicyVersion == withEligibility.PolicyVersion || withoutEligibility.ActionID == withEligibility.ActionID || withoutEligibility.PlanHash == withEligibility.PlanHash {
|
|
t.Fatalf("auto-authorization eligibility must participate in plan identity: without=%#v with=%#v", withoutEligibility, withEligibility)
|
|
}
|
|
}
|
|
|
|
func TestExportedValidationMatchesPlanningExactly(t *testing.T) {
|
|
capabilities := []unified.ResourceCapability{{
|
|
Name: "restart",
|
|
Params: []unified.CapabilityParam{
|
|
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
|
|
},
|
|
}}
|
|
|
|
// FindCapability is the planner's exact-name matcher, exported so
|
|
// proposal acceptance can never drift on resolution.
|
|
if _, ok := FindCapability(capabilities, "restart"); !ok {
|
|
t.Fatal("exact name must resolve")
|
|
}
|
|
if _, ok := FindCapability(capabilities, "Restart"); ok {
|
|
t.Fatal("case-mismatched name must not resolve; planning matches exactly")
|
|
}
|
|
if _, ok := FindCapability(capabilities, " restart "); !ok {
|
|
t.Fatal("trimmed name must resolve like planning")
|
|
}
|
|
|
|
// ValidateParams is the canonical parameter validation shared with
|
|
// proposal acceptance.
|
|
if err := ValidateParams(map[string]any{"mode": "graceful"}, capabilities[0].Params); err != nil {
|
|
t.Fatalf("valid params rejected: %v", err)
|
|
}
|
|
if err := ValidateParams(map[string]any{"mode": " "}, capabilities[0].Params); err == nil {
|
|
t.Fatal("whitespace-only required value must fail")
|
|
}
|
|
if err := ValidateParams(map[string]any{"mode": 42}, capabilities[0].Params); err == nil {
|
|
t.Fatal("wrong-typed value must fail")
|
|
}
|
|
if err := ValidateParams(map[string]any{"mode": "graceful", "extra": "x"}, capabilities[0].Params); err == nil {
|
|
t.Fatal("undeclared parameter must fail")
|
|
}
|
|
}
|
|
|
|
func TestResourceVersionIgnoresRelationshipObservationTimestamps(t *testing.T) {
|
|
base := unified.Resource{
|
|
ID: "app-container:1",
|
|
Type: unified.ResourceTypeAppContainer,
|
|
Name: "api",
|
|
Status: unified.StatusOnline,
|
|
Relationships: []unified.ResourceRelationship{{
|
|
SourceID: "app-container:1", TargetID: "docker-network:1",
|
|
Type: unified.RelationshipType("attached_to"), Confidence: 1, Active: true,
|
|
Discoverer: "docker_adapter",
|
|
ObservedAt: time.Date(2026, 7, 14, 11, 0, 0, 0, time.UTC),
|
|
LastSeenAt: time.Date(2026, 7, 14, 11, 0, 0, 0, time.UTC),
|
|
}},
|
|
}
|
|
restamped := base
|
|
restamped.Relationships = []unified.ResourceRelationship{base.Relationships[0]}
|
|
restamped.Relationships[0].ObservedAt = base.Relationships[0].ObservedAt.Add(15 * time.Second)
|
|
restamped.Relationships[0].LastSeenAt = base.Relationships[0].LastSeenAt.Add(15 * time.Second)
|
|
|
|
if ResourceVersion(base) != ResourceVersion(restamped) {
|
|
t.Fatal("resource version drifted on relationship observation restamp alone")
|
|
}
|
|
|
|
rewired := base
|
|
rewired.Relationships = []unified.ResourceRelationship{base.Relationships[0]}
|
|
rewired.Relationships[0].TargetID = "docker-network:2"
|
|
if ResourceVersion(base) == ResourceVersion(rewired) {
|
|
t.Fatal("resource version ignored a real relationship change")
|
|
}
|
|
}
|