mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
feat(alerts): migrate persisted alert identities
This commit is contained in:
@@ -152,7 +152,13 @@ Post-plan hardening (2026-08-27, same-day follow-through):
|
||||
included — records its fired event. The JSON snapshot history file is
|
||||
retired. Parity with the JSON model is pinned by
|
||||
`history_projection_parity_test.go`.
|
||||
- **The identity/config migration is registered, not rushed:** governed
|
||||
coverage gap `alert-identity-config-migration` records why the alias
|
||||
layers persist until a versioned migration rewrites persisted
|
||||
override keys and ack records to canonical identity.
|
||||
- **The identity/config migration is complete.** Alert configuration now
|
||||
carries an additive identity schema version and the monitor runs a
|
||||
non-mutating, fail-closed migration plan against the live resource registry
|
||||
before persisting it. Proven guest, guest-disk, storage, Docker, and
|
||||
provider-declared succession keys move to their single write identity;
|
||||
ambiguous, conflicting, unknown, and temporarily absent rows are retained.
|
||||
Active-alert snapshots (including acknowledgement fields) are rewritten with
|
||||
canonical state IDs after restore. The planner is idempotent across rollback
|
||||
and re-upgrade, rejects future schema versions, and the frontend round-trips
|
||||
the marker on ordinary configuration saves.
|
||||
|
||||
@@ -9763,39 +9763,6 @@
|
||||
"kind": "file"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "alert-identity-config-migration",
|
||||
"summary": "Persisted alert state (threshold override keys, ack records, active-alert IDs) still carries legacy identity forms bridged at read time by alias layers (thresholdOverrideForResourceNoLock registry translation, lookupGuestOverride's three-key fallback, storageThresholdLookupIDs, testAlertEquivalentIDs' ~8 equivalent forms). The Phase 3 policy fold contains the bridging behind one resolution surface, but the alias code must be maintained until an explicit, versioned config migration rewrites persisted keys to canonical identity. Deliberately deferred from the 2026-08 evolution work: rewriting user override keys requires the live resource registry for translation and carries the #1738 failure mode in reverse (silently orphaning overrides), so it needs its own migration design, dry-run tooling, and test round rather than landing mid-RC.",
|
||||
"owner": "project-owner",
|
||||
"status": "triaged",
|
||||
"recorded_at": "2026-08-27",
|
||||
"lane_ids": [
|
||||
"L6",
|
||||
"L13"
|
||||
],
|
||||
"subsystem_ids": [
|
||||
"alerts"
|
||||
],
|
||||
"proposed_resolution": "lane-expansion",
|
||||
"coverage_impact": 3,
|
||||
"evidence": [
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/ALERT_ENGINE_EVOLUTION.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/alerts/alert_policy.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/alerts/guest_override_identity.go",
|
||||
"kind": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"candidate_lanes": [],
|
||||
|
||||
@@ -1821,8 +1821,11 @@ active-alert persistence. Shared canonical identity helpers may infer resource,
|
||||
spec, and state ids from legacy alerts, but they must do so without mutating
|
||||
live in-memory alert instances unless the caller explicitly backfills that
|
||||
state. Persisted active-alert snapshots must therefore clone alerts under lock,
|
||||
backfill canonical identity on the clone, and serialize that snapshot instead
|
||||
of mutating the live alert map during async saves or incident rebuilds.
|
||||
backfill canonical identity on the clone, replace the persisted public ID with
|
||||
the canonical state ID, and serialize that snapshot instead of mutating the
|
||||
live alert map during async saves or incident rebuilds. Restore must preserve
|
||||
acknowledgement fields and rewrite a successfully derived legacy snapshot
|
||||
atomically after the in-memory restore; unrecognized records remain readable.
|
||||
That same ownership also governs acknowledgement and manual-clear cleanup.
|
||||
Clearing an alert through the canonical alerts runtime must remove both legacy
|
||||
public-id tracking and canonical-state acknowledgement records so old aliases
|
||||
@@ -1872,6 +1875,36 @@ reload behavior, and
|
||||
`frontend-modern/src/features/alerts/thresholds/hooks/__tests__/truenasThresholdPersistence.test.tsx`
|
||||
proves the browser-side TrueNAS save/refetch contract.
|
||||
|
||||
### Versioned persisted alert identity
|
||||
|
||||
`AlertConfig.identitySchemaVersion` is the additive persisted identity marker;
|
||||
the current schema is version 1. `PlanAlertIdentityMigration` is the mandatory
|
||||
dry-run boundary: it operates on a copied config and reports source/target
|
||||
versions, removed and added keys, deferred rows, and unsupported future
|
||||
versions before any write. `ApplyAlertIdentityMigration` may install that exact
|
||||
plan only while its source version is still current. The monitoring bridge must
|
||||
persist the result before updating the live manager.
|
||||
|
||||
Version 1 folds provider-declared canonical succession, Docker container
|
||||
recreation IDs, Proxmox guest and guest-disk aliases, and live storage aliases
|
||||
onto the single write identity already used by the editor/evaluator. A current
|
||||
row wins over retired duplicates. Multiple legacy rows claiming one target,
|
||||
ambiguous live ownership, unknown rows, and temporarily absent resources fail
|
||||
closed and stay in `alerts.json` for a later resource snapshot that can prove
|
||||
the mapping. Alias readers remain compatibility inputs for those deferred rows,
|
||||
not persistence authority. Storage alias provenance is carried on the unified
|
||||
resource `StorageMeta` so the plan does not reconstruct ownership from display
|
||||
names alone.
|
||||
|
||||
The marker is rollback-safe: older binaries ignore the additive JSON field and
|
||||
may omit it on a later save; after re-upgrade the idempotent planner runs again.
|
||||
Newer binaries preserve an already-held marker when a stale browser payload
|
||||
omits it, while the alert editor explicitly round-trips the marker. A config
|
||||
whose marker is newer than the running binary is never rewritten. Regression
|
||||
ownership is `internal/alerts/alert_identity_migration_test.go`,
|
||||
`internal/monitoring/monitor_alert_override_migration_test.go`, and
|
||||
`frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts`.
|
||||
|
||||
### Versioned alert-intent policy
|
||||
|
||||
The alerts runtime owns one versioned alert-intent document and its durable
|
||||
|
||||
@@ -4603,6 +4603,13 @@ visibility preference decides whether they are shown. A feature must not build
|
||||
counts from page-wide alert totals after search or another active facet has
|
||||
narrowed the rendered rows, and a time-scope option must not claim a count from
|
||||
an unfetched period.
|
||||
The Alert History frequency axis is also a responsive contract. Desktop keeps
|
||||
the complete model-owned tick set; below the `sm` breakpoint the render surface
|
||||
keeps only start, midpoint, and end labels so locale-formatted timestamps do
|
||||
not overlap or create horizontal overflow. The 390px operator qualification
|
||||
must assert three visible labels, non-overlapping client rectangles, keyboard
|
||||
reachability, and a contained document before its actual-pixels receipt is
|
||||
recorded.
|
||||
Because that popover combines view application, default selection, removal,
|
||||
and an inline naming form, it is a labelled non-modal dialog rather than an
|
||||
ARIA menu. Its trigger exposes the dialog relationship, Escape returns focus
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
|
||||
Own polling, typed collection, runtime state assembly, and canonical monitoring
|
||||
truth for live infrastructure data.
|
||||
Monitoring supplies the live unified-resource snapshot used by the alerts-owned
|
||||
versioned identity migration. The migration must be planned without mutating
|
||||
the active configuration, persist successfully before the alert manager adopts
|
||||
it, leave ambiguous identities untouched, and reject schema versions newer than
|
||||
the running binary. Monitoring owns this orchestration only; alert identity,
|
||||
override semantics, and the schema version remain alerts authority.
|
||||
Agent Doctor interprets Proxmox capability profiles according to the monitored
|
||||
product. Only PVE host profiles require a link to a PVE node; an explicit PBS
|
||||
profile, or an auto/missing-type host that matches a configured PBS instance by
|
||||
|
||||
@@ -35,6 +35,12 @@ Own the storage and recovery product surfaces, recovery-point persistence and
|
||||
querying, and the operator-facing storage health presentation layer while
|
||||
keeping adjacent commercial reporting APIs out of storage/recovery product
|
||||
state.
|
||||
Unified storage metadata may carry source alias IDs so the alerts subsystem can
|
||||
rewrite legacy threshold keys to the current metrics-target identity. Those
|
||||
aliases are compatibility evidence only: they do not retarget a datastore,
|
||||
backup artifact, recovery point, protection posture, or restore operation, and
|
||||
storage/recovery consumers must continue to use the canonical resource and
|
||||
provider identities for all domain authority.
|
||||
The Proxmox overview is also a large-estate read-side consumer: it owns one
|
||||
canonical unified-resource snapshot for its node and guest regions, and its
|
||||
shared workloads adapter must consume that snapshot without starting a second
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
|
||||
Own canonical resource identity, type normalization, typed views, and
|
||||
cross-source deduplication.
|
||||
Storage metadata may expose source-authored alias IDs as compatibility evidence
|
||||
for one-time consumers such as the alerts identity migration. Adapters and
|
||||
clones must preserve that bounded alias set without promoting it to primary
|
||||
identity: `Resource.ID`, canonical identity, and the storage metrics target
|
||||
remain authoritative, and ambiguous alias claims must fail closed.
|
||||
One machine may belong to more than one typed view without becoming more than
|
||||
one canonical resource. A matching Pulse host report and Docker report merge
|
||||
into one `agent` resource with Agent and Docker facets, and the Hosts and
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "96add5d57a2b703e756eb7157b4faa2f7db37c55",
|
||||
"verified_at": "2026-08-27T10:04:09Z",
|
||||
"base_sha": "ff27c3881f0c418de856b26175cb2fcd96a962ec",
|
||||
"verified_at": "2026-08-27T10:55:17Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"
|
||||
"frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx",
|
||||
"frontend-modern/src/features/alerts/alertsConfigurationModel.ts",
|
||||
"frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts",
|
||||
"frontend-modern/src/types/alerts.ts",
|
||||
"frontend-modern/src/types/resource.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "8fb34851a8bcc32c67a4f4f156fdc32f62236a0fa8c5e2aa8b3d4dfcc8ecd47f"
|
||||
"frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx": "7794ba25adfb94f5bf11a718ad774ea717dc7e8b543572ddfb461465c0593515",
|
||||
"frontend-modern/src/features/alerts/alertsConfigurationModel.ts": "d295586ac8044fda4930ee56a9ea6b1ca2bfb27792ce594f4fb9d5fc0cbf1243",
|
||||
"frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts": "3cd18ea4cf402a5a5594e63eec7bb98bc0dbad8031c8b610ee9c10f3d2caed98",
|
||||
"frontend-modern/src/types/alerts.ts": "5d57daaa49c83caa5ce40217b9bca1daf17867743d311d472724d6d5210349b1",
|
||||
"frontend-modern/src/types/resource.ts": "b1c07de178e5b8a6725bb555031af8df0abf9a731dcf84e7791c70d95b399053"
|
||||
},
|
||||
"routes": ["/proxmox/backups/date"],
|
||||
"routes": ["/alerts/history"],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
@@ -21,16 +29,15 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"mock-mode Proxmox Backups loaded with backup-vault offsite-vault, backup-vault primary-vault, and dr-vault primary-vault rows in deterministic server and datastore order",
|
||||
"the backup-vault primary-vault row expanded into the canonical resource detail drawer",
|
||||
"the primary-vault row remained expanded after multiple two-second mock provider refreshes while row order remained unchanged",
|
||||
"the same deterministic order and expanded primary-vault row rendered in the compact phone table"
|
||||
"live development History tab loaded with the seven-day alert-frequency chart and 114 history rows",
|
||||
"desktop chart rendered all five time-axis labels without overlap or horizontal page overflow",
|
||||
"phone chart rendered the start, midpoint, and Now labels without overlap or horizontal page overflow",
|
||||
"phone history cards remained contained and readable at 390px width"
|
||||
],
|
||||
"interactions": [
|
||||
"expanded the backup-vault primary-vault row and confirmed the matching detail drawer appeared",
|
||||
"waited across multiple live mock refresh intervals and confirmed offsite-vault remained before primary-vault and the expanded row did not reset",
|
||||
"resized from 1280x720 to 390x844 and confirmed the row stayed expanded with compact Server, State, Store, Used, and Backups columns",
|
||||
"confirmed document and body scroll widths matched both viewports with no horizontal page overflow",
|
||||
"opened the History tab against the running development backend and inspected the rendered alert-frequency chart",
|
||||
"measured every visible desktop and phone axis-label bounding box and confirmed no adjacent boxes overlapped",
|
||||
"resized from 1440x1000 to 390x844 and confirmed document scroll width continued to match the viewport",
|
||||
"confirmed the browser console contained no errors during the final desktop and phone verification flow"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -174,23 +174,36 @@ export function AlertHistoryFrequencyCard(props: AlertHistoryFrequencyCardProps)
|
||||
<div class="relative mt-3 h-10">
|
||||
<div class="absolute inset-x-0 top-0 h-px bg-surface-hover"></div>
|
||||
<For each={props.state.axisTicks()}>
|
||||
{(tick) => (
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 flex h-full flex-col items-center"
|
||||
style={{ left: `${tick.position * 100}%` }}
|
||||
>
|
||||
<div class="h-3 w-px bg-slate-300"></div>
|
||||
{(tick, index) => {
|
||||
const lastIndex = props.state.axisTicks().length - 1;
|
||||
const mobileMidpoint = Math.floor(lastIndex / 2);
|
||||
const visibleOnMobile =
|
||||
index() === 0 || index() === mobileMidpoint || index() === lastIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="mt-1 whitespace-nowrap text-[10px] text-muted transform"
|
||||
data-testid="alert-frequency-axis-tick"
|
||||
class="pointer-events-none absolute top-0 h-full flex-col items-center sm:flex"
|
||||
classList={{
|
||||
'-translate-x-1/2': tick.align === 'center',
|
||||
'-translate-x-full': tick.align === 'end',
|
||||
hidden: !visibleOnMobile,
|
||||
flex: visibleOnMobile,
|
||||
}}
|
||||
style={{ left: `${tick.position * 100}%` }}
|
||||
>
|
||||
{tick.label}
|
||||
<div class="h-3 w-px bg-slate-300"></div>
|
||||
<div
|
||||
data-testid="alert-frequency-axis-label"
|
||||
class="mt-1 whitespace-nowrap text-[10px] text-muted transform"
|
||||
classList={{
|
||||
'-translate-x-1/2': tick.align === 'center',
|
||||
'-translate-x-full': tick.align === 'end',
|
||||
}}
|
||||
>
|
||||
{tick.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
+17
@@ -31,6 +31,10 @@ const UNDEF_TRIGGER = { trigger: undefined };
|
||||
describe('readAlertsConfigurationSnapshot — absent optional sections', () => {
|
||||
const snapshot = readAlertsConfigurationSnapshot(cfg({}));
|
||||
|
||||
it('defaults the persisted identity schema marker to zero', () => {
|
||||
expect(snapshot.identitySchemaVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps factory guestDefaults / nodeDefaults / agentDefaults', () => {
|
||||
expect(snapshot.guestDefaults.cpu).toBe(80);
|
||||
expect(snapshot.guestDefaults.memory).toBe(85);
|
||||
@@ -1334,6 +1338,19 @@ describe('buildAlertsConfigurationPayload — additional branches', () => {
|
||||
expect(alertConfig?.observationWindowHours).toBe(48);
|
||||
});
|
||||
|
||||
it('round-trips the persisted identity schema marker', () => {
|
||||
const snapshot = readAlertsConfigurationSnapshot(cfg({ identitySchemaVersion: 1 }));
|
||||
|
||||
const { alertConfig } = buildAlertsConfigurationPayload({
|
||||
snapshot,
|
||||
rawOverridesConfig: {},
|
||||
alertsActivationState: null,
|
||||
alertsActivationConfig: null,
|
||||
});
|
||||
|
||||
expect(alertConfig?.identitySchemaVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to 24/72/true for null freshHours/staleHours/alertOrphaned', () => {
|
||||
const snapshot = createDefaultAlertsConfigurationSnapshot();
|
||||
snapshot.backupDefaults = {
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('alertsConfigurationModel', () => {
|
||||
it('creates the canonical default snapshot', () => {
|
||||
const snapshot = createDefaultAlertsConfigurationSnapshot();
|
||||
|
||||
expect(snapshot.identitySchemaVersion).toBe(0);
|
||||
expect(snapshot.guestDefaults.cpu).toBe(FACTORY_GUEST_DEFAULTS.cpu);
|
||||
expect(snapshot.dockerDefaults.serviceWarnGapPercent).toBe(
|
||||
FACTORY_DOCKER_DEFAULTS.serviceWarnGapPercent,
|
||||
@@ -31,6 +32,7 @@ describe('alertsConfigurationModel', () => {
|
||||
|
||||
it('normalizes alert config into the runtime snapshot', () => {
|
||||
const config = {
|
||||
identitySchemaVersion: 1,
|
||||
enabled: true,
|
||||
guestDefaults: {
|
||||
cpu: { trigger: 91, clear: 86 },
|
||||
@@ -80,6 +82,7 @@ describe('alertsConfigurationModel', () => {
|
||||
|
||||
const snapshot = readAlertsConfigurationSnapshot(config);
|
||||
|
||||
expect(snapshot.identitySchemaVersion).toBe(1);
|
||||
expect(snapshot.guestDefaults.cpu).toBe(91);
|
||||
expect(snapshot.guestDisableConnectivity).toBe(true);
|
||||
expect(snapshot.guestPoweredOffSeverity).toBe('critical');
|
||||
@@ -108,6 +111,7 @@ describe('alertsConfigurationModel', () => {
|
||||
|
||||
it('builds the canonical save payload from the runtime snapshot', () => {
|
||||
const snapshot = createDefaultAlertsConfigurationSnapshot();
|
||||
snapshot.identitySchemaVersion = 1;
|
||||
snapshot.guestDefaults.cpu = 92;
|
||||
snapshot.dockerIgnoredPrefixes = [' web ', ''];
|
||||
snapshot.guestTagWhitelist = [' prod ', ''];
|
||||
@@ -149,6 +153,7 @@ describe('alertsConfigurationModel', () => {
|
||||
|
||||
expect(result.dockerValidationError).toBeUndefined();
|
||||
expect(result.alertConfig).toBeDefined();
|
||||
expect(result.alertConfig?.identitySchemaVersion).toBe(1);
|
||||
expect(result.alertConfig?.guestDefaults.cpu).toEqual({ trigger: 92, clear: 87 });
|
||||
expect(result.alertConfig?.vmwareDefaults?.cpu).toEqual({ trigger: 80, clear: 75 });
|
||||
expect(result.alertConfig?.vmwareDefaults?.usage).toEqual({ trigger: 85, clear: 80 });
|
||||
|
||||
@@ -64,6 +64,7 @@ export {
|
||||
} from '@/utils/alertThresholdDefaults';
|
||||
|
||||
export interface AlertsConfigurationSnapshot {
|
||||
identitySchemaVersion: number;
|
||||
scheduleQuietHours: QuietHoursConfig;
|
||||
scheduleCooldown: CooldownConfig;
|
||||
scheduleGrouping: GroupingConfig;
|
||||
@@ -238,6 +239,7 @@ const normalizeNotificationDeliveryTarget = (
|
||||
|
||||
export function createDefaultAlertsConfigurationSnapshot(): AlertsConfigurationSnapshot {
|
||||
return {
|
||||
identitySchemaVersion: 0,
|
||||
scheduleQuietHours: createDefaultQuietHours(),
|
||||
scheduleCooldown: createDefaultCooldown(),
|
||||
scheduleGrouping: createDefaultGrouping(),
|
||||
@@ -329,6 +331,8 @@ export function createDefaultAlertsConfigurationSnapshot(): AlertsConfigurationS
|
||||
export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConfigurationSnapshot {
|
||||
const snapshot = createDefaultAlertsConfigurationSnapshot();
|
||||
|
||||
snapshot.identitySchemaVersion = Math.max(0, Math.trunc(config.identitySchemaVersion ?? 0));
|
||||
|
||||
if (config.guestDefaults) {
|
||||
snapshot.guestDefaults = {
|
||||
cpu: getTriggerValue(config.guestDefaults.cpu) ?? FACTORY_GUEST_DEFAULTS.cpu,
|
||||
@@ -721,6 +725,7 @@ export function buildAlertsConfigurationPayload({
|
||||
|
||||
return {
|
||||
alertConfig: {
|
||||
identitySchemaVersion: snapshot.identitySchemaVersion || undefined,
|
||||
enabled: alertsActivationConfig?.enabled ?? true,
|
||||
activationState: alertsActivationState ?? undefined,
|
||||
activationTime: alertsActivationConfig?.activationTime ?? undefined,
|
||||
|
||||
@@ -37,6 +37,9 @@ export function useAlertsConfigurationSnapshotState(
|
||||
props: UseAlertsConfigurationSnapshotStateProps,
|
||||
) {
|
||||
const defaultSnapshot = createDefaultAlertsConfigurationSnapshot();
|
||||
const [identitySchemaVersion, setIdentitySchemaVersion] = createSignal(
|
||||
defaultSnapshot.identitySchemaVersion,
|
||||
);
|
||||
const [scheduleQuietHours, setScheduleQuietHours] = createSignal<QuietHoursConfig>(
|
||||
defaultSnapshot.scheduleQuietHours,
|
||||
);
|
||||
@@ -163,6 +166,7 @@ export function useAlertsConfigurationSnapshotState(
|
||||
);
|
||||
|
||||
const applyConfigurationSnapshot = (snapshot: AlertsConfigurationSnapshot) => {
|
||||
setIdentitySchemaVersion(snapshot.identitySchemaVersion);
|
||||
setScheduleQuietHours({
|
||||
...snapshot.scheduleQuietHours,
|
||||
days: { ...snapshot.scheduleQuietHours.days },
|
||||
@@ -225,6 +229,7 @@ export function useAlertsConfigurationSnapshotState(
|
||||
};
|
||||
|
||||
const captureConfigurationSnapshot = (): AlertsConfigurationSnapshot => ({
|
||||
identitySchemaVersion: identitySchemaVersion(),
|
||||
scheduleQuietHours: {
|
||||
...scheduleQuietHours(),
|
||||
days: { ...scheduleQuietHours().days },
|
||||
|
||||
@@ -44,6 +44,19 @@ function createResource(overrides: Partial<Resource> = {}): Resource {
|
||||
}
|
||||
|
||||
describe('Resource Type Guards', () => {
|
||||
it('preserves storage alias IDs as compatibility metadata', () => {
|
||||
const resource = createResource({
|
||||
type: 'storage',
|
||||
storage: {
|
||||
aliasIds: ['legacy-storage-id'],
|
||||
shared: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(resource.storage?.aliasIds).toEqual(['legacy-storage-id']);
|
||||
expect(resource.id).toBe('test-1');
|
||||
});
|
||||
|
||||
it('retains three-state UDP availability evidence on canonical resources', () => {
|
||||
const resource = createResource({
|
||||
type: 'network-endpoint',
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface BackupAlertConfig {
|
||||
export type ActivationState = 'pending_review' | 'active' | 'snoozed';
|
||||
|
||||
export interface AlertConfig {
|
||||
identitySchemaVersion?: number;
|
||||
enabled: boolean;
|
||||
activationState?: ActivationState;
|
||||
observationWindowHours?: number;
|
||||
|
||||
@@ -397,6 +397,7 @@ export interface ResourceStorageMeta {
|
||||
type?: string;
|
||||
content?: string;
|
||||
contentTypes?: string[];
|
||||
aliasIds?: string[];
|
||||
shared?: boolean;
|
||||
isCeph?: boolean;
|
||||
isZfs?: boolean;
|
||||
|
||||
@@ -57,6 +57,7 @@ func (m *Manager) SaveActiveAlerts() error {
|
||||
}
|
||||
clone := alert.Clone()
|
||||
backfillCanonicalIdentity(clone)
|
||||
clone.ID = exportedAlertID(clone, clone.ID)
|
||||
alerts = append(alerts, clone)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
@@ -151,10 +152,13 @@ func (m *Manager) LoadActiveAlerts() error {
|
||||
now := time.Now()
|
||||
restoredCount := 0
|
||||
duplicateCount := 0
|
||||
identityMigrationCount := 0
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, alert := range alerts {
|
||||
backfillCanonicalIdentity(alert)
|
||||
if alert == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Migrate legacy guest alert IDs (instance-node-VMID -> instance-VMID).
|
||||
isGuestAlert := strings.Contains(alert.Type, "cpu") || strings.Contains(alert.Type, "memory") ||
|
||||
@@ -184,11 +188,25 @@ func (m *Manager) LoadActiveAlerts() error {
|
||||
oldResourceID := alert.ResourceID
|
||||
alert.ResourceID = newResourceID
|
||||
alert.ID = strings.Replace(alert.ID, oldResourceID, newResourceID, 1)
|
||||
alert.CanonicalSpecID = ""
|
||||
alert.CanonicalState = ""
|
||||
alert.CanonicalKind = ""
|
||||
if alert.Metadata != nil {
|
||||
delete(alert.Metadata, "canonicalSpecID")
|
||||
delete(alert.Metadata, "canonicalAlertKind")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
legacyID := alert.ID
|
||||
backfillCanonicalIdentity(alert)
|
||||
alert.ID = exportedAlertID(alert, alert.ID)
|
||||
if alert.ID != legacyID {
|
||||
identityMigrationCount++
|
||||
}
|
||||
|
||||
if seen[alert.ID] {
|
||||
duplicateCount++
|
||||
log.Warn().Str("alertID", alert.ID).Msg("skipping duplicate alert during restore")
|
||||
@@ -216,6 +234,12 @@ func (m *Manager) LoadActiveAlerts() error {
|
||||
}
|
||||
|
||||
m.setActiveAlertNoLock(alert.ID, alert)
|
||||
if legacyID != alert.ID {
|
||||
if m.activeAlertAlias == nil {
|
||||
m.activeAlertAlias = make(map[string]string)
|
||||
}
|
||||
m.activeAlertAlias[legacyID] = activeAlertStorageKey(alert, alert.ID)
|
||||
}
|
||||
if alert.Acknowledged {
|
||||
ackTime := alert.StartTime
|
||||
if alert.AckTime != nil {
|
||||
@@ -271,7 +295,13 @@ func (m *Manager) LoadActiveAlerts() error {
|
||||
Int("restored", restoredCount).
|
||||
Int("total", len(alerts)).
|
||||
Int("duplicates", duplicateCount).
|
||||
Int("identityMigrations", identityMigrationCount).
|
||||
Msg("Restored active alerts from disk")
|
||||
if identityMigrationCount > 0 {
|
||||
// The asynchronous save waits for the load lock, then atomically replaces
|
||||
// the legacy file with the canonical IDs just restored into memory.
|
||||
m.saveActiveAlertsAsync("canonical-identity-migration")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// CurrentAlertIdentitySchemaVersion is the persisted alert configuration
|
||||
// identity schema understood by this release.
|
||||
const CurrentAlertIdentitySchemaVersion = 1
|
||||
|
||||
// AlertIdentityMigrationDeferred describes an override which the dry-run
|
||||
// planner deliberately refused to move. The source row remains untouched.
|
||||
type AlertIdentityMigrationDeferred struct {
|
||||
Key string `json:"key"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// AlertIdentityMigrationPlan is a non-mutating preview of the versioned
|
||||
// identity migration. ApplyAlertIdentityMigration applies the exact planned
|
||||
// override snapshot only if the source schema version is still current.
|
||||
type AlertIdentityMigrationPlan struct {
|
||||
FromVersion int `json:"fromVersion"`
|
||||
ToVersion int `json:"toVersion"`
|
||||
RemovedOverrideKeys []string `json:"removedOverrideKeys,omitempty"`
|
||||
AddedOverrideKeys []string `json:"addedOverrideKeys,omitempty"`
|
||||
Deferred []AlertIdentityMigrationDeferred `json:"deferred,omitempty"`
|
||||
UnsupportedVersion bool `json:"unsupportedVersion,omitempty"`
|
||||
|
||||
plannedOverrides map[string]ThresholdConfig
|
||||
}
|
||||
|
||||
// Changed reports whether applying the plan would change persisted config.
|
||||
func (p AlertIdentityMigrationPlan) Changed() bool {
|
||||
return !p.UnsupportedVersion && (p.FromVersion != p.ToVersion || len(p.RemovedOverrideKeys) > 0 || len(p.AddedOverrideKeys) > 0)
|
||||
}
|
||||
|
||||
// PlanAlertIdentityMigration builds a dry-run migration from the live unified
|
||||
// resource snapshot. Unknown and ambiguous identities fail closed and remain
|
||||
// in the result for a later poll that can prove their ownership.
|
||||
func PlanAlertIdentityMigration(config AlertConfig, resources []unifiedresources.Resource) AlertIdentityMigrationPlan {
|
||||
plan := AlertIdentityMigrationPlan{
|
||||
FromVersion: config.IdentitySchemaVersion,
|
||||
ToVersion: CurrentAlertIdentitySchemaVersion,
|
||||
}
|
||||
if config.IdentitySchemaVersion > CurrentAlertIdentitySchemaVersion {
|
||||
plan.ToVersion = config.IdentitySchemaVersion
|
||||
plan.UnsupportedVersion = true
|
||||
return plan
|
||||
}
|
||||
|
||||
original := cloneThresholdOverrides(config.Overrides)
|
||||
working := config
|
||||
working.Overrides = cloneThresholdOverrides(config.Overrides)
|
||||
|
||||
MigrateCanonicalOverrideKeys(&working, resources)
|
||||
MigrateDockerContainerOverrideKeys(&working, resources)
|
||||
plan.Deferred = append(plan.Deferred, migrateGuestOverrideKeys(&working, resources)...)
|
||||
plan.Deferred = append(plan.Deferred, migrateStorageOverrideKeys(&working, resources)...)
|
||||
working.IdentitySchemaVersion = CurrentAlertIdentitySchemaVersion
|
||||
|
||||
for key := range original {
|
||||
if _, exists := working.Overrides[key]; !exists {
|
||||
plan.RemovedOverrideKeys = append(plan.RemovedOverrideKeys, key)
|
||||
}
|
||||
}
|
||||
for key := range working.Overrides {
|
||||
if _, exists := original[key]; !exists {
|
||||
plan.AddedOverrideKeys = append(plan.AddedOverrideKeys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(plan.RemovedOverrideKeys)
|
||||
sort.Strings(plan.AddedOverrideKeys)
|
||||
sort.Slice(plan.Deferred, func(i, j int) bool {
|
||||
if plan.Deferred[i].Key == plan.Deferred[j].Key {
|
||||
return plan.Deferred[i].Reason < plan.Deferred[j].Reason
|
||||
}
|
||||
return plan.Deferred[i].Key < plan.Deferred[j].Key
|
||||
})
|
||||
plan.plannedOverrides = cloneThresholdOverrides(working.Overrides)
|
||||
return plan
|
||||
}
|
||||
|
||||
// ApplyAlertIdentityMigration applies a previously generated plan. A stale
|
||||
// plan is rejected if another writer changed the schema version in between.
|
||||
func ApplyAlertIdentityMigration(config *AlertConfig, plan AlertIdentityMigrationPlan) bool {
|
||||
if config == nil || !plan.Changed() || plan.UnsupportedVersion || config.IdentitySchemaVersion != plan.FromVersion {
|
||||
return false
|
||||
}
|
||||
config.Overrides = cloneThresholdOverrides(plan.plannedOverrides)
|
||||
config.IdentitySchemaVersion = plan.ToVersion
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneThresholdOverrides(source map[string]ThresholdConfig) map[string]ThresholdConfig {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]ThresholdConfig, len(source))
|
||||
for key, override := range source {
|
||||
cloned[key] = override
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
type overrideAliasClaims struct {
|
||||
targets map[string]string
|
||||
ambiguous map[string]struct{}
|
||||
}
|
||||
|
||||
func newOverrideAliasClaims() *overrideAliasClaims {
|
||||
return &overrideAliasClaims{
|
||||
targets: make(map[string]string),
|
||||
ambiguous: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *overrideAliasClaims) add(alias, target string) {
|
||||
alias = strings.TrimSpace(alias)
|
||||
target = strings.TrimSpace(target)
|
||||
if alias == "" || target == "" || alias == target {
|
||||
return
|
||||
}
|
||||
if existing, exists := c.targets[alias]; exists && existing != target {
|
||||
delete(c.targets, alias)
|
||||
c.ambiguous[alias] = struct{}{}
|
||||
return
|
||||
}
|
||||
if _, conflict := c.ambiguous[alias]; conflict {
|
||||
return
|
||||
}
|
||||
c.targets[alias] = target
|
||||
}
|
||||
|
||||
func migrateGuestOverrideKeys(config *AlertConfig, resources []unifiedresources.Resource) []AlertIdentityMigrationDeferred {
|
||||
claims := newOverrideAliasClaims()
|
||||
for _, resource := range resources {
|
||||
typeKey := unifiedresources.CanonicalResourceType(resource.Type)
|
||||
if typeKey != unifiedresources.ResourceTypeVM && typeKey != unifiedresources.ResourceTypeSystemContainer {
|
||||
continue
|
||||
}
|
||||
if resource.Proxmox == nil {
|
||||
continue
|
||||
}
|
||||
ident, ok := guestOverrideIdentityFromParts(resource.Proxmox.Instance, resource.Proxmox.NodeName, resource.Proxmox.VMID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
vmid := strconv.Itoa(ident.vmid)
|
||||
target := guestOverridePrimaryKey(nil, strings.Join([]string{ident.instance, ident.node, vmid}, ":"))
|
||||
aliases := []string{
|
||||
resource.ID,
|
||||
strings.Join([]string{ident.instance, ident.node, vmid}, ":"),
|
||||
legacyGuestStableOverrideKey(ident.instance, ident.vmid),
|
||||
}
|
||||
if ident.instance != ident.node {
|
||||
aliases = append(aliases, legacyClusterGuestOverrideKey(ident.instance, ident.node, ident.vmid))
|
||||
}
|
||||
aliases = append(aliases, resource.SupersededCanonicalIDs...)
|
||||
for _, alias := range aliases {
|
||||
claims.add(alias, target)
|
||||
}
|
||||
}
|
||||
return applyOverrideAliasClaims(config, claims, true)
|
||||
}
|
||||
|
||||
func migrateStorageOverrideKeys(config *AlertConfig, resources []unifiedresources.Resource) []AlertIdentityMigrationDeferred {
|
||||
claims := newOverrideAliasClaims()
|
||||
for _, resource := range resources {
|
||||
if unifiedresources.CanonicalResourceType(resource.Type) != unifiedresources.ResourceTypeStorage {
|
||||
continue
|
||||
}
|
||||
target := ""
|
||||
if resource.MetricsTarget != nil && strings.EqualFold(resource.MetricsTarget.ResourceType, "storage") {
|
||||
target = strings.TrimSpace(resource.MetricsTarget.ResourceID)
|
||||
}
|
||||
if target == "" && resource.Proxmox != nil {
|
||||
target = strings.TrimSpace(resource.Proxmox.SourceID)
|
||||
}
|
||||
if target == "" {
|
||||
target = strings.TrimSpace(resource.ID)
|
||||
}
|
||||
aliases := append([]string{resource.ID}, resource.SupersededCanonicalIDs...)
|
||||
if resource.Storage != nil {
|
||||
aliases = append(aliases, resource.Storage.AliasIDs...)
|
||||
if resource.Storage.Shared && resource.Proxmox != nil {
|
||||
for _, node := range resource.Storage.Nodes {
|
||||
prefix := strings.TrimSpace(node)
|
||||
instance := strings.TrimSpace(resource.Proxmox.Instance)
|
||||
if instance != "" && prefix != "" && !strings.HasPrefix(strings.ToLower(prefix), strings.ToLower(instance)+"-") {
|
||||
prefix = instance + "-" + prefix
|
||||
}
|
||||
if prefix != "" && strings.TrimSpace(resource.Name) != "" {
|
||||
aliases = append(aliases, prefix+"-"+strings.TrimSpace(resource.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(resource.Storage.Platform, "pbs") {
|
||||
if slash := strings.LastIndex(target, "/"); slash > 0 {
|
||||
aliases = append(aliases, target[:slash]+"-"+target[slash+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
claims.add(alias, target)
|
||||
if cephAlias, ok := cephPoolStorageSourceAliasID(alias); ok {
|
||||
claims.add(cephAlias, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return applyOverrideAliasClaims(config, claims, false)
|
||||
}
|
||||
|
||||
func applyOverrideAliasClaims(config *AlertConfig, claims *overrideAliasClaims, includeGuestDisks bool) []AlertIdentityMigrationDeferred {
|
||||
if config == nil || len(config.Overrides) == 0 {
|
||||
return nil
|
||||
}
|
||||
original := cloneThresholdOverrides(config.Overrides)
|
||||
moves := make(map[string][]string)
|
||||
deferred := make([]AlertIdentityMigrationDeferred, 0)
|
||||
for key := range original {
|
||||
alias := key
|
||||
suffix := ""
|
||||
if includeGuestDisks && strings.HasPrefix(key, "guest-disk:") {
|
||||
if split := strings.Index(strings.TrimPrefix(key, "guest-disk:"), "/disk:"); split >= 0 {
|
||||
baseAndSuffix := strings.TrimPrefix(key, "guest-disk:")
|
||||
alias = baseAndSuffix[:split]
|
||||
suffix = baseAndSuffix[split:]
|
||||
}
|
||||
}
|
||||
if _, conflict := claims.ambiguous[alias]; conflict {
|
||||
deferred = append(deferred, AlertIdentityMigrationDeferred{Key: key, Reason: "ambiguous live identity"})
|
||||
continue
|
||||
}
|
||||
target, claimed := claims.targets[alias]
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
if suffix != "" {
|
||||
target = "guest-disk:" + target + suffix
|
||||
}
|
||||
if target != key {
|
||||
moves[target] = append(moves[target], key)
|
||||
}
|
||||
}
|
||||
|
||||
changed := false
|
||||
result := cloneThresholdOverrides(original)
|
||||
for target, sources := range moves {
|
||||
sort.Strings(sources)
|
||||
if _, targetExists := original[target]; targetExists {
|
||||
for _, source := range sources {
|
||||
delete(result, source)
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(sources) != 1 {
|
||||
for _, source := range sources {
|
||||
deferred = append(deferred, AlertIdentityMigrationDeferred{Key: source, Reason: "multiple legacy rows claim one canonical identity"})
|
||||
}
|
||||
continue
|
||||
}
|
||||
source := sources[0]
|
||||
result[target] = original[source]
|
||||
delete(result, source)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
config.Overrides = result
|
||||
}
|
||||
return deferred
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestPlanAlertIdentityMigrationDryRunAndApply(t *testing.T) {
|
||||
guestOverride := ThresholdConfig{CPU: &HysteresisThreshold{Trigger: 91, Clear: 86}}
|
||||
diskOverride := ThresholdConfig{Disk: &HysteresisThreshold{Trigger: 93, Clear: 88}}
|
||||
storageOverride := ThresholdConfig{Usage: &HysteresisThreshold{Trigger: 94, Clear: 89}}
|
||||
unknownOverride := ThresholdConfig{Disabled: true}
|
||||
config := AlertConfig{Overrides: map[string]ThresholdConfig{
|
||||
"cluster-a-node-1-100": guestOverride,
|
||||
"guest-disk:cluster-a-node-2-101/disk:root": diskOverride,
|
||||
"agent:cluster-a-ceph-pool-data": storageOverride,
|
||||
"temporarily-offline-resource": unknownOverride,
|
||||
}}
|
||||
original := cloneThresholdOverrides(config.Overrides)
|
||||
resources := []unifiedresources.Resource{
|
||||
{
|
||||
ID: "vm-aaaaaaaaaaaaaaaa",
|
||||
Type: unifiedresources.ResourceTypeVM,
|
||||
Proxmox: &unifiedresources.ProxmoxData{
|
||||
Instance: "cluster-a",
|
||||
NodeName: "node-1",
|
||||
VMID: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "vm-bbbbbbbbbbbbbbbb",
|
||||
Type: unifiedresources.ResourceTypeVM,
|
||||
Proxmox: &unifiedresources.ProxmoxData{
|
||||
Instance: "cluster-a",
|
||||
NodeName: "node-2",
|
||||
VMID: 101,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "storage-cccccccccccccccc",
|
||||
Type: unifiedresources.ResourceTypeStorage,
|
||||
MetricsTarget: &unifiedresources.MetricsTarget{
|
||||
ResourceType: "storage",
|
||||
ResourceID: "cluster-a-ceph-pool-data",
|
||||
},
|
||||
Storage: &unifiedresources.StorageMeta{
|
||||
AliasIDs: []string{"agent:cluster-a-ceph-pool-data"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
plan := PlanAlertIdentityMigration(config, resources)
|
||||
if !plan.Changed() || plan.FromVersion != 0 || plan.ToVersion != CurrentAlertIdentitySchemaVersion {
|
||||
t.Fatalf("unexpected migration plan: %+v", plan)
|
||||
}
|
||||
if !reflect.DeepEqual(config.Overrides, original) || config.IdentitySchemaVersion != 0 {
|
||||
t.Fatalf("dry-run mutated source config: %+v", config)
|
||||
}
|
||||
|
||||
if !ApplyAlertIdentityMigration(&config, plan) {
|
||||
t.Fatal("expected migration plan to apply")
|
||||
}
|
||||
if config.IdentitySchemaVersion != CurrentAlertIdentitySchemaVersion {
|
||||
t.Fatalf("identity schema version = %d", config.IdentitySchemaVersion)
|
||||
}
|
||||
for _, removed := range []string{
|
||||
"cluster-a-node-1-100",
|
||||
"guest-disk:cluster-a-node-2-101/disk:root",
|
||||
"agent:cluster-a-ceph-pool-data",
|
||||
} {
|
||||
if _, exists := config.Overrides[removed]; exists {
|
||||
t.Fatalf("legacy override %q remained after migration", removed)
|
||||
}
|
||||
}
|
||||
if got := config.Overrides["guest:cluster-a:100"]; got.CPU == nil || got.CPU.Trigger != 91 {
|
||||
t.Fatalf("guest override was not migrated: %+v", got)
|
||||
}
|
||||
if got := config.Overrides["guest-disk:guest:cluster-a:101/disk:root"]; got.Disk == nil || got.Disk.Trigger != 93 {
|
||||
t.Fatalf("guest disk override was not migrated: %+v", got)
|
||||
}
|
||||
if got := config.Overrides["cluster-a-ceph-pool-data"]; got.Usage == nil || got.Usage.Trigger != 94 {
|
||||
t.Fatalf("storage override was not migrated: %+v", got)
|
||||
}
|
||||
if got, exists := config.Overrides["temporarily-offline-resource"]; !exists || !got.Disabled {
|
||||
t.Fatalf("unknown override was not preserved: %+v", config.Overrides)
|
||||
}
|
||||
|
||||
second := PlanAlertIdentityMigration(config, resources)
|
||||
if second.Changed() {
|
||||
t.Fatalf("migration was not idempotent: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAlertIdentityMigrationFailsClosedOnConflictingLegacyRows(t *testing.T) {
|
||||
config := AlertConfig{Overrides: map[string]ThresholdConfig{
|
||||
"cluster-a-100": {CPU: &HysteresisThreshold{Trigger: 90, Clear: 85}},
|
||||
"cluster-a-node-1-100": {CPU: &HysteresisThreshold{Trigger: 95, Clear: 90}},
|
||||
}}
|
||||
resources := []unifiedresources.Resource{{
|
||||
ID: "vm-aaaaaaaaaaaaaaaa",
|
||||
Type: unifiedresources.ResourceTypeVM,
|
||||
Proxmox: &unifiedresources.ProxmoxData{
|
||||
Instance: "cluster-a",
|
||||
NodeName: "node-1",
|
||||
VMID: 100,
|
||||
},
|
||||
}}
|
||||
|
||||
plan := PlanAlertIdentityMigration(config, resources)
|
||||
if len(plan.Deferred) != 2 {
|
||||
t.Fatalf("deferred rows = %+v, want both conflicting aliases", plan.Deferred)
|
||||
}
|
||||
if !ApplyAlertIdentityMigration(&config, plan) {
|
||||
t.Fatal("schema-only portion of plan should still apply")
|
||||
}
|
||||
if len(config.Overrides) != 2 {
|
||||
t.Fatalf("conflicting rows were changed: %+v", config.Overrides)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAlertIdentityMigrationRejectsNewerSchema(t *testing.T) {
|
||||
config := AlertConfig{
|
||||
IdentitySchemaVersion: CurrentAlertIdentitySchemaVersion + 1,
|
||||
Overrides: map[string]ThresholdConfig{"legacy": {Disabled: true}},
|
||||
}
|
||||
plan := PlanAlertIdentityMigration(config, nil)
|
||||
if !plan.UnsupportedVersion || plan.Changed() {
|
||||
t.Fatalf("newer schema plan = %+v", plan)
|
||||
}
|
||||
if ApplyAlertIdentityMigration(&config, plan) {
|
||||
t.Fatal("newer schema must not be modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadActiveAlertsRewritesCanonicalPersistedIDAndPreservesAck(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
now := time.Now().UTC()
|
||||
legacy := []*Alert{{
|
||||
ID: "node-offline-pve-a",
|
||||
Type: "connectivity",
|
||||
Level: AlertLevelWarning,
|
||||
ResourceName: "pve-a",
|
||||
Node: "pve-a",
|
||||
Message: "node offline",
|
||||
StartTime: now.Add(-time.Minute),
|
||||
LastSeen: now,
|
||||
Acknowledged: true,
|
||||
AckTime: &now,
|
||||
AckUser: "operator",
|
||||
}}
|
||||
data, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alertsDir := m.getAlertsDir()
|
||||
if err := os.MkdirAll(alertsDir, alertsDirPerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(alertsDir, "active-alerts.json")
|
||||
if err := os.WriteFile(path, data, alertsFilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := m.LoadActiveAlerts(); err != nil {
|
||||
t.Fatalf("LoadActiveAlerts() error = %v", err)
|
||||
}
|
||||
wantID := "pve-a::pve-a-connectivity"
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
persistedData, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
var persisted []*Alert
|
||||
if err := json.Unmarshal(persistedData, &persisted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(persisted) == 1 && persisted[0].ID == wantID {
|
||||
if !persisted[0].Acknowledged || persisted[0].AckUser != "operator" || persisted[0].AckTime == nil {
|
||||
t.Fatalf("ack fields changed during rewrite: %+v", persisted[0])
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("active alert file was not canonically rewritten: %+v", persisted)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,10 @@ type GuestLookup struct {
|
||||
|
||||
// AlertConfig represents the complete alert configuration
|
||||
type AlertConfig struct {
|
||||
// IdentitySchemaVersion records which persisted alert-identity migration
|
||||
// has been applied. It is additive so older Pulse releases safely ignore it;
|
||||
// after a rollback, a newer release can rerun the idempotent migration.
|
||||
IdentitySchemaVersion int `json:"identitySchemaVersion,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ActivationState ActivationState `json:"activationState,omitempty"`
|
||||
ObservationWindowHours int `json:"observationWindowHours,omitempty"`
|
||||
|
||||
@@ -14,6 +14,14 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Clients and rollback-era config writers may not know about the additive
|
||||
// identity schema marker. Never lower a version already held by this
|
||||
// process; an older binary can still omit it on disk and the migration will
|
||||
// safely rerun after a later upgrade.
|
||||
if config.IdentitySchemaVersion < m.config.IdentitySchemaVersion {
|
||||
config.IdentitySchemaVersion = m.config.IdentitySchemaVersion
|
||||
}
|
||||
|
||||
// Preserve activation state/time when clients update the config without including it.
|
||||
// This avoids unintentionally resetting alerts to pending review when saving thresholds.
|
||||
if config.ActivationState == "" && m.config.ActivationState != "" {
|
||||
|
||||
@@ -153,6 +153,9 @@ func TestSyncUnifiedResourceAlertsPersistsAndEvaluatesTrueNASOverrideSuccession(
|
||||
if override := reloaded.Overrides[newID]; override.Memory == nil || override.Memory.Trigger != 95 {
|
||||
t.Fatalf("reloaded override missing under canonical identity %s: %+v", newID, override)
|
||||
}
|
||||
if got := reloaded.IdentitySchemaVersion; got != alerts.CurrentAlertIdentitySchemaVersion {
|
||||
t.Fatalf("persisted alert identity schema version = %d, want %d", got, alerts.CurrentAlertIdentitySchemaVersion)
|
||||
}
|
||||
|
||||
resources = []unifiedresources.Resource{
|
||||
trueNASMemoryAlertResource(newID, oldID, sharedName, 96),
|
||||
|
||||
@@ -97,17 +97,36 @@ func (m *Monitor) syncUnifiedResourceAlertsToState(resources []unifiedresources.
|
||||
}
|
||||
|
||||
config := m.alertManager.GetConfig()
|
||||
migrated := alerts.MigrateCanonicalOverrideKeys(&config, resources)
|
||||
if alerts.MigrateDockerContainerOverrideKeys(&config, resources) {
|
||||
migrated = true
|
||||
}
|
||||
if migrated {
|
||||
migrationPlan := alerts.PlanAlertIdentityMigration(config, resources)
|
||||
if migrationPlan.UnsupportedVersion {
|
||||
log.Warn().
|
||||
Int("configVersion", migrationPlan.FromVersion).
|
||||
Int("supportedVersion", alerts.CurrentAlertIdentitySchemaVersion).
|
||||
Msg("alert identity config uses a newer schema; leaving persisted identities untouched")
|
||||
} else if alerts.ApplyAlertIdentityMigration(&config, migrationPlan) {
|
||||
if m.configPersist == nil {
|
||||
log.Warn().Msg("cannot persist canonical alert override migration without config persistence")
|
||||
log.Warn().
|
||||
Int("fromVersion", migrationPlan.FromVersion).
|
||||
Int("toVersion", migrationPlan.ToVersion).
|
||||
Int("removedOverrides", len(migrationPlan.RemovedOverrideKeys)).
|
||||
Int("addedOverrides", len(migrationPlan.AddedOverrideKeys)).
|
||||
Int("deferredOverrides", len(migrationPlan.Deferred)).
|
||||
Msg("cannot persist planned alert identity migration without config persistence")
|
||||
} else if err := m.configPersist.SaveAlertConfig(config); err != nil {
|
||||
log.Error().Err(err).Msg("failed to persist canonical alert override migration")
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("fromVersion", migrationPlan.FromVersion).
|
||||
Int("toVersion", migrationPlan.ToVersion).
|
||||
Msg("failed to persist planned alert identity migration")
|
||||
} else {
|
||||
m.alertManager.UpdateConfig(config)
|
||||
log.Info().
|
||||
Int("fromVersion", migrationPlan.FromVersion).
|
||||
Int("toVersion", migrationPlan.ToVersion).
|
||||
Int("removedOverrides", len(migrationPlan.RemovedOverrideKeys)).
|
||||
Int("addedOverrides", len(migrationPlan.AddedOverrideKeys)).
|
||||
Int("deferredOverrides", len(migrationPlan.Deferred)).
|
||||
Msg("persisted alert identity migration plan")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1985,6 +1985,7 @@ func resourceFromStorage(storage models.Storage) (Resource, ResourceIdentity) {
|
||||
Type: storageType,
|
||||
Content: content,
|
||||
ContentTypes: parseStorageContentTypes(content),
|
||||
AliasIDs: append([]string(nil), storage.AliasIDs...),
|
||||
Shared: storage.Shared,
|
||||
Enabled: storage.Enabled,
|
||||
Active: storage.Active,
|
||||
|
||||
@@ -1037,6 +1037,7 @@ func TestResourceFromContainerPreservesProxmoxPool(t *testing.T) {
|
||||
func TestResourceFromStorageIncludesStorageMetadata(t *testing.T) {
|
||||
storage := models.Storage{
|
||||
ID: "storage-1",
|
||||
AliasIDs: []string{"cluster-a-pve-1-ceph-rbd-pool"},
|
||||
Name: "ceph-rbd-pool",
|
||||
Node: "pve-1",
|
||||
Instance: "cluster-a",
|
||||
@@ -1066,6 +1067,9 @@ func TestResourceFromStorageIncludesStorageMetadata(t *testing.T) {
|
||||
if got, want := resource.Storage.Pool, "ceph/rbd-a"; got != want {
|
||||
t.Fatalf("storage pool = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := resource.Storage.AliasIDs, storage.AliasIDs; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("storage alias IDs = %v, want %v", got, want)
|
||||
}
|
||||
wantContentTypes := []string{"images", "rootdir"}
|
||||
if len(resource.Storage.ContentTypes) != len(wantContentTypes) {
|
||||
t.Fatalf("contentTypes length = %d, want %d (%v)", len(resource.Storage.ContentTypes), len(wantContentTypes), resource.Storage.ContentTypes)
|
||||
|
||||
@@ -931,6 +931,7 @@ func TestStorageTopologyAndVDevLayoutAreDistinctIdentityFields(t *testing.T) {
|
||||
Platform: "truenas",
|
||||
Topology: "pool",
|
||||
VDevLayout: "mirror+special",
|
||||
AliasIDs: []string{"legacy-pool-id"},
|
||||
}
|
||||
|
||||
cloned := cloneStorageMeta(meta)
|
||||
@@ -943,6 +944,9 @@ func TestStorageTopologyAndVDevLayoutAreDistinctIdentityFields(t *testing.T) {
|
||||
if cloned.VDevLayout != "mirror+special" {
|
||||
t.Fatalf("cloned vdev layout = %q, want \"mirror+special\"", cloned.VDevLayout)
|
||||
}
|
||||
if len(cloned.AliasIDs) != 1 || cloned.AliasIDs[0] != "legacy-pool-id" {
|
||||
t.Fatalf("cloned alias IDs = %v, want compatibility identity", cloned.AliasIDs)
|
||||
}
|
||||
|
||||
// A pool with no reported vdevs still carries the discriminator, and the
|
||||
// layout stays absent rather than being backfilled from it.
|
||||
@@ -965,4 +969,7 @@ func TestStorageTopologyAndVDevLayoutAreDistinctIdentityFields(t *testing.T) {
|
||||
if decoded["vdevLayout"] != "mirror+special" {
|
||||
t.Fatalf("wire vdevLayout = %v, want \"mirror+special\"", decoded["vdevLayout"])
|
||||
}
|
||||
if aliases, ok := decoded["aliasIds"].([]any); !ok || len(aliases) != 1 || aliases[0] != "legacy-pool-id" {
|
||||
t.Fatalf("wire aliasIds = %v, want [legacy-pool-id]", decoded["aliasIds"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ func cloneStorageMeta(in *StorageMeta) *StorageMeta {
|
||||
}
|
||||
out := *in
|
||||
out.ContentTypes = cloneStringSlice(in.ContentTypes)
|
||||
out.AliasIDs = cloneStringSlice(in.AliasIDs)
|
||||
out.Nodes = cloneStringSlice(in.Nodes)
|
||||
out.ConsumerTypes = cloneStringSlice(in.ConsumerTypes)
|
||||
out.TopConsumers = cloneStorageConsumerMetaSlice(in.TopConsumers)
|
||||
|
||||
@@ -497,6 +497,7 @@ func TestCloneStorageMeta_Nil(t *testing.T) {
|
||||
func TestCloneStorageMeta_SliceIsolation(t *testing.T) {
|
||||
original := &StorageMeta{
|
||||
ContentTypes: []string{"images", "backup"},
|
||||
AliasIDs: []string{"legacy-storage-id"},
|
||||
Nodes: []string{"node1", "node2"},
|
||||
ConsumerTypes: []string{"vm"},
|
||||
TopConsumers: []StorageConsumerMeta{
|
||||
@@ -510,6 +511,11 @@ func TestCloneStorageMeta_SliceIsolation(t *testing.T) {
|
||||
t.Error("mutating cloned ContentTypes should not affect original")
|
||||
}
|
||||
|
||||
cloned.AliasIDs[0] = "MUTATED"
|
||||
if original.AliasIDs[0] == "MUTATED" {
|
||||
t.Error("mutating cloned AliasIDs should not affect original")
|
||||
}
|
||||
|
||||
cloned.Nodes[0] = "MUTATED"
|
||||
if original.Nodes[0] == "MUTATED" {
|
||||
t.Error("mutating cloned Nodes should not affect original")
|
||||
|
||||
@@ -405,6 +405,7 @@ type StorageMeta struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
AliasIDs []string `json:"aliasIds,omitempty"`
|
||||
Shared bool `json:"shared"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Active bool `json:"active"`
|
||||
|
||||
@@ -234,6 +234,23 @@ test.describe('Alert operator qualification', () => {
|
||||
await expect(databaseCard).toContainText('deliberately long diagnostic message');
|
||||
await expect(databaseCard).toContainText('Database Node With A Deliberately Long');
|
||||
|
||||
const visibleAxisLabels = page.locator('[data-testid="alert-frequency-axis-label"]:visible');
|
||||
await expect(visibleAxisLabels).toHaveCount(3);
|
||||
const axisBoxes = (
|
||||
await visibleAxisLabels.evaluateAll((labels) =>
|
||||
labels.map((label) => {
|
||||
const box = label.getBoundingClientRect();
|
||||
return { left: box.left, right: box.right };
|
||||
}),
|
||||
)
|
||||
).sort((a, b) => a.left - b.left);
|
||||
for (let index = 1; index < axisBoxes.length; index += 1) {
|
||||
expect(
|
||||
axisBoxes[index - 1]!.right,
|
||||
'Mobile alert-frequency axis labels must not overlap',
|
||||
).toBeLessThanOrEqual(axisBoxes[index]!.left);
|
||||
}
|
||||
|
||||
const search = page.getByPlaceholder('Search alerts...');
|
||||
await search.focus();
|
||||
await expect(search).toBeFocused();
|
||||
|
||||
@@ -109,7 +109,14 @@ test.describe('Real backend alert history qualification', () => {
|
||||
message: 'Legacy history import rendered from SQLite',
|
||||
type: 'cpu',
|
||||
});
|
||||
expect(Date.parse(importedAlert!.lastSeen) - Date.parse(importedAlert!.startTime)).toBe(30 * 60_000);
|
||||
expect(
|
||||
Math.abs(
|
||||
Date.parse(importedAlert!.lastSeen) -
|
||||
Date.parse(importedAlert!.startTime) -
|
||||
30 * 60_000,
|
||||
),
|
||||
'Seed timestamps may be constructed a few milliseconds apart',
|
||||
).toBeLessThanOrEqual(100);
|
||||
|
||||
await restartManagedLocalBackend();
|
||||
await ensureAuthenticated(page);
|
||||
|
||||
Reference in New Issue
Block a user