From 9f861e00721a71c615a6478b24aec2b8087b2fc7 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 20 Apr 2026 07:57:16 -0400 Subject: [PATCH] fix(app-store): use stack name as compose service key (#704) * fix(app-store): use stack name as compose service key App Store deployments hardcoded the compose service to "app", so every container's com.docker.compose.service label collapsed to "app". Global logs and AutoHeal policies key off that label, making it impossible to distinguish between deployed apps. Pass the already-validated stack name through to generateComposeFromTemplate so each app gets a descriptive service identifier. * test(template-service): use {2} quantifier to satisfy no-regex-spaces ESLint's no-regex-spaces rule flagged two consecutive spaces in the regex literal as an error. Swap for the {2} quantifier to keep the assertion identical while clearing the lint gate. --------- Co-authored-by: Claude --- .../src/__tests__/template-service.test.ts | 38 ++++++++++++------- backend/src/index.ts | 2 +- backend/src/services/TemplateService.ts | 4 +- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/backend/src/__tests__/template-service.test.ts b/backend/src/__tests__/template-service.test.ts index d4f58e35..2ba41660 100644 --- a/backend/src/__tests__/template-service.test.ts +++ b/backend/src/__tests__/template-service.test.ts @@ -22,7 +22,7 @@ describe('TemplateService', () => { image: 'nginx:latest', }; - const yaml = service.generateComposeFromTemplate(template); + const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('image: nginx:latest'); expect(yaml).toContain('restart: unless-stopped'); expect(yaml).not.toContain('ports:'); @@ -38,7 +38,7 @@ describe('TemplateService', () => { ports: ['80:80', '443:443/tcp'], }; - const yaml = service.generateComposeFromTemplate(template); + const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('ports:'); expect(yaml).toContain('"80:80"'); expect(yaml).toContain('"443:443/tcp"'); @@ -52,7 +52,7 @@ describe('TemplateService', () => { volumes: ['/host/data:/container/data'], }; - const yaml = service.generateComposeFromTemplate(template); + const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('volumes:'); expect(yaml).toContain('/host/data:/container/data'); }); @@ -65,7 +65,7 @@ describe('TemplateService', () => { volumes: ['/data'], }; - const yaml = service.generateComposeFromTemplate(template); + const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('- /data'); }); @@ -77,7 +77,7 @@ describe('TemplateService', () => { volumes: [{ container: '/config', bind: './config' }], }; - const yaml = service.generateComposeFromTemplate(template); + const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('./config:/config'); }); @@ -89,7 +89,7 @@ describe('TemplateService', () => { volumes: [{ container: '/app/data' }], }; - const yaml = service.generateComposeFromTemplate(template); + const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('./data:/app/data'); }); @@ -101,7 +101,7 @@ describe('TemplateService', () => { volumes: [{ container: '/config', bind: './config', readonly: true }], }; - const yaml = service.generateComposeFromTemplate(template); + const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('./config:/config:ro'); }); @@ -120,8 +120,8 @@ describe('TemplateService', () => { env: [], }; - expect(service.generateComposeFromTemplate(withEnv)).toContain('env_file:'); - expect(service.generateComposeFromTemplate(withoutEnv)).not.toContain('env_file:'); + expect(service.generateComposeFromTemplate(withEnv, 'app')).toContain('env_file:'); + expect(service.generateComposeFromTemplate(withoutEnv, 'app')).not.toContain('env_file:'); }); it('does not include env_file when env is undefined', () => { @@ -131,7 +131,7 @@ describe('TemplateService', () => { image: 'test:latest', }; - expect(service.generateComposeFromTemplate(template)).not.toContain('env_file:'); + expect(service.generateComposeFromTemplate(template, 'app')).not.toContain('env_file:'); }); it('handles string volumes with options (e.g., host:container:ro)', () => { @@ -142,7 +142,7 @@ describe('TemplateService', () => { volumes: ['/host/config:/config:ro'], }; - const yaml = service.generateComposeFromTemplate(template); + const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('/host/config:/config:ro'); }); @@ -154,7 +154,7 @@ describe('TemplateService', () => { volumes: [{ container: '' }], }; - const yaml = service.generateComposeFromTemplate(template); + const yaml = service.generateComposeFromTemplate(template, 'app'); // Empty container means `continue` is hit, no volume line emitted expect(yaml).toContain('volumes:'); // The volume header is added but no actual volume entry @@ -172,10 +172,22 @@ describe('TemplateService', () => { env: [{ name: 'KEY', default: 'val' }], }; - const yaml = service.generateComposeFromTemplate(template); + const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toMatch(/^services:\n/); expect(yaml).toContain(' app:'); }); + + it('uses the supplied service name as the compose service key', () => { + const template: Template = { + title: 'Plex', + description: 'Media server', + image: 'plex:latest', + }; + + const yaml = service.generateComposeFromTemplate(template, 'plex'); + expect(yaml).toMatch(/^services:\n {2}plex:\n/); + expect(yaml).not.toContain(' app:'); + }); }); // ─── generateEnvString ─────────────────────────────────────────────── diff --git a/backend/src/index.ts b/backend/src/index.ts index c30da513..e3f5ff28 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7405,7 +7405,7 @@ app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Resp await fsService.createStack(stackName); // 2. Generate compose YAML and save - const composeYaml = templateService.generateComposeFromTemplate(template); + const composeYaml = templateService.generateComposeFromTemplate(template, stackName); await fsService.saveStackContent(stackName, composeYaml); // 3. Generate env string and save to default .env diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts index 0db591b4..9b69d7fb 100644 --- a/backend/src/services/TemplateService.ts +++ b/backend/src/services/TemplateService.ts @@ -288,8 +288,8 @@ export class TemplateService { } } - public generateComposeFromTemplate(template: Template): string { - let yaml = `services:\n app:\n`; + public generateComposeFromTemplate(template: Template, serviceName: string): string { + let yaml = `services:\n ${serviceName}:\n`; if (template.image) { yaml += ` image: ${template.image}\n`;