diff --git a/backend/src/__tests__/template-service-registry.test.ts b/backend/src/__tests__/template-service-registry.test.ts index 99598d70..9bc34e01 100644 --- a/backend/src/__tests__/template-service-registry.test.ts +++ b/backend/src/__tests__/template-service-registry.test.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import axios from 'axios'; +import YAML from 'yaml'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; vi.mock('axios', () => ({ @@ -103,4 +104,80 @@ describe('TemplateService.getTemplates registry size cap', () => { expect(plex!.env).toEqual([{ name: 'PUID', label: 'User ID', default: '1000' }]); expect(plex!.categories).toEqual(['Media']); }); + + it('maps LSIO :ro volume paths and skips optional volumes (fail2ban)', async () => { + const service = new TemplateService(); + service.clearCache(); + mockedGet.mockResolvedValueOnce({ + data: { + data: { + repositories: { + linuxserver: { + fail2ban: { + name: 'fail2ban', + description: 'Ban IPs', + config: { + volumes: [ + { path: '/config', host_path: '/path/to/fail2ban/config', optional: false }, + { path: '/var/log:ro', host_path: '/var/log', optional: false }, + { + path: '/remotelogs/nginx:ro', + host_path: '/path/to/nginx/log', + optional: true, + }, + ], + }, + }, + }, + }, + }, + }, + }); + + const templates = await service.getTemplates(); + const fail2ban = templates.find(t => t.title === 'fail2ban')!; + expect(fail2ban.volumes).toEqual([ + { container: '/config', bind: './config' }, + { container: '/var/log', bind: '/var/log', readonly: true }, + ]); + + const yaml = service.generateComposeFromTemplate(fail2ban, 'fail2ban'); + const parsed = YAML.parse(yaml) as { services: { fail2ban: { volumes: string[] } } }; + expect(parsed.services.fail2ban.volumes).toEqual([ + './config:/config', + '/var/log:/var/log:ro', + ]); + }); + + it('maps LSIO :ro volume with placeholder host_path (mame)', async () => { + const service = new TemplateService(); + service.clearCache(); + mockedGet.mockResolvedValueOnce({ + data: { + data: { + repositories: { + linuxserver: { + mame: { + name: 'mame', + description: 'MAME', + config: { + volumes: [ + { path: '/config', host_path: '/path/to/config', optional: false }, + { path: '/mame:ro', host_path: '/path/to/mame/assets', optional: false }, + ], + }, + }, + }, + }, + }, + }, + }); + + const templates = await service.getTemplates(); + const mame = templates.find(t => t.title === 'mame'); + expect(mame!.volumes).toEqual([ + { container: '/config', bind: './config' }, + { container: '/mame', bind: './mame', readonly: true }, + ]); + }); }); diff --git a/backend/src/__tests__/template-service.test.ts b/backend/src/__tests__/template-service.test.ts index 263324bc..ea082984 100644 --- a/backend/src/__tests__/template-service.test.ts +++ b/backend/src/__tests__/template-service.test.ts @@ -84,6 +84,29 @@ describe('TemplateService', () => { expect(svc.volumes).toEqual(['./config:/config:ro']); }); + it('generates valid compose for fail2ban-shaped template volumes', () => { + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'fail2ban', + description: 'Ban IPs', + image: 'lscr.io/linuxserver/fail2ban:latest', + volumes: [ + { container: '/config', bind: './config' }, + { container: '/var/log', bind: '/var/log', readonly: true }, + ], + }, 'fail2ban'), 'fail2ban'); + expect(svc.volumes).toEqual(['./config:/config', '/var/log:/var/log:ro']); + }); + + it('repairs stale LSIO volume paths that still embed :ro in container and bind', () => { + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'fail2ban', + description: 'Ban IPs', + image: 'lscr.io/linuxserver/fail2ban:latest', + volumes: [{ container: '/var/log:ro', bind: './log:ro' }], + }, 'fail2ban'), 'fail2ban'); + expect(svc.volumes).toEqual(['./log:/var/log:ro']); + }); + it('includes env_file only when env vars are present', () => { const withEnv = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts index 1c60f021..ba03f326 100644 --- a/backend/src/services/TemplateService.ts +++ b/backend/src/services/TemplateService.ts @@ -57,7 +57,12 @@ interface ComposeServiceDefinition { // Typed shapes for the LinuxServer.io API response interface LsioPort { external?: number; internal: number; protocol?: string } -interface LsioVolume { path: string } +interface LsioVolume { + path: string; + host_path?: string; + desc?: string; + optional?: boolean; +} interface LsioEnvVar { name: string; desc?: string; default?: string } interface LsioAppConfig { ports?: LsioPort[]; volumes?: LsioVolume[]; environment?: LsioEnvVar[] } interface LsioApp { @@ -223,6 +228,45 @@ function getCategoriesForApp(name: string): string[] { return LSIO_CATEGORY_MAP[name.toLowerCase()] ?? ['Other']; } +function lastPathSegment(path: string): string { + return path.split('/').filter(Boolean).pop() || 'data'; +} + +function parseLsioVolumePath(path: string): { container: string; readonly: boolean } { + const match = path.match(/:(ro|rw)$/); + if (match) { + return { container: path.slice(0, -match[0].length), readonly: match[1] === 'ro' }; + } + return { container: path, readonly: false }; +} + +function defaultBindForLsioVolume(containerPath: string, hostPath?: string): string { + if (hostPath && !hostPath.startsWith('/path/to/')) { + return hostPath; + } + return `./${lastPathSegment(containerPath)}`; +} + +function stripErroneousBindMode(bind: string): string { + if (/^[a-zA-Z]:/.test(bind)) { + return bind; + } + const match = bind.match(/^(.*):(ro|rw)$/); + return match ? match[1] : bind; +} + +function mapLsioVolume(v: LsioVolume): TemplateVolume | null { + if (v.optional) { + return null; + } + const { container, readonly } = parseLsioVolumePath(v.path); + return { + container, + bind: defaultBindForLsioVolume(container, v.host_path), + ...(readonly ? { readonly: true } : {}), + }; +} + export class TemplateService { private static readonly CACHE_KEY = 'templates:all'; private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -280,13 +324,9 @@ export class TemplateService { source: 'linuxserver', // Map configs if available, otherwise default to empty arrays ports: (app.config?.ports ?? []).map((p: LsioPort) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`), - volumes: (app.config?.volumes ?? []).map((v: LsioVolume) => { - const folderName = v.path.split('/').filter(Boolean).pop() || 'data'; - return { - container: v.path, - bind: `./${folderName}` - }; - }), + volumes: (app.config?.volumes ?? []) + .map((v: LsioVolume) => mapLsioVolume(v)) + .filter((v): v is TemplateVolume => v !== null), env: (app.config?.environment ?? []).map((e: LsioEnvVar) => ({ name: e.name, label: e.desc || e.name, @@ -343,10 +383,9 @@ export class TemplateService { // handles any escaping the raw value needs. volumes.push(vol); } else if (vol.container) { - const containerPath = vol.container; - const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data'; - const hostPath = vol.bind ? vol.bind : `./${containerFolder}`; - const options = vol.readonly ? ':ro' : ''; + const { container: containerPath, readonly: pathReadonly } = parseLsioVolumePath(vol.container); + const hostPath = stripErroneousBindMode(vol.bind ?? defaultBindForLsioVolume(containerPath)); + const options = (vol.readonly === true || pathReadonly) ? ':ro' : ''; volumes.push(`${hostPath}:${containerPath}${options}`); } }