From 28cbd1d9c2b9ce0337e60dc5e8bb3a9a3ac57d9d Mon Sep 17 00:00:00 2001 From: Gary Date: Wed, 21 Oct 2026 10:30:02 +0200 Subject: [PATCH] fix(compose): the editor opens a service whose devices or environment are not plain strings --- src/components/Apps/ComposeConfig.spec.js | 137 ++++++++++++++++++++++ src/components/Apps/ComposeConfig.vue | 60 +++++++--- 2 files changed, 182 insertions(+), 15 deletions(-) diff --git a/src/components/Apps/ComposeConfig.spec.js b/src/components/Apps/ComposeConfig.spec.js index d74a4f34..91c9e458 100644 --- a/src/components/Apps/ComposeConfig.spec.js +++ b/src/components/Apps/ComposeConfig.spec.js @@ -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) + }) +}) diff --git a/src/components/Apps/ComposeConfig.vue b/src/components/Apps/ComposeConfig.vue index a8dcec75..fded1c56 100644 --- a/src/components/Apps/ComposeConfig.vue +++ b/src/components/Apps/ComposeConfig.vue @@ -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', @@ -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 = [] } @@ -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 @@ -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) => {