Files
sencho/backend/src/__tests__/drift-detection.test.ts
T
Anso 421177e4a6 feat(stacks): add compose-vs-runtime drift detection (#1329)
* feat(stacks): add compose-vs-runtime drift engine

Add a read-only engine that compares a stack's on-disk compose model
against the live Docker runtime and reports where the two diverge.

GET /api/stacks/:stackName/drift returns a per-stack report with a
status (in-sync, drifted, missing-runtime, unreachable) and typed,
service-scoped findings: a declared service with no running container,
a running container not declared in compose, an image mismatch, and a
published-port mismatch. The report is computed at request time with no
persistence and is available on every tier.

The check reuses the existing compose parser and Docker dependency
snapshot; the compose parser now also captures each service's declared
image. Boundaries fail closed: an unreadable compose file reports
drifted and an unreachable Docker daemon reports unreachable, never a
false in-sync.

* fix(stacks): keep drift hasContainers accurate on compose parse error

assembleStackDrift hardcoded hasContainers: false on the parse-error
path, contradicting the field's contract when the runtime actually has
running containers. Compute it once from the container set and reuse it
across all return paths.

Also add a route test that exercises the successful 200 path for an
existing stack on the Community tier (stubbing only the Docker boundary),
so a tier gate or handler regression after the existence check is caught.

* feat(stacks): add a drift detection tab to the stack view

Surface the compose-vs-runtime drift report on the per-stack Anatomy
panel as a read-only Drift tab. It shows the stack's status (in sync,
drifted, not running, unreachable) and, when drifted, the specific
service-scoped reasons with the declared and running values side by
side. A re-check action reruns the comparison.

The tab lives in the shared anatomy panel, so it appears on both the
desktop stack view and the mobile stack detail. Available on every tier.

* fix(stacks): sanitize the logged error in the drift report builder

The compose-read and Docker-snapshot catch blocks logged the raw error
object, whose message can embed the user-controlled stack path (e.g. an
ENOENT path). Log the error through the existing sanitizer so a crafted
stack name cannot forge log lines, matching the pattern used elsewhere
in the stacks router.
2026-06-07 15:48:04 -04:00

325 lines
14 KiB
TypeScript

