@dockerforge/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +88 -0
- package/package.json +44 -0
- package/src/_engine/backend/src/modules/analysis/analyser.js +1128 -0
- package/src/_engine/backend/src/modules/engine.js +219 -0
- package/src/_engine/backend/src/modules/explanation/explainer.js +78 -0
- package/src/_engine/backend/src/modules/generation/composeGenerator.js +318 -0
- package/src/_engine/backend/src/modules/generation/generator.js +1355 -0
- package/src/_engine/backend/src/modules/ingestion/ingestion.js +525 -0
- package/src/_engine/backend/src/modules/optimisation/optimiser.js +38 -0
- package/src/_engine/backend/src/modules/security/security.js +91 -0
- package/src/_engine/shared/constants.js +98 -0
- package/src/errors.js +36 -0
- package/src/index.js +70 -0
- package/src/lint/index.js +92 -0
- package/src/lint/parse.js +112 -0
- package/src/lint/rules.js +148 -0
|
@@ -0,0 +1,1355 @@
|
|
|
1
|
+
// backend/src/modules/generation/generator.js
|
|
2
|
+
// Accepts { services, sharedDirs } from the analyser.
|
|
3
|
+
// Produces one Dockerfile (multi-stage if needed) + .dockerignore.
|
|
4
|
+
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { STACKS, BASE_IMAGES } = require('../../../../shared/constants');
|
|
7
|
+
const { isSecretLikeEnvKey, isSecretLikeEnvValue } = require('../security/security');
|
|
8
|
+
|
|
9
|
+
const STATIC_RUNTIME_IMAGE = 'nginx:1.27-alpine';
|
|
10
|
+
const PNPM_VERSION = '9';
|
|
11
|
+
|
|
12
|
+
function installPnpmGlobalCmd() {
|
|
13
|
+
return `npm install -g pnpm@${PNPM_VERSION}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function dockerCmdArray(cmd, fallback = []) {
|
|
17
|
+
if (Array.isArray(cmd)) return cmd;
|
|
18
|
+
if (typeof cmd === 'string' && cmd.trim()) return cmd.trim().split(/\s+/);
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runtimeStartCmd(a) {
|
|
23
|
+
const cmd = dockerCmdArray(a.startCmd, ['node', a.entryPoint]);
|
|
24
|
+
if (!a.hasBuild || !Array.isArray(cmd) || cmd[0] !== 'node' || !cmd[1]) return cmd;
|
|
25
|
+
|
|
26
|
+
const buildOut = a.buildOutputDir || 'dist';
|
|
27
|
+
const entry = cmd[1].replace(/\\/g, '/');
|
|
28
|
+
if (entry.startsWith(`${buildOut}/`) || entry.startsWith('./' + buildOut + '/')) {
|
|
29
|
+
return cmd;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return ['node', `${buildOut}/${entry.replace(/^\.\//, '')}`];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nginxStaticServerBlock(port) {
|
|
36
|
+
return 'COPY nginx.conf /etc/nginx/conf.d/default.conf';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function nginxConf(port) {
|
|
40
|
+
return `server {
|
|
41
|
+
listen ${port};
|
|
42
|
+
root /usr/share/nginx/html;
|
|
43
|
+
index index.html;
|
|
44
|
+
|
|
45
|
+
add_header X-Frame-Options "SAMEORIGIN";
|
|
46
|
+
add_header X-Content-Type-Options "nosniff";
|
|
47
|
+
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
|
48
|
+
|
|
49
|
+
location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
|
50
|
+
expires 1y;
|
|
51
|
+
add_header Cache-Control "public, immutable";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
location /health { return 200 "ok"; add_header Content-Type text/plain; }
|
|
55
|
+
location / { try_files $uri $uri/ /index.html; }
|
|
56
|
+
|
|
57
|
+
gzip on;
|
|
58
|
+
gzip_types text/plain text/css application/json application/javascript
|
|
59
|
+
text/xml application/xml image/svg+xml font/woff2 application/manifest+json;
|
|
60
|
+
}`.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function nginxNonRootPrepBlock() {
|
|
64
|
+
return [
|
|
65
|
+
'RUN mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp /var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp /var/cache/nginx/scgi_temp',
|
|
66
|
+
'RUN touch /var/run/nginx.pid && chown -R nginx:nginx /var/cache/nginx /var/run/nginx.pid /var/log/nginx /etc/nginx/conf.d /usr/share/nginx/html',
|
|
67
|
+
].join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function simpleHttpHealthcheck(port) {
|
|
71
|
+
return `HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD wget -qO- http://localhost:${port}/health || exit 1`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function addDockerfileHeader(dockerfile) {
|
|
75
|
+
const text = normalizeDockerfileText(dockerfile).trim();
|
|
76
|
+
if (text.startsWith('# syntax=docker/dockerfile:1')) return text;
|
|
77
|
+
// Prepend the BuildKit syntax directive — required for --mount=type=cache to work.
|
|
78
|
+
return ['# syntax=docker/dockerfile:1', text].join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Power-mode header: adds CI/CD stamping args and OCI image labels.
|
|
83
|
+
* Injected into powerDockerfile only — not the default output.
|
|
84
|
+
*/
|
|
85
|
+
function addPowerDockerfileHeader(dockerfile) {
|
|
86
|
+
const text = normalizeDockerfileText(dockerfile).trim();
|
|
87
|
+
if (text.startsWith('# syntax=docker/dockerfile:1')) {
|
|
88
|
+
// Already has a simple header; replace it with the full power header.
|
|
89
|
+
const withoutSyntax = text.replace(/^# syntax=docker\/dockerfile:1\n/, '');
|
|
90
|
+
return buildPowerHeader(withoutSyntax);
|
|
91
|
+
}
|
|
92
|
+
return buildPowerHeader(text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildPowerHeader(text) {
|
|
96
|
+
const lines = text.split('\n');
|
|
97
|
+
const isMultiPlatform = text.includes('--platform=$BUILDPLATFORM');
|
|
98
|
+
|
|
99
|
+
const header = [
|
|
100
|
+
'# syntax=docker/dockerfile:1',
|
|
101
|
+
'# Enables layer caching when using a registry cache (--cache-from in CI).',
|
|
102
|
+
'ARG BUILDKIT_INLINE_CACHE=1',
|
|
103
|
+
'# Pass --build-arg GIT_SHA=$(git rev-parse HEAD) in CI to stamp the image.',
|
|
104
|
+
'ARG GIT_SHA=unknown',
|
|
105
|
+
'# Pass --build-arg VERSION=$TAG in CI to record the release version.',
|
|
106
|
+
'ARG VERSION=unknown',
|
|
107
|
+
];
|
|
108
|
+
if (isMultiPlatform) header.splice(1, 0, 'ARG TARGETPLATFORM', 'ARG BUILDPLATFORM');
|
|
109
|
+
|
|
110
|
+
const firstFromIndex = lines.findIndex(line => line.trim().startsWith('FROM '));
|
|
111
|
+
if (firstFromIndex === -1) return [...header, text].join('\n');
|
|
112
|
+
|
|
113
|
+
const lastFromIndex = lines.reduce((last, line, index) => (
|
|
114
|
+
line.trim().startsWith('FROM ') ? index : last
|
|
115
|
+
), firstFromIndex);
|
|
116
|
+
|
|
117
|
+
// Re-declare ARGs after the final FROM so they're in scope for LABEL.
|
|
118
|
+
// ARGs before FROM are only visible to FROM itself in Docker's build model.
|
|
119
|
+
const stageMetadata = [
|
|
120
|
+
'ARG GIT_SHA',
|
|
121
|
+
'ARG VERSION',
|
|
122
|
+
'# Image stamped at build time by CI via --build-arg.',
|
|
123
|
+
'LABEL org.opencontainers.image.revision="${GIT_SHA}" \\',
|
|
124
|
+
' org.opencontainers.image.version="${VERSION}"',
|
|
125
|
+
];
|
|
126
|
+
if (isMultiPlatform) stageMetadata.unshift('ARG TARGETPLATFORM', 'ARG BUILDPLATFORM');
|
|
127
|
+
|
|
128
|
+
return [
|
|
129
|
+
...header,
|
|
130
|
+
...lines.slice(0, lastFromIndex + 1),
|
|
131
|
+
...stageMetadata,
|
|
132
|
+
...lines.slice(lastFromIndex + 1),
|
|
133
|
+
].join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function builderFrom(image, stageName) {
|
|
137
|
+
return `FROM --platform=$BUILDPLATFORM ${image} AS ${stageName}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function dotnetRuntimeRid(runtimeImage) {
|
|
141
|
+
return runtimeImage.includes('alpine') ? 'linux-musl-x64' : 'linux-x64';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function dotnetPublishLine(projectPath, runtimeImage) {
|
|
145
|
+
return `RUN dotnet publish "${projectPath}" -c Release -o /app/publish --no-restore --runtime ${dotnetRuntimeRid(runtimeImage)} --self-contained false`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function nodeInstallLine(packageManager, prodOnly = false, options = {}) {
|
|
149
|
+
const prefix = options.cwd ? `cd ${options.cwd} && ` : '';
|
|
150
|
+
const omitDev = prodOnly;
|
|
151
|
+
const secretMount = options.hasScopedPackages ? '--mount=type=secret,id=npmrc,target=/root/.npmrc ' : '';
|
|
152
|
+
if (packageManager === 'pnpm') {
|
|
153
|
+
const filter = options.filter ? ` --filter ${options.filter}` : '';
|
|
154
|
+
return `RUN --mount=type=cache,target=/root/.local/share/pnpm/store ${secretMount}${prefix}${installPnpmGlobalCmd()} && ${prefix}pnpm install --frozen-lockfile${omitDev ? ' --prod' : ''}${filter}`;
|
|
155
|
+
}
|
|
156
|
+
if (packageManager === 'yarn') {
|
|
157
|
+
return `RUN --mount=type=cache,target=/usr/local/share/.cache/yarn ${secretMount}${prefix}yarn install --frozen-lockfile${omitDev ? ' --production' : ''}`;
|
|
158
|
+
}
|
|
159
|
+
return `RUN --mount=type=cache,target=/root/.npm ${secretMount}${prefix}npm ci${omitDev ? ' --omit=dev' : ''}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nodePruneLine(packageManager, cwd = '') {
|
|
163
|
+
const prefix = cwd ? `cd ${cwd} && ` : '';
|
|
164
|
+
if (packageManager === 'pnpm') return `RUN ${prefix}pnpm prune --prod`;
|
|
165
|
+
if (packageManager === 'yarn') return `RUN ${prefix}yarn install --frozen-lockfile --production --ignore-scripts && yarn cache clean`;
|
|
166
|
+
return `RUN ${prefix}npm prune --omit=dev`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function nodeSourceCopyBlock(a, configCopyLine = '', frontendAssetCopyLine = '') {
|
|
170
|
+
const blocks = [];
|
|
171
|
+
if (configCopyLine) blocks.push(configCopyLine.trimEnd());
|
|
172
|
+
if (a.framework === 'nextjs') {
|
|
173
|
+
blocks.push('COPY app/ ./app/');
|
|
174
|
+
blocks.push('COPY pages/ ./pages/');
|
|
175
|
+
blocks.push('COPY components/ ./components/');
|
|
176
|
+
blocks.push('COPY public/ ./public/');
|
|
177
|
+
} else if (a.framework === 'remix') {
|
|
178
|
+
blocks.push('COPY app/ ./app/');
|
|
179
|
+
blocks.push('COPY public/ ./public/');
|
|
180
|
+
} else if (['astro', 'vite', 'cra', 'sveltekit'].includes(a.framework)) {
|
|
181
|
+
if (frontendAssetCopyLine) blocks.push(frontendAssetCopyLine.trimEnd());
|
|
182
|
+
blocks.push('COPY src/ ./src/');
|
|
183
|
+
} else if (a.framework === 'nestjs') {
|
|
184
|
+
blocks.push('COPY src/ ./src/');
|
|
185
|
+
} else {
|
|
186
|
+
blocks.push('# Update these COPY lines if your source is not under src/.');
|
|
187
|
+
blocks.push('COPY src/ ./src/');
|
|
188
|
+
}
|
|
189
|
+
return blocks.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function pythonBuilderImage(a) {
|
|
193
|
+
return a.hasNativeDeps ? `python:${a.version}` : BASE_IMAGES[STACKS.PYTHON](a.version);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function nodeSecretMount(a) {
|
|
197
|
+
return a.hasScopedPackages ? '--mount=type=secret,id=npmrc,target=/root/.npmrc ' : '';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function nodeStopSignalBlock() {
|
|
201
|
+
return 'STOPSIGNAL SIGTERM';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function runtimeUserBlock(alpine = true) {
|
|
205
|
+
return alpine
|
|
206
|
+
? 'RUN addgroup -S appgroup && adduser -S appuser -G appgroup && chown -R appuser:appgroup /app\nUSER appuser'
|
|
207
|
+
: 'RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser && chown -R appuser:appgroup /app\nUSER appuser';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function pythonInstallBlock(a, prefix = '') {
|
|
211
|
+
const hashFlag = a.requirementsHasHashes ? ' --require-hashes' : '';
|
|
212
|
+
const installCmd = a.packageManager === 'pip'
|
|
213
|
+
? `pip install --no-cache-dir${hashFlag} -r ${a.requirementsFile}`
|
|
214
|
+
: a.installCmd;
|
|
215
|
+
const lines = [`RUN --mount=type=cache,target=/root/.cache/pip ${prefix}${installCmd}`];
|
|
216
|
+
if (a.needsGunicornInstall) {
|
|
217
|
+
lines.push(`RUN ${prefix}pip install --no-cache-dir gunicorn`);
|
|
218
|
+
}
|
|
219
|
+
return lines.join('\n');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function pythonWheelInstallBlock(a, prefix = '') {
|
|
223
|
+
const lines = [`RUN ${prefix}pip install --no-cache-dir --no-index --find-links=/wheels -r ${a.requirementsFile}`];
|
|
224
|
+
if (a.needsGunicornInstall) {
|
|
225
|
+
lines.push(`RUN ${prefix}pip install --no-cache-dir --no-index --find-links=/wheels gunicorn`);
|
|
226
|
+
}
|
|
227
|
+
return lines.join('\n');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function pythonWheelBuildBlock(a, prefix = '') {
|
|
231
|
+
const lines = [`RUN ${prefix}pip wheel --no-cache-dir --wheel-dir /wheels -r ${a.requirementsFile}`];
|
|
232
|
+
if (a.needsGunicornInstall) {
|
|
233
|
+
lines.push(`RUN ${prefix}pip wheel --no-cache-dir --wheel-dir /wheels gunicorn`);
|
|
234
|
+
}
|
|
235
|
+
return lines.join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Main Entry ───────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function generateDockerfile({ services, sharedDirs = [], staticAssets = [], rootConfigFiles = [], envVars = [], dockerignoreBlockedDirs = [], workspacePackageDirs = [], workspaceSharedConfigs = [] }) {
|
|
241
|
+
// Single service at project root — classic path
|
|
242
|
+
if (services.length === 1 && services[0].serviceDir === '.') {
|
|
243
|
+
return finalizeResult(generateSingleRoot(services[0], envVars, rootConfigFiles));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Single service in a subdirectory
|
|
247
|
+
if (services.length === 1) {
|
|
248
|
+
return finalizeResult(generateSingleSub(services[0], sharedDirs, staticAssets, rootConfigFiles, envVars, workspacePackageDirs, workspaceSharedConfigs));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Multiple services — build a multi-stage Dockerfile
|
|
252
|
+
return finalizeResult(generateMultiService(services, sharedDirs, rootConfigFiles, envVars, dockerignoreBlockedDirs, workspacePackageDirs, workspaceSharedConfigs));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Builds a safe runtime ENV block from detected non-secret defaults.
|
|
256
|
+
function buildEnvBlock(envVars) {
|
|
257
|
+
const safeDefaults = (envVars || []).filter(v =>
|
|
258
|
+
v.hasDefault &&
|
|
259
|
+
!isSecretLikeEnvKey(v.key) &&
|
|
260
|
+
!isSecretLikeEnvValue(v.value)
|
|
261
|
+
);
|
|
262
|
+
if (safeDefaults.length === 0) return 'ENV NODE_ENV=production';
|
|
263
|
+
const envPairs = [
|
|
264
|
+
'NODE_ENV=production',
|
|
265
|
+
...safeDefaults
|
|
266
|
+
.filter(v => v.key !== 'NODE_ENV')
|
|
267
|
+
.map(v => ` ${v.key}=${v.value}`),
|
|
268
|
+
];
|
|
269
|
+
const envLine = envPairs.length === 1
|
|
270
|
+
? `ENV NODE_ENV=production`
|
|
271
|
+
: `ENV ${envPairs.join(' \\\n')}`;
|
|
272
|
+
return envLine;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function buildCommandPrefix(service) {
|
|
276
|
+
// Fire for CRA regardless of how detection landed — framework='cra', role='frontend',
|
|
277
|
+
// or any build command that invokes react-scripts directly.
|
|
278
|
+
const isReactScripts =
|
|
279
|
+
service.framework === 'cra' ||
|
|
280
|
+
service.role === 'frontend' ||
|
|
281
|
+
(typeof service.buildCommand === 'string' && service.buildCommand.includes('react-scripts'));
|
|
282
|
+
if (!isReactScripts) return '';
|
|
283
|
+
return 'CI=false GENERATE_SOURCEMAP=false NODE_OPTIONS=--max-old-space-size=1024 DISABLE_ESLINT_PLUGIN=true ';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function countLineDiff(a = '', b = '') {
|
|
287
|
+
const left = String(a).split(/\r?\n/);
|
|
288
|
+
const right = String(b).split(/\r?\n/);
|
|
289
|
+
const max = Math.max(left.length, right.length);
|
|
290
|
+
let lineDiffCount = 0;
|
|
291
|
+
|
|
292
|
+
for (let i = 0; i < max; i += 1) {
|
|
293
|
+
if ((left[i] || '') !== (right[i] || '')) lineDiffCount += 1;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
lineDiffCount,
|
|
298
|
+
score: max === 0 ? 0 : Number((lineDiffCount / max).toFixed(2)),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizeDockerfileText(text) {
|
|
303
|
+
if (!text) return text;
|
|
304
|
+
return String(text)
|
|
305
|
+
.replace(/─/g, '-')
|
|
306
|
+
.replace(/[\u2500-\u257F]/g, '-')
|
|
307
|
+
.replace(/[–—]/g, '-')
|
|
308
|
+
.replace(/⚠️/g, 'WARNING:');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function finalizeResult(result) {
|
|
312
|
+
return {
|
|
313
|
+
...result,
|
|
314
|
+
dockerfile: addDockerfileHeader(result.dockerfile),
|
|
315
|
+
validationDockerfile: result.validationDockerfile
|
|
316
|
+
? addDockerfileHeader(result.validationDockerfile)
|
|
317
|
+
: result.validationDockerfile,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function withValidationVariant(result, validationDockerfile) {
|
|
322
|
+
if (!validationDockerfile || validationDockerfile === result.dockerfile) {
|
|
323
|
+
return {
|
|
324
|
+
...result,
|
|
325
|
+
validationDockerfile: null,
|
|
326
|
+
validationDrift: null,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
...result,
|
|
332
|
+
validationDockerfile: normalizeDockerfileText(validationDockerfile),
|
|
333
|
+
validationDrift: countLineDiff(
|
|
334
|
+
normalizeDockerfileText(result.dockerfile),
|
|
335
|
+
normalizeDockerfileText(validationDockerfile)
|
|
336
|
+
),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Single service: lives at project root ────────────────────────────────────
|
|
341
|
+
// Existing behaviour — nothing changes for projects where package.json is at /
|
|
342
|
+
|
|
343
|
+
function generateSingleRoot(a, envVars = [], rootConfigFiles = []) {
|
|
344
|
+
if (a.stack === STACKS.NODE) return generateNodeRoot(a, envVars, rootConfigFiles);
|
|
345
|
+
if (a.stack === STACKS.PYTHON) return generatePythonRoot(a, rootConfigFiles);
|
|
346
|
+
if (a.stack === STACKS.DOTNET) return generateDotnetRoot(a);
|
|
347
|
+
throw new Error(`No template for stack: ${a.stack}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Single service: lives in a subdirectory ──────────────────────────────────
|
|
351
|
+
|
|
352
|
+
function generateSingleSub(a, sharedDirs, staticAssets, rootConfigFiles = [], envVars = [], workspacePackageDirs = [], workspaceSharedConfigs = []) {
|
|
353
|
+
const dir = a.serviceDir;
|
|
354
|
+
|
|
355
|
+
if (a.stack === STACKS.NODE) return generateNodeSub(a, dir, sharedDirs, staticAssets, rootConfigFiles, envVars, workspacePackageDirs, workspaceSharedConfigs);
|
|
356
|
+
if (a.stack === STACKS.PYTHON) return generatePythonSub(a, dir, sharedDirs, staticAssets);
|
|
357
|
+
if (a.stack === STACKS.DOTNET) return generateDotnetSub(a, dir, sharedDirs);
|
|
358
|
+
throw new Error(`No template for stack: ${a.stack}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Multi-service ────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
function generateMultiService(services, sharedDirs, rootConfigFiles = [], envVars = [], dockerignoreBlockedDirs = [], workspacePackageDirs = [], workspaceSharedConfigs = []) {
|
|
364
|
+
const improvements = [];
|
|
365
|
+
|
|
366
|
+
if (dockerignoreBlockedDirs.length > 0) {
|
|
367
|
+
improvements.push(
|
|
368
|
+
`⚠️ Your .dockerignore uses a whitelist that blocks: ${dockerignoreBlockedDirs.map(d => d + '/').join(', ')}. ` +
|
|
369
|
+
`The generated .dockerignore tab fixes this — you MUST apply it alongside the Dockerfile or COPY commands will fail.`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Split into frontends and backends/services
|
|
374
|
+
const frontends = services.filter(s => s.role === 'frontend');
|
|
375
|
+
const backends = services.filter(s => s.role !== 'frontend');
|
|
376
|
+
|
|
377
|
+
if (frontends.length > 0 && backends.length === 0) {
|
|
378
|
+
throw new Error('Multiple frontend services detected but no backend/runtime service was found. Generate each frontend separately or add a backend service to serve the built assets.');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const stages = [];
|
|
382
|
+
|
|
383
|
+
// ── Frontend builder stages ──
|
|
384
|
+
for (const fe of frontends) {
|
|
385
|
+
if (fe.stack !== STACKS.NODE) continue;
|
|
386
|
+
const baseImage = BASE_IMAGES[STACKS.NODE](fe.version);
|
|
387
|
+
const stageName = `${fe.serviceDir.replace(/\//g, '-')}-build`;
|
|
388
|
+
|
|
389
|
+
let stageContent;
|
|
390
|
+
if (fe.lockFileAtRoot) {
|
|
391
|
+
const buildPrefix = buildCommandPrefix(fe);
|
|
392
|
+
const buildCmd = fe.packageManager === 'yarn'
|
|
393
|
+
// Explicitly prepend /build/node_modules/.bin to PATH before build.
|
|
394
|
+
// Hoisted devDeps (cross-env, vite, etc.) land in root node_modules/.bin,
|
|
395
|
+
// but yarn 1.x script runner does not reliably add them via --cwd or nested
|
|
396
|
+
// yarn calls. export PATH is inherited by all child processes.
|
|
397
|
+
? `RUN export PATH="/build/node_modules/.bin:$PATH" && cd ${fe.serviceDir} && ${buildPrefix}yarn build`
|
|
398
|
+
: fe.packageManager === 'pnpm'
|
|
399
|
+
? `RUN ${buildPrefix}pnpm --filter ./${fe.serviceDir} build`
|
|
400
|
+
: `RUN ${buildPrefix}npm run build --workspace=${fe.serviceDir}`;
|
|
401
|
+
|
|
402
|
+
if (fe.packageManager === 'yarn') {
|
|
403
|
+
// Copy ALL workspace member package.json files before install.
|
|
404
|
+
// Yarn hoists devDeps from ALL members to root node_modules/.bin.
|
|
405
|
+
// Missing members = their devDeps (cross-env, vite, etc.) not installed.
|
|
406
|
+
const allServicePkgCopies = services
|
|
407
|
+
.filter(s => s.lockFileAtRoot && s.serviceDir !== fe.serviceDir)
|
|
408
|
+
.map(s => `COPY ${s.serviceDir}/package.json ./${s.serviceDir}/package.json`)
|
|
409
|
+
.join('\n');
|
|
410
|
+
const extraPkgCopies = workspacePackageDirs
|
|
411
|
+
.filter(d => d !== fe.serviceDir && !services.some(s => s.serviceDir === d))
|
|
412
|
+
.map(d => `COPY ${d}/package.json ./${d}/package.json`)
|
|
413
|
+
.join('\n');
|
|
414
|
+
const allExtraCopies = [allServicePkgCopies, extraPkgCopies].filter(Boolean).join('\n');
|
|
415
|
+
stageContent = `
|
|
416
|
+
# ─── Stage: Build ${fe.serviceDir} ───────────────────────────────────────
|
|
417
|
+
${builderFrom(baseImage, stageName)}
|
|
418
|
+
|
|
419
|
+
WORKDIR /build
|
|
420
|
+
|
|
421
|
+
COPY ${fe.lockFile} package.json ./
|
|
422
|
+
COPY ${fe.serviceDir}/package.json ./${fe.serviceDir}/package.json${allExtraCopies ? '\n' + allExtraCopies : ''}
|
|
423
|
+
RUN yarn install --frozen-lockfile
|
|
424
|
+
|
|
425
|
+
# Copy only this service's source — other workspace dirs not needed for this build
|
|
426
|
+
COPY ${fe.serviceDir}/ ./${fe.serviceDir}/
|
|
427
|
+
${buildCmd}`.trim();
|
|
428
|
+
} else {
|
|
429
|
+
// pnpm/npm: workspace root install with all workspace package.json files
|
|
430
|
+
// COPY . . is banned — leaks .env and secrets into build context.
|
|
431
|
+
const installCmd = fe.packageManager === 'pnpm'
|
|
432
|
+
? `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile`
|
|
433
|
+
: `RUN npm ci`;
|
|
434
|
+
const workspacePkgCopies = services
|
|
435
|
+
.filter(s => s.lockFileAtRoot)
|
|
436
|
+
.map(s => `COPY ${s.serviceDir}/package.json ./${s.serviceDir}/package.json`)
|
|
437
|
+
.join('\n');
|
|
438
|
+
stageContent = `
|
|
439
|
+
# ─── Stage: Build ${fe.serviceDir} ───────────────────────────────────────
|
|
440
|
+
${builderFrom(baseImage, stageName)}
|
|
441
|
+
|
|
442
|
+
WORKDIR /build
|
|
443
|
+
|
|
444
|
+
# Copy manifests first for better Docker layer caching
|
|
445
|
+
COPY ${fe.lockFile} package.json ./
|
|
446
|
+
${workspacePkgCopies}
|
|
447
|
+
${installCmd}
|
|
448
|
+
|
|
449
|
+
# Copy only this service's source — other workspace dirs not needed for this build
|
|
450
|
+
COPY ${fe.serviceDir}/ ./${fe.serviceDir}/
|
|
451
|
+
${buildCmd}`.trim();
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
// Lock file lives inside the service directory — standalone package
|
|
455
|
+
const installCmd = fe.packageManager === 'pnpm'
|
|
456
|
+
? `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile`
|
|
457
|
+
: fe.packageManager === 'yarn'
|
|
458
|
+
? `RUN yarn install --frozen-lockfile`
|
|
459
|
+
: `RUN npm ci`;
|
|
460
|
+
|
|
461
|
+
stageContent = `
|
|
462
|
+
# ─── Stage: Build ${fe.serviceDir} ───────────────────────────────────────
|
|
463
|
+
${builderFrom(baseImage, stageName)}
|
|
464
|
+
|
|
465
|
+
WORKDIR /build
|
|
466
|
+
|
|
467
|
+
COPY ${fe.serviceDir}/${fe.lockFile} ${fe.serviceDir}/package.json ./
|
|
468
|
+
${installCmd}
|
|
469
|
+
|
|
470
|
+
COPY ${fe.serviceDir}/ .
|
|
471
|
+
RUN ${fe.buildCommand || 'npm run build'}`.trim();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
stages.push(stageContent);
|
|
475
|
+
improvements.push(`Frontend (${fe.serviceDir}): built in isolated stage — build tools never reach production image`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── Backend / service runtime stages ──
|
|
479
|
+
for (const be of backends) {
|
|
480
|
+
const dir = be.serviceDir;
|
|
481
|
+
const sharedCopies = sharedDirs.map(s => `COPY ${s}/ ./${s}/`).join('\n');
|
|
482
|
+
|
|
483
|
+
// Frontend artifact copies — use correct build output path per frontend
|
|
484
|
+
const frontendCopies = frontends.map(fe => {
|
|
485
|
+
const stageName = `${fe.serviceDir.replace(/\//g, '-')}-build`;
|
|
486
|
+
const buildOutputDir = fe.buildOutputDir || 'dist';
|
|
487
|
+
const buildOutputPath = fe.lockFileAtRoot
|
|
488
|
+
? (fe.serviceDir === '.' ? `/build/${buildOutputDir}` : `/build/${fe.serviceDir}/${buildOutputDir}`)
|
|
489
|
+
: `/build/${buildOutputDir}`;
|
|
490
|
+
return `# Copy built ${fe.serviceDir} assets into server static dir\nCOPY --from=${stageName} ${buildOutputPath} ./${dir}/public`;
|
|
491
|
+
}).join('\n');
|
|
492
|
+
|
|
493
|
+
if (be.stack === STACKS.NODE) {
|
|
494
|
+
const baseImage = BASE_IMAGES[STACKS.NODE](be.version);
|
|
495
|
+
const beStageName = `${dir.replace(/\//g, '-')}-build`;
|
|
496
|
+
|
|
497
|
+
// ── If backend has its own build step, add a dedicated build stage ──
|
|
498
|
+
if (be.hasBuild) {
|
|
499
|
+
const rootConfigCopy = rootConfigFiles.length > 0
|
|
500
|
+
? `# Copy root config files needed during build\nCOPY ${rootConfigFiles.join(' ')} ./`
|
|
501
|
+
: '';
|
|
502
|
+
|
|
503
|
+
let beBuildDepsSection, beBuildInstallSection, beBuildCmd;
|
|
504
|
+
if (be.lockFileAtRoot) {
|
|
505
|
+
const allPkgJsonCopies = services
|
|
506
|
+
.filter(s => s.lockFileAtRoot)
|
|
507
|
+
.map(s => `COPY ${s.serviceDir}/package.json ./${s.serviceDir}/`)
|
|
508
|
+
.join('\n');
|
|
509
|
+
beBuildDepsSection = `COPY ${be.lockFile} package.json ./\n${allPkgJsonCopies}`;
|
|
510
|
+
beBuildInstallSection = be.packageManager === 'pnpm'
|
|
511
|
+
? `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile && pnpm store prune`
|
|
512
|
+
: be.packageManager === 'yarn'
|
|
513
|
+
? `RUN yarn install --frozen-lockfile && yarn cache clean`
|
|
514
|
+
: `RUN npm ci && npm cache clean --force`;
|
|
515
|
+
beBuildCmd = be.packageManager === 'yarn'
|
|
516
|
+
? `RUN export PATH="/build/node_modules/.bin:$PATH" && cd ${dir} && yarn build`
|
|
517
|
+
: be.packageManager === 'pnpm'
|
|
518
|
+
? `RUN pnpm --filter ./${dir} build`
|
|
519
|
+
: `RUN npm run build --workspace=${dir}`;
|
|
520
|
+
} else {
|
|
521
|
+
beBuildDepsSection = `COPY ${dir}/${be.lockFile} ${dir}/package.json ./${dir}/`;
|
|
522
|
+
beBuildInstallSection = be.packageManager === 'pnpm'
|
|
523
|
+
? `RUN ${installPnpmGlobalCmd()} && cd ${dir} && pnpm install --frozen-lockfile && pnpm store prune`
|
|
524
|
+
: be.packageManager === 'yarn'
|
|
525
|
+
? `RUN cd ${dir} && yarn install --frozen-lockfile && yarn cache clean`
|
|
526
|
+
: `RUN cd ${dir} && npm ci && npm cache clean --force`;
|
|
527
|
+
beBuildCmd = `RUN cd ${dir} && ${be.buildCommand}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
stages.push(`
|
|
531
|
+
# ─── Stage: Build ${dir} ─────────────────────────────────────────────────
|
|
532
|
+
${builderFrom(baseImage, beStageName)}
|
|
533
|
+
|
|
534
|
+
WORKDIR /build
|
|
535
|
+
|
|
536
|
+
${beBuildDepsSection}
|
|
537
|
+
${beBuildInstallSection}
|
|
538
|
+
${rootConfigCopy ? '\n' + rootConfigCopy : ''}
|
|
539
|
+
COPY ${dir}/ ./${dir}/
|
|
540
|
+
${beBuildCmd}`.trim());
|
|
541
|
+
|
|
542
|
+
improvements.push(`Backend (${dir}): compiled in isolated build stage — source and dev tools never reach runtime image`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── Runtime stage ──
|
|
546
|
+
let copyDepsSection, installSection;
|
|
547
|
+
if (be.lockFileAtRoot) {
|
|
548
|
+
const allPkgJsonCopies = services
|
|
549
|
+
.filter(s => s.lockFileAtRoot)
|
|
550
|
+
.map(s => `COPY ${s.serviceDir}/package.json ./${s.serviceDir}/`)
|
|
551
|
+
.join('\n');
|
|
552
|
+
let installCmd;
|
|
553
|
+
if (be.packageManager === 'pnpm') {
|
|
554
|
+
const filter = be.isWorkspace ? ` --filter ${dir}` : '';
|
|
555
|
+
installCmd = `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile --prod${filter} && pnpm store prune`;
|
|
556
|
+
} else if (be.packageManager === 'yarn') {
|
|
557
|
+
installCmd = be.isWorkspace
|
|
558
|
+
? `RUN yarn --cwd ./${dir} install --frozen-lockfile --production && yarn cache clean`
|
|
559
|
+
: `RUN yarn install --frozen-lockfile --production && yarn cache clean`;
|
|
560
|
+
} else {
|
|
561
|
+
installCmd = `RUN npm ci --omit=dev && npm cache clean --force`;
|
|
562
|
+
}
|
|
563
|
+
copyDepsSection = `COPY ${be.lockFile} package.json ./\n${allPkgJsonCopies}`;
|
|
564
|
+
installSection = installCmd;
|
|
565
|
+
} else {
|
|
566
|
+
const installCmd = be.packageManager === 'pnpm'
|
|
567
|
+
? `RUN ${installPnpmGlobalCmd()} && cd ${dir} && pnpm install --frozen-lockfile --prod && pnpm store prune`
|
|
568
|
+
: be.packageManager === 'yarn'
|
|
569
|
+
? `RUN cd ${dir} && yarn install --frozen-lockfile --production && yarn cache clean`
|
|
570
|
+
: `RUN cd ${dir} && npm ci --omit=dev && npm cache clean --force`;
|
|
571
|
+
copyDepsSection = `COPY ${dir}/${be.lockFile} ${dir}/package.json ./${dir}/`;
|
|
572
|
+
installSection = installCmd;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Runtime copies either compiled dist OR raw source (when no build step)
|
|
576
|
+
const sourceSection = be.hasBuild
|
|
577
|
+
? `# Copy compiled output from build stage\nCOPY --from=${beStageName} /build/${dir}/${be.buildOutputDir || 'dist'} ./${dir}/${be.buildOutputDir || 'dist'}`
|
|
578
|
+
: `COPY ${dir}/ ./${dir}/`;
|
|
579
|
+
|
|
580
|
+
const startCmdJson = JSON.stringify(runtimeStartCmd(be));
|
|
581
|
+
|
|
582
|
+
stages.push(`
|
|
583
|
+
# ─── Stage: Runtime ${dir} ───────────────────────────────────────────────
|
|
584
|
+
FROM ${baseImage}
|
|
585
|
+
|
|
586
|
+
WORKDIR /app
|
|
587
|
+
|
|
588
|
+
${buildEnvBlock(envVars)}
|
|
589
|
+
|
|
590
|
+
${copyDepsSection}
|
|
591
|
+
${installSection}
|
|
592
|
+
|
|
593
|
+
${sourceSection}
|
|
594
|
+
${sharedCopies ? sharedCopies + '\n' : ''}${frontendCopies ? frontendCopies + '\n' : ''}
|
|
595
|
+
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
|
596
|
+
USER appuser
|
|
597
|
+
|
|
598
|
+
${nodeStopSignalBlock()}
|
|
599
|
+
EXPOSE ${be.port}
|
|
600
|
+
${simpleHttpHealthcheck(be.port)}
|
|
601
|
+
WORKDIR /app/${dir}
|
|
602
|
+
CMD ${startCmdJson}`.trim());
|
|
603
|
+
|
|
604
|
+
} else if (be.stack === STACKS.PYTHON) {
|
|
605
|
+
const baseImage = BASE_IMAGES[STACKS.PYTHON](be.version);
|
|
606
|
+
stages.push(`
|
|
607
|
+
# ─── Stage: Runtime ${dir} ───────────────────────────────────────────────
|
|
608
|
+
FROM ${baseImage}
|
|
609
|
+
|
|
610
|
+
ENV PYTHONDONTWRITEBYTECODE=1
|
|
611
|
+
ENV PYTHONUNBUFFERED=1
|
|
612
|
+
|
|
613
|
+
WORKDIR /app
|
|
614
|
+
|
|
615
|
+
COPY ${dir}/${be.requirementsFile} ./${dir}/
|
|
616
|
+
${pythonInstallBlock(be, `cd ${dir} && `)}
|
|
617
|
+
|
|
618
|
+
COPY ${dir}/ ./${dir}/
|
|
619
|
+
${sharedCopies ? sharedCopies : ''}
|
|
620
|
+
|
|
621
|
+
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
|
|
622
|
+
USER appuser
|
|
623
|
+
|
|
624
|
+
EXPOSE ${be.port}
|
|
625
|
+
WORKDIR /app/${dir}
|
|
626
|
+
CMD ${JSON.stringify(dockerCmdArray(be.startCmd, ['python', be.entryPoint]))}`.trim());
|
|
627
|
+
|
|
628
|
+
} else if (be.stack === STACKS.DOTNET) {
|
|
629
|
+
const runtimeImage = BASE_IMAGES[STACKS.DOTNET].runtime(be.version);
|
|
630
|
+
const sdkImage = BASE_IMAGES[STACKS.DOTNET].sdk(be.version);
|
|
631
|
+
const stageName = `${dir.replace(/\//g, '-')}-dotnet-build`;
|
|
632
|
+
stages.push(`
|
|
633
|
+
# ─── Stage: Build ${dir} (.NET) ──────────────────────────────────────────
|
|
634
|
+
${builderFrom(sdkImage, stageName)}
|
|
635
|
+
|
|
636
|
+
WORKDIR /src
|
|
637
|
+
|
|
638
|
+
COPY ${dir}/${be.csprojPath} ./${dir}/
|
|
639
|
+
RUN dotnet restore "${dir}/${be.csprojPath}"
|
|
640
|
+
|
|
641
|
+
COPY ${dir}/ ./${dir}/
|
|
642
|
+
${dotnetPublishLine(`${dir}/${be.csprojPath}`, runtimeImage)}
|
|
643
|
+
|
|
644
|
+
# ─── Stage: Runtime ${dir} ───────────────────────────────────────────────
|
|
645
|
+
FROM ${runtimeImage}
|
|
646
|
+
|
|
647
|
+
WORKDIR /app
|
|
648
|
+
|
|
649
|
+
ENV DOTNET_RUNNING_IN_CONTAINER=true \\
|
|
650
|
+
ASPNETCORE_URLS=http://+:${be.port}
|
|
651
|
+
|
|
652
|
+
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
|
|
653
|
+
USER appuser
|
|
654
|
+
|
|
655
|
+
COPY --from=${stageName} /app/publish .
|
|
656
|
+
|
|
657
|
+
EXPOSE ${be.port}
|
|
658
|
+
ENTRYPOINT ["dotnet", "${be.projectName}.dll"]`.trim());
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const header = `# ⚠️ IMPORTANT: Replace your .dockerignore with the generated one from the .dockerignore tab.\n# Without it, COPY commands may fail if your existing .dockerignore uses a whitelist.\n`;
|
|
663
|
+
const dockerfile = header + stages.join('\n\n');
|
|
664
|
+
const dockerignore = buildDockerignore(services);
|
|
665
|
+
|
|
666
|
+
if (frontends.length > 0 && backends.length > 0) {
|
|
667
|
+
improvements.push('Multi-stage build: frontend compiled separately, backend runs lean runtime image');
|
|
668
|
+
}
|
|
669
|
+
improvements.push('HEALTHCHECK should probe /health for each runtime service; add a /health endpoint returning 2xx where missing');
|
|
670
|
+
|
|
671
|
+
const collectedNginxConf = frontends.length > 0 ? nginxConf(frontends[0].port) : undefined;
|
|
672
|
+
return { dockerfile, dockerignore, improvements, nginxConf: collectedNginxConf };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── Node at root (original behaviour) ───────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
function generateNodeRoot(a, envVars = [], rootConfigFiles = []) {
|
|
678
|
+
const baseImage = BASE_IMAGES[STACKS.NODE](a.version);
|
|
679
|
+
const improvements = [];
|
|
680
|
+
const startCmdJson = JSON.stringify(runtimeStartCmd(a));
|
|
681
|
+
const extraConfigs = rootConfigFiles.filter(f => !['package.json', a.lockFile].includes(f));
|
|
682
|
+
const configCopyLine = extraConfigs.length > 0 ? `COPY ${extraConfigs.join(' ')} ./\n` : '';
|
|
683
|
+
const frontendAssetCopyLine = (a.role === 'frontend' || a.framework === 'cra') ? 'COPY public/ ./public/\n' : '';
|
|
684
|
+
|
|
685
|
+
let dockerfile;
|
|
686
|
+
|
|
687
|
+
if (a.hasBuild) {
|
|
688
|
+
const buildInstall = nodeInstallLine(a.packageManager, false, { hasScopedPackages: a.hasScopedPackages });
|
|
689
|
+
const buildPrefix = buildCommandPrefix(a);
|
|
690
|
+
const buildOut = a.buildOutputDir || 'dist';
|
|
691
|
+
const sourceCopyBlock = nodeSourceCopyBlock(a, configCopyLine, frontendAssetCopyLine);
|
|
692
|
+
|
|
693
|
+
// CRA / pure-frontend: runtime is a static file server, not a Node app.
|
|
694
|
+
// react-scripts is a devDep — it won't be present after --production install,
|
|
695
|
+
// and react-scripts start is a dev server anyway.
|
|
696
|
+
if (a.role === 'frontend' || a.framework === 'cra') {
|
|
697
|
+
dockerfile = `
|
|
698
|
+
# ─── Stage 1: Build ───────────────────────────────────────
|
|
699
|
+
${builderFrom(baseImage, 'builder')}
|
|
700
|
+
|
|
701
|
+
WORKDIR /app
|
|
702
|
+
|
|
703
|
+
COPY ${a.lockFile} package.json ./
|
|
704
|
+
${buildInstall}
|
|
705
|
+
|
|
706
|
+
# Copy source
|
|
707
|
+
${sourceCopyBlock}
|
|
708
|
+
RUN ${buildPrefix}${a.buildCommand}
|
|
709
|
+
|
|
710
|
+
# ─── Stage 2: Runtime ─────────────────────────────────────
|
|
711
|
+
FROM ${STATIC_RUNTIME_IMAGE}
|
|
712
|
+
|
|
713
|
+
COPY --from=builder /app/${buildOut} /usr/share/nginx/html
|
|
714
|
+
${nginxStaticServerBlock(a.port)}
|
|
715
|
+
|
|
716
|
+
EXPOSE ${a.port}
|
|
717
|
+
${simpleHttpHealthcheck(a.port)}
|
|
718
|
+
CMD ["nginx", "-g", "daemon off;"]`.trim();
|
|
719
|
+
|
|
720
|
+
const validationDockerfile = dockerfile;
|
|
721
|
+
improvements.push(`Frontend-only: built then served with ${STATIC_RUNTIME_IMAGE}. No Node runtime or node_modules in final image.`);
|
|
722
|
+
return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), nginxConf: nginxConf(a.port), improvements }, validationDockerfile);
|
|
723
|
+
|
|
724
|
+
} else {
|
|
725
|
+
const pruneCmd = nodePruneLine(a.packageManager);
|
|
726
|
+
dockerfile = `
|
|
727
|
+
# ─── Stage 1: Build ───────────────────────────────────────
|
|
728
|
+
${builderFrom(baseImage, 'builder')}
|
|
729
|
+
|
|
730
|
+
WORKDIR /app
|
|
731
|
+
|
|
732
|
+
COPY ${a.lockFile} package.json ./
|
|
733
|
+
${buildInstall}
|
|
734
|
+
|
|
735
|
+
# Copy source
|
|
736
|
+
${sourceCopyBlock}
|
|
737
|
+
RUN ${buildPrefix}${a.buildCommand}
|
|
738
|
+
${pruneCmd}
|
|
739
|
+
|
|
740
|
+
# ─── Stage 2: Runtime ─────────────────────────────────────
|
|
741
|
+
FROM ${baseImage}
|
|
742
|
+
|
|
743
|
+
WORKDIR /app
|
|
744
|
+
|
|
745
|
+
${buildEnvBlock(envVars)}
|
|
746
|
+
|
|
747
|
+
COPY ${a.lockFile} package.json ./
|
|
748
|
+
COPY --from=builder /app/node_modules ./node_modules
|
|
749
|
+
COPY --from=builder /app/${buildOut} ./${buildOut}
|
|
750
|
+
|
|
751
|
+
${runtimeUserBlock(true)}
|
|
752
|
+
|
|
753
|
+
${nodeStopSignalBlock()}
|
|
754
|
+
EXPOSE ${a.port}
|
|
755
|
+
${simpleHttpHealthcheck(a.port)}
|
|
756
|
+
CMD ${startCmdJson}`.trim();
|
|
757
|
+
}
|
|
758
|
+
} else {
|
|
759
|
+
dockerfile = `
|
|
760
|
+
FROM ${baseImage}
|
|
761
|
+
|
|
762
|
+
WORKDIR /app
|
|
763
|
+
|
|
764
|
+
${buildEnvBlock(envVars)}
|
|
765
|
+
|
|
766
|
+
COPY ${a.lockFile} package.json ./
|
|
767
|
+
${nodeInstallLine(a.packageManager, true, { hasScopedPackages: a.hasScopedPackages })}
|
|
768
|
+
|
|
769
|
+
# Copy source
|
|
770
|
+
${nodeSourceCopyBlock(a, configCopyLine)}
|
|
771
|
+
|
|
772
|
+
${runtimeUserBlock(true)}
|
|
773
|
+
|
|
774
|
+
${nodeStopSignalBlock()}
|
|
775
|
+
EXPOSE ${a.port}
|
|
776
|
+
${simpleHttpHealthcheck(a.port)}
|
|
777
|
+
CMD ${startCmdJson}`.trim();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
improvements.push('HEALTHCHECK probes /health; add a /health endpoint returning 2xx if your app does not already expose one');
|
|
781
|
+
if (!a.hasBuild) improvements.push('If you add a build step later, use multi-stage builds to keep image small');
|
|
782
|
+
if (a.framework === null) {
|
|
783
|
+
improvements.push('WARNING: Unknown Node backend source layout - verify your source is not under src/ before using the generated COPY src/ line.');
|
|
784
|
+
}
|
|
785
|
+
if (a.hasScopedPackages) {
|
|
786
|
+
improvements.push('Scoped packages detected: pass private registry credentials with docker build --secret id=npmrc,src=.npmrc');
|
|
787
|
+
}
|
|
788
|
+
improvements.push('Verify your source lives in src/ — if not, update the COPY src/ line in the build stage to match your actual source directory.');
|
|
789
|
+
|
|
790
|
+
improvements.push('Add a SIGTERM handler: process.on(\'SIGTERM\', () => server.close())');
|
|
791
|
+
|
|
792
|
+
return { dockerfile, dockerignore: nodeDockerignore(), improvements };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ── Node in a subdirectory ───────────────────────────────────────────────────
|
|
796
|
+
|
|
797
|
+
function generateNodeSub(a, dir, sharedDirs, staticAssets = [], rootConfigFiles = [], envVars = [], workspacePackageDirs = [], workspaceSharedConfigs = []) {
|
|
798
|
+
const baseImage = BASE_IMAGES[STACKS.NODE](a.version);
|
|
799
|
+
const improvements = [];
|
|
800
|
+
const sharedCopies = sharedDirs.map(s => `COPY ${s}/ ./${s}/`).join('\n');
|
|
801
|
+
const staticCopies = staticAssets.map(s => `COPY ${s}/ ./${s}/`).join('\n');
|
|
802
|
+
// Sibling dirs detected via require('../xxx') in the entry point — deduplicated against sharedDirs
|
|
803
|
+
const siblingOnlyCopies = (a.siblingDeps || [])
|
|
804
|
+
.filter(d => !sharedDirs.includes(d))
|
|
805
|
+
.map(d => `COPY ${d}/ ./${d}/`)
|
|
806
|
+
.join('\n');
|
|
807
|
+
|
|
808
|
+
let dockerfile;
|
|
809
|
+
|
|
810
|
+
if (a.hasBuild && a.role === 'frontend') {
|
|
811
|
+
// Frontend build → serve static via nginx
|
|
812
|
+
const stageName = 'build';
|
|
813
|
+
|
|
814
|
+
// When lock file is at project root, copy root files + service package.json and
|
|
815
|
+
// use workspace-scoped build. Build output lands at /build/<dir>/dist in that case.
|
|
816
|
+
let lockCopy, buildInstall, sourceCopy, buildRun, buildOutput;
|
|
817
|
+
if (a.lockFileAtRoot) {
|
|
818
|
+
// Workspace monorepo: copy root lockfile + this service's package.json,
|
|
819
|
+
// then copy service source explicitly.
|
|
820
|
+
// COPY . . is banned — leaks .env and secrets into build context.
|
|
821
|
+
if (a.packageManager === 'yarn') {
|
|
822
|
+
// Copy root lockfile + package.json + ALL workspace member package.json files.
|
|
823
|
+
// Yarn hoists devDeps from ALL workspace members to root node_modules/.bin.
|
|
824
|
+
// Missing members = their devDeps (cross-env, vite, etc.) not installed at all.
|
|
825
|
+
// COPY . . solved this by accident; we replicate it selectively here.
|
|
826
|
+
const extraPkgCopies = workspacePackageDirs
|
|
827
|
+
.filter(d => d !== dir)
|
|
828
|
+
.map(d => `COPY ${d}/package.json ./${d}/package.json`)
|
|
829
|
+
.join('\n');
|
|
830
|
+
lockCopy = `COPY ${a.lockFile} package.json ./\nCOPY ${dir}/package.json ./${dir}/package.json${extraPkgCopies ? '\n' + extraPkgCopies : ''}`;
|
|
831
|
+
buildInstall = `RUN ${nodeSecretMount(a)}yarn install --frozen-lockfile`;
|
|
832
|
+
} else {
|
|
833
|
+
lockCopy = `COPY ${a.lockFile} package.json ./\nCOPY ${dir}/package.json ./${dir}/package.json`;
|
|
834
|
+
buildInstall = installLine(a.packageManager, false);
|
|
835
|
+
}
|
|
836
|
+
sourceCopy = `COPY ${dir}/ ./${dir}/`;
|
|
837
|
+
// Copy root-level dirs imported by vite config (e.g. scripts/) — needed at build time
|
|
838
|
+
const rootDepCopies = (a.rootBuildDeps || []).map(d => `COPY ${d}/ ./${d}/`).join('\n');
|
|
839
|
+
if (rootDepCopies) sourceCopy += '\n' + rootDepCopies;
|
|
840
|
+
// Workspace packages are local source imports — vite resolves them at build time
|
|
841
|
+
const workspaceSrcCopies = workspacePackageDirs
|
|
842
|
+
.filter(d => d !== dir)
|
|
843
|
+
.map(d => `COPY ${d}/ ./${d}/`)
|
|
844
|
+
.join('\n');
|
|
845
|
+
if (workspaceSrcCopies) sourceCopy += '\n' + workspaceSrcCopies;
|
|
846
|
+
const buildPrefix = buildCommandPrefix(a);
|
|
847
|
+
buildRun = a.packageManager === 'yarn'
|
|
848
|
+
// Explicitly prepend /build/node_modules/.bin to PATH before build.
|
|
849
|
+
// Hoisted devDeps (cross-env, vite, etc.) land in root node_modules/.bin,
|
|
850
|
+
// but yarn 1.x script runner does not reliably add them when running via
|
|
851
|
+
// --cwd or from a sub-yarn call. export PATH is inherited by all child
|
|
852
|
+
// processes (yarn build:app → shell → cross-env) so this covers nested calls.
|
|
853
|
+
? `RUN export PATH="/build/node_modules/.bin:$PATH" && cd ${dir} && ${buildPrefix}yarn build`
|
|
854
|
+
: a.packageManager === 'pnpm'
|
|
855
|
+
? `RUN ${buildPrefix}pnpm --filter ./${dir} build`
|
|
856
|
+
: `RUN ${buildPrefix}npm run build --workspace=${dir}`;
|
|
857
|
+
buildOutput = `/build/${dir}/${a.buildOutputDir || 'build'}`;
|
|
858
|
+
} else {
|
|
859
|
+
lockCopy = `COPY ${dir}/${a.lockFile} ${dir}/package.json ./`;
|
|
860
|
+
buildInstall = installLine(a.packageManager, false);
|
|
861
|
+
sourceCopy = `COPY ${dir}/ .`;
|
|
862
|
+
buildRun = `RUN ${buildCommandPrefix(a)}${a.buildCommand}`;
|
|
863
|
+
buildOutput = `/build/${a.buildOutputDir || 'build'}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const rootConfigCopy = rootConfigFiles.length > 0
|
|
867
|
+
? `# Copy root config files needed during build\nCOPY ${rootConfigFiles.join(' ')} ./\n`
|
|
868
|
+
: '';
|
|
869
|
+
const sharedConfigCopy = workspaceSharedConfigs.length > 0
|
|
870
|
+
? workspaceSharedConfigs.map(f => `COPY ${f} ./${path.dirname(f)}/`).join('\n') + '\n'
|
|
871
|
+
: '';
|
|
872
|
+
|
|
873
|
+
dockerfile = `
|
|
874
|
+
# ─── Stage 1: Build ───────────────────────────────────────
|
|
875
|
+
${builderFrom(baseImage, stageName)}
|
|
876
|
+
|
|
877
|
+
WORKDIR /build
|
|
878
|
+
|
|
879
|
+
${lockCopy}
|
|
880
|
+
${buildInstall}
|
|
881
|
+
${rootConfigCopy}
|
|
882
|
+
${sourceCopy}
|
|
883
|
+
${sharedConfigCopy}${buildRun}
|
|
884
|
+
|
|
885
|
+
# ─── Stage 2: Serve ───────────────────────────────────────
|
|
886
|
+
FROM ${STATIC_RUNTIME_IMAGE}
|
|
887
|
+
|
|
888
|
+
COPY --from=${stageName} ${buildOutput} /usr/share/nginx/html
|
|
889
|
+
${nginxStaticServerBlock(a.port)}
|
|
890
|
+
|
|
891
|
+
EXPOSE ${a.port}
|
|
892
|
+
${simpleHttpHealthcheck(a.port)}
|
|
893
|
+
CMD ["nginx", "-g", "daemon off;"]`.trim();
|
|
894
|
+
|
|
895
|
+
const validationDockerfile = dockerfile;
|
|
896
|
+
improvements.push(`Frontend-only: built then served with ${STATIC_RUNTIME_IMAGE}. If a backend also serves the files, merge into one multi-service build.`);
|
|
897
|
+
return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), nginxConf: nginxConf(a.port), improvements }, validationDockerfile);
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
} else {
|
|
901
|
+
// Backend/service in subdirectory
|
|
902
|
+
const startCmdJson = JSON.stringify(runtimeStartCmd(a));
|
|
903
|
+
const outputDir = a.buildOutputDir || 'dist';
|
|
904
|
+
|
|
905
|
+
let lockCopy, installStep;
|
|
906
|
+
if (a.lockFileAtRoot) {
|
|
907
|
+
lockCopy = `COPY ${a.lockFile} package.json ./\nCOPY ${dir}/package.json ./${dir}/`;
|
|
908
|
+
if (a.packageManager === 'pnpm') {
|
|
909
|
+
const filter = a.isWorkspace ? ` --filter ${dir}` : '';
|
|
910
|
+
installStep = `RUN ${nodeSecretMount(a)}${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile --prod${filter} && pnpm store prune`;
|
|
911
|
+
} else if (a.packageManager === 'yarn') {
|
|
912
|
+
installStep = a.isWorkspace
|
|
913
|
+
? `RUN ${nodeSecretMount(a)}yarn --cwd ./${dir} install --frozen-lockfile --production && yarn cache clean`
|
|
914
|
+
: `RUN ${nodeSecretMount(a)}yarn install --frozen-lockfile --production && yarn cache clean`;
|
|
915
|
+
} else {
|
|
916
|
+
installStep = `RUN ${nodeSecretMount(a)}npm ci --omit=dev && npm cache clean --force`;
|
|
917
|
+
}
|
|
918
|
+
} else {
|
|
919
|
+
lockCopy = `COPY ${dir}/${a.lockFile} ${dir}/package.json ./${dir}/`;
|
|
920
|
+
installStep = a.packageManager === 'pnpm'
|
|
921
|
+
? `RUN ${nodeSecretMount(a)}${installPnpmGlobalCmd()} && cd ${dir} && pnpm install --frozen-lockfile --prod && pnpm store prune`
|
|
922
|
+
: a.packageManager === 'yarn'
|
|
923
|
+
? `RUN ${nodeSecretMount(a)}cd ${dir} && yarn install --frozen-lockfile --production && yarn cache clean`
|
|
924
|
+
: `RUN ${nodeSecretMount(a)}cd ${dir} && npm ci --omit=dev && npm cache clean --force`;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// When backend has a build step, add a build stage and copy only compiled output
|
|
928
|
+
if (a.hasBuild) {
|
|
929
|
+
const rootConfigCopy = rootConfigFiles.length > 0
|
|
930
|
+
? `# Copy root config files needed during build\nCOPY ${rootConfigFiles.join(' ')} ./\n`
|
|
931
|
+
: '';
|
|
932
|
+
const buildInstall = a.lockFileAtRoot
|
|
933
|
+
? (a.packageManager === 'pnpm'
|
|
934
|
+
? `RUN ${nodeSecretMount(a)}${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile && pnpm store prune`
|
|
935
|
+
: a.packageManager === 'yarn'
|
|
936
|
+
? `RUN ${nodeSecretMount(a)}yarn install --frozen-lockfile && yarn cache clean`
|
|
937
|
+
: `RUN ${nodeSecretMount(a)}npm ci && npm cache clean --force`)
|
|
938
|
+
: (a.packageManager === 'pnpm'
|
|
939
|
+
? `RUN ${nodeSecretMount(a)}${installPnpmGlobalCmd()} && cd ${dir} && pnpm install --frozen-lockfile && pnpm store prune`
|
|
940
|
+
: a.packageManager === 'yarn'
|
|
941
|
+
? `RUN ${nodeSecretMount(a)}cd ${dir} && yarn install --frozen-lockfile && yarn cache clean`
|
|
942
|
+
: `RUN ${nodeSecretMount(a)}cd ${dir} && npm ci && npm cache clean --force`);
|
|
943
|
+
const buildCmd = a.lockFileAtRoot
|
|
944
|
+
? (a.packageManager === 'yarn' ? `RUN export PATH="/build/node_modules/.bin:$PATH" && cd ${dir} && yarn build`
|
|
945
|
+
: a.packageManager === 'pnpm' ? `RUN pnpm --filter ./${dir} build`
|
|
946
|
+
: `RUN npm run build --workspace=${dir}`)
|
|
947
|
+
: `RUN cd ${dir} && ${a.buildCommand}`;
|
|
948
|
+
|
|
949
|
+
const buildStageName = `${dir.replace(/\//g, '-')}-build`;
|
|
950
|
+
const pruneCmd = nodePruneLine(a.packageManager, a.lockFileAtRoot ? '' : dir);
|
|
951
|
+
const runtimeNodeModulesCopy = a.lockFileAtRoot
|
|
952
|
+
? `COPY --from=${buildStageName} /build/node_modules ./node_modules`
|
|
953
|
+
: `COPY --from=${buildStageName} /build/${dir}/node_modules ./${dir}/node_modules`;
|
|
954
|
+
dockerfile = `
|
|
955
|
+
# ─── Stage 1: Build ───────────────────────────────────────
|
|
956
|
+
${builderFrom(baseImage, buildStageName)}
|
|
957
|
+
|
|
958
|
+
WORKDIR /build
|
|
959
|
+
|
|
960
|
+
# Install all deps (dev included) for build
|
|
961
|
+
${lockCopy}
|
|
962
|
+
${buildInstall}
|
|
963
|
+
${rootConfigCopy}
|
|
964
|
+
COPY ${dir}/ ./${dir}/
|
|
965
|
+
${buildCmd}
|
|
966
|
+
${pruneCmd}
|
|
967
|
+
|
|
968
|
+
# ─── Stage 2: Runtime ─────────────────────────────────────
|
|
969
|
+
FROM ${baseImage}
|
|
970
|
+
|
|
971
|
+
WORKDIR /app
|
|
972
|
+
|
|
973
|
+
${buildEnvBlock(envVars)}
|
|
974
|
+
|
|
975
|
+
# Install prod deps only
|
|
976
|
+
${lockCopy}
|
|
977
|
+
${runtimeNodeModulesCopy}
|
|
978
|
+
|
|
979
|
+
# Copy compiled output from build stage — no raw source in runtime
|
|
980
|
+
COPY --from=${buildStageName} /build/${dir}/${outputDir} ./${dir}/${outputDir}
|
|
981
|
+
${sharedCopies ? `\n# Copy shared utilities\n${sharedCopies}` : ''}${siblingOnlyCopies ? `\n# Copy sibling dirs required via require('../xxx')\n${siblingOnlyCopies}` : ''}
|
|
982
|
+
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
|
983
|
+
USER appuser
|
|
984
|
+
|
|
985
|
+
${nodeStopSignalBlock()}
|
|
986
|
+
EXPOSE ${a.port}
|
|
987
|
+
${simpleHttpHealthcheck(a.port)}
|
|
988
|
+
WORKDIR /app/${dir}
|
|
989
|
+
CMD ${startCmdJson}`.trim();
|
|
990
|
+
|
|
991
|
+
improvements.push(`Backend (${dir}): compiled in isolated build stage — source and dev tools stay out of runtime image`);
|
|
992
|
+
} else {
|
|
993
|
+
dockerfile = `
|
|
994
|
+
FROM ${baseImage}
|
|
995
|
+
|
|
996
|
+
WORKDIR /app
|
|
997
|
+
|
|
998
|
+
${buildEnvBlock(envVars)}
|
|
999
|
+
|
|
1000
|
+
# Install prod deps — separate layer so Docker cache busts only on dependency changes
|
|
1001
|
+
${lockCopy}
|
|
1002
|
+
${installStep}
|
|
1003
|
+
|
|
1004
|
+
# Copy service source
|
|
1005
|
+
COPY ${dir}/ ./${dir}/
|
|
1006
|
+
${sharedCopies ? `\n# Copy shared utilities\n${sharedCopies}` : ''}${siblingOnlyCopies ? `\n# Copy sibling dirs required via require('../xxx')\n${siblingOnlyCopies}` : ''}
|
|
1007
|
+
${staticCopies ? `\n# Copy static assets served by the backend\n${staticCopies}` : ''}
|
|
1008
|
+
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
|
1009
|
+
USER appuser
|
|
1010
|
+
|
|
1011
|
+
${nodeStopSignalBlock()}
|
|
1012
|
+
EXPOSE ${a.port}
|
|
1013
|
+
|
|
1014
|
+
# Set working dir to service root so relative require() paths resolve correctly
|
|
1015
|
+
${simpleHttpHealthcheck(a.port)}
|
|
1016
|
+
WORKDIR /app/${dir}
|
|
1017
|
+
CMD ${startCmdJson}`.trim();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (sharedDirs.length > 0) {
|
|
1021
|
+
improvements.push(`Shared dirs copied: ${sharedDirs.join(', ')} — mounted alongside service so relative imports resolve`);
|
|
1022
|
+
}
|
|
1023
|
+
if ((a.siblingDeps || []).length > 0) {
|
|
1024
|
+
improvements.push(`Sibling dirs detected and copied: ${a.siblingDeps.join(', ')} — referenced via require('../xxx') in your entry point`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
improvements.push('HEALTHCHECK probes /health; add a /health endpoint returning 2xx if your app does not already expose one');
|
|
1029
|
+
improvements.push('Add a SIGTERM handler: process.on(\'SIGTERM\', () => server.close())');
|
|
1030
|
+
|
|
1031
|
+
return { dockerfile, dockerignore: nodeDockerignore(), improvements };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ── Python at root ───────────────────────────────────────────────────────────
|
|
1035
|
+
|
|
1036
|
+
function generatePythonRoot(a, rootConfigFiles = []) {
|
|
1037
|
+
const baseImage = BASE_IMAGES[STACKS.PYTHON](a.version);
|
|
1038
|
+
const builderImage = pythonBuilderImage(a);
|
|
1039
|
+
const improvements = [];
|
|
1040
|
+
|
|
1041
|
+
// Copy root config files if any exist (e.g. pyproject.toml, setup.cfg)
|
|
1042
|
+
const extraConfigs = rootConfigFiles.filter(f => f !== a.requirementsFile);
|
|
1043
|
+
const configCopyLine = extraConfigs.length > 0
|
|
1044
|
+
? `COPY ${extraConfigs.join(' ')} ./\n`
|
|
1045
|
+
: '';
|
|
1046
|
+
|
|
1047
|
+
// Explicit source copy — COPY . . is banned.
|
|
1048
|
+
// Django: manage.py lives at root; user must add app dirs themselves.
|
|
1049
|
+
// FastAPI/Flask: copy the detected entry point.
|
|
1050
|
+
const sourceCopyBlock = a.framework === 'django'
|
|
1051
|
+
? `COPY manage.py ./\n# TODO: add your Django app directories below, e.g.:\n# COPY myapp/ ./myapp/`
|
|
1052
|
+
: `COPY ${a.entryPoint} ./`;
|
|
1053
|
+
|
|
1054
|
+
const detectedDjangoCopyBlock = a.framework === 'django' && (a.djangoAppDirs || []).length > 0
|
|
1055
|
+
? `COPY manage.py ./\n${a.djangoAppDirs.map(d => `COPY ${d}/ ./${d}/`).join('\n')}`
|
|
1056
|
+
: sourceCopyBlock;
|
|
1057
|
+
|
|
1058
|
+
{
|
|
1059
|
+
const dockerfile = `
|
|
1060
|
+
${builderFrom(builderImage, 'builder')}
|
|
1061
|
+
|
|
1062
|
+
ENV VIRTUAL_ENV=/opt/venv
|
|
1063
|
+
RUN python -m venv $VIRTUAL_ENV
|
|
1064
|
+
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
|
1065
|
+
|
|
1066
|
+
WORKDIR /app
|
|
1067
|
+
|
|
1068
|
+
COPY ${a.requirementsFile} ./
|
|
1069
|
+
${pythonInstallBlock(a)}
|
|
1070
|
+
|
|
1071
|
+
FROM ${baseImage}
|
|
1072
|
+
|
|
1073
|
+
ENV VIRTUAL_ENV=/opt/venv
|
|
1074
|
+
ENV PATH="/opt/venv/bin:$PATH"
|
|
1075
|
+
ENV PYTHONDONTWRITEBYTECODE=1 \\
|
|
1076
|
+
PYTHONUNBUFFERED=1 \\
|
|
1077
|
+
PYTHONPATH=/app
|
|
1078
|
+
|
|
1079
|
+
WORKDIR /app
|
|
1080
|
+
|
|
1081
|
+
COPY --from=builder /opt/venv /opt/venv
|
|
1082
|
+
${configCopyLine}${detectedDjangoCopyBlock}
|
|
1083
|
+
|
|
1084
|
+
${runtimeUserBlock(false)}
|
|
1085
|
+
|
|
1086
|
+
EXPOSE ${a.port}
|
|
1087
|
+
CMD ${JSON.stringify(dockerCmdArray(a.startCmd, ['python', a.entryPoint]))}`.trim();
|
|
1088
|
+
|
|
1089
|
+
if (a.hasNativeDeps) {
|
|
1090
|
+
improvements.push(`Native Python dependencies detected (${(a.nativeDeps || []).join(', ')}) - installed in a builder venv so build tooling stays out of runtime`);
|
|
1091
|
+
}
|
|
1092
|
+
improvements.push('Pin all dependency versions in requirements.txt for reproducible builds');
|
|
1093
|
+
if (!a.requirementsHasHashes) {
|
|
1094
|
+
improvements.push('Use pip-tools hashes in requirements.txt to enable pip --require-hashes');
|
|
1095
|
+
}
|
|
1096
|
+
improvements.push('Verify all source directories are explicitly COPYed - add COPY <yourdir>/ ./<yourdir>/ for each module');
|
|
1097
|
+
if (a.framework === 'django') {
|
|
1098
|
+
improvements.push('Run collectstatic as part of your build or entrypoint for production');
|
|
1099
|
+
improvements.push('Use gunicorn instead of the Django dev server in production');
|
|
1100
|
+
}
|
|
1101
|
+
if (a.framework === 'fastapi') {
|
|
1102
|
+
improvements.push('Tune FastAPI --workers for the CPU quota assigned to the container');
|
|
1103
|
+
}
|
|
1104
|
+
return { dockerfile, dockerignore: pythonDockerignore(), improvements };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// ── Python in subdirectory ───────────────────────────────────────────────────
|
|
1110
|
+
|
|
1111
|
+
function generatePythonSub(a, dir, sharedDirs) {
|
|
1112
|
+
const baseImage = BASE_IMAGES[STACKS.PYTHON](a.version);
|
|
1113
|
+
const builderImage = pythonBuilderImage(a);
|
|
1114
|
+
const sharedCopies = sharedDirs.map(s => `COPY ${s}/ ./${s}/`).join('\n');
|
|
1115
|
+
|
|
1116
|
+
{
|
|
1117
|
+
const dockerfile = `
|
|
1118
|
+
${builderFrom(builderImage, 'builder')}
|
|
1119
|
+
|
|
1120
|
+
ENV VIRTUAL_ENV=/opt/venv
|
|
1121
|
+
RUN python -m venv $VIRTUAL_ENV
|
|
1122
|
+
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
|
1123
|
+
|
|
1124
|
+
WORKDIR /app
|
|
1125
|
+
|
|
1126
|
+
COPY ${dir}/${a.requirementsFile} ./${dir}/
|
|
1127
|
+
${pythonInstallBlock(a, `cd ${dir} && `)}
|
|
1128
|
+
|
|
1129
|
+
FROM ${baseImage}
|
|
1130
|
+
|
|
1131
|
+
ENV VIRTUAL_ENV=/opt/venv
|
|
1132
|
+
ENV PATH="/opt/venv/bin:$PATH"
|
|
1133
|
+
ENV PYTHONDONTWRITEBYTECODE=1 \\
|
|
1134
|
+
PYTHONUNBUFFERED=1 \\
|
|
1135
|
+
PYTHONPATH=/app
|
|
1136
|
+
|
|
1137
|
+
WORKDIR /app
|
|
1138
|
+
|
|
1139
|
+
COPY --from=builder /opt/venv /opt/venv
|
|
1140
|
+
COPY ${dir}/ ./${dir}/
|
|
1141
|
+
${sharedCopies ? `\n# Copy shared utilities\n${sharedCopies}` : ''}
|
|
1142
|
+
|
|
1143
|
+
${runtimeUserBlock(false)}
|
|
1144
|
+
|
|
1145
|
+
EXPOSE ${a.port}
|
|
1146
|
+
WORKDIR /app/${dir}
|
|
1147
|
+
CMD ${JSON.stringify(dockerCmdArray(a.startCmd, ['python', a.entryPoint]))}`.trim();
|
|
1148
|
+
|
|
1149
|
+
const improvements = [];
|
|
1150
|
+
if (a.hasNativeDeps) {
|
|
1151
|
+
improvements.push(`Native Python dependencies detected (${(a.nativeDeps || []).join(', ')}) - installed in a builder venv so build tooling stays out of runtime`);
|
|
1152
|
+
}
|
|
1153
|
+
return { dockerfile, dockerignore: pythonDockerignore(), improvements };
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// ── .NET at root ─────────────────────────────────────────────────────────────
|
|
1159
|
+
|
|
1160
|
+
function generateDotnetRoot(a) {
|
|
1161
|
+
const runtimeImage = BASE_IMAGES[STACKS.DOTNET].runtime(a.version);
|
|
1162
|
+
const sdkImage = BASE_IMAGES[STACKS.DOTNET].sdk(a.version);
|
|
1163
|
+
|
|
1164
|
+
// Determine source root — csprojPath is relative, e.g. "MyApp.csproj" or "src/MyApp.csproj"
|
|
1165
|
+
// If the .csproj is in a subdirectory, copy that subdirectory. Otherwise copy common C# dirs.
|
|
1166
|
+
const csprojDir = a.csprojPath.includes('/')
|
|
1167
|
+
? a.csprojPath.split('/').slice(0, -1).join('/')
|
|
1168
|
+
: null;
|
|
1169
|
+
|
|
1170
|
+
let sourceCopyBlock = csprojDir
|
|
1171
|
+
? `COPY ${csprojDir}/ ./${csprojDir}/`
|
|
1172
|
+
: `# Copy source - add explicit COPY for each source directory in your project\nCOPY *.cs ./\nCOPY Properties/ ./Properties/`;
|
|
1173
|
+
|
|
1174
|
+
if (!csprojDir && a.dotnetSourceDirs && a.dotnetSourceDirs.length > 0) {
|
|
1175
|
+
sourceCopyBlock = ['COPY *.cs ./', ...a.dotnetSourceDirs.map(d => `COPY ${d}/ ./${d}/`)].join('\n');
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const dockerfile = `
|
|
1179
|
+
# ─── Stage 1: Build ───────────────────────────────────────
|
|
1180
|
+
${builderFrom(sdkImage, 'builder')}
|
|
1181
|
+
|
|
1182
|
+
WORKDIR /src
|
|
1183
|
+
|
|
1184
|
+
COPY ${a.csprojPath} ./
|
|
1185
|
+
RUN dotnet restore "${a.csprojPath}"
|
|
1186
|
+
|
|
1187
|
+
${sourceCopyBlock}
|
|
1188
|
+
${dotnetPublishLine(a.csprojPath, runtimeImage)}
|
|
1189
|
+
|
|
1190
|
+
# ─── Stage 2: Runtime ─────────────────────────────────────
|
|
1191
|
+
FROM ${runtimeImage}
|
|
1192
|
+
|
|
1193
|
+
WORKDIR /app
|
|
1194
|
+
|
|
1195
|
+
ENV DOTNET_RUNNING_IN_CONTAINER=true \\
|
|
1196
|
+
ASPNETCORE_URLS=http://+:${a.port}
|
|
1197
|
+
|
|
1198
|
+
COPY --from=builder /app/publish .
|
|
1199
|
+
|
|
1200
|
+
${runtimeUserBlock(false)}
|
|
1201
|
+
|
|
1202
|
+
EXPOSE ${a.port}
|
|
1203
|
+
ENTRYPOINT ["dotnet", "${a.projectName}.dll"]`.trim();
|
|
1204
|
+
|
|
1205
|
+
return {
|
|
1206
|
+
dockerfile,
|
|
1207
|
+
dockerignore: dotnetDockerignore(),
|
|
1208
|
+
improvements: [
|
|
1209
|
+
'Set ASPNETCORE_ENVIRONMENT=Production in your container runtime config, not in the Dockerfile',
|
|
1210
|
+
'Consider adding a health check endpoint (/health) and HEALTHCHECK instruction',
|
|
1211
|
+
'Add explicit COPY instructions for each source directory (Controllers/, Models/, Services/, etc.) to avoid leaking secrets into build context',
|
|
1212
|
+
],
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// ── .NET in subdirectory ─────────────────────────────────────────────────────
|
|
1217
|
+
|
|
1218
|
+
function generateDotnetSub(a, dir, sharedDirs) {
|
|
1219
|
+
const runtimeImage = BASE_IMAGES[STACKS.DOTNET].runtime(a.version);
|
|
1220
|
+
const sdkImage = BASE_IMAGES[STACKS.DOTNET].sdk(a.version);
|
|
1221
|
+
|
|
1222
|
+
const dockerfile = `
|
|
1223
|
+
# ─── Stage 1: Build ───────────────────────────────────────
|
|
1224
|
+
${builderFrom(sdkImage, 'builder')}
|
|
1225
|
+
|
|
1226
|
+
WORKDIR /src
|
|
1227
|
+
|
|
1228
|
+
COPY ${dir}/${a.csprojPath} ./${dir}/
|
|
1229
|
+
RUN dotnet restore "${dir}/${a.csprojPath}"
|
|
1230
|
+
|
|
1231
|
+
COPY ${dir}/ ./${dir}/
|
|
1232
|
+
${dotnetPublishLine(`${dir}/${a.csprojPath}`, runtimeImage)}
|
|
1233
|
+
|
|
1234
|
+
# ─── Stage 2: Runtime ─────────────────────────────────────
|
|
1235
|
+
FROM ${runtimeImage}
|
|
1236
|
+
|
|
1237
|
+
WORKDIR /app
|
|
1238
|
+
|
|
1239
|
+
ENV DOTNET_RUNNING_IN_CONTAINER=true \\
|
|
1240
|
+
ASPNETCORE_URLS=http://+:${a.port}
|
|
1241
|
+
|
|
1242
|
+
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
|
|
1243
|
+
USER appuser
|
|
1244
|
+
|
|
1245
|
+
COPY --from=builder /app/publish .
|
|
1246
|
+
|
|
1247
|
+
EXPOSE ${a.port}
|
|
1248
|
+
ENTRYPOINT ["dotnet", "${a.projectName}.dll"]`.trim();
|
|
1249
|
+
|
|
1250
|
+
return { dockerfile, dockerignore: dotnetDockerignore(), improvements: [] };
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
1254
|
+
|
|
1255
|
+
function installLine(packageManager, prodOnly) {
|
|
1256
|
+
if (packageManager === 'pnpm') {
|
|
1257
|
+
return prodOnly
|
|
1258
|
+
? `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile --prod`
|
|
1259
|
+
: `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile`;
|
|
1260
|
+
}
|
|
1261
|
+
if (packageManager === 'yarn') {
|
|
1262
|
+
return prodOnly
|
|
1263
|
+
? 'RUN yarn install --frozen-lockfile --production'
|
|
1264
|
+
: 'RUN yarn install --frozen-lockfile';
|
|
1265
|
+
}
|
|
1266
|
+
return prodOnly ? 'RUN npm ci --omit=dev' : 'RUN npm ci';
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function buildDockerignore(services) {
|
|
1270
|
+
const hasNode = services.some(s => s.stack === STACKS.NODE);
|
|
1271
|
+
const hasPython = services.some(s => s.stack === STACKS.PYTHON);
|
|
1272
|
+
const hasDotnet = services.some(s => s.stack === STACKS.DOTNET);
|
|
1273
|
+
|
|
1274
|
+
const lines = ['.git', '.gitignore', '*.md', '.env', '.env.*', 'Dockerfile', '.dockerignore'];
|
|
1275
|
+
|
|
1276
|
+
if (hasNode) lines.push('**/node_modules', '**/npm-debug.log', '**/.next', '**/dist', '**/coverage');
|
|
1277
|
+
if (hasPython) lines.push('**/__pycache__', '**/*.pyc', '**/.venv', '**/venv', '**/*.egg-info');
|
|
1278
|
+
if (hasDotnet) lines.push('**/bin', '**/obj', '**/*.user', '**/TestResults');
|
|
1279
|
+
|
|
1280
|
+
// Explicitly un-exclude all top-level service dirs so existing whitelist dockerignores
|
|
1281
|
+
// don't silently block COPY commands. Safe no-op on repos without a whitelist.
|
|
1282
|
+
const topLevelServiceDirs = [...new Set(
|
|
1283
|
+
services
|
|
1284
|
+
.map(s => s.serviceDir.split('/')[0])
|
|
1285
|
+
.filter(d => d && d !== '.')
|
|
1286
|
+
)];
|
|
1287
|
+
if (topLevelServiceDirs.length > 0) {
|
|
1288
|
+
lines.push('', '# Ensure service dirs are always in build context');
|
|
1289
|
+
topLevelServiceDirs.forEach(d => lines.push(`!${d}/`));
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
return lines.join('\n');
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function nodeDockerignore() {
|
|
1296
|
+
return `node_modules
|
|
1297
|
+
npm-debug.log
|
|
1298
|
+
yarn-error.log
|
|
1299
|
+
.pnpm-debug.log
|
|
1300
|
+
.git
|
|
1301
|
+
.gitignore
|
|
1302
|
+
.env
|
|
1303
|
+
.env.*
|
|
1304
|
+
!.env.example
|
|
1305
|
+
!.env.sample
|
|
1306
|
+
*.md
|
|
1307
|
+
dist
|
|
1308
|
+
coverage
|
|
1309
|
+
.nyc_output
|
|
1310
|
+
.next
|
|
1311
|
+
.nuxt
|
|
1312
|
+
Dockerfile
|
|
1313
|
+
.dockerignore`.trim();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function pythonDockerignore() {
|
|
1317
|
+
return `__pycache__
|
|
1318
|
+
*.pyc
|
|
1319
|
+
*.pyo
|
|
1320
|
+
*.pyd
|
|
1321
|
+
.Python
|
|
1322
|
+
.venv
|
|
1323
|
+
venv
|
|
1324
|
+
env
|
|
1325
|
+
ENV
|
|
1326
|
+
.git
|
|
1327
|
+
.gitignore
|
|
1328
|
+
.env
|
|
1329
|
+
.env.*
|
|
1330
|
+
*.md
|
|
1331
|
+
*.egg-info
|
|
1332
|
+
dist
|
|
1333
|
+
build
|
|
1334
|
+
.pytest_cache
|
|
1335
|
+
.mypy_cache
|
|
1336
|
+
.ruff_cache
|
|
1337
|
+
Dockerfile
|
|
1338
|
+
.dockerignore`.trim();
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function dotnetDockerignore() {
|
|
1342
|
+
return `bin
|
|
1343
|
+
obj
|
|
1344
|
+
.git
|
|
1345
|
+
.gitignore
|
|
1346
|
+
.env
|
|
1347
|
+
*.md
|
|
1348
|
+
**/*.user
|
|
1349
|
+
**/*.suo
|
|
1350
|
+
.vs
|
|
1351
|
+
TestResults
|
|
1352
|
+
Dockerfile
|
|
1353
|
+
.dockerignore`.trim();
|
|
1354
|
+
}
|
|
1355
|
+
module.exports = { generateDockerfile, addPowerDockerfileHeader };
|