mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
fix(app-store): harden template deploy, registry fetch, and catalogue refresh (#1250)
* fix(app-store): harden template deploy, registry fetch, and catalogue refresh Serialize generated compose through the YAML emitter so registry-supplied values are escaped correctly instead of interpolated into hand-built lines. Cap the registry response size so an oversized or runaway catalogue cannot exhaust backend memory, and surface the fetch failure to the caller. Reload the catalogue when the active node changes, since the registry is node-scoped. Add developer-mode deploy diagnostics (counts only, no values) and extend the unit tests with YAML round-trip, LinuxServer.io mapping, and size-cap coverage. * fix(app-store): reset node-scoped catalogue state on fetch and bound deploy diagnostics Clear the templates list and Trivy availability at the start of each catalogue load so a failed fetch after a node switch shows the new node's empty state instead of the previous node's catalogue or scan toggle. Bound the developer-mode diagnostic template title/source length, and document that the registry cache serves the last-known-good catalogue on a transient fetch failure (the size cap still protects memory in every case).
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import axios, { type AxiosRequestConfig } from 'axios';
|
||||
import YAML from 'yaml';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { CacheService } from './CacheService';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -43,6 +44,17 @@ interface TemplatesResponse {
|
||||
templates: Template[];
|
||||
}
|
||||
|
||||
// Shape of a single compose service block emitted for a deployed template.
|
||||
// restart is always set; the other fields appear only when the template
|
||||
// supplies them.
|
||||
interface ComposeServiceDefinition {
|
||||
image?: string;
|
||||
restart: string;
|
||||
ports?: string[];
|
||||
volumes?: string[];
|
||||
env_file?: string[];
|
||||
}
|
||||
|
||||
// Typed shapes for the LinuxServer.io API response
|
||||
interface LsioPort { external?: number; internal: number; protocol?: string }
|
||||
interface LsioVolume { path: string }
|
||||
@@ -214,6 +226,14 @@ function getCategoriesForApp(name: string): string[] {
|
||||
export class TemplateService {
|
||||
private static readonly CACHE_KEY = 'templates:all';
|
||||
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
// Cap the registry response so a large or compromised custom registry
|
||||
// cannot exhaust backend memory by streaming an unbounded body.
|
||||
private static readonly MAX_REGISTRY_RESPONSE_BYTES = 25 * 1024 * 1024;
|
||||
private static readonly REGISTRY_FETCH_OPTIONS = {
|
||||
timeout: 20_000,
|
||||
maxContentLength: TemplateService.MAX_REGISTRY_RESPONSE_BYTES,
|
||||
maxBodyLength: TemplateService.MAX_REGISTRY_RESPONSE_BYTES,
|
||||
} satisfies AxiosRequestConfig;
|
||||
|
||||
public clearCache(): void {
|
||||
CacheService.getInstance().invalidate(TemplateService.CACHE_KEY);
|
||||
@@ -222,6 +242,12 @@ export class TemplateService {
|
||||
|
||||
public async getTemplates(): Promise<Template[]> {
|
||||
try {
|
||||
// getOrFetch serves the last-known-good catalogue when the fetcher
|
||||
// rejects and a (now-expired) cache entry exists, so the mapped
|
||||
// errors below surface only on a cold or freshly cleared cache.
|
||||
// That is deliberate: a transient registry failure keeps the
|
||||
// catalogue usable. The response size cap still protects memory in
|
||||
// every case, since axios aborts before buffering the full body.
|
||||
return await CacheService.getInstance().getOrFetch<Template[]>(
|
||||
TemplateService.CACHE_KEY,
|
||||
this.CACHE_DURATION_MS,
|
||||
@@ -236,7 +262,7 @@ export class TemplateService {
|
||||
let registryHost = '';
|
||||
try { registryHost = new URL(registryUrl).hostname.toLowerCase(); } catch { /* invalid URL, treated as non-LSIO */ }
|
||||
if (registryHost === 'api.linuxserver.io') {
|
||||
const response = await axios.get<LsioApiResponse>(registryUrl, { timeout: 20_000 });
|
||||
const response = await axios.get<LsioApiResponse>(registryUrl, TemplateService.REGISTRY_FETCH_OPTIONS);
|
||||
// Official LSIO API Schema Mapping
|
||||
const lsioApps = response.data?.data?.repositories?.linuxserver ?? {};
|
||||
|
||||
@@ -275,7 +301,7 @@ export class TemplateService {
|
||||
|
||||
// Legacy Portainer v2 Format (Fallback for custom registries)
|
||||
// The Portainer v2 spec includes a native `categories` field; pass it through.
|
||||
const response = await axios.get<TemplatesResponse>(registryUrl, { timeout: 20_000 });
|
||||
const response = await axios.get<TemplatesResponse>(registryUrl, TemplateService.REGISTRY_FETCH_OPTIONS);
|
||||
const templates = (response.data.templates || [])
|
||||
.filter((t: Template) => !!t.image && t.type === 1)
|
||||
.map((t: Template) => ({ ...t, source: 'custom' }));
|
||||
@@ -286,62 +312,56 @@ export class TemplateService {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Templates] Failed to fetch from registry:', error);
|
||||
throw new Error('Could not fetch templates from registry');
|
||||
// Match the stable axios error code first; fall back to the
|
||||
// message text in case a transport reports the cap differently.
|
||||
const oversized = axios.isAxiosError(error)
|
||||
&& (error.code === 'ERR_FR_MAX_CONTENT_LENGTH_EXCEEDED'
|
||||
|| /maxContentLength|maxBodyLength/i.test(error.message ?? ''));
|
||||
if (oversized) {
|
||||
throw new Error('Could not fetch templates from registry (response exceeded the size limit)', { cause: error });
|
||||
}
|
||||
throw new Error('Could not fetch templates from registry', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
public generateComposeFromTemplate(template: Template, serviceName: string): string {
|
||||
let yaml = `services:\n ${serviceName}:\n`;
|
||||
const service: ComposeServiceDefinition = { restart: 'unless-stopped' };
|
||||
|
||||
if (template.image) {
|
||||
yaml += ` image: ${template.image}\n`;
|
||||
service.image = template.image;
|
||||
}
|
||||
|
||||
yaml += ` restart: unless-stopped\n`;
|
||||
|
||||
if (template.ports && template.ports.length > 0) {
|
||||
yaml += ` ports:\n`;
|
||||
for (const port of template.ports) {
|
||||
yaml += ` - "${port}"\n`;
|
||||
}
|
||||
service.ports = [...template.ports];
|
||||
}
|
||||
|
||||
if (template.volumes && template.volumes.length > 0) {
|
||||
yaml += ` volumes:\n`;
|
||||
const volumes: string[] = [];
|
||||
for (const vol of template.volumes) {
|
||||
let hostPath = '';
|
||||
let containerPath = '';
|
||||
let options = '';
|
||||
|
||||
if (typeof vol === 'string') {
|
||||
const parts = vol.split(':');
|
||||
if (parts.length === 1) {
|
||||
yaml += ` - ${vol}\n`;
|
||||
continue;
|
||||
}
|
||||
hostPath = parts[0];
|
||||
containerPath = parts[1];
|
||||
options = parts.slice(2).join(':');
|
||||
if (options) options = `:${options}`;
|
||||
// Pass string volumes through verbatim; the YAML emitter
|
||||
// handles any escaping the raw value needs.
|
||||
volumes.push(vol);
|
||||
} else if (vol.container) {
|
||||
containerPath = vol.container;
|
||||
const containerPath = vol.container;
|
||||
const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data';
|
||||
hostPath = vol.bind ? vol.bind : `./${containerFolder}`;
|
||||
options = vol.readonly ? ':ro' : '';
|
||||
} else {
|
||||
continue;
|
||||
const hostPath = vol.bind ? vol.bind : `./${containerFolder}`;
|
||||
const options = vol.readonly ? ':ro' : '';
|
||||
volumes.push(`${hostPath}:${containerPath}${options}`);
|
||||
}
|
||||
|
||||
|
||||
yaml += ` - ${hostPath}:${containerPath}${options}\n`;
|
||||
}
|
||||
if (volumes.length > 0) {
|
||||
service.volumes = volumes;
|
||||
}
|
||||
}
|
||||
|
||||
if (template.env && template.env.length > 0) {
|
||||
yaml += ` env_file:\n - .env\n`;
|
||||
service.env_file = ['.env'];
|
||||
}
|
||||
|
||||
return yaml;
|
||||
// Serialize through the YAML emitter so registry-supplied values are
|
||||
// escaped correctly instead of interpolated raw into hand-built lines.
|
||||
return YAML.stringify({ services: { [serviceName]: service } }, { lineWidth: 0 });
|
||||
}
|
||||
|
||||
public generateEnvString(envVars: Record<string, string>): string {
|
||||
|
||||
Reference in New Issue
Block a user