Initial commit: StackPilot - Docker stack builder with 186 apps, compose analyser, analytics
This commit is contained in:
156
src/generators/compose.js
Normal file
156
src/generators/compose.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import jsYaml from 'js-yaml';
|
||||
|
||||
export function generateCompose(selectedApps, config) {
|
||||
const compose = {
|
||||
version: '3.8',
|
||||
services: {},
|
||||
networks: { stackpilot: { driver: 'bridge' } },
|
||||
};
|
||||
|
||||
for (const app of selectedApps) {
|
||||
const service = {
|
||||
image: app.image,
|
||||
container_name: app.id,
|
||||
restart: 'unless-stopped',
|
||||
networks: ['stackpilot'],
|
||||
environment: {
|
||||
PUID: '1000',
|
||||
PGID: '1000',
|
||||
TZ: config.timezone,
|
||||
...app.env,
|
||||
},
|
||||
};
|
||||
|
||||
// Ports
|
||||
if (app.ports && app.ports.length > 0) {
|
||||
service.ports = app.ports.map(p =>
|
||||
p.protocol === 'udp'
|
||||
? `${p.host}:${p.container}/udp`
|
||||
: `${p.host}:${p.container}`
|
||||
);
|
||||
}
|
||||
|
||||
// Volumes
|
||||
if (app.volumes && app.volumes.length > 0) {
|
||||
service.volumes = app.volumes.map(v => {
|
||||
const hostPath = v.host
|
||||
.replace('${CONFIG_PATH}', config.configPath)
|
||||
.replace('${MEDIA_PATH}', config.mediaPath)
|
||||
.replace('${DOWNLOADS_PATH}', config.downloadsPath);
|
||||
return v.readonly ? `${hostPath}:${v.container}:ro` : `${hostPath}:${v.container}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Cap add
|
||||
if (app.capAdd) {
|
||||
service.cap_add = app.capAdd;
|
||||
}
|
||||
|
||||
// Healthcheck
|
||||
if (app.healthcheck) {
|
||||
service.healthcheck = {
|
||||
test: ['CMD-SHELL', app.healthcheck],
|
||||
interval: '30s',
|
||||
timeout: '10s',
|
||||
retries: 3,
|
||||
};
|
||||
}
|
||||
|
||||
// VPN routing — if gluetun is selected and this is a download client
|
||||
const gluetunSelected = selectedApps.some(a => a.id === 'gluetun');
|
||||
if (gluetunSelected && app.links?.providesDownloadClient) {
|
||||
service.network_mode = `service:gluetun`;
|
||||
delete service.networks;
|
||||
delete service.ports;
|
||||
}
|
||||
|
||||
// If this IS gluetun, expose download client ports through it
|
||||
if (app.id === 'gluetun' && gluetunSelected) {
|
||||
const dlClients = selectedApps.filter(a => a.links?.providesDownloadClient);
|
||||
const extraPorts = dlClients.flatMap(a => a.ports || []);
|
||||
if (extraPorts.length > 0) {
|
||||
service.ports = [
|
||||
...(service.ports || []),
|
||||
...extraPorts.map(p =>
|
||||
p.protocol === 'udp' ? `${p.host}:${p.container}/udp` : `${p.host}:${p.container}`
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
compose.services[app.id] = service;
|
||||
}
|
||||
|
||||
// Tailscale sidecar
|
||||
if (config.tailscale?.enabled) {
|
||||
const tsArgs = ['--hostname=' + (config.tailscale.hostname || 'stackpilot')];
|
||||
if (config.tailscale.subnetRouter) {
|
||||
tsArgs.push('--advertise-routes=172.20.0.0/16');
|
||||
}
|
||||
if (config.tailscale.funnel) {
|
||||
tsArgs.push('--advertise-exit-node');
|
||||
}
|
||||
|
||||
compose.services['tailscale-sidecar'] = {
|
||||
image: 'tailscale/tailscale:latest',
|
||||
container_name: 'tailscale-sidecar',
|
||||
restart: 'unless-stopped',
|
||||
hostname: config.tailscale.hostname || 'stackpilot',
|
||||
networks: ['stackpilot'],
|
||||
cap_add: ['NET_ADMIN', 'SYS_MODULE'],
|
||||
environment: {
|
||||
TS_AUTHKEY: config.tailscale.authKey || '${TS_AUTHKEY}',
|
||||
TS_EXTRA_ARGS: tsArgs.join(' '),
|
||||
TS_STATE_DIR: '/var/lib/tailscale',
|
||||
},
|
||||
volumes: [
|
||||
`${config.configPath}/tailscale:/var/lib/tailscale`,
|
||||
'/dev/net/tun:/dev/net/tun',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return jsYaml.dump(compose, { lineWidth: 120, noRefs: true });
|
||||
}
|
||||
|
||||
export function generateEnv(selectedApps, config) {
|
||||
const lines = [
|
||||
'# StackPilot — Generated Environment File',
|
||||
`# Generated: ${new Date().toISOString()}`,
|
||||
'',
|
||||
'# Paths',
|
||||
`CONFIG_PATH=${config.configPath}`,
|
||||
`MEDIA_PATH=${config.mediaPath}`,
|
||||
`DOWNLOADS_PATH=${config.downloadsPath}`,
|
||||
`TZ=${config.timezone}`,
|
||||
'',
|
||||
'# User/Group IDs',
|
||||
'PUID=1000',
|
||||
'PGID=1000',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const app of selectedApps) {
|
||||
if (app.env && Object.keys(app.env).length > 0) {
|
||||
const envVarsWithValues = Object.entries(app.env).filter(([, v]) => v !== '');
|
||||
if (envVarsWithValues.length > 0) {
|
||||
lines.push(`# ${app.name}`);
|
||||
for (const [key, value] of envVarsWithValues) {
|
||||
lines.push(`${app.id.toUpperCase()}_${key}=${value}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
// Prompt-style placeholders for empty values
|
||||
const envVarsEmpty = Object.entries(app.env).filter(([, v]) => v === '');
|
||||
if (envVarsEmpty.length > 0) {
|
||||
lines.push(`# ${app.name} — FILL THESE IN`);
|
||||
for (const [key] of envVarsEmpty) {
|
||||
lines.push(`${app.id.toUpperCase()}_${key}=CHANGE_ME`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user