fix(deploy): preserve compose.override.yml when Mesh is enabled (#1420)

When a single-file stack is opted into Sencho Mesh, the deploy builds an
explicit `docker compose -f <base> -f <mesh override>` list. Passing any
explicit -f disables Compose's automatic discovery of compose.override.yml
(and the docker-compose.override variants), so a user's hand-authored
override was silently dropped from the effective deploy once Mesh was on.

Resolve the user's override file (first existing variant, with the same
stack-name and symlink-containment guards as the base compose file) and
insert it between the base and the mesh override, so it layers exactly as
Compose's implicit discovery would, with the mesh override still taking
precedence. A transient read failure during the lookup degrades to "no
override" rather than failing the deploy; a stack-name or containment-guard
rejection still aborts. Multi-file Git-source stacks and non-mesh deploys
are unaffected.
This commit is contained in:
Anso
2026-06-23 10:44:49 -04:00
committed by GitHub
parent b2713b8a4d
commit b753d2d5e0
5 changed files with 298 additions and 14 deletions
+22 -3
View File
@@ -117,10 +117,29 @@ export class ComposeService {
}
if (overridePath) {
if (filePrefix.length === 0) {
// Single-file stack: passing any -f disables auto-discovery, so name the
// base file explicitly before layering the override on top of it.
const baseFilename = await FileSystemService.getInstance(this.nodeId).getComposeFilename(stackName);
// Single-file stack: passing any -f disables compose's auto-discovery, so name
// the base file explicitly, then re-add the user's implicit override (if any) so
// it is not silently dropped, before layering the mesh override on top.
const fsSvc = FileSystemService.getInstance(this.nodeId);
const baseFilename = await fsSvc.getComposeFilename(stackName);
args.push('-f', baseFilename);
let userOverride: string | null = null;
try {
userOverride = await fsSvc.getOverrideFilename(stackName);
} catch (err) {
// Containment-guard rejections (bad stack name / symlink escape) are hard errors:
// abort the deploy rather than degrade. The "no override" case returns null rather
// than throwing, so any other throw is transient I/O: drop the override and proceed
// (logging the consequence) instead of failing the deploy.
const code = (err as { code?: string }).code;
if (code === 'INVALID_STACK_NAME' || code === 'INVALID_PATH' || code === 'SYMLINK_ESCAPE') {
throw err;
}
console.warn('[ComposeService] could not resolve user compose override; deploying without it:', sanitizeForLog((err as Error).message));
}
if (userOverride) {
args.push('-f', userOverride);
}
}
args.push('-f', overridePath);
}
+38
View File
@@ -60,6 +60,16 @@ const PROTECTED_STACK_FILES = new Set([
// list FileSystemService uses elsewhere; named here for the import scan.
const IMPORT_COMPOSE_FILENAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'] as const;
const IMPORT_COMPOSE_FILENAME_SET = new Set<string>(IMPORT_COMPOSE_FILENAMES);
// Override filenames docker compose can auto-discover, listed in priority order (first
// match wins, not paired to the chosen base file's family). We resolve the first that
// exists, mirroring compose's default override resolution, to re-add it when an explicit
// -f list (mesh injection) would otherwise suppress that discovery.
const COMPOSE_OVERRIDE_FILENAMES = [
'compose.override.yaml',
'compose.override.yml',
'docker-compose.override.yaml',
'docker-compose.override.yml',
] as const;
// Skip reading compose files larger than this into the import preview.
const IMPORT_MAX_PREVIEW_BYTES = 1_048_576; // 1 MiB
@@ -221,6 +231,34 @@ export class FileSystemService {
return path.basename(await this.getComposeFilePath(stackName));
}
/**
* The stack's hand-authored compose override filename (bare basename, e.g.
* `compose.override.yml`), or `null` when none exists. Mirrors how docker compose
* itself resolves the default override: the first existing variant in priority order.
* Callers building an explicit `-f` list (which suppresses compose's built-in override
* discovery) use this to re-add the implicit override. Applies the same stack-name and
* symlink-containment guards as `getComposeFilePath`.
*/
async getOverrideFilename(stackName: string): Promise<string | null> {
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
// Canonical js/path-injection barrier inline with the access sink (same pattern as
// envExists): stackName is already validated by resolveStackDir and assertRealWithinBase
// above, but static analysis only credits the containment check when it sits at the sink.
const baseResolved = path.resolve(this.baseDir);
for (const file of COMPOSE_OVERRIDE_FILENAMES) {
const target = path.resolve(stackDir, file);
if (!target.startsWith(baseResolved + path.sep)) continue;
try {
await fsPromises.access(target);
return file;
} catch {
// continue
}
}
return null;
}
async getStacks(): Promise<string[]> {
try {
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });