diff --git a/backend/src/__tests__/compose-dependency-parse.test.ts b/backend/src/__tests__/compose-dependency-parse.test.ts index a178e04c..140983f8 100644 --- a/backend/src/__tests__/compose-dependency-parse.test.ts +++ b/backend/src/__tests__/compose-dependency-parse.test.ts @@ -37,6 +37,42 @@ describe('parseComposeDependencies - volumes', () => { const r = parseComposeDependencies('services:\n web:\n volumes:\n - type: volume\n source: data\n target: /d\n - type: bind\n source: /host\n target: /b\n'); expect(r.services[0].volumes).toEqual(['data']); }); + + it('drops env-var-interpolated bind sources and keeps real named volumes', () => { + const r = parseComposeDependencies(svc('web', 'volumes:\n - ${BACKUPS_PATH}:/backups\n - $LEGACY_PATH:/legacy\n - db_data:/var/lib')); + expect(r.services[0].volumes).toEqual(['db_data']); + }); + + it('drops a bind source with an embedded (non-leading) env var', () => { + const r = parseComposeDependencies(svc('web', 'volumes:\n - prefix-${SUB}:/data')); + expect(r.services[0].volumes).toEqual([]); + }); + + it('passes a long-form type: volume env-var source through unchanged', () => { + // The long-form path never calls isNamedVolumeSource, so its contract is + // unaffected by the short-form env-var fix; this guards that. + const r = parseComposeDependencies('services:\n web:\n volumes:\n - type: volume\n source: ${MY_VOLUME}\n target: /d\n'); + expect(r.services[0].volumes).toEqual(['${MY_VOLUME}']); + }); + + it('does not flag env-var bind mounts as named volumes (issue #1464 repro)', () => { + const r = parseComposeDependencies( + 'services:\n' + + ' core:\n' + + ' volumes:\n' + + ' - ${COMPOSE_KOMODO_BACKUPS_PATH}:/backups\n' + + ' - keys:/config/keys\n' + + ' periphery:\n' + + ' volumes:\n' + + ' - /var/run/docker.sock:/var/run/docker.sock\n' + + ' - ${PERIPHERY_ROOT_DIRECTORY}:/root\n' + + ' - ${PERIPHERY_STACK_DIR}:/stack\n', + ); + const core = r.services.find((s) => s.name === 'core'); + const periphery = r.services.find((s) => s.name === 'periphery'); + expect(core?.volumes).toEqual(['keys']); + expect(periphery?.volumes).toEqual([]); + }); }); describe('parseComposeDependencies - ports', () => { diff --git a/backend/src/helpers/composeDependencyParse.ts b/backend/src/helpers/composeDependencyParse.ts index 2eb2e700..f8059fd0 100644 --- a/backend/src/helpers/composeDependencyParse.ts +++ b/backend/src/helpers/composeDependencyParse.ts @@ -66,6 +66,9 @@ function collectKeys(value: unknown): string[] { /** True when a volume source is a named volume (not a bind mount or anonymous). */ function isNamedVolumeSource(source: string): boolean { if (!source) return false; + // A named-volume key is [a-zA-Z0-9._-]+, so any '$' marks an env-var bind path + // (or the '$$' literal-dollar escape) whose value is unresolvable at parse time. + if (source.includes('$')) return false; if (source.includes('/')) return false; // bind mount path if (source.startsWith('.') || source.startsWith('~')) return false; if (/^[a-zA-Z]:[\\/]/.test(source)) return false; // Windows drive path