189 lines
4.7 KiB
JavaScript
189 lines
4.7 KiB
JavaScript
import test from 'brittle'
|
|
import {
|
|
parseComposeFile,
|
|
validateComposeFile,
|
|
removeComposeStack,
|
|
} from '../server/utils/composeManager.js'
|
|
|
|
/**
|
|
* Minimal dockerode-shaped mock for removeComposeStack.
|
|
* @param {{ containers?: object[], networks?: object[] }} seed
|
|
*/
|
|
function mockDocker(seed = {}) {
|
|
const containers = [...(seed.containers || [])]
|
|
const networks = [...(seed.networks || [])]
|
|
const removedNetworks = []
|
|
const removedContainers = []
|
|
|
|
return {
|
|
removedNetworks,
|
|
removedContainers,
|
|
listContainers: async () => containers,
|
|
listNetworks: async () => networks,
|
|
getContainer: (id) => ({
|
|
stop: async () => {},
|
|
remove: async () => {
|
|
removedContainers.push(id)
|
|
const i = containers.findIndex((c) => c.Id === id)
|
|
if (i >= 0) containers.splice(i, 1)
|
|
},
|
|
}),
|
|
getNetwork: (id) => ({
|
|
remove: async () => {
|
|
const n = networks.find((x) => x.Id === id)
|
|
if (!n) throw new Error(`network ${id} not found`)
|
|
removedNetworks.push({ id, name: n.Name })
|
|
const i = networks.findIndex((x) => x.Id === id)
|
|
if (i >= 0) networks.splice(i, 1)
|
|
},
|
|
}),
|
|
}
|
|
}
|
|
|
|
const SAMPLE = `
|
|
version: "3.8"
|
|
services:
|
|
web:
|
|
image: nginx:alpine
|
|
ports:
|
|
- "8080:80"
|
|
environment:
|
|
FOO: bar
|
|
BAZ: "1"
|
|
volumes:
|
|
- ./data:/data:ro
|
|
depends_on:
|
|
- db
|
|
restart: unless-stopped
|
|
db:
|
|
image: postgres:16
|
|
environment:
|
|
- POSTGRES_PASSWORD=secret
|
|
networks:
|
|
- backend
|
|
networks:
|
|
backend:
|
|
volumes:
|
|
pgdata:
|
|
`
|
|
|
|
test('parseComposeFile uses js-yaml and normalizes services', (t) => {
|
|
const parsed = parseComposeFile(SAMPLE)
|
|
t.is(parsed.version, '3.8')
|
|
t.ok(parsed.services.web)
|
|
t.ok(parsed.services.db)
|
|
t.is(parsed.services.web.image, 'nginx:alpine')
|
|
t.alike(parsed.services.web.ports, ['8080:80'])
|
|
t.ok(parsed.services.web.environment.includes('FOO=bar'))
|
|
t.ok(parsed.services.web.depends_on.includes('db'))
|
|
t.is(parsed.services.web.restart, 'unless-stopped')
|
|
t.ok(parsed.services.db.environment.some((e) => e.includes('POSTGRES_PASSWORD')))
|
|
t.ok(Object.prototype.hasOwnProperty.call(parsed.networks, 'backend'))
|
|
})
|
|
|
|
test('parseComposeFile rejects invalid YAML', (t) => {
|
|
try {
|
|
parseComposeFile('services: [')
|
|
t.fail('should throw')
|
|
} catch (err) {
|
|
t.ok(/Invalid compose YAML/i.test(err.message))
|
|
}
|
|
})
|
|
|
|
test('parseComposeFile requires services mapping', (t) => {
|
|
try {
|
|
parseComposeFile('version: "3"\n')
|
|
t.fail('should throw')
|
|
} catch (err) {
|
|
t.ok(/services/i.test(err.message))
|
|
}
|
|
})
|
|
|
|
test('validateComposeFile requires image or build', (t) => {
|
|
try {
|
|
validateComposeFile(`
|
|
services:
|
|
bad:
|
|
command: echo hi
|
|
`)
|
|
t.fail('should throw')
|
|
} catch (err) {
|
|
t.ok(/image or build/i.test(err.message))
|
|
}
|
|
})
|
|
|
|
test('validateComposeFile accepts build-only service', (t) => {
|
|
const parsed = validateComposeFile(`
|
|
services:
|
|
app:
|
|
build: .
|
|
`)
|
|
t.ok(parsed.services.app.build)
|
|
})
|
|
|
|
test('removeComposeStack removes project networks after containers', async (t) => {
|
|
const docker = mockDocker({
|
|
containers: [
|
|
{
|
|
Id: 'c1',
|
|
State: 'running',
|
|
Labels: { 'com.docker.compose.project': 'mystack' },
|
|
},
|
|
],
|
|
networks: [
|
|
{
|
|
Id: 'n1',
|
|
Name: 'mystack_default',
|
|
Labels: { 'com.docker.compose.project': 'mystack' },
|
|
},
|
|
{
|
|
Id: 'n2',
|
|
Name: 'other_default',
|
|
Labels: { 'com.docker.compose.project': 'other' },
|
|
},
|
|
{
|
|
Id: 'bridge',
|
|
Name: 'bridge',
|
|
Labels: {},
|
|
},
|
|
],
|
|
})
|
|
|
|
const result = await removeComposeStack(docker, 'mystack')
|
|
t.is(result.removed, 1)
|
|
t.is(result.networksRemoved, 1)
|
|
t.alike(docker.removedContainers, ['c1'])
|
|
t.is(docker.removedNetworks.length, 1)
|
|
t.is(docker.removedNetworks[0].name, 'mystack_default')
|
|
})
|
|
|
|
test('removeComposeStack cleans orphaned project networks with no containers', async (t) => {
|
|
const docker = mockDocker({
|
|
containers: [],
|
|
networks: [
|
|
{
|
|
Id: 'n1',
|
|
Name: 'orphan_backend',
|
|
Labels: { 'com.docker.compose.project': 'orphan' },
|
|
},
|
|
],
|
|
})
|
|
|
|
const result = await removeComposeStack(docker, 'orphan')
|
|
t.is(result.removed, 0)
|
|
t.is(result.networksRemoved, 1)
|
|
t.is(docker.removedNetworks[0].name, 'orphan_backend')
|
|
})
|
|
|
|
test('removeComposeStack reports already removed when nothing matches', async (t) => {
|
|
const docker = mockDocker({
|
|
containers: [],
|
|
networks: [{ Id: 'bridge', Name: 'bridge', Labels: {} }],
|
|
})
|
|
|
|
const result = await removeComposeStack(docker, 'missing')
|
|
t.is(result.removed, 0)
|
|
t.is(result.networksRemoved, 0)
|
|
t.ok(/not found|already removed/i.test(result.message))
|
|
})
|