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:
Anso
2026-07-21 21:19:03 -04:00
committed by GitHub
parent 155db30554
commit e15b9d1244
9 changed files with 262 additions and 9 deletions
@@ -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);
+8 -2
View File
@@ -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 () => {
+1 -1
View File
@@ -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) {
+5 -1
View File
@@ -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,
+28
View File
@@ -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);