Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/components/Apps/ComposeConfig.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,140 @@ describe('composeConfig service built from a Dockerfile', () => {
expect(out.services.redis).not.toHaveProperty('build')
})
})

// A Plex service asks for hardware transcoding: devices and environment come in every
// shape the compose spec allows, and none of them may stop the editor from opening.
describe('composeConfig service with non-string devices and environment', () => {
const plexYaml = body => `name: plex
services:
plex:
image: plexinc/pms-docker:latest
${body}`

it.each([
['a host:container string', '/dev/dri:/dev/dri', { host: '/dev/dri', container: '/dev/dri' }],
['a path without colon', '/dev/dri', { host: '/dev/dri', container: '/dev/dri' }],
['a string with permissions', '/dev/dri:/dev/dri:rwm', { host: '/dev/dri', container: '/dev/dri', permissions: 'rwm' }],
])('reads %s as a device', (_label, device, expected) => {
const model = editorModel(plexYaml(` devices:\n - ${device}\n`))
expect(model.services.plex.devices).toEqual([expected])
})

it('reads an object device and a number device', () => {
const model = editorModel(plexYaml(` devices:
- source: /dev/dri
target: /dev/dri
- path_on_host: /dev/a
path_in_container: /dev/b
- 12
`))
expect(model.services.plex.devices).toEqual([
{ host: '/dev/dri', container: '/dev/dri' },
{ host: '/dev/a', container: '/dev/b' },
{ host: '12', container: '12' },
])
})

it('keeps non-default device permissions through a round trip, and writes rwm short', () => {
const text = plexYaml(` devices:
- /dev/dri:/dev/dri:r
- /dev/a:/dev/a:rwm
- /dev/snd
`)
expect(emitYaml(editorModel(text), text).services.plex.devices).toEqual(['/dev/dri:/dev/dri:r', '/dev/a:/dev/a', '/dev/snd:/dev/snd'])
})

// What the compose library stores: the normalised form, every list in long syntax.
const storedPlex = `name: plex
services:
plex:
image: lscr.io/linuxserver/plex:latest
network_mode: host
cpu_shares: 90
command: []
environment:
PUID: "1000"
TZ: Europe/Paris
devices:
- source: /dev/dri
target: /dev/dri
permissions: rwm
- source: /dev/snd
target: /dev/snd
permissions: r
ports:
- mode: ingress
target: 32400
published: "32400"
protocol: tcp
volumes:
- type: bind
source: /DATA/AppData/plex/config
target: /config
deploy:
resources:
limits:
memory: "16773021696"
x-casaos:
title:
en_us: Plex
`

it('opens the stored plex service and saves it without loss', () => {
const model = editorModel(storedPlex)
expect(model.services.plex.devices).toEqual([
{ host: '/dev/dri', container: '/dev/dri', permissions: 'rwm' },
{ host: '/dev/snd', container: '/dev/snd', permissions: 'r' },
])
const out = emitYaml(model, storedPlex).services.plex
expect(out.devices).toEqual(['/dev/dri:/dev/dri', '/dev/snd:/dev/snd:r'])
expect(out.environment).toEqual(['PUID=1000', 'TZ=Europe/Paris'])
expect(out.network_mode).toBe('host')
expect(out.ports).toHaveLength(1)
expect(out.ports[0]).toMatchObject({ target: 32400, published: '32400', protocol: 'tcp' })
expect(out.volumes).toHaveLength(1)
expect(out.volumes[0]).toMatchObject({ type: 'bind', source: '/DATA/AppData/plex/config', target: '/config' })
})

it('reads an environment list of strings, bare keys and numbers', () => {
const model = editorModel(plexYaml(' environment:\n - TZ=Europe/Paris\n - PLEX_CLAIM\n - 42\n - "A=b=c"\n'))
expect(model.services.plex.environment).toEqual([
{ container: 'TZ', host: 'Europe/Paris' },
{ container: 'PLEX_CLAIM', host: '' },
{ container: '42', host: '' },
{ container: 'A', host: 'b=c' },
])
})

it('reads an environment map of numbers, booleans, null and objects', () => {
const model = editorModel(plexYaml(' environment:\n PLEX_UID: 1000\n ADVERTISE_IP: null\n X: true\n O:\n a: 1\n Q: "\\"quoted\\""\n'))
expect(model.services.plex.environment).toEqual([
{ container: 'PLEX_UID', host: '1000' },
{ container: 'ADVERTISE_IP', host: '' },
{ container: 'X', host: 'true' },
{ container: 'O', host: '{a:1}' },
{ container: 'Q', host: 'quoted' },
])
})

it('never writes the string null for a null value', () => {
const text = plexYaml(' environment:\n PLEX_UID: 1000\n ADVERTISE_IP: null\n')
expect(emitYaml(editorModel(text), text).services.plex.environment).toEqual(['PLEX_UID=1000', 'ADVERTISE_IP='])
})

it('reads ports as numbers, strings and objects, and volumes in long syntax', () => {
const model = editorModel(plexYaml(` ports:
- 32400
- "1900:1900/udp"
- target: 8324
published: 8324
protocol: tcp
volumes:
- type: bind
source: /DATA/AppData/plex/config
target: /config
`))
expect(model.services.plex.ports).toHaveLength(3)
expect(model.services.plex.volumes).toHaveLength(1)
})
})
60 changes: 45 additions & 15 deletions src/components/Apps/ComposeConfig.vue
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,48 @@ import { normalizeVolume } from './composeVolumes'
import { ice_i18n } from '@/mixins/base/common-i18n'
import VolumesInputGroup from '@/components/forms/VolumesInputGroup.vue'

