mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
+218
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package actionlifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
type stubExecutor struct {
|
||||
result *unified.ExecutionResult
|
||||
err error
|
||||
calls int
|
||||
received unified.ActionAuditRecord
|
||||
readiness *unified.ResourceActionReadiness
|
||||
}
|
||||
|
||||
func (s *stubExecutor) ExecuteAction(_ context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error) {
|
||||
s.calls++
|
||||
s.received = record
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
func (s *stubExecutor) CheckActionAvailable(_ context.Context, _ unified.ActionRequest, _ unified.Resource) unified.ResourceActionReadiness {
|
||||
if s.readiness == nil {
|
||||
return unified.ResourceActionReadiness{}
|
||||
}
|
||||
return *s.readiness
|
||||
}
|
||||
|
||||
type serviceEnv struct {
|
||||
store unified.ResourceStore
|
||||
registry *unified.ResourceRegistry
|
||||
executor *stubExecutor
|
||||
completed []unified.ActionAuditRecord
|
||||
service *Service
|
||||
}
|
||||
|
||||
func testResource(now time.Time, minimumApproval unified.ActionApprovalLevel) unified.Resource {
|
||||
return unified.Resource{
|
||||
ID: "vm:42",
|
||||
Type: unified.ResourceTypeVM,
|
||||
Name: "web-42",
|
||||
Status: unified.StatusWarning,
|
||||
LastSeen: now,
|
||||
UpdatedAt: now,
|
||||
Sources: []unified.DataSource{unified.SourceProxmox},
|
||||
Capabilities: []unified.ResourceCapability{
|
||||
{
|
||||
Name: "restart",
|
||||
Type: unified.CapabilityTypeCommon,
|
||||
Description: "Restart the VM",
|
||||
MinimumApprovalLevel: minimumApproval,
|
||||
InternalHandler: "proxmox.vm.restart",
|
||||
Params: []unified.CapabilityParam{
|
||||
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newServiceEnv(t *testing.T, resource unified.Resource) *serviceEnv {
|
||||
t.Helper()
|
||||
env := &serviceEnv{
|
||||
store: unified.NewMemoryStore(),
|
||||
executor: &stubExecutor{result: &unified.ExecutionResult{Success: true, Output: "restarted"}},
|
||||
}
|
||||
env.registry = unified.NewRegistry(env.store)
|
||||
env.registry.IngestResources([]unified.Resource{resource})
|
||||
env.service = &Service{
|
||||
Registry: func(orgID string) (*unified.ResourceRegistry, error) {
|
||||
if orgID != "default" {
|
||||
return nil, errors.New("unknown org")
|
||||
}
|
||||
return env.registry, nil
|
||||
},
|
||||
Store: func(orgID string) (Store, error) {
|
||||
if orgID != "default" {
|
||||
return nil, errors.New("unknown org")
|
||||
}
|
||||
return env.store, nil
|
||||
},
|
||||
Executor: env.executor,
|
||||
OnActionCompleted: func(record unified.ActionAuditRecord) {
|
||||
env.completed = append(env.completed, record)
|
||||
},
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func restartRequest() unified.ActionRequest {
|
||||
return unified.ActionRequest{
|
||||
RequestID: "req-1",
|
||||
ResourceID: "vm:42",
|
||||
CapabilityName: "restart",
|
||||
Params: map[string]any{"mode": "graceful"},
|
||||
Reason: "Recover after confirmed outage",
|
||||
RequestedBy: "agent:test",
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPersistsPendingAuditAndLifecycle(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
if !plan.RequiresApproval {
|
||||
t.Fatal("expected admin-gated capability to require approval")
|
||||
}
|
||||
|
||||
record, ok, err := env.store.GetActionAudit(plan.ActionID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetActionAudit: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if record.State != unified.ActionStatePending {
|
||||
t.Fatalf("audit state = %q, want pending_approval", record.State)
|
||||
}
|
||||
events, err := env.store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActionLifecycleEvents: %v", err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("lifecycle events = %d, want planned + pending", len(events))
|
||||
}
|
||||
|
||||
// Idempotent replan must not duplicate lifecycle events.
|
||||
if _, err := env.service.Plan(context.Background(), "default", restartRequest()); err != nil {
|
||||
t.Fatalf("replan: %v", err)
|
||||
}
|
||||
events, err = env.store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActionLifecycleEvents after replan: %v", err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("replan duplicated lifecycle events: %d", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFailsClosedOnUnknownResourceAndCapability(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
|
||||
|
||||
missing := restartRequest()
|
||||
missing.ResourceID = "vm:404"
|
||||
var notFound *ResourceNotFoundError
|
||||
if _, err := env.service.Plan(context.Background(), "default", missing); !errors.As(err, ¬Found) {
|
||||
t.Fatalf("unknown resource error = %v, want ResourceNotFoundError", err)
|
||||
}
|
||||
|
||||
unknownCap := restartRequest()
|
||||
unknownCap.CapabilityName = "detonate"
|
||||
_, err := env.service.Plan(context.Background(), "default", unknownCap)
|
||||
var capErr *CapabilityNotFoundError
|
||||
if !errors.As(err, &capErr) || !errors.Is(err, actionplanner.ErrCapabilityNotFound) {
|
||||
t.Fatalf("unknown capability error = %v, want CapabilityNotFoundError wrapping ErrCapabilityNotFound", err)
|
||||
}
|
||||
if capErr.ResourceID != "vm:42" || capErr.CapabilityName != "detonate" {
|
||||
t.Fatalf("capability error detail = %#v", capErr)
|
||||
}
|
||||
|
||||
empty := restartRequest()
|
||||
empty.ResourceID = " "
|
||||
var validation *actionplanner.ValidationError
|
||||
if _, err := env.service.Plan(context.Background(), "default", empty); !errors.As(err, &validation) {
|
||||
t.Fatalf("empty resource id error = %v, want ValidationError", err)
|
||||
}
|
||||
|
||||
audits, err := env.store.GetActionAudits("vm:42", time.Time{}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActionAudits: %v", err)
|
||||
}
|
||||
if len(audits) != 0 {
|
||||
t.Fatalf("failed plans must not persist audits, got %d", len(audits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAvailabilityRefusalPersistsNothing(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
|
||||
env.executor.readiness = &unified.ResourceActionReadiness{
|
||||
Name: "restart",
|
||||
Available: false,
|
||||
ReasonCode: "agent_disconnected",
|
||||
Reason: "no connected command agent",
|
||||
}
|
||||
|
||||
_, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
var refused *AvailabilityRefusedError
|
||||
if !errors.As(err, &refused) {
|
||||
t.Fatalf("error = %v, want AvailabilityRefusedError", err)
|
||||
}
|
||||
if refused.Readiness.ReasonCode != "agent_disconnected" {
|
||||
t.Fatalf("readiness = %#v", refused.Readiness)
|
||||
}
|
||||
audits, err := env.store.GetActionAudits("vm:42", time.Time{}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActionAudits: %v", err)
|
||||
}
|
||||
if len(audits) != 0 {
|
||||
t.Fatalf("refused availability must not persist audits, got %d", len(audits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideApprovesPendingAction(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
|
||||
updated, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{
|
||||
Actor: "operator@example.com",
|
||||
Method: unified.MethodAPI,
|
||||
Outcome: unified.OutcomeApproved,
|
||||
Reason: "confirmed outage",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if updated.State != unified.ActionStateApproved {
|
||||
t.Fatalf("state = %q, want approved", updated.State)
|
||||
}
|
||||
if len(updated.Approvals) != 1 || updated.Approvals[0].Actor != "operator@example.com" {
|
||||
t.Fatalf("approvals = %#v", updated.Approvals)
|
||||
}
|
||||
|
||||
var notFound *ActionNotFoundError
|
||||
if _, err := env.service.Decide(context.Background(), "default", "act_missing", unified.ActionApprovalRecord{
|
||||
Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved,
|
||||
}); !errors.As(err, ¬Found) {
|
||||
t.Fatalf("unknown action error = %v, want ActionNotFoundError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRunsApprovedActionToTerminalAudit(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{
|
||||
Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved,
|
||||
}); err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
|
||||
completed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "operator@example.com", "approved restart")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if completed.State != unified.ActionStateCompleted {
|
||||
t.Fatalf("state = %q, want completed", completed.State)
|
||||
}
|
||||
if env.executor.calls != 1 {
|
||||
t.Fatalf("executor calls = %d, want 1", env.executor.calls)
|
||||
}
|
||||
if len(env.completed) != 1 || env.completed[0].ID != plan.ActionID {
|
||||
t.Fatalf("completion publisher observed %#v", env.completed)
|
||||
}
|
||||
|
||||
record, ok, err := env.store.GetActionAudit(plan.ActionID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetActionAudit: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if record.State != unified.ActionStateCompleted || record.Result == nil || !record.Result.Success {
|
||||
t.Fatalf("terminal audit = state %q result %#v", record.State, record.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRefusesUnapprovedActionWithoutDispatch(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "operator@example.com", ""); !errors.Is(err, unified.ErrActionNotApproved) {
|
||||
t.Fatalf("error = %v, want ErrActionNotApproved", err)
|
||||
}
|
||||
if env.executor.calls != 0 {
|
||||
t.Fatalf("executor must not run for unapproved actions, calls = %d", env.executor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRefusesRemediationLockedResource(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalNone))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
if err := env.store.SetResourceOperatorState(unified.ResourceOperatorState{
|
||||
CanonicalID: "vm:42",
|
||||
NeverAutoRemediate: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("SetResourceOperatorState: %v", err)
|
||||
}
|
||||
|
||||
failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", "")
|
||||
if !errors.Is(err, unified.ErrResourceRemediationLocked) {
|
||||
t.Fatalf("error = %v, want ErrResourceRemediationLocked", err)
|
||||
}
|
||||
if env.executor.calls != 0 {
|
||||
t.Fatalf("executor must not run on locked resources, calls = %d", env.executor.calls)
|
||||
}
|
||||
if failed.State != unified.ActionStateFailed {
|
||||
t.Fatalf("refusal must persist a terminal failed audit, state = %q", failed.State)
|
||||
}
|
||||
if len(env.completed) != 1 || env.completed[0].State != unified.ActionStateFailed {
|
||||
t.Fatalf("refusal must publish the failed record, got %#v", env.completed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRefusesDriftedPlan(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalNone))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
|
||||
// The capability policy tightens between plan and dispatch: the
|
||||
// replanned contract no longer matches the recorded plan hash.
|
||||
drifted := testResource(now, unified.ApprovalAdmin)
|
||||
env.registry = unified.NewRegistry(env.store)
|
||||
env.registry.IngestResources([]unified.Resource{drifted})
|
||||
|
||||
failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", "")
|
||||
if !errors.Is(err, unified.ErrActionPlanDrift) {
|
||||
t.Fatalf("error = %v, want ErrActionPlanDrift", err)
|
||||
}
|
||||
if env.executor.calls != 0 {
|
||||
t.Fatalf("executor must not run on drifted plans, calls = %d", env.executor.calls)
|
||||
}
|
||||
if failed.State != unified.ActionStateFailed {
|
||||
t.Fatalf("drift refusal must persist a terminal failed audit, state = %q", failed.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteFailsClosedWithoutExecutor(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalNone))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
env.service.Executor = nil
|
||||
if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", ""); !errors.Is(err, ErrExecutorUnavailable) {
|
||||
t.Fatalf("error = %v, want ErrExecutorUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleFailsClosedWithoutStore(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalNone))
|
||||
env.service.Store = func(string) (Store, error) { return nil, errors.New("db offline") }
|
||||
|
||||
if _, err := env.service.Plan(context.Background(), "default", restartRequest()); !errors.Is(err, ErrStoreUnavailable) {
|
||||
t.Fatalf("Plan error = %v, want ErrStoreUnavailable", err)
|
||||
}
|
||||
if _, err := env.service.Decide(context.Background(), "default", "act_x", unified.ActionApprovalRecord{Outcome: unified.OutcomeApproved}); !errors.Is(err, ErrStoreUnavailable) {
|
||||
t.Fatalf("Decide error = %v, want ErrStoreUnavailable", err)
|
||||
}
|
||||
if _, err := env.service.Execute(context.Background(), "default", "act_x", "agent:test", ""); !errors.Is(err, ErrStoreUnavailable) {
|
||||
t.Fatalf("Execute error = %v, want ErrStoreUnavailable", err)
|
||||
}
|
||||
}
|
||||
+110
-335
@@ -1,15 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
@@ -19,17 +17,14 @@ const maxActionPlanRequestBytes = 1 << 20
|
||||
const maxActionDecisionRequestBytes = 64 << 10
|
||||
const maxActionExecutionRequestBytes = 64 << 10
|
||||
|
||||
// ActionExecutor runs a previously planned and approved action through the
|
||||
// API-owned execution contract.
|
||||
type ActionExecutor interface {
|
||||
ExecuteAction(ctx context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error)
|
||||
}
|
||||
// ActionExecutor is the API-facing name for the canonical action lifecycle
|
||||
// execution contract. The interface is owned by internal/actionlifecycle;
|
||||
// the alias keeps existing executor implementations and wiring source-stable.
|
||||
type ActionExecutor = actionlifecycle.Executor
|
||||
|
||||
// ActionAvailabilityChecker lets an executor contribute live readiness checks
|
||||
// before Pulse advertises or persists an executable action plan.
|
||||
type ActionAvailabilityChecker interface {
|
||||
CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness
|
||||
}
|
||||
// ActionAvailabilityChecker is the API-facing name for the canonical
|
||||
// pre-plan readiness contract owned by internal/actionlifecycle.
|
||||
type ActionAvailabilityChecker = actionlifecycle.AvailabilityChecker
|
||||
|
||||
type actionDecisionRequest struct {
|
||||
Outcome unified.ApprovalOutcome `json:"outcome"`
|
||||
@@ -54,6 +49,22 @@ type actionExecutionResponse struct {
|
||||
Audit unified.ActionAuditRecord `json:"audit"`
|
||||
}
|
||||
|
||||
// ActionLifecycle returns the shared transport-independent action lifecycle
|
||||
// service bound to this handler set's registry, store, executor, and
|
||||
// completion publisher. The REST handlers below and any in-process broker
|
||||
// (e.g. Patrol) must route through this one service; there is no other
|
||||
// sanctioned path from a typed action request to execution.
|
||||
func (h *ResourceHandlers) ActionLifecycle() *actionlifecycle.Service {
|
||||
return &actionlifecycle.Service{
|
||||
Registry: h.buildRegistry,
|
||||
Store: func(orgID string) (actionlifecycle.Store, error) {
|
||||
return h.getStore(orgID)
|
||||
},
|
||||
Executor: h.actionExecutor,
|
||||
OnActionCompleted: h.actionCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -76,69 +87,9 @@ func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
req.ResourceID = unified.CanonicalResourceID(req.ResourceID)
|
||||
if req.ResourceID == "" {
|
||||
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", map[string]string{
|
||||
"resourceId": "resource id is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
registry, err := h.buildRegistry(orgID)
|
||||
plan, err := h.ActionLifecycle().Plan(r.Context(), GetOrgID(r.Context()), req)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "resource_registry_unavailable", sanitizeErrorForClient(err, "Resource registry unavailable"))
|
||||
return
|
||||
}
|
||||
|
||||
resource, ok := registry.Get(req.ResourceID)
|
||||
if !ok || resource == nil {
|
||||
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound, "Resource not found", map[string]string{
|
||||
"resourceId": req.ResourceID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := (actionplanner.Planner{}).Plan(req, *resource)
|
||||
if err != nil {
|
||||
if validationErr, ok := actionplanner.AsValidationError(err); ok {
|
||||
details := map[string]string{}
|
||||
if validationErr.Field != "" {
|
||||
details[validationErr.Field] = validationErr.Message
|
||||
}
|
||||
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", details)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, actionplanner.ErrCapabilityNotFound) {
|
||||
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeCapabilityNotFound, "Capability not found on resource", map[string]string{
|
||||
"resourceId": req.ResourceID,
|
||||
"capabilityName": req.CapabilityName,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_plan_failed", sanitizeErrorForClient(err, "Action planning failed"))
|
||||
return
|
||||
}
|
||||
|
||||
req = normalizeActionRequestForAudit(req)
|
||||
if checker, ok := h.actionExecutor.(ActionAvailabilityChecker); ok {
|
||||
if readiness := checker.CheckActionAvailable(r.Context(), req, *resource); readiness.Name != "" && !readiness.Available {
|
||||
writeJSONErrorWithDetails(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionUnavailable, "Action execution is unavailable", map[string]string{
|
||||
"resourceId": req.ResourceID,
|
||||
"capabilityName": req.CapabilityName,
|
||||
"reasonCode": readiness.ReasonCode,
|
||||
"reason": firstNonEmpty(readiness.Reason, "action execution is unavailable"),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
store, err := h.getStore(orgID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
||||
return
|
||||
}
|
||||
if err := persistActionPlanAudit(store, req, plan); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_audit_persist_failed", sanitizeErrorForClient(err, "Failed to persist action audit"))
|
||||
writeActionPlanError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,71 +99,46 @@ func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeActionRequestForAudit(req unified.ActionRequest) unified.ActionRequest {
|
||||
req.RequestID = strings.TrimSpace(req.RequestID)
|
||||
req.ResourceID = unified.CanonicalResourceID(req.ResourceID)
|
||||
req.CapabilityName = strings.TrimSpace(req.CapabilityName)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.RequestedBy = strings.TrimSpace(req.RequestedBy)
|
||||
if req.Params == nil {
|
||||
req.Params = map[string]any{}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func persistActionPlanAudit(store unified.ResourceStore, req unified.ActionRequest, plan unified.ActionPlan) error {
|
||||
state := plannedActionState(plan)
|
||||
record := unified.ActionAuditRecord{
|
||||
ID: plan.ActionID,
|
||||
CreatedAt: plan.PlannedAt,
|
||||
UpdatedAt: plan.PlannedAt,
|
||||
State: state,
|
||||
Request: req,
|
||||
Plan: plan,
|
||||
}
|
||||
if err := store.RecordActionAudit(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingEvents, err := store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 100)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seenStates := map[unified.ActionState]bool{}
|
||||
for _, event := range existingEvents {
|
||||
seenStates[event.State] = true
|
||||
}
|
||||
|
||||
if !seenStates[unified.ActionStatePlanned] {
|
||||
if err := store.RecordActionLifecycleEvent(unified.ActionLifecycleEvent{
|
||||
ActionID: plan.ActionID,
|
||||
Timestamp: plan.PlannedAt,
|
||||
State: unified.ActionStatePlanned,
|
||||
Actor: req.RequestedBy,
|
||||
Message: "Action plan created.",
|
||||
}); err != nil {
|
||||
return err
|
||||
func writeActionPlanError(w http.ResponseWriter, err error) {
|
||||
var validationErr *actionplanner.ValidationError
|
||||
var notFound *actionlifecycle.ResourceNotFoundError
|
||||
var unavailable *actionlifecycle.AvailabilityRefusedError
|
||||
var persist *actionlifecycle.PersistError
|
||||
switch {
|
||||
case errors.As(err, &validationErr):
|
||||
details := map[string]string{}
|
||||
if validationErr.Field != "" {
|
||||
details[validationErr.Field] = validationErr.Message
|
||||
}
|
||||
}
|
||||
if state != unified.ActionStatePlanned && !seenStates[state] {
|
||||
if err := store.RecordActionLifecycleEvent(unified.ActionLifecycleEvent{
|
||||
ActionID: plan.ActionID,
|
||||
Timestamp: plan.PlannedAt,
|
||||
State: state,
|
||||
Actor: req.RequestedBy,
|
||||
Message: "Action is waiting for approval before execution.",
|
||||
}); err != nil {
|
||||
return err
|
||||
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", details)
|
||||
case errors.Is(err, actionplanner.ErrCapabilityNotFound):
|
||||
details := map[string]string{}
|
||||
var capabilityNotFound *actionlifecycle.CapabilityNotFoundError
|
||||
if errors.As(err, &capabilityNotFound) {
|
||||
details["resourceId"] = capabilityNotFound.ResourceID
|
||||
details["capabilityName"] = capabilityNotFound.CapabilityName
|
||||
}
|
||||
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeCapabilityNotFound, "Capability not found on resource", details)
|
||||
case errors.As(err, ¬Found):
|
||||
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound, "Resource not found", map[string]string{
|
||||
"resourceId": notFound.ResourceID,
|
||||
})
|
||||
case errors.As(err, &unavailable):
|
||||
writeJSONErrorWithDetails(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionUnavailable, "Action execution is unavailable", map[string]string{
|
||||
"resourceId": unavailable.ResourceID,
|
||||
"capabilityName": unavailable.CapabilityName,
|
||||
"reasonCode": unavailable.Readiness.ReasonCode,
|
||||
"reason": firstNonEmpty(unavailable.Readiness.Reason, "action execution is unavailable"),
|
||||
})
|
||||
case errors.Is(err, actionlifecycle.ErrRegistryUnavailable):
|
||||
writeJSONError(w, http.StatusInternalServerError, "resource_registry_unavailable", sanitizeErrorForClient(err, "Resource registry unavailable"))
|
||||
case errors.Is(err, actionlifecycle.ErrStoreUnavailable):
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
||||
case errors.As(err, &persist):
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_audit_persist_failed", sanitizeErrorForClient(err, "Failed to persist action audit"))
|
||||
default:
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_plan_failed", sanitizeErrorForClient(err, "Action planning failed"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func plannedActionState(plan unified.ActionPlan) unified.ActionState {
|
||||
if plan.RequiresApproval {
|
||||
return unified.ActionStatePending
|
||||
}
|
||||
return unified.ActionStatePlanned
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -255,44 +181,22 @@ func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
store, err := h.getStore(orgID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
||||
return
|
||||
}
|
||||
record, ok, err := store.GetActionAudit(actionID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{
|
||||
"actionId": actionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
actor := actionDecisionActor(h, r)
|
||||
now := time.Now().UTC()
|
||||
approval := unified.ActionApprovalRecord{
|
||||
Actor: actor,
|
||||
Method: unified.MethodAPI,
|
||||
Timestamp: now,
|
||||
Outcome: decision.Outcome,
|
||||
Reason: decision.Reason,
|
||||
Actor: actionDecisionActor(h, r),
|
||||
Method: unified.MethodAPI,
|
||||
Outcome: decision.Outcome,
|
||||
Reason: decision.Reason,
|
||||
}
|
||||
updated, event, err := unified.ApplyActionDecision(record, approval, now)
|
||||
updated, err := h.ActionLifecycle().Decide(r.Context(), GetOrgID(r.Context()), actionID, approval)
|
||||
if err != nil {
|
||||
writeActionDecisionApplyError(w, err)
|
||||
return
|
||||
}
|
||||
if err := store.RecordActionDecision(updated, event); err != nil {
|
||||
if errors.Is(err, unified.ErrActionNotPending) {
|
||||
writeActionLifecycleReadError(w, err, func() {
|
||||
var persist *actionlifecycle.PersistError
|
||||
if errors.As(err, &persist) {
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_decision_persist_failed", sanitizeErrorForClient(err, "Failed to persist action decision"))
|
||||
return
|
||||
}
|
||||
writeActionDecisionApplyError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_decision_persist_failed", sanitizeErrorForClient(err, "Failed to persist action decision"))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -345,99 +249,14 @@ func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
execution.Reason = strings.TrimSpace(execution.Reason)
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
store, err := h.getStore(orgID)
|
||||
completed, err := h.ActionLifecycle().Execute(r.Context(), GetOrgID(r.Context()), actionID, actionDecisionActor(h, r), execution.Reason)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
||||
return
|
||||
}
|
||||
record, ok, err := store.GetActionAudit(actionID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{
|
||||
"actionId": actionID,
|
||||
writeActionLifecycleReadError(w, err, func() {
|
||||
writeActionExecuteError(w, err)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
actor := actionDecisionActor(h, r)
|
||||
now := time.Now().UTC()
|
||||
if err := unified.ValidateActionExecutionStart(record, now); err != nil {
|
||||
if unified.IsPermanentActionExecutionRefusal(err) {
|
||||
if failed, persistErr := recordRefusedActionExecution(store, record, actor, now, err); persistErr == nil {
|
||||
h.publishActionCompleted(failed)
|
||||
} else {
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(persistErr, "Failed to persist refused action execution"))
|
||||
return
|
||||
}
|
||||
}
|
||||
writeActionExecutionApplyError(w, err)
|
||||
return
|
||||
}
|
||||
if h.actionExecutor == nil {
|
||||
writeJSONError(w, http.StatusNotImplemented, agentcapabilities.AgentErrCodeActionExecutorUnavailable, "No action executor is configured for this API instance")
|
||||
return
|
||||
}
|
||||
if err := h.validateActionPlanFresh(orgID, record); err != nil {
|
||||
if errors.Is(err, unified.ErrActionPlanDrift) {
|
||||
if failed, persistErr := recordRefusedActionExecution(store, record, actor, now, err); persistErr == nil {
|
||||
h.publishActionCompleted(failed)
|
||||
} else {
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(persistErr, "Failed to persist refused action execution"))
|
||||
return
|
||||
}
|
||||
writeActionExecutionApplyError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_plan_validation_failed", sanitizeErrorForClient(err, "Failed to validate action plan freshness"))
|
||||
return
|
||||
}
|
||||
if err := h.validateActionExecutionPolicy(store, record); err != nil {
|
||||
if unified.IsPermanentActionExecutionRefusal(err) {
|
||||
if failed, persistErr := recordRefusedActionExecution(store, record, actor, now, err); persistErr == nil {
|
||||
h.publishActionCompleted(failed)
|
||||
} else {
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(persistErr, "Failed to persist refused action execution"))
|
||||
return
|
||||
}
|
||||
writeActionExecutionApplyError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_policy_validation_failed", sanitizeErrorForClient(err, "Failed to validate action policy"))
|
||||
return
|
||||
}
|
||||
|
||||
started, startEvent, err := unified.BeginActionExecution(record, actor, now)
|
||||
if err != nil {
|
||||
writeActionExecutionApplyError(w, err)
|
||||
return
|
||||
}
|
||||
if execution.Reason != "" {
|
||||
startEvent.Message = "Action execution started: " + execution.Reason
|
||||
}
|
||||
if err := store.RecordActionExecutionStart(started, startEvent); err != nil {
|
||||
writeActionExecutionPersistError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, execErr := h.actionExecutor.ExecuteAction(r.Context(), started)
|
||||
if execErr != nil {
|
||||
result = &unified.ExecutionResult{Success: false, ErrorMessage: execErr.Error()}
|
||||
}
|
||||
completed, doneEvent, err := unified.CompleteActionExecution(started, result, actor, time.Now().UTC())
|
||||
if err != nil {
|
||||
writeActionExecutionApplyError(w, err)
|
||||
return
|
||||
}
|
||||
if err := store.RecordActionExecutionResult(completed, doneEvent); err != nil {
|
||||
writeActionExecutionPersistError(w, err)
|
||||
return
|
||||
}
|
||||
h.publishActionCompleted(completed)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(actionExecutionResponse{
|
||||
ActionID: completed.ID,
|
||||
@@ -449,86 +268,42 @@ func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) validateActionPlanFresh(orgID string, record unified.ActionAuditRecord) error {
|
||||
if h == nil {
|
||||
return fmt.Errorf("%w: resource handler unavailable", unified.ErrActionPlanDrift)
|
||||
// writeActionLifecycleReadError handles the store/query/not-found failures
|
||||
// shared by the decision and execution endpoints, delegating anything else
|
||||
// to the endpoint-specific fallback.
|
||||
func writeActionLifecycleReadError(w http.ResponseWriter, err error, fallback func()) {
|
||||
var notFound *actionlifecycle.ActionNotFoundError
|
||||
var query *actionlifecycle.QueryError
|
||||
switch {
|
||||
case errors.Is(err, actionlifecycle.ErrStoreUnavailable):
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
||||
case errors.As(err, &query):
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit")
|
||||
case errors.As(err, ¬Found):
|
||||
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{
|
||||
"actionId": notFound.ActionID,
|
||||
})
|
||||
default:
|
||||
fallback()
|
||||
}
|
||||
normalized, err := unified.NormalizeActionAuditRecord(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", unified.ErrActionPlanDrift, err)
|
||||
}
|
||||
registry, err := h.buildRegistry(orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resource, ok := registry.Get(normalized.Request.ResourceID)
|
||||
if !ok || resource == nil {
|
||||
return fmt.Errorf("%w: resource %q is no longer present", unified.ErrActionPlanDrift, normalized.Request.ResourceID)
|
||||
}
|
||||
currentPlan, err := (actionplanner.Planner{Now: func() time.Time {
|
||||
return normalized.Plan.PlannedAt
|
||||
}}).Plan(normalized.Request, *resource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", unified.ErrActionPlanDrift, err)
|
||||
}
|
||||
if currentPlan.ActionID != normalized.Plan.ActionID {
|
||||
return fmt.Errorf("%w: action identity changed", unified.ErrActionPlanDrift)
|
||||
}
|
||||
if currentPlan.PlanHash != normalized.Plan.PlanHash {
|
||||
return fmt.Errorf("%w: plan hash changed", unified.ErrActionPlanDrift)
|
||||
}
|
||||
if currentPlan.ResourceVersion != normalized.Plan.ResourceVersion {
|
||||
return fmt.Errorf("%w: resource version changed", unified.ErrActionPlanDrift)
|
||||
}
|
||||
if currentPlan.PolicyVersion != normalized.Plan.PolicyVersion {
|
||||
return fmt.Errorf("%w: capability policy changed", unified.ErrActionPlanDrift)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) validateActionExecutionPolicy(store unified.ResourceStore, record unified.ActionAuditRecord) error {
|
||||
if store == nil {
|
||||
return errors.New("action audit store unavailable")
|
||||
func writeActionExecuteError(w http.ResponseWriter, err error) {
|
||||
var persist *actionlifecycle.PersistError
|
||||
var freshness *actionlifecycle.FreshnessCheckError
|
||||
var policy *actionlifecycle.PolicyCheckError
|
||||
switch {
|
||||
case errors.Is(err, actionlifecycle.ErrExecutorUnavailable):
|
||||
writeJSONError(w, http.StatusNotImplemented, agentcapabilities.AgentErrCodeActionExecutorUnavailable, "No action executor is configured for this API instance")
|
||||
case errors.As(err, &persist):
|
||||
writeActionExecutionPersistError(w, err)
|
||||
case errors.As(err, &freshness):
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_plan_validation_failed", sanitizeErrorForClient(err, "Failed to validate action plan freshness"))
|
||||
case errors.As(err, &policy):
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_policy_validation_failed", sanitizeErrorForClient(err, "Failed to validate action policy"))
|
||||
default:
|
||||
writeActionExecutionApplyError(w, err)
|
||||
}
|
||||
normalized, err := unified.NormalizeActionAuditRecord(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, found, err := store.GetResourceOperatorState(normalized.Request.ResourceID)
|
||||
if err != nil || !found {
|
||||
return err
|
||||
}
|
||||
if state.NeverAutoRemediate {
|
||||
return unified.ErrResourceRemediationLocked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordRefusedActionExecution(store unified.ResourceStore, record unified.ActionAuditRecord, actor string, now time.Time, reason error) (unified.ActionAuditRecord, error) {
|
||||
failed, event, err := unified.RefuseActionExecution(record, reason, actor, now)
|
||||
if err != nil {
|
||||
return unified.ActionAuditRecord{}, err
|
||||
}
|
||||
if store == nil {
|
||||
return unified.ActionAuditRecord{}, errors.New("action audit store unavailable")
|
||||
}
|
||||
if err := store.RecordActionAudit(failed); err != nil {
|
||||
return unified.ActionAuditRecord{}, err
|
||||
}
|
||||
if err := store.RecordActionLifecycleEvent(event); err != nil {
|
||||
return unified.ActionAuditRecord{}, err
|
||||
}
|
||||
return failed, nil
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) publishActionCompleted(record unified.ActionAuditRecord) {
|
||||
if h == nil || h.actionCompleted == nil {
|
||||
return
|
||||
}
|
||||
if record.State != unified.ActionStateCompleted && record.State != unified.ActionStateFailed {
|
||||
return
|
||||
}
|
||||
h.actionCompleted(record)
|
||||
}
|
||||
|
||||
func actionDecisionActor(h *ResourceHandlers, r *http.Request) string {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user