diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index ec4e08be6..3b087d99b 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -336,7 +336,8 @@ fleet projection used by Infrastructure. Agent lifecycle and fleet-operation surfaces may consume `POST /api/actions/plan` for resource capability planning, but the action plan -contract remains API-owned through `internal/api/actions.go` and +contract remains API-owned through `internal/api/actions.go`, +`internal/actionlifecycle/service.go`, and `internal/actionplanner/planner.go`. Agent lifecycle work must not define a parallel approval policy, blast-radius model, stale-plan hash, or execution contract for those resource actions. Successful action plans also belong to diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 9f2af0092..40b7112fa 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -37,6 +37,7 @@ product API routes free of maintainer commercial analytics. 5a. `internal/api/action_executor.go` 5b. `internal/api/docker_container_action_executor.go` 5c. `internal/api/proxmox_guest_action_executor.go` + 6a. `internal/actionlifecycle/service.go` 7. `internal/actionplanner/planner.go` 8. `pkg/pulsecli/api_client.go` 9. `pkg/pulsecli/actions.go` @@ -1808,7 +1809,8 @@ a new API state machine, queue contract, or verification-accounting field. cannot become unbounded table scans through API parameters. Plan-only unified action planning is part of that same API-first action contract: `POST /api/actions/plan` must route through - `internal/api/actions.go`, `internal/actionplanner/planner.go`, and + `internal/api/actions.go`, `internal/actionlifecycle/service.go`, + `internal/actionplanner/planner.go`, and `internal/api/contract_test.go` together, returning deterministic `ActionPlan` identity, approval policy, blast radius, resource/policy versions, plan hash, and preflight checks without approving or executing the @@ -1819,8 +1821,18 @@ a new API state machine, queue contract, or verification-accounting field. not duplicate the initial lifecycle events. MCP, CLI, and UI consumers may adapt this payload, but they must not become the source of truth for action planning semantics. + The plan/decision/execution lifecycle itself is transport-independent and + lives in `internal/actionlifecycle/service.go`; the REST handlers in + `internal/api/actions.go` are thin decode/actor/error-mapping adapters over + that one service. In-process consumers (for example a Patrol action broker) + must call the same service and therefore inherit identical resource lookup, + availability checks, plan hashing, audit persistence, approval decisions, + remediation locks, plan-drift revalidation, execution, and terminal + publication. No caller, HTTP or in-process, may implement a parallel + lifecycle or dispatch a resource mutation around this service. Executor-owned live readiness is part of planning, not a UI precheck: - after planner validation and before audit persistence, `actions.go` must ask + after planner validation and before audit persistence, the lifecycle + service must ask the registered executor whether the resource/capability is currently executable. A failed readiness check returns `409` `action_execution_unavailable`, does not create or mutate an action audit diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 16c235b07..4c0630c36 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2021,6 +2021,7 @@ "owned_prefixes": [ "cmd/pulse-mcp/", "frontend-modern/src/api/", + "internal/actionlifecycle/", "internal/actionplanner/", "internal/agentcapabilities/", "internal/api/" @@ -2541,6 +2542,21 @@ "exact_files": [ "internal/actionplanner/planner_test.go" ] + }, + { + "id": "action-lifecycle-service-runtime", + "label": "transport-independent action lifecycle service proof", + "match_prefixes": [ + "internal/actionlifecycle/" + ], + "match_files": [], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/actionlifecycle/service_test.go", + "internal/api/actions_test.go", + "internal/api/contract_test.go" + ] } ], "match_files": null diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 064c99826..0393c9967 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -311,7 +311,8 @@ Storage/recovery remediation or restore-adjacent workflows may consume `POST /api/actions/plan` only as the API-owned resource capability planning contract. This subsystem must not create a storage-local approval policy, stale-plan hash, blast-radius model, or execution protocol outside -`internal/api/actions.go` and `internal/actionplanner/planner.go`. +`internal/api/actions.go`, `internal/actionlifecycle/service.go`, and +`internal/actionplanner/planner.go`. Storage/recovery surfaces may consume unified-resource `platformScopes` as read-only platform membership context, but they must not reinterpret runtime scope overlap as storage or recovery ownership. A Docker workload that also diff --git a/frontend-modern/src/components/Settings/__tests__/aiSettingsModel.coverage.test.ts b/frontend-modern/src/components/Settings/__tests__/aiSettingsModel.coverage.test.ts new file mode 100644 index 000000000..4a8e812ea --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/aiSettingsModel.coverage.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import type { AIProvider, AISettings } from '@/types/ai'; +import { PROVIDER_DESCRIPTIONS } from '@/types/ai'; +import { + AI_PROVIDERS, + AI_PROVIDER_CONFIGS, + AI_SETUP_PROVIDER_OPTIONS, + createInitialProviderHealth, + getAIProviderConfig, + isAIProviderConfigured, + isModelProviderConfigured, +} from '../aiSettingsModel'; + +const ALL_PROVIDERS: AIProvider[] = [ + 'anthropic', + 'openai', + 'openrouter', + 'deepseek', + 'gemini', + 'zai', + 'groq', + 'mistral', + 'cerebras', + 'together', + 'fireworks', + 'ollama', +]; + +function makeSettings(overrides: Partial = {}): AISettings { + return { + enabled: false, + model: '', + configured: false, + custom_context: '', + auth_method: 'api_key', + oauth_connected: false, + anthropic_configured: false, + openai_configured: false, + openrouter_configured: false, + deepseek_configured: false, + gemini_configured: false, + ollama_configured: false, + ollama_base_url: '', + ollama_keep_alive: '', + configured_providers: [], + ...overrides, + }; +} + +describe('aiSettingsModel - AI_SETUP_PROVIDER_OPTIONS contract', () => { + it('uses each provider value at most once', () => { + const values = AI_SETUP_PROVIDER_OPTIONS.map((option) => option.value); + expect(new Set(values).size).toBe(values.length); + }); + + it('lists provider values in the same order as AI_PROVIDERS', () => { + expect(AI_SETUP_PROVIDER_OPTIONS.map((option) => option.value)).toEqual(AI_PROVIDERS); + }); + + it('gives every option a non-empty title and a present description', () => { + for (const option of AI_SETUP_PROVIDER_OPTIONS) { + expect(option.title.length).toBeGreaterThan(0); + expect(option.description).toBeDefined(); + expect((option.description ?? '').length).toBeGreaterThan(0); + } + }); + + it('keeps option values in sync with PROVIDER_DESCRIPTIONS in both directions', () => { + const optionValues = new Set(AI_SETUP_PROVIDER_OPTIONS.map((option) => option.value)); + const descriptionKeys = new Set(Object.keys(PROVIDER_DESCRIPTIONS)); + + for (const value of optionValues) { + expect(descriptionKeys.has(value)).toBe(true); + } + for (const key of descriptionKeys) { + expect(optionValues.has(key as AIProvider)).toBe(true); + } + }); +}); + +describe('aiSettingsModel - createInitialProviderHealth', () => { + it('seeds exactly the known providers, each not_configured with an empty message', () => { + const health = createInitialProviderHealth(); + + expect(Object.keys(health).sort()).toEqual([...ALL_PROVIDERS].sort()); + for (const provider of ALL_PROVIDERS) { + expect(health[provider]).toEqual({ status: 'not_configured', message: '' }); + } + }); +}); + +describe('aiSettingsModel - getAIProviderConfig', () => { + it('returns the config whose provider matches for every known provider', () => { + for (const provider of ALL_PROVIDERS) { + expect(getAIProviderConfig(provider).provider).toBe(provider); + } + }); + + it('distinguishes the url-based ollama config from password-based providers', () => { + expect(getAIProviderConfig('ollama').inputType).toBe('url'); + expect(getAIProviderConfig('ollama').inputField).toBe('ollamaBaseUrl'); + expect(getAIProviderConfig('anthropic').inputType).toBe('password'); + expect(getAIProviderConfig('anthropic').inputField).toBe('anthropicApiKey'); + }); + + it('throws and names the provider when it is unknown', () => { + const unknown = 'pulse' as unknown as AIProvider; + expect(() => getAIProviderConfig(unknown)).toThrowError(/pulse/); + }); + + it('exposes a config entry for every option value', () => { + const configuredProviders = new Set(AI_PROVIDER_CONFIGS.map((config) => config.provider)); + for (const option of AI_SETUP_PROVIDER_OPTIONS) { + expect(configuredProviders.has(option.value)).toBe(true); + } + }); +}); + +describe('aiSettingsModel - isAIProviderConfigured', () => { + it('returns false when settings are null regardless of provider', () => { + expect(isAIProviderConfigured('anthropic', null)).toBe(false); + expect(isAIProviderConfigured('ollama', null)).toBe(false); + }); + + it('routes each provider to its own configured field', () => { + const fieldByProvider: Record = { + anthropic: 'anthropic_configured', + openai: 'openai_configured', + openrouter: 'openrouter_configured', + deepseek: 'deepseek_configured', + gemini: 'gemini_configured', + zai: 'zai_configured', + groq: 'groq_configured', + mistral: 'mistral_configured', + cerebras: 'cerebras_configured', + together: 'together_configured', + fireworks: 'fireworks_configured', + ollama: 'ollama_configured', + }; + + for (const provider of ALL_PROVIDERS) { + const onlyThis = makeSettings({ + [fieldByProvider[provider]]: true, + } as Partial); + + expect(isAIProviderConfigured(provider, onlyThis)).toBe(true); + const other = provider === 'anthropic' ? 'openai' : 'anthropic'; + expect(isAIProviderConfigured(other, onlyThis)).toBe(false); + } + }); + + it('treats missing optional provider fields as not configured', () => { + const settings = makeSettings(); + expect(settings.zai_configured).toBeUndefined(); + expect(isAIProviderConfigured('zai', settings)).toBe(false); + expect(isAIProviderConfigured('zai', makeSettings({ zai_configured: true }))).toBe(true); + }); + + it('returns false for an unknown provider', () => { + expect(isAIProviderConfigured('pulse', makeSettings())).toBe(false); + }); +}); + +describe('aiSettingsModel - isModelProviderConfigured', () => { + it('returns false when settings are null', () => { + expect(isModelProviderConfigured('anthropic:claude-opus-4', null)).toBe(false); + }); + + it('delegates to the provider resolved from the model id', () => { + const anthropicConfigured = makeSettings({ anthropic_configured: true }); + expect(isModelProviderConfigured('claude-opus-4', anthropicConfigured)).toBe(true); + + const noneConfigured = makeSettings(); + expect(isModelProviderConfigured('gpt-4o', noneConfigured)).toBe(false); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.coverage.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.coverage.test.ts new file mode 100644 index 000000000..f800731c6 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.coverage.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentCapability } from '@/utils/agentCapabilityPresentation'; +import type { UnifiedAgentRow, UnifiedAgentSurface } from '../infrastructureOperationsModel'; +import { + buildCommandsByPlatform, + buildDefaultTokenName, + getReconnectActionLabel, + getRowReportingSummary, + shellQuoteArg, +} from '../infrastructureOperationsModel'; + +const makeRow = (overrides: Partial): UnifiedAgentRow => ({ + rowKey: 'row-1', + id: 'agent-1', + name: 'node-a', + capabilities: [], + status: 'active', + upgradePlatform: 'linux', + scope: { label: 'Default', category: 'default' }, + installFlags: [], + searchText: '', + surfaces: [], + ...overrides, +}); + +const surface = (label: string, kind: AgentCapability = 'agent'): UnifiedAgentSurface => ({ + key: kind, + kind, + label, + detail: '', +}); + +describe('buildDefaultTokenName', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('formats the current UTC instant as "Agent YYYY-MM-DD HH-MM"', () => { + vi.setSystemTime(new Date('2024-03-05T09:07:30.123Z')); + expect(buildDefaultTokenName()).toBe('Agent 2024-03-05 09-07'); + }); + + it('reflects a different instant and rewrites the "T" and ":" separators', () => { + vi.setSystemTime(new Date('2024-12-31T23:59:00.000Z')); + expect(buildDefaultTokenName()).toBe('Agent 2024-12-31 23-59'); + }); +}); + +describe('shellQuoteArg', () => { + it('wraps a plain token in single quotes', () => { + expect(shellQuoteArg('abc')).toBe("'abc'"); + }); + + it('wraps an empty string into an empty single-quoted arg', () => { + expect(shellQuoteArg('')).toBe("''"); + }); + + it('preserves spaces and double quotes without escaping them', () => { + expect(shellQuoteArg('say "hi" now')).toBe("'say \"hi\" now'"); + }); + + it('keeps shell metacharacters literal inside the single quotes', () => { + expect(shellQuoteArg('a$b`c\\d')).toBe("'a$b`c\\d'"); + }); + + it('escapes an embedded single quote via the concatenated close-reopen sequence', () => { + expect(shellQuoteArg("it's")).toBe("'it'\"'\"'s'"); + }); + + it('escapes a leading single quote', () => { + expect(shellQuoteArg("'ab")).toBe("''\"'\"'ab'"); + }); + + it('escapes every single quote when several appear', () => { + expect(shellQuoteArg("a'b'c")).toBe("'a'\"'\"'b'\"'\"'c'"); + }); +}); + +describe('getReconnectActionLabel', () => { + it('returns the Docker label when docker is present', () => { + expect(getReconnectActionLabel(makeRow({ capabilities: ['docker'] }))).toBe( + 'Allow Docker reconnect', + ); + }); + + it('prefers docker over kubernetes when both are present', () => { + expect(getReconnectActionLabel(makeRow({ capabilities: ['kubernetes', 'docker'] }))).toBe( + 'Allow Docker reconnect', + ); + }); + + it('returns the Kubernetes label when only kubernetes is present', () => { + expect(getReconnectActionLabel(makeRow({ capabilities: ['kubernetes'] }))).toBe( + 'Allow Kubernetes reconnect', + ); + }); + + it('falls back to the host label for host-only capabilities', () => { + expect(getReconnectActionLabel(makeRow({ capabilities: ['agent'] }))).toBe( + 'Allow host reconnect', + ); + }); + + it('falls back to the host label when capabilities is empty', () => { + expect(getReconnectActionLabel(makeRow({ capabilities: [] }))).toBe('Allow host reconnect'); + }); +}); + +describe('getRowReportingSummary', () => { + it('returns an empty string when there are no surfaces', () => { + expect(getRowReportingSummary(makeRow({ surfaces: [] }))).toBe(''); + }); + + it('lower-cases only the first surface label and wraps a single item', () => { + expect( + getRowReportingSummary( + makeRow({ surfaces: [surface('Host telemetry', 'agent')] }), + ), + ).toBe('Pulse is receiving host telemetry from this item.'); + }); + + it('joins two surfaces with "and" and leaves the non-first label cased', () => { + expect( + getRowReportingSummary( + makeRow({ + surfaces: [surface('Host telemetry', 'agent'), surface('Docker runtime data', 'docker')], + }), + ), + ).toBe('Pulse is receiving host telemetry and Docker runtime data from this item.'); + }); + + it('joins three surfaces with an Oxford comma', () => { + expect( + getRowReportingSummary( + makeRow({ + surfaces: [ + surface('Host telemetry', 'agent'), + surface('Docker runtime data', 'docker'), + surface('Kubernetes cluster data', 'kubernetes'), + ], + }), + ), + ).toBe( + 'Pulse is receiving host telemetry, Docker runtime data, and Kubernetes cluster data from this item.', + ); + }); + + it('keeps an empty first label empty rather than lower-casing nothing', () => { + // sentenceCaseSurfaceLabel guards label.length === 0; an empty first label + // therefore flows through unchanged, producing a double-space gap. + expect( + getRowReportingSummary( + makeRow({ surfaces: [surface('', 'agent')] }), + ), + ).toBe('Pulse is receiving from this item.'); + }); + + it('does not mutate labels that are not in the first position', () => { + expect( + getRowReportingSummary( + makeRow({ surfaces: [surface('Host telemetry', 'agent'), surface('PBS data', 'pbs')] }), + ), + ).toBe('Pulse is receiving host telemetry and PBS data from this item.'); + }); +}); + +describe('buildCommandsByPlatform', () => { + const sections = buildCommandsByPlatform('UNIX_CMD', 'WIN_INTERACTIVE', 'WIN_PARAM'); + + it('returns one section per AgentPlatform key', () => { + expect(Object.keys(sections).sort()).toEqual(['freebsd', 'linux', 'macos', 'windows']); + }); + + it('routes the unix command through Linux with a single snippet', () => { + expect(sections.linux.title).toBe('Install on Linux'); + expect(sections.linux.snippets).toHaveLength(1); + expect(sections.linux.snippets[0].label).toBe('Install'); + expect(sections.linux.snippets[0].command).toBe('UNIX_CMD'); + }); + + it('routes the same unix command through macOS with a distinct launchd framing', () => { + expect(sections.macos.title).toBe('Install on macOS'); + expect(sections.macos.snippets).toHaveLength(1); + expect(sections.macos.snippets[0].label).toBe('Install with launchd'); + expect(sections.macos.snippets[0].command).toBe('UNIX_CMD'); + expect(sections.macos.description).not.toBe(sections.linux.description); + }); + + it('routes the same unix command through FreeBSD with an rc.d framing', () => { + expect(sections.freebsd.title).toBe('Install on FreeBSD / pfSense / OPNsense'); + expect(sections.freebsd.snippets).toHaveLength(1); + expect(sections.freebsd.snippets[0].label).toBe('Install with rc.d'); + expect(sections.freebsd.snippets[0].command).toBe('UNIX_CMD'); + }); + + it('uses both Windows commands in two distinct snippets', () => { + expect(sections.windows.title).toBe('Install on Windows'); + expect(sections.windows.snippets).toHaveLength(2); + expect(sections.windows.snippets[0].label).toBe('Install as Windows Service (PowerShell)'); + expect(sections.windows.snippets[0].command).toBe('WIN_INTERACTIVE'); + expect(sections.windows.snippets[1].label).toBe('Install with parameters (PowerShell)'); + expect(sections.windows.snippets[1].command).toBe('WIN_PARAM'); + }); + + it('keeps every platform description distinct', () => { + const descriptions = [ + sections.linux.description, + sections.macos.description, + sections.freebsd.description, + sections.windows.description, + ]; + expect(new Set(descriptions).size).toBe(descriptions.length); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/alertGroupingPresentation.coverage.test.ts b/frontend-modern/src/utils/__tests__/alertGroupingPresentation.coverage.test.ts new file mode 100644 index 000000000..76a16fd32 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/alertGroupingPresentation.coverage.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + getAlertGroupingCardClass, + getAlertGroupingCheckboxClass, +} from '@/utils/alertGroupingPresentation'; + +describe('getAlertGroupingCardClass', () => { + it('emits the active presentation when selected', () => { + expect(getAlertGroupingCardClass(true)).toBe( + 'relative flex items-center gap-2 rounded-md border-2 p-3 transition-all border-blue-500 bg-blue-50 shadow-sm dark:bg-blue-900', + ); + }); + + it('emits the idle presentation when not selected', () => { + expect(getAlertGroupingCardClass(false)).toBe( + 'relative flex items-center gap-2 rounded-md border-2 p-3 transition-all border-border hover:bg-surface-hover', + ); + }); + + it('selects distinct styling for the selected vs unselected card', () => { + const selected = getAlertGroupingCardClass(true); + const unselected = getAlertGroupingCardClass(false); + + expect(selected).not.toBe(unselected); + // The selected card signals the active border/fill; the idle card defers to the theme border. + expect(selected).toContain('border-blue-500'); + expect(selected).toContain('bg-blue-50'); + expect(unselected).toContain('border-border'); + expect(unselected).not.toContain('border-blue-500'); + }); +}); + +describe('getAlertGroupingCheckboxClass', () => { + it('emits the checked presentation when selected', () => { + expect(getAlertGroupingCheckboxClass(true)).toBe( + 'flex h-4 w-4 items-center justify-center rounded border-2 border-blue-500 bg-blue-500', + ); + }); + + it('emits the unchecked presentation when not selected', () => { + expect(getAlertGroupingCheckboxClass(false)).toBe( + 'flex h-4 w-4 items-center justify-center rounded border-2 border-border', + ); + }); + + it('selects distinct styling for the selected vs unselected checkbox', () => { + const selected = getAlertGroupingCheckboxClass(true); + const unselected = getAlertGroupingCheckboxClass(false); + + expect(selected).not.toBe(unselected); + // The checked checkbox applies the blue fill; the unchecked one only shows the theme border. + expect(selected).toContain('bg-blue-500'); + expect(unselected).not.toContain('bg-blue-500'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/emptyStatePresentation.coverage.test.ts b/frontend-modern/src/utils/__tests__/emptyStatePresentation.coverage.test.ts new file mode 100644 index 000000000..acc29b655 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/emptyStatePresentation.coverage.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { + getEmptyStatePresentation, + type EmptyStateTone, +} from '@/utils/emptyStatePresentation'; + +// NOTE: The existing `emptyStatePresentation.test.ts` only covers the `danger` +// and `default` tones. This file adds coverage for the remaining tones, the +// cross-tone distinctness invariant, and the actual behavior for invalid input. +// +// IMPORTANT divergence from the task brief: the source has NO `|| DEFAULT` +// fallback branch. `getEmptyStatePresentation` performs a raw record lookup +// (`EMPTY_STATE_PRESENTATION[tone]`), so any tone absent from the record +// resolves to `undefined` rather than falling back to default classes. These +// tests assert that real current behavior. + +describe('getEmptyStatePresentation — full tone coverage', () => { + it('every supported tone yields a distinct value for each class field', () => { + const tones: EmptyStateTone[] = ['default', 'info', 'success', 'warning', 'danger']; + + const presentations = tones.map((tone) => getEmptyStatePresentation(tone)); + + const iconClasses = new Set(presentations.map((p) => p.iconClass)); + const titleClasses = new Set(presentations.map((p) => p.titleClass)); + const descriptionClasses = new Set(presentations.map((p) => p.descriptionClass)); + + // Distinctness across all five tones proves the tones are not aliases of + // one another; a size < 5 would indicate a duplicated (copy-paste) class. + expect(iconClasses.size).toBe(tones.length); + expect(titleClasses.size).toBe(tones.length); + expect(descriptionClasses.size).toBe(tones.length); + }); + + it('default and danger tones are differentiated (existing-test pair, asserted indirectly)', () => { + // The existing test asserts their exact literals independently; here we + // additionally prove they are not the same presentation object. + const def = getEmptyStatePresentation('default'); + const danger = getEmptyStatePresentation('danger'); + expect(def.iconClass).not.toBe(danger.iconClass); + expect(def.titleClass).not.toBe(danger.titleClass); + expect(def.descriptionClass).not.toBe(danger.descriptionClass); + }); + + describe('invalid or missing tone input', () => { + // Source does a raw record lookup with no fallback, so every unrecognized + // key resolves to `undefined` (no `|| DEFAULT` branch exists). + it.each([ + ['unknown string key', 'nonexistent'], + ['empty string', ''], + ['null', null], + ['undefined', undefined], + ])('returns undefined for %s', (_label, invalid) => { + const result = getEmptyStatePresentation( + invalid as unknown as EmptyStateTone, + ); + expect(result).toBeUndefined(); + }); + + it('does not fall back to default classes for an unknown tone', () => { + const fallback = getEmptyStatePresentation( + 'nonexistent' as unknown as EmptyStateTone, + ); + const def = getEmptyStatePresentation('default'); + // Explicitly documents the absence of a default-fallback branch. + expect(fallback).not.toEqual(def); + expect(fallback).toBeUndefined(); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/patrolRuntimePresentation.coverage.test.ts b/frontend-modern/src/utils/__tests__/patrolRuntimePresentation.coverage.test.ts new file mode 100644 index 000000000..94498a8e7 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/patrolRuntimePresentation.coverage.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; + +import type { PatrolRuntimeState } from '@/api/patrol'; + +import { + getPatrolRuntimePresentation, + normalizePatrolRuntimeBlockedReason, +} from '@/utils/patrolRuntimePresentation'; + +const RETIRED_HOSTED_PATROL_BLOCKED_REASON = + 'Connect your own AI provider or local model to use Pulse Patrol.'; + +describe('normalizePatrolRuntimeBlockedReason', () => { + it('returns empty string when no reason is provided', () => { + expect(normalizePatrolRuntimeBlockedReason(undefined)).toBe(''); + }); + + it('returns empty string for an empty reason', () => { + expect(normalizePatrolRuntimeBlockedReason('')).toBe(''); + }); + + it('returns empty string for a whitespace-only reason', () => { + expect(normalizePatrolRuntimeBlockedReason(' \t\n')).toBe(''); + }); + + it('rewrites a "quickstart" reason to the retired-hosted message', () => { + expect(normalizePatrolRuntimeBlockedReason('quickstart plan expired')).toBe( + RETIRED_HOSTED_PATROL_BLOCKED_REASON, + ); + }); + + it('rewrites a "hosted" reason to the retired-hosted message', () => { + expect(normalizePatrolRuntimeBlockedReason('hosted plan is gone')).toBe( + RETIRED_HOSTED_PATROL_BLOCKED_REASON, + ); + }); + + it('matches "hosted" case-insensitively', () => { + expect(normalizePatrolRuntimeBlockedReason('HOSTED tier retired')).toBe( + RETIRED_HOSTED_PATROL_BLOCKED_REASON, + ); + }); + + it('passes through an unrelated reason unchanged but trimmed', () => { + expect(normalizePatrolRuntimeBlockedReason(' provider offline ')).toBe( + 'provider offline', + ); + }); + + it('does not rewrite when the trigger word is a substring without word boundaries', () => { + expect(normalizePatrolRuntimeBlockedReason('thishostedfoo')).toBe('thishostedfoo'); + }); +}); + +describe('getPatrolRuntimePresentation', () => { + it('maps "blocked" to a warning paused shell with a fallback description when no reason is given', () => { + expect(getPatrolRuntimePresentation('blocked')).toMatchObject({ + label: 'Patrol paused', + title: 'Patrol paused', + description: + 'Patrol cannot check infrastructure until the blocking condition is cleared.', + tone: 'warning', + }); + }); + + it('uses the normalized blocked reason as the "blocked" description when provided', () => { + expect(getPatrolRuntimePresentation('blocked', ' provider offline ')).toMatchObject({ + label: 'Patrol paused', + description: 'provider offline', + tone: 'warning', + }); + }); + + it('rewrites a hosted blocked reason in the "blocked" description', () => { + expect(getPatrolRuntimePresentation('blocked', 'hosted plan retired')).toMatchObject({ + description: RETIRED_HOSTED_PATROL_BLOCKED_REASON, + tone: 'warning', + }); + }); + + it('ignores an empty blocked reason and falls back to the default blocked description', () => { + expect(getPatrolRuntimePresentation('blocked', ' ').description).toBe( + 'Patrol cannot check infrastructure until the blocking condition is cleared.', + ); + }); + + it('maps "disabled" to an info disabled shell', () => { + expect(getPatrolRuntimePresentation('disabled')).toMatchObject({ + label: 'Patrol disabled', + title: 'Patrol disabled', + description: 'Enable Patrol to resume checks.', + tone: 'info', + }); + }); + + it('maps "running" to an info enabled shell with a run-in-progress title', () => { + expect(getPatrolRuntimePresentation('running')).toMatchObject({ + label: 'Patrol enabled', + title: 'Patrol running', + description: 'Patrol is checking your infrastructure now.', + tone: 'info', + }); + }); + + it('maps "unavailable" to an error unavailable shell', () => { + expect(getPatrolRuntimePresentation('unavailable')).toMatchObject({ + label: 'Patrol unavailable', + title: 'Patrol unavailable', + description: + 'Patrol is not ready yet. Check Provider & Models and runtime availability.', + tone: 'error', + }); + }); + + it('maps "active" to an info enabled shell with a ready-to-check description', () => { + expect(getPatrolRuntimePresentation('active')).toMatchObject({ + label: 'Patrol enabled', + title: 'Patrol enabled', + description: 'Patrol is ready to check your infrastructure.', + tone: 'info', + }); + }); + + it('falls back to the "active" presentation for an undefined state', () => { + expect(getPatrolRuntimePresentation(undefined)).toMatchObject({ + label: 'Patrol enabled', + title: 'Patrol enabled', + description: 'Patrol is ready to check your infrastructure.', + tone: 'info', + }); + }); + + it('falls back to the "active" presentation for an unknown state value', () => { + expect( + getPatrolRuntimePresentation('idle' as unknown as PatrolRuntimeState), + ).toMatchObject({ + label: 'Patrol enabled', + title: 'Patrol enabled', + description: 'Patrol is ready to check your infrastructure.', + tone: 'info', + }); + }); + + it('assigns a distinct tone to the warning (blocked) and error (unavailable) states', () => { + expect(getPatrolRuntimePresentation('blocked').tone).toBe('warning'); + expect(getPatrolRuntimePresentation('unavailable').tone).toBe('error'); + }); + + it('distinguishes "running" from "active" by title even though both are info/enabled', () => { + const running = getPatrolRuntimePresentation('running'); + const active = getPatrolRuntimePresentation('active'); + expect(running.tone).toBe('info'); + expect(active.tone).toBe('info'); + expect(running.label).toBe(active.label); + expect(running.title).not.toBe(active.title); + expect(running.description).not.toBe(active.description); + }); + + it('gives "disabled" a label that differs from the info-enabled group', () => { + expect(getPatrolRuntimePresentation('disabled').label).not.toBe( + getPatrolRuntimePresentation('active').label, + ); + }); +}); diff --git a/internal/actionlifecycle/service.go b/internal/actionlifecycle/service.go new file mode 100644 index 000000000..61916a3cc --- /dev/null +++ b/internal/actionlifecycle/service.go @@ -0,0 +1,521 @@ +// Package actionlifecycle is the transport-independent action lifecycle +// service: planning, approval decisions, and execution for typed resource +// actions. The REST handlers in internal/api and any in-process broker +// (e.g. Patrol investigation proposals) must route through this one +// service so every caller gets identical resource lookup, availability +// checks, plan hashing, audit persistence, remediation locks, plan-drift +// detection, execution, and terminal verification. No caller may dispatch +// a resource mutation around it. +package actionlifecycle + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/actionplanner" + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// Executor runs a previously planned and approved action through the +// canonical execution contract. +type Executor interface { + ExecuteAction(ctx context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error) +} + +// AvailabilityChecker lets an executor contribute live readiness checks +// before Pulse advertises or persists an executable action plan. +type AvailabilityChecker interface { + CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness +} + +// Store is the narrow persistence surface the lifecycle needs. It is a +// structural subset of unified.ResourceStore so the canonical store +// satisfies it without adaptation. +type Store interface { + RecordActionAudit(record unified.ActionAuditRecord) error + GetActionAudit(actionID string) (unified.ActionAuditRecord, bool, error) + RecordActionDecision(record unified.ActionAuditRecord, event unified.ActionLifecycleEvent) error + RecordActionExecutionStart(record unified.ActionAuditRecord, event unified.ActionLifecycleEvent) error + RecordActionExecutionResult(record unified.ActionAuditRecord, event unified.ActionLifecycleEvent) error + RecordActionLifecycleEvent(event unified.ActionLifecycleEvent) error + GetActionLifecycleEvents(actionID string, since time.Time, limit int) ([]unified.ActionLifecycleEvent, error) + GetResourceOperatorState(canonicalID string) (unified.ResourceOperatorState, bool, error) +} + +// Service wires the lifecycle over per-org registry and store lookups. All +// dependencies are resolved per call so late-bound wiring (executors and +// publishers set after construction) stays current. +type Service struct { + Registry func(orgID string) (*unified.ResourceRegistry, error) + Store func(orgID string) (Store, error) + Executor Executor + // OnActionCompleted receives every terminal (completed/failed) audit + // record, including refused-before-dispatch failures, so SSE bridges + // and reconcilers observe the full lifecycle regardless of transport. + OnActionCompleted func(unified.ActionAuditRecord) + Now func() time.Time +} + +// Sentinel errors for dependency failures. Callers map these to their +// transport's unavailability semantics. +var ( + ErrRegistryUnavailable = errors.New("resource registry unavailable") + ErrStoreUnavailable = errors.New("action audit store unavailable") + ErrExecutorUnavailable = errors.New("no action executor is configured") +) + +// ResourceNotFoundError reports that the requested resource is not present +// in the org's registry. +type ResourceNotFoundError struct{ ResourceID string } + +func (e *ResourceNotFoundError) Error() string { + return fmt.Sprintf("resource %q not found", e.ResourceID) +} + +// ActionNotFoundError reports that no audit record exists for the action ID. +type ActionNotFoundError struct{ ActionID string } + +func (e *ActionNotFoundError) Error() string { + return fmt.Sprintf("action %q not found", e.ActionID) +} + +// CapabilityNotFoundError reports that the resource does not advertise the +// requested capability. It unwraps to actionplanner.ErrCapabilityNotFound. +type CapabilityNotFoundError struct { + ResourceID string + CapabilityName string +} + +func (e *CapabilityNotFoundError) Error() string { + return fmt.Sprintf("capability %q not found on resource %q", e.CapabilityName, e.ResourceID) +} + +func (e *CapabilityNotFoundError) Unwrap() error { return actionplanner.ErrCapabilityNotFound } + +// AvailabilityRefusedError reports that the executor's live readiness check +// refused the action before a plan was persisted. +type AvailabilityRefusedError struct { + ResourceID string + CapabilityName string + Readiness unified.ResourceActionReadiness +} + +func (e *AvailabilityRefusedError) Error() string { + reason := strings.TrimSpace(e.Readiness.Reason) + if reason == "" { + reason = "action execution is unavailable" + } + return fmt.Sprintf("action %s on %s unavailable: %s", e.CapabilityName, e.ResourceID, reason) +} + +// PersistError wraps a storage write failure at a named lifecycle stage. +type PersistError struct { + Op string + Err error +} + +func (e *PersistError) Error() string { return fmt.Sprintf("persist %s: %v", e.Op, e.Err) } +func (e *PersistError) Unwrap() error { return e.Err } + +// QueryError wraps a storage read failure. +type QueryError struct { + Op string + Err error +} + +func (e *QueryError) Error() string { return fmt.Sprintf("query %s: %v", e.Op, e.Err) } +func (e *QueryError) Unwrap() error { return e.Err } + +// FreshnessCheckError wraps an infrastructure failure while revalidating +// plan freshness. Plan drift itself is reported as unified.ErrActionPlanDrift. +type FreshnessCheckError struct{ Err error } + +func (e *FreshnessCheckError) Error() string { return fmt.Sprintf("plan freshness check: %v", e.Err) } +func (e *FreshnessCheckError) Unwrap() error { return e.Err } + +// PolicyCheckError wraps an infrastructure failure while evaluating +// execution policy. A remediation lock itself is reported as +// unified.ErrResourceRemediationLocked. +type PolicyCheckError struct{ Err error } + +func (e *PolicyCheckError) Error() string { return fmt.Sprintf("execution policy check: %v", e.Err) } +func (e *PolicyCheckError) Unwrap() error { return e.Err } + +func (s *Service) now() time.Time { + if s != nil && s.Now != nil { + return s.Now().UTC() + } + return time.Now().UTC() +} + +func (s *Service) registry(orgID string) (*unified.ResourceRegistry, error) { + if s == nil || s.Registry == nil { + return nil, ErrRegistryUnavailable + } + registry, err := s.Registry(orgID) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrRegistryUnavailable, err) + } + if registry == nil { + return nil, ErrRegistryUnavailable + } + return registry, nil +} + +func (s *Service) store(orgID string) (Store, error) { + if s == nil || s.Store == nil { + return nil, ErrStoreUnavailable + } + store, err := s.Store(orgID) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrStoreUnavailable, err) + } + if store == nil { + return nil, ErrStoreUnavailable + } + return store, nil +} + +// NormalizeRequest trims and canonicalizes an action request before audit +// persistence so persisted requests hash and replan deterministically. +func NormalizeRequest(req unified.ActionRequest) unified.ActionRequest { + req.RequestID = strings.TrimSpace(req.RequestID) + req.ResourceID = unified.CanonicalResourceID(req.ResourceID) + req.CapabilityName = strings.TrimSpace(req.CapabilityName) + req.Reason = strings.TrimSpace(req.Reason) + req.RequestedBy = strings.TrimSpace(req.RequestedBy) + if req.Params == nil { + req.Params = map[string]any{} + } + return req +} + +// Plan validates the request against the org's live resource registry, +// produces a typed plan through the canonical planner, runs the executor's +// availability check, and persists the plan-stage audit trail. Approval +// requirements come from the capability's declared policy, never from the +// caller. +func (s *Service) Plan(ctx context.Context, orgID string, req unified.ActionRequest) (unified.ActionPlan, error) { + req.ResourceID = unified.CanonicalResourceID(req.ResourceID) + if req.ResourceID == "" { + return unified.ActionPlan{}, &actionplanner.ValidationError{Field: "resourceId", Message: "resource id is required"} + } + + registry, err := s.registry(orgID) + if err != nil { + return unified.ActionPlan{}, err + } + resource, ok := registry.Get(req.ResourceID) + if !ok || resource == nil { + return unified.ActionPlan{}, &ResourceNotFoundError{ResourceID: req.ResourceID} + } + + plan, err := (actionplanner.Planner{}).Plan(req, *resource) + if err != nil { + if errors.Is(err, actionplanner.ErrCapabilityNotFound) { + return unified.ActionPlan{}, &CapabilityNotFoundError{ + ResourceID: req.ResourceID, + CapabilityName: strings.TrimSpace(req.CapabilityName), + } + } + return unified.ActionPlan{}, err + } + + req = NormalizeRequest(req) + if checker, ok := s.Executor.(AvailabilityChecker); ok { + if readiness := checker.CheckActionAvailable(ctx, req, *resource); readiness.Name != "" && !readiness.Available { + return unified.ActionPlan{}, &AvailabilityRefusedError{ + ResourceID: req.ResourceID, + CapabilityName: req.CapabilityName, + Readiness: readiness, + } + } + } + + store, err := s.store(orgID) + if err != nil { + return unified.ActionPlan{}, err + } + if err := PersistPlanAudit(store, req, plan); err != nil { + return unified.ActionPlan{}, &PersistError{Op: "action plan audit", Err: err} + } + return plan, nil +} + +// PersistPlanAudit records the planned action's audit record and its +// initial lifecycle events, deduplicating states that were already +// recorded for the same action ID (idempotent replans). +func PersistPlanAudit(store Store, req unified.ActionRequest, plan unified.ActionPlan) error { + state := PlannedActionState(plan) + record := unified.ActionAuditRecord{ + ID: plan.ActionID, + CreatedAt: plan.PlannedAt, + UpdatedAt: plan.PlannedAt, + State: state, + Request: req, + Plan: plan, + } + if err := store.RecordActionAudit(record); err != nil { + return err + } + + existingEvents, err := store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 100) + if err != nil { + return err + } + seenStates := map[unified.ActionState]bool{} + for _, event := range existingEvents { + seenStates[event.State] = true + } + + if !seenStates[unified.ActionStatePlanned] { + if err := store.RecordActionLifecycleEvent(unified.ActionLifecycleEvent{ + ActionID: plan.ActionID, + Timestamp: plan.PlannedAt, + State: unified.ActionStatePlanned, + Actor: req.RequestedBy, + Message: "Action plan created.", + }); err != nil { + return err + } + } + if state != unified.ActionStatePlanned && !seenStates[state] { + if err := store.RecordActionLifecycleEvent(unified.ActionLifecycleEvent{ + ActionID: plan.ActionID, + Timestamp: plan.PlannedAt, + State: state, + Actor: req.RequestedBy, + Message: "Action is waiting for approval before execution.", + }); err != nil { + return err + } + } + return nil +} + +// PlannedActionState is the initial audit state for a fresh plan: pending +// when the capability policy requires approval, planned otherwise. +func PlannedActionState(plan unified.ActionPlan) unified.ActionState { + if plan.RequiresApproval { + return unified.ActionStatePending + } + return unified.ActionStatePlanned +} + +// Decide applies an approval outcome to a pending action. The caller +// supplies the approval's actor, method, outcome, and reason; the service +// stamps the decision time when unset and persists the resulting state +// transition and lifecycle event atomically through the store contract. +func (s *Service) Decide(ctx context.Context, orgID, actionID string, approval unified.ActionApprovalRecord) (unified.ActionAuditRecord, error) { + _ = ctx + actionID = strings.TrimSpace(actionID) + if actionID == "" { + return unified.ActionAuditRecord{}, &ActionNotFoundError{ActionID: actionID} + } + store, err := s.store(orgID) + if err != nil { + return unified.ActionAuditRecord{}, err + } + record, ok, err := store.GetActionAudit(actionID) + if err != nil { + return unified.ActionAuditRecord{}, &QueryError{Op: "action audit", Err: err} + } + if !ok { + return unified.ActionAuditRecord{}, &ActionNotFoundError{ActionID: actionID} + } + + now := s.now() + if approval.Timestamp.IsZero() { + approval.Timestamp = now + } + updated, event, err := unified.ApplyActionDecision(record, approval, now) + if err != nil { + return unified.ActionAuditRecord{}, err + } + if err := store.RecordActionDecision(updated, event); err != nil { + if errors.Is(err, unified.ErrActionNotPending) { + return unified.ActionAuditRecord{}, err + } + return unified.ActionAuditRecord{}, &PersistError{Op: "action decision", Err: err} + } + return updated, nil +} + +// Execute runs an approved action to a terminal audit state. Every refusal +// path fails closed: expired plans, unapproved or already-final actions, +// plan drift against the live resource contract, and operator remediation +// locks are all persisted as refused executions (never silently dropped) +// and published to the completion hook. There is no bypass that reaches +// the executor without passing every gate. +func (s *Service) Execute(ctx context.Context, orgID, actionID, actor, reason string) (unified.ActionAuditRecord, error) { + actionID = strings.TrimSpace(actionID) + if actionID == "" { + return unified.ActionAuditRecord{}, &ActionNotFoundError{ActionID: actionID} + } + store, err := s.store(orgID) + if err != nil { + return unified.ActionAuditRecord{}, err + } + record, ok, err := store.GetActionAudit(actionID) + if err != nil { + return unified.ActionAuditRecord{}, &QueryError{Op: "action audit", Err: err} + } + if !ok { + return unified.ActionAuditRecord{}, &ActionNotFoundError{ActionID: actionID} + } + + now := s.now() + if err := unified.ValidateActionExecutionStart(record, now); err != nil { + if unified.IsPermanentActionExecutionRefusal(err) { + failed, persistErr := RecordRefusedExecution(store, record, actor, now, err) + if persistErr != nil { + return unified.ActionAuditRecord{}, &PersistError{Op: "refused action execution", Err: persistErr} + } + s.publishCompleted(failed) + return failed, err + } + return unified.ActionAuditRecord{}, err + } + if s.Executor == nil { + return unified.ActionAuditRecord{}, ErrExecutorUnavailable + } + if err := s.ValidatePlanFresh(orgID, record); err != nil { + if errors.Is(err, unified.ErrActionPlanDrift) { + failed, persistErr := RecordRefusedExecution(store, record, actor, now, err) + if persistErr != nil { + return unified.ActionAuditRecord{}, &PersistError{Op: "refused action execution", Err: persistErr} + } + s.publishCompleted(failed) + return failed, err + } + return unified.ActionAuditRecord{}, &FreshnessCheckError{Err: err} + } + if err := validateExecutionPolicy(store, record); err != nil { + if unified.IsPermanentActionExecutionRefusal(err) { + failed, persistErr := RecordRefusedExecution(store, record, actor, now, err) + if persistErr != nil { + return unified.ActionAuditRecord{}, &PersistError{Op: "refused action execution", Err: persistErr} + } + s.publishCompleted(failed) + return failed, err + } + return unified.ActionAuditRecord{}, &PolicyCheckError{Err: err} + } + + started, startEvent, err := unified.BeginActionExecution(record, actor, now) + if err != nil { + return unified.ActionAuditRecord{}, err + } + if reason != "" { + startEvent.Message = "Action execution started: " + reason + } + if err := store.RecordActionExecutionStart(started, startEvent); err != nil { + return unified.ActionAuditRecord{}, &PersistError{Op: "action execution start", Err: err} + } + + result, execErr := s.Executor.ExecuteAction(ctx, started) + if execErr != nil { + result = &unified.ExecutionResult{Success: false, ErrorMessage: execErr.Error()} + } + completed, doneEvent, err := unified.CompleteActionExecution(started, result, actor, s.now()) + if err != nil { + return unified.ActionAuditRecord{}, err + } + if err := store.RecordActionExecutionResult(completed, doneEvent); err != nil { + return unified.ActionAuditRecord{}, &PersistError{Op: "action execution result", Err: err} + } + s.publishCompleted(completed) + return completed, nil +} + +// ValidatePlanFresh replans the persisted request against the live +// resource contract and refuses execution when the action identity, plan +// hash, resource version, or capability policy no longer match. Execute +// runs it before every dispatch; brokers may also call it as a standalone +// preflight before requesting a decision. +func (s *Service) ValidatePlanFresh(orgID string, record unified.ActionAuditRecord) error { + normalized, err := unified.NormalizeActionAuditRecord(record) + if err != nil { + return fmt.Errorf("%w: %v", unified.ErrActionPlanDrift, err) + } + registry, err := s.registry(orgID) + if err != nil { + return err + } + resource, ok := registry.Get(normalized.Request.ResourceID) + if !ok || resource == nil { + return fmt.Errorf("%w: resource %q is no longer present", unified.ErrActionPlanDrift, normalized.Request.ResourceID) + } + currentPlan, err := (actionplanner.Planner{Now: func() time.Time { + return normalized.Plan.PlannedAt + }}).Plan(normalized.Request, *resource) + if err != nil { + return fmt.Errorf("%w: %v", unified.ErrActionPlanDrift, err) + } + if currentPlan.ActionID != normalized.Plan.ActionID { + return fmt.Errorf("%w: action identity changed", unified.ErrActionPlanDrift) + } + if currentPlan.PlanHash != normalized.Plan.PlanHash { + return fmt.Errorf("%w: plan hash changed", unified.ErrActionPlanDrift) + } + if currentPlan.ResourceVersion != normalized.Plan.ResourceVersion { + return fmt.Errorf("%w: resource version changed", unified.ErrActionPlanDrift) + } + if currentPlan.PolicyVersion != normalized.Plan.PolicyVersion { + return fmt.Errorf("%w: capability policy changed", unified.ErrActionPlanDrift) + } + return nil +} + +// validateExecutionPolicy enforces operator-set per-resource policy at the +// dispatch decision point, currently the NeverAutoRemediate lock. +func validateExecutionPolicy(store Store, record unified.ActionAuditRecord) error { + if store == nil { + return errors.New("action audit store unavailable") + } + normalized, err := unified.NormalizeActionAuditRecord(record) + if err != nil { + return err + } + state, found, err := store.GetResourceOperatorState(normalized.Request.ResourceID) + if err != nil || !found { + return err + } + if state.NeverAutoRemediate { + return unified.ErrResourceRemediationLocked + } + return nil +} + +// RecordRefusedExecution persists a refused-before-dispatch execution as a +// terminal failed audit record with its lifecycle event so refusals are +// never silently dropped from the action history. +func RecordRefusedExecution(store Store, record unified.ActionAuditRecord, actor string, now time.Time, reason error) (unified.ActionAuditRecord, error) { + failed, event, err := unified.RefuseActionExecution(record, reason, actor, now) + if err != nil { + return unified.ActionAuditRecord{}, err + } + if store == nil { + return unified.ActionAuditRecord{}, errors.New("action audit store unavailable") + } + if err := store.RecordActionAudit(failed); err != nil { + return unified.ActionAuditRecord{}, err + } + if err := store.RecordActionLifecycleEvent(event); err != nil { + return unified.ActionAuditRecord{}, err + } + return failed, nil +} + +func (s *Service) publishCompleted(record unified.ActionAuditRecord) { + if s == nil || s.OnActionCompleted == nil { + return + } + if record.State != unified.ActionStateCompleted && record.State != unified.ActionStateFailed { + return + } + s.OnActionCompleted(record) +} diff --git a/internal/actionlifecycle/service_test.go b/internal/actionlifecycle/service_test.go new file mode 100644 index 000000000..66d6eae4a --- /dev/null +++ b/internal/actionlifecycle/service_test.go @@ -0,0 +1,382 @@ +package actionlifecycle + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/actionplanner" + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +type stubExecutor struct { + result *unified.ExecutionResult + err error + calls int + received unified.ActionAuditRecord + readiness *unified.ResourceActionReadiness +} + +func (s *stubExecutor) ExecuteAction(_ context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error) { + s.calls++ + s.received = record + return s.result, s.err +} + +func (s *stubExecutor) CheckActionAvailable(_ context.Context, _ unified.ActionRequest, _ unified.Resource) unified.ResourceActionReadiness { + if s.readiness == nil { + return unified.ResourceActionReadiness{} + } + return *s.readiness +} + +type serviceEnv struct { + store unified.ResourceStore + registry *unified.ResourceRegistry + executor *stubExecutor + completed []unified.ActionAuditRecord + service *Service +} + +func testResource(now time.Time, minimumApproval unified.ActionApprovalLevel) unified.Resource { + return 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: minimumApproval, + InternalHandler: "proxmox.vm.restart", + Params: []unified.CapabilityParam{ + {Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}}, + }, + }, + }, + } +} + +func newServiceEnv(t *testing.T, resource unified.Resource) *serviceEnv { + t.Helper() + env := &serviceEnv{ + store: unified.NewMemoryStore(), + executor: &stubExecutor{result: &unified.ExecutionResult{Success: true, Output: "restarted"}}, + } + env.registry = unified.NewRegistry(env.store) + env.registry.IngestResources([]unified.Resource{resource}) + env.service = &Service{ + Registry: func(orgID string) (*unified.ResourceRegistry, error) { + if orgID != "default" { + return nil, errors.New("unknown org") + } + return env.registry, nil + }, + Store: func(orgID string) (Store, error) { + if orgID != "default" { + return nil, errors.New("unknown org") + } + return env.store, nil + }, + Executor: env.executor, + OnActionCompleted: func(record unified.ActionAuditRecord) { + env.completed = append(env.completed, record) + }, + } + return env +} + +func restartRequest() unified.ActionRequest { + return unified.ActionRequest{ + RequestID: "req-1", + ResourceID: "vm:42", + CapabilityName: "restart", + Params: map[string]any{"mode": "graceful"}, + Reason: "Recover after confirmed outage", + RequestedBy: "agent:test", + } +} + +func TestPlanPersistsPendingAuditAndLifecycle(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) + + plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if !plan.RequiresApproval { + t.Fatal("expected admin-gated capability to require approval") + } + + record, ok, err := env.store.GetActionAudit(plan.ActionID) + if err != nil || !ok { + t.Fatalf("GetActionAudit: ok=%v err=%v", ok, err) + } + if record.State != unified.ActionStatePending { + t.Fatalf("audit state = %q, want pending_approval", record.State) + } + events, err := env.store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 10) + if err != nil { + t.Fatalf("GetActionLifecycleEvents: %v", err) + } + if len(events) != 2 { + t.Fatalf("lifecycle events = %d, want planned + pending", len(events)) + } + + // Idempotent replan must not duplicate lifecycle events. + if _, err := env.service.Plan(context.Background(), "default", restartRequest()); err != nil { + t.Fatalf("replan: %v", err) + } + events, err = env.store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 10) + if err != nil { + t.Fatalf("GetActionLifecycleEvents after replan: %v", err) + } + if len(events) != 2 { + t.Fatalf("replan duplicated lifecycle events: %d", len(events)) + } +} + +func TestPlanFailsClosedOnUnknownResourceAndCapability(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) + + missing := restartRequest() + missing.ResourceID = "vm:404" + var notFound *ResourceNotFoundError + if _, err := env.service.Plan(context.Background(), "default", missing); !errors.As(err, ¬Found) { + t.Fatalf("unknown resource error = %v, want ResourceNotFoundError", err) + } + + unknownCap := restartRequest() + unknownCap.CapabilityName = "detonate" + _, err := env.service.Plan(context.Background(), "default", unknownCap) + var capErr *CapabilityNotFoundError + if !errors.As(err, &capErr) || !errors.Is(err, actionplanner.ErrCapabilityNotFound) { + t.Fatalf("unknown capability error = %v, want CapabilityNotFoundError wrapping ErrCapabilityNotFound", err) + } + if capErr.ResourceID != "vm:42" || capErr.CapabilityName != "detonate" { + t.Fatalf("capability error detail = %#v", capErr) + } + + empty := restartRequest() + empty.ResourceID = " " + var validation *actionplanner.ValidationError + if _, err := env.service.Plan(context.Background(), "default", empty); !errors.As(err, &validation) { + t.Fatalf("empty resource id error = %v, want ValidationError", err) + } + + audits, err := env.store.GetActionAudits("vm:42", time.Time{}, 10) + if err != nil { + t.Fatalf("GetActionAudits: %v", err) + } + if len(audits) != 0 { + t.Fatalf("failed plans must not persist audits, got %d", len(audits)) + } +} + +func TestPlanAvailabilityRefusalPersistsNothing(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) + env.executor.readiness = &unified.ResourceActionReadiness{ + Name: "restart", + Available: false, + ReasonCode: "agent_disconnected", + Reason: "no connected command agent", + } + + _, err := env.service.Plan(context.Background(), "default", restartRequest()) + var refused *AvailabilityRefusedError + if !errors.As(err, &refused) { + t.Fatalf("error = %v, want AvailabilityRefusedError", err) + } + if refused.Readiness.ReasonCode != "agent_disconnected" { + t.Fatalf("readiness = %#v", refused.Readiness) + } + audits, err := env.store.GetActionAudits("vm:42", time.Time{}, 10) + if err != nil { + t.Fatalf("GetActionAudits: %v", err) + } + if len(audits) != 0 { + t.Fatalf("refused availability must not persist audits, got %d", len(audits)) + } +} + +func TestDecideApprovesPendingAction(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) + + plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + if err != nil { + t.Fatalf("Plan: %v", err) + } + + updated, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{ + Actor: "operator@example.com", + Method: unified.MethodAPI, + Outcome: unified.OutcomeApproved, + Reason: "confirmed outage", + }) + if err != nil { + t.Fatalf("Decide: %v", err) + } + if updated.State != unified.ActionStateApproved { + t.Fatalf("state = %q, want approved", updated.State) + } + if len(updated.Approvals) != 1 || updated.Approvals[0].Actor != "operator@example.com" { + t.Fatalf("approvals = %#v", updated.Approvals) + } + + var notFound *ActionNotFoundError + if _, err := env.service.Decide(context.Background(), "default", "act_missing", unified.ActionApprovalRecord{ + Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved, + }); !errors.As(err, ¬Found) { + t.Fatalf("unknown action error = %v, want ActionNotFoundError", err) + } +} + +func TestExecuteRunsApprovedActionToTerminalAudit(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) + + plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{ + Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved, + }); err != nil { + t.Fatalf("Decide: %v", err) + } + + completed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "operator@example.com", "approved restart") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if completed.State != unified.ActionStateCompleted { + t.Fatalf("state = %q, want completed", completed.State) + } + if env.executor.calls != 1 { + t.Fatalf("executor calls = %d, want 1", env.executor.calls) + } + if len(env.completed) != 1 || env.completed[0].ID != plan.ActionID { + t.Fatalf("completion publisher observed %#v", env.completed) + } + + record, ok, err := env.store.GetActionAudit(plan.ActionID) + if err != nil || !ok { + t.Fatalf("GetActionAudit: ok=%v err=%v", ok, err) + } + if record.State != unified.ActionStateCompleted || record.Result == nil || !record.Result.Success { + t.Fatalf("terminal audit = state %q result %#v", record.State, record.Result) + } +} + +func TestExecuteRefusesUnapprovedActionWithoutDispatch(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) + + plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "operator@example.com", ""); !errors.Is(err, unified.ErrActionNotApproved) { + t.Fatalf("error = %v, want ErrActionNotApproved", err) + } + if env.executor.calls != 0 { + t.Fatalf("executor must not run for unapproved actions, calls = %d", env.executor.calls) + } +} + +func TestExecuteRefusesRemediationLockedResource(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalNone)) + + plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if err := env.store.SetResourceOperatorState(unified.ResourceOperatorState{ + CanonicalID: "vm:42", + NeverAutoRemediate: true, + }); err != nil { + t.Fatalf("SetResourceOperatorState: %v", err) + } + + failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", "") + if !errors.Is(err, unified.ErrResourceRemediationLocked) { + t.Fatalf("error = %v, want ErrResourceRemediationLocked", err) + } + if env.executor.calls != 0 { + t.Fatalf("executor must not run on locked resources, calls = %d", env.executor.calls) + } + if failed.State != unified.ActionStateFailed { + t.Fatalf("refusal must persist a terminal failed audit, state = %q", failed.State) + } + if len(env.completed) != 1 || env.completed[0].State != unified.ActionStateFailed { + t.Fatalf("refusal must publish the failed record, got %#v", env.completed) + } +} + +func TestExecuteRefusesDriftedPlan(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalNone)) + + plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + if err != nil { + t.Fatalf("Plan: %v", err) + } + + // The capability policy tightens between plan and dispatch: the + // replanned contract no longer matches the recorded plan hash. + drifted := testResource(now, unified.ApprovalAdmin) + env.registry = unified.NewRegistry(env.store) + env.registry.IngestResources([]unified.Resource{drifted}) + + failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", "") + if !errors.Is(err, unified.ErrActionPlanDrift) { + t.Fatalf("error = %v, want ErrActionPlanDrift", err) + } + if env.executor.calls != 0 { + t.Fatalf("executor must not run on drifted plans, calls = %d", env.executor.calls) + } + if failed.State != unified.ActionStateFailed { + t.Fatalf("drift refusal must persist a terminal failed audit, state = %q", failed.State) + } +} + +func TestExecuteFailsClosedWithoutExecutor(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalNone)) + + plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + if err != nil { + t.Fatalf("Plan: %v", err) + } + env.service.Executor = nil + if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", ""); !errors.Is(err, ErrExecutorUnavailable) { + t.Fatalf("error = %v, want ErrExecutorUnavailable", err) + } +} + +func TestLifecycleFailsClosedWithoutStore(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalNone)) + env.service.Store = func(string) (Store, error) { return nil, errors.New("db offline") } + + if _, err := env.service.Plan(context.Background(), "default", restartRequest()); !errors.Is(err, ErrStoreUnavailable) { + t.Fatalf("Plan error = %v, want ErrStoreUnavailable", err) + } + if _, err := env.service.Decide(context.Background(), "default", "act_x", unified.ActionApprovalRecord{Outcome: unified.OutcomeApproved}); !errors.Is(err, ErrStoreUnavailable) { + t.Fatalf("Decide error = %v, want ErrStoreUnavailable", err) + } + if _, err := env.service.Execute(context.Background(), "default", "act_x", "agent:test", ""); !errors.Is(err, ErrStoreUnavailable) { + t.Fatalf("Execute error = %v, want ErrStoreUnavailable", err) + } +} diff --git a/internal/api/actions.go b/internal/api/actions.go index 1fd4ed94e..2176eeff2 100644 --- a/internal/api/actions.go +++ b/internal/api/actions.go @@ -1,15 +1,13 @@ package api import ( - "context" "encoding/json" "errors" - "fmt" "io" "net/http" "strings" - "time" + "github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle" "github.com/rcourtman/pulse-go-rewrite/internal/actionplanner" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" @@ -19,17 +17,14 @@ const maxActionPlanRequestBytes = 1 << 20 const maxActionDecisionRequestBytes = 64 << 10 const maxActionExecutionRequestBytes = 64 << 10 -// ActionExecutor runs a previously planned and approved action through the -// API-owned execution contract. -type ActionExecutor interface { - ExecuteAction(ctx context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error) -} +// ActionExecutor is the API-facing name for the canonical action lifecycle +// execution contract. The interface is owned by internal/actionlifecycle; +// the alias keeps existing executor implementations and wiring source-stable. +type ActionExecutor = actionlifecycle.Executor -// ActionAvailabilityChecker lets an executor contribute live readiness checks -// before Pulse advertises or persists an executable action plan. -type ActionAvailabilityChecker interface { - CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness -} +// ActionAvailabilityChecker is the API-facing name for the canonical +// pre-plan readiness contract owned by internal/actionlifecycle. +type ActionAvailabilityChecker = actionlifecycle.AvailabilityChecker type actionDecisionRequest struct { Outcome unified.ApprovalOutcome `json:"outcome"` @@ -54,6 +49,22 @@ type actionExecutionResponse struct { Audit unified.ActionAuditRecord `json:"audit"` } +// ActionLifecycle returns the shared transport-independent action lifecycle +// service bound to this handler set's registry, store, executor, and +// completion publisher. The REST handlers below and any in-process broker +// (e.g. Patrol) must route through this one service; there is no other +// sanctioned path from a typed action request to execution. +func (h *ResourceHandlers) ActionLifecycle() *actionlifecycle.Service { + return &actionlifecycle.Service{ + Registry: h.buildRegistry, + Store: func(orgID string) (actionlifecycle.Store, error) { + return h.getStore(orgID) + }, + Executor: h.actionExecutor, + OnActionCompleted: h.actionCompleted, + } +} + func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -76,69 +87,9 @@ func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Reque return } - req.ResourceID = unified.CanonicalResourceID(req.ResourceID) - if req.ResourceID == "" { - writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", map[string]string{ - "resourceId": "resource id is required", - }) - return - } - - orgID := GetOrgID(r.Context()) - registry, err := h.buildRegistry(orgID) + plan, err := h.ActionLifecycle().Plan(r.Context(), GetOrgID(r.Context()), req) if err != nil { - writeJSONError(w, http.StatusInternalServerError, "resource_registry_unavailable", sanitizeErrorForClient(err, "Resource registry unavailable")) - return - } - - resource, ok := registry.Get(req.ResourceID) - if !ok || resource == nil { - writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound, "Resource not found", map[string]string{ - "resourceId": req.ResourceID, - }) - return - } - - plan, err := (actionplanner.Planner{}).Plan(req, *resource) - if err != nil { - if validationErr, ok := actionplanner.AsValidationError(err); ok { - details := map[string]string{} - if validationErr.Field != "" { - details[validationErr.Field] = validationErr.Message - } - writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", details) - return - } - if errors.Is(err, actionplanner.ErrCapabilityNotFound) { - writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeCapabilityNotFound, "Capability not found on resource", map[string]string{ - "resourceId": req.ResourceID, - "capabilityName": req.CapabilityName, - }) - return - } - writeJSONError(w, http.StatusInternalServerError, "action_plan_failed", sanitizeErrorForClient(err, "Action planning failed")) - return - } - - req = normalizeActionRequestForAudit(req) - if checker, ok := h.actionExecutor.(ActionAvailabilityChecker); ok { - if readiness := checker.CheckActionAvailable(r.Context(), req, *resource); readiness.Name != "" && !readiness.Available { - writeJSONErrorWithDetails(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionUnavailable, "Action execution is unavailable", map[string]string{ - "resourceId": req.ResourceID, - "capabilityName": req.CapabilityName, - "reasonCode": readiness.ReasonCode, - "reason": firstNonEmpty(readiness.Reason, "action execution is unavailable"), - }) - return - } - } - store, err := h.getStore(orgID) - if err != nil { - writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available") - return - } - if err := persistActionPlanAudit(store, req, plan); err != nil { - writeJSONError(w, http.StatusInternalServerError, "action_audit_persist_failed", sanitizeErrorForClient(err, "Failed to persist action audit")) + writeActionPlanError(w, err) return } @@ -148,71 +99,46 @@ func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Reque } } -func normalizeActionRequestForAudit(req unified.ActionRequest) unified.ActionRequest { - req.RequestID = strings.TrimSpace(req.RequestID) - req.ResourceID = unified.CanonicalResourceID(req.ResourceID) - req.CapabilityName = strings.TrimSpace(req.CapabilityName) - req.Reason = strings.TrimSpace(req.Reason) - req.RequestedBy = strings.TrimSpace(req.RequestedBy) - if req.Params == nil { - req.Params = map[string]any{} - } - return req -} - -func persistActionPlanAudit(store unified.ResourceStore, req unified.ActionRequest, plan unified.ActionPlan) error { - state := plannedActionState(plan) - record := unified.ActionAuditRecord{ - ID: plan.ActionID, - CreatedAt: plan.PlannedAt, - UpdatedAt: plan.PlannedAt, - State: state, - Request: req, - Plan: plan, - } - if err := store.RecordActionAudit(record); err != nil { - return err - } - - existingEvents, err := store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 100) - if err != nil { - return err - } - seenStates := map[unified.ActionState]bool{} - for _, event := range existingEvents { - seenStates[event.State] = true - } - - if !seenStates[unified.ActionStatePlanned] { - if err := store.RecordActionLifecycleEvent(unified.ActionLifecycleEvent{ - ActionID: plan.ActionID, - Timestamp: plan.PlannedAt, - State: unified.ActionStatePlanned, - Actor: req.RequestedBy, - Message: "Action plan created.", - }); err != nil { - return err +func writeActionPlanError(w http.ResponseWriter, err error) { + var validationErr *actionplanner.ValidationError + var notFound *actionlifecycle.ResourceNotFoundError + var unavailable *actionlifecycle.AvailabilityRefusedError + var persist *actionlifecycle.PersistError + switch { + case errors.As(err, &validationErr): + details := map[string]string{} + if validationErr.Field != "" { + details[validationErr.Field] = validationErr.Message } - } - if state != unified.ActionStatePlanned && !seenStates[state] { - if err := store.RecordActionLifecycleEvent(unified.ActionLifecycleEvent{ - ActionID: plan.ActionID, - Timestamp: plan.PlannedAt, - State: state, - Actor: req.RequestedBy, - Message: "Action is waiting for approval before execution.", - }); err != nil { - return err + writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", details) + case errors.Is(err, actionplanner.ErrCapabilityNotFound): + details := map[string]string{} + var capabilityNotFound *actionlifecycle.CapabilityNotFoundError + if errors.As(err, &capabilityNotFound) { + details["resourceId"] = capabilityNotFound.ResourceID + details["capabilityName"] = capabilityNotFound.CapabilityName } + writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeCapabilityNotFound, "Capability not found on resource", details) + case errors.As(err, ¬Found): + writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound, "Resource not found", map[string]string{ + "resourceId": notFound.ResourceID, + }) + case errors.As(err, &unavailable): + writeJSONErrorWithDetails(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionUnavailable, "Action execution is unavailable", map[string]string{ + "resourceId": unavailable.ResourceID, + "capabilityName": unavailable.CapabilityName, + "reasonCode": unavailable.Readiness.ReasonCode, + "reason": firstNonEmpty(unavailable.Readiness.Reason, "action execution is unavailable"), + }) + case errors.Is(err, actionlifecycle.ErrRegistryUnavailable): + writeJSONError(w, http.StatusInternalServerError, "resource_registry_unavailable", sanitizeErrorForClient(err, "Resource registry unavailable")) + case errors.Is(err, actionlifecycle.ErrStoreUnavailable): + writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available") + case errors.As(err, &persist): + writeJSONError(w, http.StatusInternalServerError, "action_audit_persist_failed", sanitizeErrorForClient(err, "Failed to persist action audit")) + default: + writeJSONError(w, http.StatusInternalServerError, "action_plan_failed", sanitizeErrorForClient(err, "Action planning failed")) } - return nil -} - -func plannedActionState(plan unified.ActionPlan) unified.ActionState { - if plan.RequiresApproval { - return unified.ActionStatePending - } - return unified.ActionStatePlanned } func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Request) { @@ -255,44 +181,22 @@ func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Req return } - orgID := GetOrgID(r.Context()) - store, err := h.getStore(orgID) - if err != nil { - writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available") - return - } - record, ok, err := store.GetActionAudit(actionID) - if err != nil { - writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit") - return - } - if !ok { - writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{ - "actionId": actionID, - }) - return - } - - actor := actionDecisionActor(h, r) - now := time.Now().UTC() approval := unified.ActionApprovalRecord{ - Actor: actor, - Method: unified.MethodAPI, - Timestamp: now, - Outcome: decision.Outcome, - Reason: decision.Reason, + Actor: actionDecisionActor(h, r), + Method: unified.MethodAPI, + Outcome: decision.Outcome, + Reason: decision.Reason, } - updated, event, err := unified.ApplyActionDecision(record, approval, now) + updated, err := h.ActionLifecycle().Decide(r.Context(), GetOrgID(r.Context()), actionID, approval) if err != nil { - writeActionDecisionApplyError(w, err) - return - } - if err := store.RecordActionDecision(updated, event); err != nil { - if errors.Is(err, unified.ErrActionNotPending) { + writeActionLifecycleReadError(w, err, func() { + var persist *actionlifecycle.PersistError + if errors.As(err, &persist) { + writeJSONError(w, http.StatusInternalServerError, "action_decision_persist_failed", sanitizeErrorForClient(err, "Failed to persist action decision")) + return + } writeActionDecisionApplyError(w, err) - return - } - writeJSONError(w, http.StatusInternalServerError, "action_decision_persist_failed", sanitizeErrorForClient(err, "Failed to persist action decision")) + }) return } @@ -345,99 +249,14 @@ func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Re } execution.Reason = strings.TrimSpace(execution.Reason) - orgID := GetOrgID(r.Context()) - store, err := h.getStore(orgID) + completed, err := h.ActionLifecycle().Execute(r.Context(), GetOrgID(r.Context()), actionID, actionDecisionActor(h, r), execution.Reason) if err != nil { - writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available") - return - } - record, ok, err := store.GetActionAudit(actionID) - if err != nil { - writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit") - return - } - if !ok { - writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{ - "actionId": actionID, + writeActionLifecycleReadError(w, err, func() { + writeActionExecuteError(w, err) }) return } - actor := actionDecisionActor(h, r) - now := time.Now().UTC() - if err := unified.ValidateActionExecutionStart(record, now); err != nil { - if unified.IsPermanentActionExecutionRefusal(err) { - if failed, persistErr := recordRefusedActionExecution(store, record, actor, now, err); persistErr == nil { - h.publishActionCompleted(failed) - } else { - writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(persistErr, "Failed to persist refused action execution")) - return - } - } - writeActionExecutionApplyError(w, err) - return - } - if h.actionExecutor == nil { - writeJSONError(w, http.StatusNotImplemented, agentcapabilities.AgentErrCodeActionExecutorUnavailable, "No action executor is configured for this API instance") - return - } - if err := h.validateActionPlanFresh(orgID, record); err != nil { - if errors.Is(err, unified.ErrActionPlanDrift) { - if failed, persistErr := recordRefusedActionExecution(store, record, actor, now, err); persistErr == nil { - h.publishActionCompleted(failed) - } else { - writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(persistErr, "Failed to persist refused action execution")) - return - } - writeActionExecutionApplyError(w, err) - return - } - writeJSONError(w, http.StatusInternalServerError, "action_plan_validation_failed", sanitizeErrorForClient(err, "Failed to validate action plan freshness")) - return - } - if err := h.validateActionExecutionPolicy(store, record); err != nil { - if unified.IsPermanentActionExecutionRefusal(err) { - if failed, persistErr := recordRefusedActionExecution(store, record, actor, now, err); persistErr == nil { - h.publishActionCompleted(failed) - } else { - writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(persistErr, "Failed to persist refused action execution")) - return - } - writeActionExecutionApplyError(w, err) - return - } - writeJSONError(w, http.StatusInternalServerError, "action_policy_validation_failed", sanitizeErrorForClient(err, "Failed to validate action policy")) - return - } - - started, startEvent, err := unified.BeginActionExecution(record, actor, now) - if err != nil { - writeActionExecutionApplyError(w, err) - return - } - if execution.Reason != "" { - startEvent.Message = "Action execution started: " + execution.Reason - } - if err := store.RecordActionExecutionStart(started, startEvent); err != nil { - writeActionExecutionPersistError(w, err) - return - } - - result, execErr := h.actionExecutor.ExecuteAction(r.Context(), started) - if execErr != nil { - result = &unified.ExecutionResult{Success: false, ErrorMessage: execErr.Error()} - } - completed, doneEvent, err := unified.CompleteActionExecution(started, result, actor, time.Now().UTC()) - if err != nil { - writeActionExecutionApplyError(w, err) - return - } - if err := store.RecordActionExecutionResult(completed, doneEvent); err != nil { - writeActionExecutionPersistError(w, err) - return - } - h.publishActionCompleted(completed) - w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(actionExecutionResponse{ ActionID: completed.ID, @@ -449,86 +268,42 @@ func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Re } } -func (h *ResourceHandlers) validateActionPlanFresh(orgID string, record unified.ActionAuditRecord) error { - if h == nil { - return fmt.Errorf("%w: resource handler unavailable", unified.ErrActionPlanDrift) +// writeActionLifecycleReadError handles the store/query/not-found failures +// shared by the decision and execution endpoints, delegating anything else +// to the endpoint-specific fallback. +func writeActionLifecycleReadError(w http.ResponseWriter, err error, fallback func()) { + var notFound *actionlifecycle.ActionNotFoundError + var query *actionlifecycle.QueryError + switch { + case errors.Is(err, actionlifecycle.ErrStoreUnavailable): + writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available") + case errors.As(err, &query): + writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit") + case errors.As(err, ¬Found): + writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{ + "actionId": notFound.ActionID, + }) + default: + fallback() } - normalized, err := unified.NormalizeActionAuditRecord(record) - if err != nil { - return fmt.Errorf("%w: %v", unified.ErrActionPlanDrift, err) - } - registry, err := h.buildRegistry(orgID) - if err != nil { - return err - } - resource, ok := registry.Get(normalized.Request.ResourceID) - if !ok || resource == nil { - return fmt.Errorf("%w: resource %q is no longer present", unified.ErrActionPlanDrift, normalized.Request.ResourceID) - } - currentPlan, err := (actionplanner.Planner{Now: func() time.Time { - return normalized.Plan.PlannedAt - }}).Plan(normalized.Request, *resource) - if err != nil { - return fmt.Errorf("%w: %v", unified.ErrActionPlanDrift, err) - } - if currentPlan.ActionID != normalized.Plan.ActionID { - return fmt.Errorf("%w: action identity changed", unified.ErrActionPlanDrift) - } - if currentPlan.PlanHash != normalized.Plan.PlanHash { - return fmt.Errorf("%w: plan hash changed", unified.ErrActionPlanDrift) - } - if currentPlan.ResourceVersion != normalized.Plan.ResourceVersion { - return fmt.Errorf("%w: resource version changed", unified.ErrActionPlanDrift) - } - if currentPlan.PolicyVersion != normalized.Plan.PolicyVersion { - return fmt.Errorf("%w: capability policy changed", unified.ErrActionPlanDrift) - } - return nil } -func (h *ResourceHandlers) validateActionExecutionPolicy(store unified.ResourceStore, record unified.ActionAuditRecord) error { - if store == nil { - return errors.New("action audit store unavailable") +func writeActionExecuteError(w http.ResponseWriter, err error) { + var persist *actionlifecycle.PersistError + var freshness *actionlifecycle.FreshnessCheckError + var policy *actionlifecycle.PolicyCheckError + switch { + case errors.Is(err, actionlifecycle.ErrExecutorUnavailable): + writeJSONError(w, http.StatusNotImplemented, agentcapabilities.AgentErrCodeActionExecutorUnavailable, "No action executor is configured for this API instance") + case errors.As(err, &persist): + writeActionExecutionPersistError(w, err) + case errors.As(err, &freshness): + writeJSONError(w, http.StatusInternalServerError, "action_plan_validation_failed", sanitizeErrorForClient(err, "Failed to validate action plan freshness")) + case errors.As(err, &policy): + writeJSONError(w, http.StatusInternalServerError, "action_policy_validation_failed", sanitizeErrorForClient(err, "Failed to validate action policy")) + default: + writeActionExecutionApplyError(w, err) } - normalized, err := unified.NormalizeActionAuditRecord(record) - if err != nil { - return err - } - state, found, err := store.GetResourceOperatorState(normalized.Request.ResourceID) - if err != nil || !found { - return err - } - if state.NeverAutoRemediate { - return unified.ErrResourceRemediationLocked - } - return nil -} - -func recordRefusedActionExecution(store unified.ResourceStore, record unified.ActionAuditRecord, actor string, now time.Time, reason error) (unified.ActionAuditRecord, error) { - failed, event, err := unified.RefuseActionExecution(record, reason, actor, now) - if err != nil { - return unified.ActionAuditRecord{}, err - } - if store == nil { - return unified.ActionAuditRecord{}, errors.New("action audit store unavailable") - } - if err := store.RecordActionAudit(failed); err != nil { - return unified.ActionAuditRecord{}, err - } - if err := store.RecordActionLifecycleEvent(event); err != nil { - return unified.ActionAuditRecord{}, err - } - return failed, nil -} - -func (h *ResourceHandlers) publishActionCompleted(record unified.ActionAuditRecord) { - if h == nil || h.actionCompleted == nil { - return - } - if record.State != unified.ActionStateCompleted && record.State != unified.ActionStateFailed { - return - } - h.actionCompleted(record) } func actionDecisionActor(h *ResourceHandlers, r *http.Request) string { diff --git a/internal/api/actions_test.go b/internal/api/actions_test.go index 8777de729..c42d0dda0 100644 --- a/internal/api/actions_test.go +++ b/internal/api/actions_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" @@ -547,7 +548,7 @@ func TestHandleExecuteActionRejectsStalePlanBeforeExecutor(t *testing.T) { if !ok { t.Fatal("expected approved action audit before execute") } - if err := h.validateActionPlanFresh("default", approvedAudit); !errors.Is(err, unified.ErrActionPlanDrift) { + if err := h.ActionLifecycle().ValidatePlanFresh("default", approvedAudit); !errors.Is(err, unified.ErrActionPlanDrift) { t.Fatalf("expected current resource contract to drift before execute, got %v", err) } @@ -874,7 +875,7 @@ func TestPersistActionPlanAuditFillsMissingLifecycleState(t *testing.T) { t.Fatalf("seed lifecycle event: %v", err) } - if err := persistActionPlanAudit(store, req, plan); err != nil { + if err := actionlifecycle.PersistPlanAudit(store, req, plan); err != nil { t.Fatalf("persistActionPlanAudit: %v", err) } events, err := store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 10) @@ -889,7 +890,7 @@ func TestPersistActionPlanAuditFillsMissingLifecycleState(t *testing.T) { t.Fatalf("events = %#v, want one planned and one pending event", events) } - if err := persistActionPlanAudit(store, req, plan); err != nil { + if err := actionlifecycle.PersistPlanAudit(store, req, plan); err != nil { t.Fatalf("persistActionPlanAudit retry: %v", err) } events, err = store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 10) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index b06635552..fcb970eda 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -23,6 +23,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/gorilla/websocket" + "github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle" "github.com/rcourtman/pulse-go-rewrite/internal/actionplanner" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" @@ -13732,7 +13733,7 @@ func TestContract_ActionPlanAuditLifecycleSnapshot(t *testing.T) { } store := unifiedresources.NewMemoryStore() - if err := persistActionPlanAudit(store, req, plan); err != nil { + if err := actionlifecycle.PersistPlanAudit(store, req, plan); err != nil { t.Fatalf("persist action plan audit: %v", err) } audits, err := store.GetActionAudits("vm:42", time.Time{}, 10) @@ -14276,26 +14277,33 @@ func TestContract_ActionDryRunOnlyExecutionErrorJSONSnapshot(t *testing.T) { } func TestContract_APIActionExecutionRevalidatesPlanFreshness(t *testing.T) { - source, err := os.ReadFile("actions.go") + source, err := os.ReadFile(filepath.Join("..", "actionlifecycle", "service.go")) if err != nil { - t.Fatalf("read actions.go: %v", err) + t.Fatalf("read actionlifecycle service source: %v", err) } src := string(source) for _, snippet := range []string{ "if unified.IsPermanentActionExecutionRefusal(err)", - "if err := h.validateActionPlanFresh(orgID, record); err != nil", + "if err := s.ValidatePlanFresh(orgID, record); err != nil", "errors.Is(err, unified.ErrActionPlanDrift)", - "recordRefusedActionExecution(store, record, actor, now, err)", + "RecordRefusedExecution(store, record, actor, now, err)", "unified.RefuseActionExecution(record, reason, actor, now)", - "writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanDrift", } { if !strings.Contains(src, snippet) { - t.Fatalf("actions.go must pin API execute plan freshness guard snippet %q", snippet) + t.Fatalf("actionlifecycle service must pin execute plan freshness guard snippet %q", snippet) } } - if strings.Index(src, "if err := h.validateActionPlanFresh(orgID, record); err != nil") > + if strings.Index(src, "if err := s.ValidatePlanFresh(orgID, record); err != nil") > strings.Index(src, "started, startEvent, err := unified.BeginActionExecution(record, actor, now)") { - t.Fatal("HandleExecuteAction must validate plan freshness before entering executing state or calling the executor") + t.Fatal("Execute must validate plan freshness before entering executing state or calling the executor") + } + + adapterSource, err := os.ReadFile("actions.go") + if err != nil { + t.Fatalf("read actions.go: %v", err) + } + if !strings.Contains(string(adapterSource), "writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanDrift") { + t.Fatal("actions.go must map plan drift to a 409 conflict with the canonical drift error code") } } diff --git a/internal/api/telemetry_pulse_intelligence_test.go b/internal/api/telemetry_pulse_intelligence_test.go index f08ec5a2a..7f95eec41 100644 --- a/internal/api/telemetry_pulse_intelligence_test.go +++ b/internal/api/telemetry_pulse_intelligence_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/telemetry" @@ -146,7 +147,7 @@ func TestGetPulseIntelligenceActionTelemetry_CountsApprovedLifecycleAttemptsInsi if err := store.RecordActionAudit(oldRefused); err != nil { t.Fatalf("RecordActionAudit(oldRefused): %v", err) } - if _, err := recordRefusedActionExecution(store, oldRefused, "operator", now.Add(-30*time.Minute), unifiedresources.ErrActionPlanDrift); err != nil { + if _, err := actionlifecycle.RecordRefusedExecution(store, oldRefused, "operator", now.Add(-30*time.Minute), unifiedresources.ErrActionPlanDrift); err != nil { t.Fatalf("recordRefusedActionExecution(oldRefused): %v", err) } @@ -155,7 +156,7 @@ func TestGetPulseIntelligenceActionTelemetry_CountsApprovedLifecycleAttemptsInsi if err := store.RecordActionAudit(unapprovedRefused); err != nil { t.Fatalf("RecordActionAudit(unapprovedRefused): %v", err) } - if _, err := recordRefusedActionExecution(store, unapprovedRefused, "operator", now.Add(-15*time.Minute), unifiedresources.ErrActionPlanDrift); err != nil { + if _, err := actionlifecycle.RecordRefusedExecution(store, unapprovedRefused, "operator", now.Add(-15*time.Minute), unifiedresources.ErrActionPlanDrift); err != nil { t.Fatalf("recordRefusedActionExecution(unapprovedRefused): %v", err) } diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 55e14a7fe..6995248e6 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -639,16 +639,28 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) { filepath.Join(".", "types.go"): { "ActionReadiness []ResourceActionReadiness `json:\"actionReadiness,omitempty\"`", }, - filepath.Join("..", "api", "actions.go"): { - "type ActionExecutor interface", - "type ActionAvailabilityChecker interface", + filepath.Join("..", "actionlifecycle", "service.go"): { + // The transport-independent lifecycle service is the only + // sanctioned path from a typed action request to execution. + // REST handlers and in-process brokers must both route + // through it; pin its execution-boundary invariants here. + "type Executor interface", + "type AvailabilityChecker interface", "CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness", - "func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Request)", - "func (h *ResourceHandlers) validateActionPlanFresh(orgID string, record unified.ActionAuditRecord) error", - "func recordRefusedActionExecution(store unified.ResourceStore, record unified.ActionAuditRecord", - "func (h *ResourceHandlers) publishActionCompleted(record unified.ActionAuditRecord)", + "func (s *Service) ValidatePlanFresh(orgID string, record unified.ActionAuditRecord) error", + "func RecordRefusedExecution(store Store, record unified.ActionAuditRecord", + "func (s *Service) publishCompleted(record unified.ActionAuditRecord)", "store.RecordActionExecutionStart(started, startEvent)", "store.RecordActionExecutionResult(completed, doneEvent)", + }, + filepath.Join("..", "api", "actions.go"): { + // The REST layer is a thin adapter over the shared lifecycle + // service; it owns only decode, actor resolution, and error + // code mapping. + "type ActionExecutor = actionlifecycle.Executor", + "type ActionAvailabilityChecker = actionlifecycle.AvailabilityChecker", + "func (h *ResourceHandlers) ActionLifecycle() *actionlifecycle.Service", + "func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Request)", "agentcapabilities.AgentErrCodeActionExecutionUnavailable", "agentcapabilities.AgentErrCodeActionPlanDrift", "agentcapabilities.AgentErrCodeActionExecutorUnavailable", diff --git a/scripts/installtests/windows_agent_lifecycle.ps1 b/scripts/installtests/windows_agent_lifecycle.ps1 new file mode 100644 index 000000000..33261abf4 --- /dev/null +++ b/scripts/installtests/windows_agent_lifecycle.ps1 @@ -0,0 +1,240 @@ +[CmdletBinding()] +param ( + [ValidateSet('Full', 'InstallUpdate', 'PostRebootUninstall')] + [string]$Phase = 'Full', + [Parameter(Mandatory = $true)] + [string]$ServerBinary, + [string]$AgentV1, + [Parameter(Mandatory = $true)] + [string]$AgentV2, + [Parameter(Mandatory = $true)] + [string]$InstallerPath, + [int]$Port = 17655, + [switch]$ConfirmLifecycleMutation +) + +$ErrorActionPreference = 'Stop' +$serviceName = 'PulseAgent' +$stateDir = Join-Path $env:ProgramData 'Pulse' +$logFile = Join-Path $stateDir 'pulse-agent.log' +$proofStatePath = Join-Path $stateDir 'windows-lifecycle-proof.json' +$baseUrl = "http://127.0.0.1:$Port" +$serverProcess = $null +$previousDisableAutoUpdate = $null +$restoreAutoUpdateAtExit = $true + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Windows lifecycle proof must run from an elevated PowerShell session.' + } +} + +function Resolve-RequiredPath { + param([string]$Path, [string]$Label) + if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path $Path -PathType Leaf)) { + throw "$Label does not exist: $Path" + } + return (Resolve-Path $Path).Path +} + +function Stop-LifecycleServer { + if ($null -ne $script:serverProcess -and -not $script:serverProcess.HasExited) { + Stop-Process -Id $script:serverProcess.Id -Force -ErrorAction SilentlyContinue + $script:serverProcess.WaitForExit(5000) | Out-Null + } + $script:serverProcess = $null +} + +function Start-LifecycleServer { + param([string]$AgentPath) + Stop-LifecycleServer + $version = ((& $AgentPath --version 2>$null) | Select-Object -First 1).Trim() + if ([string]::IsNullOrWhiteSpace($version)) { + throw "Could not read agent version from $AgentPath" + } + $arguments = @( + '--listen', "127.0.0.1:$Port", + '--agent-binary', ('"{0}"' -f $AgentPath), + '--version', $version + ) + $script:serverProcess = Start-Process -FilePath $script:resolvedServerBinary -ArgumentList $arguments -PassThru -NoNewWindow + for ($i = 0; $i -lt 30; $i++) { + if ($script:serverProcess.HasExited) { + throw "Lifecycle server exited with code $($script:serverProcess.ExitCode)." + } + try { + $response = Invoke-RestMethod -Uri "$baseUrl/api/version" -TimeoutSec 2 + if ($response.version -eq $version) { + return $version + } + } catch { + } + Start-Sleep -Milliseconds 500 + } + throw "Lifecycle server did not become ready at $baseUrl." +} + +function Invoke-Installer { + param([string]$Arguments, [string]$Label) + $escapedInstaller = $script:resolvedInstallerPath.Replace("'", "''") + $command = "& '$escapedInstaller' $Arguments" + & $script:powerShellPath -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command $command + if ($LASTEXITCODE -ne 0) { + throw "$Label failed with exit code $LASTEXITCODE." + } +} + +function Wait-AgentReady { + param([int]$TimeoutSeconds = 45) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + try { + $service = Get-Service -Name $serviceName -ErrorAction Stop + $ready = Invoke-RestMethod -Uri 'http://127.0.0.1:9191/readyz' -TimeoutSec 2 + if ($service.Status -eq 'Running' -and $ready.ready -eq $true) { + return + } + } catch { + } + Start-Sleep -Seconds 1 + } while ((Get-Date) -lt $deadline) + throw 'PulseAgent did not reach local readiness before the timeout.' +} + +function Assert-AgentRuntime { + param([string]$ExpectedVersion) + Wait-AgentReady + $service = Get-CimInstance Win32_Service | Where-Object Name -eq $serviceName + if ($null -eq $service -or $service.State -ne 'Running' -or $service.StartMode -ne 'Auto') { + throw "Unexpected service state: $($service | ConvertTo-Json -Compress)" + } + $installedVersion = ((& "$env:ProgramFiles\Pulse\pulse-agent.exe" --version 2>$null) | Select-Object -First 1).Trim() + if ($installedVersion -ne $ExpectedVersion) { + throw "Installed version is $installedVersion; expected $ExpectedVersion." + } + if ($service.PathName -notlike '*--log-file*' -or $service.PathName -notlike '*pulse-agent.log*') { + throw "Service command does not carry the canonical log-file argument: $($service.PathName)" + } + if (-not (Test-Path $logFile) -or (Get-Item $logFile).Length -le 0) { + throw "Agent log file is missing or empty: $logFile" + } + $logText = Get-Content $logFile -Raw + $hasStartupEvent = $logText -like '*Starting Pulse Unified Agent*' -or $logText -like '*Pulse Agent service is running*' + if (-not $hasStartupEvent -or $logText -notlike "*$ExpectedVersion*") { + throw "Agent log does not contain startup evidence for $ExpectedVersion." + } + $recovery = (& sc.exe qfailure $serviceName 2>&1 | Out-String) + if ([regex]::Matches($recovery, 'RESTART').Count -lt 3) { + throw "Service recovery actions are incomplete: $recovery" + } + $failureFlag = (& sc.exe qfailureflag $serviceName 2>&1 | Out-String) + if ($failureFlag -notmatch 'TRUE|1') { + throw "Service non-crash recovery flag is not enabled: $failureFlag" + } + return [uint32]$service.ProcessId +} + +function Assert-CrashRecovery { + param([string]$ExpectedVersion) + $previousPid = Assert-AgentRuntime -ExpectedVersion $ExpectedVersion + Stop-Process -Id $previousPid -Force + $deadline = (Get-Date).AddSeconds(45) + do { + Start-Sleep -Seconds 1 + $service = Get-CimInstance Win32_Service | Where-Object Name -eq $serviceName + if ($null -ne $service -and $service.State -eq 'Running' -and [uint32]$service.ProcessId -ne $previousPid) { + try { + Wait-AgentReady -TimeoutSeconds 5 + return + } catch { + } + } + } while ((Get-Date) -lt $deadline) + throw 'PulseAgent did not recover after its process was terminated.' +} + +function Invoke-UninstallAndAssertClean { + Invoke-Installer -Label 'uninstall' -Arguments '-Uninstall $true -NonInteractive $true' + Start-Sleep -Seconds 2 + if ($null -ne (Get-Service $serviceName -ErrorAction SilentlyContinue)) { + throw 'PulseAgent service still exists after uninstall.' + } + if (Test-Path "$env:ProgramFiles\Pulse\pulse-agent.exe") { + throw 'Pulse Agent binary still exists after uninstall.' + } + if (Test-Path $stateDir) { + throw 'Pulse Agent state directory still exists after uninstall.' + } + if (Get-NetTCPConnection -LocalPort 9191 -State Listen -ErrorAction SilentlyContinue) { + throw 'Pulse Agent readiness listener still exists after uninstall.' + } +} + +Assert-Administrator +if (-not $ConfirmLifecycleMutation) { + throw 'Pass -ConfirmLifecycleMutation to acknowledge service installation, process termination, and uninstall on this dedicated Windows runner.' +} + +$resolvedServerBinary = Resolve-RequiredPath $ServerBinary 'Lifecycle server binary' +$resolvedAgentV2 = Resolve-RequiredPath $AgentV2 'Version-two agent binary' +$resolvedInstallerPath = Resolve-RequiredPath $InstallerPath 'Windows installer' +$resolvedAgentV1 = $null +if ($Phase -ne 'PostRebootUninstall') { + $resolvedAgentV1 = Resolve-RequiredPath $AgentV1 'Version-one agent binary' +} +$powerShellPath = (Get-Process -Id $PID).Path + +try { + if ($Phase -eq 'PostRebootUninstall') { + $proofState = Get-Content $proofStatePath -Raw | ConvertFrom-Json + $previousDisableAutoUpdate = $proofState.previousDisableAutoUpdate + $restoreAutoUpdateAtExit = $true + $versionV2 = Start-LifecycleServer -AgentPath $resolvedAgentV2 + Assert-AgentRuntime -ExpectedVersion $versionV2 | Out-Null + Invoke-UninstallAndAssertClean + Write-Host 'Post-reboot persistence and uninstall proof passed.' -ForegroundColor Green + return + } + + if (Get-Service $serviceName -ErrorAction SilentlyContinue) { + throw 'PulseAgent is already installed. Use a disposable runner or uninstall it before starting this proof.' + } + if (Test-Path $stateDir) { + throw "Pulse state already exists at $stateDir. Use a clean disposable runner." + } + + $previousDisableAutoUpdate = [Environment]::GetEnvironmentVariable('PULSE_DISABLE_AUTO_UPDATE', 'Machine') + [Environment]::SetEnvironmentVariable('PULSE_DISABLE_AUTO_UPDATE', 'true', 'Machine') + + $versionV1 = Start-LifecycleServer -AgentPath $resolvedAgentV1 + Invoke-Installer -Label 'preflight' -Arguments "-Url '$baseUrl' -PreflightOnly `$true -NonInteractive `$true" + Invoke-Installer -Label 'install' -Arguments "-Url '$baseUrl' -AgentId 'windows-lifecycle-agent' -Hostname 'windows-lifecycle-agent' -EnableHost `$true -EnableDocker `$false -EnableKubernetes `$false -EnableProxmox `$false -EnableCommands `$false -NonInteractive `$true" + Assert-AgentRuntime -ExpectedVersion $versionV1 | Out-Null + + $versionV2 = Start-LifecycleServer -AgentPath $resolvedAgentV2 + Invoke-Installer -Label 'update' -Arguments "-Url '$baseUrl' -AgentId 'windows-lifecycle-agent' -Hostname 'windows-lifecycle-agent' -EnableHost `$true -EnableDocker `$false -EnableKubernetes `$false -EnableProxmox `$false -EnableCommands `$false -NonInteractive `$true" + Assert-AgentRuntime -ExpectedVersion $versionV2 | Out-Null + Assert-CrashRecovery -ExpectedVersion $versionV2 + + if ($Phase -eq 'InstallUpdate') { + [ordered]@{ + expectedVersion = $versionV2 + previousDisableAutoUpdate = $previousDisableAutoUpdate + } | ConvertTo-Json | Set-Content $proofStatePath -Encoding ascii + $restoreAutoUpdateAtExit = $false + Write-Host 'Install, update, logging, and crash-recovery proof passed; reboot the VM and run PostRebootUninstall.' -ForegroundColor Green + return + } + + Restart-Service $serviceName -Force + Assert-AgentRuntime -ExpectedVersion $versionV2 | Out-Null + Invoke-UninstallAndAssertClean + Write-Host 'Full Windows service lifecycle proof passed.' -ForegroundColor Green +} finally { + Stop-LifecycleServer + if ($restoreAutoUpdateAtExit) { + [Environment]::SetEnvironmentVariable('PULSE_DISABLE_AUTO_UPDATE', $previousDisableAutoUpdate, 'Machine') + } +} diff --git a/scripts/installtests/windowslifecycleserver/main.go b/scripts/installtests/windowslifecycleserver/main.go new file mode 100644 index 000000000..e8c4510a5 --- /dev/null +++ b/scripts/installtests/windowslifecycleserver/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +func main() { + listen := flag.String("listen", "127.0.0.1:17655", "HTTP listen address") + agentBinary := flag.String("agent-binary", "", "Windows agent binary to serve") + version := flag.String("version", "", "Version returned by /api/version") + flag.Parse() + + if strings.TrimSpace(*agentBinary) == "" || strings.TrimSpace(*version) == "" { + log.Fatal("--agent-binary and --version are required") + } + + binaryPath, err := filepath.Abs(*agentBinary) + if err != nil { + log.Fatalf("resolve agent binary: %v", err) + } + binary, err := os.ReadFile(binaryPath) + if err != nil { + log.Fatalf("read agent binary: %v", err) + } + digest := sha256.Sum256(binary) + checksum := hex.EncodeToString(digest[:]) + + mux := http.NewServeMux() + mux.HandleFunc("/api/version", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "version": *version, + "agentUpdateTargetVersion": *version, + "channel": "stable", + }) + }) + mux.HandleFunc("/download/pulse-agent", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("arch") != "windows-amd64" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(binary))) + w.Header().Set("X-Checksum-Sha256", checksum) + w.Header().Set("Cache-Control", "no-store") + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + _, _ = w.Write(binary) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + + server := &http.Server{ + Addr: *listen, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 30 * time.Second, + } + log.Printf("Windows lifecycle server listening on %s with %s (%s)", *listen, filepath.Base(binaryPath), *version) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +} diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index ace01278b..72cee496a 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2917,8 +2917,8 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 1196, - "heading_line": 140, + "line": 1197, + "heading_line": 141, } ], )