// Compose lets an environment entry be 'KEY=value', a bare 'KEY', a number, or a [key, value]
// pair out of the map form whose value may be a number, boolean, null or object. The form
// wants two strings, so everything that is not one is stringified (null is empty, never 'null').
function scalarToString(value) {
if (isNil(value))
return ''
return typeof value === 'object' ? JSON.stringify(value) : String(value)
}

function parseEnvItem(item) {
let key
let value
if (Array.isArray(item)) {
[key, value] = item
} else if (typeof item === 'string') {
const at = item.indexOf('=')
key = at === -1 ? item : item.slice(0, at)
value = at === -1 ? '' : item.slice(at + 1)
} else {
key = item
}
return {
host: scalarToString(value).replace(/"/g, ''),
container: scalarToString(key),
}
}

// A device is 'host', 'host:container' or 'host:container:permissions', or (in tooling that
// writes the long syntax) an object naming source/target or path_on_host/path_in_container.
function parseDeviceItem(item) {
if (isObject(item)) {
const host = scalarToString(item.source ?? item.path_on_host)
const permissions = scalarToString(item.permissions)
const device = { host, container: scalarToString(item.target ?? item.path_in_container) || host }
return permissions ? { ...device, permissions } : device
}
const [host, container, permissions] = scalarToString(item).split(':')
return permissions
? { host, container, permissions }
: { host, container: container || host }
}

const data = [
'AUDIT_CONTROL',
'AUDIT_READ',
Expand Down Expand Up @@ -562,13 +604,7 @@ export default {
const envArray = Array.isArray(composeServicesItemInput.environment)
? composeServicesItemInput.environment
: Object.entries(composeServicesItemInput.environment)
composeServicesItem.environment = envArray.map((item) => {
const ii = typeof item === 'object' ? Array.from(item) : item.split('=')
return {
host: ii[1].replace(/"/g, ''),
container: ii[0],
}
})
composeServicesItem.environment = envArray.map(parseEnvItem)
} else {
composeServicesItem.environment = []
}
Expand Down Expand Up @@ -632,13 +668,7 @@ export default {
isNil(composeServicesItem.volumes) && (composeServicesItem.volumes = [])

// Devices
composeServicesItem.devices = this.makeArray(composeServicesItemInput.devices).map((item) => {
const ii = item.split(':')
return {
container: ii[1],
host: ii[0],
}
})
composeServicesItem.devices = this.makeArray(composeServicesItemInput.devices).map(parseDeviceItem)
isNil(composeServicesItem.devices) && (composeServicesItem.devices = [])

// Network_mode
Expand Down Expand Up @@ -806,7 +836,7 @@ export default {
return false
})
.map((device) => {
return `${device.host}:${device.container}`
return `${device.host}:${device.container}${device.permissions && device.permissions !== 'rwm' ? `:${device.permissions}` : ''}`
})
outputService.environment = service.environment
.filter((env) => {
Expand Down
Loading