mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +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.
66 lines
2.8 KiB
TypeScript
66 lines
2.8 KiB
TypeScript
/**
|
|
* 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 });
|
|
});
|
|
});
|