mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 13:18:08 +00:00
275c654407
* feat(fleet): add self dev-build detection primitives Split compareLocalToRemoteTag into compareLocalToRemoteTagDetailed (returns the probe's primary digest alongside the match/update/error verdict) with compareLocalToRemoteTag now a thin wrapper, so a caller that needs both the verdict and the digest no longer has to probe the same mutable tag twice. Add detectSelfDevBuildUpdate, which compares the running container's own image against the rolling ghcr.io/studio-saelix/sencho-dev:dev tag using the new detailed comparison, laying the groundwork for surfacing dev-build updates in Fleet. * feat: add isSenchoDevRepository and isSenchoDevFloatingTag predicates Add two pure predicate functions to helpers/selfUpdateCompose.ts for identifying Sencho dev repository references and floating tag variants: - isSenchoDevRepository: checks if a reference is to the ghcr.io/studio-saelix/sencho-dev repository, including digest-pinned and dev-<sha> tag variants - isSenchoDevFloatingTag: checks if a reference is specifically the floating :dev tag on the Sencho dev repository (not digest-pinned, not immutable dev-<sha>) Both functions reuse existing parsing patterns (normalizeImageRepository for repository extraction, classifyImagePin idiom for digest and tag detection) to maintain consistency. Add comprehensive test coverage in self-update-compose.test.ts covering all specified test cases including edge cases (malformed refs, unrelated repos, digest pins, etc.). * feat(gitops): wire dev-build detection into MonitorService Adds a dev_build_update_available notification category and a new checkSenchoDevBuild() cycle in MonitorService that detects when the running container has fallen behind the rolling ghcr.io/studio-saelix/sencho-dev:dev build it is pinned to, using detectSelfDevBuildUpdate() and isSenchoDevFloatingTag(). Availability state is written unconditionally so the Fleet update affordance never depends on notification delivery succeeding, while a separate dedup key prevents re-notifying for a digest already announced. Also guards checkSenchoVersion() so a dev-repo pin no longer produces a false positive stable-release update notification. * feat(fleet): surface dev-image status and build availability Fleet's GET /update-status now reports isDevImage (any reference to the sencho-dev repository, including digest pins) and devBuildUpdateAvailable (the exact floating :dev tag with a newer build observed, read from the system-state key MonitorService already maintains). A dev-pinned local node forces updateAvailable to false and clears any stale stable-release skip, since that skip was computed before image-pin classification and would otherwise leak a bogus "Skipped" state onto a dev row. Made MonitorService's SENCHO_DEV_BUILD_AVAILABLE_KEY constant public so both call sites share one string instead of duplicating it. * fix(fleet): omit targetVersion for a dev-image update trigger updateRequestInit() always forwarded latestVersion (the latest stable release) as targetVersion whenever it was valid semver, even for a dev-pinned node. The backend already ignores targetVersion safely for a floating pin, so this never caused an actual repin, but it produced a misleading "Update to X.Y.Z" button label and confirm-dialog copy for an update that installs the dev image, not that stable release. * feat(fleet): add integration-image badge and dev build update button NodeCard now shows a persistent "Integration image" badge whenever a node's compose image is any sencho-dev reference, independent of update availability, visible to every role. When a newer dev build is available, a solid brand-colored "Update dev build" button appears alongside it, admin-only, reusing the existing update trigger and requireAdmin route. Styled distinctly from the neutral stable "Update to X.Y.Z" button so an operator always knows which channel they're acting on. * feat(fleet): add dev-image copy to the local update confirm dialog LocalUpdateConfirmDialog now recognizes isDevImage and shows a distinct LOCAL - DEV UPDATE kicker plus copy stating the sencho-dev:dev reference will be pulled without rewriting the compose image, and that integration images are unsigned and carry no release attestations. Without this, a dev-pinned node's update confirmation fell through to the generic "Pulls Sencho the latest release" copy. FleetView.tsx threads isDevImage from the node's update status through to the dialog, same source as its other pin fields. * feat(fleet): separate dev and stable availability in the Node Updates sheet The sheet counted stable and dev availability together via the same updateAvailable field, so a dev-pinned node with a build available fell into neither the summary counts nor any row action, and would have misleadingly rendered as "Up to date" once devBuildUpdateAvailable existed. stableAvailable and devAvailable are now tracked separately: the changelog dot lights only from stableAvailable (a dev build has no release changelog), the summary and meta text report the combined total, a dev row shows "Integration build" instead of a stable version in the Latest column, and the existing Update button/badge now also fires for devBuildUpdateAvailable. Update all and Skip stay stable-only, since both already gate on fields a dev row never satisfies. * feat(fleet): bring dev-build detection and update to Mobile Fleet Mobile Fleet previously had no update capability at all: it only polled /fleet/overview and never called useFleetUpdateStatus, so it could not show the stable update flow either. It now fetches update status alongside the overview poll, shows the same "integration" marker as desktop on any dev-pinned node's card (visible to every role), and gives admins a dev-build update action. The action renders as a sibling of the card's own button rather than nested inside it, since the card is itself a <button> and a nested button is invalid HTML with broken touch semantics. It reuses the exact same triggerNodeUpdate/confirmLocalUpdate flow and LocalUpdateConfirmDialog /ReconnectingOverlay components desktop already renders, so there is no parallel API implementation to keep in sync. * feat(notifications): wire dev_build_update_available through the frontend Adds the category to the frontend NotificationCategory union, its bell label, the per-node "mute update notifications" bundle, and the bell's friendly dot-color memo. The changelog navigation and "View changelog" button stay scoped to node_update_available only: a dev build has no release changelog entry to navigate to. * docs: document dev-build detection and update on Fleet Adds the dev_build_update_available notification category, the persistent Integration image marker, and the dev-build update action (desktop and mobile) to the alerts-notifications, verifying-images, fleet-view, remote-updates, and upgrade pages. States the detection cadence explicitly: it polls on a fixed interval and reflects the newest build observed, not necessarily every individual build. * fix(gitops): sanitize the inconclusive-reason debug log for log injection CodeQL flagged the dev-build check's debug log as depending on a user-influenced value (a registry probe failure reason can trace back to external input). Wraps it with sanitizeForLog(), the existing repo-wide remediation for this class of finding, matching how registry-api.ts already handles the same pattern. * test(gitops): cover the no-repin invariant on a dev-build self-update Proves triggerUpdate(), called with neither targetVersion nor targetImageRef (the exact dev-build update call), pulls the current compose-declared ref unchanged and never stages a compose rewrite. * fix(fleet): use the shared busy-button pattern on Mobile Fleet's dev update action Replaces the local Loader2 plus boolean pending logic with BusyButton so busy behavior and interaction locking stay in sync with the rest of the app's async click surfaces. * test(gitops): exercise the production call shape in the no-repin regression Fleet substitutes the stable compare target when the request body omits one, so SelfUpdateService receives a targetVersion even for a dev-build update. The guard that protects a :dev install is therefore the semver check inside the repin branch, not the absence of a target. Drives triggerUpdate with a forwarded target against a floating :dev pin and asserts the reference is pulled unchanged with no staged patch, and pairs it with a semver case so the negative assertions cannot pass vacuously.
2358 lines
95 KiB
TypeScript
2358 lines
95 KiB
TypeScript
/**
|
|
* Unit tests for MonitorService — alert state machine, metric calculations,
|
|
* cleanup delegation, global settings evaluation, and concurrency guards.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
import { installArcstatsFsMock, arcstatsBody, DEFAULT_ARC_PATH, type ArcstatsFsMock } from './helpers/arcstatsFsMock';
|
|
|
|
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
|
|
|
const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContainerMetric,
|
|
mockCleanupOldMetrics, mockCleanupOldNotifications, mockCleanupOldAuditLogs,
|
|
mockUpdateStackAlertLastFired, mockGetStackAlertServiceCooldown,
|
|
mockHasAnyStackAlertServiceCooldown, mockUpsertStackAlertServiceCooldown,
|
|
mockGetSystemState, mockSetSystemState,
|
|
mockPruneScanHistoryPerImage, mockDeleteScansByImageRef,
|
|
mockGetRunningContainers, mockGetAllContainers, mockGetContainerStatsStream,
|
|
mockGetContainerRestartCount, mockGetDiskUsage, mockGetImages, mockGetStacks,
|
|
mockDispatchAlert,
|
|
mockCurrentLoad, mockMem, mockFsSize,
|
|
mockExecAsync,
|
|
mockFetchLatestSenchoVersion,
|
|
mockGetLatestVersion,
|
|
mockGetLatestVersionInfo,
|
|
mockGetSenchoVersion,
|
|
mockGetPinInfo,
|
|
mockGetIdentity,
|
|
mockGetAuthForRegistry,
|
|
mockDetectSelfDevBuildUpdate,
|
|
} = vi.hoisted(() => ({
|
|
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
|
|
mockGetNodes: vi.fn().mockReturnValue([]),
|
|
mockGetStackAlerts: vi.fn().mockReturnValue([]),
|
|
mockAddContainerMetric: vi.fn(),
|
|
mockCleanupOldMetrics: vi.fn(),
|
|
mockCleanupOldNotifications: vi.fn(),
|
|
mockCleanupOldAuditLogs: vi.fn(),
|
|
mockUpdateStackAlertLastFired: vi.fn(),
|
|
mockGetStackAlertServiceCooldown: vi.fn().mockReturnValue(null),
|
|
mockHasAnyStackAlertServiceCooldown: vi.fn().mockReturnValue(false),
|
|
mockUpsertStackAlertServiceCooldown: vi.fn(),
|
|
mockGetSystemState: vi.fn().mockReturnValue(null),
|
|
mockSetSystemState: vi.fn(),
|
|
mockPruneScanHistoryPerImage: vi.fn().mockReturnValue(0),
|
|
mockDeleteScansByImageRef: vi.fn().mockReturnValue(0),
|
|
mockGetRunningContainers: vi.fn().mockResolvedValue([]),
|
|
mockGetAllContainers: vi.fn().mockResolvedValue([]),
|
|
mockGetContainerStatsStream: vi.fn().mockResolvedValue('{}'),
|
|
mockGetContainerRestartCount: vi.fn().mockResolvedValue(0),
|
|
mockGetDiskUsage: vi.fn().mockResolvedValue({
|
|
reclaimableImages: 0, reclaimableContainers: 0, reclaimableVolumes: 0, reclaimableBuildCache: 0,
|
|
reclaimableImageCount: 0, reclaimableContainerCount: 0, reclaimableVolumeCount: 0, reclaimableBuildCacheCount: 0,
|
|
}),
|
|
mockGetImages: vi.fn().mockResolvedValue([]),
|
|
mockGetStacks: vi.fn().mockResolvedValue([]),
|
|
mockDispatchAlert: vi.fn().mockResolvedValue({ persisted: true }),
|
|
mockCurrentLoad: vi.fn().mockResolvedValue({ currentLoad: 10 }),
|
|
mockMem: vi.fn().mockResolvedValue({ total: 16e9, used: 4e9, active: 4e9, available: 12e9, free: 12e9, buffcache: 0 }),
|
|
mockFsSize: vi.fn().mockResolvedValue([{ mount: '/', use: 30 }]),
|
|
mockExecAsync: vi.fn().mockResolvedValue({ stdout: '' }),
|
|
mockFetchLatestSenchoVersion: vi.fn().mockRejectedValue(new Error('not configured')),
|
|
mockGetLatestVersion: vi.fn().mockResolvedValue(null),
|
|
mockGetLatestVersionInfo: vi.fn().mockResolvedValue(null),
|
|
mockGetSenchoVersion: vi.fn().mockReturnValue(null),
|
|
// Default: no known compose pin, so checkSenchoVersion() falls through
|
|
// unchanged and checkSenchoDevBuild() is a no-op, matching pre-existing
|
|
// test expectations.
|
|
mockGetPinInfo: vi.fn().mockResolvedValue(null),
|
|
mockGetIdentity: vi.fn().mockReturnValue({
|
|
containerId: null, containerName: null, composeProjectName: null,
|
|
imageId: null, networkNames: [], volumeNames: [],
|
|
}),
|
|
mockGetAuthForRegistry: vi.fn().mockResolvedValue(null),
|
|
mockDetectSelfDevBuildUpdate: vi.fn().mockResolvedValue({ kind: 'up_to_date' }),
|
|
}));
|
|
|
|
vi.mock('../services/DatabaseService', () => ({
|
|
DatabaseService: {
|
|
getInstance: () => ({
|
|
getGlobalSettings: mockGetGlobalSettings,
|
|
getNodes: mockGetNodes,
|
|
getStackAlerts: mockGetStackAlerts,
|
|
addContainerMetric: mockAddContainerMetric,
|
|
cleanupOldMetrics: mockCleanupOldMetrics,
|
|
cleanupOldNotifications: mockCleanupOldNotifications,
|
|
cleanupOldAuditLogs: mockCleanupOldAuditLogs,
|
|
updateStackAlertLastFired: mockUpdateStackAlertLastFired,
|
|
getStackAlertServiceCooldown: mockGetStackAlertServiceCooldown,
|
|
hasAnyStackAlertServiceCooldown: mockHasAnyStackAlertServiceCooldown,
|
|
upsertStackAlertServiceCooldown: mockUpsertStackAlertServiceCooldown,
|
|
getSystemState: mockGetSystemState,
|
|
setSystemState: mockSetSystemState,
|
|
pruneScanHistoryPerImage: mockPruneScanHistoryPerImage,
|
|
deleteScansByImageRef: mockDeleteScansByImageRef,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/DockerController', () => ({
|
|
default: {
|
|
getInstance: () => ({
|
|
getRunningContainers: mockGetRunningContainers,
|
|
getAllContainers: mockGetAllContainers,
|
|
getContainerStatsStream: mockGetContainerStatsStream,
|
|
getContainerRestartCount: mockGetContainerRestartCount,
|
|
getDiskUsage: mockGetDiskUsage,
|
|
getImages: mockGetImages,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/FileSystemService', () => ({
|
|
FileSystemService: {
|
|
getInstance: () => ({
|
|
getStacks: mockGetStacks,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../utils/version-check', () => ({
|
|
fetchLatestSenchoVersion: (...args: unknown[]) => mockFetchLatestSenchoVersion(...args),
|
|
getLatestVersion: (...args: unknown[]) => mockGetLatestVersion(...args),
|
|
getLatestVersionInfo: (...args: unknown[]) => mockGetLatestVersionInfo(...args),
|
|
}));
|
|
|
|
vi.mock('../services/CapabilityRegistry', async () => {
|
|
const semver = await import('semver');
|
|
return {
|
|
isValidVersion: (v: string | null | undefined): v is string =>
|
|
!!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.default.valid(v),
|
|
getSenchoVersion: () => mockGetSenchoVersion(),
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/NotificationService', () => ({
|
|
NotificationService: {
|
|
getInstance: () => ({
|
|
dispatchAlert: mockDispatchAlert,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/SelfUpdateService', () => ({
|
|
default: {
|
|
getInstance: () => ({
|
|
getPinInfo: mockGetPinInfo,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/SelfIdentityService', () => ({
|
|
default: {
|
|
getInstance: () => ({
|
|
getIdentity: mockGetIdentity,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/RegistryService', () => ({
|
|
RegistryService: {
|
|
getInstance: () => ({
|
|
getAuthForRegistry: mockGetAuthForRegistry,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/selfDevBuildDetect', () => ({
|
|
detectSelfDevBuildUpdate: (...args: unknown[]) => mockDetectSelfDevBuildUpdate(...args),
|
|
}));
|
|
|
|
vi.mock('../services/NodeRegistry', () => ({
|
|
NodeRegistry: {
|
|
getInstance: () => ({
|
|
getDefaultNodeId: () => 1,
|
|
getNode: () => ({ id: 1, name: 'local-test', type: 'local' }),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('systeminformation', () => ({
|
|
default: {
|
|
currentLoad: (...args: unknown[]) => mockCurrentLoad(...args),
|
|
mem: (...args: unknown[]) => mockMem(...args),
|
|
fsSize: (...args: unknown[]) => mockFsSize(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock('child_process', () => ({
|
|
exec: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('util', () => ({
|
|
promisify: () => mockExecAsync,
|
|
}));
|
|
|
|
import { MonitorService, _resetHostAlertSuppressionStateForTests } from '../services/MonitorService';
|
|
|
|
// Host memory now reads ZFS ARC stats; intercept those reads so the suite does
|
|
// not depend on whether the machine running it is itself a ZFS host.
|
|
let arcFs: ArcstatsFsMock;
|
|
beforeAll(() => {
|
|
arcFs = installArcstatsFsMock();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
arcFs.clear();
|
|
(MonitorService as any).instance = undefined;
|
|
_resetHostAlertSuppressionStateForTests();
|
|
mockGetSystemState.mockReturnValue(null);
|
|
mockGetPinInfo.mockResolvedValue(null);
|
|
mockGetIdentity.mockReturnValue({
|
|
containerId: null, containerName: null, composeProjectName: null,
|
|
imageId: null, networkNames: [], volumeNames: [],
|
|
});
|
|
mockGetAuthForRegistry.mockResolvedValue(null);
|
|
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'up_to_date' });
|
|
});
|
|
|
|
// si.mem() returns active/available alongside used (used counts reclaimable page
|
|
// cache, active/available exclude it). The host-RAM percentage is derived from the
|
|
// cache-excluded figures, so mocks must supply a realistic shape. `realUsed` is the
|
|
// active working set; available/free are the remainder.
|
|
const memSample = (realUsed: number, total = 16e9) => ({
|
|
total, used: realUsed, active: realUsed,
|
|
available: total - realUsed, free: total - realUsed, buffcache: 0,
|
|
});
|
|
|
|
// ── Pure calculation helpers (accessed via private method reflection) ───
|
|
|
|
describe('MonitorService - calculateCpuPercent', () => {
|
|
function calcCpu(stats: any): number {
|
|
const svc = MonitorService.getInstance();
|
|
return (svc as any).calculateCpuPercent(stats);
|
|
}
|
|
|
|
it('returns correct percentage for normal stats', () => {
|
|
const stats = {
|
|
cpu_stats: { cpu_usage: { total_usage: 2000 }, system_cpu_usage: 10000, online_cpus: 4 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
};
|
|
// (1000 / 5000) * 4 * 100 = 80%
|
|
expect(calcCpu(stats)).toBeCloseTo(80, 1);
|
|
});
|
|
|
|
it('returns 0 when cpu_stats is missing', () => {
|
|
expect(calcCpu({})).toBe(0);
|
|
expect(calcCpu(null)).toBe(0);
|
|
expect(calcCpu({ cpu_stats: {} })).toBe(0);
|
|
});
|
|
|
|
it('returns 0 when systemDelta is zero', () => {
|
|
const stats = {
|
|
cpu_stats: { cpu_usage: { total_usage: 2000 }, system_cpu_usage: 5000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
};
|
|
expect(calcCpu(stats)).toBe(0);
|
|
});
|
|
|
|
it('accounts for online_cpus count', () => {
|
|
const stats = {
|
|
cpu_stats: { cpu_usage: { total_usage: 2000 }, system_cpu_usage: 10000, online_cpus: 8 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
};
|
|
// (1000/5000) * 8 * 100 = 160%
|
|
expect(calcCpu(stats)).toBeCloseTo(160, 1);
|
|
});
|
|
|
|
it('falls back to percpu_usage length when online_cpus missing', () => {
|
|
const stats = {
|
|
cpu_stats: { cpu_usage: { total_usage: 2000, percpu_usage: [0, 0] }, system_cpu_usage: 10000 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
};
|
|
// (1000/5000) * 2 * 100 = 40%
|
|
expect(calcCpu(stats)).toBeCloseTo(40, 1);
|
|
});
|
|
});
|
|
|
|
describe('MonitorService - calculateMemoryPercent', () => {
|
|
function calcMem(stats: any): number {
|
|
const svc = MonitorService.getInstance();
|
|
return (svc as any).calculateMemoryPercent(stats);
|
|
}
|
|
|
|
it('returns correct percentage subtracting cache', () => {
|
|
const stats = {
|
|
memory_stats: { usage: 500e6, limit: 1e9, stats: { cache: 100e6 } },
|
|
};
|
|
// (400e6 / 1e9) * 100 = 40%
|
|
expect(calcMem(stats)).toBeCloseTo(40, 1);
|
|
});
|
|
|
|
it('returns 0 when memory_stats is missing', () => {
|
|
expect(calcMem({})).toBe(0);
|
|
expect(calcMem({ memory_stats: {} })).toBe(0);
|
|
});
|
|
|
|
it('returns 0 when limit is zero', () => {
|
|
const stats = { memory_stats: { usage: 100, limit: 0 } };
|
|
expect(calcMem(stats)).toBe(0);
|
|
});
|
|
|
|
it('handles missing cache field', () => {
|
|
const stats = { memory_stats: { usage: 500e6, limit: 1e9 } };
|
|
// No cache → (500e6 / 1e9) * 100 = 50%
|
|
expect(calcMem(stats)).toBeCloseTo(50, 1);
|
|
});
|
|
});
|
|
|
|
describe('MonitorService - calculateNetwork', () => {
|
|
function calcNet(stats: any, dir: 'rx' | 'tx'): number {
|
|
const svc = MonitorService.getInstance();
|
|
return (svc as any).calculateNetwork(stats, dir);
|
|
}
|
|
|
|
it('sums rx_bytes across all interfaces', () => {
|
|
const stats = {
|
|
networks: {
|
|
eth0: { rx_bytes: 1024 * 1024, tx_bytes: 0 },
|
|
eth1: { rx_bytes: 2 * 1024 * 1024, tx_bytes: 0 },
|
|
},
|
|
};
|
|
expect(calcNet(stats, 'rx')).toBeCloseTo(3, 0); // 3 MB
|
|
});
|
|
|
|
it('sums tx_bytes across all interfaces', () => {
|
|
const stats = {
|
|
networks: {
|
|
eth0: { rx_bytes: 0, tx_bytes: 512 * 1024 },
|
|
},
|
|
};
|
|
expect(calcNet(stats, 'tx')).toBeCloseTo(0.5, 1); // 0.5 MB
|
|
});
|
|
|
|
it('returns 0 when no networks present', () => {
|
|
expect(calcNet({}, 'rx')).toBe(0);
|
|
expect(calcNet({ networks: null }, 'tx')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('MonitorService - evaluateCondition', () => {
|
|
function evalCond(actual: number, operator: string, threshold: number): boolean {
|
|
const svc = MonitorService.getInstance();
|
|
return (svc as any).evaluateCondition(actual, operator, threshold);
|
|
}
|
|
|
|
it('handles > operator', () => {
|
|
expect(evalCond(81, '>', 80)).toBe(true);
|
|
expect(evalCond(80, '>', 80)).toBe(false);
|
|
});
|
|
|
|
it('handles < operator', () => {
|
|
expect(evalCond(79, '<', 80)).toBe(true);
|
|
expect(evalCond(80, '<', 80)).toBe(false);
|
|
});
|
|
|
|
it('handles >= operator at boundary', () => {
|
|
expect(evalCond(80, '>=', 80)).toBe(true);
|
|
expect(evalCond(79, '>=', 80)).toBe(false);
|
|
});
|
|
|
|
it('handles <= operator at boundary', () => {
|
|
expect(evalCond(80, '<=', 80)).toBe(true);
|
|
expect(evalCond(81, '<=', 80)).toBe(false);
|
|
});
|
|
|
|
it('handles == operator', () => {
|
|
expect(evalCond(80, '==', 80)).toBe(true);
|
|
expect(evalCond(81, '==', 80)).toBe(false);
|
|
});
|
|
|
|
it('returns false for unknown operator', () => {
|
|
expect(evalCond(80, '!=', 80)).toBe(false);
|
|
expect(evalCond(80, 'foo', 80)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── Integration-level: evaluateGlobalSettings ──────────────────────────
|
|
|
|
describe('MonitorService - evaluateGlobalSettings', () => {
|
|
it('dispatches CPU warning when over threshold', async () => {
|
|
mockGetGlobalSettings.mockReturnValue({ host_cpu_limit: '50' });
|
|
mockCurrentLoad.mockResolvedValue({ currentLoad: 75 });
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '50' });
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'));
|
|
});
|
|
|
|
it('does not dispatch when CPU below threshold', async () => {
|
|
mockCurrentLoad.mockResolvedValue({ currentLoad: 25 });
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '50' });
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'));
|
|
});
|
|
|
|
it('dispatches RAM warning when over threshold', async () => {
|
|
mockMem.mockResolvedValue(memSample(15e9)); // ~94%
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory'));
|
|
});
|
|
|
|
it('does not alert when most RAM is reclaimable page cache, not real usage', async () => {
|
|
// Busy host: 15.8G "used" including cache, but only 1.6G actively in use and
|
|
// 14.4G available. The naive used/total formula reads ~99% and would breach an
|
|
// 80% threshold; the active/total figure is 10% and must stay silent.
|
|
mockMem.mockResolvedValue({
|
|
total: 16e9, used: 15.8e9, active: 1.6e9, available: 14.4e9, free: 14.4e9, buffcache: 14.2e9,
|
|
});
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory'));
|
|
});
|
|
|
|
it('alerts when the active working set is genuinely over threshold', async () => {
|
|
// Cache-heavy and genuinely busy: 14G active of 16G (87.5%) with 15.8G "used"
|
|
// including cache. active/total exceeds 80%, so the alert must still fire. This
|
|
// is the two-sided partner to the page-cache case above: keying off active must
|
|
// not suppress real breaches.
|
|
mockMem.mockResolvedValue({
|
|
total: 16e9, used: 15.8e9, active: 14e9, available: 2e9, free: 2e9, buffcache: 1.8e9,
|
|
});
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory'));
|
|
});
|
|
|
|
it('does not alert when reclaimable ZFS ARC accounts for the memory pressure', async () => {
|
|
// Active working set reads 15G/16G (~94%, breaches 80%), but 5G of that is
|
|
// reclaimable ARC. Adding ARC back into available drops effective usage to
|
|
// ~62.5%, so no host-memory alert should fire.
|
|
mockMem.mockResolvedValue(memSample(15e9)); // available 1e9 -> 93.75%
|
|
arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(5e9, 0)); // reclaimable 5e9
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory'));
|
|
});
|
|
|
|
it('dispatches disk warning when over threshold', async () => {
|
|
mockFsSize.mockResolvedValue([{ mount: '/', use: 92 }]);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_disk_limit: '90' });
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Disk'));
|
|
});
|
|
|
|
it('skips host limits when threshold is 0 or NaN', async () => {
|
|
mockCurrentLoad.mockResolvedValue({ currentLoad: 99 });
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '0' });
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'));
|
|
|
|
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: 'abc' });
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'));
|
|
});
|
|
});
|
|
|
|
describe('MonitorService - host_alerts_enabled toggle', () => {
|
|
beforeEach(() => {
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockDispatchAlert.mockClear();
|
|
mockCurrentLoad.mockClear();
|
|
mockMem.mockClear();
|
|
mockFsSize.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore default mock implementations so version-check state does not
|
|
// leak into sibling describe blocks (F-11 suppression, version check).
|
|
mockGetLatestVersion.mockResolvedValue(null);
|
|
mockGetLatestVersionInfo.mockResolvedValue(null);
|
|
mockGetSenchoVersion.mockReturnValue(null);
|
|
});
|
|
|
|
it('skips host metrics and dispatch when disabled', async () => {
|
|
mockCurrentLoad.mockResolvedValue({ currentLoad: 75 });
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0', host_cpu_limit: '50' });
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
expect(mockCurrentLoad).not.toHaveBeenCalled();
|
|
expect(mockMem).not.toHaveBeenCalled();
|
|
expect(mockFsSize).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not call systeminformation when disabled', async () => {
|
|
mockMem.mockResolvedValue(memSample(15e9)); // ~94% - would breach
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0', host_ram_limit: '80' });
|
|
|
|
expect(mockCurrentLoad).not.toHaveBeenCalled();
|
|
expect(mockMem).not.toHaveBeenCalled();
|
|
expect(mockFsSize).not.toHaveBeenCalled();
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears persisted suppression state when disabled', async () => {
|
|
const store: Record<string, string> = {};
|
|
mockGetSystemState.mockImplementation((key: string) => store[key] ?? null);
|
|
mockSetSystemState.mockImplementation((key: string, value: string) => { store[key] = value; });
|
|
|
|
// Simulate a prior breach that set the persisted suppression timestamp.
|
|
store['last_host_cpu_alert_ts'] = String(Date.now());
|
|
store['last_host_ram_alert_ts'] = String(Date.now());
|
|
store['last_host_disk_alert_ts'] = String(Date.now());
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0' });
|
|
|
|
expect(store['last_host_cpu_alert_ts']).toBe('0');
|
|
expect(store['last_host_ram_alert_ts']).toBe('0');
|
|
expect(store['last_host_disk_alert_ts']).toBe('0');
|
|
});
|
|
|
|
it('re-enable fires fresh without suppressed suffix', async () => {
|
|
const store: Record<string, string> = {};
|
|
mockGetSystemState.mockImplementation((key: string) => store[key] ?? null);
|
|
mockSetSystemState.mockImplementation((key: string, value: string) => { store[key] = value; });
|
|
mockMem.mockResolvedValue(memSample(15e9)); // ~94% - breaches 80% limit
|
|
|
|
const svc = MonitorService.getInstance();
|
|
|
|
// Step 1: breach while enabled — fires alert and seeds suppression state.
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory'));
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Suppressed'));
|
|
mockDispatchAlert.mockClear();
|
|
|
|
// Step 2: disable — clears suppression state and skips dispatch.
|
|
await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0', host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
expect(store['last_host_ram_alert_ts']).toBe('0');
|
|
|
|
// Step 3: re-enable while still breaching — fires fresh, no "Suppressed" suffix.
|
|
await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '1', host_ram_limit: '80' });
|
|
const calls = mockDispatchAlert.mock.calls.filter(
|
|
(c: unknown[]) => c[0] === 'warning' && c[1] === 'monitor_alert',
|
|
);
|
|
expect(calls.length).toBe(1);
|
|
const msg = calls[0] as string[];
|
|
expect(msg[2]).toContain('Memory');
|
|
expect(msg[2]).not.toContain('Suppressed');
|
|
});
|
|
|
|
it('still runs version check when disabled', async () => {
|
|
mockGetSenchoVersion.mockReturnValue('0.45.0');
|
|
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0' });
|
|
|
|
expect(mockGetLatestVersionInfo).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// Crash + healthcheck detection now lives in DockerEventService (event-driven).
|
|
// Tests for those flows live in docker-event-service.test.ts.
|
|
|
|
// ── F-11: host-metric alert suppression ────────────────────────────────
|
|
|
|
describe('MonitorService - host alert suppression (F-11)', () => {
|
|
// Force RAM-over-threshold for every test in this block; CPU/disk are
|
|
// independently controlled per-test so a single mockMem set-up covers the
|
|
// common "I want a breach happening" case without test repetition.
|
|
beforeEach(() => {
|
|
mockMem.mockResolvedValue(memSample(15e9)); // ~94%
|
|
});
|
|
|
|
it('first breach dispatches immediately', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
const [, , message] = mockDispatchAlert.mock.calls[0];
|
|
expect(message).toContain('Memory');
|
|
expect(message).not.toContain('Suppressed');
|
|
});
|
|
|
|
it('second breach within window does not dispatch', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime);
|
|
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
// 30 seconds later — well inside the 60-minute default window.
|
|
nowSpy.mockReturnValue(baseTime + 30_000);
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('many breaches within window accumulate count without dispatching', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime);
|
|
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
for (let i = 1; i < 10; i++) {
|
|
nowSpy.mockReturnValue(baseTime + i * 30_000);
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
}
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('breach after window elapses dispatches follow-up with count summary and persists new timestamp', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime);
|
|
|
|
// First dispatch.
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
expect(mockSetSystemState).toHaveBeenCalledWith('last_host_ram_alert_ts', String(baseTime));
|
|
|
|
// 5 cycles inside the window, each suppressed.
|
|
for (let i = 1; i <= 5; i++) {
|
|
nowSpy.mockReturnValue(baseTime + i * 30_000);
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
}
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
// Jump past the suppression window — 61 minutes.
|
|
const followUpTime = baseTime + 61 * 60 * 1000;
|
|
nowSpy.mockReturnValue(followUpTime);
|
|
mockSetSystemState.mockClear();
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
|
const [, , followUpMessage] = mockDispatchAlert.mock.calls[1];
|
|
expect(followUpMessage).toMatch(/Suppressed 5 alerts in the last \d+m/);
|
|
expect(followUpMessage).toMatch(/first over threshold at \d{2}:\d{2} UTC/);
|
|
// Follow-up dispatch must persist the new timestamp so a subsequent
|
|
// restart-survivability seed picks up the most-recent fire, not the
|
|
// pre-window first fire (otherwise a restart 30min later would see a
|
|
// 90min-old persisted row and re-fire immediately).
|
|
expect(mockSetSystemState).toHaveBeenCalledWith('last_host_ram_alert_ts', String(followUpTime));
|
|
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('dispatches at exactly the suppression window boundary', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime);
|
|
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
// Exactly 60 minutes later — at the boundary. The check is `<`, so the
|
|
// boundary tick should DISPATCH a follow-up rather than suppress.
|
|
nowSpy.mockReturnValue(baseTime + 60 * 60 * 1000);
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
|
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('follow-up summary uses singular "alert" when exactly one cycle was suppressed', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime);
|
|
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
// One suppressed cycle.
|
|
nowSpy.mockReturnValue(baseTime + 30_000);
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
// Past window.
|
|
nowSpy.mockReturnValue(baseTime + 61 * 60 * 1000);
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
|
const [, , followUpMessage] = mockDispatchAlert.mock.calls[1];
|
|
expect(followUpMessage).toContain('Suppressed 1 alert in the last');
|
|
expect(followUpMessage).not.toContain('Suppressed 1 alerts'); // singular form, not plural
|
|
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('disk-metric path dispatches and suppresses through the same mechanism', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
mockFsSize.mockResolvedValue([{ mount: '/', use: 95 }]);
|
|
// Set RAM below threshold so it does not interfere with the disk-only assertions.
|
|
mockMem.mockResolvedValue(memSample(4e9));
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime);
|
|
|
|
await (svc as any).evaluateGlobalSettings({ host_disk_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
expect((mockDispatchAlert.mock.calls[0][2] as string)).toContain('Disk');
|
|
|
|
nowSpy.mockReturnValue(baseTime + 30_000);
|
|
await (svc as any).evaluateGlobalSettings({ host_disk_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1); // suppressed
|
|
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('metric drop below threshold clears in-memory state and persisted timestamp', async () => {
|
|
// Recovery now reads system_state before deciding to write '0' (so it
|
|
// skips a redundant write when persisted is already null/0). Model the
|
|
// persistence chain so the recovery branch can observe the timestamp
|
|
// the first-fire path just wrote.
|
|
let persistedRamTs: string | null = null;
|
|
mockGetSystemState.mockImplementation((key: string) =>
|
|
key === 'last_host_ram_alert_ts' ? persistedRamTs : null,
|
|
);
|
|
mockSetSystemState.mockImplementation((key: string, value: string) => {
|
|
if (key === 'last_host_ram_alert_ts') persistedRamTs = value;
|
|
});
|
|
|
|
const svc = MonitorService.getInstance();
|
|
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
expect(persistedRamTs).not.toBeNull();
|
|
expect(persistedRamTs).not.toBe('0');
|
|
|
|
// Drop RAM back under threshold.
|
|
mockMem.mockResolvedValue(memSample(4e9)); // 25%
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
// Recovery branch resets the persisted timestamp to '0'.
|
|
expect(persistedRamTs).toBe('0');
|
|
});
|
|
|
|
it('re-breach after recovery fires fresh first alert (no Suppressed suffix)', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
|
|
// Initial breach.
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
// Recovery.
|
|
mockMem.mockResolvedValue(memSample(4e9));
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
// Re-breach.
|
|
mockMem.mockResolvedValue(memSample(15e9));
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
|
const [, , reBreachMessage] = mockDispatchAlert.mock.calls[1];
|
|
expect(reBreachMessage).not.toContain('Suppressed');
|
|
});
|
|
|
|
it('CPU and RAM suppression states are isolated per metric', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
mockCurrentLoad.mockResolvedValue({ currentLoad: 95 });
|
|
|
|
// First cycle: both fire.
|
|
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '80', host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
|
|
|
const messages1 = mockDispatchAlert.mock.calls.map(c => c[2] as string);
|
|
expect(messages1.some(m => m.includes('CPU'))).toBe(true);
|
|
expect(messages1.some(m => m.includes('Memory'))).toBe(true);
|
|
|
|
// Second cycle: both suppressed.
|
|
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '80', host_ram_limit: '80' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
|
|
|
// CPU drops below threshold (recovers); RAM stays high.
|
|
mockCurrentLoad.mockResolvedValue({ currentLoad: 10 });
|
|
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '80', host_ram_limit: '80' });
|
|
|
|
// CPU re-breaches; RAM still in suppression window.
|
|
mockCurrentLoad.mockResolvedValue({ currentLoad: 95 });
|
|
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '80', host_ram_limit: '80' });
|
|
|
|
// CPU fires fresh; RAM stays silent.
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(3);
|
|
const newCpuMessage = mockDispatchAlert.mock.calls[2][2] as string;
|
|
expect(newCpuMessage).toContain('CPU');
|
|
expect(newCpuMessage).not.toContain('Suppressed');
|
|
});
|
|
|
|
it('respects custom host_alert_suppression_mins setting (5 minutes)', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime);
|
|
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80', host_alert_suppression_mins: '5' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
// 3 minutes — still within custom 5-minute window.
|
|
nowSpy.mockReturnValue(baseTime + 3 * 60 * 1000);
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80', host_alert_suppression_mins: '5' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
// 6 minutes — past the custom window; follow-up should fire.
|
|
nowSpy.mockReturnValue(baseTime + 6 * 60 * 1000);
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80', host_alert_suppression_mins: '5' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
|
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('post-restart recovery clears persisted timestamp so re-breach inside the window fires fresh', async () => {
|
|
// Codex audit scenario:
|
|
// T0: breach fires → system_state['last_host_ram_alert_ts'] = T0
|
|
// T1: process restart (in-memory state cleared, persisted T0 survives)
|
|
// T2: metric drops below threshold → clearHostMetricSuppression() runs
|
|
// T3: metric re-breaches within suppression window
|
|
// Expected: fresh first alert at T3 (no Suppressed suffix).
|
|
// Pre-fix bug: clearHostMetricSuppression early-returned on missing in-
|
|
// memory state, leaving persisted T0 alive; T3 hit the restart-survival
|
|
// path and was silently suppressed.
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime + 5 * 60 * 1000); // T1+T2 = T0+5min
|
|
|
|
// Simulate persisted state from a previous process's first fire.
|
|
let persistedRamTs: string | null = String(baseTime);
|
|
mockGetSystemState.mockImplementation((key: string) =>
|
|
key === 'last_host_ram_alert_ts' ? persistedRamTs : null,
|
|
);
|
|
mockSetSystemState.mockImplementation((key: string, value: string) => {
|
|
if (key === 'last_host_ram_alert_ts') persistedRamTs = value;
|
|
});
|
|
|
|
// T2: post-restart cycle finds metric BELOW threshold (recovered).
|
|
mockMem.mockResolvedValue(memSample(4e9));
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
// No dispatch on a non-breach cycle.
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
// Recovery branch MUST have reset persisted state so a re-breach is
|
|
// treated as fresh, not as a still-active cooldown.
|
|
expect(persistedRamTs).toBe('0');
|
|
|
|
// T3: re-breach 10 minutes later (well inside the original 60-min window).
|
|
nowSpy.mockReturnValue(baseTime + 15 * 60 * 1000);
|
|
mockMem.mockResolvedValue(memSample(15e9));
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
const [, , message] = mockDispatchAlert.mock.calls[0];
|
|
expect(message).not.toContain('Suppressed');
|
|
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('caps host_alert_suppression_mins at 24h (1440) to defend against unvalidated single-key POST writes', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime);
|
|
|
|
// The single-key POST /api/settings path stores allowlisted keys
|
|
// without zod re-validation. A 999999999-minute value (or any
|
|
// accidental garbage above 1440) must not let a metric stay
|
|
// suppressed for centuries; cap at 24h in the consumer.
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80', host_alert_suppression_mins: '999999999' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
// 1441 minutes later — past the 24h cap.
|
|
nowSpy.mockReturnValue(baseTime + 1441 * 60 * 1000);
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80', host_alert_suppression_mins: '999999999' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
|
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('post-restart with persisted timestamp inside window does not re-fire', async () => {
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime + 10 * 60 * 1000); // "now" = T+10min
|
|
|
|
// Simulate a previous process having persisted a fire 10 minutes ago.
|
|
mockGetSystemState.mockImplementation((key: string) =>
|
|
key === 'last_host_ram_alert_ts' ? String(baseTime) : null,
|
|
);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
|
|
|
// Post-restart cycle must NOT re-fire because the persisted cooldown
|
|
// is still active (10 min into the default 60 min window).
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('zero or negative suppression_mins falls back to default', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
const baseTime = 1_700_000_000_000;
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseTime);
|
|
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80', host_alert_suppression_mins: '0' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
// Default 60-minute window applies — a cycle 30 minutes later stays
|
|
// suppressed even though the setting said 0.
|
|
nowSpy.mockReturnValue(baseTime + 30 * 60 * 1000);
|
|
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80', host_alert_suppression_mins: '0' });
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
|
|
nowSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ── Alert breach state machine ─────────────────────────────────────────
|
|
|
|
describe('MonitorService - breach state machine', () => {
|
|
function setupAlertScenario(cpuPercent: number) {
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetRunningContainers.mockResolvedValue([{
|
|
Id: 'c1abcdefghijk',
|
|
Names: ['/my-stack-api-1'],
|
|
Labels: {
|
|
'com.docker.compose.project': 'my-stack',
|
|
'com.docker.compose.service': 'api',
|
|
},
|
|
}]);
|
|
mockGetContainerStatsStream.mockResolvedValue(JSON.stringify({
|
|
cpu_stats: { cpu_usage: { total_usage: 1000 + cpuPercent * 50 }, system_cpu_usage: 10000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
}));
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 1,
|
|
stack_name: 'my-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 0, // Fire immediately on breach
|
|
cooldown_mins: 60,
|
|
last_fired_at: 0,
|
|
}]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetStackAlertServiceCooldown.mockReturnValue(null);
|
|
mockHasAnyStackAlertServiceCooldown.mockReturnValue(false);
|
|
}
|
|
|
|
it('fires alert when condition met and duration is 0', async () => {
|
|
setupAlertScenario(90); // Will produce CPU > 80%
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
|
'warning',
|
|
'monitor_alert',
|
|
'The **CPU usage** for **api** in **my-stack** (container **my-stack-api-1**) has exceeded your threshold of **80%** (Currently: 90%).',
|
|
{ stackName: 'my-stack', containerName: 'my-stack-api-1', actor: 'system:monitor' },
|
|
);
|
|
expect(mockUpsertStackAlertServiceCooldown).toHaveBeenCalledWith(1, 'api', expect.any(Number));
|
|
expect(mockUpdateStackAlertLastFired).toHaveBeenCalledWith(1, expect.any(Number));
|
|
});
|
|
|
|
it('does not fire when condition not met', async () => {
|
|
setupAlertScenario(10); // Will produce CPU < 80%
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU'));
|
|
});
|
|
|
|
it('respects cooldown after firing via persisted service cooldown', async () => {
|
|
setupAlertScenario(90);
|
|
mockGetStackAlertServiceCooldown.mockReturnValue(Date.now() - 30 * 60 * 1000);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockUpsertStackAlertServiceCooldown).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('respects legacy last_fired_at floor when no child cooldown rows exist', async () => {
|
|
setupAlertScenario(90);
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 1,
|
|
stack_name: 'my-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 0,
|
|
cooldown_mins: 60,
|
|
last_fired_at: Date.now() - 30 * 60 * 1000,
|
|
}]);
|
|
mockGetStackAlertServiceCooldown.mockReturnValue(null);
|
|
mockHasAnyStackAlertServiceCooldown.mockReturnValue(false);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockUpsertStackAlertServiceCooldown).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fires after legacy last_fired_at cooldown expires and writes a child cooldown row', async () => {
|
|
setupAlertScenario(90);
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 1,
|
|
stack_name: 'my-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 0,
|
|
cooldown_mins: 60,
|
|
last_fired_at: Date.now() - 90 * 60 * 1000,
|
|
}]);
|
|
mockGetStackAlertServiceCooldown.mockReturnValue(null);
|
|
mockHasAnyStackAlertServiceCooldown.mockReturnValue(false);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
expect(mockUpsertStackAlertServiceCooldown).toHaveBeenCalledWith(1, 'api', expect.any(Number));
|
|
expect(mockUpdateStackAlertLastFired).toHaveBeenCalledWith(1, expect.any(Number));
|
|
});
|
|
|
|
it('does not advance cooldown when notification history is not persisted', async () => {
|
|
setupAlertScenario(90);
|
|
mockDispatchAlert.mockResolvedValueOnce({ persisted: false });
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
expect(mockUpsertStackAlertServiceCooldown).not.toHaveBeenCalled();
|
|
expect(mockUpdateStackAlertLastFired).not.toHaveBeenCalled();
|
|
|
|
mockDispatchAlert.mockResolvedValueOnce({ persisted: true });
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
|
expect(mockUpsertStackAlertServiceCooldown).toHaveBeenCalledWith(1, 'api', expect.any(Number));
|
|
expect(mockUpdateStackAlertLastFired).toHaveBeenCalledWith(1, expect.any(Number));
|
|
});
|
|
|
|
it('drops in-memory breach timers when a rule is deleted', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
(svc as any).activeBreaches.set('55:gone-container', { breachStartedAt: Date.now() });
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetRunningContainers.mockResolvedValue([]);
|
|
mockGetStackAlerts.mockReturnValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
|
|
await (svc as any).evaluate();
|
|
|
|
expect((svc as any).activeBreaches.has('55:gone-container')).toBe(false);
|
|
});
|
|
|
|
it('names unlabeled containers as unknown service in the alert body', async () => {
|
|
setupAlertScenario(90);
|
|
mockGetRunningContainers.mockResolvedValue([{
|
|
Id: 'orphan123456789',
|
|
Names: ['/orphan'],
|
|
Labels: {
|
|
'com.docker.compose.project': 'my-stack',
|
|
},
|
|
}]);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
|
'warning',
|
|
'monitor_alert',
|
|
expect.stringContaining('**unknown service**'),
|
|
expect.objectContaining({ stackName: 'my-stack', containerName: 'orphan' }),
|
|
);
|
|
expect(mockUpsertStackAlertServiceCooldown).toHaveBeenCalledWith(1, '_unlabeled', expect.any(Number));
|
|
});
|
|
|
|
it('resets breach state when condition clears for that container only', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
|
|
// First: breach starts
|
|
setupAlertScenario(90);
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 42,
|
|
stack_name: 'my-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 999, // Won't fire due to long duration
|
|
cooldown_mins: 0,
|
|
last_fired_at: 0,
|
|
}]);
|
|
await (svc as any).evaluate();
|
|
expect((svc as any).activeBreaches.has('42:c1abcdefghijk')).toBe(true);
|
|
|
|
// Second: condition clears
|
|
setupAlertScenario(10);
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 42,
|
|
stack_name: 'my-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 999,
|
|
cooldown_mins: 0,
|
|
last_fired_at: 0,
|
|
}]);
|
|
await (svc as any).evaluate();
|
|
expect((svc as any).activeBreaches.has('42:c1abcdefghijk')).toBe(false);
|
|
});
|
|
|
|
it('falls back to short container id when Names is absent', async () => {
|
|
setupAlertScenario(90);
|
|
mockGetRunningContainers.mockResolvedValue([{
|
|
Id: 'abcdef1234567890',
|
|
Labels: {
|
|
'com.docker.compose.project': 'my-stack',
|
|
'com.docker.compose.service': 'api',
|
|
},
|
|
}]);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
|
'warning',
|
|
'monitor_alert',
|
|
'The **CPU usage** for **api** in **my-stack** (container **abcdef123456**) has exceeded your threshold of **80%** (Currently: 90%).',
|
|
{ stackName: 'my-stack', containerName: 'abcdef123456', actor: 'system:monitor' },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Cleanup triggers ───────────────────────────────────────────────────
|
|
|
|
describe('MonitorService - cleanup triggers', () => {
|
|
it('calls cleanup methods with configured retention', async () => {
|
|
mockGetNodes.mockReturnValue([]);
|
|
mockGetStackAlerts.mockReturnValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({
|
|
metrics_retention_hours: '48',
|
|
log_retention_days: '7',
|
|
audit_retention_days: '30',
|
|
});
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockCleanupOldMetrics).toHaveBeenCalledWith(48);
|
|
expect(mockCleanupOldNotifications).toHaveBeenCalledWith(7);
|
|
expect(mockCleanupOldAuditLogs).toHaveBeenCalledWith(30);
|
|
});
|
|
|
|
it('uses defaults when settings are NaN', async () => {
|
|
mockGetNodes.mockReturnValue([]);
|
|
mockGetStackAlerts.mockReturnValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({
|
|
metrics_retention_hours: 'bad',
|
|
log_retention_days: 'bad',
|
|
audit_retention_days: 'bad',
|
|
});
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockCleanupOldMetrics).toHaveBeenCalledWith(24);
|
|
expect(mockCleanupOldNotifications).toHaveBeenCalledWith(30);
|
|
expect(mockCleanupOldAuditLogs).toHaveBeenCalledWith(90);
|
|
});
|
|
});
|
|
|
|
// ── isProcessing guard ─────────────────────────────────────────────────
|
|
|
|
describe('MonitorService - isProcessing guard', () => {
|
|
it('skips evaluation if already processing', async () => {
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetNodes.mockReturnValue([]);
|
|
mockGetStackAlerts.mockReturnValue([]);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
(svc as any).isProcessing = true;
|
|
|
|
await (svc as any).evaluate();
|
|
|
|
// Should have been skipped — no DB calls
|
|
expect(mockGetGlobalSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resets isProcessing after evaluate completes (even on error)', async () => {
|
|
mockGetGlobalSettings.mockImplementationOnce(() => { throw new Error('boom'); });
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
// isProcessing should be reset in finally block
|
|
expect((svc as any).isProcessing).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── restart_count metric ──────────────────────────────────────────────
|
|
|
|
describe('MonitorService - restart_count metric', () => {
|
|
function setupRestartScenario(restartCount: number, hasRestartRule: boolean) {
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetRunningContainers.mockResolvedValue([{
|
|
Id: 'c1',
|
|
Labels: { 'com.docker.compose.project': 'my-stack' },
|
|
}]);
|
|
mockGetContainerStatsStream.mockResolvedValue(JSON.stringify({
|
|
cpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
}));
|
|
mockGetContainerRestartCount.mockResolvedValue(restartCount);
|
|
const alerts = [];
|
|
if (hasRestartRule) {
|
|
alerts.push({
|
|
id: 100,
|
|
stack_name: 'my-stack',
|
|
metric: 'restart_count',
|
|
operator: '>',
|
|
threshold: 3,
|
|
duration_mins: 0,
|
|
cooldown_mins: 60,
|
|
last_fired_at: 0,
|
|
});
|
|
}
|
|
mockGetStackAlerts.mockReturnValue(alerts);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
}
|
|
|
|
it('fetches restart count from Docker when a restart_count rule exists', async () => {
|
|
setupRestartScenario(5, true);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockGetContainerRestartCount).toHaveBeenCalledWith('c1');
|
|
// restart_count=5 > threshold=3, should fire
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
|
'warning',
|
|
'monitor_alert',
|
|
expect.stringContaining('Restart count'),
|
|
{ stackName: 'my-stack', containerName: 'c1', actor: 'system:monitor' },
|
|
);
|
|
});
|
|
|
|
it('skips Docker inspect when no restart_count rules exist', async () => {
|
|
setupRestartScenario(5, false);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockGetContainerRestartCount).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not fire when restart count is below threshold', async () => {
|
|
setupRestartScenario(2, true);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockGetContainerRestartCount).toHaveBeenCalledWith('c1');
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Restart count'), expect.anything());
|
|
});
|
|
});
|
|
|
|
// ── Sencho version update check ───────────────────────────────────────
|
|
|
|
describe('MonitorService - Sencho version check', () => {
|
|
/** In-memory system_state so get/set round-trip within one evaluation. */
|
|
function wireStatefulSystemState(seed: Record<string, string> = {}) {
|
|
const store: Record<string, string> = { ...seed };
|
|
mockGetSystemState.mockImplementation((key: string) => store[key] ?? null);
|
|
mockSetSystemState.mockImplementation((key: string, value: string) => { store[key] = value; });
|
|
return store;
|
|
}
|
|
|
|
async function runEvaluate(): Promise<void> {
|
|
await (MonitorService.getInstance() as any).evaluate();
|
|
}
|
|
|
|
function updateAvailabilityCalls(): unknown[][] {
|
|
return mockDispatchAlert.mock.calls.filter(
|
|
(args: unknown[]) => args[1] === 'node_update_available',
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetNodes.mockReturnValue([]);
|
|
mockGetStackAlerts.mockReturnValue([]);
|
|
});
|
|
|
|
it('dispatches notification when newer version available', async () => {
|
|
mockGetSenchoVersion.mockReturnValue('0.45.0');
|
|
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
|
|
mockGetSystemState.mockReturnValue(null);
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0'));
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('currently running 0.45.0'));
|
|
expect(mockSetSystemState).toHaveBeenCalledWith('last_sencho_update_notified_version', '0.46.0');
|
|
});
|
|
|
|
it('does not re-notify for the same version', async () => {
|
|
mockGetSenchoVersion.mockReturnValue('0.45.0');
|
|
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
|
|
// Running < last notified: self-heal leaves the dedup key alone.
|
|
mockGetSystemState.mockReturnValue('0.46.0');
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0'));
|
|
});
|
|
|
|
it('handles version check failure gracefully and retries next cycle', async () => {
|
|
mockGetSenchoVersion.mockReturnValue('0.45.0');
|
|
mockGetLatestVersionInfo.mockResolvedValue(null);
|
|
mockGetSystemState.mockReturnValue(null);
|
|
|
|
await expect(runEvaluate()).resolves.toBeUndefined();
|
|
await expect(runEvaluate()).resolves.toBeUndefined();
|
|
|
|
expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(2);
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('available'));
|
|
});
|
|
|
|
it('skips version check when getSenchoVersion returns null', async () => {
|
|
mockGetSenchoVersion.mockReturnValue(null);
|
|
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
|
|
mockGetSystemState.mockReturnValue(null);
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0'));
|
|
expect(mockSetSystemState).not.toHaveBeenCalledWith('last_sencho_update_notified_version', expect.anything());
|
|
expect(mockGetLatestVersionInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retries while a GitHub release is pending registry publish without notifying', async () => {
|
|
mockGetSenchoVersion.mockReturnValue('0.93.0');
|
|
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.93.0', publishPending: true });
|
|
mockGetSystemState.mockReturnValue(null);
|
|
|
|
await runEvaluate();
|
|
await runEvaluate();
|
|
|
|
expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(2);
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('available'));
|
|
});
|
|
|
|
it('notifies on the next eval after an up-to-date success when a newer version appears', async () => {
|
|
const store = wireStatefulSystemState();
|
|
mockGetSenchoVersion.mockReturnValue('0.45.0');
|
|
mockGetLatestVersionInfo
|
|
.mockResolvedValueOnce({ version: '0.45.0', publishPending: false })
|
|
.mockResolvedValue({ version: '0.46.0', publishPending: false });
|
|
|
|
await runEvaluate();
|
|
expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(1);
|
|
expect(updateAvailabilityCalls()).toHaveLength(0);
|
|
expect(store.last_sencho_update_notified_version).toBeUndefined();
|
|
|
|
await runEvaluate();
|
|
expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(2);
|
|
const afterSecond = updateAvailabilityCalls();
|
|
expect(afterSecond).toHaveLength(1);
|
|
expect(afterSecond[0][2]).toContain('0.46.0');
|
|
expect(store.last_sencho_update_notified_version).toBe('0.46.0');
|
|
|
|
await runEvaluate();
|
|
expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(3);
|
|
expect(updateAvailabilityCalls()).toHaveLength(1);
|
|
});
|
|
|
|
it('notifies a newer release after a prior notify while still on the old version', async () => {
|
|
const store = wireStatefulSystemState({ last_sencho_update_notified_version: '0.46.0' });
|
|
mockGetSenchoVersion.mockReturnValue('0.45.0');
|
|
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.47.0', publishPending: false });
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.47.0'));
|
|
expect(store.last_sencho_update_notified_version).toBe('0.47.0');
|
|
});
|
|
|
|
it('self-heals dedup after user upgrades to the previously-notified version', async () => {
|
|
// Notified for 0.46.0 while on 0.45.0; now running 0.46.0 with 0.47.0 out.
|
|
const store = wireStatefulSystemState({ last_sencho_update_notified_version: '0.46.0' });
|
|
mockGetSenchoVersion.mockReturnValue('0.46.0');
|
|
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.47.0', publishPending: false });
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.47.0'));
|
|
expect(store.last_sencho_update_notified_version).toBe('0.47.0');
|
|
});
|
|
|
|
it('notifies once the registry publish completes after a pending GitHub release', async () => {
|
|
mockGetSenchoVersion.mockReturnValue('0.93.0');
|
|
mockGetSystemState.mockReturnValue(null);
|
|
|
|
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.93.0', publishPending: true });
|
|
await runEvaluate();
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.94.0'));
|
|
|
|
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.94.0', publishPending: false });
|
|
await runEvaluate();
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.94.0'));
|
|
});
|
|
});
|
|
|
|
describe('MonitorService - Sencho dev build check', () => {
|
|
const DEV_PIN = { pinKind: 'floating' as const, composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev', filePath: '/compose/docker-compose.yml' };
|
|
const DEV_SHA_PIN = { pinKind: 'floating' as const, composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev-abc1234', filePath: '/compose/docker-compose.yml' };
|
|
const DEV_DIGEST_PIN = { pinKind: 'digest' as const, composeImageRef: `ghcr.io/studio-saelix/sencho-dev@sha256:${'a'.repeat(64)}`, filePath: '/compose/docker-compose.yml' };
|
|
const STABLE_PIN = { pinKind: 'semver' as const, composeImageRef: 'ghcr.io/studio-saelix/sencho:0.97.1', filePath: '/compose/docker-compose.yml' };
|
|
|
|
/** In-memory system_state so get/set round-trip within one evaluation. */
|
|
function wireStatefulSystemState(seed: Record<string, string> = {}) {
|
|
const store: Record<string, string> = { ...seed };
|
|
mockGetSystemState.mockImplementation((key: string) => store[key] ?? null);
|
|
mockSetSystemState.mockImplementation((key: string, value: string) => { store[key] = value; });
|
|
return store;
|
|
}
|
|
|
|
async function runEvaluate(): Promise<void> {
|
|
await (MonitorService.getInstance() as any).evaluate();
|
|
}
|
|
|
|
/**
|
|
* checkSenchoDevBuild() is cadence-gated (5 or 30 minutes), so back-to-back
|
|
* calls in a single test are otherwise short-circuited before the detector
|
|
* ever runs. Tests that exercise a second check bypass the gate directly;
|
|
* tests targeting the gate itself assert `lastDevBuildCheckGateMs` instead.
|
|
*/
|
|
function bypassCadenceGate() {
|
|
(MonitorService.getInstance() as any).lastDevBuildCheckAt = 0;
|
|
}
|
|
|
|
function devBuildCalls(): unknown[][] {
|
|
return mockDispatchAlert.mock.calls.filter(
|
|
(args: unknown[]) => args[1] === 'dev_build_update_available',
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetNodes.mockReturnValue([]);
|
|
mockGetStackAlerts.mockReturnValue([]);
|
|
mockGetIdentity.mockReturnValue({
|
|
containerId: null, containerName: null, composeProjectName: null,
|
|
imageId: 'deadbeefcafe0000', networkNames: [], volumeNames: [],
|
|
});
|
|
});
|
|
|
|
it('dispatches and persists dedup state on first detected update', async () => {
|
|
const store = wireStatefulSystemState();
|
|
mockGetPinInfo.mockResolvedValue(DEV_PIN);
|
|
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
|
|
|
|
await runEvaluate();
|
|
|
|
expect(devBuildCalls()).toHaveLength(1);
|
|
expect(devBuildCalls()[0][2]).toContain('ghcr.io/studio-saelix/sencho-dev:dev');
|
|
expect(store.sencho_dev_build_available_digest).toBe('sha256:d1');
|
|
expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d1');
|
|
});
|
|
|
|
it('does not re-notify for the same digest (dedup)', async () => {
|
|
const store = wireStatefulSystemState();
|
|
mockGetPinInfo.mockResolvedValue(DEV_PIN);
|
|
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
|
|
await runEvaluate();
|
|
expect(devBuildCalls()).toHaveLength(1);
|
|
|
|
bypassCadenceGate();
|
|
await runEvaluate();
|
|
|
|
expect(devBuildCalls()).toHaveLength(1);
|
|
expect(store.sencho_dev_build_available_digest).toBe('sha256:d1');
|
|
expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d1');
|
|
});
|
|
|
|
it('notifies again when a later digest appears', async () => {
|
|
const store = wireStatefulSystemState();
|
|
mockGetPinInfo.mockResolvedValue(DEV_PIN);
|
|
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
|
|
await runEvaluate();
|
|
|
|
bypassCadenceGate();
|
|
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d2' });
|
|
await runEvaluate();
|
|
|
|
expect(devBuildCalls()).toHaveLength(2);
|
|
expect(store.sencho_dev_build_available_digest).toBe('sha256:d2');
|
|
expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d2');
|
|
});
|
|
|
|
it('clears the availability key when up to date', async () => {
|
|
const store = wireStatefulSystemState({ sencho_dev_build_available_digest: 'sha256:old' });
|
|
mockGetPinInfo.mockResolvedValue(DEV_PIN);
|
|
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'up_to_date' });
|
|
|
|
await runEvaluate();
|
|
|
|
expect(store.sencho_dev_build_available_digest).toBe('');
|
|
expect(devBuildCalls()).toHaveLength(0);
|
|
});
|
|
|
|
it('does not touch state when inconclusive, and uses the short cadence gate for the next check', async () => {
|
|
const store = wireStatefulSystemState({ sencho_dev_build_available_digest: 'sha256:existing' });
|
|
mockGetPinInfo.mockResolvedValue(DEV_PIN);
|
|
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'inconclusive', reason: 'registry unreachable' });
|
|
|
|
await runEvaluate();
|
|
|
|
expect(store.sencho_dev_build_available_digest).toBe('sha256:existing');
|
|
expect(store.last_sencho_dev_build_notified_digest).toBeUndefined();
|
|
expect(devBuildCalls()).toHaveLength(0);
|
|
expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000);
|
|
});
|
|
|
|
it('keeps the availability key set but leaves the notified key unchanged when the dispatch is not persisted, and retries next check', async () => {
|
|
const store = wireStatefulSystemState();
|
|
mockGetPinInfo.mockResolvedValue(DEV_PIN);
|
|
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
|
|
mockDispatchAlert.mockResolvedValueOnce({ persisted: false });
|
|
|
|
await runEvaluate();
|
|
|
|
expect(store.sencho_dev_build_available_digest).toBe('sha256:d1');
|
|
expect(store.last_sencho_dev_build_notified_digest).toBeUndefined();
|
|
expect(devBuildCalls()).toHaveLength(1);
|
|
expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000);
|
|
|
|
bypassCadenceGate();
|
|
mockDispatchAlert.mockResolvedValueOnce({ persisted: true });
|
|
await runEvaluate();
|
|
|
|
expect(devBuildCalls()).toHaveLength(2);
|
|
expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d1');
|
|
});
|
|
|
|
it('makes no registry call for an immutable dev-<sha> tag', async () => {
|
|
mockGetPinInfo.mockResolvedValue(DEV_SHA_PIN);
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
|
|
expect(mockSetSystemState).not.toHaveBeenCalled();
|
|
expect(devBuildCalls()).toHaveLength(0);
|
|
});
|
|
|
|
it('makes no registry call for a digest-pinned dev-repo ref', async () => {
|
|
mockGetPinInfo.mockResolvedValue(DEV_DIGEST_PIN);
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
|
|
expect(mockSetSystemState).not.toHaveBeenCalled();
|
|
expect(devBuildCalls()).toHaveLength(0);
|
|
});
|
|
|
|
it('is a no-op for a stable-pinned node, and leaves the existing stable version-update behavior unchanged', async () => {
|
|
mockGetPinInfo.mockResolvedValue(STABLE_PIN);
|
|
mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
|
|
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
|
|
expect(devBuildCalls()).toHaveLength(0);
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0'));
|
|
});
|
|
|
|
it('suppresses the stable version-update notification for a dev-repo :dev pin even when a stable update is available', async () => {
|
|
mockGetPinInfo.mockResolvedValue(DEV_PIN);
|
|
mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
|
|
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
|
|
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'up_to_date' });
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
|
|
});
|
|
|
|
it('suppresses the stable version-update notification for an immutable dev-<sha> pin', async () => {
|
|
mockGetPinInfo.mockResolvedValue(DEV_SHA_PIN);
|
|
mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
|
|
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
|
|
});
|
|
|
|
it('suppresses the stable version-update notification for a digest-pinned dev-repo ref', async () => {
|
|
mockGetPinInfo.mockResolvedValue(DEV_DIGEST_PIN);
|
|
mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
|
|
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
|
|
|
|
await runEvaluate();
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
|
|
});
|
|
});
|
|
|
|
// ── Per-container parallel fan-out ────────────────────────────────────
|
|
|
|
describe('MonitorService - parallel container processing', () => {
|
|
/** Build a stats payload that yields a positive CPU percent so the
|
|
* metric pipeline runs end-to-end (calculateCpuPercent + DB write). */
|
|
function statsPayload(): string {
|
|
return JSON.stringify({
|
|
cpu_stats: { cpu_usage: { total_usage: 2000 }, system_cpu_usage: 10000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetStackAlerts.mockReturnValue([]);
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetStackAlertServiceCooldown.mockReturnValue(null);
|
|
mockHasAnyStackAlertServiceCooldown.mockReturnValue(false);
|
|
});
|
|
|
|
it('fans out per-container stats fetches in parallel (wall time ~ max not sum)', async () => {
|
|
const containerCount = 10;
|
|
const perCallDelayMs = 200;
|
|
const containers = Array.from({ length: containerCount }, (_, i) => ({
|
|
Id: `container-${i}`,
|
|
Labels: { 'com.docker.compose.project': 'stack-x' },
|
|
}));
|
|
mockGetRunningContainers.mockResolvedValue(containers);
|
|
mockGetContainerStatsStream.mockImplementation(
|
|
() => new Promise((resolve) => setTimeout(() => resolve(statsPayload()), perCallDelayMs)),
|
|
);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
const start = Date.now();
|
|
await (svc as any).evaluate();
|
|
const elapsed = Date.now() - start;
|
|
|
|
// Serial would be containerCount * perCallDelayMs = 2000ms; parallel
|
|
// collapses to ~perCallDelayMs plus a little dispatch overhead. Allow
|
|
// generous headroom (3x the per-call delay) to avoid CI flake.
|
|
expect(elapsed).toBeLessThan(perCallDelayMs * 3);
|
|
// All containers were processed: one stats call and one metric write each.
|
|
expect(mockGetContainerStatsStream).toHaveBeenCalledTimes(containerCount);
|
|
expect(mockAddContainerMetric).toHaveBeenCalledTimes(containerCount);
|
|
});
|
|
|
|
it('isolates per-container failures: one rejection does not abort siblings', async () => {
|
|
const containers = Array.from({ length: 5 }, (_, i) => ({
|
|
Id: `container-${i}`,
|
|
Labels: { 'com.docker.compose.project': 'stack-x' },
|
|
}));
|
|
mockGetRunningContainers.mockResolvedValue(containers);
|
|
mockGetContainerStatsStream.mockImplementation(async (id: string) => {
|
|
if (id === 'container-2') {
|
|
const err = Object.assign(new Error('no such container'), { statusCode: 404 });
|
|
throw err;
|
|
}
|
|
return statsPayload();
|
|
});
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
// 4 successful containers each wrote a metric; the 404 container is silently skipped.
|
|
expect(mockAddContainerMetric).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it('dispatches a stack alert exactly once per cycle even when multiple containers in the stack breach', async () => {
|
|
// 5 replicas of the same Compose service, all breaching. Dedup is per
|
|
// rule+service, so only one fire should land this cycle.
|
|
const containers = Array.from({ length: 5 }, (_, i) => ({
|
|
Id: `container-${i}`,
|
|
Names: [`/shared-stack-web-${i}`],
|
|
Labels: {
|
|
'com.docker.compose.project': 'shared-stack',
|
|
'com.docker.compose.service': 'web',
|
|
},
|
|
}));
|
|
mockGetRunningContainers.mockResolvedValue(containers);
|
|
mockGetContainerStatsStream.mockResolvedValue(JSON.stringify({
|
|
// Produces CPU = 90% to breach threshold of 80%.
|
|
cpu_stats: { cpu_usage: { total_usage: 5500 }, system_cpu_usage: 10000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
}));
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 7,
|
|
stack_name: 'shared-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 0,
|
|
cooldown_mins: 60,
|
|
last_fired_at: 0,
|
|
}]);
|
|
mockGetStackAlertServiceCooldown.mockReturnValue(null);
|
|
mockHasAnyStackAlertServiceCooldown.mockReturnValue(false);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
const cpuDispatches = mockDispatchAlert.mock.calls.filter(
|
|
(args: unknown[]) => args[1] === 'monitor_alert' && typeof args[2] === 'string' && args[2].includes('CPU'),
|
|
);
|
|
expect(cpuDispatches).toHaveLength(1);
|
|
expect(mockUpsertStackAlertServiceCooldown).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('allows different services on an All-services rule to fire in the same cycle', async () => {
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetRunningContainers.mockResolvedValue([
|
|
{
|
|
Id: 'api-1',
|
|
Names: ['/stack-api-1'],
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'api' },
|
|
},
|
|
{
|
|
Id: 'db-1',
|
|
Names: ['/stack-db-1'],
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'database' },
|
|
},
|
|
]);
|
|
mockGetContainerStatsStream.mockResolvedValue(JSON.stringify({
|
|
cpu_stats: { cpu_usage: { total_usage: 5500 }, system_cpu_usage: 10000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
}));
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 8,
|
|
stack_name: 'shared-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 0,
|
|
cooldown_mins: 60,
|
|
last_fired_at: 0,
|
|
}]);
|
|
mockGetStackAlertServiceCooldown.mockReturnValue(null);
|
|
mockHasAnyStackAlertServiceCooldown.mockReturnValue(false);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
const cpuDispatches = mockDispatchAlert.mock.calls.filter(
|
|
(args: unknown[]) => args[1] === 'monitor_alert' && typeof args[2] === 'string' && args[2].includes('CPU'),
|
|
);
|
|
expect(cpuDispatches).toHaveLength(2);
|
|
expect(mockUpsertStackAlertServiceCooldown).toHaveBeenCalledWith(8, 'api', expect.any(Number));
|
|
expect(mockUpsertStackAlertServiceCooldown).toHaveBeenCalledWith(8, 'database', expect.any(Number));
|
|
});
|
|
|
|
it('does not let a healthy sibling clear another container breach timer', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 9,
|
|
stack_name: 'shared-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 999,
|
|
cooldown_mins: 0,
|
|
last_fired_at: 0,
|
|
}]);
|
|
|
|
const highCpu = JSON.stringify({
|
|
cpu_stats: { cpu_usage: { total_usage: 5500 }, system_cpu_usage: 10000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
});
|
|
const lowCpu = JSON.stringify({
|
|
cpu_stats: { cpu_usage: { total_usage: 1100 }, system_cpu_usage: 10000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
});
|
|
|
|
mockGetRunningContainers.mockResolvedValue([
|
|
{
|
|
Id: 'api-hot',
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'api' },
|
|
},
|
|
{
|
|
Id: 'db-cool',
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'database' },
|
|
},
|
|
]);
|
|
mockGetContainerStatsStream.mockImplementation(async (id: string) => (id === 'api-hot' ? highCpu : lowCpu));
|
|
|
|
await (svc as any).evaluate();
|
|
|
|
expect((svc as any).activeBreaches.has('9:api-hot')).toBe(true);
|
|
expect((svc as any).activeBreaches.has('9:db-cool')).toBe(false);
|
|
});
|
|
|
|
it('ignores sibling services for a service-scoped rule', async () => {
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetRunningContainers.mockResolvedValue([
|
|
{
|
|
Id: 'api-1',
|
|
Names: ['/stack-api-1'],
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'api' },
|
|
},
|
|
{
|
|
Id: 'db-1',
|
|
Names: ['/stack-db-1'],
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'database' },
|
|
},
|
|
]);
|
|
mockGetContainerStatsStream.mockResolvedValue(JSON.stringify({
|
|
cpu_stats: { cpu_usage: { total_usage: 5500 }, system_cpu_usage: 10000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
}));
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 10,
|
|
stack_name: 'shared-stack',
|
|
service_name: 'api',
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 0,
|
|
cooldown_mins: 0,
|
|
last_fired_at: 0,
|
|
}]);
|
|
mockGetStackAlertServiceCooldown.mockReturnValue(null);
|
|
mockHasAnyStackAlertServiceCooldown.mockReturnValue(false);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
expect(mockDispatchAlert.mock.calls[0][2]).toContain('**api**');
|
|
expect(mockUpsertStackAlertServiceCooldown).toHaveBeenCalledWith(10, 'api', expect.any(Number));
|
|
});
|
|
|
|
it('does not block service B after service A fires under new-schema cooldown', async () => {
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetRunningContainers.mockResolvedValue([
|
|
{
|
|
Id: 'db-1',
|
|
Names: ['/stack-db-1'],
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'database' },
|
|
},
|
|
]);
|
|
mockGetContainerStatsStream.mockResolvedValue(JSON.stringify({
|
|
cpu_stats: { cpu_usage: { total_usage: 5500 }, system_cpu_usage: 10000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
}));
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 11,
|
|
stack_name: 'shared-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 0,
|
|
cooldown_mins: 60,
|
|
last_fired_at: Date.now() - 5 * 60 * 1000, // would block under old rule-wide semantics
|
|
}]);
|
|
// api already has a cooldown row; database does not, so database may still fire.
|
|
mockGetStackAlertServiceCooldown.mockImplementation((_id: number, service: string) =>
|
|
service === 'api' ? Date.now() - 5 * 60 * 1000 : null,
|
|
);
|
|
mockHasAnyStackAlertServiceCooldown.mockReturnValue(true);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockUpsertStackAlertServiceCooldown).toHaveBeenCalledWith(11, 'database', expect.any(Number));
|
|
});
|
|
|
|
it('honors persisted per-service cooldown after a fresh MonitorService instance', async () => {
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetRunningContainers.mockResolvedValue([
|
|
{
|
|
Id: 'api-1',
|
|
Names: ['/stack-api-1'],
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'api' },
|
|
},
|
|
{
|
|
Id: 'api-2',
|
|
Names: ['/stack-api-2'],
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'api' },
|
|
},
|
|
{
|
|
Id: 'db-1',
|
|
Names: ['/stack-db-1'],
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'database' },
|
|
},
|
|
]);
|
|
mockGetContainerStatsStream.mockResolvedValue(JSON.stringify({
|
|
cpu_stats: { cpu_usage: { total_usage: 5500 }, system_cpu_usage: 10000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
}));
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 13,
|
|
stack_name: 'shared-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 0,
|
|
cooldown_mins: 60,
|
|
last_fired_at: 0,
|
|
}]);
|
|
mockGetStackAlertServiceCooldown.mockImplementation((_id: number, service: string) =>
|
|
service === 'api' ? Date.now() - 5 * 60 * 1000 : null,
|
|
);
|
|
mockHasAnyStackAlertServiceCooldown.mockReturnValue(true);
|
|
|
|
// beforeEach already cleared the singleton; this instance has empty maps.
|
|
const svc = MonitorService.getInstance();
|
|
expect((svc as any).activeBreaches.size).toBe(0);
|
|
expect((svc as any).firedThisCycle.size).toBe(0);
|
|
|
|
await (svc as any).evaluate();
|
|
|
|
const cpuDispatches = mockDispatchAlert.mock.calls.filter(
|
|
(args: unknown[]) => args[1] === 'monitor_alert' && typeof args[2] === 'string' && args[2].includes('CPU'),
|
|
);
|
|
expect(cpuDispatches).toHaveLength(1);
|
|
expect(cpuDispatches[0][2]).toContain('**database**');
|
|
expect(mockUpsertStackAlertServiceCooldown).toHaveBeenCalledWith(13, 'database', expect.any(Number));
|
|
expect(mockUpsertStackAlertServiceCooldown).not.toHaveBeenCalledWith(13, 'api', expect.any(Number));
|
|
});
|
|
|
|
it('preserves breach timers when container enumeration fails', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
(svc as any).activeBreaches.set('12:still-running', { breachStartedAt: Date.now() });
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 12,
|
|
stack_name: 'shared-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 999,
|
|
cooldown_mins: 0,
|
|
last_fired_at: 0,
|
|
}]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetRunningContainers.mockRejectedValue(new Error('docker down'));
|
|
|
|
await (svc as any).evaluate();
|
|
|
|
expect((svc as any).activeBreaches.has('12:still-running')).toBe(true);
|
|
});
|
|
|
|
it('removes breach timers for stopped containers after successful enumeration', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
(svc as any).activeBreaches.set('13:gone', { breachStartedAt: Date.now() });
|
|
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
|
|
mockGetStackAlerts.mockReturnValue([{
|
|
id: 13,
|
|
stack_name: 'shared-stack',
|
|
service_name: null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 999,
|
|
cooldown_mins: 0,
|
|
last_fired_at: 0,
|
|
}]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
mockGetRunningContainers.mockResolvedValue([
|
|
{
|
|
Id: 'still-here',
|
|
Labels: { 'com.docker.compose.project': 'shared-stack', 'com.docker.compose.service': 'api' },
|
|
},
|
|
]);
|
|
mockGetContainerStatsStream.mockResolvedValue(JSON.stringify({
|
|
cpu_stats: { cpu_usage: { total_usage: 1100 }, system_cpu_usage: 10000, online_cpus: 1 },
|
|
precpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
|
|
memory_stats: { usage: 100e6, limit: 1e9 },
|
|
}));
|
|
|
|
await (svc as any).evaluate();
|
|
|
|
expect((svc as any).activeBreaches.has('13:gone')).toBe(false);
|
|
});
|
|
|
|
it('caps simultaneous Docker calls at MAX_CONTAINER_CONCURRENCY', async () => {
|
|
// 25 containers with a long per-call delay; observe that no more than
|
|
// 10 (MAX_CONTAINER_CONCURRENCY) are in flight at the same time.
|
|
const containerCount = 25;
|
|
const perCallDelayMs = 100;
|
|
let inFlight = 0;
|
|
let peakInFlight = 0;
|
|
const containers = Array.from({ length: containerCount }, (_, i) => ({
|
|
Id: `container-${i}`,
|
|
Labels: { 'com.docker.compose.project': 'stack-x' },
|
|
}));
|
|
mockGetRunningContainers.mockResolvedValue(containers);
|
|
mockGetContainerStatsStream.mockImplementation(
|
|
() => new Promise((resolve) => {
|
|
inFlight += 1;
|
|
if (inFlight > peakInFlight) peakInFlight = inFlight;
|
|
setTimeout(() => {
|
|
inFlight -= 1;
|
|
resolve(statsPayload());
|
|
}, perCallDelayMs);
|
|
}),
|
|
);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(peakInFlight).toBeLessThanOrEqual(10);
|
|
// And we still saw real concurrency (not serialized), so peak should be
|
|
// close to the cap when we have many more items than workers.
|
|
expect(peakInFlight).toBeGreaterThan(1);
|
|
expect(mockAddContainerMetric).toHaveBeenCalledTimes(containerCount);
|
|
});
|
|
});
|
|
|
|
// ── Janitor cycle and circuit breaker (F-6) ────────────────────────────
|
|
|
|
describe('MonitorService - janitor cycle and circuit breaker', () => {
|
|
// Convenience: builds a never-settling promise used to simulate a hung df().
|
|
function hangForever(): Promise<never> {
|
|
return new Promise<never>(() => { /* never resolves */ });
|
|
}
|
|
|
|
// Reclaimable payload large enough to cross a 0.5 GB janitor threshold.
|
|
const RECLAIMABLE_3GB = {
|
|
reclaimableImages: 3 * 1024 * 1024 * 1024,
|
|
reclaimableContainers: 0,
|
|
reclaimableVolumes: 0,
|
|
reclaimableBuildCache: 0,
|
|
reclaimableImageCount: 5,
|
|
reclaimableContainerCount: 0,
|
|
reclaimableVolumeCount: 0,
|
|
reclaimableBuildCacheCount: 0,
|
|
};
|
|
|
|
it('evaluate() does NOT call getDiskUsage (decoupling guardrail)', async () => {
|
|
// F-6 regression guard: the 30s monitor cycle must never call df().
|
|
// If someone re-couples the janitor into evaluate(), this test fails.
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0.5' });
|
|
mockGetNodes.mockReturnValue([]);
|
|
mockGetStackAlerts.mockReturnValue([]);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluate();
|
|
|
|
expect(mockGetDiskUsage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('evaluate() completes promptly even when getDiskUsage would hang (F-6 regression)', async () => {
|
|
// If df() were still on the 30s cycle, hangForever would compound the
|
|
// cycle beyond its 25s threshold. Decoupled, evaluate() must return
|
|
// within a small wall-clock budget regardless.
|
|
mockGetDiskUsage.mockReturnValue(hangForever());
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0.5' });
|
|
mockGetNodes.mockReturnValue([]);
|
|
mockGetStackAlerts.mockReturnValue([]);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
const t0 = Date.now();
|
|
await (svc as any).evaluate();
|
|
const elapsed = Date.now() - t0;
|
|
|
|
expect(elapsed).toBeLessThan(2000);
|
|
expect(mockGetDiskUsage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('evaluateJanitor() honors isJanitorProcessing re-entrancy guard', async () => {
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0.5' });
|
|
mockGetDiskUsage.mockResolvedValue(RECLAIMABLE_3GB);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
(svc as any).isJanitorProcessing = true;
|
|
|
|
await (svc as any).evaluateJanitor();
|
|
|
|
// Second concurrent call must skip without touching settings or df.
|
|
expect(mockGetGlobalSettings).not.toHaveBeenCalled();
|
|
expect(mockGetDiskUsage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips when docker_janitor_gb is unset, zero, or NaN', async () => {
|
|
const svc = MonitorService.getInstance();
|
|
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
await (svc as any).evaluateJanitor();
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0' });
|
|
await (svc as any).evaluateJanitor();
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: 'abc' });
|
|
await (svc as any).evaluateJanitor();
|
|
|
|
expect(mockGetDiskUsage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('dispatches a node-neutral janitor alert', async () => {
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0.5' });
|
|
mockGetDiskUsage.mockResolvedValue(RECLAIMABLE_3GB);
|
|
mockGetSystemState.mockReturnValue('0'); // No prior alert; cooldown elapsed.
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateJanitor();
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
|
'info',
|
|
'system',
|
|
'This node has accumulated 3.0 GB of unused Docker data. Open Resources to reclaim space, or set up a Prune Node Resources schedule.',
|
|
{ stackName: undefined, actor: 'system:monitor' },
|
|
);
|
|
});
|
|
|
|
it('does NOT alert when reclaimable is below MIN_RECLAIMABLE_GB even if threshold is aggressive', async () => {
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0.001' });
|
|
mockGetDiskUsage.mockResolvedValue({
|
|
...RECLAIMABLE_3GB,
|
|
reclaimableImages: 50 * 1024 * 1024, // 50 MB, below the 100 MB floor
|
|
});
|
|
mockGetSystemState.mockReturnValue('0');
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateJanitor();
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('opens the circuit breaker after JANITOR_BREAKER_THRESHOLD consecutive timeouts', async () => {
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0.5' });
|
|
mockGetDiskUsage.mockReturnValue(hangForever());
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateJanitor();
|
|
await (svc as any).evaluateJanitor();
|
|
expect(warnSpy).not.toHaveBeenCalled(); // Threshold not yet reached.
|
|
await (svc as any).evaluateJanitor(); // Third timeout trips the breaker.
|
|
|
|
const breakerLine = warnSpy.mock.calls.find(
|
|
(args) => typeof args[0] === 'string' && args[0].includes('circuit breaker opened'),
|
|
);
|
|
expect(breakerLine).toBeDefined();
|
|
expect((svc as any).janitorBreakerUntil).toBeGreaterThan(Date.now());
|
|
// Counter resets on open so the cooldown is what gates the next attempt.
|
|
expect((svc as any).janitorConsecutiveTimeouts).toBe(0);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('respects the breaker cooldown; does not call getDiskUsage while open', async () => {
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0.5' });
|
|
|
|
const svc = MonitorService.getInstance();
|
|
(svc as any).janitorBreakerUntil = Date.now() + 60 * 60 * 1000;
|
|
|
|
await (svc as any).evaluateJanitor();
|
|
|
|
expect(mockGetDiskUsage).not.toHaveBeenCalled();
|
|
expect(mockGetGlobalSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resets the timeout counter on a successful call after partial failures', async () => {
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0.5' });
|
|
mockGetDiskUsage
|
|
.mockReturnValueOnce(hangForever())
|
|
.mockReturnValueOnce(hangForever())
|
|
.mockResolvedValueOnce(RECLAIMABLE_3GB);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateJanitor();
|
|
await (svc as any).evaluateJanitor();
|
|
expect((svc as any).janitorConsecutiveTimeouts).toBe(2);
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
await (svc as any).evaluateJanitor();
|
|
|
|
expect((svc as any).janitorConsecutiveTimeouts).toBe(0);
|
|
const recoveryLine = logSpy.mock.calls.find(
|
|
(args) => typeof args[0] === 'string' && args[0].includes('recovered'),
|
|
);
|
|
expect(recoveryLine).toBeDefined();
|
|
logSpy.mockRestore();
|
|
});
|
|
|
|
it('logs recovery on the first successful call after a full breaker-open cooldown', async () => {
|
|
// After the breaker opens, the counter is zeroed; once the cooldown
|
|
// lapses, a successful call must still emit the recovered log so the
|
|
// operator observability story is symmetric with the partial-failure
|
|
// recovery path.
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0.5' });
|
|
|
|
const svc = MonitorService.getInstance();
|
|
(svc as any).janitorBreakerUntil = Date.now() - 1; // cooldown just elapsed
|
|
(svc as any).janitorConsecutiveTimeouts = 0; // zeroed on open
|
|
mockGetDiskUsage.mockResolvedValue(RECLAIMABLE_3GB);
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
await (svc as any).evaluateJanitor();
|
|
|
|
const recoveryLine = logSpy.mock.calls.find(
|
|
(args) => typeof args[0] === 'string' && args[0].includes('recovered'),
|
|
);
|
|
expect(recoveryLine).toBeDefined();
|
|
expect((svc as any).janitorBreakerUntil).toBe(0);
|
|
expect((svc as any).janitorConsecutiveTimeouts).toBe(0);
|
|
logSpy.mockRestore();
|
|
});
|
|
|
|
it('does NOT advance the breaker counter on non-timeout errors', async () => {
|
|
mockGetGlobalSettings.mockReturnValue({ docker_janitor_gb: '0.5' });
|
|
mockGetDiskUsage.mockRejectedValue(Object.assign(new Error('daemon unreachable'), { statusCode: 500 }));
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).evaluateJanitor();
|
|
await (svc as any).evaluateJanitor();
|
|
await (svc as any).evaluateJanitor();
|
|
await (svc as any).evaluateJanitor();
|
|
|
|
expect((svc as any).janitorConsecutiveTimeouts).toBe(0);
|
|
expect((svc as any).janitorBreakerUntil).toBe(0);
|
|
// The original support-grep line must still fire on every non-timeout error.
|
|
const janitorErrorLines = errSpy.mock.calls.filter(
|
|
(args) => typeof args[0] === 'string' && args[0].includes('Error checking docker janitor limits'),
|
|
);
|
|
expect(janitorErrorLines.length).toBe(4);
|
|
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
it('stop() clears both intervals AND both deferred first-tick timeouts', () => {
|
|
// Without canceling the first-tick setTimeouts, stop() would let
|
|
// evaluate() / evaluateJanitor() fire on a service that the caller
|
|
// believes is dormant. The 5s and 45s windows are wider than typical
|
|
// graceful-shutdown budgets, so this matters.
|
|
const svc = MonitorService.getInstance();
|
|
svc.start();
|
|
expect((svc as any).intervalId).not.toBeNull();
|
|
expect((svc as any).firstTickTimeoutId).not.toBeNull();
|
|
expect((svc as any).janitorIntervalId).not.toBeNull();
|
|
expect((svc as any).janitorFirstTickTimeoutId).not.toBeNull();
|
|
|
|
svc.stop();
|
|
|
|
expect((svc as any).intervalId).toBeNull();
|
|
expect((svc as any).firstTickTimeoutId).toBeNull();
|
|
expect((svc as any).janitorIntervalId).toBeNull();
|
|
expect((svc as any).janitorFirstTickTimeoutId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('MonitorService - reconcileOrphanedScans', () => {
|
|
function makeDb(refs: string[]) {
|
|
return {
|
|
getDistinctScanImageRefs: vi.fn().mockReturnValue(refs),
|
|
deleteScansByImageRef: vi.fn().mockReturnValue(1),
|
|
};
|
|
}
|
|
|
|
async function run(db: ReturnType<typeof makeDb>, settings: Record<string, string>) {
|
|
const svc = MonitorService.getInstance();
|
|
await (svc as any).reconcileOrphanedScans(db, settings);
|
|
return db.deleteScansByImageRef.mock.calls.map((c) => c[1] as string).sort();
|
|
}
|
|
|
|
it('does nothing when prune_orphaned_scans is not "1"', async () => {
|
|
const db = makeDb(['nginx:1']);
|
|
await run(db, { prune_orphaned_scans: '0' });
|
|
expect(db.getDistinctScanImageRefs).not.toHaveBeenCalled();
|
|
expect(db.deleteScansByImageRef).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('purges only the image and stack scans whose artifact is gone, scoped to the local node', async () => {
|
|
mockGetImages.mockResolvedValue([
|
|
{ RepoTags: ['nginx:1', 'redis:7'] },
|
|
{ RepoTags: ['<none>:<none>'] },
|
|
{ RepoTags: undefined }, // dangling image with no tags
|
|
]);
|
|
mockGetStacks.mockResolvedValue(['web']);
|
|
const db = makeDb(['nginx:1', 'ghost:9', 'stack:web', 'stack:old']);
|
|
expect(await run(db, { prune_orphaned_scans: '1' })).toEqual(['ghost:9', 'stack:old']);
|
|
// Per-instance: reconciliation reads and deletes against the local node id only.
|
|
expect(db.getDistinctScanImageRefs).toHaveBeenCalledWith(1);
|
|
for (const call of db.deleteScansByImageRef.mock.calls) {
|
|
expect(call[0]).toBe(1);
|
|
}
|
|
});
|
|
|
|
it('keeps scans whose ref matches a live image after normalization (untagged, registry-qualified, digest-pinned)', async () => {
|
|
mockGetImages.mockResolvedValue([
|
|
{ RepoTags: ['alpine:latest', 'nginx:1.14'], RepoDigests: ['redis@sha256:abc'] },
|
|
]);
|
|
mockGetStacks.mockResolvedValue(['web']);
|
|
// Stored refs in non-canonical forms that all resolve to a present image:
|
|
// alpine -> alpine:latest, docker.io/library/nginx:1.14 -> nginx:1.14,
|
|
// docker.io/library/redis@sha256:abc -> redis@sha256:abc (digest).
|
|
const db = makeDb(['alpine', 'docker.io/library/nginx:1.14', 'docker.io/library/redis@sha256:abc', 'ghost:9']);
|
|
expect(await run(db, { prune_orphaned_scans: '1' })).toEqual(['ghost:9']);
|
|
});
|
|
|
|
it('skips stack reconciliation when getStacks returns empty (ambiguous), still purges image orphans', async () => {
|
|
mockGetImages.mockResolvedValue([{ RepoTags: ['nginx:1'] }]);
|
|
mockGetStacks.mockResolvedValue([]);
|
|
const db = makeDb(['stack:web', 'ghost:9']);
|
|
const deleted = await run(db, { prune_orphaned_scans: '1' });
|
|
expect(deleted).toContain('ghost:9');
|
|
expect(deleted).not.toContain('stack:web');
|
|
});
|
|
|
|
it('purges nothing when the Docker image list cannot be read (fail-safe)', async () => {
|
|
mockGetImages.mockRejectedValue(new Error('docker down'));
|
|
mockGetStacks.mockResolvedValue(['web']);
|
|
const db = makeDb(['ghost:9', 'stack:old']);
|
|
await run(db, { prune_orphaned_scans: '1' });
|
|
expect(db.deleteScansByImageRef).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('purges every image scan when there are zero live images (empty but successful read)', async () => {
|
|
mockGetImages.mockResolvedValue([]);
|
|
mockGetStacks.mockResolvedValue(['web']);
|
|
const db = makeDb(['nginx:1', 'redis:7', 'stack:web']);
|
|
expect(await run(db, { prune_orphaned_scans: '1' })).toEqual(['nginx:1', 'redis:7']);
|
|
});
|
|
});
|