compose generated from a container now captures network config and resource limits

This commit is contained in:
Jarek Krochmalski committed 2026-08-27 07:34:37 +02:00
1 parent bd42cfaa1b
commit 909e9934d3
11 files changed
+831 -39

No files matched your search

+282 -5
View File
@@ -55,7 +55,12 @@ describe('inspectToCompose - real gitea inspect (compose-managed, named-vol, cus
test('the custom stack network is emitted and declared external at top level', () => {
const { doc } = parseCompose(gitea);
const svc = doc.services['semver-demo-stack-gitea-1'];
expect(svc.networks).toEqual(['semver-demo-stack_default']);
// The network carries a per-container MAC, so it renders in the map form. Its auto
// aliases (the container name + the compose service name) are stripped, leaving no
// user aliases - only the mac_address.
expect(svc.networks).toEqual({
'semver-demo-stack_default': { mac_address: 'f6:1f:4a:64:b6:74' }
});
expect(doc.networks['semver-demo-stack_default']).toEqual({ external: true });
// bare bridge/default are never emitted
expect(svc.network_mode).toBeUndefined();
@@ -128,14 +133,17 @@ describe('inspectToCompose - real nginx inspect (published ports, bind :ro, name
expect(labels['my.custom.label']).toBe('hello');
});
test('auto-generated noise (hostname, working_dir /, shm_size) is not emitted', () => {
test('auto-generated noise (hostname, working_dir /) is not emitted', () => {
const { doc } = parseCompose(nginx);
const svc = doc.services[svcName];
// Hostname is the short container id (12-hex) -> dropped; working_dir is "/" -> dropped;
// shm_size is host-derived -> never emitted.
// Hostname is the short container id (12-hex) -> dropped; working_dir is "/" -> dropped.
expect(svc.hostname).toBeUndefined();
expect(svc.working_dir).toBeUndefined();
expect(svc.shm_size).toBeUndefined();
});
test('a non-default shm_size is emitted (this fixture set --shm-size)', () => {
const svc = parseCompose(nginx).doc.services[svcName];
expect(svc.shm_size).toBe(8399093760);
});
test('image-default entrypoint/command are dropped when the image values are provided', () => {
@@ -196,8 +204,277 @@ describe('inspectToCompose - default-noise filtering', () => {
expect(svc.image).toBe('busybox:latest');
});
test('network_mode: host is emitted (a host-mode container must not fall back to bridge)', () => {
// #1464: a host-mode container reports NetworkMode "host" AND a `host` entry in
// NetworkSettings.Networks. The Networks entry is filtered (we never want
// `networks: [host]`), so the mode must be emitted from the else branch.
const inspect: DockerInspect = {
Name: '/hostnet',
Config: { Image: 'busybox:latest' },
HostConfig: { NetworkMode: 'host' },
NetworkSettings: { Networks: { host: {} } }
};
const svc = parseCompose(inspect).doc.services['hostnet'];
expect(svc.network_mode).toBe('host');
expect(svc.networks).toBeUndefined();
});
test('network_mode: none is emitted (disabled networking is a deliberate choice)', () => {
const inspect: DockerInspect = {
Name: '/nonet',
Config: { Image: 'busybox:latest' },
HostConfig: { NetworkMode: 'none' },
NetworkSettings: { Networks: { none: {} } }
};
const svc = parseCompose(inspect).doc.services['nonet'];
expect(svc.network_mode).toBe('none');
expect(svc.networks).toBeUndefined();
});
test('network_mode: default produces no network_mode key', () => {
const inspect: DockerInspect = {
Name: '/def',
Config: { Image: 'busybox:latest' },
HostConfig: { NetworkMode: 'default' },
NetworkSettings: { Networks: { bridge: {} } }
};
const svc = parseCompose(inspect).doc.services['def'];
expect(svc.network_mode).toBeUndefined();
expect(svc.networks).toBeUndefined();
});
test('serviceName option overrides the container name', () => {
const { name } = inspectToComposeService({ Name: '/original', Config: { Image: 'x' } }, { serviceName: 'renamed' });
expect(name).toBe('renamed');
});
});
describe('inspectToCompose - full network model (static IP, aliases, MAC)', () => {
test('static IPv4/IPv6, user aliases and MAC render as a per-network map', () => {
const inspect: DockerInspect = {
Name: '/app',
Config: { Image: 'nginx:1.27' },
HostConfig: {},
NetworkSettings: {
Networks: {
backend: {
Aliases: ['app', 'db.internal', 'a1b2c3d4e5f6'],
MacAddress: '02:42:ac:11:00:05',
IPAMConfig: { IPv4Address: '10.5.0.9', IPv6Address: 'fd00::9' }
}
}
}
};
const svc = parseCompose(inspect).doc.services['app'];
// 'app' (service key) and the 12-hex short id are stripped; 'db.internal' survives.
expect(svc.networks).toEqual({
backend: {
ipv4_address: '10.5.0.9',
ipv6_address: 'fd00::9',
aliases: ['db.internal'],
mac_address: '02:42:ac:11:00:05'
}
});
expect(svc.network_mode).toBeUndefined();
});
test('a compose service alias (com.docker.compose.service) is stripped as auto', () => {
const inspect: DockerInspect = {
Name: '/proj-web-1',
Config: { Image: 'nginx', Labels: { 'com.docker.compose.service': 'web' } },
HostConfig: {},
NetworkSettings: {
Networks: { proj_default: { Aliases: ['proj-web-1', 'web'], IPAMConfig: { IPv4Address: '10.1.0.2' } } }
}
};
const svc = parseCompose(inspect).doc.services['proj-web-1'];
// both 'proj-web-1' (name) and 'web' (compose service) are auto; only the static IP is left.
expect(svc.networks).toEqual({ proj_default: { ipv4_address: '10.1.0.2' } });
});
test('bare networks with no detail stay in the short list form (zero noise)', () => {
const inspect: DockerInspect = {
Name: '/app',
Config: { Image: 'nginx' },
HostConfig: {},
NetworkSettings: { Networks: { frontend: { Aliases: ['app'] }, backend: {} } }
};
const svc = parseCompose(inspect).doc.services['app'];
expect(svc.networks).toEqual(['frontend', 'backend']);
});
test('dns_search and dns_opt are emitted alongside dns', () => {
const inspect: DockerInspect = {
Name: '/app',
Config: { Image: 'nginx' },
HostConfig: { Dns: ['1.1.1.1'], DnsSearch: ['corp.local'], DnsOptions: ['ndots:2'] }
};
const svc = parseCompose(inspect).doc.services['app'];
expect(svc.dns).toEqual(['1.1.1.1']);
expect(svc.dns_search).toEqual(['corp.local']);
expect(svc.dns_opt).toEqual(['ndots:2']);
});
});
describe('inspectToCompose - resource limits', () => {
test('memory + cpu + pids limits map to the standalone top-level keys', () => {
const inspect: DockerInspect = {
Name: '/app',
Config: { Image: 'nginx' },
HostConfig: {
Memory: 536870912,
MemoryReservation: 268435456,
MemorySwap: 1073741824,
NanoCpus: 1500000000,
CpusetCpus: '0-1',
PidsLimit: 200
}
};
const svc = parseCompose(inspect).doc.services['app'];
expect(svc.mem_limit).toBe(536870912);
expect(svc.mem_reservation).toBe(268435456);
expect(svc.memswap_limit).toBe(1073741824);
expect(svc.cpus).toBe(1.5); // 1.5e9 nanocpus -> 1.5 cores
expect(svc.cpuset).toBe('0-1');
expect(svc.pids_limit).toBe(200);
});
test('daemon-default resource values are skipped', () => {
const inspect: DockerInspect = {
Name: '/app',
Config: { Image: 'nginx' },
HostConfig: {
Memory: 0,
MemorySwap: 0,
NanoCpus: 0,
CpuShares: 1024, // neutral weight - not user intent
CpusetCpus: '',
PidsLimit: -1 // unlimited (the default)
}
};
const svc = parseCompose(inspect).doc.services['app'];
expect(svc.mem_limit).toBeUndefined();
expect(svc.memswap_limit).toBeUndefined();
expect(svc.cpus).toBeUndefined();
expect(svc.cpu_shares).toBeUndefined();
expect(svc.cpuset).toBeUndefined();
expect(svc.pids_limit).toBeUndefined();
});
test('cpu_shares is emitted for a non-neutral weight', () => {
const svc = parseCompose({
Name: '/app',
Config: { Image: 'nginx' },
HostConfig: { CpuShares: 512 }
}).doc.services['app'];
expect(svc.cpu_shares).toBe(512);
});
test('memswap_limit -1 (unlimited swap) is a real user value and is emitted', () => {
const svc = parseCompose({
Name: '/app',
Config: { Image: 'nginx' },
HostConfig: { Memory: 536870912, MemorySwap: -1 }
}).doc.services['app'];
expect(svc.memswap_limit).toBe(-1);
});
});
describe('inspectToCompose - lifecycle (stop/expose/group_add/init)', () => {
test('stop_signal + stop_grace_period from a non-default StopSignal/StopTimeout', () => {
const svc = parseCompose({
Name: '/app',
Config: { Image: 'nginx', StopSignal: 'SIGINT', StopTimeout: 30 },
HostConfig: {}
}).doc.services['app'];
expect(svc.stop_signal).toBe('SIGINT');
expect(svc.stop_grace_period).toBe('30s');
});
test('default SIGTERM and null StopTimeout are skipped', () => {
const svc = parseCompose({
Name: '/app',
Config: { Image: 'nginx', StopSignal: 'SIGTERM', StopTimeout: null },
HostConfig: {}
}).doc.services['app'];
expect(svc.stop_signal).toBeUndefined();
expect(svc.stop_grace_period).toBeUndefined();
});
test('expose lists ports that are exposed but neither published nor image-baked', () => {
const inspect: DockerInspect = {
Name: '/app',
Config: { Image: 'nginx', ExposedPorts: { '80/tcp': {}, '9000/tcp': {}, '53/udp': {} } },
HostConfig: { PortBindings: { '80/tcp': [{ HostPort: '8080' }] } }
};
// image itself EXPOSEs 9000; that must not leak into expose.
const svc = parseCompose(inspect, { imageExposedPorts: { '9000/tcp': {} } }).doc.services['app'];
// 80 is published (in ports), 9000 is image-baked -> only 53/udp remains.
expect(svc.expose).toEqual(['53/udp']);
});
test('group_add and init: true are emitted', () => {
const svc = parseCompose({
Name: '/app',
Config: { Image: 'nginx' },
HostConfig: { GroupAdd: ['docker'], Init: true }
}).doc.services['app'];
expect(svc.group_add).toEqual(['docker']);
expect(svc.init).toBe(true);
});
test('init: null / false is not emitted', () => {
const svcNull = parseCompose({ Name: '/a', Config: { Image: 'x' }, HostConfig: { Init: null } }).doc.services['a'];
const svcFalse = parseCompose({ Name: '/b', Config: { Image: 'x' }, HostConfig: { Init: false } }).doc.services['b'];
expect(svcNull.init).toBeUndefined();
expect(svcFalse.init).toBeUndefined();
});
});
describe('inspectToCompose - runtime/security options', () => {
test('security_opt, pid, userns, cgroup_parent, runtime, shm_size, volumes_from, oom, domainname', () => {
const inspect: DockerInspect = {
Name: '/app',
Config: { Image: 'nginx', Domainname: 'corp.local' },
HostConfig: {
SecurityOpt: ['no-new-privileges'],
PidMode: 'host',
UsernsMode: 'host',
CgroupParent: '/custom.slice',
Runtime: 'nvidia',
ShmSize: 134217728, // 128MiB, differs from the 64MiB default
VolumesFrom: ['data-container'],
OomKillDisable: true,
OomScoreAdj: -500
}
};
const svc = parseCompose(inspect).doc.services['app'];
expect(svc.security_opt).toEqual(['no-new-privileges']);
expect(svc.pid).toBe('host');
expect(svc.userns_mode).toBe('host');
expect(svc.cgroup_parent).toBe('/custom.slice');
expect(svc.runtime).toBe('nvidia');
expect(svc.shm_size).toBe(134217728);
expect(svc.volumes_from).toEqual(['data-container']);
expect(svc.oom_kill_disable).toBe(true);
expect(svc.oom_score_adj).toBe(-500);
expect(svc.domainname).toBe('corp.local');
});
test('runc runtime, 64MiB shm_size, and oom defaults are skipped', () => {
const svc = parseCompose({
Name: '/app',
Config: { Image: 'nginx' },
HostConfig: {
Runtime: 'runc',
ShmSize: 67108864, // 64MiB default
OomKillDisable: false,
OomScoreAdj: 0
}
}).doc.services['app'];
expect(svc.runtime).toBeUndefined();
expect(svc.shm_size).toBeUndefined();
expect(svc.oom_kill_disable).toBeUndefined();
expect(svc.oom_score_adj).toBeUndefined();
});
});