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 <noreply@anthropic.com>
This commit is contained in:
Anso
2026-04-20 07:57:16 -04:00
committed by GitHub
parent 370b67d7ec
commit 9f861e0072
3 changed files with 28 additions and 16 deletions
+25 -13
View File
@@ -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 ───────────────────────────────────────────────
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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`;