mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
fix(blueprints): write compose.yaml so first-time apply is not shadowed (#1668)
createStack scaffolds compose.yaml; Blueprint was writing docker-compose.yml, so Compose discovery ran the nginx boilerplate. Align Blueprint writes with the canonical filename, clear alternate root Compose siblings on local/modern apply, and cover the regression paths.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Regression: Blueprint apply must write canonical compose.yaml (overwriting
|
||||
* createStack's nginx scaffold) and remove alternate root Compose filenames so
|
||||
* Docker Compose discovery cannot prefer a leftover docker-compose.yml.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let BlueprintService: typeof import('../services/BlueprintService').BlueprintService;
|
||||
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
|
||||
let ComposeService: typeof import('../services/ComposeService').ComposeService;
|
||||
let StackOpLockService: typeof import('../services/StackOpLockService').StackOpLockService;
|
||||
let counter = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ BlueprintService } = await import('../services/BlueprintService'));
|
||||
({ FileSystemService } = await import('../services/FileSystemService'));
|
||||
({ ComposeService } = await import('../services/ComposeService'));
|
||||
({ StackOpLockService } = await import('../services/StackOpLockService'));
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
counter += 1;
|
||||
StackOpLockService.resetForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function seedLocalNode(): number {
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
const result = DatabaseService.getInstance().getDb().prepare(
|
||||
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
|
||||
VALUES (?, 'local', 'proxy', ?, 0, 'online', ?)`,
|
||||
).run(`bp-compose-node-${counter}`, composeDir, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
function defaultNodeId(): number {
|
||||
const id = DatabaseService.getInstance().getNodes().find(n => n.is_default)?.id;
|
||||
if (id === undefined) throw new Error('default node missing');
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Point the default node at the test COMPOSE_DIR (FileSystemService.getInstance uses it). */
|
||||
function bindDefaultComposeDir(): number {
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
DatabaseService.getInstance().getDb()
|
||||
.prepare('UPDATE nodes SET compose_dir = ? WHERE is_default = 1')
|
||||
.run(composeDir);
|
||||
return defaultNodeId();
|
||||
}
|
||||
|
||||
async function expectMissing(filePath: string): Promise<void> {
|
||||
await expect(fsPromises.access(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
}
|
||||
|
||||
async function anyExists(...paths: string[]): Promise<boolean> {
|
||||
const results = await Promise.all(
|
||||
paths.map((p) => fsPromises.access(p).then(() => true, () => false)),
|
||||
);
|
||||
return results.some(Boolean);
|
||||
}
|
||||
|
||||
describe('Blueprint compose apply (real filesystem)', () => {
|
||||
it('first-time apply overwrites createStack nginx scaffold with blueprint content in compose.yaml', async () => {
|
||||
const nodeId = seedLocalNode();
|
||||
const stackName = `bp-first-${counter}`;
|
||||
const composeContent = 'services:\n web:\n image: traefik:v3\n';
|
||||
const markerContent = JSON.stringify({ blueprintId: 1, revision: 1, lastApplied: Date.now() }, null, 2);
|
||||
|
||||
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue(undefined);
|
||||
|
||||
const outcome = await BlueprintService.getInstance().applyLocalUnderLock(
|
||||
nodeId,
|
||||
stackName,
|
||||
composeContent,
|
||||
markerContent,
|
||||
'/api/blueprints/test/apply',
|
||||
);
|
||||
expect(outcome).toEqual({ ran: true });
|
||||
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe(composeContent);
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yml'));
|
||||
|
||||
const resolved = await FileSystemService.getInstance(nodeId).getComposeFilename(stackName);
|
||||
expect(resolved).toBe('compose.yaml');
|
||||
expect(deploySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-apply on a dual-file stack replaces compose.yaml and removes alternate root Compose files before deploy', async () => {
|
||||
const nodeId = seedLocalNode();
|
||||
const stackName = `bp-heal-${counter}`;
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services:\n stale:\n image: nginx:latest\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yml'), 'services:\n a:\n image: a\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yaml'), 'services:\n b:\n image: b\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yml'), 'services:\n c:\n image: c\n');
|
||||
|
||||
const composeContent = 'services:\n app:\n image: redis:7\n';
|
||||
const markerContent = JSON.stringify({ blueprintId: 2, revision: 3, lastApplied: Date.now() }, null, 2);
|
||||
|
||||
let alternatesPresentAtDeploy = true;
|
||||
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockImplementation(async () => {
|
||||
alternatesPresentAtDeploy = await anyExists(
|
||||
path.join(stackDir, 'compose.yml'),
|
||||
path.join(stackDir, 'docker-compose.yaml'),
|
||||
path.join(stackDir, 'docker-compose.yml'),
|
||||
);
|
||||
});
|
||||
|
||||
const outcome = await BlueprintService.getInstance().applyLocalUnderLock(
|
||||
nodeId,
|
||||
stackName,
|
||||
composeContent,
|
||||
markerContent,
|
||||
'/api/blueprints/test/apply',
|
||||
);
|
||||
expect(outcome).toEqual({ ran: true });
|
||||
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe(composeContent);
|
||||
expect(alternatesPresentAtDeploy).toBe(false);
|
||||
await expectMissing(path.join(stackDir, 'compose.yml'));
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yaml'));
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yml'));
|
||||
expect(deploySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileSystemService.removeAlternateRootComposeFiles', () => {
|
||||
it('removes all three alternate filenames and leaves compose.yaml', async () => {
|
||||
const nodeId = bindDefaultComposeDir();
|
||||
const stackName = `bp-alts-${counter}`;
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services:\n keep:\n image: keep\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yml'), 'x');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yaml'), 'y');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yml'), 'z');
|
||||
|
||||
await FileSystemService.getInstance(nodeId).removeAlternateRootComposeFiles(stackName);
|
||||
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toContain('image: keep');
|
||||
await expectMissing(path.join(stackDir, 'compose.yml'));
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yaml'));
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yml'));
|
||||
});
|
||||
|
||||
it('treats absent alternate files as success', async () => {
|
||||
const nodeId = bindDefaultComposeDir();
|
||||
const stackName = `bp-absent-${counter}`;
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services:\n keep:\n image: keep\n');
|
||||
|
||||
await expect(
|
||||
FileSystemService.getInstance(nodeId).removeAlternateRootComposeFiles(stackName),
|
||||
).resolves.toBeUndefined();
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toContain('image: keep');
|
||||
});
|
||||
|
||||
it('logs and continues when a non-ENOENT unlink failure occurs', async () => {
|
||||
const nodeId = bindDefaultComposeDir();
|
||||
const stackName = `bp-eacces-${counter}`;
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services:\n keep:\n image: keep\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yml'), 'services:\n stale:\n image: stale\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yml'), 'services:\n other:\n image: other\n');
|
||||
|
||||
const originalUnlink = fsPromises.unlink.bind(fsPromises);
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.spyOn(fsPromises, 'unlink').mockImplementation(async (target) => {
|
||||
const asString = String(target);
|
||||
if (asString.endsWith(`${path.sep}docker-compose.yml`) || asString.endsWith('/docker-compose.yml')) {
|
||||
const err = new Error('permission denied') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return originalUnlink(target);
|
||||
});
|
||||
|
||||
await FileSystemService.getInstance(nodeId).removeAlternateRootComposeFiles(stackName);
|
||||
|
||||
expect(warnSpy.mock.calls.some((args) => {
|
||||
const label = String(args[0] ?? '');
|
||||
const detail = String(args[1] ?? '');
|
||||
return label.includes('Could not remove alternate compose file')
|
||||
&& label.includes('docker-compose.yml')
|
||||
&& detail.includes('permission denied');
|
||||
})).toBe(true);
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toContain('image: keep');
|
||||
await expectMissing(path.join(stackDir, 'compose.yml'));
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'docker-compose.yml'), 'utf-8')).toContain('image: stale');
|
||||
});
|
||||
});
|
||||
@@ -337,6 +337,9 @@ describe('POST /api/blueprints/:id/apply confirm binding', () => {
|
||||
expect(change.action).toBe('blocked_name_conflict');
|
||||
expect(change.severity).toBe('blocker');
|
||||
expect(preview.body.summary.blocker).toBeGreaterThan(0);
|
||||
// Name-conflict detection must not mutate unmanaged alternate compose files.
|
||||
expect(fs.readFileSync(path.join(stackDir, 'docker-compose.yml'), 'utf-8'))
|
||||
.toBe('services:\n app:\n image: nginx\n');
|
||||
});
|
||||
|
||||
it('blocks rename while a non-withdrawn deployment exists', async () => {
|
||||
|
||||
@@ -120,7 +120,15 @@ describe('BlueprintService remote deploy', () => {
|
||||
expect(postSpy.mock.calls[0][0]).toMatch(/\/api\/blueprints\/apply-local$/);
|
||||
expect(postSpy.mock.calls[1][0]).toMatch(/\/api\/stacks$/);
|
||||
expect(putSpy).toHaveBeenCalledTimes(2); // compose + marker
|
||||
const composePutUrl = String(putSpy.mock.calls[0][0]);
|
||||
const markerPutUrl = String(putSpy.mock.calls[1][0]);
|
||||
expect(composePutUrl).toMatch(/[?&]path=compose\.yaml(?:&|$)/);
|
||||
expect(markerPutUrl).toMatch(/[?&]path=\.blueprint\.json(?:&|$)/);
|
||||
expect(postSpy.mock.calls[2][0]).toMatch(/\/deploy$/);
|
||||
const [composePutOrder, markerPutOrder] = putSpy.mock.invocationCallOrder;
|
||||
const deployOrder = postSpy.mock.invocationCallOrder[2];
|
||||
expect(composePutOrder).toBeLessThan(markerPutOrder);
|
||||
expect(markerPutOrder).toBeLessThan(deployOrder);
|
||||
});
|
||||
|
||||
it('maps a remote apply lock-conflict (409) to status=failed', async () => {
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('POST /api/blueprints/:id/withdraw/:nodeId', () => {
|
||||
.all(res.body.snapshotId) as Array<{ stack_name: string; filename: string; content: string; node_id: number }>;
|
||||
expect(fileRows).toHaveLength(1);
|
||||
expect(fileRows[0].stack_name).toBe(bp.name);
|
||||
expect(fileRows[0].filename).toBe('docker-compose.yml');
|
||||
expect(fileRows[0].filename).toBe('compose.yaml');
|
||||
// Content is encrypted at rest; it decrypts back to the captured compose.
|
||||
const { CryptoService } = await import('../services/CryptoService');
|
||||
expect(CryptoService.getInstance().isEncrypted(fileRows[0].content)).toBe(true);
|
||||
|
||||
@@ -407,6 +407,7 @@ describe('BlueprintService per-stack lock', () => {
|
||||
// without touching the real filesystem or Docker.
|
||||
vi.spyOn(FileSystemService.prototype, 'createStack').mockResolvedValue(undefined);
|
||||
const writeSpy = vi.spyOn(FileSystemService.prototype, 'writeStackFile').mockResolvedValue(undefined);
|
||||
const cleanupSpy = vi.spyOn(FileSystemService.prototype, 'removeAlternateRootComposeFiles').mockResolvedValue(undefined);
|
||||
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue(undefined);
|
||||
|
||||
const outcome = await BlueprintService.getInstance().deployToNode(bp, node);
|
||||
@@ -416,14 +417,19 @@ describe('BlueprintService per-stack lock', () => {
|
||||
source: 'blueprint',
|
||||
actor: 'system:blueprint',
|
||||
});
|
||||
// Compose is written first, then the marker, both before the deploy.
|
||||
// Compose is written first, then the marker, both before sibling cleanup and deploy.
|
||||
expect(writeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(writeSpy.mock.calls[0][1]).toBe('compose.yaml');
|
||||
expect(writeSpy.mock.calls[0][2]).toBe(bp.compose_content);
|
||||
expect(writeSpy.mock.calls[1][1]).toBe('.blueprint.json');
|
||||
expect(writeSpy.mock.calls[1][2]).toContain('"blueprintId"');
|
||||
expect(cleanupSpy).toHaveBeenCalledWith(bp.name);
|
||||
const [composeOrder, markerOrder] = writeSpy.mock.invocationCallOrder;
|
||||
const [cleanupOrder] = cleanupSpy.mock.invocationCallOrder;
|
||||
const [deployOrder] = deploySpy.mock.invocationCallOrder;
|
||||
expect(composeOrder).toBeLessThan(markerOrder);
|
||||
expect(markerOrder).toBeLessThan(deployOrder);
|
||||
expect(markerOrder).toBeLessThan(cleanupOrder);
|
||||
expect(cleanupOrder).toBeLessThan(deployOrder);
|
||||
});
|
||||
|
||||
it('deploy skips, writes no stack files, and records failed when the stack lock is held', async () => {
|
||||
|
||||
@@ -535,7 +535,7 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
stackName: blueprint.name,
|
||||
filename: 'docker-compose.yml',
|
||||
filename: 'compose.yaml',
|
||||
content: compose,
|
||||
}]);
|
||||
} catch (snapErr) {
|
||||
|
||||
@@ -21,7 +21,8 @@ import { BlueprintAnalyzer } from './BlueprintAnalyzer';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const MARKER_FILENAME = '.blueprint.json';
|
||||
const COMPOSE_FILENAME = 'docker-compose.yml';
|
||||
/** On-disk compose name for Blueprint applies. Must match createStack scaffold and Sencho discovery priority. */
|
||||
const COMPOSE_FILENAME = 'compose.yaml';
|
||||
const REMOTE_HTTP_TIMEOUT_MS = 30_000;
|
||||
|
||||
function isDeveloperModeEnabled(): boolean {
|
||||
@@ -447,6 +448,9 @@ export class BlueprintService {
|
||||
}
|
||||
await fs.writeStackFile(stackName, COMPOSE_FILENAME, composeContent);
|
||||
await fs.writeStackFile(stackName, MARKER_FILENAME, markerContent);
|
||||
// Clear lower-priority compose siblings so discovery cannot shadow compose.yaml.
|
||||
// Local + modern apply-local only; legacy remote has no sibling DELETE.
|
||||
await fs.removeAlternateRootComposeFiles(stackName);
|
||||
await assertPolicyGateAllows(
|
||||
stackName,
|
||||
nodeId,
|
||||
|
||||
@@ -578,6 +578,34 @@ export class FileSystemService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove non-canonical root Compose filenames so discovery cannot shadow
|
||||
* compose.yaml. Filename set is fixed inside this method. ENOENT is success;
|
||||
* other unlink failures are logged and skipped.
|
||||
*/
|
||||
async removeAlternateRootComposeFiles(stackName: string): Promise<void> {
|
||||
const stackDir = this.resolveStackDir(stackName);
|
||||
await this.assertRealWithinBase(stackDir);
|
||||
const baseResolved = path.resolve(this.baseDir);
|
||||
for (const file of IMPORT_COMPOSE_FILENAMES) {
|
||||
if (file === 'compose.yaml') continue;
|
||||
// Containment barrier at the unlink sink (same pattern as rollback orphan removal).
|
||||
const target = path.resolve(baseResolved, path.join(stackDir, file));
|
||||
if (!target.startsWith(baseResolved + path.sep)) {
|
||||
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
try {
|
||||
await fsPromises.unlink(target);
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
||||
console.warn(
|
||||
`[FileSystemService] Could not remove alternate compose file ${sanitizeForLog(file)} in stack ${sanitizeForLog(stackName)}:`,
|
||||
sanitizeForLog((e as Error)?.message ?? String(e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteStack(stackName: string): Promise<void> {
|
||||
const stackDir = this.resolveStackDir(stackName);
|
||||
await this.assertRealWithinBase(stackDir);
|
||||
|
||||
Reference in New Issue
Block a user