Files
sencho/backend/src/__tests__/compose-dependency-parse.test.ts
T
Anso 5e2194f4a3 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.
2026-06-26 14:59:35 -04:00

142 lines
6.7 KiB
TypeScript

/**
* Unit tests for the dependency-map compose parser: depends_on / network /
* named-volume / declared-port extraction, external + name: resolution, and
* the fail-soft parseError paths.
*/
import { describe, it, expect } from 'vitest';
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
const svc = (name: string, body: string) => `services:\n ${name}:\n${body.split('\n').map((l) => l ? ' ' + l : l).join('\n')}\n`;
describe('parseComposeDependencies - services and depends_on', () => {
it('extracts depends_on in list form', () => {
const r = parseComposeDependencies(svc('web', 'image: nginx\ndepends_on:\n - db\n - cache'));
expect(r.services[0].dependsOn).toEqual(['db', 'cache']);
});
it('extracts depends_on in map form', () => {
const r = parseComposeDependencies('services:\n web:\n depends_on:\n db:\n condition: service_healthy\n');
expect(r.services[0].dependsOn).toEqual(['db']);
});
it('extracts service networks in list and map form', () => {
const list = parseComposeDependencies(svc('web', 'networks:\n - frontend\n - backend'));
expect(list.services[0].networks).toEqual(['frontend', 'backend']);
const map = parseComposeDependencies('services:\n web:\n networks:\n frontend:\n aliases: [w]\n');
expect(map.services[0].networks).toEqual(['frontend']);
});
});
describe('parseComposeDependencies - volumes', () => {
it('keeps named volumes and drops binds and anonymous volumes', () => {
const r = parseComposeDependencies(svc('web', 'volumes:\n - db_data:/var/lib\n - ./local:/app\n - /abs/path:/data\n - /anon-target'));
expect(r.services[0].volumes).toEqual(['db_data']);
});
it('keeps a long-form named volume and drops a long-form bind', () => {
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', () => {
it('parses short-form host:container with default tcp', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "8080:80"'));
expect(r.services[0].ports).toEqual([{ hostIp: '', publishedPort: 8080, protocol: 'tcp' }]);
});
it('parses ip:host:container and the /udp protocol', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "127.0.0.1:5353:53/udp"'));
expect(r.services[0].ports).toEqual([{ hostIp: '127.0.0.1', publishedPort: 5353, protocol: 'udp' }]);
});
it('drops a container-only port (no host publish)', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "80"'));
expect(r.services[0].ports).toEqual([]);
});
it('takes the low end of a published range', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "8000-8002:80"'));
expect(r.services[0].ports[0].publishedPort).toBe(8000);
});
it('parses long-form ports with host_ip and protocol', () => {
const r = parseComposeDependencies('services:\n web:\n ports:\n - target: 80\n published: 8080\n host_ip: 10.0.0.5\n protocol: udp\n');
expect(r.services[0].ports).toEqual([{ hostIp: '10.0.0.5', publishedPort: 8080, protocol: 'udp' }]);
});
});
describe('parseComposeDependencies - network_mode', () => {
it('captures network_mode and leaves it undefined when absent', () => {
const host = parseComposeDependencies(svc('web', 'image: nginx\nnetwork_mode: host'));
expect(host.services[0].networkMode).toBe('host');
const none = parseComposeDependencies(svc('web', 'image: nginx'));
expect(none.services[0].networkMode).toBeUndefined();
});
});
describe('parseComposeDependencies - top-level resources', () => {
it('normalizes external (bool), legacy external object, and name: override', () => {
const r = parseComposeDependencies('services:\n web:\n image: nginx\nnetworks:\n a:\n b:\n external: true\n c:\n external:\n name: legacy_net\n d:\n name: custom_net\nvolumes:\n v:\n external: true\n');
expect(r.networks.a).toEqual({ external: false });
expect(r.networks.b).toEqual({ external: true });
expect(r.networks.c).toEqual({ name: 'legacy_net', external: true });
expect(r.networks.d).toEqual({ name: 'custom_net', external: false });
expect(r.volumes.v).toEqual({ external: true });
});
});
describe('parseComposeDependencies - fail soft', () => {
it('reports a parseError for invalid YAML and never throws', () => {
const r = parseComposeDependencies('services:\n web:\n - this: : is broken\n :::');
expect(r.parseError).toBeTruthy();
expect(r.services).toEqual([]);
});
it('reports a parseError when there are no services', () => {
const r = parseComposeDependencies('networks:\n a:\n');
expect(r.parseError).toBe('No services found in this file.');
});
it('reports a parseError for an oversized file', () => {
const r = parseComposeDependencies('x'.repeat(1_048_577));
expect(r.parseError).toBe('Compose file is too large to parse.');
});
});