mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 00:18:00 +00:00
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.
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Route tests for the per-stack drift endpoint: auth enforcement and that the
|
||||
* read-only report is reachable on the Community tier (no tier gate). Deep diff
|
||||
* behaviour is covered by drift-detection.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import DockerController from '../services/DockerController';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('GET /api/stacks/:stackName/drift', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await request(app).get('/api/stacks/myapp/drift');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('is reachable on the Community tier (404 for an unknown stack, not 403)', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const res = await request(app).get('/api/stacks/myapp/drift').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(404);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns 200 with a report for an existing stack on the Community tier', async () => {
|
||||
const composeDir = process.env.COMPOSE_DIR as string;
|
||||
const stackDir = path.join(composeDir, 'driftroutetest');
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:1.27\n');
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
// Stub only the Docker boundary so the test is deterministic and daemon-free;
|
||||
// the route, requireStackExists, compose parse, and the diff all run for real.
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
|
||||
} as unknown as DockerController);
|
||||
|
||||
const res = await request(app).get('/api/stacks/driftroutetest/drift').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ stack: 'driftroutetest', status: 'missing-runtime' });
|
||||
expect(Array.isArray(res.body.findings)).toBe(true);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ export interface DeclaredService {
|
||||
/** Named-volume source keys only (bind mounts and anonymous volumes excluded). */
|
||||
volumes: string[];
|
||||
ports: DeclaredPort[];
|
||||
/** Service `image:` reference as declared, or undefined for build-only services. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/** A top-level networks:/volumes: entry. */
|
||||
@@ -188,6 +190,7 @@ export function parseComposeDependencies(content: string): DeclaredCompose {
|
||||
networks: collectKeys(svc.networks),
|
||||
volumes,
|
||||
ports,
|
||||
image: asString(svc.image),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CacheService } from '../services/CacheService';
|
||||
import { UpdatePreviewService } from '../services/UpdatePreviewService';
|
||||
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { buildStackDriftReport } from '../services/DriftDetectionService';
|
||||
import { requirePermission, checkPermission } from '../middleware/permissions';
|
||||
import { NotificationService, type NotificationCategory } from '../services/NotificationService';
|
||||
import { StackOpLockService, type StackOpAction } from '../services/StackOpLockService';
|
||||
@@ -1008,6 +1009,18 @@ stacksRouter.get('/:stackName/services', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName/drift', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
try {
|
||||
const report = await buildStackDriftReport(req.nodeId, stackName);
|
||||
res.json(report);
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to build drift report for %s:', sanitizeForLog(stackName), error);
|
||||
res.status(500).json({ error: 'Failed to build drift report' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import DockerController from './DockerController';
|
||||
import type { DependencyContainer } from './DockerController';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
|
||||
import type { DeclaredCompose, DeclaredService } from '../helpers/composeDependencyParse';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
/**
|
||||
* Spatial drift engine: compares a stack's on-disk compose model against the
|
||||
* live Docker runtime and reports where the two diverge. Read-only and
|
||||
* stateless. It does NOT persist findings, track change-over-time, or compare
|
||||
* against a last-applied hash: temporal drift ("the file changed since you
|
||||
* deployed"), env-key/value comparison, the cross-fleet rollup, and
|
||||
* unknown-source (orphan containers with no on-disk stack) belong to the
|
||||
* persistence-backed Drift Ledger that builds on this engine. The pure
|
||||
* assembleStackDrift step is exported so that layer can call it per stack.
|
||||
*/
|
||||
|
||||
/** High-level alignment of a stack's runtime against its compose source. */
|
||||
export type StackDriftStatus = 'in-sync' | 'drifted' | 'missing-runtime' | 'unreachable';
|
||||
|
||||
/** A specific, service-scoped reason a stack is drifted. */
|
||||
export type DriftFindingKind =
|
||||
| 'service-missing'
|
||||
| 'service-undeclared'
|
||||
| 'image-mismatch'
|
||||
| 'ports-mismatch';
|
||||
|
||||
export interface StackDriftFinding {
|
||||
kind: DriftFindingKind;
|
||||
/** Compose service (or runtime service identity) the finding applies to. */
|
||||
service: string;
|
||||
/** Specific, actionable description of the divergence. */
|
||||
detail: string;
|
||||
/** Declared/expected value, when the finding compares two values. */
|
||||
expected?: string;
|
||||
/** Observed runtime value, when the finding compares two values. */
|
||||
actual?: string;
|
||||
}
|
||||
|
||||
export interface StackDriftReport {
|
||||
stack: string;
|
||||
status: StackDriftStatus;
|
||||
/** True when a parseable compose file is present (false on a parse failure). */
|
||||
hasComposeFile: boolean;
|
||||
/** True when the stack has at least one running container. */
|
||||
hasContainers: boolean;
|
||||
findings: StackDriftFinding[];
|
||||
/** Set when the compose file could not be parsed; status is then 'drifted'. */
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
// Container states that count as actually deployed. 'restarting' is included
|
||||
// deliberately: a crash-looping container is still the live deployment attempt,
|
||||
// so excluding it would falsely read the stack as missing-runtime.
|
||||
const RUNNING_STATES = new Set(['running', 'restarting']);
|
||||
|
||||
/**
|
||||
* Normalizes an image reference so equivalent forms compare equal: the implicit
|
||||
* Docker Hub registry (`docker.io/`, plus its `library/` namespace for official
|
||||
* images) is stripped and a missing tag defaults to `:latest`. A digest-pinned
|
||||
* reference is left intact, so a digest runtime vs a tag-only declaration reads
|
||||
* as a mismatch. That is intentional: the engine prefers reporting an actionable
|
||||
* difference over hiding one, and never reports a false in-sync.
|
||||
*/
|
||||
export function normalizeImageRef(ref: string): string {
|
||||
let s = ref.trim();
|
||||
if (!s) return s;
|
||||
if (s.startsWith('docker.io/')) {
|
||||
s = s.slice('docker.io/'.length);
|
||||
if (s.startsWith('library/')) s = s.slice('library/'.length);
|
||||
}
|
||||
if (s.includes('@')) return s; // digest-pinned: an exact ref, no :latest defaulting applies
|
||||
const lastSlash = s.lastIndexOf('/');
|
||||
const lastColon = s.lastIndexOf(':');
|
||||
const hasTag = lastColon > lastSlash; // a ':' after the last '/' is the tag
|
||||
return hasTag ? s : `${s}:latest`;
|
||||
}
|
||||
|
||||
const portKey = (publishedPort: number, protocol: string): string => `${publishedPort}/${protocol}`;
|
||||
|
||||
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const v of a) if (!b.has(v)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatPorts(ports: Set<string>): string {
|
||||
return ports.size ? [...ports].sort().join(', ') : 'none';
|
||||
}
|
||||
|
||||
/** Runtime aggregate for one service across its (possibly replicated) containers. */
|
||||
interface RuntimeService {
|
||||
images: Set<string>;
|
||||
ports: Set<string>;
|
||||
}
|
||||
|
||||
export interface AssembleStackDriftInput {
|
||||
stack: string;
|
||||
declared: DeclaredCompose;
|
||||
/** All runtime containers belonging to this stack (any state). */
|
||||
containers: DependencyContainer[];
|
||||
/** Set when the compose file could not be parsed. */
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure diff step (no Docker / FS access) so it is directly unit-testable. Only
|
||||
* running containers are compared, since a stopped container publishes no ports
|
||||
* and is not "deployed": a declared service with no running container is
|
||||
* service-missing, a running container with no matching service is
|
||||
* service-undeclared, and image / port differences are checked only for services
|
||||
* present on both sides so a missing/undeclared service is not double-reported.
|
||||
*/
|
||||
export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftReport {
|
||||
const { stack, declared, containers, parseError } = input;
|
||||
const hasContainers = containers.some((c) => RUNNING_STATES.has(c.state));
|
||||
|
||||
// A parse failure means the declared model is untrustworthy: report drift
|
||||
// rather than risk a false in-sync. hasContainers still reflects runtime.
|
||||
if (parseError) {
|
||||
return { stack, status: 'drifted', hasComposeFile: false, hasContainers, findings: [], parseError };
|
||||
}
|
||||
|
||||
const runtimeByService = new Map<string, RuntimeService>();
|
||||
for (const c of containers) {
|
||||
if (!RUNNING_STATES.has(c.state)) continue;
|
||||
const name = c.service ?? c.name;
|
||||
const agg = runtimeByService.get(name) ?? { images: new Set<string>(), ports: new Set<string>() };
|
||||
if (c.image) agg.images.add(normalizeImageRef(c.image));
|
||||
for (const p of c.ports) agg.ports.add(portKey(p.publishedPort, p.protocol));
|
||||
runtimeByService.set(name, agg);
|
||||
}
|
||||
|
||||
// Nothing running: the stack is defined on disk but not deployed. One status
|
||||
// conveys this; per-service findings would just be noise.
|
||||
if (!hasContainers) {
|
||||
return { stack, status: 'missing-runtime', hasComposeFile: true, hasContainers: false, findings: [] };
|
||||
}
|
||||
|
||||
const declaredByName = new Map<string, DeclaredService>();
|
||||
for (const svc of declared.services) declaredByName.set(svc.name, svc);
|
||||
|
||||
const findings: StackDriftFinding[] = [];
|
||||
|
||||
// Declared service with no running container.
|
||||
for (const svc of declared.services) {
|
||||
if (!runtimeByService.has(svc.name)) {
|
||||
findings.push({
|
||||
kind: 'service-missing',
|
||||
service: svc.name,
|
||||
detail: `Service "${svc.name}" is declared in compose but is not running.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Running container with no matching declared service.
|
||||
for (const name of runtimeByService.keys()) {
|
||||
if (!declaredByName.has(name)) {
|
||||
findings.push({
|
||||
kind: 'service-undeclared',
|
||||
service: name,
|
||||
detail: `Service "${name}" is running but is not declared in compose.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Image / port divergence for services present on both sides.
|
||||
for (const [name, svc] of declaredByName) {
|
||||
const runtime = runtimeByService.get(name);
|
||||
if (!runtime) continue;
|
||||
|
||||
if (svc.image && runtime.images.size > 0) {
|
||||
const declaredImage = normalizeImageRef(svc.image);
|
||||
const runtimeImages = [...runtime.images];
|
||||
// Any running image that differs from the declared one is drift, so a
|
||||
// replica left on an old image is caught even when a sibling matches.
|
||||
if (runtimeImages.some((img) => img !== declaredImage)) {
|
||||
findings.push({
|
||||
kind: 'image-mismatch',
|
||||
service: name,
|
||||
detail: `Service "${name}" runs a different image than compose declares.`,
|
||||
expected: declaredImage,
|
||||
actual: runtimeImages.sort().join(', '),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ports compare as exact sets. The compose parser collapses a published
|
||||
// range (e.g. "8000-8002:80") to its first port, while the runtime reports
|
||||
// every port in the range, so a range can read as a mismatch. That errs
|
||||
// toward reporting drift rather than hiding it, consistent with the engine's
|
||||
// philosophy.
|
||||
const declaredPorts = new Set(svc.ports.map((p) => portKey(p.publishedPort, p.protocol)));
|
||||
if (!setsEqual(declaredPorts, runtime.ports)) {
|
||||
findings.push({
|
||||
kind: 'ports-mismatch',
|
||||
service: name,
|
||||
detail: `Service "${name}" publishes different ports than compose declares.`,
|
||||
expected: formatPorts(declaredPorts),
|
||||
actual: formatPorts(runtime.ports),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const status: StackDriftStatus = findings.length > 0 ? 'drifted' : 'in-sync';
|
||||
return { stack, status, hasComposeFile: true, hasContainers, findings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the drift report for one stack on one node: reads the compose file,
|
||||
* takes a Docker snapshot, and diffs them. Fails closed at each boundary: a
|
||||
* compose read failure is reported as a parse error (drifted, never in-sync),
|
||||
* and a Docker failure is reported as 'unreachable' rather than crashing.
|
||||
*/
|
||||
export async function buildStackDriftReport(nodeId: number, stackName: string): Promise<StackDriftReport> {
|
||||
const fs = FileSystemService.getInstance(nodeId);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.getStackContent(stackName);
|
||||
} catch (error) {
|
||||
console.error('[Drift] Failed to read compose for stack %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'read failed')));
|
||||
return {
|
||||
stack: stackName,
|
||||
status: 'drifted',
|
||||
hasComposeFile: false,
|
||||
hasContainers: false,
|
||||
findings: [],
|
||||
parseError: getErrorMessage(error, 'Failed to read compose file'),
|
||||
};
|
||||
}
|
||||
|
||||
const declared = parseComposeDependencies(content);
|
||||
|
||||
let containers: DependencyContainer[];
|
||||
try {
|
||||
// The snapshot needs the full known-stacks set to resolve each container to
|
||||
// its stack; we then filter to this one. Do not narrow to [stackName] or
|
||||
// resolution breaks.
|
||||
const stacks = await fs.getStacks();
|
||||
const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks);
|
||||
containers = snapshot.containers.filter((c) => c.stack === stackName);
|
||||
} catch (error) {
|
||||
// Docker is unreachable, so runtime drift cannot be assessed. The headline
|
||||
// failure is reachability; a separate parse error (if any) surfaces as
|
||||
// drifted once Docker is back, keeping the parseError-implies-drifted invariant.
|
||||
console.error('[Drift] Docker snapshot failed for stack %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'snapshot failed')));
|
||||
return {
|
||||
stack: stackName,
|
||||
status: 'unreachable',
|
||||
hasComposeFile: !declared.parseError,
|
||||
hasContainers: false,
|
||||
findings: [],
|
||||
};
|
||||
}
|
||||
|
||||
return assembleStackDrift({ stack: stackName, declared, containers, parseError: declared.parseError });
|
||||
}
|
||||
@@ -104,6 +104,7 @@
|
||||
"features/stack-file-explorer",
|
||||
"features/stack-activity",
|
||||
"features/stack-dossier",
|
||||
"features/stack-drift",
|
||||
"features/stack-labels",
|
||||
"features/sidebar"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Drift Detection
|
||||
description: See at a glance whether a stack's running containers still match the Compose file on disk, with specific, actionable reasons when they have diverged.
|
||||
---
|
||||
|
||||
The **Drift** tab in the right-hand **Anatomy** panel answers a single day-two question: does what is actually running still match the Compose file on disk? Sencho treats your Compose file as the source of truth, so it compares the file against the live Docker runtime and reports exactly where the two have diverged.
|
||||
|
||||
The check is read-only. It tells you what changed and never alters a stack on its own, so you can trust the report before deciding what to do about it. The report is built fresh each time you open the tab, with no separate state to maintain.
|
||||
|
||||
## Status
|
||||
|
||||
Every stack resolves to one of four states, shown as a badge at the top of the tab:
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| **In sync** | The running containers match the Compose file: same services, images, and published ports. |
|
||||
| **Drifted** | Something running differs from the file. The specific reasons are listed below the badge. |
|
||||
| **Not running** | The stack is defined on disk but no containers are running. |
|
||||
| **Unreachable** | Docker could not be reached, so drift cannot be assessed right now. |
|
||||
|
||||
## Findings
|
||||
|
||||
When a stack is drifted, each reason is listed against the service it affects:
|
||||
|
||||
| Finding | What it means |
|
||||
|---------|---------------|
|
||||
| **Service missing** | The Compose file declares a service, but it has no running container. |
|
||||
| **Undeclared** | A container is running for the stack, but no matching service exists in the Compose file. |
|
||||
| **Image** | A running container uses a different image than the Compose file declares. The expected and running values are shown side by side. |
|
||||
| **Ports** | The published ports of a service differ from what the Compose file declares. |
|
||||
|
||||
Image references are compared after normalizing the implicit Docker Hub registry and a missing tag to `:latest`, so `nginx` and `docker.io/library/nginx:latest` are treated as the same image. A running container pinned to a digest is compared against the declared tag as written.
|
||||
|
||||
## Accessing the Drift tab
|
||||
|
||||
1. Click any stack in the left sidebar to open it.
|
||||
2. Switch to the **Drift** tab in the Anatomy panel header.
|
||||
3. Read the status badge and any findings. Use **re-check** to run the comparison again after you deploy or change something.
|
||||
|
||||
On a phone, the same report appears under the **Compose** section of the stack detail.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="A stack I deliberately stopped shows as 'Not running'">
|
||||
That is expected. Drift compares the file on disk against what is actually running, so a stack with no running containers reports **Not running** even when you stopped it on purpose. Deploy it to return it to **In sync**.
|
||||
</Accordion>
|
||||
<Accordion title="The image finding flags a stack I just updated">
|
||||
Open **re-check** after the deploy finishes. During a rolling update, replicas can briefly run different images, and the report is a snapshot of that moment. Once every container is on the declared image, the finding clears.
|
||||
</Accordion>
|
||||
<Accordion title="A stack that uses a published port range shows a ports finding">
|
||||
A Compose port range such as `8000-8002:8000-8002` is compared conservatively and can read as a ports difference even when the deployment is correct. Sencho errs toward surfacing a possible difference rather than hiding one. The status reflects this as drift you can confirm against the file.
|
||||
</Accordion>
|
||||
<Accordion title="The status says 'Unreachable'">
|
||||
Sencho could not reach Docker on the active node, so it cannot compare the runtime. Confirm the Docker engine is running and the node is online, then use **re-check**. Other stacks on the same node will show the same state until Docker responds.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
|
||||
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
||||
import StackDossierPanel from './stack/StackDossierPanel';
|
||||
import DriftPanel from './stack/DriftPanel';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
interface StackAnatomyPanelProps {
|
||||
@@ -417,6 +418,7 @@ export default function StackAnatomyPanel({
|
||||
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
||||
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-3">
|
||||
{onOpenFiles && (
|
||||
@@ -624,6 +626,9 @@ export default function StackAnatomyPanel({
|
||||
<TabsContent value="dossier" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<StackDossierPanel stackName={stackName} anatomy={anatomyInput} canEdit={canEdit} />
|
||||
</TabsContent>
|
||||
<TabsContent value="drift" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<DriftPanel stackName={stackName} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Covers the read-only drift panel: it renders each per-stack status, lists
|
||||
* findings with their expected/actual values, surfaces a parse error, shows a
|
||||
* retry state on load failure, and re-checks on demand.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import DriftPanel from './DriftPanel';
|
||||
|
||||
interface DriftReport {
|
||||
stack: string;
|
||||
status: string;
|
||||
hasComposeFile: boolean;
|
||||
hasContainers: boolean;
|
||||
findings: Array<{ kind: string; service: string; detail: string; expected?: string; actual?: string }>;
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
function report(partial: Partial<DriftReport>): DriftReport {
|
||||
return { stack: 'web', status: 'in-sync', hasComposeFile: true, hasContainers: true, findings: [], ...partial };
|
||||
}
|
||||
|
||||
function jsonRes(body: unknown, ok = true) {
|
||||
return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('DriftPanel', () => {
|
||||
it('renders the in-sync status', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'in-sync' })));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
const status = await screen.findByTestId('drift-status');
|
||||
expect(status).toHaveAttribute('data-status', 'in-sync');
|
||||
expect(screen.getByText(/Runtime matches/i)).toBeInTheDocument();
|
||||
// A clean stack shows no findings section.
|
||||
expect(screen.queryByText(/findings/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders every finding kind with its label and expected/actual values', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'drifted',
|
||||
findings: [
|
||||
{ kind: 'image-mismatch', service: 'web', detail: 'Service "web" runs a different image than compose declares.', expected: 'nginx:1.25', actual: 'nginx:1.24' },
|
||||
{ kind: 'ports-mismatch', service: 'web', detail: 'Service "web" publishes different ports than compose declares.', expected: '8080/tcp', actual: '9090/tcp' },
|
||||
{ kind: 'service-missing', service: 'db', detail: 'Service "db" is declared in compose but is not running.' },
|
||||
{ kind: 'service-undeclared', service: 'sidecar', detail: 'Service "sidecar" is running but is not declared in compose.' },
|
||||
],
|
||||
})));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
const status = await screen.findByTestId('drift-status');
|
||||
expect(status).toHaveAttribute('data-status', 'drifted');
|
||||
expect(screen.getByText(/4 findings/)).toBeInTheDocument();
|
||||
// Finding-kind labels.
|
||||
expect(screen.getByText('image')).toBeInTheDocument();
|
||||
expect(screen.getByText('ports')).toBeInTheDocument();
|
||||
expect(screen.getByText('service missing')).toBeInTheDocument();
|
||||
expect(screen.getByText('undeclared')).toBeInTheDocument();
|
||||
// Comparison values for image and ports findings.
|
||||
expect(screen.getByText('nginx:1.25')).toBeInTheDocument();
|
||||
expect(screen.getByText('nginx:1.24')).toBeInTheDocument();
|
||||
expect(screen.getByText('8080/tcp')).toBeInTheDocument();
|
||||
expect(screen.getByText('9090/tcp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the singular noun for a single finding', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'drifted',
|
||||
findings: [{ kind: 'service-missing', service: 'db', detail: 'Service "db" is declared in compose but is not running.' }],
|
||||
})));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
await screen.findByTestId('drift-status');
|
||||
expect(screen.getByText(/1 finding$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the missing-runtime status', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'missing-runtime', hasContainers: false })));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
const status = await screen.findByTestId('drift-status');
|
||||
expect(status).toHaveAttribute('data-status', 'missing-runtime');
|
||||
});
|
||||
|
||||
it('renders the unreachable status', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'unreachable', hasContainers: false })));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
const status = await screen.findByTestId('drift-status');
|
||||
expect(status).toHaveAttribute('data-status', 'unreachable');
|
||||
expect(screen.getByText(/Docker is unreachable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a compose parse error', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'drifted', hasComposeFile: false, parseError: 'Could not parse compose file: bad yaml',
|
||||
})));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
await screen.findByTestId('drift-status');
|
||||
expect(screen.getByText(/Could not parse compose file/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a retry state (not a status) when the load fails', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ error: 'down' }, false));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
await screen.findByTestId('drift-retry-btn');
|
||||
expect(screen.queryByTestId('drift-status')).not.toBeInTheDocument();
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the retry state when the request throws', async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new Error('network'));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
await screen.findByTestId('drift-retry-btn');
|
||||
expect(screen.queryByTestId('drift-status')).not.toBeInTheDocument();
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retry refetches and recovers to a status', async () => {
|
||||
vi.mocked(apiFetch)
|
||||
.mockResolvedValueOnce(jsonRes({ error: 'down' }, false))
|
||||
.mockResolvedValueOnce(jsonRes(report({ status: 'in-sync' })));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
fireEvent.click(await screen.findByTestId('drift-retry-btn'));
|
||||
const status = await screen.findByTestId('drift-status');
|
||||
expect(status).toHaveAttribute('data-status', 'in-sync');
|
||||
expect(screen.queryByTestId('drift-retry-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-checks on demand', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'in-sync' })));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
await screen.findByTestId('drift-status');
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByTestId('drift-recheck-btn'));
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, TriangleAlert, CircleSlash, WifiOff, RefreshCw, type LucideIcon } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
// Mirrors the backend StackDriftReport shape (the frontend never imports backend).
|
||||
type StackDriftStatus = 'in-sync' | 'drifted' | 'missing-runtime' | 'unreachable';
|
||||
type DriftFindingKind = 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch';
|
||||
|
||||
interface StackDriftFinding {
|
||||
kind: DriftFindingKind;
|
||||
service: string;
|
||||
detail: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
}
|
||||
|
||||
interface StackDriftReport {
|
||||
stack: string;
|
||||
status: StackDriftStatus;
|
||||
hasComposeFile: boolean;
|
||||
hasContainers: boolean;
|
||||
findings: StackDriftFinding[];
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
const ACTION_CLASS =
|
||||
'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40';
|
||||
|
||||
const STATUS_META: Record<StackDriftStatus, { label: string; icon: LucideIcon; tone: string; line: string }> = {
|
||||
'in-sync': {
|
||||
label: 'in sync',
|
||||
icon: Check,
|
||||
tone: 'border-success/40 bg-success/[0.06] text-success',
|
||||
line: 'Runtime matches the compose file.',
|
||||
},
|
||||
drifted: {
|
||||
label: 'drifted',
|
||||
icon: TriangleAlert,
|
||||
tone: 'border-warning/40 bg-warning/[0.06] text-warning',
|
||||
line: 'Runtime differs from the compose file.',
|
||||
},
|
||||
'missing-runtime': {
|
||||
label: 'not running',
|
||||
icon: CircleSlash,
|
||||
tone: 'border-muted bg-card/40 text-stat-subtitle',
|
||||
line: 'Defined on disk but no containers are running.',
|
||||
},
|
||||
unreachable: {
|
||||
label: 'unreachable',
|
||||
icon: WifiOff,
|
||||
tone: 'border-destructive/40 bg-destructive/[0.06] text-destructive',
|
||||
line: 'Docker is unreachable, so drift cannot be assessed.',
|
||||
},
|
||||
};
|
||||
|
||||
const FINDING_LABEL: Record<DriftFindingKind, string> = {
|
||||
'service-missing': 'service missing',
|
||||
'service-undeclared': 'undeclared',
|
||||
'image-mismatch': 'image',
|
||||
'ports-mismatch': 'ports',
|
||||
};
|
||||
|
||||
function Finding({ finding }: { finding: StackDriftFinding }) {
|
||||
return (
|
||||
<div className="border-t border-muted py-2 first:border-t-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">{FINDING_LABEL[finding.kind]}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-foreground/90">{finding.detail}</div>
|
||||
{finding.expected !== undefined && finding.actual !== undefined && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 font-mono text-[11px]">
|
||||
<span className="text-stat-subtitle">compose</span>
|
||||
<span className="text-foreground/90">{finding.expected}</span>
|
||||
<span className="text-stat-subtitle">→ running</span>
|
||||
<span className="font-semibold text-foreground">{finding.actual}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DriftPanel({ stackName }: { stackName: string }) {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const [report, setReport] = useState<StackDriftReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
// Refetch when the stack OR the active node changes (the same stack can exist on
|
||||
// two nodes), and on an explicit re-check. Drift is a point-in-time snapshot, so
|
||||
// a failed load shows a distinct retry state rather than a stale or blank report.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
// Clear any prior failure so an in-flight re-check shows the checking
|
||||
// affordance instead of leaving the error card up.
|
||||
setLoadError(false);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/drift`);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the drift report.');
|
||||
return;
|
||||
}
|
||||
setReport((await res.json()) as StackDriftReport);
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the drift report.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, nodeId, reloadKey]);
|
||||
|
||||
const meta = report ? STATUS_META[report.status] : null;
|
||||
const StatusIcon = meta?.icon;
|
||||
|
||||
return (
|
||||
<div data-testid="drift-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={LABEL_CLASS}>compose vs runtime</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="drift-recheck-btn"
|
||||
onClick={() => setReloadKey(k => k + 1)}
|
||||
disabled={loading}
|
||||
className={ACTION_CLASS}
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} strokeWidth={1.5} /> re-check
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadError ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-3">
|
||||
<span className="font-mono text-[11px] text-destructive">Could not load the drift report.</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="drift-retry-btn"
|
||||
onClick={() => setReloadKey(k => k + 1)}
|
||||
className="font-mono text-[10px] uppercase tracking-wide text-destructive hover:underline"
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
) : !report ? (
|
||||
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Checking drift…</div>
|
||||
) : (
|
||||
<>
|
||||
{meta && StatusIcon && (
|
||||
<div data-testid="drift-status" data-status={report.status} className={cn('rounded-lg border px-3 py-2.5', meta.tone)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px] uppercase tracking-wide">{meta.label}</span>
|
||||
{report.findings.length > 0 && (
|
||||
<span className="font-mono text-[10px] text-stat-subtitle">
|
||||
· {report.findings.length} finding{report.findings.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] leading-relaxed text-foreground/80">{meta.line}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.parseError && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-2 font-mono text-[11px] text-destructive">
|
||||
{report.parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.findings.length > 0 && (
|
||||
<section>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>findings</div>
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{report.findings.map((f, i) => (
|
||||
<Finding key={`${f.service}-${f.kind}-${i}`} finding={f} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user