Extract the transport-independent action lifecycle service

Planning, approval decisions, and execution for typed resource actions
move out of the HTTP handlers in internal/api/actions.go into a new
internal/actionlifecycle.Service owned by api-contracts. The REST
handlers become thin decode/actor/error-mapping adapters over the one
shared service, and ResourceHandlers.ActionLifecycle() exposes the same
service for in-process consumers, so a future Patrol action broker
inherits identical resource lookup, availability checks, plan hashing,
audit persistence, remediation locks, plan-drift revalidation,
execution, and terminal publication instead of loopback HTTP or a
parallel lifecycle.

Behavior is preserved: same status codes, error codes, and audit/
lifecycle persistence ordering, backed by the existing api contract
tests plus new fail-closed proofs for the service itself (unknown
resource/capability, availability refusal, unapproved execution,
remediation lock, plan drift, missing executor, missing store).

Contract text in api-contracts, agent-lifecycle, and storage-recovery
now names the service alongside actions.go and planner.go; the
subsystem registry owns internal/actionlifecycle/ under api-contracts
with a dedicated path policy; the code-standards and contract source
pins follow the moved invariants; and the subsystem_lookup line-number
pin shifts with the api-contracts canonical-files list insertion.

This is the first slice of making the typed action lifecycle the only
autonomous execution route for Patrol, Assistant, and MCP.
This commit is contained in:
rcourtman
2026-07-10 11:06:55 +01:00
parent 3c252918b3
commit f356994869
19 changed files with 2097 additions and 362 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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> = {}): 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<AIProvider, keyof AISettings> = {
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<AISettings>);
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);
});
});
@@ -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>): 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);
});
});
@@ -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');
});
});
@@ -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();
});
});
});
@@ -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,
);
});
});
+521
View File
@@ -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)
}
+382
View File
@@ -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, &notFound) {
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, &notFound) {
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)
}
}
+110 -335
View File
@@ -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, &notFound):
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, &notFound):
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 {
+4 -3
View File
@@ -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)
+17 -9
View File
@@ -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")
}
}
@@ -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)
}
@@ -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",
@@ -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')
}
}
@@ -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)
}
}
@@ -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,
}
],
)