feat: purge scan data for deleted images and stacks (#1467)

Vulnerability scan rows were never cleaned up when their image was removed
from Docker or their stack was deleted, so the Security Overview (including
the Top exploit-risk findings card) kept surfacing findings for artifacts that
no longer exist.

Scan results now reflect what is still on the host:

- Deleting a stack immediately purges its stack:<name> compose-config scan.
- A background reconciliation in the monitor janitor removes scans whose image
  is gone from the node, or whose stack folder no longer exists. It is
  fail-safe: a scan is only removed when its artifact is positively known to be
  gone, the Docker image list is read with a timeout (skipped on failure), and
  stack scans are reconciled only when the stack list is non-empty.
- An opt-out "Remove scans for deleted images and stacks" setting (on by
  default, per-node) lets operators retain scan history for removed artifacts.

Scan deletes remove child findings explicitly, since SQLite foreign-key cascade
is not enabled on the connection.
This commit is contained in:
Anso
2026-06-26 11:59:37 -04:00
committed by GitHub
parent eaf0642d88
commit 26d557a701
12 changed files with 540 additions and 2 deletions
@@ -0,0 +1,141 @@
/**
* Coverage for the orphan-scan purge helpers on the real DatabaseService:
* - getDistinctScanImageRefs (node-scoped, deduplicated)
* - deleteScansByImageRef (removes the scan AND its children explicitly,
* since SQLite FK cascade is not enabled on the connection)
* - deleteStackScans (purges the stack:<name> misconfig scan only)
*
* Uses the real DatabaseService against a temp DB so the no-cascade delete path
* is exercised exactly as in production, not against an in-memory mirror that
* might enable foreign_keys and mask a missing child delete.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => cleanupTestDb(tmpDir));
function db() {
return DatabaseService.getInstance();
}
function reset(): void {
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
raw.prepare('DELETE FROM vulnerability_scans').run();
raw.prepare('DELETE FROM vulnerability_details').run();
raw.prepare('DELETE FROM secret_findings').run();
raw.prepare('DELETE FROM misconfig_findings').run();
}
function seedScan(imageRef: string, nodeId = 1): number {
return db().createVulnerabilityScan({
node_id: nodeId,
image_ref: imageRef,
image_digest: `sha256:${imageRef}-${Math.random().toString(16).slice(2)}`,
scanned_at: Date.now(),
total_vulnerabilities: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: null,
os_info: null,
trivy_version: null,
scan_duration_ms: null,
triggered_by: 'manual',
status: 'completed',
error: null,
stack_context: null,
});
}
function countChild(table: string, scanId: number): number {
const raw = (db() as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => { c: number } } } }).db;
return raw.prepare(`SELECT COUNT(*) as c FROM ${table} WHERE scan_id = ?`).get(scanId).c;
}
beforeEach(reset);
describe('getDistinctScanImageRefs', () => {
it('returns each image_ref once, scoped to the node', () => {
seedScan('nginx:1');
seedScan('nginx:1'); // duplicate ref, same node
seedScan('redis:7');
seedScan('other:1', 2); // different node
const refs = db().getDistinctScanImageRefs(1).sort();
expect(refs).toEqual(['nginx:1', 'redis:7']);
});
it('returns an empty array when the node has no scans', () => {
expect(db().getDistinctScanImageRefs(99)).toEqual([]);
});
});
describe('deleteScansByImageRef', () => {
it('removes the scan and ALL of its child findings (no FK cascade reliance)', () => {
const scanId = seedScan('nginx:1');
db().insertVulnerabilityDetails(scanId, [
{ vulnerability_id: 'CVE-1', pkg_name: 'libssl', installed_version: '1.0', fixed_version: '1.1', severity: 'CRITICAL', title: null, description: null, primary_url: null },
]);
db().insertSecretFindings(scanId, [
{ rule_id: 'aws-key', category: 'secret', severity: 'HIGH', title: null, target: 'app.env', start_line: 1, end_line: 1, match_excerpt: null },
]);
db().insertMisconfigFindings(scanId, [
{ rule_id: 'DS002', check_id: null, severity: 'MEDIUM', title: null, message: null, resolution: null, target: 'Dockerfile', primary_url: null },
]);
expect(countChild('vulnerability_details', scanId)).toBe(1);
expect(countChild('secret_findings', scanId)).toBe(1);
expect(countChild('misconfig_findings', scanId)).toBe(1);
const removed = db().deleteScansByImageRef(1, 'nginx:1');
expect(removed).toBe(1);
expect(db().getDistinctScanImageRefs(1)).toEqual([]);
expect(countChild('vulnerability_details', scanId)).toBe(0);
expect(countChild('secret_findings', scanId)).toBe(0);
expect(countChild('misconfig_findings', scanId)).toBe(0);
});
it('only deletes the requested ref and node, leaving others intact', () => {
seedScan('nginx:1');
seedScan('redis:7');
seedScan('nginx:1', 2);
const removed = db().deleteScansByImageRef(1, 'nginx:1');
expect(removed).toBe(1);
expect(db().getDistinctScanImageRefs(1).sort()).toEqual(['redis:7']);
expect(db().getDistinctScanImageRefs(2)).toEqual(['nginx:1']);
});
it('is idempotent (0 when nothing matches)', () => {
expect(db().deleteScansByImageRef(1, 'ghost:1')).toBe(0);
});
});
describe('deleteStackScans', () => {
it('purges only the stack:<name> scan, not unrelated image scans', () => {
seedScan('stack:web');
seedScan('stack:db');
seedScan('nginx:1');
const removed = db().deleteStackScans(1, 'web');
expect(removed).toBe(1);
expect(db().getDistinctScanImageRefs(1).sort()).toEqual(['nginx:1', 'stack:db']);
});
});
+86 -1
View File
@@ -10,7 +10,7 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockCleanupOldMetrics, mockCleanupOldNotifications, mockCleanupOldAuditLogs,
mockUpdateStackAlertLastFired, mockGetSystemState, mockSetSystemState,
mockGetRunningContainers, mockGetAllContainers, mockGetContainerStatsStream,
mockGetContainerRestartCount, mockGetDiskUsage,
mockGetContainerRestartCount, mockGetDiskUsage, mockGetImages, mockGetStacks,
mockDispatchAlert,
mockCurrentLoad, mockMem, mockFsSize,
mockExecAsync,
@@ -36,6 +36,8 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
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(undefined),
mockCurrentLoad: vi.fn().mockResolvedValue({ currentLoad: 10 }),
mockMem: vi.fn().mockResolvedValue({ total: 16e9, used: 4e9, active: 4e9, available: 12e9, free: 12e9, buffcache: 0 }),
@@ -71,6 +73,15 @@ vi.mock('../services/DockerController', () => ({
getContainerStatsStream: mockGetContainerStatsStream,
getContainerRestartCount: mockGetContainerRestartCount,
getDiskUsage: mockGetDiskUsage,
getImages: mockGetImages,
}),
},
}));
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
getStacks: mockGetStacks,
}),
},
}));
@@ -1562,3 +1573,77 @@ describe('MonitorService - janitor cycle and circuit breaker', () => {
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']);
});
});
@@ -241,6 +241,46 @@ describe('prune_on_update (auto-prune after updates)', () => {
});
});
describe('prune_orphaned_scans (purge scans for deleted images/stacks)', () => {
it('defaults to ON in a freshly seeded database', () => {
expect(DatabaseService.getInstance().getGlobalSettings().prune_orphaned_scans).toBe('1');
});
it('is exposed through the settings GET projection', async () => {
const res = await request(app).get('/api/settings').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.prune_orphaned_scans).toBeDefined();
});
it('rejects a non-admin write with 403', async () => {
const res = await request(app)
.post('/api/settings')
.set('Cookie', viewerCookie)
.send({ key: 'prune_orphaned_scans', value: '0' });
expect(res.status).toBe(403);
});
it('accepts a well-formed write and rejects a non-enum value', async () => {
const ok = await request(app)
.post('/api/settings')
.set('Cookie', adminCookie)
.send({ key: 'prune_orphaned_scans', value: '0' });
expect(ok.status).toBe(200);
expect(DatabaseService.getInstance().getGlobalSettings().prune_orphaned_scans).toBe('0');
const bad = await request(app)
.post('/api/settings')
.set('Cookie', adminCookie)
.send({ key: 'prune_orphaned_scans', value: 'banana' });
expect(bad.status).toBe(400);
expect(bad.body.error).toBe('Validation failed');
expect(DatabaseService.getInstance().getGlobalSettings().prune_orphaned_scans).toBe('0');
// Restore the seeded default so later suites observe the shipped behavior.
DatabaseService.getInstance().updateGlobalSetting('prune_orphaned_scans', '1');
});
});
describe('health gate settings', () => {
it('seeds enabled with a 90 second window in a fresh database', () => {
const settings = DatabaseService.getInstance().getGlobalSettings();
@@ -0,0 +1,92 @@
/**
* DELETE /api/stacks/:stackName must purge the deleted stack's compose-config
* (misconfig) scan, keyed by the `stack:<name>` image_ref convention, so it no
* longer skews the Security Overview. Image scans are intentionally left to the
* janitor reconciler (images are shared and may still exist on the host).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let ComposeService: typeof import('../services/ComposeService').ComposeService;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
let MeshService: typeof import('../services/MeshService').MeshService;
let adminCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ ComposeService } = await import('../services/ComposeService'));
({ FileSystemService } = await import('../services/FileSystemService'));
({ MeshService } = await import('../services/MeshService'));
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
// Stub leaf I/O so the real delete handler runs through to the DB cleanup.
vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
vi.spyOn(FileSystemService.prototype, 'deleteStack').mockResolvedValue(undefined);
vi.spyOn(MeshService.getInstance(), 'optOutStack').mockResolvedValue(undefined);
const raw = DatabaseService.getInstance().getDb();
raw.prepare('DELETE FROM vulnerability_scans').run();
});
function seedStackScan(nodeId: number, imageRef: string): void {
DatabaseService.getInstance().createVulnerabilityScan({
node_id: nodeId,
image_ref: imageRef,
image_digest: null,
scanned_at: Date.now(),
total_vulnerabilities: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 1,
scanners_used: 'misconfig',
highest_severity: 'MEDIUM',
os_info: null,
trivy_version: null,
scan_duration_ms: null,
triggered_by: 'manual',
status: 'completed',
error: null,
stack_context: imageRef.startsWith('stack:') ? imageRef.slice('stack:'.length) : null,
});
}
describe('DELETE /api/stacks/:stackName purges scan data', () => {
it("removes the deleted stack's stack:<name> scan and leaves other scans", async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getNodes()[0].id;
seedStackScan(nodeId, 'stack:web');
seedStackScan(nodeId, 'stack:db');
seedStackScan(nodeId, 'nginx:1');
const res = await request(app).delete('/api/stacks/web').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(db.getDistinctScanImageRefs(nodeId).sort()).toEqual(['nginx:1', 'stack:db']);
});
it('is a no-op (still 200) when the deleted stack has no scans', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getNodes()[0].id;
seedStackScan(nodeId, 'nginx:1');
const res = await request(app).delete('/api/stacks/never-scanned').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(db.getDistinctScanImageRefs(nodeId)).toEqual(['nginx:1']);
});
});
+2
View File
@@ -25,6 +25,7 @@ const ALLOWED_SETTING_KEYS = new Set([
'audit_retention_days',
'mesh_auto_recreate',
'scan_history_per_image_limit',
'prune_orphaned_scans',
'prune_on_update',
'reclaim_hero',
'snapshot_documentation',
@@ -54,6 +55,7 @@ const SettingsPatchSchema = z.object({
audit_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
mesh_auto_recreate: z.enum(['0', '1']),
scan_history_per_image_limit: z.coerce.number().int().min(5).max(1000).transform(String),
prune_orphaned_scans: z.enum(['0', '1']),
prune_on_update: z.enum(['0', '1']),
reclaim_hero: z.enum(['0', '1']),
snapshot_documentation: z.enum(['0', '1']),
+1
View File
@@ -1047,6 +1047,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
DatabaseService.getInstance().deleteStackExposureIntents(req.nodeId, stackName);
DatabaseService.getInstance().deleteStackExposure(req.nodeId, stackName);
DatabaseService.getInstance().deleteStackProjectEnvFiles(req.nodeId, stackName);
DatabaseService.getInstance().deleteStackScans(req.nodeId, stackName);
if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName });
} catch (dbErr) {
console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr);
+54
View File
@@ -1572,6 +1572,11 @@ export class DatabaseService {
stmt.run('metrics_retention_hours', '24');
stmt.run('log_retention_days', '30');
stmt.run('scan_history_per_image_limit', '50');
// Remove scan results when their image is gone from Docker or their
// stack folder is deleted, so the Security Overview reflects what still
// exists. On by default; operators who keep scan history for deleted
// artifacts can turn it off.
stmt.run('prune_orphaned_scans', '1');
stmt.run('trivy_auto_update', '0');
stmt.run('trivy_last_notified_version', '');
stmt.run('deploy_block_honor_suppressions', '0');
@@ -4495,6 +4500,55 @@ export class DatabaseService {
return txn();
}
/**
* Distinct image_refs that have at least one scan row for a node. Used by
* the orphan-scan reconciler to compare stored scans against the artifacts
* (images, stacks) that still exist on the host.
*/
public getDistinctScanImageRefs(nodeId: number): string[] {
return (
this.db
.prepare(
'SELECT DISTINCT image_ref FROM vulnerability_scans WHERE node_id = ?',
)
.all(nodeId) as Array<{ image_ref: string }>
).map((r) => r.image_ref);
}
/**
* Delete every scan (and its findings) for one (node_id, image_ref). Used
* to purge scans whose artifact is gone. Children are deleted explicitly
* because SQLite foreign-key cascade is not enabled at the connection
* level (see pruneScanHistoryPerImage). Returns the parent rows removed;
* idempotent (0 when nothing matches).
*/
public deleteScansByImageRef(nodeId: number, imageRef: string): number {
const idSubquery =
'SELECT id FROM vulnerability_scans WHERE node_id = ? AND image_ref = ?';
const deleteChild = (table: string) =>
this.db
.prepare(`DELETE FROM ${table} WHERE scan_id IN (${idSubquery})`)
.run(nodeId, imageRef);
const deleteParent = this.db.prepare(
'DELETE FROM vulnerability_scans WHERE node_id = ? AND image_ref = ?',
);
const txn = this.db.transaction(() => {
deleteChild('vulnerability_details');
deleteChild('secret_findings');
deleteChild('misconfig_findings');
return deleteParent.run(nodeId, imageRef).changes;
});
return txn();
}
/**
* Purge the compose-config (misconfig) scans for a deleted stack, keyed by
* the `stack:<name>` image_ref convention used by scanComposeStack.
*/
public deleteStackScans(nodeId: number, stackName: string): number {
return this.deleteScansByImageRef(nodeId, `stack:${stackName}`);
}
public getLatestScanForImage(
nodeId: number,
imageRef: string,
+90
View File
@@ -3,6 +3,8 @@ import semver from 'semver';
import DockerController from './DockerController';
import { DatabaseService, Node, StackAlert } from './DatabaseService';
import { NodeRegistry } from './NodeRegistry';
import { FileSystemService } from './FileSystemService';
import { normalizeImageRef } from './DriftDetectionService';
import { NotificationService } from './NotificationService';
import { FleetUpdateTrackerService } from './FleetUpdateTrackerService';
import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
@@ -374,6 +376,17 @@ export class MonitorService {
try {
const db = DatabaseService.getInstance();
const settings = db.getGlobalSettings();
// Prune scans for artifacts that no longer exist (own try/catch so a
// reconcile failure never aborts the disk-usage check below). Lives
// here because it is Docker-heavy and the 15-min janitor cadence is
// the right throttle; it runs even when the disk alert is disabled.
try {
await this.reconcileOrphanedScans(db, settings);
} catch (e) {
console.error('[Monitor] Orphaned-scan reconcile failed', e);
}
const janitorLimitGb = parseFloat(settings['docker_janitor_gb']);
if (isNaN(janitorLimitGb) || janitorLimitGb <= 0) return;
@@ -445,6 +458,83 @@ export class MonitorService {
}
}
/**
* Remove scans whose artifact no longer exists, so the Security Overview
* reflects what is still on the host. Per-instance: only the local node's
* scans are reconciled against the local Docker image list and stack dirs.
*
* Fail-safe by construction: a scan is only purged when we positively know
* its artifact is gone. If the image list cannot be read, the whole pass is
* skipped (we never delete on an unread list). Stack scans are reconciled
* only when getStacks() returns a non-empty list, because it returns [] on
* both an empty dir and an FS error and we cannot tell them apart.
*
* Image refs are compared after normalizeImageRef so equivalent forms match
* (untagged `alpine` vs Docker's `alpine:latest`, `docker.io/library/`
* prefixes), and both RepoTags and RepoDigests feed the live set so a
* digest-pinned scan ref still matches a present image. Erring toward
* "matches, keep" is the safe direction: the cost of a miss is a stale scan,
* not a wrongly deleted one.
*/
private async reconcileOrphanedScans(
db: DatabaseService,
settings: Readonly<Record<string, string>>,
): Promise<void> {
// Destructive cleanup: enabled only on an explicit '1'. A missing key or
// a read failure leaves it off, the safe default.
if (settings['prune_orphaned_scans'] !== '1') return;
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
let liveImageRefs: Set<string>;
try {
const images = (await withTimeout(
DockerController.getInstance(nodeId).getImages(),
JANITOR_TIMEOUT_MS,
'docker images (scan reconcile)',
)) as Array<{ RepoTags?: string[]; RepoDigests?: string[] }>;
liveImageRefs = new Set<string>();
for (const img of images) {
for (const ref of [...(img.RepoTags ?? []), ...(img.RepoDigests ?? [])]) {
if (ref && !ref.startsWith('<none>')) liveImageRefs.add(normalizeImageRef(ref));
}
}
} catch (e) {
if (isDebugEnabled()) {
console.debug('[Monitor:diag] Scan reconcile skipped: image list unavailable', e);
}
return;
}
// getStacks() never throws (it swallows FS errors and returns []), so an
// empty list is ambiguous (empty dir vs failed read); reconcileStacks
// gates stack purging on a non-empty list to avoid deleting on a failure.
const liveStacks = await FileSystemService.getInstance(nodeId).getStacks();
const reconcileStacks = liveStacks.length > 0;
const liveStackSet = new Set(liveStacks);
let purgedImageScans = 0;
let purgedStackScans = 0;
for (const ref of db.getDistinctScanImageRefs(nodeId)) {
if (ref.startsWith('stack:')) {
if (!reconcileStacks) continue;
if (!liveStackSet.has(ref.slice('stack:'.length))) {
purgedStackScans += db.deleteScansByImageRef(nodeId, ref);
}
} else if (!liveImageRefs.has(normalizeImageRef(ref))) {
purgedImageScans += db.deleteScansByImageRef(nodeId, ref);
}
}
if (isDebugEnabled() && (purgedImageScans > 0 || purgedStackScans > 0)) {
console.debug(
`[Monitor:diag] Scan reconcile: purged ${purgedImageScans} image scan(s) `
+ `(${liveImageRefs.size} live image refs), ${purgedStackScans} stack scan(s)`
+ (reconcileStacks ? '' : ' (stack reconcile skipped: empty stack list)'),
);
}
}
/**
* Check GitHub/Docker Hub for a newer Sencho release and dispatch a
* one-shot notification. Uses getLatestVersion() which wraps CacheService