mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
fix: parse LSIO volume :ro suffixes for App Store deploys (#1557)
LSIO encodes read-only mounts in volume path (e.g. /var/log:ro). Map host_path and optional correctly so fail2ban and similar templates generate valid compose specs. Fixes #1554
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import YAML from 'yaml';
|
||||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||||
|
|
||||||
vi.mock('axios', () => ({
|
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!.env).toEqual([{ name: 'PUID', label: 'User ID', default: '1000' }]);
|
||||||
expect(plex!.categories).toEqual(['Media']);
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,6 +84,29 @@ describe('TemplateService', () => {
|
|||||||
expect(svc.volumes).toEqual(['./config:/config:ro']);
|
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', () => {
|
it('includes env_file only when env vars are present', () => {
|
||||||
const withEnv = serviceOf(service.generateComposeFromTemplate({
|
const withEnv = serviceOf(service.generateComposeFromTemplate({
|
||||||
title: 'app', description: 'Test', image: 'test:latest',
|
title: 'app', description: 'Test', image: 'test:latest',
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ interface ComposeServiceDefinition {
|
|||||||
|
|
||||||
// Typed shapes for the LinuxServer.io API response
|
// Typed shapes for the LinuxServer.io API response
|
||||||
interface LsioPort { external?: number; internal: number; protocol?: string }
|
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 LsioEnvVar { name: string; desc?: string; default?: string }
|
||||||
interface LsioAppConfig { ports?: LsioPort[]; volumes?: LsioVolume[]; environment?: LsioEnvVar[] }
|
interface LsioAppConfig { ports?: LsioPort[]; volumes?: LsioVolume[]; environment?: LsioEnvVar[] }
|
||||||
interface LsioApp {
|
interface LsioApp {
|
||||||
@@ -223,6 +228,45 @@ function getCategoriesForApp(name: string): string[] {
|
|||||||
return LSIO_CATEGORY_MAP[name.toLowerCase()] ?? ['Other'];
|
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 {
|
export class TemplateService {
|
||||||
private static readonly CACHE_KEY = 'templates:all';
|
private static readonly CACHE_KEY = 'templates:all';
|
||||||
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
@@ -280,13 +324,9 @@ export class TemplateService {
|
|||||||
source: 'linuxserver',
|
source: 'linuxserver',
|
||||||
// Map configs if available, otherwise default to empty arrays
|
// 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'}`),
|
ports: (app.config?.ports ?? []).map((p: LsioPort) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`),
|
||||||
volumes: (app.config?.volumes ?? []).map((v: LsioVolume) => {
|
volumes: (app.config?.volumes ?? [])
|
||||||
const folderName = v.path.split('/').filter(Boolean).pop() || 'data';
|
.map((v: LsioVolume) => mapLsioVolume(v))
|
||||||
return {
|
.filter((v): v is TemplateVolume => v !== null),
|
||||||
container: v.path,
|
|
||||||
bind: `./${folderName}`
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
env: (app.config?.environment ?? []).map((e: LsioEnvVar) => ({
|
env: (app.config?.environment ?? []).map((e: LsioEnvVar) => ({
|
||||||
name: e.name,
|
name: e.name,
|
||||||
label: e.desc || e.name,
|
label: e.desc || e.name,
|
||||||
@@ -343,10 +383,9 @@ export class TemplateService {
|
|||||||
// handles any escaping the raw value needs.
|
// handles any escaping the raw value needs.
|
||||||
volumes.push(vol);
|
volumes.push(vol);
|
||||||
} else if (vol.container) {
|
} else if (vol.container) {
|
||||||
const containerPath = vol.container;
|
const { container: containerPath, readonly: pathReadonly } = parseLsioVolumePath(vol.container);
|
||||||
const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data';
|
const hostPath = stripErroneousBindMode(vol.bind ?? defaultBindForLsioVolume(containerPath));
|
||||||
const hostPath = vol.bind ? vol.bind : `./${containerFolder}`;
|
const options = (vol.readonly === true || pathReadonly) ? ':ro' : '';
|
||||||
const options = vol.readonly ? ':ro' : '';
|
|
||||||
volumes.push(`${hostPath}:${containerPath}${options}`);
|
volumes.push(`${hostPath}:${containerPath}${options}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user