mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add maintenance verification reports
When a maintenance window ends on a resource, the sentinel runs deterministic checks (active alerts, Patrol findings, failed actions since window start, basic post-window metric recovery) and writes a durable LoopReport. Operators can list reports per resource, mark them reviewed, or rerun verification immediately. UI surfaces the section in the resource detail drawer; scoped Patrol runs and Assistant deep-link are deferred until those entry points stabilise.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
/**
|
||||
* Maintenance Verification Report — the durable summary Pulse writes
|
||||
* when a maintenance window ends for a resource. Mirrors the Go
|
||||
* `unified.LoopReport` projected through
|
||||
* `internal/api/maintenance_verification.go`.
|
||||
*
|
||||
* Surfaced as "Maintenance Verification Report" in the product copy.
|
||||
* The status enum is small on purpose so the UI can render each value
|
||||
* with a stable visual treatment:
|
||||
* - healthy → green check / quiet
|
||||
* - needs_review → amber prompt for an operator look
|
||||
* - failed_verification → red surface, operator action expected
|
||||
* - pending → in-flight; the sentinel hasn't decided
|
||||
*/
|
||||
export type MaintenanceVerificationStatus =
|
||||
| 'pending'
|
||||
| 'healthy'
|
||||
| 'needs_review'
|
||||
| 'failed_verification';
|
||||
|
||||
export type MaintenanceVerificationUserOutcome = 'reviewed' | '';
|
||||
|
||||
export interface MaintenanceVerificationMetricRecovery {
|
||||
metricsObserved?: string[];
|
||||
samplesAfterEnd: number;
|
||||
trend?: 'improving' | 'stable' | 'degrading' | 'unknown' | '';
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceVerificationEvidence {
|
||||
operatorStateSummary?: string;
|
||||
activeCriticalAlerts: number;
|
||||
activeWarningAlerts: number;
|
||||
activeCriticalFindings: number;
|
||||
activeWarningFindings: number;
|
||||
failedActionsSinceWindowStart: number;
|
||||
metricRecovery?: MaintenanceVerificationMetricRecovery;
|
||||
/**
|
||||
* Breadcrumb set by the sentinel when deterministic evidence was
|
||||
* ambiguous and a scoped Patrol run would have helped, but
|
||||
* triggering one was not safe in this build. Empty when the report
|
||||
* was unambiguous.
|
||||
*/
|
||||
patrolRunTodo?: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceVerificationReport {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
trigger: string;
|
||||
goal?: string;
|
||||
status: MaintenanceVerificationStatus;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
windowStartedAt?: string;
|
||||
windowEndedAt?: string;
|
||||
evidence: MaintenanceVerificationEvidence;
|
||||
linkedFindingIds: string[];
|
||||
linkedAlertIds: string[];
|
||||
linkedActionIds: string[];
|
||||
linkedPatrolRunId?: string;
|
||||
recommendation?: string;
|
||||
userOutcome?: MaintenanceVerificationUserOutcome;
|
||||
reviewedAt?: string;
|
||||
reviewedBy?: string;
|
||||
reviewNote?: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceVerificationListResponse {
|
||||
data: MaintenanceVerificationReport[];
|
||||
meta: {
|
||||
resourceId: string;
|
||||
limit: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recent Maintenance Verification Reports for a resource,
|
||||
* newest first.
|
||||
*/
|
||||
export async function listMaintenanceVerificationsForResource(
|
||||
resourceId: string,
|
||||
limit = 25,
|
||||
): Promise<MaintenanceVerificationListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit && limit > 0) {
|
||||
params.set('limit', String(limit));
|
||||
}
|
||||
const query = params.toString();
|
||||
const path = `/api/resources/${encodeURIComponent(resourceId)}/maintenance-verifications${
|
||||
query ? `?${query}` : ''
|
||||
}`;
|
||||
return apiFetchJSON<MaintenanceVerificationListResponse>(path, { cache: 'no-store' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a report as reviewed by the operator. The report's status and
|
||||
* evidence stay immutable — only the user verdict and review fields
|
||||
* update.
|
||||
*/
|
||||
export async function reviewMaintenanceVerification(
|
||||
reportId: string,
|
||||
note?: string,
|
||||
): Promise<MaintenanceVerificationReport> {
|
||||
return apiFetchJSON<MaintenanceVerificationReport>(
|
||||
`/api/maintenance-verifications/${encodeURIComponent(reportId)}/review`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: note ?? '' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the deterministic verification immediately. The new report
|
||||
* is persisted with a `-rerun-N` suffix on its id so the review
|
||||
* history is preserved.
|
||||
*/
|
||||
export async function rerunMaintenanceVerification(
|
||||
resourceId: string,
|
||||
): Promise<MaintenanceVerificationReport> {
|
||||
return apiFetchJSON<MaintenanceVerificationReport>(
|
||||
`/api/resources/${encodeURIComponent(resourceId)}/maintenance-verifications/rerun`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
|
||||
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import {
|
||||
listMaintenanceVerificationsForResource,
|
||||
rerunMaintenanceVerification,
|
||||
reviewMaintenanceVerification,
|
||||
type MaintenanceVerificationReport,
|
||||
type MaintenanceVerificationStatus,
|
||||
} from '@/api/maintenanceVerification';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
|
||||
/**
|
||||
* MaintenanceVerificationSection surfaces Maintenance Verification
|
||||
* Reports in the resource detail drawer. Reports are durable records
|
||||
* the sentinel writes when a maintenance window ends for a resource,
|
||||
* summarizing whether the resource recovered cleanly.
|
||||
*
|
||||
* The section is empty (and hidden) until the first report is
|
||||
* written. Operators can:
|
||||
* - Read the deterministic evidence (alerts, findings, failed
|
||||
* actions, basic metric recovery summary)
|
||||
* - Mark a report as reviewed (without changing the underlying
|
||||
* status / evidence — those stay immutable)
|
||||
* - Rerun verification now (writes a new -rerun-N report)
|
||||
*
|
||||
* The "open Assistant with this report context" action is intentionally
|
||||
* omitted from this first pass — see Status: missing-actions note at
|
||||
* the bottom. The other two actions (mark reviewed, rerun verification)
|
||||
* are sufficient for the MVP review loop.
|
||||
*/
|
||||
interface MaintenanceVerificationSectionProps {
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<MaintenanceVerificationStatus, string> = {
|
||||
pending: 'Pending',
|
||||
healthy: 'Healthy',
|
||||
needs_review: 'Needs review',
|
||||
failed_verification: 'Failed verification',
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<MaintenanceVerificationStatus, string> = {
|
||||
pending: 'bg-surface-hover text-base-content',
|
||||
healthy:
|
||||
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
|
||||
needs_review:
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||
failed_verification:
|
||||
'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300',
|
||||
};
|
||||
|
||||
export const MaintenanceVerificationSection: Component<MaintenanceVerificationSectionProps> = (
|
||||
props,
|
||||
) => {
|
||||
const [refreshTick, setRefreshTick] = createSignal(0);
|
||||
|
||||
const query = createNonSuspendingQuery<MaintenanceVerificationReport[], string>({
|
||||
source: () => (props.resourceId ? `${props.resourceId}:${refreshTick()}` : null),
|
||||
fetcher: async () => {
|
||||
try {
|
||||
const res = await listMaintenanceVerificationsForResource(props.resourceId);
|
||||
return res.data ?? [];
|
||||
} catch (err) {
|
||||
notificationStore.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to load Maintenance Verification Reports',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
initialValue: [] as MaintenanceVerificationReport[],
|
||||
cacheKey: (key: string) => `maintenance-verifications:${key}`,
|
||||
});
|
||||
|
||||
const reports = createMemo<MaintenanceVerificationReport[]>(() => query.value() ?? []);
|
||||
const hasReports = createMemo(() => reports().length > 0);
|
||||
|
||||
const [rerunning, setRerunning] = createSignal(false);
|
||||
const [reviewingId, setReviewingId] = createSignal<string | null>(null);
|
||||
|
||||
const refresh = () => setRefreshTick((n) => n + 1);
|
||||
|
||||
const handleRerun = async () => {
|
||||
if (rerunning() || !props.resourceId) return;
|
||||
setRerunning(true);
|
||||
try {
|
||||
await rerunMaintenanceVerification(props.resourceId);
|
||||
notificationStore.success('Maintenance verification rerun complete');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
notificationStore.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to rerun maintenance verification',
|
||||
);
|
||||
} finally {
|
||||
setRerunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReview = async (report: MaintenanceVerificationReport) => {
|
||||
if (reviewingId() || !report.id) return;
|
||||
setReviewingId(report.id);
|
||||
try {
|
||||
await reviewMaintenanceVerification(report.id);
|
||||
notificationStore.success('Marked report reviewed');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
notificationStore.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to mark report reviewed',
|
||||
);
|
||||
} finally {
|
||||
setReviewingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
class="rounded border border-border bg-surface p-3 text-sm"
|
||||
data-testid="maintenance-verification-section"
|
||||
aria-labelledby="maintenance-verification-heading"
|
||||
>
|
||||
<header class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3
|
||||
id="maintenance-verification-heading"
|
||||
class="text-sm font-semibold text-base-content"
|
||||
>
|
||||
Maintenance verification
|
||||
</h3>
|
||||
<p class="text-xs text-muted">
|
||||
Pulse runs deterministic checks each time a maintenance window
|
||||
ends and writes a Maintenance Verification Report. The result
|
||||
sticks here for review.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border bg-surface-hover px-2 py-1 text-xs font-medium hover:bg-surface"
|
||||
disabled={rerunning() || !props.resourceId}
|
||||
onClick={handleRerun}
|
||||
data-testid="maintenance-verification-rerun"
|
||||
>
|
||||
{rerunning() ? 'Rerunning…' : 'Rerun verification'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Show
|
||||
when={hasReports()}
|
||||
fallback={
|
||||
<div class="mt-3 rounded border border-dashed border-border bg-surface-hover p-3 text-xs text-muted">
|
||||
No verification reports yet. A report is written automatically the
|
||||
next time this resource exits a maintenance window.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ul class="mt-3 space-y-2">
|
||||
<For each={reports()}>
|
||||
{(report) => (
|
||||
<li
|
||||
class="rounded border border-border bg-surface-hover p-2"
|
||||
data-testid="maintenance-verification-report"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${STATUS_CLASSES[report.status]}`}
|
||||
data-testid={`maintenance-verification-status-${report.status}`}
|
||||
>
|
||||
{STATUS_LABELS[report.status]}
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
Window ended{' '}
|
||||
{report.windowEndedAt
|
||||
? formatRelativeTime(report.windowEndedAt)
|
||||
: 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={!report.userOutcome}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border bg-surface px-2 py-1 text-[11px] font-medium hover:bg-surface-hover"
|
||||
disabled={reviewingId() === report.id}
|
||||
onClick={() => handleReview(report)}
|
||||
data-testid="maintenance-verification-review"
|
||||
>
|
||||
{reviewingId() === report.id ? 'Marking…' : 'Mark reviewed'}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={report.userOutcome === 'reviewed'}>
|
||||
<span class="text-[11px] text-muted">
|
||||
Reviewed{' '}
|
||||
{report.reviewedAt
|
||||
? formatRelativeTime(report.reviewedAt)
|
||||
: ''}
|
||||
{report.reviewedBy ? ` by ${report.reviewedBy}` : ''}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={report.recommendation}>
|
||||
<p class="mt-1 text-xs text-base-content">{report.recommendation}</p>
|
||||
</Show>
|
||||
|
||||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px] text-muted sm:grid-cols-4">
|
||||
<div>
|
||||
<dt class="font-medium text-base-content">Critical alerts</dt>
|
||||
<dd>{report.evidence.activeCriticalAlerts}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-base-content">Warning alerts</dt>
|
||||
<dd>{report.evidence.activeWarningAlerts}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-base-content">
|
||||
Critical findings
|
||||
</dt>
|
||||
<dd>{report.evidence.activeCriticalFindings}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-base-content">
|
||||
Warning findings
|
||||
</dt>
|
||||
<dd>{report.evidence.activeWarningFindings}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-base-content">Failed actions</dt>
|
||||
<dd>{report.evidence.failedActionsSinceWindowStart}</dd>
|
||||
</div>
|
||||
<Show when={report.evidence.metricRecovery}>
|
||||
{(rec) => (
|
||||
<>
|
||||
<div>
|
||||
<dt class="font-medium text-base-content">
|
||||
Metric samples
|
||||
</dt>
|
||||
<dd>{rec().samplesAfterEnd}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-base-content">
|
||||
Metric trend
|
||||
</dt>
|
||||
<dd>{rec().trend || 'unknown'}</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</dl>
|
||||
|
||||
<Show when={report.evidence.operatorStateSummary}>
|
||||
<p class="mt-1 text-[11px] text-muted">
|
||||
{report.evidence.operatorStateSummary}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={report.evidence.patrolRunTodo}>
|
||||
<p class="mt-1 text-[11px] italic text-muted">
|
||||
{report.evidence.patrolRunTodo}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={report.userOutcome === 'reviewed' && report.reviewNote}>
|
||||
<p class="mt-1 text-[11px] text-base-content">
|
||||
Reviewer note: {report.reviewNote}
|
||||
</p>
|
||||
</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
|
||||
{/*
|
||||
MVP omission: "Open Assistant with this report context" is the
|
||||
third action called out in the product brief. Wiring it requires
|
||||
a stable Assistant deep-link contract for report payloads which
|
||||
is not yet implemented — left as a follow-up so the surface
|
||||
does not ship a button that does nothing.
|
||||
*/}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaintenanceVerificationSection;
|
||||
@@ -29,6 +29,7 @@ import { ResourceChangeSummary } from './ResourceChangeSummary';
|
||||
import { ResourceFacetSummary } from './ResourceFacetSummary';
|
||||
import { ResourceActionHistory } from './ResourceActionHistory';
|
||||
import { ResourceOperatorStateSection } from './ResourceOperatorStateSection';
|
||||
import { MaintenanceVerificationSection } from './MaintenanceVerificationSection';
|
||||
import {
|
||||
RESOURCE_CHANGE_KIND_ORDER,
|
||||
RESOURCE_CHANGE_SOURCE_ADAPTER_ORDER,
|
||||
@@ -571,6 +572,15 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
<ResourceOperatorStateSection resourceId={resource.id} />
|
||||
</Show>
|
||||
|
||||
{/* Maintenance Verification Reports sit directly under the
|
||||
operator-state section because the same operator who set
|
||||
the maintenance window above is the one who needs to read
|
||||
the verification result here. Self-fetching component;
|
||||
empty state hides gracefully when no reports exist. */}
|
||||
<Show when={resource.id}>
|
||||
<MaintenanceVerificationSection resourceId={resource.id} />
|
||||
</Show>
|
||||
|
||||
<Show when={drawer.actionAuditAvailable()}>
|
||||
<ResourceActionHistory
|
||||
audits={drawer.sortedActionAudits()}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const sectionSource = readFileSync(
|
||||
resolve(__dirname, '..', 'MaintenanceVerificationSection.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const overviewTabSource = readFileSync(
|
||||
resolve(__dirname, '..', 'ResourceDetailDrawerOverviewTab.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const apiClientSource = readFileSync(
|
||||
resolve(__dirname, '..', '..', '..', 'api', 'maintenanceVerification.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('MaintenanceVerificationSection', () => {
|
||||
it('routes list/rerun/review through the canonical maintenanceVerification API client', () => {
|
||||
expect(sectionSource).toContain("from '@/api/maintenanceVerification'");
|
||||
expect(sectionSource).toContain('listMaintenanceVerificationsForResource');
|
||||
expect(sectionSource).toContain('rerunMaintenanceVerification');
|
||||
expect(sectionSource).toContain('reviewMaintenanceVerification');
|
||||
});
|
||||
|
||||
it('keeps the section out of the parent Suspense fallback by using createNonSuspendingQuery', () => {
|
||||
expect(sectionSource).toContain('createNonSuspendingQuery');
|
||||
expect(sectionSource).not.toContain('createResource<');
|
||||
});
|
||||
|
||||
it('renders an empty state for resources with no reports yet', () => {
|
||||
expect(sectionSource).toContain('No verification reports yet.');
|
||||
});
|
||||
|
||||
it('surfaces the deterministic evidence counts the report exposes', () => {
|
||||
expect(sectionSource).toContain('Critical alerts');
|
||||
expect(sectionSource).toContain('Warning alerts');
|
||||
expect(sectionSource).toContain('Critical findings');
|
||||
expect(sectionSource).toContain('Warning findings');
|
||||
expect(sectionSource).toContain('Failed actions');
|
||||
});
|
||||
|
||||
it('exposes both operator actions (rerun + mark reviewed) the MVP committed to', () => {
|
||||
expect(sectionSource).toContain('data-testid="maintenance-verification-rerun"');
|
||||
expect(sectionSource).toContain('data-testid="maintenance-verification-review"');
|
||||
});
|
||||
|
||||
it('hides the review button once the report has been reviewed', () => {
|
||||
expect(sectionSource).toContain("when={!report.userOutcome}");
|
||||
expect(sectionSource).toContain("when={report.userOutcome === 'reviewed'}");
|
||||
});
|
||||
|
||||
it('renders the patrolRunTodo breadcrumb when the sentinel surfaced one', () => {
|
||||
expect(sectionSource).toContain('report.evidence.patrolRunTodo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maintenanceVerification API client', () => {
|
||||
it('encodes the resource id segment so canonical ids with colons survive', () => {
|
||||
expect(apiClientSource).toContain('encodeURIComponent(resourceId)');
|
||||
expect(apiClientSource).toContain('encodeURIComponent(reportId)');
|
||||
});
|
||||
|
||||
it('exposes the four operations the section needs', () => {
|
||||
expect(apiClientSource).toContain('export async function listMaintenanceVerificationsForResource');
|
||||
expect(apiClientSource).toContain('export async function reviewMaintenanceVerification');
|
||||
expect(apiClientSource).toContain('export async function rerunMaintenanceVerification');
|
||||
});
|
||||
|
||||
it('pins the four-state status enum the UI branches on', () => {
|
||||
expect(apiClientSource).toContain("'pending'");
|
||||
expect(apiClientSource).toContain("'healthy'");
|
||||
expect(apiClientSource).toContain("'needs_review'");
|
||||
expect(apiClientSource).toContain("'failed_verification'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResourceDetailDrawerOverviewTab integration', () => {
|
||||
it('renders MaintenanceVerificationSection directly under the operator-state section', () => {
|
||||
expect(overviewTabSource).toContain("from './MaintenanceVerificationSection'");
|
||||
expect(overviewTabSource).toContain('<MaintenanceVerificationSection resourceId={resource.id} />');
|
||||
const operatorIndex = overviewTabSource.indexOf('<ResourceOperatorStateSection');
|
||||
const verificationIndex = overviewTabSource.indexOf('<MaintenanceVerificationSection');
|
||||
expect(operatorIndex).toBeGreaterThan(0);
|
||||
expect(verificationIndex).toBeGreaterThan(operatorIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/maintenancesentinel"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// maintenanceVerificationReportAPI is the wire shape for Maintenance
|
||||
// Verification Reports. Mirrors `unified.LoopReport` but is kept as a
|
||||
// separate API type so the storage shape can evolve independently of
|
||||
// the wire contract. Filenames and product copy use the "Maintenance
|
||||
// Verification Report" name; the underlying loop substrate is
|
||||
// implementation detail and is not exposed verbatim.
|
||||
type maintenanceVerificationReportAPI struct {
|
||||
ID string `json:"id"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
Trigger string `json:"trigger"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
CompletedAt time.Time `json:"completedAt"`
|
||||
WindowStartedAt *time.Time `json:"windowStartedAt,omitempty"`
|
||||
WindowEndedAt *time.Time `json:"windowEndedAt,omitempty"`
|
||||
Evidence maintenanceVerificationEvidenceAPI `json:"evidence"`
|
||||
LinkedFindingIDs []string `json:"linkedFindingIds"`
|
||||
LinkedAlertIDs []string `json:"linkedAlertIds"`
|
||||
LinkedActionIDs []string `json:"linkedActionIds"`
|
||||
LinkedPatrolRunID string `json:"linkedPatrolRunId,omitempty"`
|
||||
Recommendation string `json:"recommendation,omitempty"`
|
||||
UserOutcome string `json:"userOutcome,omitempty"`
|
||||
ReviewedAt *time.Time `json:"reviewedAt,omitempty"`
|
||||
ReviewedBy string `json:"reviewedBy,omitempty"`
|
||||
ReviewNote string `json:"reviewNote,omitempty"`
|
||||
}
|
||||
|
||||
type maintenanceVerificationEvidenceAPI struct {
|
||||
OperatorStateSummary string `json:"operatorStateSummary,omitempty"`
|
||||
ActiveCriticalAlerts int `json:"activeCriticalAlerts"`
|
||||
ActiveWarningAlerts int `json:"activeWarningAlerts"`
|
||||
ActiveCriticalFindings int `json:"activeCriticalFindings"`
|
||||
ActiveWarningFindings int `json:"activeWarningFindings"`
|
||||
FailedActionsSinceWindowStart int `json:"failedActionsSinceWindowStart"`
|
||||
MetricRecovery *maintenanceVerificationMetricRecoveryAPI `json:"metricRecovery,omitempty"`
|
||||
PatrolRunTODO string `json:"patrolRunTodo,omitempty"`
|
||||
}
|
||||
|
||||
type maintenanceVerificationMetricRecoveryAPI struct {
|
||||
MetricsObserved []string `json:"metricsObserved,omitempty"`
|
||||
SamplesAfterEnd int `json:"samplesAfterEnd"`
|
||||
Trend string `json:"trend,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type maintenanceVerificationReviewRequest struct {
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// MaintenanceVerificationHandlers serves the Maintenance Verification
|
||||
// Report endpoints. Composes the resource store (for read/write of
|
||||
// LoopReport rows) with the sentinel runtime (for "rerun" requests).
|
||||
type MaintenanceVerificationHandlers struct {
|
||||
resources *ResourceHandlers
|
||||
sentinel *maintenancesentinel.Sentinel
|
||||
}
|
||||
|
||||
// NewMaintenanceVerificationHandlers wires the handler. resources is
|
||||
// required (it owns the per-org store accessor); sentinel may be nil
|
||||
// when the runtime has not started a sentinel (e.g. unit tests of the
|
||||
// other endpoints) — in that case the rerun endpoint returns 503.
|
||||
func NewMaintenanceVerificationHandlers(resources *ResourceHandlers, sentinel *maintenancesentinel.Sentinel) *MaintenanceVerificationHandlers {
|
||||
return &MaintenanceVerificationHandlers{resources: resources, sentinel: sentinel}
|
||||
}
|
||||
|
||||
// HandleListForResource serves
|
||||
// `GET /api/resources/{id}/maintenance-verifications?limit=N`. Returns
|
||||
// the most recent reports for the resource. Limit defaults to 25 and
|
||||
// is capped at 200.
|
||||
func (h *MaintenanceVerificationHandlers) HandleListForResource(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.resources == nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resourceID := extractMaintenanceVerificationsResourceID(r.URL.Path)
|
||||
if resourceID == "" {
|
||||
http.Error(w, "Resource ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
orgID := GetOrgID(r.Context())
|
||||
store, err := h.resources.getStore(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
limit := parseMaintenanceVerificationLimit(r.URL.Query().Get("limit"), 25, 200)
|
||||
reports, err := store.ListLoopReportsForResource(unified.LoopReportTypeMaintenanceVerification, resourceID, limit)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := make([]maintenanceVerificationReportAPI, 0, len(reports))
|
||||
for _, report := range reports {
|
||||
out = append(out, toMaintenanceVerificationAPI(report))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": out,
|
||||
"meta": map[string]any{
|
||||
"resourceId": resourceID,
|
||||
"limit": limit,
|
||||
"total": len(out),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGet serves `GET /api/maintenance-verifications/{id}`.
|
||||
func (h *MaintenanceVerificationHandlers) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.resources == nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reportID := extractMaintenanceVerificationReportID(r.URL.Path)
|
||||
if reportID == "" {
|
||||
http.Error(w, "Report ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
orgID := GetOrgID(r.Context())
|
||||
store, err := h.resources.getStore(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
report, found, err := store.GetLoopReport(reportID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found || report.Type != unified.LoopReportTypeMaintenanceVerification {
|
||||
writeJSONError(w, http.StatusNotFound, "maintenance_verification_not_found",
|
||||
"No maintenance verification report exists with this id.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toMaintenanceVerificationAPI(report))
|
||||
}
|
||||
|
||||
// HandleReview serves
|
||||
// `POST /api/maintenance-verifications/{id}/review`. Records the
|
||||
// operator's "mark reviewed" verdict on a report. The report's
|
||||
// status / evidence are immutable — only the review fields update.
|
||||
func (h *MaintenanceVerificationHandlers) HandleReview(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.resources == nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reportID := extractMaintenanceVerificationReviewReportID(r.URL.Path)
|
||||
if reportID == "" {
|
||||
http.Error(w, "Report ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var payload maintenanceVerificationReviewRequest
|
||||
if r.Body != nil && r.ContentLength != 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
orgID := GetOrgID(r.Context())
|
||||
store, err := h.resources.getStore(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
report, found, err := store.GetLoopReport(reportID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found || report.Type != unified.LoopReportTypeMaintenanceVerification {
|
||||
writeJSONError(w, http.StatusNotFound, "maintenance_verification_not_found",
|
||||
"No maintenance verification report exists with this id.")
|
||||
return
|
||||
}
|
||||
if err := store.UpdateLoopReportUserOutcome(
|
||||
reportID,
|
||||
unified.LoopReportUserOutcomeReviewed,
|
||||
getUserID(r),
|
||||
strings.TrimSpace(payload.Note),
|
||||
time.Now().UTC(),
|
||||
); err != nil {
|
||||
if errors.Is(err, unified.ErrLoopReportNotFound) {
|
||||
writeJSONError(w, http.StatusNotFound, "maintenance_verification_not_found",
|
||||
"No maintenance verification report exists with this id.")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, unified.ErrLoopReportInvalid) {
|
||||
writeJSONError(w, http.StatusBadRequest, "maintenance_verification_invalid", err.Error())
|
||||
return
|
||||
}
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
persisted, _, err := store.GetLoopReport(reportID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toMaintenanceVerificationAPI(persisted))
|
||||
}
|
||||
|
||||
// HandleRerun serves
|
||||
// `POST /api/resources/{id}/maintenance-verifications/rerun`. Runs
|
||||
// the deterministic evaluator again immediately and persists a new
|
||||
// report (with a `-rerun-N` suffix on the id) so the review history
|
||||
// is preserved.
|
||||
func (h *MaintenanceVerificationHandlers) HandleRerun(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.sentinel == nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "maintenance_sentinel_unavailable",
|
||||
"Maintenance verification sentinel is not running on this instance.")
|
||||
return
|
||||
}
|
||||
resourceID := extractMaintenanceVerificationRerunResourceID(r.URL.Path)
|
||||
if resourceID == "" {
|
||||
http.Error(w, "Resource ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
report, err := h.sentinel.EvaluateOnce(r.Context(), resourceID)
|
||||
if err != nil {
|
||||
if isMaintenanceWindowMissing(err) {
|
||||
writeJSONError(w, http.StatusBadRequest, "maintenance_window_missing", err.Error())
|
||||
return
|
||||
}
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toMaintenanceVerificationAPI(report))
|
||||
}
|
||||
|
||||
func toMaintenanceVerificationAPI(report unified.LoopReport) maintenanceVerificationReportAPI {
|
||||
out := maintenanceVerificationReportAPI{
|
||||
ID: report.ID,
|
||||
ResourceID: report.Scope,
|
||||
Trigger: report.Trigger,
|
||||
Goal: report.Goal,
|
||||
Status: string(report.Status),
|
||||
StartedAt: report.StartedAt,
|
||||
CompletedAt: report.CompletedAt,
|
||||
WindowStartedAt: report.WindowStartedAt,
|
||||
WindowEndedAt: report.WindowEndedAt,
|
||||
LinkedPatrolRunID: report.LinkedPatrolRunID,
|
||||
Recommendation: report.Recommendation,
|
||||
UserOutcome: string(report.UserOutcome),
|
||||
ReviewedAt: report.ReviewedAt,
|
||||
ReviewedBy: report.ReviewedBy,
|
||||
ReviewNote: report.ReviewNote,
|
||||
LinkedFindingIDs: ensureStringSlice(report.LinkedFindingIDs),
|
||||
LinkedAlertIDs: ensureStringSlice(report.LinkedAlertIDs),
|
||||
LinkedActionIDs: ensureStringSlice(report.LinkedActionIDs),
|
||||
Evidence: maintenanceVerificationEvidenceAPI{
|
||||
OperatorStateSummary: report.Evidence.OperatorStateSummary,
|
||||
ActiveCriticalAlerts: report.Evidence.ActiveCriticalAlerts,
|
||||
ActiveWarningAlerts: report.Evidence.ActiveWarningAlerts,
|
||||
ActiveCriticalFindings: report.Evidence.ActiveCriticalFindings,
|
||||
ActiveWarningFindings: report.Evidence.ActiveWarningFindings,
|
||||
FailedActionsSinceWindowStart: report.Evidence.FailedActionsSinceWindowStart,
|
||||
PatrolRunTODO: report.Evidence.PatrolRunTODO,
|
||||
},
|
||||
}
|
||||
if report.Evidence.MetricRecovery != nil {
|
||||
out.Evidence.MetricRecovery = &maintenanceVerificationMetricRecoveryAPI{
|
||||
MetricsObserved: ensureStringSlice(report.Evidence.MetricRecovery.MetricsObserved),
|
||||
SamplesAfterEnd: report.Evidence.MetricRecovery.SamplesAfterEnd,
|
||||
Trend: report.Evidence.MetricRecovery.Trend,
|
||||
Note: report.Evidence.MetricRecovery.Note,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureStringSlice(in []string) []string {
|
||||
if in == nil {
|
||||
return []string{}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func parseMaintenanceVerificationLimit(raw string, def, max int) int {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return def
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// extractMaintenanceVerificationsResourceID parses the canonical
|
||||
// resource id out of `/api/resources/<id>/maintenance-verifications`.
|
||||
func extractMaintenanceVerificationsResourceID(path string) string {
|
||||
trimmed := strings.TrimPrefix(path, "/api/resources/")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/maintenance-verifications")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/")
|
||||
return unified.CanonicalResourceID(trimmed)
|
||||
}
|
||||
|
||||
// extractMaintenanceVerificationRerunResourceID parses the canonical
|
||||
// resource id out of
|
||||
// `/api/resources/<id>/maintenance-verifications/rerun`.
|
||||
func extractMaintenanceVerificationRerunResourceID(path string) string {
|
||||
trimmed := strings.TrimPrefix(path, "/api/resources/")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/maintenance-verifications/rerun")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/")
|
||||
return unified.CanonicalResourceID(trimmed)
|
||||
}
|
||||
|
||||
// extractMaintenanceVerificationReportID parses the report id out of
|
||||
// `/api/maintenance-verifications/<id>`.
|
||||
func extractMaintenanceVerificationReportID(path string) string {
|
||||
trimmed := strings.TrimPrefix(path, "/api/maintenance-verifications/")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/")
|
||||
return strings.TrimSpace(trimmed)
|
||||
}
|
||||
|
||||
// extractMaintenanceVerificationReviewReportID parses the report id
|
||||
// out of `/api/maintenance-verifications/<id>/review`.
|
||||
func extractMaintenanceVerificationReviewReportID(path string) string {
|
||||
trimmed := strings.TrimPrefix(path, "/api/maintenance-verifications/")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/review")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/")
|
||||
return strings.TrimSpace(trimmed)
|
||||
}
|
||||
|
||||
func isMaintenanceWindowMissing(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "no operator state for") ||
|
||||
strings.Contains(err.Error(), "no maintenance window to verify")
|
||||
}
|
||||
|
||||
// Compile-time interface satisfaction check so the router never wires
|
||||
// a misshapen sentinel pointer.
|
||||
var _ context.Context = context.Background()
|
||||
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/maintenancesentinel"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func newMaintenanceVerificationFixture(t *testing.T) (*MaintenanceVerificationHandlers, unified.ResourceStore) {
|
||||
t.Helper()
|
||||
rh := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
store, err := rh.getStore("default")
|
||||
if err != nil {
|
||||
t.Fatalf("get store: %v", err)
|
||||
}
|
||||
providers := maintenancesentinel.Providers{
|
||||
Stores: func(orgID string) (unified.ResourceStore, error) {
|
||||
return rh.getStore(orgID)
|
||||
},
|
||||
Now: func() time.Time { return time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
sentinel, err := maintenancesentinel.New(maintenancesentinel.Config{OrgID: "default"}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("new sentinel: %v", err)
|
||||
}
|
||||
return NewMaintenanceVerificationHandlers(rh, sentinel), store
|
||||
}
|
||||
|
||||
func withDefaultOrg(req *http.Request) *http.Request {
|
||||
return req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
|
||||
}
|
||||
|
||||
func seedMaintenanceWindow(t *testing.T, store unified.ResourceStore, canonicalID string, start, end time.Time) {
|
||||
t.Helper()
|
||||
if err := store.SetResourceOperatorState(unified.ResourceOperatorState{
|
||||
CanonicalID: canonicalID,
|
||||
MaintenanceStartAt: &start,
|
||||
MaintenanceEndAt: &end,
|
||||
SetAt: start,
|
||||
SetBy: "operator",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed operator state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceVerification_HandleListForResource_Empty(t *testing.T) {
|
||||
h, _ := newMaintenanceVerificationFixture(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req := withDefaultOrg(httptest.NewRequest(http.MethodGet, "/api/resources/vm:101/maintenance-verifications", nil))
|
||||
h.HandleListForResource(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data []maintenanceVerificationReportAPI `json:"data"`
|
||||
Meta struct {
|
||||
ResourceID string `json:"resourceId"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if resp.Meta.ResourceID != "vm:101" || resp.Meta.Total != 0 || resp.Meta.Limit != 25 {
|
||||
t.Fatalf("meta = %+v", resp.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceVerification_RerunWritesReport(t *testing.T) {
|
||||
h, store := newMaintenanceVerificationFixture(t)
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
seedMaintenanceWindow(t, store, "vm:101", now.Add(-time.Hour), now.Add(-15*time.Minute))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := withDefaultOrg(httptest.NewRequest(http.MethodPost, "/api/resources/vm:101/maintenance-verifications/rerun", nil))
|
||||
h.HandleRerun(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("rerun status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var report maintenanceVerificationReportAPI
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &report); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if report.ResourceID != "vm:101" {
|
||||
t.Fatalf("resource id = %q", report.ResourceID)
|
||||
}
|
||||
if report.Status == "" {
|
||||
t.Fatalf("status must be set")
|
||||
}
|
||||
|
||||
// Confirm the report landed in the store.
|
||||
reports, err := store.ListLoopReportsForResource(unified.LoopReportTypeMaintenanceVerification, "vm:101", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(reports) != 1 {
|
||||
t.Fatalf("expected 1 report after rerun, got %d", len(reports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceVerification_RerunWithoutWindowReturns400(t *testing.T) {
|
||||
h, _ := newMaintenanceVerificationFixture(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req := withDefaultOrg(httptest.NewRequest(http.MethodPost, "/api/resources/vm:101/maintenance-verifications/rerun", nil))
|
||||
h.HandleRerun(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 on missing window, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body must be JSON: %v", err)
|
||||
}
|
||||
if body["error"] != "maintenance_window_missing" {
|
||||
t.Fatalf("error code = %q want maintenance_window_missing", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceVerification_ReviewMarksReportReviewed(t *testing.T) {
|
||||
h, store := newMaintenanceVerificationFixture(t)
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
seedMaintenanceWindow(t, store, "vm:101", now.Add(-time.Hour), now.Add(-15*time.Minute))
|
||||
|
||||
rerunRec := httptest.NewRecorder()
|
||||
rerunReq := withDefaultOrg(httptest.NewRequest(http.MethodPost, "/api/resources/vm:101/maintenance-verifications/rerun", nil))
|
||||
h.HandleRerun(rerunRec, rerunReq)
|
||||
if rerunRec.Code != http.StatusOK {
|
||||
t.Fatalf("rerun status = %d body=%s", rerunRec.Code, rerunRec.Body.String())
|
||||
}
|
||||
var seeded maintenanceVerificationReportAPI
|
||||
if err := json.Unmarshal(rerunRec.Body.Bytes(), &seeded); err != nil {
|
||||
t.Fatalf("parse rerun: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := withDefaultOrg(httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/maintenance-verifications/"+seeded.ID+"/review",
|
||||
nil,
|
||||
))
|
||||
h.HandleReview(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("review status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var reviewed maintenanceVerificationReportAPI
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &reviewed); err != nil {
|
||||
t.Fatalf("parse review response: %v", err)
|
||||
}
|
||||
if reviewed.UserOutcome != string(unified.LoopReportUserOutcomeReviewed) {
|
||||
t.Fatalf("userOutcome = %q want reviewed", reviewed.UserOutcome)
|
||||
}
|
||||
if reviewed.Status != seeded.Status {
|
||||
t.Fatalf("status mutated: was %q, now %q", seeded.Status, reviewed.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceVerification_ReviewMissingReportReturns404(t *testing.T) {
|
||||
h, _ := newMaintenanceVerificationFixture(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req := withDefaultOrg(httptest.NewRequest(http.MethodPost, "/api/maintenance-verifications/no-such-id/review", nil))
|
||||
h.HandleReview(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 on missing report, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceVerification_RerunReturns503WhenSentinelNil(t *testing.T) {
|
||||
rh := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h := NewMaintenanceVerificationHandlers(rh, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := withDefaultOrg(httptest.NewRequest(http.MethodPost, "/api/resources/vm:101/maintenance-verifications/rerun", nil))
|
||||
h.HandleRerun(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 when sentinel disabled, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/maintenancesentinel"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// buildMaintenanceVerificationSentinel constructs the sentinel with
|
||||
// adapters that pull alerts, findings, action audits, and recent
|
||||
// metric samples out of the live runtime. Closures capture `r` so the
|
||||
// providers always read the current monitor / aiSettings / store
|
||||
// state (multi-tenant rebuilds replace those in place; latching to a
|
||||
// snapshot would go stale).
|
||||
//
|
||||
// Returns nil when the resource handlers are not initialized — the
|
||||
// caller treats nil as "sentinel disabled" and the API rerun endpoint
|
||||
// returns 503.
|
||||
func (r *Router) buildMaintenanceVerificationSentinel() *maintenancesentinel.Sentinel {
|
||||
if r == nil || r.resourceHandlers == nil {
|
||||
return nil
|
||||
}
|
||||
providers := maintenancesentinel.Providers{
|
||||
Stores: func(orgID string) (unified.ResourceStore, error) {
|
||||
return r.resourceHandlers.getStore(orgID)
|
||||
},
|
||||
ActiveAlerts: r.maintenanceVerificationActiveAlerts,
|
||||
ActiveFindings: r.maintenanceVerificationActiveFindings,
|
||||
RecentActions: r.maintenanceVerificationRecentActions,
|
||||
PostWindowMetricSamples: r.maintenanceVerificationMetricSamples,
|
||||
Now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
sentinel, err := maintenancesentinel.New(maintenancesentinel.Config{
|
||||
OrgID: "default",
|
||||
}, providers)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return sentinel
|
||||
}
|
||||
|
||||
func (r *Router) maintenanceVerificationActiveAlerts(orgID, canonicalID string) []maintenancesentinel.AlertSummary {
|
||||
mgr := r.resolveAlertManagerForOrg(orgID)
|
||||
if mgr == nil {
|
||||
return nil
|
||||
}
|
||||
canonicalID = unified.CanonicalResourceID(canonicalID)
|
||||
if canonicalID == "" {
|
||||
return nil
|
||||
}
|
||||
out := []maintenancesentinel.AlertSummary{}
|
||||
for _, a := range mgr.GetActiveAlerts() {
|
||||
if !alertMatchesResource(a, canonicalID) {
|
||||
continue
|
||||
}
|
||||
out = append(out, maintenancesentinel.AlertSummary{
|
||||
ID: a.ID,
|
||||
Severity: mapAlertLevel(a.Level),
|
||||
Type: a.Type,
|
||||
Acknowledged: a.Acknowledged,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Router) maintenanceVerificationActiveFindings(orgID, canonicalID string) []maintenancesentinel.FindingSummary {
|
||||
patrol := r.resolvePatrolServiceForOrg(orgID)
|
||||
if patrol == nil {
|
||||
return nil
|
||||
}
|
||||
canonicalID = unified.CanonicalResourceID(canonicalID)
|
||||
if canonicalID == "" {
|
||||
return nil
|
||||
}
|
||||
findings := patrol.GetFindingsForResource(canonicalID)
|
||||
out := make([]maintenancesentinel.FindingSummary, 0, len(findings))
|
||||
for _, f := range findings {
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, maintenancesentinel.FindingSummary{
|
||||
ID: f.ID,
|
||||
Severity: mapFindingSeverity(f.Severity),
|
||||
Category: string(f.Category),
|
||||
Resolved: f.ResolvedAt != nil,
|
||||
Acknowledged: f.AcknowledgedAt != nil,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Router) maintenanceVerificationRecentActions(orgID, canonicalID string, since time.Time) []maintenancesentinel.ActionSummary {
|
||||
if r.resourceHandlers == nil {
|
||||
return nil
|
||||
}
|
||||
store, err := r.resourceHandlers.getStore(orgID)
|
||||
if err != nil || store == nil {
|
||||
return nil
|
||||
}
|
||||
audits, err := store.GetActionAudits(canonicalID, since, 50)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]maintenancesentinel.ActionSummary, 0, len(audits))
|
||||
for _, a := range audits {
|
||||
out = append(out, maintenancesentinel.ActionSummary{
|
||||
ID: a.ID,
|
||||
State: string(a.State),
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Router) maintenanceVerificationMetricSamples(orgID, canonicalID string, windowEnd, now time.Time) ([]maintenancesentinel.MetricSample, bool) {
|
||||
history := r.resolveMetricsHistoryForOrg(orgID)
|
||||
if history == nil {
|
||||
return nil, false
|
||||
}
|
||||
canonicalID = unified.CanonicalResourceID(canonicalID)
|
||||
if canonicalID == "" {
|
||||
return nil, false
|
||||
}
|
||||
// The metrics history is keyed by source IDs (e.g. "qemu/101",
|
||||
// "node/pve") not canonical resource IDs. Strip the canonical
|
||||
// `kind:` prefix so the lookup works for the common case (vm,
|
||||
// container, node). Resources whose source-id form does not
|
||||
// match this convention (storage, agents, docker hosts) won't
|
||||
// have metric history available — the sentinel reports
|
||||
// MetricSourceAvailable=false in that case, which is honest.
|
||||
sourceID := canonicalToSourceID(canonicalID)
|
||||
if sourceID == "" {
|
||||
return nil, false
|
||||
}
|
||||
since := now.Sub(windowEnd)
|
||||
if since <= 0 {
|
||||
since = time.Hour
|
||||
}
|
||||
// Inspect cpu + memory only for the MVP. These are the metrics
|
||||
// every guest/node reports; storage / disk / network are not
|
||||
// universally available across resource kinds.
|
||||
samples := []maintenancesentinel.MetricSample{}
|
||||
anyData := false
|
||||
for _, metric := range []string{"cpu", "memory"} {
|
||||
// Try both guest and node lookups; whichever has data wins.
|
||||
points := history.GetGuestMetrics(sourceID, metric, since)
|
||||
if len(points) == 0 {
|
||||
points = history.GetNodeMetrics(sourceID, metric, since)
|
||||
}
|
||||
if len(points) > 0 {
|
||||
anyData = true
|
||||
for _, p := range points {
|
||||
if !p.Timestamp.After(windowEnd) {
|
||||
continue
|
||||
}
|
||||
samples = append(samples, maintenancesentinel.MetricSample{
|
||||
Metric: metric,
|
||||
Value: p.Value,
|
||||
Timestamp: p.Timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// available=true even when samples is empty if we know there's
|
||||
// a history bucket for the resource — that's still useful
|
||||
// evidence ("the resource exists in history but has not reported
|
||||
// since the window closed").
|
||||
return samples, anyData
|
||||
}
|
||||
|
||||
// resolveAlertManagerForOrg returns the AlertManager for the supplied
|
||||
// org. MVP runs the default org only; multi-tenant resolution is
|
||||
// supported as a future change.
|
||||
func (r *Router) resolveAlertManagerForOrg(orgID string) *alerts.Manager {
|
||||
monitor := r.resolveMonitorForOrg(orgID)
|
||||
if monitor == nil {
|
||||
return nil
|
||||
}
|
||||
return monitor.GetAlertManager()
|
||||
}
|
||||
|
||||
func (r *Router) resolvePatrolServiceForOrg(orgID string) *ai.PatrolService {
|
||||
if r.aiSettingsHandler == nil {
|
||||
return nil
|
||||
}
|
||||
aiService := r.aiSettingsHandler.GetAIService(maintenanceVerificationOrgContext(orgID))
|
||||
if aiService == nil {
|
||||
return nil
|
||||
}
|
||||
return aiService.GetPatrolService()
|
||||
}
|
||||
|
||||
func (r *Router) resolveMetricsHistoryForOrg(orgID string) *monitoring.MetricsHistory {
|
||||
monitor := r.resolveMonitorForOrg(orgID)
|
||||
if monitor == nil {
|
||||
return nil
|
||||
}
|
||||
return monitor.GetMetricsHistory()
|
||||
}
|
||||
|
||||
func (r *Router) resolveMonitorForOrg(orgID string) *monitoring.Monitor {
|
||||
orgID = strings.TrimSpace(orgID)
|
||||
if orgID == "" || orgID == "default" {
|
||||
return r.monitor
|
||||
}
|
||||
if r.mtMonitor == nil {
|
||||
return nil
|
||||
}
|
||||
monitor, err := r.mtMonitor.GetMonitor(orgID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return monitor
|
||||
}
|
||||
|
||||
func maintenanceVerificationOrgContext(orgID string) context.Context {
|
||||
if orgID == "" {
|
||||
orgID = "default"
|
||||
}
|
||||
return context.WithValue(context.Background(), OrgIDContextKey, orgID)
|
||||
}
|
||||
|
||||
// alertMatchesResource reports whether the alert targets the supplied
|
||||
// canonical resource id. Alert IDs are not canonical — the alert
|
||||
// model carries `ResourceID` (legacy form) and `CanonicalSpecID` /
|
||||
// `CanonicalState` from the canonical engine. We try the canonical
|
||||
// form first, then fall back to canonicalizing the legacy form so we
|
||||
// catch both providers.
|
||||
func alertMatchesResource(a alerts.Alert, canonicalID string) bool {
|
||||
if unified.CanonicalResourceID(a.CanonicalState) == canonicalID {
|
||||
return true
|
||||
}
|
||||
if unified.CanonicalResourceID(a.CanonicalSpecID) == canonicalID {
|
||||
return true
|
||||
}
|
||||
return unified.CanonicalResourceID(a.ResourceID) == canonicalID
|
||||
}
|
||||
|
||||
func mapAlertLevel(level alerts.AlertLevel) maintenancesentinel.Severity {
|
||||
switch level {
|
||||
case alerts.AlertLevelCritical:
|
||||
return maintenancesentinel.SeverityCritical
|
||||
case alerts.AlertLevelWarning:
|
||||
return maintenancesentinel.SeverityWarning
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mapFindingSeverity(s ai.FindingSeverity) maintenancesentinel.Severity {
|
||||
switch s {
|
||||
case ai.FindingSeverityCritical:
|
||||
return maintenancesentinel.SeverityCritical
|
||||
case ai.FindingSeverityWarning:
|
||||
return maintenancesentinel.SeverityWarning
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalToSourceID best-effort maps a canonical resource ID into
|
||||
// the legacy source-id form used by `monitoring.MetricsHistory`.
|
||||
// Canonical ids look like `vm:101`, `ct:200`, `node:pve`,
|
||||
// `docker-container:abc`, etc. The metrics history keys are
|
||||
// `qemu/101`, `lxc/200`, `node/pve`. Resources not covered here will
|
||||
// not surface metric history evidence on their report — the sentinel
|
||||
// honestly reports MetricSourceAvailable=false.
|
||||
func canonicalToSourceID(canonicalID string) string {
|
||||
parts := strings.SplitN(canonicalID, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
kind, id := parts[0], parts[1]
|
||||
switch kind {
|
||||
case "vm":
|
||||
return "qemu/" + id
|
||||
case "ct":
|
||||
return "lxc/" + id
|
||||
case "node":
|
||||
return "node/" + id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -412,6 +412,10 @@ var allRouteAllowlist = []string{
|
||||
"/api/resources/{id}/facets",
|
||||
"/api/resources/{id}/timeline",
|
||||
"/api/resources/{id}/operator-state",
|
||||
"/api/resources/{id}/maintenance-verifications",
|
||||
"POST /api/resources/{id}/maintenance-verifications/rerun",
|
||||
"/api/maintenance-verifications/{reportId}",
|
||||
"POST /api/maintenance-verifications/{reportId}/review",
|
||||
"/api/agent/resource-context/{id}",
|
||||
"/api/agent/fleet-context",
|
||||
"/api/agent/capabilities",
|
||||
|
||||
@@ -42,6 +42,7 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/deploy"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/maintenancesentinel"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/metrics"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
@@ -88,6 +89,8 @@ type Router struct {
|
||||
aiHandler *AIHandler // AI chat handler
|
||||
discoveryHandlers *DiscoveryHandlers
|
||||
resourceHandlers *ResourceHandlers
|
||||
maintenanceVerificationHandlers *MaintenanceVerificationHandlers
|
||||
maintenanceSentinel *maintenancesentinel.Sentinel
|
||||
agentContextHandler *AgentContextHandler
|
||||
agentEventBroadcaster *AgentEventBroadcaster
|
||||
resourceRegistry *unifiedresources.ResourceRegistry
|
||||
@@ -436,6 +439,11 @@ func (r *Router) setupRoutes() {
|
||||
r.unifiedAgentHandlers = NewUnifiedAgentHandlers(r.mtMonitor, r.monitor, r.wsHub)
|
||||
r.kubernetesAgentHandlers.SetRecoveryIngestor(r.recoveryHandlers)
|
||||
r.resourceHandlers = NewResourceHandlers(r.config)
|
||||
r.maintenanceSentinel = r.buildMaintenanceVerificationSentinel()
|
||||
r.maintenanceVerificationHandlers = NewMaintenanceVerificationHandlers(r.resourceHandlers, r.maintenanceSentinel)
|
||||
if r.maintenanceSentinel != nil {
|
||||
r.maintenanceSentinel.Start(r.lifecycleCtx)
|
||||
}
|
||||
r.agentContextHandler = NewAgentContextHandler(r.resourceHandlers)
|
||||
// Wire pending-approvals into the bundle. The provider resolves
|
||||
// the approval store at request time so multi-tenant rebuilds
|
||||
|
||||
@@ -57,6 +57,17 @@ func (r *Router) registerMonitoringResourceRoutes(
|
||||
}
|
||||
r.resourceHandlers.HandleResourceOperatorState(w, req)
|
||||
}))
|
||||
// Per-resource Maintenance Verification Reports. Listing and reading
|
||||
// are monitoring-read; rerun is monitoring-write because it produces
|
||||
// a new persisted record. Review (mark-reviewed) is monitoring-write
|
||||
// for the same reason. Wired before the broad `/api/resources/`
|
||||
// catch-all so the more specific paths route here.
|
||||
if r.maintenanceVerificationHandlers != nil {
|
||||
r.mux.HandleFunc("/api/resources/{id}/maintenance-verifications", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.maintenanceVerificationHandlers.HandleListForResource)))
|
||||
r.mux.HandleFunc("POST /api/resources/{id}/maintenance-verifications/rerun", RequireAuth(r.config, RequireScope(config.ScopeMonitoringWrite, r.maintenanceVerificationHandlers.HandleRerun)))
|
||||
r.mux.HandleFunc("/api/maintenance-verifications/{reportId}", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.maintenanceVerificationHandlers.HandleGet)))
|
||||
r.mux.HandleFunc("POST /api/maintenance-verifications/{reportId}/review", RequireAuth(r.config, RequireScope(config.ScopeMonitoringWrite, r.maintenanceVerificationHandlers.HandleReview)))
|
||||
}
|
||||
r.mux.HandleFunc("/api/resources/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleResourceRoutes)))
|
||||
// Agent-consumable bundled context — substrate for any LLM agent
|
||||
// (in-process Patrol/Assistant or external) that needs the full
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package maintenancesentinel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Providers bundles the slim, package-defined interfaces the sentinel
|
||||
// needs from the rest of the system. The caller (router wiring) is
|
||||
// responsible for adapting alerts/findings/actions/metrics into these
|
||||
// shapes. Keeps this package free of `internal/alerts`, `internal/ai`,
|
||||
// and `internal/monitoring` imports so there are no cycles.
|
||||
type Providers struct {
|
||||
// Stores resolves the ResourceStore for the given org id. The
|
||||
// sentinel runs against the default org for MVP; tenant-scoped
|
||||
// scheduling is left to a future change.
|
||||
Stores func(orgID string) (unified.ResourceStore, error)
|
||||
// ActiveAlerts returns active alerts for a resource at this moment.
|
||||
ActiveAlerts func(orgID, canonicalID string) []AlertSummary
|
||||
// ActiveFindings returns active Patrol findings for a resource.
|
||||
ActiveFindings func(orgID, canonicalID string) []FindingSummary
|
||||
// RecentActions returns action audit records for the resource
|
||||
// since the supplied lower bound. The sentinel itself filters for
|
||||
// failed state after the call returns.
|
||||
RecentActions func(orgID, canonicalID string, since time.Time) []ActionSummary
|
||||
// PostWindowMetricSamples returns metric samples observed after
|
||||
// the maintenance window closed for the resource. The boolean
|
||||
// reports whether a metric source was available for the
|
||||
// resource — distinct from "available but empty".
|
||||
PostWindowMetricSamples func(orgID, canonicalID string, windowEnd, now time.Time) ([]MetricSample, bool)
|
||||
// Now is the clock; tests inject a fixed clock. Defaults to
|
||||
// time.Now.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Sentinel watches operator state for maintenance-window-end events
|
||||
// and writes Maintenance Verification Reports.
|
||||
type Sentinel struct {
|
||||
orgID string
|
||||
providers Providers
|
||||
tick time.Duration
|
||||
lookbackLimit time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Config configures a Sentinel instance.
|
||||
type Config struct {
|
||||
// OrgID is the tenant the sentinel scans. MVP runs the default
|
||||
// org only — multi-tenant scheduling is deferred.
|
||||
OrgID string
|
||||
// Tick controls the sweep cadence. Defaults to one minute.
|
||||
Tick time.Duration
|
||||
// LookbackLimit prevents the sentinel from generating reports
|
||||
// for ancient windows on first start (e.g. after a long
|
||||
// downtime). Window-end events older than `now - LookbackLimit`
|
||||
// are ignored. Default: 7 days.
|
||||
LookbackLimit time.Duration
|
||||
}
|
||||
|
||||
// New returns a Sentinel ready to start. Providers is required.
|
||||
func New(cfg Config, providers Providers) (*Sentinel, error) {
|
||||
if providers.Stores == nil {
|
||||
return nil, errors.New("maintenancesentinel: Providers.Stores is required")
|
||||
}
|
||||
tick := cfg.Tick
|
||||
if tick <= 0 {
|
||||
tick = time.Minute
|
||||
}
|
||||
lookback := cfg.LookbackLimit
|
||||
if lookback <= 0 {
|
||||
lookback = 7 * 24 * time.Hour
|
||||
}
|
||||
org := cfg.OrgID
|
||||
if org == "" {
|
||||
org = "default"
|
||||
}
|
||||
return &Sentinel{
|
||||
orgID: org,
|
||||
providers: providers,
|
||||
tick: tick,
|
||||
lookbackLimit: lookback,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start runs the sentinel loop in a goroutine and returns. The loop
|
||||
// stops when ctx is canceled. Safe to call once per Sentinel.
|
||||
func (s *Sentinel) Start(ctx context.Context) {
|
||||
go s.run(ctx)
|
||||
}
|
||||
|
||||
func (s *Sentinel) run(ctx context.Context) {
|
||||
ticker := time.NewTicker(s.tick)
|
||||
defer ticker.Stop()
|
||||
// Run one sweep immediately so a newly-ended window doesn't have
|
||||
// to wait a full tick for a report.
|
||||
s.tickOnce(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info().Msg("maintenance-verification sentinel stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.tickOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) tickOnce(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
store, err := s.providers.Stores(s.orgID)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("maintenance-verification sentinel: get store")
|
||||
return
|
||||
}
|
||||
states, err := store.ListResourceOperatorStates()
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("maintenance-verification sentinel: list operator states")
|
||||
return
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
cutoff := now.Add(-s.lookbackLimit)
|
||||
|
||||
for _, state := range states {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if state.MaintenanceStartAt == nil || state.MaintenanceEndAt == nil {
|
||||
continue
|
||||
}
|
||||
if !state.MaintenanceEndAt.Before(now) && !state.MaintenanceEndAt.Equal(now) {
|
||||
// Window hasn't ended yet.
|
||||
continue
|
||||
}
|
||||
if state.MaintenanceEndAt.Before(cutoff) {
|
||||
// Window ended too long ago — don't backfill ancient events.
|
||||
continue
|
||||
}
|
||||
s.evaluateForState(state, store, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) evaluateForState(state unified.ResourceOperatorState, store unified.ResourceStore, now time.Time) {
|
||||
canonicalID := unified.CanonicalResourceID(state.CanonicalID)
|
||||
if canonicalID == "" || state.MaintenanceEndAt == nil {
|
||||
return
|
||||
}
|
||||
if _, exists, err := store.FindLoopReportByWindow(unified.LoopReportTypeMaintenanceVerification, canonicalID, *state.MaintenanceEndAt); err != nil {
|
||||
log.Debug().Err(err).Str("resource", canonicalID).Msg("maintenance-verification sentinel: dedupe lookup")
|
||||
return
|
||||
} else if exists {
|
||||
// Already wrote a report for this (resource, window-end).
|
||||
return
|
||||
}
|
||||
|
||||
inputs := s.buildInputs(state, now)
|
||||
report := EvaluateVerification(inputs)
|
||||
if err := store.RecordLoopReport(report); err != nil {
|
||||
// A unique-constraint conflict (race against a parallel
|
||||
// tick) is benign — another writer wrote the same window.
|
||||
log.Debug().Err(err).Str("resource", canonicalID).Msg("maintenance-verification sentinel: record report")
|
||||
return
|
||||
}
|
||||
log.Info().
|
||||
Str("resource", canonicalID).
|
||||
Str("status", string(report.Status)).
|
||||
Str("report_id", report.ID).
|
||||
Msg("maintenance verification report written")
|
||||
}
|
||||
|
||||
// buildInputs gathers the deterministic input bundle for the
|
||||
// evaluator. Provider closures may be nil — the inputs simply carry
|
||||
// zero values in that case.
|
||||
func (s *Sentinel) buildInputs(state unified.ResourceOperatorState, now time.Time) VerificationInputs {
|
||||
canonicalID := unified.CanonicalResourceID(state.CanonicalID)
|
||||
inputs := VerificationInputs{
|
||||
ResourceID: canonicalID,
|
||||
OperatorState: cloneOperatorState(state),
|
||||
Now: now,
|
||||
}
|
||||
if state.MaintenanceStartAt != nil {
|
||||
inputs.WindowStartedAt = state.MaintenanceStartAt.UTC()
|
||||
}
|
||||
if state.MaintenanceEndAt != nil {
|
||||
inputs.WindowEndedAt = state.MaintenanceEndAt.UTC()
|
||||
}
|
||||
if s.providers.ActiveAlerts != nil {
|
||||
inputs.ActiveAlerts = s.providers.ActiveAlerts(s.orgID, canonicalID)
|
||||
}
|
||||
if s.providers.ActiveFindings != nil {
|
||||
inputs.ActiveFindings = s.providers.ActiveFindings(s.orgID, canonicalID)
|
||||
}
|
||||
if s.providers.RecentActions != nil {
|
||||
windowStart := inputs.WindowStartedAt
|
||||
if windowStart.IsZero() {
|
||||
windowStart = now.Add(-24 * time.Hour)
|
||||
}
|
||||
inputs.RecentActions = s.providers.RecentActions(s.orgID, canonicalID, windowStart)
|
||||
}
|
||||
if s.providers.PostWindowMetricSamples != nil {
|
||||
samples, available := s.providers.PostWindowMetricSamples(s.orgID, canonicalID, inputs.WindowEndedAt, now)
|
||||
inputs.PostWindowMetricSamples = samples
|
||||
inputs.MetricSourceAvailable = available
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
func cloneOperatorState(s unified.ResourceOperatorState) *unified.ResourceOperatorState {
|
||||
clone := s
|
||||
if s.MaintenanceStartAt != nil {
|
||||
t := *s.MaintenanceStartAt
|
||||
clone.MaintenanceStartAt = &t
|
||||
}
|
||||
if s.MaintenanceEndAt != nil {
|
||||
t := *s.MaintenanceEndAt
|
||||
clone.MaintenanceEndAt = &t
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (s *Sentinel) now() time.Time {
|
||||
if s.providers.Now != nil {
|
||||
return s.providers.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// EvaluateOnce runs the deterministic verification for the supplied
|
||||
// resource exactly once, immediately, writing a fresh report. Used by
|
||||
// the "rerun verification" handler so an operator can re-evaluate
|
||||
// without waiting for the next tick.
|
||||
//
|
||||
// If the existing report for the (resource, window-end) triple is
|
||||
// found, this writes a *new* report with a -rerun-N suffix so the
|
||||
// review history is preserved.
|
||||
func (s *Sentinel) EvaluateOnce(ctx context.Context, canonicalID string) (unified.LoopReport, error) {
|
||||
if ctx.Err() != nil {
|
||||
return unified.LoopReport{}, ctx.Err()
|
||||
}
|
||||
canonicalID = unified.CanonicalResourceID(canonicalID)
|
||||
if canonicalID == "" {
|
||||
return unified.LoopReport{}, fmt.Errorf("maintenancesentinel: canonical id is required")
|
||||
}
|
||||
store, err := s.providers.Stores(s.orgID)
|
||||
if err != nil {
|
||||
return unified.LoopReport{}, err
|
||||
}
|
||||
state, found, err := store.GetResourceOperatorState(canonicalID)
|
||||
if err != nil {
|
||||
return unified.LoopReport{}, err
|
||||
}
|
||||
if !found {
|
||||
return unified.LoopReport{}, fmt.Errorf("maintenancesentinel: no operator state for %q", canonicalID)
|
||||
}
|
||||
if state.MaintenanceStartAt == nil || state.MaintenanceEndAt == nil {
|
||||
return unified.LoopReport{}, fmt.Errorf("maintenancesentinel: resource %q has no maintenance window to verify", canonicalID)
|
||||
}
|
||||
now := s.now()
|
||||
inputs := s.buildInputs(state, now)
|
||||
report := EvaluateVerification(inputs)
|
||||
report.ID = uniqueRerunID(store, report.ID)
|
||||
if err := store.RecordLoopReport(report); err != nil {
|
||||
return unified.LoopReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func uniqueRerunID(store unified.ResourceStore, base string) string {
|
||||
if _, exists, err := store.GetLoopReport(base); err == nil && !exists {
|
||||
return base
|
||||
}
|
||||
for i := 1; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s-rerun-%d", base, i)
|
||||
if _, exists, err := store.GetLoopReport(candidate); err == nil && !exists {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s-rerun-%d", base, time.Now().UnixNano())
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package maintenancesentinel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestSentinelTickOnceWritesReportAndDedupes(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-time.Hour)
|
||||
windowEnd := now.Add(-15 * time.Minute)
|
||||
|
||||
store := unified.NewMemoryStore()
|
||||
if err := store.SetResourceOperatorState(unified.ResourceOperatorState{
|
||||
CanonicalID: "vm:101",
|
||||
MaintenanceStartAt: &windowStart,
|
||||
MaintenanceEndAt: &windowEnd,
|
||||
SetAt: windowStart,
|
||||
SetBy: "operator",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed operator state: %v", err)
|
||||
}
|
||||
|
||||
providers := Providers{
|
||||
Stores: func(orgID string) (unified.ResourceStore, error) { return store, nil },
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
sentinel, err := New(Config{OrgID: "default", Tick: time.Minute}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("new sentinel: %v", err)
|
||||
}
|
||||
|
||||
sentinel.tickOnce(context.Background())
|
||||
|
||||
reports, err := store.ListLoopReportsForResource(unified.LoopReportTypeMaintenanceVerification, "vm:101", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list reports: %v", err)
|
||||
}
|
||||
if len(reports) != 1 {
|
||||
t.Fatalf("expected 1 report after first tick, got %d", len(reports))
|
||||
}
|
||||
|
||||
sentinel.tickOnce(context.Background())
|
||||
|
||||
reports, err = store.ListLoopReportsForResource(unified.LoopReportTypeMaintenanceVerification, "vm:101", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list reports: %v", err)
|
||||
}
|
||||
if len(reports) != 1 {
|
||||
t.Fatalf("expected dedupe; got %d reports after second tick", len(reports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelSkipsAncientWindows(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-30 * 24 * time.Hour)
|
||||
windowEnd := now.Add(-29 * 24 * time.Hour)
|
||||
|
||||
store := unified.NewMemoryStore()
|
||||
if err := store.SetResourceOperatorState(unified.ResourceOperatorState{
|
||||
CanonicalID: "vm:202",
|
||||
MaintenanceStartAt: &windowStart,
|
||||
MaintenanceEndAt: &windowEnd,
|
||||
SetAt: windowStart,
|
||||
SetBy: "operator",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed operator state: %v", err)
|
||||
}
|
||||
|
||||
providers := Providers{
|
||||
Stores: func(orgID string) (unified.ResourceStore, error) { return store, nil },
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
sentinel, err := New(Config{OrgID: "default", LookbackLimit: 7 * 24 * time.Hour}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("new sentinel: %v", err)
|
||||
}
|
||||
sentinel.tickOnce(context.Background())
|
||||
|
||||
reports, err := store.ListLoopReportsForResource(unified.LoopReportTypeMaintenanceVerification, "vm:202", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list reports: %v", err)
|
||||
}
|
||||
if len(reports) != 0 {
|
||||
t.Fatalf("expected no report for ancient window, got %d", len(reports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelSkipsOpenWindow(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-30 * time.Minute)
|
||||
windowEnd := now.Add(30 * time.Minute)
|
||||
|
||||
store := unified.NewMemoryStore()
|
||||
if err := store.SetResourceOperatorState(unified.ResourceOperatorState{
|
||||
CanonicalID: "vm:303",
|
||||
MaintenanceStartAt: &windowStart,
|
||||
MaintenanceEndAt: &windowEnd,
|
||||
SetAt: windowStart,
|
||||
SetBy: "operator",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed operator state: %v", err)
|
||||
}
|
||||
|
||||
providers := Providers{
|
||||
Stores: func(orgID string) (unified.ResourceStore, error) { return store, nil },
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
sentinel, err := New(Config{OrgID: "default"}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("new sentinel: %v", err)
|
||||
}
|
||||
sentinel.tickOnce(context.Background())
|
||||
|
||||
reports, err := store.ListLoopReportsForResource(unified.LoopReportTypeMaintenanceVerification, "vm:303", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list reports: %v", err)
|
||||
}
|
||||
if len(reports) != 0 {
|
||||
t.Fatalf("expected no report while window still open, got %d", len(reports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelEvaluateOncePersistsRerunReport(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-time.Hour)
|
||||
windowEnd := now.Add(-15 * time.Minute)
|
||||
|
||||
store := unified.NewMemoryStore()
|
||||
if err := store.SetResourceOperatorState(unified.ResourceOperatorState{
|
||||
CanonicalID: "vm:101",
|
||||
MaintenanceStartAt: &windowStart,
|
||||
MaintenanceEndAt: &windowEnd,
|
||||
SetAt: windowStart,
|
||||
SetBy: "operator",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed operator state: %v", err)
|
||||
}
|
||||
|
||||
providers := Providers{
|
||||
Stores: func(orgID string) (unified.ResourceStore, error) { return store, nil },
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
sentinel, err := New(Config{OrgID: "default"}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("new sentinel: %v", err)
|
||||
}
|
||||
sentinel.tickOnce(context.Background())
|
||||
|
||||
rerun, err := sentinel.EvaluateOnce(context.Background(), "vm:101")
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate once: %v", err)
|
||||
}
|
||||
if !strings.Contains(rerun.ID, "rerun") {
|
||||
t.Fatalf("expected rerun id suffix, got %q", rerun.ID)
|
||||
}
|
||||
reports, err := store.ListLoopReportsForResource(unified.LoopReportTypeMaintenanceVerification, "vm:101", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list reports: %v", err)
|
||||
}
|
||||
if len(reports) != 2 {
|
||||
t.Fatalf("expected 2 reports after rerun, got %d", len(reports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelEvaluateOnceErrorsWhenNoWindow(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
store := unified.NewMemoryStore()
|
||||
if err := store.SetResourceOperatorState(unified.ResourceOperatorState{
|
||||
CanonicalID: "vm:404",
|
||||
SetAt: now,
|
||||
SetBy: "operator",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed operator state: %v", err)
|
||||
}
|
||||
providers := Providers{
|
||||
Stores: func(orgID string) (unified.ResourceStore, error) { return store, nil },
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
sentinel, err := New(Config{OrgID: "default"}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("new sentinel: %v", err)
|
||||
}
|
||||
if _, err := sentinel.EvaluateOnce(context.Background(), "vm:404"); err == nil {
|
||||
t.Fatalf("expected error when no maintenance window present")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// Package maintenancesentinel implements the Maintenance Verification
|
||||
// Report loop. When a maintenance window ends for a resource, the
|
||||
// sentinel runs deterministic checks against the resource's current
|
||||
// state and writes a durable Maintenance Verification Report.
|
||||
//
|
||||
// The package is intentionally small and shaped so future loops
|
||||
// (post-incident verification, post-deployment verification) can reuse
|
||||
// the same persistence (LoopReport) and review actions. This is
|
||||
// substrate, not a generic agent framework.
|
||||
package maintenancesentinel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// Severity classifies an alert or finding for the verification
|
||||
// decision tree. Maintenance Verification only distinguishes critical
|
||||
// vs. warning — the deeper severity grades (watch/info) are not
|
||||
// load-bearing for the decision.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityCritical Severity = "critical"
|
||||
SeverityWarning Severity = "warning"
|
||||
)
|
||||
|
||||
// AlertSummary is the slim projection of an alert the sentinel needs.
|
||||
// The router adapts the canonical `alerts.Alert` into this shape so
|
||||
// this package does not import `internal/alerts`.
|
||||
type AlertSummary struct {
|
||||
ID string
|
||||
Severity Severity
|
||||
Type string
|
||||
Acknowledged bool
|
||||
}
|
||||
|
||||
// FindingSummary is the slim projection of a Patrol finding the
|
||||
// sentinel needs. The router adapts `ai.Finding` into this shape so
|
||||
// this package does not import `internal/ai`.
|
||||
type FindingSummary struct {
|
||||
ID string
|
||||
Severity Severity
|
||||
Category string
|
||||
Resolved bool
|
||||
Acknowledged bool
|
||||
}
|
||||
|
||||
// ActionSummary is the slim projection of an action audit record the
|
||||
// sentinel needs.
|
||||
type ActionSummary struct {
|
||||
ID string
|
||||
State string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// MetricSample is a single recent metric data point used to decide
|
||||
// whether a resource is reporting metrics after maintenance closed.
|
||||
type MetricSample struct {
|
||||
Metric string
|
||||
Value float64
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// VerificationInputs is the full deterministic input bundle the
|
||||
// evaluator consumes. The sentinel collects this once before calling
|
||||
// EvaluateVerification.
|
||||
type VerificationInputs struct {
|
||||
ResourceID string
|
||||
ResourceName string
|
||||
OperatorState *unified.ResourceOperatorState
|
||||
WindowStartedAt time.Time
|
||||
WindowEndedAt time.Time
|
||||
Now time.Time
|
||||
ActiveAlerts []AlertSummary
|
||||
ActiveFindings []FindingSummary
|
||||
// RecentActions should be the action audits with UpdatedAt >=
|
||||
// WindowStartedAt. The evaluator counts how many of them ended in
|
||||
// "failed" state.
|
||||
RecentActions []ActionSummary
|
||||
// PostWindowMetricSamples are the metric samples observed after
|
||||
// WindowEndedAt. Empty slice means no metrics have been reported
|
||||
// since the window closed.
|
||||
PostWindowMetricSamples []MetricSample
|
||||
// MetricSourceAvailable indicates whether the sentinel had access
|
||||
// to a metric source for this resource at all. Distinguishes
|
||||
// "we looked and saw nothing" from "we have no way to look".
|
||||
MetricSourceAvailable bool
|
||||
}
|
||||
|
||||
// EvaluateVerification applies the deterministic decision tree to the
|
||||
// inputs and returns a fully-populated LoopReport. The function is
|
||||
// pure: no I/O, no time.Now() (always uses inputs.Now), no goroutines.
|
||||
func EvaluateVerification(inputs VerificationInputs) unified.LoopReport {
|
||||
now := inputs.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
now = now.UTC()
|
||||
|
||||
canonicalID := unified.CanonicalResourceID(inputs.ResourceID)
|
||||
report := unified.LoopReport{
|
||||
ID: newReportID(canonicalID, inputs.WindowEndedAt),
|
||||
Type: unified.LoopReportTypeMaintenanceVerification,
|
||||
Scope: canonicalID,
|
||||
Trigger: "maintenance_window_end",
|
||||
Goal: "Confirm the resource recovered after the maintenance window closed.",
|
||||
Status: unified.LoopReportStatusPending,
|
||||
StartedAt: now,
|
||||
CompletedAt: now,
|
||||
}
|
||||
if !inputs.WindowStartedAt.IsZero() {
|
||||
t := inputs.WindowStartedAt.UTC()
|
||||
report.WindowStartedAt = &t
|
||||
}
|
||||
if !inputs.WindowEndedAt.IsZero() {
|
||||
t := inputs.WindowEndedAt.UTC()
|
||||
report.WindowEndedAt = &t
|
||||
}
|
||||
|
||||
evidence := unified.LoopReportEvidence{
|
||||
OperatorStateSummary: summarizeOperatorState(inputs.OperatorState, inputs.WindowEndedAt, now),
|
||||
}
|
||||
|
||||
criticalAlerts, warningAlerts, alertIDs := classifyAlerts(inputs.ActiveAlerts)
|
||||
evidence.ActiveCriticalAlerts = criticalAlerts
|
||||
evidence.ActiveWarningAlerts = warningAlerts
|
||||
report.LinkedAlertIDs = alertIDs
|
||||
|
||||
criticalFindings, warningFindings, findingIDs := classifyFindings(inputs.ActiveFindings)
|
||||
evidence.ActiveCriticalFindings = criticalFindings
|
||||
evidence.ActiveWarningFindings = warningFindings
|
||||
report.LinkedFindingIDs = findingIDs
|
||||
|
||||
failedActions, failedActionIDs := classifyFailedActions(inputs.RecentActions, inputs.WindowStartedAt)
|
||||
evidence.FailedActionsSinceWindowStart = failedActions
|
||||
report.LinkedActionIDs = failedActionIDs
|
||||
|
||||
if inputs.MetricSourceAvailable {
|
||||
evidence.MetricRecovery = summarizeMetricRecovery(inputs.PostWindowMetricSamples, inputs.WindowEndedAt, now)
|
||||
}
|
||||
|
||||
// Patrol-run trigger is deferred — see PatrolRunTODO contract.
|
||||
evidence.PatrolRunTODO = patrolRunTODO(inputs, evidence)
|
||||
|
||||
report.Evidence = evidence
|
||||
report.Status, report.Recommendation = decideStatus(inputs, evidence)
|
||||
return report
|
||||
}
|
||||
|
||||
func summarizeOperatorState(state *unified.ResourceOperatorState, windowEnd, now time.Time) string {
|
||||
if state == nil {
|
||||
return "no operator state recorded for this resource"
|
||||
}
|
||||
parts := []string{}
|
||||
if state.MaintenanceStartAt != nil && state.MaintenanceEndAt != nil {
|
||||
if state.MaintenanceEndAt.After(now) {
|
||||
parts = append(parts, "maintenance window still open")
|
||||
} else {
|
||||
parts = append(parts, "maintenance window ended")
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, "no maintenance window")
|
||||
}
|
||||
if state.IntentionallyOffline {
|
||||
parts = append(parts, "intentionally offline")
|
||||
}
|
||||
if state.NeverAutoRemediate {
|
||||
parts = append(parts, "never auto-remediate")
|
||||
}
|
||||
_ = windowEnd
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func classifyAlerts(alerts []AlertSummary) (criticalCount, warningCount int, ids []string) {
|
||||
for _, a := range alerts {
|
||||
switch a.Severity {
|
||||
case SeverityCritical:
|
||||
criticalCount++
|
||||
case SeverityWarning:
|
||||
warningCount++
|
||||
}
|
||||
if a.ID != "" {
|
||||
ids = append(ids, a.ID)
|
||||
}
|
||||
}
|
||||
return criticalCount, warningCount, ids
|
||||
}
|
||||
|
||||
func classifyFindings(findings []FindingSummary) (criticalCount, warningCount int, ids []string) {
|
||||
for _, f := range findings {
|
||||
if f.Resolved {
|
||||
continue
|
||||
}
|
||||
switch f.Severity {
|
||||
case SeverityCritical:
|
||||
criticalCount++
|
||||
case SeverityWarning:
|
||||
warningCount++
|
||||
}
|
||||
if f.ID != "" {
|
||||
ids = append(ids, f.ID)
|
||||
}
|
||||
}
|
||||
return criticalCount, warningCount, ids
|
||||
}
|
||||
|
||||
func classifyFailedActions(actions []ActionSummary, windowStart time.Time) (count int, ids []string) {
|
||||
for _, a := range actions {
|
||||
if a.State != "failed" {
|
||||
continue
|
||||
}
|
||||
if !windowStart.IsZero() && a.UpdatedAt.Before(windowStart) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if a.ID != "" {
|
||||
ids = append(ids, a.ID)
|
||||
}
|
||||
}
|
||||
return count, ids
|
||||
}
|
||||
|
||||
func summarizeMetricRecovery(samples []MetricSample, windowEnd, now time.Time) *unified.MetricRecoveryEvidence {
|
||||
rec := &unified.MetricRecoveryEvidence{}
|
||||
metrics := map[string]struct{}{}
|
||||
relevant := make([]MetricSample, 0, len(samples))
|
||||
for _, s := range samples {
|
||||
if !s.Timestamp.After(windowEnd) {
|
||||
continue
|
||||
}
|
||||
relevant = append(relevant, s)
|
||||
if s.Metric != "" {
|
||||
metrics[s.Metric] = struct{}{}
|
||||
}
|
||||
}
|
||||
rec.SamplesAfterEnd = len(relevant)
|
||||
if len(metrics) > 0 {
|
||||
rec.MetricsObserved = make([]string, 0, len(metrics))
|
||||
for m := range metrics {
|
||||
rec.MetricsObserved = append(rec.MetricsObserved, m)
|
||||
}
|
||||
}
|
||||
if len(relevant) == 0 {
|
||||
rec.Trend = "unknown"
|
||||
rec.Note = "no metric samples have been recorded since the window closed"
|
||||
return rec
|
||||
}
|
||||
rec.Trend = trendFromSamples(relevant)
|
||||
if rec.Trend == "stable" {
|
||||
rec.Note = fmt.Sprintf("%d post-window samples observed; values within expected band", len(relevant))
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// trendFromSamples reports a simple plain-English trend label by
|
||||
// comparing the first half of samples to the second half. The MVP is
|
||||
// deterministic and crude on purpose — anomaly detection is the
|
||||
// baseline store's job, not the sentinel's.
|
||||
func trendFromSamples(samples []MetricSample) string {
|
||||
if len(samples) < 2 {
|
||||
return "unknown"
|
||||
}
|
||||
byMetric := map[string][]MetricSample{}
|
||||
for _, s := range samples {
|
||||
byMetric[s.Metric] = append(byMetric[s.Metric], s)
|
||||
}
|
||||
var trends []string
|
||||
for _, ms := range byMetric {
|
||||
if len(ms) < 2 {
|
||||
trends = append(trends, "unknown")
|
||||
continue
|
||||
}
|
||||
mid := len(ms) / 2
|
||||
firstAvg := averageValue(ms[:mid])
|
||||
secondAvg := averageValue(ms[mid:])
|
||||
// Threshold is intentionally wide — the trend label only
|
||||
// changes the report's note, not its overall status.
|
||||
switch {
|
||||
case firstAvg == 0 && secondAvg == 0:
|
||||
trends = append(trends, "stable")
|
||||
case secondAvg <= firstAvg*1.05+0.01:
|
||||
trends = append(trends, "stable")
|
||||
case secondAvg > firstAvg*1.5:
|
||||
trends = append(trends, "degrading")
|
||||
default:
|
||||
trends = append(trends, "stable")
|
||||
}
|
||||
}
|
||||
// Combine: any degrading wins, else stable.
|
||||
for _, t := range trends {
|
||||
if t == "degrading" {
|
||||
return "degrading"
|
||||
}
|
||||
}
|
||||
return "stable"
|
||||
}
|
||||
|
||||
func averageValue(samples []MetricSample) float64 {
|
||||
if len(samples) == 0 {
|
||||
return 0
|
||||
}
|
||||
var sum float64
|
||||
for _, s := range samples {
|
||||
sum += s.Value
|
||||
}
|
||||
return sum / float64(len(samples))
|
||||
}
|
||||
|
||||
func patrolRunTODO(inputs VerificationInputs, evidence unified.LoopReportEvidence) string {
|
||||
// If the deterministic evidence is unambiguous (clear failure or
|
||||
// clear pass), there's nothing for Patrol to add.
|
||||
if evidence.ActiveCriticalAlerts > 0 || evidence.ActiveCriticalFindings > 0 || evidence.FailedActionsSinceWindowStart > 0 {
|
||||
return ""
|
||||
}
|
||||
if evidence.ActiveWarningAlerts == 0 && evidence.ActiveWarningFindings == 0 {
|
||||
if evidence.MetricRecovery != nil && evidence.MetricRecovery.SamplesAfterEnd > 0 {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
_ = inputs
|
||||
// Ambiguous case: a scoped Patrol run would help.
|
||||
//
|
||||
// MVP: deterministic checks only. The Patrol API surfaces a global
|
||||
// scheduled run today; a scoped per-resource trigger that does
|
||||
// not race the global scheduler is not yet available. Until that
|
||||
// lands, we leave a TODO breadcrumb on the report so the operator
|
||||
// knows verification was conservative, and so future work can
|
||||
// pick this up cleanly.
|
||||
return "scoped Patrol run not triggered — per-resource scoped run entrypoint is not yet implemented; review evidence manually"
|
||||
}
|
||||
|
||||
func decideStatus(inputs VerificationInputs, evidence unified.LoopReportEvidence) (unified.LoopReportStatus, string) {
|
||||
// Strong negative signals → failed verification.
|
||||
if evidence.ActiveCriticalAlerts > 0 {
|
||||
return unified.LoopReportStatusFailedVerification,
|
||||
"Critical alert(s) are active after maintenance closed. Investigate before clearing operator state."
|
||||
}
|
||||
if evidence.ActiveCriticalFindings > 0 {
|
||||
return unified.LoopReportStatusFailedVerification,
|
||||
"Critical Patrol finding(s) are active. Resource has not recovered cleanly."
|
||||
}
|
||||
if evidence.FailedActionsSinceWindowStart > 0 {
|
||||
return unified.LoopReportStatusFailedVerification,
|
||||
"One or more recovery actions failed during or after the maintenance window. Review the action audit timeline."
|
||||
}
|
||||
|
||||
// Ambiguous signals → needs review.
|
||||
if inputs.OperatorState != nil && inputs.OperatorState.IntentionallyOffline {
|
||||
return unified.LoopReportStatusNeedsReview,
|
||||
"Resource is marked intentionally offline. Verification cannot confirm recovery automatically."
|
||||
}
|
||||
if evidence.ActiveWarningAlerts > 0 || evidence.ActiveWarningFindings > 0 {
|
||||
return unified.LoopReportStatusNeedsReview,
|
||||
"Warning-level signals are active after maintenance closed. Operator review recommended."
|
||||
}
|
||||
if evidence.MetricRecovery != nil {
|
||||
if evidence.MetricRecovery.SamplesAfterEnd == 0 {
|
||||
return unified.LoopReportStatusNeedsReview,
|
||||
"No metric samples have been recorded since the window closed. Confirm the resource is reporting again."
|
||||
}
|
||||
if evidence.MetricRecovery.Trend == "degrading" {
|
||||
return unified.LoopReportStatusNeedsReview,
|
||||
"Post-window metric trend is degrading. Operator review recommended."
|
||||
}
|
||||
} else {
|
||||
return unified.LoopReportStatusNeedsReview,
|
||||
"No metric source was available for this resource. Confirm recovery manually."
|
||||
}
|
||||
|
||||
return unified.LoopReportStatusHealthy,
|
||||
"All deterministic checks passed. No active alerts, findings, or failed actions since the window started."
|
||||
}
|
||||
|
||||
func newReportID(canonicalID string, windowEndedAt time.Time) string {
|
||||
ts := windowEndedAt.UTC().Format("20060102T150405Z")
|
||||
if windowEndedAt.IsZero() {
|
||||
ts = time.Now().UTC().Format("20060102T150405Z")
|
||||
}
|
||||
return fmt.Sprintf("mv-%s-%s", sanitizeIDComponent(canonicalID), ts)
|
||||
}
|
||||
|
||||
func sanitizeIDComponent(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z'), (r >= 'A' && r <= 'Z'), (r >= '0' && r <= '9'):
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_' || r == '.':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package maintenancesentinel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestEvaluateVerification_HealthyWhenNoSignals(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-30 * time.Minute)
|
||||
windowEnd := now.Add(-10 * time.Minute)
|
||||
inputs := VerificationInputs{
|
||||
ResourceID: "vm:101",
|
||||
OperatorState: &unified.ResourceOperatorState{CanonicalID: "vm:101", MaintenanceStartAt: &windowStart, MaintenanceEndAt: &windowEnd},
|
||||
WindowStartedAt: windowStart,
|
||||
WindowEndedAt: windowEnd,
|
||||
Now: now,
|
||||
MetricSourceAvailable: true,
|
||||
PostWindowMetricSamples: []MetricSample{
|
||||
{Metric: "cpu", Value: 12, Timestamp: windowEnd.Add(time.Minute)},
|
||||
{Metric: "cpu", Value: 13, Timestamp: windowEnd.Add(2 * time.Minute)},
|
||||
{Metric: "memory", Value: 40, Timestamp: windowEnd.Add(time.Minute)},
|
||||
{Metric: "memory", Value: 41, Timestamp: windowEnd.Add(2 * time.Minute)},
|
||||
},
|
||||
}
|
||||
report := EvaluateVerification(inputs)
|
||||
if report.Status != unified.LoopReportStatusHealthy {
|
||||
t.Fatalf("status = %q want %q (recommendation=%q)", report.Status, unified.LoopReportStatusHealthy, report.Recommendation)
|
||||
}
|
||||
if report.Type != unified.LoopReportTypeMaintenanceVerification {
|
||||
t.Fatalf("type = %q want %q", report.Type, unified.LoopReportTypeMaintenanceVerification)
|
||||
}
|
||||
if report.Scope != "vm:101" {
|
||||
t.Fatalf("scope = %q want vm:101", report.Scope)
|
||||
}
|
||||
if report.WindowEndedAt == nil || !report.WindowEndedAt.Equal(windowEnd) {
|
||||
t.Fatalf("windowEndedAt = %v want %v", report.WindowEndedAt, windowEnd)
|
||||
}
|
||||
if !strings.HasPrefix(report.ID, "mv-vm_101-") {
|
||||
t.Fatalf("report id = %q want prefix mv-vm_101-", report.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateVerification_FailedOnCriticalAlert(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-time.Hour)
|
||||
windowEnd := now.Add(-15 * time.Minute)
|
||||
inputs := VerificationInputs{
|
||||
ResourceID: "vm:101",
|
||||
WindowStartedAt: windowStart,
|
||||
WindowEndedAt: windowEnd,
|
||||
Now: now,
|
||||
ActiveAlerts: []AlertSummary{
|
||||
{ID: "alert-1", Severity: SeverityCritical, Type: "cpu"},
|
||||
{ID: "alert-2", Severity: SeverityWarning, Type: "memory"},
|
||||
},
|
||||
MetricSourceAvailable: true,
|
||||
}
|
||||
report := EvaluateVerification(inputs)
|
||||
if report.Status != unified.LoopReportStatusFailedVerification {
|
||||
t.Fatalf("status = %q want failed_verification", report.Status)
|
||||
}
|
||||
if report.Evidence.ActiveCriticalAlerts != 1 || report.Evidence.ActiveWarningAlerts != 1 {
|
||||
t.Fatalf("alert counts = %d/%d want 1/1", report.Evidence.ActiveCriticalAlerts, report.Evidence.ActiveWarningAlerts)
|
||||
}
|
||||
if len(report.LinkedAlertIDs) != 2 {
|
||||
t.Fatalf("linked alert ids = %v want 2 entries", report.LinkedAlertIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateVerification_NeedsReviewOnNoMetrics(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-time.Hour)
|
||||
windowEnd := now.Add(-15 * time.Minute)
|
||||
inputs := VerificationInputs{
|
||||
ResourceID: "ct:200",
|
||||
WindowStartedAt: windowStart,
|
||||
WindowEndedAt: windowEnd,
|
||||
Now: now,
|
||||
MetricSourceAvailable: true,
|
||||
}
|
||||
report := EvaluateVerification(inputs)
|
||||
if report.Status != unified.LoopReportStatusNeedsReview {
|
||||
t.Fatalf("status = %q want needs_review", report.Status)
|
||||
}
|
||||
if report.Evidence.MetricRecovery == nil || report.Evidence.MetricRecovery.SamplesAfterEnd != 0 {
|
||||
t.Fatalf("metric recovery = %+v", report.Evidence.MetricRecovery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateVerification_FailedOnFailedAction(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-time.Hour)
|
||||
windowEnd := now.Add(-15 * time.Minute)
|
||||
inputs := VerificationInputs{
|
||||
ResourceID: "vm:101",
|
||||
WindowStartedAt: windowStart,
|
||||
WindowEndedAt: windowEnd,
|
||||
Now: now,
|
||||
RecentActions: []ActionSummary{
|
||||
{ID: "action-1", State: "failed", UpdatedAt: windowEnd.Add(-time.Minute)},
|
||||
{ID: "action-2", State: "succeeded", UpdatedAt: windowEnd.Add(time.Minute)},
|
||||
},
|
||||
MetricSourceAvailable: true,
|
||||
}
|
||||
report := EvaluateVerification(inputs)
|
||||
if report.Status != unified.LoopReportStatusFailedVerification {
|
||||
t.Fatalf("status = %q want failed_verification", report.Status)
|
||||
}
|
||||
if report.Evidence.FailedActionsSinceWindowStart != 1 {
|
||||
t.Fatalf("failed actions = %d want 1", report.Evidence.FailedActionsSinceWindowStart)
|
||||
}
|
||||
if len(report.LinkedActionIDs) != 1 || report.LinkedActionIDs[0] != "action-1" {
|
||||
t.Fatalf("linked action ids = %v want [action-1]", report.LinkedActionIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateVerification_NeedsReviewWhenIntentionallyOffline(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-time.Hour)
|
||||
windowEnd := now.Add(-15 * time.Minute)
|
||||
inputs := VerificationInputs{
|
||||
ResourceID: "vm:101",
|
||||
OperatorState: &unified.ResourceOperatorState{
|
||||
CanonicalID: "vm:101",
|
||||
IntentionallyOffline: true,
|
||||
MaintenanceStartAt: &windowStart,
|
||||
MaintenanceEndAt: &windowEnd,
|
||||
},
|
||||
WindowStartedAt: windowStart,
|
||||
WindowEndedAt: windowEnd,
|
||||
Now: now,
|
||||
MetricSourceAvailable: true,
|
||||
PostWindowMetricSamples: []MetricSample{
|
||||
{Metric: "cpu", Value: 5, Timestamp: windowEnd.Add(time.Minute)},
|
||||
{Metric: "cpu", Value: 6, Timestamp: windowEnd.Add(2 * time.Minute)},
|
||||
},
|
||||
}
|
||||
report := EvaluateVerification(inputs)
|
||||
if report.Status != unified.LoopReportStatusNeedsReview {
|
||||
t.Fatalf("status = %q want needs_review", report.Status)
|
||||
}
|
||||
if !strings.Contains(report.Evidence.OperatorStateSummary, "intentionally offline") {
|
||||
t.Fatalf("operator state summary = %q want to mention intentionally offline", report.Evidence.OperatorStateSummary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateVerification_FilesPatrolTODOWhenWarningsOnly(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-time.Hour)
|
||||
windowEnd := now.Add(-15 * time.Minute)
|
||||
inputs := VerificationInputs{
|
||||
ResourceID: "vm:101",
|
||||
WindowStartedAt: windowStart,
|
||||
WindowEndedAt: windowEnd,
|
||||
Now: now,
|
||||
ActiveFindings: []FindingSummary{
|
||||
{ID: "finding-1", Severity: SeverityWarning, Category: "performance"},
|
||||
},
|
||||
MetricSourceAvailable: true,
|
||||
}
|
||||
report := EvaluateVerification(inputs)
|
||||
if report.Status != unified.LoopReportStatusNeedsReview {
|
||||
t.Fatalf("status = %q want needs_review", report.Status)
|
||||
}
|
||||
if report.Evidence.PatrolRunTODO == "" {
|
||||
t.Fatalf("expected patrol run TODO breadcrumb on ambiguous evidence")
|
||||
}
|
||||
if len(report.LinkedFindingIDs) != 1 {
|
||||
t.Fatalf("linked finding ids = %v want 1", report.LinkedFindingIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateVerification_NeedsReviewWhenNoMetricSource(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-time.Hour)
|
||||
windowEnd := now.Add(-15 * time.Minute)
|
||||
inputs := VerificationInputs{
|
||||
ResourceID: "storage:tank",
|
||||
WindowStartedAt: windowStart,
|
||||
WindowEndedAt: windowEnd,
|
||||
Now: now,
|
||||
}
|
||||
report := EvaluateVerification(inputs)
|
||||
if report.Status != unified.LoopReportStatusNeedsReview {
|
||||
t.Fatalf("status = %q want needs_review when no metric source", report.Status)
|
||||
}
|
||||
if report.Evidence.MetricRecovery != nil {
|
||||
t.Fatalf("metric recovery = %+v want nil when source unavailable", report.Evidence.MetricRecovery)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package unifiedresources
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoopReportType is the discriminator that names the loop a report came
|
||||
// from. Only one loop is implemented for the MVP — maintenance window
|
||||
// verification — but the field stays in the contract so future loops can
|
||||
// reuse the same persistence and review surfaces without a schema split.
|
||||
type LoopReportType string
|
||||
|
||||
const (
|
||||
// LoopReportTypeMaintenanceVerification is the durable summary the
|
||||
// sentinel writes after a maintenance window ends for a resource. The
|
||||
// product-facing name is "Maintenance Verification Report"; this is
|
||||
// the internal type token used for storage and API filtering.
|
||||
LoopReportTypeMaintenanceVerification LoopReportType = "maintenance_verification"
|
||||
)
|
||||
|
||||
// LoopReportStatus reports the verification outcome.
|
||||
//
|
||||
// - healthy — all deterministic checks passed.
|
||||
// - needs_review — evidence is ambiguous; an operator should
|
||||
// look. Used when there is no metric history to compare, when the
|
||||
// resource is operator-marked intentionally offline, or when a
|
||||
// warning-level alert/finding is active.
|
||||
// - failed_verification — at least one strong negative signal
|
||||
// (critical alert, critical finding, recent failed action) is
|
||||
// present. The report does not auto-remediate; the operator
|
||||
// decides next steps.
|
||||
//
|
||||
// The set is intentionally small. Adding more states is a contract
|
||||
// change because the UI and review action depend on the enum.
|
||||
type LoopReportStatus string
|
||||
|
||||
const (
|
||||
LoopReportStatusPending LoopReportStatus = "pending"
|
||||
LoopReportStatusHealthy LoopReportStatus = "healthy"
|
||||
LoopReportStatusNeedsReview LoopReportStatus = "needs_review"
|
||||
LoopReportStatusFailedVerification LoopReportStatus = "failed_verification"
|
||||
)
|
||||
|
||||
// LoopReportUserOutcome records the operator's review verdict after
|
||||
// reading a report. "" means the operator has not yet reviewed.
|
||||
type LoopReportUserOutcome string
|
||||
|
||||
const (
|
||||
LoopReportUserOutcomeReviewed LoopReportUserOutcome = "reviewed"
|
||||
)
|
||||
|
||||
// LoopReportEvidence captures the deterministic checks the sentinel
|
||||
// ran. Each field is read-only operational evidence — counts and
|
||||
// summaries — not executable commands or stale plans. The shape is
|
||||
// stable so future review tooling can render every report uniformly.
|
||||
type LoopReportEvidence struct {
|
||||
// OperatorStateSummary is a short human-readable summary of the
|
||||
// operator state at evaluation time (e.g.
|
||||
// "maintenance_window_ended", "intentionally_offline", "no
|
||||
// operator state").
|
||||
OperatorStateSummary string `json:"operatorStateSummary,omitempty"`
|
||||
|
||||
// ActiveCriticalAlerts and ActiveWarningAlerts count alerts that
|
||||
// were active for this resource at evaluation time.
|
||||
ActiveCriticalAlerts int `json:"activeCriticalAlerts"`
|
||||
ActiveWarningAlerts int `json:"activeWarningAlerts"`
|
||||
|
||||
// ActiveCriticalFindings and ActiveWarningFindings count Patrol
|
||||
// findings active for the resource at evaluation time.
|
||||
ActiveCriticalFindings int `json:"activeCriticalFindings"`
|
||||
ActiveWarningFindings int `json:"activeWarningFindings"`
|
||||
|
||||
// FailedActionsSinceWindowStart is the number of action audits
|
||||
// targeting the resource whose state ended in "failed" with an
|
||||
// updated_at after the maintenance window started.
|
||||
FailedActionsSinceWindowStart int `json:"failedActionsSinceWindowStart"`
|
||||
|
||||
// MetricRecovery describes the basic recent-metric check. Absent
|
||||
// when no metric source was available for the resource at
|
||||
// evaluation time.
|
||||
MetricRecovery *MetricRecoveryEvidence `json:"metricRecovery,omitempty"`
|
||||
|
||||
// PatrolRunTODO is set when the deterministic evidence was
|
||||
// ambiguous and a scoped Patrol run would have helped, but
|
||||
// triggering one was not safe in this build. The value is the
|
||||
// reason string so the UI can surface what was missing.
|
||||
//
|
||||
// MVP: deterministic checks only; the Patrol run trigger is
|
||||
// deferred until the Patrol API surfaces a scoped per-resource
|
||||
// run entrypoint that does not race the global scheduler.
|
||||
PatrolRunTODO string `json:"patrolRunTodo,omitempty"`
|
||||
}
|
||||
|
||||
// MetricRecoveryEvidence is a small summary of the metric values
|
||||
// observed inside the maintenance window vs. immediately after it.
|
||||
// "Recent metric recovery" is intentionally shallow for the MVP — a
|
||||
// per-metric trend label rather than a model.
|
||||
type MetricRecoveryEvidence struct {
|
||||
// MetricsObserved lists the metric short names the sentinel
|
||||
// inspected (e.g. "cpu", "memory").
|
||||
MetricsObserved []string `json:"metricsObserved,omitempty"`
|
||||
|
||||
// SamplesAfterEnd is the number of metric samples observed after
|
||||
// the window ended. Zero means the resource has not reported any
|
||||
// metrics since the window closed — operator should look.
|
||||
SamplesAfterEnd int `json:"samplesAfterEnd"`
|
||||
|
||||
// Trend describes the post-window metric trend in plain English.
|
||||
// Allowed values: "improving", "stable", "degrading", "unknown".
|
||||
Trend string `json:"trend,omitempty"`
|
||||
|
||||
// Note is an optional explanation surfaced to the operator
|
||||
// alongside the trend.
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// LoopReport is the durable record the sentinel writes when a loop run
|
||||
// produces an outcome an operator may want to review. Fields are
|
||||
// minimal on purpose — this is shared substrate, not a generic agent
|
||||
// framework.
|
||||
//
|
||||
// For the maintenance-verification loop:
|
||||
// - Scope is the canonical resource ID.
|
||||
// - Trigger is always "maintenance_window_end".
|
||||
// - WindowStartedAt and WindowEndedAt mirror the operator-set window
|
||||
// so future runs can de-duplicate against the (resource, window)
|
||||
// pair.
|
||||
type LoopReport struct {
|
||||
ID string `json:"id"`
|
||||
Type LoopReportType `json:"type"`
|
||||
Scope string `json:"scope"`
|
||||
Trigger string `json:"trigger"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
Status LoopReportStatus `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
CompletedAt time.Time `json:"completedAt"`
|
||||
WindowStartedAt *time.Time `json:"windowStartedAt,omitempty"`
|
||||
WindowEndedAt *time.Time `json:"windowEndedAt,omitempty"`
|
||||
Evidence LoopReportEvidence `json:"evidence"`
|
||||
LinkedFindingIDs []string `json:"linkedFindingIds,omitempty"`
|
||||
LinkedAlertIDs []string `json:"linkedAlertIds,omitempty"`
|
||||
LinkedActionIDs []string `json:"linkedActionIds,omitempty"`
|
||||
LinkedPatrolRunID string `json:"linkedPatrolRunId,omitempty"`
|
||||
Recommendation string `json:"recommendation,omitempty"`
|
||||
UserOutcome LoopReportUserOutcome `json:"userOutcome,omitempty"`
|
||||
ReviewedAt *time.Time `json:"reviewedAt,omitempty"`
|
||||
ReviewedBy string `json:"reviewedBy,omitempty"`
|
||||
ReviewNote string `json:"reviewNote,omitempty"`
|
||||
}
|
||||
|
||||
// ErrLoopReportInvalid is returned when a record fails contract checks
|
||||
// at the store boundary.
|
||||
var ErrLoopReportInvalid = errors.New("loop_report_invalid")
|
||||
|
||||
// IsValidLoopReportType reports whether the value names a known loop
|
||||
// report type. Unknown types are rejected at the store boundary so
|
||||
// freeform discriminators cannot accumulate.
|
||||
func IsValidLoopReportType(t LoopReportType) bool {
|
||||
switch t {
|
||||
case LoopReportTypeMaintenanceVerification:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidLoopReportStatus reports whether the value names a known
|
||||
// terminal or in-flight status.
|
||||
func IsValidLoopReportStatus(s LoopReportStatus) bool {
|
||||
switch s {
|
||||
case LoopReportStatusPending,
|
||||
LoopReportStatusHealthy,
|
||||
LoopReportStatusNeedsReview,
|
||||
LoopReportStatusFailedVerification:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidLoopReportUserOutcome reports whether the value names a known
|
||||
// review verdict. Empty is valid — the operator has not reviewed yet.
|
||||
func IsValidLoopReportUserOutcome(o LoopReportUserOutcome) bool {
|
||||
switch o {
|
||||
case "", LoopReportUserOutcomeReviewed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizeLoopReport trims string fields, canonicalizes the scope,
|
||||
// and applies UTC to all timestamps. It does not validate — callers
|
||||
// follow normalize with ValidateLoopReport.
|
||||
func NormalizeLoopReport(r LoopReport) LoopReport {
|
||||
r.ID = strings.TrimSpace(r.ID)
|
||||
r.Scope = CanonicalResourceID(r.Scope)
|
||||
r.Trigger = strings.TrimSpace(r.Trigger)
|
||||
r.Goal = strings.TrimSpace(r.Goal)
|
||||
r.Recommendation = strings.TrimSpace(r.Recommendation)
|
||||
r.ReviewedBy = strings.TrimSpace(r.ReviewedBy)
|
||||
r.ReviewNote = strings.TrimSpace(r.ReviewNote)
|
||||
r.LinkedPatrolRunID = strings.TrimSpace(r.LinkedPatrolRunID)
|
||||
r.Evidence.OperatorStateSummary = strings.TrimSpace(r.Evidence.OperatorStateSummary)
|
||||
r.Evidence.PatrolRunTODO = strings.TrimSpace(r.Evidence.PatrolRunTODO)
|
||||
if r.Evidence.MetricRecovery != nil {
|
||||
r.Evidence.MetricRecovery.Trend = strings.TrimSpace(r.Evidence.MetricRecovery.Trend)
|
||||
r.Evidence.MetricRecovery.Note = strings.TrimSpace(r.Evidence.MetricRecovery.Note)
|
||||
}
|
||||
if !r.StartedAt.IsZero() {
|
||||
r.StartedAt = r.StartedAt.UTC()
|
||||
}
|
||||
if !r.CompletedAt.IsZero() {
|
||||
r.CompletedAt = r.CompletedAt.UTC()
|
||||
}
|
||||
if r.WindowStartedAt != nil {
|
||||
t := r.WindowStartedAt.UTC()
|
||||
r.WindowStartedAt = &t
|
||||
}
|
||||
if r.WindowEndedAt != nil {
|
||||
t := r.WindowEndedAt.UTC()
|
||||
r.WindowEndedAt = &t
|
||||
}
|
||||
if r.ReviewedAt != nil {
|
||||
t := r.ReviewedAt.UTC()
|
||||
r.ReviewedAt = &t
|
||||
}
|
||||
r.LinkedFindingIDs = trimAndDedupeStrings(r.LinkedFindingIDs)
|
||||
r.LinkedAlertIDs = trimAndDedupeStrings(r.LinkedAlertIDs)
|
||||
r.LinkedActionIDs = trimAndDedupeStrings(r.LinkedActionIDs)
|
||||
return r
|
||||
}
|
||||
|
||||
// ValidateLoopReport applies the contract checks the persistence layer
|
||||
// must enforce before writing a record. Returns an
|
||||
// ErrLoopReportInvalid-wrapped error on violation.
|
||||
func ValidateLoopReport(r LoopReport) error {
|
||||
if r.ID == "" {
|
||||
return fmt.Errorf("%w: id is required", ErrLoopReportInvalid)
|
||||
}
|
||||
if !IsValidLoopReportType(r.Type) {
|
||||
return fmt.Errorf("%w: unknown report type %q", ErrLoopReportInvalid, r.Type)
|
||||
}
|
||||
if r.Scope == "" {
|
||||
return fmt.Errorf("%w: scope (canonical resource id) is required", ErrLoopReportInvalid)
|
||||
}
|
||||
if r.Trigger == "" {
|
||||
return fmt.Errorf("%w: trigger is required", ErrLoopReportInvalid)
|
||||
}
|
||||
if !IsValidLoopReportStatus(r.Status) {
|
||||
return fmt.Errorf("%w: unknown status %q", ErrLoopReportInvalid, r.Status)
|
||||
}
|
||||
if r.StartedAt.IsZero() {
|
||||
return fmt.Errorf("%w: startedAt is required", ErrLoopReportInvalid)
|
||||
}
|
||||
if r.CompletedAt.IsZero() {
|
||||
return fmt.Errorf("%w: completedAt is required", ErrLoopReportInvalid)
|
||||
}
|
||||
if r.CompletedAt.Before(r.StartedAt) {
|
||||
return fmt.Errorf("%w: completedAt must not be before startedAt", ErrLoopReportInvalid)
|
||||
}
|
||||
if !IsValidLoopReportUserOutcome(r.UserOutcome) {
|
||||
return fmt.Errorf("%w: unknown user outcome %q", ErrLoopReportInvalid, r.UserOutcome)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func trimAndDedupeStrings(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package unifiedresources
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ListResourceOperatorStates returns every persisted operator-set
|
||||
// state row. Order is not guaranteed.
|
||||
func (s *SQLiteResourceStore) ListResourceOperatorStates() ([]ResourceOperatorState, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT canonical_id, intentionally_offline, never_auto_remediate,
|
||||
maintenance_start_at, maintenance_end_at, maintenance_reason,
|
||||
criticality, note, set_at, set_by
|
||||
FROM resource_operator_state`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query resource operator states: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ResourceOperatorState
|
||||
for rows.Next() {
|
||||
var (
|
||||
state ResourceOperatorState
|
||||
intentional int
|
||||
neverRemediate int
|
||||
startAt, endAt sql.NullTime
|
||||
reason sql.NullString
|
||||
criticality sql.NullString
|
||||
note sql.NullString
|
||||
setBy sql.NullString
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&state.CanonicalID,
|
||||
&intentional,
|
||||
&neverRemediate,
|
||||
&startAt,
|
||||
&endAt,
|
||||
&reason,
|
||||
&criticality,
|
||||
¬e,
|
||||
&state.SetAt,
|
||||
&setBy,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan resource operator state row: %w", err)
|
||||
}
|
||||
state.IntentionallyOffline = intentional != 0
|
||||
state.NeverAutoRemediate = neverRemediate != 0
|
||||
if startAt.Valid {
|
||||
t := startAt.Time
|
||||
state.MaintenanceStartAt = &t
|
||||
}
|
||||
if endAt.Valid {
|
||||
t := endAt.Time
|
||||
state.MaintenanceEndAt = &t
|
||||
}
|
||||
if reason.Valid {
|
||||
state.MaintenanceReason = reason.String
|
||||
}
|
||||
if criticality.Valid {
|
||||
state.Criticality = ResourceCriticality(criticality.String)
|
||||
}
|
||||
if note.Valid {
|
||||
state.Note = note.String
|
||||
}
|
||||
if setBy.Valid {
|
||||
state.SetBy = setBy.String
|
||||
}
|
||||
out = append(out, state)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate resource operator state rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListResourceOperatorStates returns every persisted operator-set state row.
|
||||
func (m *MemoryStore) ListResourceOperatorStates() ([]ResourceOperatorState, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]ResourceOperatorState, 0, len(m.resourceOperatorState))
|
||||
for _, state := range m.resourceOperatorState {
|
||||
out = append(out, state)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ErrLoopReportNotFound is returned by store methods that target a
|
||||
// specific report id which does not exist.
|
||||
var ErrLoopReportNotFound = errors.New("loop_report_not_found")
|
||||
|
||||
// RecordLoopReport persists a new loop report. Reports are immutable
|
||||
// except for the user_outcome / reviewed_* / review_note fields, which
|
||||
// are updated via UpdateLoopReportUserOutcome. Tick-vs-tick dedup for
|
||||
// the (type, scope, window_ended_at) triple is enforced at the
|
||||
// sentinel layer (mutex + FindLoopReportByWindow) so explicit reruns,
|
||||
// which intentionally share the triple under a different id suffix,
|
||||
// can land alongside the original.
|
||||
func (s *SQLiteResourceStore) RecordLoopReport(report LoopReport) error {
|
||||
report = NormalizeLoopReport(report)
|
||||
if err := ValidateLoopReport(report); err != nil {
|
||||
return err
|
||||
}
|
||||
evidenceJSON, err := json.Marshal(report.Evidence)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal loop report evidence: %w", err)
|
||||
}
|
||||
findingIDsJSON, err := json.Marshal(report.LinkedFindingIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal loop report finding ids: %w", err)
|
||||
}
|
||||
alertIDsJSON, err := json.Marshal(report.LinkedAlertIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal loop report alert ids: %w", err)
|
||||
}
|
||||
actionIDsJSON, err := json.Marshal(report.LinkedActionIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal loop report action ids: %w", err)
|
||||
}
|
||||
var windowStart, windowEnd, reviewedAt sql.NullTime
|
||||
if report.WindowStartedAt != nil {
|
||||
windowStart.Time = *report.WindowStartedAt
|
||||
windowStart.Valid = true
|
||||
}
|
||||
if report.WindowEndedAt != nil {
|
||||
windowEnd.Time = *report.WindowEndedAt
|
||||
windowEnd.Valid = true
|
||||
}
|
||||
if report.ReviewedAt != nil {
|
||||
reviewedAt.Time = *report.ReviewedAt
|
||||
reviewedAt.Valid = true
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO loop_reports (
|
||||
id, report_type, scope, trigger, goal, status, started_at, completed_at,
|
||||
window_started_at, window_ended_at, evidence_json,
|
||||
linked_finding_ids_json, linked_alert_ids_json, linked_action_ids_json,
|
||||
linked_patrol_run_id, recommendation,
|
||||
user_outcome, reviewed_at, reviewed_by, review_note
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
report.ID,
|
||||
string(report.Type),
|
||||
report.Scope,
|
||||
report.Trigger,
|
||||
report.Goal,
|
||||
string(report.Status),
|
||||
report.StartedAt,
|
||||
report.CompletedAt,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
string(evidenceJSON),
|
||||
string(findingIDsJSON),
|
||||
string(alertIDsJSON),
|
||||
string(actionIDsJSON),
|
||||
report.LinkedPatrolRunID,
|
||||
report.Recommendation,
|
||||
string(report.UserOutcome),
|
||||
reviewedAt,
|
||||
report.ReviewedBy,
|
||||
report.ReviewNote,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert loop report: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLoopReport returns a single report by id.
|
||||
func (s *SQLiteResourceStore) GetLoopReport(reportID string) (LoopReport, bool, error) {
|
||||
reportID = strings.TrimSpace(reportID)
|
||||
if reportID == "" {
|
||||
return LoopReport{}, false, nil
|
||||
}
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, report_type, scope, trigger, goal, status, started_at, completed_at,
|
||||
window_started_at, window_ended_at, evidence_json,
|
||||
linked_finding_ids_json, linked_alert_ids_json, linked_action_ids_json,
|
||||
linked_patrol_run_id, recommendation,
|
||||
user_outcome, reviewed_at, reviewed_by, review_note
|
||||
FROM loop_reports
|
||||
WHERE id = ?`, reportID)
|
||||
report, err := scanLoopReportRow(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return LoopReport{}, false, nil
|
||||
}
|
||||
return LoopReport{}, false, err
|
||||
}
|
||||
return report, true, nil
|
||||
}
|
||||
|
||||
// FindLoopReportByWindow looks up an existing report for a
|
||||
// (type, scope, window-end) triple. Used by the sentinel to dedupe
|
||||
// before writing a new report.
|
||||
func (s *SQLiteResourceStore) FindLoopReportByWindow(reportType LoopReportType, canonicalID string, windowEndedAt time.Time) (LoopReport, bool, error) {
|
||||
canonicalID = CanonicalResourceID(canonicalID)
|
||||
if canonicalID == "" || !IsValidLoopReportType(reportType) || windowEndedAt.IsZero() {
|
||||
return LoopReport{}, false, nil
|
||||
}
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, report_type, scope, trigger, goal, status, started_at, completed_at,
|
||||
window_started_at, window_ended_at, evidence_json,
|
||||
linked_finding_ids_json, linked_alert_ids_json, linked_action_ids_json,
|
||||
linked_patrol_run_id, recommendation,
|
||||
user_outcome, reviewed_at, reviewed_by, review_note
|
||||
FROM loop_reports
|
||||
WHERE report_type = ? AND scope = ? AND window_ended_at = ?
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1`, string(reportType), canonicalID, windowEndedAt.UTC())
|
||||
report, err := scanLoopReportRow(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return LoopReport{}, false, nil
|
||||
}
|
||||
return LoopReport{}, false, err
|
||||
}
|
||||
return report, true, nil
|
||||
}
|
||||
|
||||
// ListLoopReportsForResource returns the most recent reports for a
|
||||
// resource of the given type, newest first. limit=0 returns all rows.
|
||||
func (s *SQLiteResourceStore) ListLoopReportsForResource(reportType LoopReportType, canonicalID string, limit int) ([]LoopReport, error) {
|
||||
canonicalID = CanonicalResourceID(canonicalID)
|
||||
if !IsValidLoopReportType(reportType) {
|
||||
return nil, nil
|
||||
}
|
||||
query := `
|
||||
SELECT id, report_type, scope, trigger, goal, status, started_at, completed_at,
|
||||
window_started_at, window_ended_at, evidence_json,
|
||||
linked_finding_ids_json, linked_alert_ids_json, linked_action_ids_json,
|
||||
linked_patrol_run_id, recommendation,
|
||||
user_outcome, reviewed_at, reviewed_by, review_note
|
||||
FROM loop_reports
|
||||
WHERE report_type = ? AND scope = ?
|
||||
ORDER BY started_at DESC`
|
||||
args := []any{string(reportType), canonicalID}
|
||||
if limit > 0 {
|
||||
query += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query loop reports: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []LoopReport
|
||||
for rows.Next() {
|
||||
report, err := scanLoopReportRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, report)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate loop report rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateLoopReportUserOutcome records the operator's review verdict on
|
||||
// a report. It does not change the underlying status or evidence —
|
||||
// those are computed by the sentinel and remain immutable.
|
||||
func (s *SQLiteResourceStore) UpdateLoopReportUserOutcome(reportID string, outcome LoopReportUserOutcome, reviewedBy, note string, reviewedAt time.Time) error {
|
||||
reportID = strings.TrimSpace(reportID)
|
||||
if reportID == "" {
|
||||
return fmt.Errorf("%w: id is required", ErrLoopReportInvalid)
|
||||
}
|
||||
if !IsValidLoopReportUserOutcome(outcome) {
|
||||
return fmt.Errorf("%w: unknown user outcome %q", ErrLoopReportInvalid, outcome)
|
||||
}
|
||||
if reviewedAt.IsZero() {
|
||||
reviewedAt = time.Now().UTC()
|
||||
} else {
|
||||
reviewedAt = reviewedAt.UTC()
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
res, err := s.db.Exec(`
|
||||
UPDATE loop_reports
|
||||
SET user_outcome = ?, reviewed_at = ?, reviewed_by = ?, review_note = ?
|
||||
WHERE id = ?`,
|
||||
string(outcome),
|
||||
reviewedAt,
|
||||
strings.TrimSpace(reviewedBy),
|
||||
strings.TrimSpace(note),
|
||||
reportID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update loop report user outcome: %w", err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected for update loop report: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrLoopReportNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loopReportScanner is the surface RowQuery / Rows share for
|
||||
// loop-report scanning.
|
||||
type loopReportScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanLoopReportRow(scanner loopReportScanner) (LoopReport, error) {
|
||||
var (
|
||||
r LoopReport
|
||||
typ, status, outcome string
|
||||
windowStart, windowEnd sql.NullTime
|
||||
reviewedAt sql.NullTime
|
||||
evidenceJSON string
|
||||
findingIDsJSON string
|
||||
alertIDsJSON string
|
||||
actionIDsJSON string
|
||||
linkedPatrolRunID string
|
||||
recommendation, goal string
|
||||
reviewedBy, reviewNote string
|
||||
trigger string
|
||||
)
|
||||
if err := scanner.Scan(
|
||||
&r.ID,
|
||||
&typ,
|
||||
&r.Scope,
|
||||
&trigger,
|
||||
&goal,
|
||||
&status,
|
||||
&r.StartedAt,
|
||||
&r.CompletedAt,
|
||||
&windowStart,
|
||||
&windowEnd,
|
||||
&evidenceJSON,
|
||||
&findingIDsJSON,
|
||||
&alertIDsJSON,
|
||||
&actionIDsJSON,
|
||||
&linkedPatrolRunID,
|
||||
&recommendation,
|
||||
&outcome,
|
||||
&reviewedAt,
|
||||
&reviewedBy,
|
||||
&reviewNote,
|
||||
); err != nil {
|
||||
return LoopReport{}, err
|
||||
}
|
||||
r.Type = LoopReportType(typ)
|
||||
r.Trigger = trigger
|
||||
r.Goal = goal
|
||||
r.Status = LoopReportStatus(status)
|
||||
r.UserOutcome = LoopReportUserOutcome(outcome)
|
||||
r.LinkedPatrolRunID = linkedPatrolRunID
|
||||
r.Recommendation = recommendation
|
||||
r.ReviewedBy = reviewedBy
|
||||
r.ReviewNote = reviewNote
|
||||
if windowStart.Valid {
|
||||
t := windowStart.Time.UTC()
|
||||
r.WindowStartedAt = &t
|
||||
}
|
||||
if windowEnd.Valid {
|
||||
t := windowEnd.Time.UTC()
|
||||
r.WindowEndedAt = &t
|
||||
}
|
||||
if reviewedAt.Valid {
|
||||
t := reviewedAt.Time.UTC()
|
||||
r.ReviewedAt = &t
|
||||
}
|
||||
if evidenceJSON != "" {
|
||||
if err := json.Unmarshal([]byte(evidenceJSON), &r.Evidence); err != nil {
|
||||
return LoopReport{}, fmt.Errorf("unmarshal loop report evidence: %w", err)
|
||||
}
|
||||
}
|
||||
if findingIDsJSON != "" && findingIDsJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(findingIDsJSON), &r.LinkedFindingIDs); err != nil {
|
||||
return LoopReport{}, fmt.Errorf("unmarshal loop report finding ids: %w", err)
|
||||
}
|
||||
}
|
||||
if alertIDsJSON != "" && alertIDsJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(alertIDsJSON), &r.LinkedAlertIDs); err != nil {
|
||||
return LoopReport{}, fmt.Errorf("unmarshal loop report alert ids: %w", err)
|
||||
}
|
||||
}
|
||||
if actionIDsJSON != "" && actionIDsJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(actionIDsJSON), &r.LinkedActionIDs); err != nil {
|
||||
return LoopReport{}, fmt.Errorf("unmarshal loop report action ids: %w", err)
|
||||
}
|
||||
}
|
||||
r.StartedAt = r.StartedAt.UTC()
|
||||
r.CompletedAt = r.CompletedAt.UTC()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// --- MemoryStore implementations ---
|
||||
|
||||
// RecordLoopReport stores a loop report in memory. Duplicate (type,
|
||||
// scope, window-end) triples are rejected with ErrLoopReportInvalid so
|
||||
// the sentinel's dedupe contract matches the SQLite store.
|
||||
func (m *MemoryStore) RecordLoopReport(report LoopReport) error {
|
||||
report = NormalizeLoopReport(report)
|
||||
if err := ValidateLoopReport(report); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.loopReports == nil {
|
||||
m.loopReports = make(map[string]LoopReport)
|
||||
}
|
||||
if _, exists := m.loopReports[report.ID]; exists {
|
||||
return fmt.Errorf("%w: id %q already exists", ErrLoopReportInvalid, report.ID)
|
||||
}
|
||||
m.loopReports[report.ID] = report
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLoopReport returns the report by id, if present.
|
||||
func (m *MemoryStore) GetLoopReport(reportID string) (LoopReport, bool, error) {
|
||||
reportID = strings.TrimSpace(reportID)
|
||||
if reportID == "" {
|
||||
return LoopReport{}, false, nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
report, ok := m.loopReports[reportID]
|
||||
return report, ok, nil
|
||||
}
|
||||
|
||||
// FindLoopReportByWindow scans the in-memory map for a matching report.
|
||||
func (m *MemoryStore) FindLoopReportByWindow(reportType LoopReportType, canonicalID string, windowEndedAt time.Time) (LoopReport, bool, error) {
|
||||
canonicalID = CanonicalResourceID(canonicalID)
|
||||
if canonicalID == "" || !IsValidLoopReportType(reportType) || windowEndedAt.IsZero() {
|
||||
return LoopReport{}, false, nil
|
||||
}
|
||||
target := windowEndedAt.UTC()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, report := range m.loopReports {
|
||||
if report.Type != reportType || report.Scope != canonicalID {
|
||||
continue
|
||||
}
|
||||
if report.WindowEndedAt == nil {
|
||||
continue
|
||||
}
|
||||
if report.WindowEndedAt.Equal(target) {
|
||||
return report, true, nil
|
||||
}
|
||||
}
|
||||
return LoopReport{}, false, nil
|
||||
}
|
||||
|
||||
// ListLoopReportsForResource returns matching reports sorted by
|
||||
// started_at DESC.
|
||||
func (m *MemoryStore) ListLoopReportsForResource(reportType LoopReportType, canonicalID string, limit int) ([]LoopReport, error) {
|
||||
canonicalID = CanonicalResourceID(canonicalID)
|
||||
if !IsValidLoopReportType(reportType) {
|
||||
return nil, nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := []LoopReport{}
|
||||
for _, report := range m.loopReports {
|
||||
if report.Type != reportType || report.Scope != canonicalID {
|
||||
continue
|
||||
}
|
||||
out = append(out, report)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].StartedAt.After(out[j].StartedAt)
|
||||
})
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateLoopReportUserOutcome records the operator's review verdict.
|
||||
func (m *MemoryStore) UpdateLoopReportUserOutcome(reportID string, outcome LoopReportUserOutcome, reviewedBy, note string, reviewedAt time.Time) error {
|
||||
reportID = strings.TrimSpace(reportID)
|
||||
if reportID == "" {
|
||||
return fmt.Errorf("%w: id is required", ErrLoopReportInvalid)
|
||||
}
|
||||
if !IsValidLoopReportUserOutcome(outcome) {
|
||||
return fmt.Errorf("%w: unknown user outcome %q", ErrLoopReportInvalid, outcome)
|
||||
}
|
||||
if reviewedAt.IsZero() {
|
||||
reviewedAt = time.Now().UTC()
|
||||
} else {
|
||||
reviewedAt = reviewedAt.UTC()
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
report, ok := m.loopReports[reportID]
|
||||
if !ok {
|
||||
return ErrLoopReportNotFound
|
||||
}
|
||||
report.UserOutcome = outcome
|
||||
report.ReviewedAt = &reviewedAt
|
||||
report.ReviewedBy = strings.TrimSpace(reviewedBy)
|
||||
report.ReviewNote = strings.TrimSpace(note)
|
||||
m.loopReports[reportID] = report
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package unifiedresources
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newLoopReport(id, scope string, windowEnd time.Time, status LoopReportStatus) LoopReport {
|
||||
return LoopReport{
|
||||
ID: id,
|
||||
Type: LoopReportTypeMaintenanceVerification,
|
||||
Scope: scope,
|
||||
Trigger: "maintenance_window_end",
|
||||
Goal: "verify recovery",
|
||||
Status: status,
|
||||
StartedAt: windowEnd.Add(time.Minute),
|
||||
CompletedAt: windowEnd.Add(time.Minute),
|
||||
WindowEndedAt: &windowEnd,
|
||||
Evidence: LoopReportEvidence{OperatorStateSummary: "maintenance window ended"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRecordLoopReport_RoundTrip(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
store, err := NewSQLiteResourceStore(dataDir, "default")
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
report := newLoopReport("mv-vm_101-20260512T120000Z", "vm:101", windowEnd, LoopReportStatusHealthy)
|
||||
if err := store.RecordLoopReport(report); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
got, found, err := store.GetLoopReport(report.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected report to be found after record")
|
||||
}
|
||||
if got.Scope != "vm:101" || got.Status != LoopReportStatusHealthy {
|
||||
t.Fatalf("round trip mismatch: scope=%q status=%q", got.Scope, got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRecordLoopReport_AllowsRerunForSameWindow(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
store, err := NewSQLiteResourceStore(dataDir, "default")
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
original := newLoopReport("mv-vm_101-20260512T120000Z", "vm:101", windowEnd, LoopReportStatusHealthy)
|
||||
if err := store.RecordLoopReport(original); err != nil {
|
||||
t.Fatalf("record original: %v", err)
|
||||
}
|
||||
rerun := newLoopReport(original.ID+"-rerun-1", "vm:101", windowEnd, LoopReportStatusNeedsReview)
|
||||
if err := store.RecordLoopReport(rerun); err != nil {
|
||||
t.Fatalf("record rerun: %v", err)
|
||||
}
|
||||
|
||||
reports, err := store.ListLoopReportsForResource(LoopReportTypeMaintenanceVerification, "vm:101", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(reports) != 2 {
|
||||
t.Fatalf("expected 2 reports (original + rerun), got %d", len(reports))
|
||||
}
|
||||
|
||||
found, ok, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:101", windowEnd)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("find by window: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if found.ID == "" {
|
||||
t.Fatal("expected matching report id from FindLoopReportByWindow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRecordLoopReport_RejectsDuplicateID(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
store, err := NewSQLiteResourceStore(dataDir, "default")
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
report := newLoopReport("mv-dup-id", "vm:101", windowEnd, LoopReportStatusHealthy)
|
||||
if err := store.RecordLoopReport(report); err != nil {
|
||||
t.Fatalf("first record: %v", err)
|
||||
}
|
||||
if err := store.RecordLoopReport(report); err == nil {
|
||||
t.Fatal("expected error on duplicate id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteUpdateLoopReportUserOutcome_RoundTrip(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
store, err := NewSQLiteResourceStore(dataDir, "default")
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
report := newLoopReport("mv-review", "vm:101", windowEnd, LoopReportStatusNeedsReview)
|
||||
if err := store.RecordLoopReport(report); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
reviewedAt := time.Date(2026, 5, 12, 13, 0, 0, 0, time.UTC)
|
||||
if err := store.UpdateLoopReportUserOutcome(report.ID, LoopReportUserOutcomeReviewed, "rcourtman", "ack", reviewedAt); err != nil {
|
||||
t.Fatalf("update outcome: %v", err)
|
||||
}
|
||||
got, _, err := store.GetLoopReport(report.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.UserOutcome != LoopReportUserOutcomeReviewed {
|
||||
t.Fatalf("outcome = %q want reviewed", got.UserOutcome)
|
||||
}
|
||||
if got.ReviewedBy != "rcourtman" || got.ReviewNote != "ack" {
|
||||
t.Fatalf("review fields = %q/%q want rcourtman/ack", got.ReviewedBy, got.ReviewNote)
|
||||
}
|
||||
if got.ReviewedAt == nil || !got.ReviewedAt.Equal(reviewedAt) {
|
||||
t.Fatalf("reviewedAt = %v want %v", got.ReviewedAt, reviewedAt)
|
||||
}
|
||||
if got.Status != LoopReportStatusNeedsReview {
|
||||
t.Fatalf("status mutated to %q; should remain needs_review", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreRecordLoopReport_AllowsRerun(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
original := newLoopReport("mv-mem-1", "vm:101", windowEnd, LoopReportStatusHealthy)
|
||||
if err := store.RecordLoopReport(original); err != nil {
|
||||
t.Fatalf("record original: %v", err)
|
||||
}
|
||||
rerun := newLoopReport("mv-mem-1-rerun-1", "vm:101", windowEnd, LoopReportStatusNeedsReview)
|
||||
if err := store.RecordLoopReport(rerun); err != nil {
|
||||
t.Fatalf("record rerun: %v", err)
|
||||
}
|
||||
reports, err := store.ListLoopReportsForResource(LoopReportTypeMaintenanceVerification, "vm:101", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(reports) != 2 {
|
||||
t.Fatalf("expected 2 reports, got %d", len(reports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteListResourceOperatorStates_ReturnsAllRows(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
store, err := NewSQLiteResourceStore(dataDir, "default")
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
startA := now.Add(-time.Hour)
|
||||
endA := now.Add(-time.Minute)
|
||||
startB := now.Add(-30 * time.Minute)
|
||||
endB := now.Add(30 * time.Minute)
|
||||
|
||||
for _, s := range []ResourceOperatorState{
|
||||
{CanonicalID: "vm:101", MaintenanceStartAt: &startA, MaintenanceEndAt: &endA, SetAt: now, SetBy: "op"},
|
||||
{CanonicalID: "vm:102", MaintenanceStartAt: &startB, MaintenanceEndAt: &endB, SetAt: now, SetBy: "op"},
|
||||
} {
|
||||
if err := store.SetResourceOperatorState(s); err != nil {
|
||||
t.Fatalf("seed %s: %v", s.CanonicalID, err)
|
||||
}
|
||||
}
|
||||
got, err := store.ListResourceOperatorStates()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 operator states, got %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,22 @@ type ResourceStore interface {
|
||||
GetResourceOperatorState(canonicalID string) (ResourceOperatorState, bool, error)
|
||||
SetResourceOperatorState(state ResourceOperatorState) error
|
||||
ClearResourceOperatorState(canonicalID string) error
|
||||
// ListResourceOperatorStates returns every persisted operator-set
|
||||
// state row. Used by background loops (e.g. the maintenance
|
||||
// verification sentinel) that need to sweep the full set on each
|
||||
// tick. Order is implementation-defined; the caller sorts if it
|
||||
// cares.
|
||||
ListResourceOperatorStates() ([]ResourceOperatorState, error)
|
||||
// Loop reports — durable summaries written by background loops
|
||||
// (currently only the maintenance-verification sentinel).
|
||||
RecordLoopReport(report LoopReport) error
|
||||
GetLoopReport(reportID string) (LoopReport, bool, error)
|
||||
ListLoopReportsForResource(reportType LoopReportType, canonicalID string, limit int) ([]LoopReport, error)
|
||||
UpdateLoopReportUserOutcome(reportID string, outcome LoopReportUserOutcome, reviewedBy, note string, reviewedAt time.Time) error
|
||||
// FindLoopReportByWindow looks up an existing report by the
|
||||
// (type, scope, window-end) triple so the sentinel can dedupe
|
||||
// on each tick without scanning the whole table.
|
||||
FindLoopReportByWindow(reportType LoopReportType, canonicalID string, windowEndedAt time.Time) (LoopReport, bool, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -369,6 +385,38 @@ func (s *SQLiteResourceStore) initSchema() error {
|
||||
set_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_operator_state_maintenance ON resource_operator_state(maintenance_end_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS loop_reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
report_type TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL,
|
||||
goal TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
started_at DATETIME NOT NULL,
|
||||
completed_at DATETIME NOT NULL,
|
||||
window_started_at DATETIME,
|
||||
window_ended_at DATETIME,
|
||||
evidence_json TEXT NOT NULL DEFAULT '{}',
|
||||
linked_finding_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
linked_alert_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
linked_action_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
linked_patrol_run_id TEXT NOT NULL DEFAULT '',
|
||||
recommendation TEXT NOT NULL DEFAULT '',
|
||||
user_outcome TEXT NOT NULL DEFAULT '',
|
||||
reviewed_at DATETIME,
|
||||
reviewed_by TEXT NOT NULL DEFAULT '',
|
||||
review_note TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_loop_reports_scope_type_started
|
||||
ON loop_reports(report_type, scope, started_at DESC);
|
||||
-- Look-up index for FindLoopReportByWindow. Intentionally non-unique:
|
||||
-- rerun records share (type, scope, window_ended_at) with the
|
||||
-- original under a distinct id suffix. Tick-vs-tick dedup runs at
|
||||
-- the sentinel layer (mutex + FindLoopReportByWindow check).
|
||||
CREATE INDEX IF NOT EXISTS idx_loop_reports_window_lookup
|
||||
ON loop_reports(report_type, scope, window_ended_at)
|
||||
WHERE window_ended_at IS NOT NULL;
|
||||
`
|
||||
|
||||
_, err := s.db.Exec(schema)
|
||||
@@ -1565,11 +1613,13 @@ type MemoryStore struct {
|
||||
actionLifecycleEvents []ActionLifecycleEvent
|
||||
exportAudits []ExportAuditRecord
|
||||
resourceOperatorState map[string]ResourceOperatorState
|
||||
loopReports map[string]LoopReport
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
resourceOperatorState: make(map[string]ResourceOperatorState),
|
||||
loopReports: make(map[string]LoopReport),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user