fix(dependency-map): stop flagging env-var bind mounts as missing volumes (#1468)

* fix(dependency-map): stop flagging env-var bind mounts as missing volumes

The Fleet Map "Missing dependencies" anomaly fired false positives for
services whose volumes use env-var-interpolated bind sources such as
${BACKUPS_PATH}:/backups. The compose parser classified the source as a
named volume because the ${VAR} token contains no slash, then the runtime
presence check found no matching volume and flagged it.

A compose named-volume key can never contain $, so any source with an env
var is a bind path whose value is unresolvable at parse time. Exclude it
from named-volume classification.

Closes #1464

* docs(dependency-map): clarify the env-var volume guard comment

Note that the $ check also covers the $$ literal-dollar Compose escape, and
state the named-volume key charset that makes the guard safe. No behavior change.
This commit is contained in:
Anso
2026-06-26 14:59:35 -04:00
committed by GitHub
parent 26d557a701
commit 5e2194f4a3
2 changed files with 39 additions and 0 deletions
@@ -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', () => {
@@ -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