/**
* Unit tests for the spatial drift engine: per-finding and per-status diff
* behaviour of assembleStackDrift, image-reference normalization, and the
* fail-soft boundaries of buildStackDriftReport (compose read failure → drifted,
* Docker failure → unreachable).
*/
import { describe, it, expect, vi } from 'vitest';
import {
assembleStackDrift,
normalizeImageRef,
buildStackDriftReport,
} from '../services/DriftDetectionService';
import DockerController from '../services/DockerController';
import type { DependencyContainer, DependencySnapshot } from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
import type { DeclaredCompose, DeclaredService, DeclaredPort } from '../helpers/composeDependencyParse';
// ── builders ────────────────────────────────────────────────────────────
function port(publishedPort: number, protocol = 'tcp'): DeclaredPort {
return { hostIp: '', publishedPort, protocol };
}
function service(p: Partial<DeclaredService> & { name: string }): DeclaredService {
return { dependsOn: [], networks: [], volumes: [], ports: [], ...p };
}
function declared(services: DeclaredService[], parseError?: string): DeclaredCompose {
return { services, networks: {}, volumes: {}, ...(parseError ? { parseError } : {}) };
}
function container(p: Partial<DependencyContainer> & { id: string }): DependencyContainer {
return {
name: p.id, service: null, composeProject: null, stack: 'app',
state: 'running', image: 'img:latest', networks: [], volumes: [], ports: [], ...p,
};
}
const findingKinds = (r: { findings: { kind: string }[] }): string[] => r.findings.map((f) => f.kind).sort();
// ── assembleStackDrift: statuses ──────────────────────────────────────────
describe('assembleStackDrift - status', () => {
it('reports in-sync when running services, images and ports all match', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', image: 'nginx:1.25', ports: [port(8080)] })]),
containers: [container({ id: 'c1', service: 'web', image: 'nginx:1.25', ports: [{ ip: '', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }] })],
});
expect(report.status).toBe('in-sync');
expect(report.findings).toEqual([]);
expect(report.hasContainers).toBe(true);
});
it('reports missing-runtime with no findings when nothing is running', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' }), service({ name: 'db' })]),
containers: [],
});
expect(report.status).toBe('missing-runtime');
expect(report.findings).toEqual([]);
expect(report.hasContainers).toBe(false);
});
it('treats a stack whose only container is exited as missing-runtime', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' })]),
containers: [container({ id: 'c1', service: 'web', state: 'exited' })],
});
expect(report.status).toBe('missing-runtime');
expect(report.findings).toEqual([]);
});
it('counts a restarting container as deployed', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', image: 'nginx:1.25' })]),
containers: [container({ id: 'c1', service: 'web', image: 'nginx:1.25', state: 'restarting' })],
});
expect(report.status).toBe('in-sync');
expect(report.hasContainers).toBe(true);
});
it('reports drifted with a synthetic-free parseError when compose cannot be parsed', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([], 'Could not parse compose file: bad yaml'),
containers: [container({ id: 'c1', service: 'web' })],
parseError: 'Could not parse compose file: bad yaml',
});
expect(report.status).toBe('drifted');
expect(report.hasComposeFile).toBe(false);
expect(report.parseError).toContain('Could not parse');
expect(report.findings).toEqual([]);
// hasContainers still reflects the runtime even when compose is unparseable.
expect(report.hasContainers).toBe(true);
});
});
// ── assembleStackDrift: findings ──────────────────────────────────────────
describe('assembleStackDrift - findings', () => {
it('flags a declared service that has no running container', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' }), service({ name: 'db' })]),
containers: [container({ id: 'c1', service: 'web' })],
});
expect(report.status).toBe('drifted');
expect(report.findings).toHaveLength(1);
expect(report.findings[0]).toMatchObject({ kind: 'service-missing', service: 'db' });
});
it('flags a running container with no matching declared service', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' })]),
containers: [container({ id: 'c1', service: 'web' }), container({ id: 'c2', service: 'sidecar' })],
});
expect(findingKinds(report)).toEqual(['service-undeclared']);
expect(report.findings[0]).toMatchObject({ kind: 'service-undeclared', service: 'sidecar' });
});
it('flags an image mismatch with expected and actual values', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', image: 'nginx:1.25' })]),
containers: [container({ id: 'c1', service: 'web', image: 'nginx:1.24' })],
});
expect(findingKinds(report)).toEqual(['image-mismatch']);
expect(report.findings[0]).toMatchObject({ kind: 'image-mismatch', service: 'web', expected: 'nginx:1.25', actual: 'nginx:1.24' });
});
it('does not flag an image mismatch for tag-equivalent references', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', image: 'nginx' })]),
containers: [container({ id: 'c1', service: 'web', image: 'docker.io/library/nginx:latest' })],
});
expect(report.status).toBe('in-sync');
expect(report.findings).toEqual([]);
});
it('skips the image check for a build-only service (no declared image)', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' })]),
containers: [container({ id: 'c1', service: 'web', image: 'app-web:built' })],
});
expect(report.status).toBe('in-sync');
expect(report.findings).toEqual([]);
});
it('flags a port mismatch with expected and actual sets', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', ports: [port(8080)] })]),
containers: [container({ id: 'c1', service: 'web', ports: [{ ip: '', publishedPort: 9090, privatePort: 80, protocol: 'tcp' }] })],
});
expect(findingKinds(report)).toEqual(['ports-mismatch']);
expect(report.findings[0]).toMatchObject({ kind: 'ports-mismatch', service: 'web', expected: '8080/tcp', actual: '9090/tcp' });
});
it('treats the same port number on a different protocol as a mismatch', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', ports: [port(53, 'tcp')] })]),
containers: [container({ id: 'c1', service: 'web', ports: [{ ip: '', publishedPort: 53, privatePort: 53, protocol: 'udp' }] })],
});
expect(findingKinds(report)).toEqual(['ports-mismatch']);
});
it('collapses replicas of one service without a spurious undeclared finding', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', image: 'nginx:1.25' })]),
containers: [
container({ id: 'c1', service: 'web', image: 'nginx:1.25' }),
container({ id: 'c2', service: 'web', image: 'nginx:1.25' }),
],
});
expect(report.status).toBe('in-sync');
expect(report.findings).toEqual([]);
});
it('ignores non-running containers when aggregating runtime state', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', image: 'nginx:1.25' })]),
containers: [
container({ id: 'c1', service: 'web', image: 'nginx:1.25' }),
container({ id: 'c2', service: 'web', image: 'nginx:1.24', state: 'exited' }),
],
});
expect(report.status).toBe('in-sync');
expect(report.findings).toEqual([]);
});
it('reports multiple distinct findings without double-reporting a service', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', image: 'nginx:1.25' }), service({ name: 'db' })]),
containers: [
container({ id: 'c1', service: 'web', image: 'nginx:1.24' }),
container({ id: 'c2', service: 'cache' }),
],
});
// web -> image-mismatch, db -> service-missing, cache -> service-undeclared.
expect(findingKinds(report)).toEqual(['image-mismatch', 'service-missing', 'service-undeclared']);
expect(report.findings.filter((f) => f.service === 'web')).toHaveLength(1);
});
it('flags an image mismatch when replicas run divergent images', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', image: 'nginx:1.25' })]),
containers: [
container({ id: 'c1', service: 'web', image: 'nginx:1.25' }),
container({ id: 'c2', service: 'web', image: 'nginx:1.24' }),
],
});
expect(findingKinds(report)).toEqual(['image-mismatch']);
expect(report.findings[0].actual).toContain('nginx:1.24');
expect(report.findings[0].actual).toContain('nginx:1.25');
});
it('falls back to the container name when the compose service label is null', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' })]),
containers: [
container({ id: 'c1', service: 'web' }),
container({ id: 'orphan', service: null, name: 'orphan' }),
],
});
expect(findingKinds(report)).toEqual(['service-undeclared']);
expect(report.findings[0].service).toBe('orphan');
});
it('reports "none" as the runtime side when a declared port is unpublished', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web', ports: [port(8080)] })]),
containers: [container({ id: 'c1', service: 'web', ports: [] })],
});
expect(findingKinds(report)).toEqual(['ports-mismatch']);
expect(report.findings[0]).toMatchObject({ expected: '8080/tcp', actual: 'none' });
});
});
// ── normalizeImageRef ─────────────────────────────────────────────────────
describe('normalizeImageRef', () => {
it('appends :latest when no tag is present', () => {
expect(normalizeImageRef('nginx')).toBe('nginx:latest');
});
it('strips the docker.io/library prefix for official images', () => {
expect(normalizeImageRef('docker.io/library/nginx')).toBe('nginx:latest');
expect(normalizeImageRef('docker.io/library/redis:7')).toBe('redis:7');
});
it('does not mistake a registry port for a tag', () => {
expect(normalizeImageRef('registry:5000/team/app')).toBe('registry:5000/team/app:latest');
});
it('leaves a digest-pinned reference intact', () => {
expect(normalizeImageRef('nginx@sha256:abc')).toBe('nginx@sha256:abc');
});
});
// ── buildStackDriftReport: fail-soft boundaries ───────────────────────────
describe('buildStackDriftReport - boundaries', () => {
it('reports unreachable when the Docker snapshot fails', async () => {
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
getStackContent: vi.fn().mockResolvedValue('services:\n web:\n image: nginx:1.25\n'),
getStacks: vi.fn().mockResolvedValue(['app']),
} as unknown as FileSystemService);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockRejectedValue(new Error('docker down')),
} as unknown as DockerController);
const report = await buildStackDriftReport(0, 'app');
expect(report.status).toBe('unreachable');
expect(report.findings).toEqual([]);
vi.restoreAllMocks();
});
it('reports drifted with a parseError when the compose file cannot be read', async () => {
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
getStackContent: vi.fn().mockRejectedValue(new Error('ENOENT')),
getStacks: vi.fn().mockResolvedValue(['app']),
} as unknown as FileSystemService);
const report = await buildStackDriftReport(0, 'app');
expect(report.status).toBe('drifted');
expect(report.hasComposeFile).toBe(false);
expect(report.parseError).toBe('ENOENT');
vi.restoreAllMocks();
});
it('diffs a real snapshot into an image-mismatch finding', async () => {
const snapshot: DependencySnapshot = {
containers: [container({ id: 'c1', service: 'web', stack: 'app', image: 'nginx:1.24' })],
networks: [],
volumes: [],
};
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
getStackContent: vi.fn().mockResolvedValue('services:\n web:\n image: nginx:1.25\n'),
getStacks: vi.fn().mockResolvedValue(['app']),
} as unknown as FileSystemService);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue(snapshot),
} as unknown as DockerController);
const report = await buildStackDriftReport(0, 'app');
expect(report.status).toBe('drifted');
expect(findingKinds(report)).toEqual(['image-mismatch']);
vi.restoreAllMocks();
});
});