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
+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);