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

+29 -1
View File
@@ -13,7 +13,7 @@ function find(src: string, ruleId: string, ctx: ValidateContext = {}) {
describe('parse + line tracking', () => {
test('a clean compose yields no findings', () => {
const src = `services:\n web:\n image: nginx:1.27\n restart: unless-stopped\n ports:\n - "127.0.0.1:8080:80"\n`;
const src = `services:\n web:\n image: nginx:1.27\n restart: unless-stopped\n healthcheck:\n test: ["CMD", "true"]\n ports:\n - "127.0.0.1:8080:80"\n`;
expect(runValidate(src).findings).toEqual([]);
});
@@ -358,6 +358,34 @@ describe('secret + security + reliability rules', () => {
const src = `services:\n a:\n image: x\n deploy:\n restart_policy:\n condition: any\n`;
expect(ids(src)).not.toContain('MISSING_RESTART_POLICY');
});
test('MISSING_HEALTHCHECK fires when absent, not when present or explicitly disabled', () => {
expect(ids(`services:\n a:\n image: x\n`)).toContain('MISSING_HEALTHCHECK');
expect(ids(`services:\n a:\n image: x\n healthcheck:\n test: ["CMD", "true"]\n`)).not.toContain('MISSING_HEALTHCHECK');
expect(ids(`services:\n a:\n image: x\n healthcheck:\n disable: true\n`)).not.toContain('MISSING_HEALTHCHECK');
});
test('MISSING_HEALTHCHECK is info severity', () => {
const f = find(`services:\n a:\n image: x\n`, 'MISSING_HEALTHCHECK');
expect(f?.severity).toBe('info');
});
test('UNUSED_VOLUME fires for a defined-but-unmounted named volume, not for a used one', () => {
expect(ids(`services:\n a:\n image: x\nvolumes:\n data: {}\n`)).toContain('UNUSED_VOLUME');
expect(ids(`services:\n a:\n image: x\n volumes:\n - data:/d\nvolumes:\n data: {}\n`)).not.toContain('UNUSED_VOLUME');
});
test('UNUSED_VOLUME ignores an external volume (may be mounted elsewhere)', () => {
expect(ids(`services:\n a:\n image: x\nvolumes:\n data:\n external: true\n`)).not.toContain('UNUSED_VOLUME');
});
test('UNUSED_VOLUME does not fire when no top-level volumes are defined', () => {
expect(ids(`services:\n a:\n image: x\n volumes:\n - ./d:/d\n`)).not.toContain('UNUSED_VOLUME');
});
test('UNUSED_VOLUME recognizes a long-form { type: volume, source } mount as usage', () => {
expect(
ids(
`services:\n a:\n image: x\n volumes:\n - type: volume\n source: data\n target: /d\nvolumes:\n data: {}\n`
)
).not.toContain('UNUSED_VOLUME');
});
});
describe('review regressions (found by the final audit)', () => {
+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();
});
});
+109 -1
View File
@@ -3,7 +3,11 @@
* whose tag is the SAME numeric version, ignoring v-prefix / flavor noise.
*/
import { describe, it, expect, mock } from 'bun:test';
import { matchReleasesToVersions, fetchReleaseNotes } from '../src/lib/server/semver/release-notes';
import {
matchReleasesToVersions,
fetchReleaseNotes,
resolveAndFetchReleaseNotes
} from '../src/lib/server/semver/release-notes';
import type { ReleaseSource } from '../src/lib/server/semver/release-source';
const rel = (tag: string, body = 'notes') => ({
@@ -119,3 +123,107 @@ describe('fetchReleaseNotes rate-limit detection', () => {
expect(r.rateLimited).toBe(false);
});
});
// A fetch double that returns a fixed release list only for a specific api slug,
// and empty for any other repo (the "wrong same-named repo" case).
const fetchForSlug = (slug: string, tags: string[]) =>
mock(async (url: string) => ({
ok: true,
status: 200,
headers: { get: () => null },
json: async () =>
url.includes(`/repos/${slug}/`) ? tags.map((t) => rel(t)) : []
}));
describe('resolveAndFetchReleaseNotes (validated fallback)', () => {
it('uses a confident source label directly', async () => {
const f = fetchForSlug('traefik/traefik', ['v3.1']);
const r = await resolveAndFetchReleaseNotes(
'traefik:v3.1',
{ 'org.opencontainers.image.source': 'https://github.com/traefik/traefik' },
['3.1'],
f as unknown as typeof fetch
);
expect(r.source?.slug).toBe('traefik/traefik');
expect(r.notes.map((n) => n.version)).toEqual(['3.1']);
});
it('accepts a guessed image-name repo when a release matches the wanted version', async () => {
const f = fetchForSlug('grafana/grafana', ['v11.0.0']);
const r = await resolveAndFetchReleaseNotes(
'grafana/grafana:11.0.0',
{},
['11.0.0'],
f as unknown as typeof fetch
);
expect(r.source?.slug).toBe('grafana/grafana');
expect(r.source?.needsValidation).toBe(true);
expect(r.notes).toHaveLength(1);
});
it('REJECTS a guessed repo whose releases do not match the wanted version', async () => {
// The same-named repo exists and returns releases, but none match 11.0.0.
const f = fetchForSlug('grafana/grafana', ['v1.2.3', 'v0.9.0']);
const r = await resolveAndFetchReleaseNotes(
'grafana/grafana:11.0.0',
{},
['11.0.0'],
f as unknown as typeof fetch
);
expect(r.source).toBeNull();
expect(r.notes).toHaveLength(0);
});
it('returns null source for an image with no guessable repo', async () => {
const f = fetchForSlug('nobody/nothing', ['v1.0.0']);
const r = await resolveAndFetchReleaseNotes('nginx:1.25', {}, ['1.25'], f as unknown as typeof fetch);
expect(r.source).toBeNull();
expect(r.notes).toHaveLength(0);
});
it('preserves rateLimited when a guessed repo is discarded due to a 403 rate limit', async () => {
// grafana/grafana is a correct guess, but GitHub 403s (rate limit) so no notes
// match. The source is dropped, yet the rateLimited signal must survive so the UI
// can prompt for a token.
const f = mock(async () => ({
ok: false,
status: 403,
headers: { get: (k: string) => (k.toLowerCase() === 'x-ratelimit-remaining' ? '0' : null) },
json: async () => ({})
}));
const r = await resolveAndFetchReleaseNotes('grafana/grafana:11.0.0', {}, ['11.0.0'], f as unknown as typeof fetch);
expect(r.source).toBeNull();
expect(r.notes).toHaveLength(0);
expect(r.rateLimited).toBe(true);
});
it('caps guessed candidates so many labels do not fan out unboundedly', async () => {
// 8 distinct github URLs in labels + an image-name guess; only the first few are
// tried. None match, so we can count fetches via the mock call count staying small.
const labels: Record<string, string> = {};
for (let i = 0; i < 8; i++) labels[`org.opencontainers.image.x${i}`] = `https://github.com/o${i}/r${i}`;
const f = mock(async () => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => [] }));
await resolveAndFetchReleaseNotes('acme/app:1.0.0', labels, ['1.0.0'], f as unknown as typeof fetch);
// MAX_GUESSES = 5: image-name guess + 4 label URLs at most.
expect(f.mock.calls.length).toBeLessThanOrEqual(5);
});
it('falls through image-name guess to a matching label URL', async () => {
// image-name repo (acme/app) has no matching release; the url label (acme/real) does.
const f = mock(async (url: string) => ({
ok: true,
status: 200,
headers: { get: () => null },
json: async () =>
url.includes('/repos/acme/real/') ? [rel('v2.0.0')] : []
}));
const r = await resolveAndFetchReleaseNotes(
'acme/app:2.0.0',
{ 'org.opencontainers.image.url': 'https://github.com/acme/real' },
['2.0.0'],
f as unknown as typeof fetch
);
expect(r.source?.slug).toBe('acme/real');
expect(r.notes).toHaveLength(1);
});
});
+59 -1
View File
@@ -3,7 +3,10 @@
* forge families semver supports (GitHub, Gitea/Forgejo, GHCR).
*/
import { describe, it, expect } from 'bun:test';
import { resolveReleaseSource } from '../src/lib/server/semver/release-source';
import {
resolveReleaseSource,
resolveReleaseSourceCandidates
} from '../src/lib/server/semver/release-source';
describe('resolveReleaseSource', () => {
it('resolves a GitHub source label to the GitHub releases API', () => {
@@ -60,3 +63,58 @@ describe('resolveReleaseSource', () => {
expect(resolveReleaseSource('ghcr.io/something:1')).toBeNull();
});
});
describe('resolveReleaseSourceCandidates', () => {
it('returns the single confident source (no guesses) when a source label exists', () => {
const cands = resolveReleaseSourceCandidates('whatever:1', {
'org.opencontainers.image.source': 'https://github.com/traefik/traefik'
});
expect(cands).toHaveLength(1);
expect(cands[0].slug).toBe('traefik/traefik');
expect(cands[0].needsValidation).toBeUndefined();
});
it('guesses a GitHub repo from a Docker Hub image name (grafana/grafana)', () => {
const cands = resolveReleaseSourceCandidates('grafana/grafana:11.0.0');
expect(cands).toHaveLength(1);
expect(cands[0].kind).toBe('github');
expect(cands[0].slug).toBe('grafana/grafana');
expect(cands[0].needsValidation).toBe(true);
expect(cands[0].apiBase).toBe('https://api.github.com/repos/grafana/grafana/releases');
});
it('drops a registry host segment before guessing (registry.io/org/app -> org/app)', () => {
const cands = resolveReleaseSourceCandidates('registry.example.com/acme/app:2.1');
expect(cands[0].slug).toBe('acme/app');
expect(cands[0].needsValidation).toBe(true);
});
it('does NOT guess a slug for a single-name official image (nginx)', () => {
expect(resolveReleaseSourceCandidates('nginx:1.25')).toHaveLength(0);
});
it('harvests a github URL from a non-source label', () => {
const cands = resolveReleaseSourceCandidates('nginx:1.25', {
'org.opencontainers.image.url': 'https://github.com/acme/proxy'
});
expect(cands.some((c) => c.slug === 'acme/proxy' && c.needsValidation)).toBe(true);
});
it('dedupes the image-name guess and a matching label URL', () => {
const cands = resolveReleaseSourceCandidates('grafana/grafana:11', {
'org.opencontainers.image.documentation': 'https://github.com/grafana/grafana/blob/main/README.md'
});
expect(cands.filter((c) => c.slug === 'grafana/grafana')).toHaveLength(1);
});
it('ignores the source label key when harvesting other labels', () => {
// A source label host that is not github/ghcr yields a confident gitea source,
// so candidates is just that one - no guessing happens.
const cands = resolveReleaseSourceCandidates('x:1', {
'org.opencontainers.image.source': 'https://git.example.com/team/app'
});
expect(cands).toHaveLength(1);
expect(cands[0].kind).toBe('gitea');
expect(cands[0].needsValidation).toBeUndefined();
});
});