mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
421177e4a6
* 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.
325 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|