generate a compose file from any container - save as new or append to existing stack
This commit is contained in:
12 files changed
+1939
-4
No files matched your search
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Unit tests for the "Append to existing" merge helpers (#489): merging a generated
|
||||
* single-service compose into an existing stack compose, and computing the highlight range
|
||||
* of the added service. No browser needed - pure js-yaml logic.
|
||||
*
|
||||
* Run with: bun test tests/compose-merge.test.ts
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import yaml from 'js-yaml';
|
||||
import { mergeServiceIntoCompose, computeAddedRange } from '../src/lib/utils/compose-merge';
|
||||
|
||||
const BASE = `services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- '8080:80'
|
||||
`;
|
||||
|
||||
describe('mergeServiceIntoCompose', () => {
|
||||
test('adds a new service under services without touching the existing one', () => {
|
||||
const service = `services:
|
||||
redis:
|
||||
image: redis:7
|
||||
`;
|
||||
const { merged, key, renamed } = mergeServiceIntoCompose(BASE, service);
|
||||
expect(key).toBe('redis');
|
||||
expect(renamed).toBe(false);
|
||||
const doc = yaml.load(merged) as any;
|
||||
expect(Object.keys(doc.services).sort()).toEqual(['redis', 'web']);
|
||||
expect(doc.services.web.image).toBe('nginx:latest');
|
||||
expect(doc.services.redis.image).toBe('redis:7');
|
||||
});
|
||||
|
||||
test('auto-suffixes on a name clash instead of overwriting', () => {
|
||||
const service = `services:
|
||||
web:
|
||||
image: caddy:2
|
||||
`;
|
||||
const { merged, key, renamed } = mergeServiceIntoCompose(BASE, service);
|
||||
expect(key).toBe('web-2');
|
||||
expect(renamed).toBe(true);
|
||||
const doc = yaml.load(merged) as any;
|
||||
// original web is untouched, the added one is web-2
|
||||
expect(doc.services.web.image).toBe('nginx:latest');
|
||||
expect(doc.services['web-2'].image).toBe('caddy:2');
|
||||
});
|
||||
|
||||
test('picks the lowest free suffix when -2 is also taken', () => {
|
||||
const base = `services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
web-2:
|
||||
image: nginx:1.25
|
||||
`;
|
||||
const service = `services:
|
||||
web:
|
||||
image: caddy:2
|
||||
`;
|
||||
const { key } = mergeServiceIntoCompose(base, service);
|
||||
expect(key).toBe('web-3');
|
||||
});
|
||||
|
||||
test('carries over top-level networks and volumes the service references', () => {
|
||||
const service = `services:
|
||||
db:
|
||||
image: postgres:16
|
||||
networks:
|
||||
- backend
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
networks:
|
||||
backend:
|
||||
external: true
|
||||
volumes:
|
||||
pgdata:
|
||||
external: true
|
||||
`;
|
||||
const { merged } = mergeServiceIntoCompose(BASE, service);
|
||||
const doc = yaml.load(merged) as any;
|
||||
expect(doc.networks.backend).toEqual({ external: true });
|
||||
expect(doc.volumes.pgdata).toEqual({ external: true });
|
||||
});
|
||||
|
||||
test('handles an empty base compose (creates services)', () => {
|
||||
const service = `services:
|
||||
app:
|
||||
image: alpine
|
||||
`;
|
||||
const { merged, key } = mergeServiceIntoCompose('', service);
|
||||
expect(key).toBe('app');
|
||||
expect((yaml.load(merged) as any).services.app.image).toBe('alpine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAddedRange', () => {
|
||||
test('returns the exact block of the added service', () => {
|
||||
const service = `services:
|
||||
redis:
|
||||
image: redis:7
|
||||
`;
|
||||
const { merged, key } = mergeServiceIntoCompose(BASE, service);
|
||||
const [range] = computeAddedRange(merged, key);
|
||||
const lines = merged.split('\n');
|
||||
// the highlighted range must start at the ` redis:` line
|
||||
expect(lines[range.line - 1]).toMatch(/^\s+redis:/);
|
||||
// and every highlighted line must be inside the redis block (indent > 2) or the header
|
||||
for (let l = range.line; l <= range.endLine; l++) {
|
||||
expect(lines[l - 1].startsWith(' ')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('does NOT over-highlight the trailing blank line when the service is last', () => {
|
||||
// merged doc ends with the added service and a trailing newline from yaml.dump.
|
||||
const service = `services:
|
||||
last:
|
||||
image: busybox
|
||||
`;
|
||||
const { merged, key } = mergeServiceIntoCompose(BASE, service);
|
||||
const [range] = computeAddedRange(merged, key);
|
||||
const lines = merged.split('\n');
|
||||
// endLine must be a real content line, not the trailing empty one.
|
||||
expect(lines[range.endLine - 1].trim()).not.toBe('');
|
||||
});
|
||||
|
||||
test('returns empty when the key is absent', () => {
|
||||
expect(computeAddedRange(BASE, 'nope')).toEqual([]);
|
||||
});
|
||||
});
|
||||
Vendored
+248
@@ -0,0 +1,248 @@
|
||||
[
|
||||
{
|
||||
"Id": "b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8",
|
||||
"Created": "2026-08-16T15:05:55.489652712Z",
|
||||
"Path": "/usr/bin/entrypoint",
|
||||
"Args": [
|
||||
"/bin/s6-svscan",
|
||||
"/etc/s6"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 442,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2026-08-22T16:40:00.901354Z",
|
||||
"FinishedAt": "2026-08-21T08:42:47.411649045Z"
|
||||
},
|
||||
"Image": "sha256:1b34baabfe2a23bc4ac5c1db5c2316c683d0afa6bc600867c9aafc02a5f9c959",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8/b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8-json.log",
|
||||
"Name": "/semver-demo-stack-gitea-1",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlayfs",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": null,
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {
|
||||
"max-file": "5",
|
||||
"max-size": "20m"
|
||||
}
|
||||
},
|
||||
"NetworkMode": "semver-demo-stack_default",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "unless-stopped",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": [],
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": [],
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 0,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 8399093760,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": null,
|
||||
"BlkioDeviceReadBps": null,
|
||||
"BlkioDeviceWriteBps": null,
|
||||
"BlkioDeviceReadIOps": null,
|
||||
"BlkioDeviceWriteIOps": null,
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": null,
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": null,
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/acpi",
|
||||
"/proc/asound",
|
||||
"/proc/interrupts",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/sys/devices/virtual/powercap",
|
||||
"/sys/firmware"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"Storage": {
|
||||
"RootFS": {
|
||||
"Snapshot": {
|
||||
"Name": "overlayfs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Mounts": [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "b93b00146e83907cc09b13ab2dfa145bfd18e232ba37d1e6e1cd77c5c43cfc4c",
|
||||
"Source": "/var/lib/docker/volumes/b93b00146e83907cc09b13ab2dfa145bfd18e232ba37d1e6e1cd77c5c43cfc4c/_data",
|
||||
"Destination": "/data",
|
||||
"Driver": "local",
|
||||
"Mode": "",
|
||||
"RW": true,
|
||||
"Propagation": ""
|
||||
}
|
||||
],
|
||||
"Config": {
|
||||
"Hostname": "b4aa30f522a9",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": true,
|
||||
"AttachStderr": true,
|
||||
"ExposedPorts": {
|
||||
"22/tcp": {},
|
||||
"3000/tcp": {}
|
||||
},
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"USER=git",
|
||||
"GITEA_CUSTOM=/data/gitea"
|
||||
],
|
||||
"Cmd": [
|
||||
"/bin/s6-svscan",
|
||||
"/etc/s6"
|
||||
],
|
||||
"Image": "gitea/gitea:1.21.0",
|
||||
"Volumes": {
|
||||
"/data": {}
|
||||
},
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": [
|
||||
"/usr/bin/entrypoint"
|
||||
],
|
||||
"Labels": {
|
||||
"com.docker.compose.config-hash": "2ec0758dc4a920276a9710ee9d5867e7ba6f0c0da358b3f4f6474a912b80af4c",
|
||||
"com.docker.compose.container-number": "1",
|
||||
"com.docker.compose.depends_on": "",
|
||||
"com.docker.compose.image": "sha256:1b34baabfe2a23bc4ac5c1db5c2316c683d0afa6bc600867c9aafc02a5f9c959",
|
||||
"com.docker.compose.oneoff": "False",
|
||||
"com.docker.compose.project": "semver-demo-stack",
|
||||
"com.docker.compose.project.config_files": "/home/user/stacks/semver-stack/docker-compose.yml",
|
||||
"com.docker.compose.project.working_dir": "/home/user/stacks/semver-stack",
|
||||
"com.docker.compose.service": "gitea",
|
||||
"com.docker.compose.version": "5.1.2",
|
||||
"maintainer": "[email protected]",
|
||||
"org.opencontainers.image.created": "2023-11-14T14:06:39.037Z",
|
||||
"org.opencontainers.image.description": "Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD",
|
||||
"org.opencontainers.image.licenses": "MIT",
|
||||
"org.opencontainers.image.revision": "bc6477b36b774b0c4616a0ac7af76ba2d56da081",
|
||||
"org.opencontainers.image.source": "https://github.com/go-gitea/gitea",
|
||||
"org.opencontainers.image.title": "gitea",
|
||||
"org.opencontainers.image.url": "https://github.com/go-gitea/gitea",
|
||||
"org.opencontainers.image.version": "1"
|
||||
}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"SandboxID": "4db52ddc90824185fc7f9774a46919aee72a3c61f4be8afdb4225197e0a4270c",
|
||||
"SandboxKey": "/var/run/docker/netns/4db52ddc9082",
|
||||
"Ports": {
|
||||
"22/tcp": null,
|
||||
"3000/tcp": null
|
||||
},
|
||||
"Networks": {
|
||||
"semver-demo-stack_default": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": [
|
||||
"semver-demo-stack-gitea-1",
|
||||
"gitea"
|
||||
],
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "69f26cd4ead78d2b954f663eb49c08a04b7027fb080e06d2164e56cbb527b7b2",
|
||||
"EndpointID": "49804adcbc2e0bca9e4b0f4199c3ab3a35e989bbf77508654be6435e22e3f6f5",
|
||||
"Gateway": "192.168.117.1",
|
||||
"IPAddress": "192.168.117.3",
|
||||
"MacAddress": "f6:1f:4a:64:b6:74",
|
||||
"IPPrefixLen": 24,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": [
|
||||
"semver-demo-stack-gitea-1",
|
||||
"gitea",
|
||||
"b4aa30f522a9"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageManifestDescriptor": {
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"digest": "sha256:54dc2aeb69f37251ef9e6674e9c92c1893e0350cbefa3b0628b5dea4fdfae9da",
|
||||
"size": 1625,
|
||||
"platform": {
|
||||
"architecture": "arm64",
|
||||
"os": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
[
|
||||
{
|
||||
"Id": "12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c",
|
||||
"Created": "2026-08-23T09:13:15.772313516Z",
|
||||
"Path": "/docker-entrypoint.sh",
|
||||
"Args": [
|
||||
"nginx",
|
||||
"-g",
|
||||
"daemon off;"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 19337,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2026-08-23T09:13:15.902693702Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||
"Health": {
|
||||
"Status": "starting",
|
||||
"FailingStreak": 0,
|
||||
"Log": []
|
||||
}
|
||||
},
|
||||
"Image": "sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c/12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c-json.log",
|
||||
"Name": "/itc-fixture",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlayfs",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": [
|
||||
"/tmp/itc-html:/usr/share/nginx/html:ro",
|
||||
"itc-named:/var/cache/nginx"
|
||||
],
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {
|
||||
"max-file": "5",
|
||||
"max-size": "20m"
|
||||
}
|
||||
},
|
||||
"NetworkMode": "bridge",
|
||||
"PortBindings": {
|
||||
"443/tcp": [
|
||||
{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "8443"
|
||||
}
|
||||
],
|
||||
"80/tcp": [
|
||||
{
|
||||
"HostIp": "",
|
||||
"HostPort": "8099"
|
||||
}
|
||||
]
|
||||
},
|
||||
"RestartPolicy": {
|
||||
"Name": "on-failure",
|
||||
"MaximumRetryCount": 5
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"CapAdd": [
|
||||
"CAP_NET_ADMIN"
|
||||
],
|
||||
"CapDrop": [
|
||||
"CAP_MKNOD"
|
||||
],
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": null,
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 0,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 8399093760,
|
||||
"Sysctls": {
|
||||
"net.core.somaxconn": "1024"
|
||||
},
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": [],
|
||||
"BlkioDeviceReadBps": [],
|
||||
"BlkioDeviceWriteBps": [],
|
||||
"BlkioDeviceReadIOps": [],
|
||||
"BlkioDeviceWriteIOps": [],
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": [],
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": [],
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/acpi",
|
||||
"/proc/asound",
|
||||
"/proc/interrupts",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/sys/devices/virtual/powercap",
|
||||
"/sys/firmware"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"Storage": {
|
||||
"RootFS": {
|
||||
"Snapshot": {
|
||||
"Name": "overlayfs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Mounts": [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/tmp/itc-html",
|
||||
"Destination": "/usr/share/nginx/html",
|
||||
"Mode": "ro",
|
||||
"RW": false,
|
||||
"Propagation": "rprivate"
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "itc-named",
|
||||
"Source": "/var/lib/docker/volumes/itc-named/_data",
|
||||
"Destination": "/var/cache/nginx",
|
||||
"Driver": "local",
|
||||
"Mode": "z",
|
||||
"RW": true,
|
||||
"Propagation": ""
|
||||
}
|
||||
],
|
||||
"Config": {
|
||||
"Hostname": "12ca69229880",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": false,
|
||||
"AttachStderr": false,
|
||||
"ExposedPorts": {
|
||||
"443/tcp": {},
|
||||
"80/tcp": {}
|
||||
},
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"MY_VAR=uservalue",
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"NGINX_VERSION=1.31.4",
|
||||
"PKG_RELEASE=1",
|
||||
"DYNPKG_RELEASE=1",
|
||||
"NJS_VERSION=1.0.0",
|
||||
"NJS_RELEASE=1",
|
||||
"ACME_VERSION=0.4.1"
|
||||
],
|
||||
"Cmd": [
|
||||
"nginx",
|
||||
"-g",
|
||||
"daemon off;"
|
||||
],
|
||||
"Healthcheck": {
|
||||
"Test": [
|
||||
"CMD-SHELL",
|
||||
"curl -f http://localhost/ || exit 1"
|
||||
],
|
||||
"Interval": 30000000000,
|
||||
"Retries": 3
|
||||
},
|
||||
"Image": "nginx:alpine",
|
||||
"Volumes": null,
|
||||
"WorkingDir": "/",
|
||||
"Entrypoint": [
|
||||
"/docker-entrypoint.sh"
|
||||
],
|
||||
"Labels": {
|
||||
"maintainer": "NGINX Docker Maintainers <[email protected]>",
|
||||
"my.custom.label": "hello"
|
||||
},
|
||||
"StopSignal": "SIGQUIT"
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"SandboxID": "0e595e6e5fbab287f32ddbc517e23632e7bf5bf2003188c8c841aa2de5c24636",
|
||||
"SandboxKey": "/var/run/docker/netns/0e595e6e5fba",
|
||||
"Ports": {
|
||||
"443/tcp": [
|
||||
{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "8443"
|
||||
}
|
||||
],
|
||||
"80/tcp": [
|
||||
{
|
||||
"HostIp": "0.0.0.0",
|
||||
"HostPort": "8099"
|
||||
},
|
||||
{
|
||||
"HostIp": "::",
|
||||
"HostPort": "8099"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": null,
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "5dc6b41da3d9793cee90794c9df870e78db74d01374ff25779b18ff97486a1da",
|
||||
"EndpointID": "8a736e50be4fc3e44f34b43981d487a8661b70b18179c67c0ba3127e31d8a8ad",
|
||||
"Gateway": "192.168.215.1",
|
||||
"IPAddress": "192.168.215.3",
|
||||
"MacAddress": "56:d6:31:71:d5:06",
|
||||
"IPPrefixLen": 24,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageManifestDescriptor": {
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"digest": "sha256:57744b8fa99abc438b1fbde6bd69e4270d0984ccfdee60c661ec22243047373a",
|
||||
"size": 2497,
|
||||
"annotations": {
|
||||
"com.docker.official-images.bashbrew.arch": "arm64v8",
|
||||
"org.opencontainers.image.base.digest": "sha256:de4f26ec52b4ffb74405922a96a619bb691b70daf8565b0f87eacd74c69bb0ef",
|
||||
"org.opencontainers.image.base.name": "nginx:1.31.4-alpine-slim",
|
||||
"org.opencontainers.image.created": "2026-08-19T20:10:31Z",
|
||||
"org.opencontainers.image.revision": "b8590bd36b4504b9b847fcf2e98a9111dcae85fa",
|
||||
"org.opencontainers.image.source": "https://github.com/nginx/docker-nginx.git#b8590bd36b4504b9b847fcf2e98a9111dcae85fa:mainline/alpine",
|
||||
"org.opencontainers.image.url": "https://hub.docker.com/_/nginx",
|
||||
"org.opencontainers.image.version": "1.31.4-alpine"
|
||||
},
|
||||
"platform": {
|
||||
"architecture": "arm64",
|
||||
"os": "linux",
|
||||
"variant": "v8"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Unit tests for inspectToCompose - the container-inspect -> compose YAML mapper (#489).
|
||||
*
|
||||
* Uses REAL `docker inspect` payloads captured from running containers
|
||||
* (tests/fixtures/inspect/*.json), so the mapper is exercised against the exact shapes
|
||||
* Docker actually emits, not hand-written approximations. Assertions run on the re-parsed
|
||||
* YAML object so they are independent of formatting.
|
||||
*
|
||||
* Run with: bun test tests/inspect-to-compose.test.ts
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { inspectToCompose, inspectToComposeService, type DockerInspect } from '../src/lib/utils/inspect-to-compose';
|
||||
|
||||
function loadFixture(name: string): DockerInspect {
|
||||
const path = join(import.meta.dir, 'fixtures', 'inspect', name);
|
||||
const arr = JSON.parse(readFileSync(path, 'utf8')) as DockerInspect[];
|
||||
return arr[0];
|
||||
}
|
||||
|
||||
function parseCompose(inspect: DockerInspect, options = {}) {
|
||||
const yamlStr = inspectToCompose(inspect, options);
|
||||
return { yamlStr, doc: yaml.load(yamlStr) as any };
|
||||
}
|
||||
|
||||
describe('inspectToCompose - real gitea inspect (compose-managed, named-vol, custom net)', () => {
|
||||
const gitea = loadFixture('gitea.json');
|
||||
|
||||
test('service key is the clean container name', () => {
|
||||
const { doc } = parseCompose(gitea);
|
||||
expect(Object.keys(doc.services)).toEqual(['semver-demo-stack-gitea-1']);
|
||||
});
|
||||
|
||||
test('image + container_name + restart carry through', () => {
|
||||
const { doc } = parseCompose(gitea);
|
||||
const svc = doc.services['semver-demo-stack-gitea-1'];
|
||||
expect(svc.image).toBe('gitea/gitea:1.21.0');
|
||||
expect(svc.container_name).toBe('semver-demo-stack-gitea-1');
|
||||
expect(svc.restart).toBe('unless-stopped');
|
||||
});
|
||||
|
||||
test('the com.docker.compose.* labels are stripped', () => {
|
||||
const { yamlStr, doc } = parseCompose(gitea);
|
||||
expect(yamlStr).not.toContain('com.docker.compose');
|
||||
const svc = doc.services['semver-demo-stack-gitea-1'];
|
||||
if (svc.labels) {
|
||||
for (const key of Object.keys(svc.labels)) {
|
||||
expect(key.startsWith('com.docker.compose.')).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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']);
|
||||
expect(doc.networks['semver-demo-stack_default']).toEqual({ external: true });
|
||||
// bare bridge/default are never emitted
|
||||
expect(svc.network_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the anonymous (64-hex) volume becomes a bare anonymous mount, not a named one', () => {
|
||||
const { doc } = parseCompose(gitea);
|
||||
const svc = doc.services['semver-demo-stack-gitea-1'];
|
||||
// gitea's /data rides a 64-hex anonymous volume -> `- /data`, no hash name, no top-level volume
|
||||
expect(svc.volumes).toContain('/data');
|
||||
for (const v of svc.volumes as string[]) {
|
||||
expect(v).not.toMatch(/^[0-9a-f]{64}:/);
|
||||
}
|
||||
expect(doc.volumes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspectToCompose - real nginx inspect (published ports, bind :ro, named vol, healthcheck, caps, sysctl)', () => {
|
||||
const nginx = loadFixture('nginx-rich.json');
|
||||
const svcName = 'itc-fixture';
|
||||
|
||||
test('published ports map to host:container, respecting a bound host IP', () => {
|
||||
const { doc } = parseCompose(nginx);
|
||||
const svc = doc.services[svcName];
|
||||
expect(svc.ports).toContain('8099:80');
|
||||
expect(svc.ports).toContain('127.0.0.1:8443:443');
|
||||
});
|
||||
|
||||
test('bind mount is source:dest with :ro; named volume is name:dest + top-level external', () => {
|
||||
const { doc } = parseCompose(nginx);
|
||||
const svc = doc.services[svcName];
|
||||
expect(svc.volumes).toContain('/tmp/itc-html:/usr/share/nginx/html:ro');
|
||||
expect(svc.volumes).toContain('itc-named:/var/cache/nginx');
|
||||
expect(doc.volumes['itc-named']).toEqual({ external: true });
|
||||
});
|
||||
|
||||
test('restart on-failure keeps its retry count', () => {
|
||||
const { doc } = parseCompose(nginx);
|
||||
expect(doc.services[svcName].restart).toBe('on-failure:5');
|
||||
});
|
||||
|
||||
test('cap_add/cap_drop and sysctls carry through', () => {
|
||||
const { doc } = parseCompose(nginx);
|
||||
const svc = doc.services[svcName];
|
||||
expect(svc.cap_add).toContain('CAP_NET_ADMIN');
|
||||
expect(svc.cap_drop).toContain('CAP_MKNOD');
|
||||
expect(svc.sysctls['net.core.somaxconn']).toBe('1024');
|
||||
});
|
||||
|
||||
test('healthcheck maps Test + interval(ns->s) + retries', () => {
|
||||
const { doc } = parseCompose(nginx);
|
||||
const hc = doc.services[svcName].healthcheck;
|
||||
expect(hc.test).toEqual(['CMD-SHELL', 'curl -f http://localhost/ || exit 1']);
|
||||
expect(hc.interval).toBe('30s');
|
||||
expect(hc.retries).toBe(3);
|
||||
});
|
||||
|
||||
test('custom label survives', () => {
|
||||
const { doc } = parseCompose(nginx);
|
||||
expect(doc.services[svcName].labels['my.custom.label']).toBe('hello');
|
||||
});
|
||||
|
||||
test('image-baked labels (maintainer) are dropped when imageLabels is provided; user label stays', () => {
|
||||
// The image sets maintainer; the container adds my.custom.label. With the image labels
|
||||
// passed, only the user-added one survives.
|
||||
const imageLabels = { maintainer: 'NGINX Docker Maintainers <[email protected]>' };
|
||||
const { doc } = parseCompose(nginx, { imageLabels });
|
||||
const labels = doc.services[svcName].labels;
|
||||
expect(labels.maintainer).toBeUndefined();
|
||||
expect(labels['my.custom.label']).toBe('hello');
|
||||
});
|
||||
|
||||
test('auto-generated noise (hostname, working_dir /, shm_size) 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.
|
||||
expect(svc.hostname).toBeUndefined();
|
||||
expect(svc.working_dir).toBeUndefined();
|
||||
expect(svc.shm_size).toBeUndefined();
|
||||
});
|
||||
|
||||
test('image-default entrypoint/command are dropped when the image values are provided', () => {
|
||||
const imageEntrypoint = nginx.Config?.Entrypoint ?? null;
|
||||
const imageCmd = nginx.Config?.Cmd ?? null;
|
||||
const { doc } = parseCompose(nginx, { imageEntrypoint, imageCmd });
|
||||
const svc = doc.services[svcName];
|
||||
// nginx's entrypoint+cmd come from the image, so with the image passed they vanish.
|
||||
expect(svc.entrypoint).toBeUndefined();
|
||||
expect(svc.command).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a user-overridden command is KEPT even when the image command is provided', () => {
|
||||
const custom = structuredClone(nginx);
|
||||
custom.Config!.Cmd = ['nginx', '-g', 'daemon off; worker_processes 4;'];
|
||||
const { doc } = parseCompose(custom, { imageCmd: nginx.Config?.Cmd ?? null });
|
||||
expect(doc.services[svcName].command).toEqual(['nginx', '-g', 'daemon off; worker_processes 4;']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspectToCompose - image-env subtraction', () => {
|
||||
const nginx = loadFixture('nginx-rich.json');
|
||||
const svcName = 'itc-fixture';
|
||||
|
||||
test('without imageEnv, all env (incl. inherited PATH) is kept', () => {
|
||||
const { doc } = parseCompose(nginx);
|
||||
const env = (doc.services[svcName].environment as string[]) ?? [];
|
||||
expect(env.some((e) => e.startsWith('PATH='))).toBe(true);
|
||||
expect(env).toContain('MY_VAR=uservalue');
|
||||
});
|
||||
|
||||
test('with imageEnv, inherited vars are dropped and only user-set env remains', () => {
|
||||
const imageEnv = (nginx.Config?.Env ?? []).filter((e) => !e.startsWith('MY_VAR='));
|
||||
const { doc } = parseCompose(nginx, { imageEnv });
|
||||
const env = (doc.services[svcName].environment as string[]) ?? [];
|
||||
expect(env).toEqual(['MY_VAR=uservalue']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspectToCompose - default-noise filtering', () => {
|
||||
test('an explicit restart "no" and a bare bridge network produce no restart/network keys', () => {
|
||||
const minimal: DockerInspect = {
|
||||
Name: '/plain',
|
||||
Config: { Image: 'busybox:latest' },
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'no', MaximumRetryCount: 0 },
|
||||
NetworkMode: 'bridge',
|
||||
LogConfig: { Type: 'json-file', Config: {} }
|
||||
},
|
||||
NetworkSettings: { Networks: { bridge: {} } }
|
||||
};
|
||||
const { doc } = parseCompose(minimal);
|
||||
const svc = doc.services['plain'];
|
||||
expect(svc.restart).toBeUndefined();
|
||||
expect(svc.networks).toBeUndefined();
|
||||
expect(svc.network_mode).toBeUndefined();
|
||||
expect(svc.logging).toBeUndefined();
|
||||
expect(svc.image).toBe('busybox:latest');
|
||||
});
|
||||
|
||||
test('serviceName option overrides the container name', () => {
|
||||
const { name } = inspectToComposeService({ Name: '/original', Config: { Image: 'x' } }, { serviceName: 'renamed' });
|
||||
expect(name).toBe('renamed');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user