@aifabrix/builder 2.0.2 → 2.0.4
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/README.md +2 -4
- package/lib/app-run.js +55 -165
- package/lib/build.js +3 -62
- package/lib/schema/env-config.yaml +9 -1
- package/lib/secrets.js +52 -194
- package/lib/utils/compose-generator.js +185 -0
- package/lib/utils/docker-build.js +173 -0
- package/lib/utils/health-check.js +26 -7
- package/lib/utils/secrets-generator.js +209 -0
- package/lib/validator.js +7 -3
- package/package.json +2 -1
- package/templates/applications/miso-controller/rbac.yaml +47 -1
- package/templates/applications/miso-controller/variables.yaml +10 -10
- package/templates/infra/compose.yaml +0 -2
- package/templates/python/docker-compose.hbs +22 -13
- package/templates/typescript/docker-compose.hbs +22 -13
package/lib/secrets.js
CHANGED
|
@@ -13,9 +13,12 @@ const fs = require('fs');
|
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const yaml = require('js-yaml');
|
|
15
15
|
const os = require('os');
|
|
16
|
-
const crypto = require('crypto');
|
|
17
16
|
const chalk = require('chalk');
|
|
18
17
|
const logger = require('./utils/logger');
|
|
18
|
+
const {
|
|
19
|
+
generateMissingSecrets,
|
|
20
|
+
createDefaultSecrets
|
|
21
|
+
} = require('./utils/secrets-generator');
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Loads environment configuration for docker/local context
|
|
@@ -42,13 +45,7 @@ function loadEnvConfig() {
|
|
|
42
45
|
* // Returns: { 'postgres-passwordKeyVault': 'admin123', ... }
|
|
43
46
|
*/
|
|
44
47
|
async function loadSecrets(secretsPath) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (!resolvedPath) {
|
|
48
|
-
resolvedPath = path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
|
|
49
|
-
} else if (secretsPath.startsWith('..')) {
|
|
50
|
-
resolvedPath = path.resolve(process.cwd(), secretsPath);
|
|
51
|
-
}
|
|
48
|
+
const resolvedPath = resolveSecretsPath(secretsPath);
|
|
52
49
|
|
|
53
50
|
if (!fs.existsSync(resolvedPath)) {
|
|
54
51
|
throw new Error(`Secrets file not found: ${resolvedPath}`);
|
|
@@ -64,6 +61,45 @@ async function loadSecrets(secretsPath) {
|
|
|
64
61
|
return secrets;
|
|
65
62
|
}
|
|
66
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Resolves secrets file path (same logic as loadSecrets)
|
|
66
|
+
* Also checks common locations if path is not provided
|
|
67
|
+
* @function resolveSecretsPath
|
|
68
|
+
* @param {string} [secretsPath] - Path to secrets file (optional)
|
|
69
|
+
* @returns {string} Resolved secrets file path
|
|
70
|
+
*/
|
|
71
|
+
function resolveSecretsPath(secretsPath) {
|
|
72
|
+
let resolvedPath = secretsPath;
|
|
73
|
+
|
|
74
|
+
if (!resolvedPath) {
|
|
75
|
+
// Check common locations for secrets.local.yaml
|
|
76
|
+
const commonLocations = [
|
|
77
|
+
path.join(process.cwd(), '..', 'aifabrix-setup', 'secrets.local.yaml'),
|
|
78
|
+
path.join(process.cwd(), '..', '..', 'aifabrix-setup', 'secrets.local.yaml'),
|
|
79
|
+
path.join(process.cwd(), 'secrets.local.yaml'),
|
|
80
|
+
path.join(process.cwd(), '..', 'secrets.local.yaml'),
|
|
81
|
+
path.join(os.homedir(), '.aifabrix', 'secrets.yaml')
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// Find first existing file
|
|
85
|
+
for (const location of commonLocations) {
|
|
86
|
+
if (fs.existsSync(location)) {
|
|
87
|
+
resolvedPath = location;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// If none found, use default location
|
|
93
|
+
if (!resolvedPath) {
|
|
94
|
+
resolvedPath = path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
|
|
95
|
+
}
|
|
96
|
+
} else if (secretsPath.startsWith('..')) {
|
|
97
|
+
resolvedPath = path.resolve(process.cwd(), secretsPath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return resolvedPath;
|
|
101
|
+
}
|
|
102
|
+
|
|
67
103
|
/**
|
|
68
104
|
* Resolves kv:// references in environment template
|
|
69
105
|
* Replaces kv://keyName with actual values from secrets
|
|
@@ -73,6 +109,7 @@ async function loadSecrets(secretsPath) {
|
|
|
73
109
|
* @param {string} envTemplate - Environment template content
|
|
74
110
|
* @param {Object} secrets - Secrets object from loadSecrets()
|
|
75
111
|
* @param {string} [environment='local'] - Environment context (docker/local)
|
|
112
|
+
* @param {string} [secretsFilePath] - Path to secrets file (for error messages)
|
|
76
113
|
* @returns {Promise<string>} Resolved environment content
|
|
77
114
|
* @throws {Error} If kv:// reference cannot be resolved
|
|
78
115
|
*
|
|
@@ -80,7 +117,7 @@ async function loadSecrets(secretsPath) {
|
|
|
80
117
|
* const resolved = await resolveKvReferences(template, secrets, 'local');
|
|
81
118
|
* // Returns: 'DATABASE_URL=postgresql://user:pass@localhost:5432/db'
|
|
82
119
|
*/
|
|
83
|
-
async function resolveKvReferences(envTemplate, secrets, environment = 'local') {
|
|
120
|
+
async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePath = null) {
|
|
84
121
|
const envConfig = loadEnvConfig();
|
|
85
122
|
const envVars = envConfig.environments[environment] || envConfig.environments.local;
|
|
86
123
|
|
|
@@ -101,7 +138,8 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local')
|
|
|
101
138
|
}
|
|
102
139
|
|
|
103
140
|
if (missingSecrets.length > 0) {
|
|
104
|
-
|
|
141
|
+
const fileInfo = secretsFilePath ? `\n\nSecrets file location: ${secretsFilePath}` : '';
|
|
142
|
+
throw new Error(`Missing secrets: ${missingSecrets.join(', ')}${fileInfo}`);
|
|
105
143
|
}
|
|
106
144
|
|
|
107
145
|
// Now replace kv:// references, and handle ${VAR} inside the secret values
|
|
@@ -119,146 +157,6 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local')
|
|
|
119
157
|
return resolved;
|
|
120
158
|
}
|
|
121
159
|
|
|
122
|
-
/**
|
|
123
|
-
* Finds missing secret keys from template
|
|
124
|
-
* @function findMissingSecretKeys
|
|
125
|
-
* @param {string} envTemplate - Environment template content
|
|
126
|
-
* @param {Object} existingSecrets - Existing secrets object
|
|
127
|
-
* @returns {string[]} Array of missing secret keys
|
|
128
|
-
*/
|
|
129
|
-
function findMissingSecretKeys(envTemplate, existingSecrets) {
|
|
130
|
-
const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
|
|
131
|
-
const missingKeys = [];
|
|
132
|
-
const seenKeys = new Set();
|
|
133
|
-
|
|
134
|
-
let match;
|
|
135
|
-
while ((match = kvPattern.exec(envTemplate)) !== null) {
|
|
136
|
-
const secretKey = match[1];
|
|
137
|
-
if (!seenKeys.has(secretKey) && !(secretKey in existingSecrets)) {
|
|
138
|
-
missingKeys.push(secretKey);
|
|
139
|
-
seenKeys.add(secretKey);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return missingKeys;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Generates secret value based on key name
|
|
148
|
-
* @function generateSecretValue
|
|
149
|
-
* @param {string} key - Secret key name
|
|
150
|
-
* @returns {string} Generated secret value
|
|
151
|
-
*/
|
|
152
|
-
function generateSecretValue(key) {
|
|
153
|
-
const keyLower = key.toLowerCase();
|
|
154
|
-
|
|
155
|
-
if (keyLower.includes('password')) {
|
|
156
|
-
const dbPasswordMatch = key.match(/^databases-([a-z0-9-_]+)-\d+-passwordKeyVault$/i);
|
|
157
|
-
if (dbPasswordMatch) {
|
|
158
|
-
const appName = dbPasswordMatch[1];
|
|
159
|
-
const dbName = appName.replace(/-/g, '_');
|
|
160
|
-
return `${dbName}_pass123`;
|
|
161
|
-
}
|
|
162
|
-
return crypto.randomBytes(32).toString('base64');
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (keyLower.includes('url') || keyLower.includes('uri')) {
|
|
166
|
-
const dbUrlMatch = key.match(/^databases-([a-z0-9-_]+)-\d+-urlKeyVault$/i);
|
|
167
|
-
if (dbUrlMatch) {
|
|
168
|
-
const appName = dbUrlMatch[1];
|
|
169
|
-
const dbName = appName.replace(/-/g, '_');
|
|
170
|
-
return `postgresql://${dbName}_user:${dbName}_pass123@\${DB_HOST}:5432/${dbName}`;
|
|
171
|
-
}
|
|
172
|
-
return '';
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (keyLower.includes('key') || keyLower.includes('secret') || keyLower.includes('token')) {
|
|
176
|
-
return crypto.randomBytes(32).toString('base64');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return '';
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Loads existing secrets from file
|
|
184
|
-
* @function loadExistingSecrets
|
|
185
|
-
* @param {string} resolvedPath - Path to secrets file
|
|
186
|
-
* @returns {Object} Existing secrets object
|
|
187
|
-
*/
|
|
188
|
-
function loadExistingSecrets(resolvedPath) {
|
|
189
|
-
if (!fs.existsSync(resolvedPath)) {
|
|
190
|
-
return {};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
195
|
-
const secrets = yaml.load(content) || {};
|
|
196
|
-
return typeof secrets === 'object' ? secrets : {};
|
|
197
|
-
} catch (error) {
|
|
198
|
-
logger.warn(`Warning: Could not read existing secrets file: ${error.message}`);
|
|
199
|
-
return {};
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Saves secrets file
|
|
205
|
-
* @function saveSecretsFile
|
|
206
|
-
* @param {string} resolvedPath - Path to secrets file
|
|
207
|
-
* @param {Object} secrets - Secrets object to save
|
|
208
|
-
* @throws {Error} If save fails
|
|
209
|
-
*/
|
|
210
|
-
function saveSecretsFile(resolvedPath, secrets) {
|
|
211
|
-
const dir = path.dirname(resolvedPath);
|
|
212
|
-
if (!fs.existsSync(dir)) {
|
|
213
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const yamlContent = yaml.dump(secrets, {
|
|
217
|
-
indent: 2,
|
|
218
|
-
lineWidth: 120,
|
|
219
|
-
noRefs: true,
|
|
220
|
-
sortKeys: false
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
fs.writeFileSync(resolvedPath, yamlContent, { mode: 0o600 });
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Generates missing secret keys in secrets file
|
|
228
|
-
* Scans env.template for kv:// references and adds missing keys with secure defaults
|
|
229
|
-
*
|
|
230
|
-
* @async
|
|
231
|
-
* @function generateMissingSecrets
|
|
232
|
-
* @param {string} envTemplate - Environment template content
|
|
233
|
-
* @param {string} secretsPath - Path to secrets file
|
|
234
|
-
* @returns {Promise<string[]>} Array of newly generated secret keys
|
|
235
|
-
* @throws {Error} If generation fails
|
|
236
|
-
*
|
|
237
|
-
* @example
|
|
238
|
-
* const newKeys = await generateMissingSecrets(template, '~/.aifabrix/secrets.yaml');
|
|
239
|
-
* // Returns: ['new-secret-key', 'another-secret']
|
|
240
|
-
*/
|
|
241
|
-
async function generateMissingSecrets(envTemplate, secretsPath) {
|
|
242
|
-
const resolvedPath = secretsPath || path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
|
|
243
|
-
const existingSecrets = loadExistingSecrets(resolvedPath);
|
|
244
|
-
const missingKeys = findMissingSecretKeys(envTemplate, existingSecrets);
|
|
245
|
-
|
|
246
|
-
if (missingKeys.length === 0) {
|
|
247
|
-
return [];
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const newSecrets = {};
|
|
251
|
-
for (const key of missingKeys) {
|
|
252
|
-
newSecrets[key] = generateSecretValue(key);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const updatedSecrets = { ...existingSecrets, ...newSecrets };
|
|
256
|
-
saveSecretsFile(resolvedPath, updatedSecrets);
|
|
257
|
-
|
|
258
|
-
logger.log(`✓ Generated ${missingKeys.length} missing secret key(s): ${missingKeys.join(', ')}`);
|
|
259
|
-
return missingKeys;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
160
|
/**
|
|
263
161
|
* Loads environment template from file
|
|
264
162
|
* @function loadEnvTemplate
|
|
@@ -335,13 +233,15 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
|
|
|
335
233
|
|
|
336
234
|
const template = loadEnvTemplate(templatePath);
|
|
337
235
|
|
|
236
|
+
// Resolve secrets path to show in error messages
|
|
237
|
+
const resolvedSecretsPath = resolveSecretsPath(secretsPath);
|
|
238
|
+
|
|
338
239
|
if (force) {
|
|
339
|
-
const resolvedSecretsPath = secretsPath || path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
|
|
340
240
|
await generateMissingSecrets(template, resolvedSecretsPath);
|
|
341
241
|
}
|
|
342
242
|
|
|
343
243
|
const secrets = await loadSecrets(secretsPath);
|
|
344
|
-
const resolved = await resolveKvReferences(template, secrets, environment);
|
|
244
|
+
const resolved = await resolveKvReferences(template, secrets, environment, resolvedSecretsPath);
|
|
345
245
|
|
|
346
246
|
fs.writeFileSync(envPath, resolved, { mode: 0o600 });
|
|
347
247
|
processEnvVariables(envPath, variablesPath);
|
|
@@ -434,48 +334,6 @@ function validateSecrets(envTemplate, secrets) {
|
|
|
434
334
|
};
|
|
435
335
|
}
|
|
436
336
|
|
|
437
|
-
/**
|
|
438
|
-
* Creates default secrets file if it doesn't exist
|
|
439
|
-
* Generates template with common secrets for local development
|
|
440
|
-
*
|
|
441
|
-
* @async
|
|
442
|
-
* @function createDefaultSecrets
|
|
443
|
-
* @param {string} secretsPath - Path where to create secrets file
|
|
444
|
-
* @returns {Promise<void>} Resolves when file is created
|
|
445
|
-
* @throws {Error} If file creation fails
|
|
446
|
-
*
|
|
447
|
-
* @example
|
|
448
|
-
* await createDefaultSecrets('~/.aifabrix/secrets.yaml');
|
|
449
|
-
* // Default secrets file is created
|
|
450
|
-
*/
|
|
451
|
-
async function createDefaultSecrets(secretsPath) {
|
|
452
|
-
const resolvedPath = secretsPath.startsWith('~')
|
|
453
|
-
? path.join(os.homedir(), secretsPath.slice(1))
|
|
454
|
-
: secretsPath;
|
|
455
|
-
|
|
456
|
-
const dir = path.dirname(resolvedPath);
|
|
457
|
-
if (!fs.existsSync(dir)) {
|
|
458
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
const defaultSecrets = `# Local Development Secrets
|
|
462
|
-
# Production uses Azure KeyVault
|
|
463
|
-
|
|
464
|
-
# Database Secrets
|
|
465
|
-
postgres-passwordKeyVault: "admin123"
|
|
466
|
-
|
|
467
|
-
# Redis Secrets
|
|
468
|
-
redis-passwordKeyVault: ""
|
|
469
|
-
redis-urlKeyVault: "redis://\${REDIS_HOST}:6379"
|
|
470
|
-
|
|
471
|
-
# Keycloak Secrets
|
|
472
|
-
keycloak-admin-passwordKeyVault: "admin123"
|
|
473
|
-
keycloak-auth-server-urlKeyVault: "http://\${KEYCLOAK_HOST}:8082"
|
|
474
|
-
`;
|
|
475
|
-
|
|
476
|
-
fs.writeFileSync(resolvedPath, defaultSecrets, { mode: 0o600 });
|
|
477
|
-
}
|
|
478
|
-
|
|
479
337
|
module.exports = {
|
|
480
338
|
loadSecrets,
|
|
481
339
|
resolveKvReferences,
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Compose Generation Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module handles Docker Compose configuration generation for application running.
|
|
5
|
+
* Separated from app-run.js to maintain file size limits.
|
|
6
|
+
*
|
|
7
|
+
* @fileoverview Docker Compose generation utilities
|
|
8
|
+
* @author AI Fabrix Team
|
|
9
|
+
* @version 2.0.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fsSync = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const handlebars = require('handlebars');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Loads and compiles Docker Compose template
|
|
18
|
+
* @param {string} language - Language type
|
|
19
|
+
* @returns {Function} Compiled Handlebars template
|
|
20
|
+
* @throws {Error} If template not found
|
|
21
|
+
*/
|
|
22
|
+
function loadDockerComposeTemplate(language) {
|
|
23
|
+
const templatePath = path.join(__dirname, '..', '..', 'templates', language, 'docker-compose.hbs');
|
|
24
|
+
if (!fsSync.existsSync(templatePath)) {
|
|
25
|
+
throw new Error(`Docker Compose template not found for language: ${language}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const templateContent = fsSync.readFileSync(templatePath, 'utf8');
|
|
29
|
+
return handlebars.compile(templateContent);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extracts image name from configuration (same logic as build.js)
|
|
34
|
+
* @param {Object} config - Application configuration
|
|
35
|
+
* @param {string} appName - Application name (fallback)
|
|
36
|
+
* @returns {string} Image name
|
|
37
|
+
*/
|
|
38
|
+
function getImageName(config, appName) {
|
|
39
|
+
if (typeof config.image === 'string') {
|
|
40
|
+
return config.image.split(':')[0];
|
|
41
|
+
} else if (config.image?.name) {
|
|
42
|
+
return config.image.name;
|
|
43
|
+
} else if (config.app?.key) {
|
|
44
|
+
return config.app.key;
|
|
45
|
+
}
|
|
46
|
+
return appName;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Builds app configuration section
|
|
51
|
+
* @param {string} appName - Application name
|
|
52
|
+
* @param {Object} config - Application configuration
|
|
53
|
+
* @returns {Object} App configuration
|
|
54
|
+
*/
|
|
55
|
+
function buildAppConfig(appName, config) {
|
|
56
|
+
return {
|
|
57
|
+
key: appName,
|
|
58
|
+
name: config.displayName || appName
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds image configuration section
|
|
64
|
+
* @param {Object} config - Application configuration
|
|
65
|
+
* @param {string} appName - Application name
|
|
66
|
+
* @returns {Object} Image configuration
|
|
67
|
+
*/
|
|
68
|
+
function buildImageConfig(config, appName) {
|
|
69
|
+
const imageName = getImageName(config, appName);
|
|
70
|
+
const imageTag = config.image?.tag || 'latest';
|
|
71
|
+
return {
|
|
72
|
+
name: imageName,
|
|
73
|
+
tag: imageTag
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Builds health check configuration section
|
|
79
|
+
* @param {Object} config - Application configuration
|
|
80
|
+
* @returns {Object} Health check configuration
|
|
81
|
+
*/
|
|
82
|
+
function buildHealthCheckConfig(config) {
|
|
83
|
+
return {
|
|
84
|
+
path: config.healthCheck?.path || '/health',
|
|
85
|
+
interval: config.healthCheck?.interval || 30
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Builds requires configuration section
|
|
91
|
+
* @param {Object} config - Application configuration
|
|
92
|
+
* @returns {Object} Requires configuration
|
|
93
|
+
*/
|
|
94
|
+
function buildRequiresConfig(config) {
|
|
95
|
+
return {
|
|
96
|
+
requiresDatabase: config.requires?.database || config.services?.database || false,
|
|
97
|
+
requiresStorage: config.requires?.storage || config.services?.storage || false,
|
|
98
|
+
requiresRedis: config.requires?.redis || config.services?.redis || false
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Builds service configuration for template data
|
|
104
|
+
* @param {string} appName - Application name
|
|
105
|
+
* @param {Object} config - Application configuration
|
|
106
|
+
* @param {number} port - Application port
|
|
107
|
+
* @returns {Object} Service configuration
|
|
108
|
+
*/
|
|
109
|
+
function buildServiceConfig(appName, config, port) {
|
|
110
|
+
const containerPort = config.build?.containerPort || config.port || 3000;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
app: buildAppConfig(appName, config),
|
|
114
|
+
image: buildImageConfig(config, appName),
|
|
115
|
+
port: containerPort,
|
|
116
|
+
build: {
|
|
117
|
+
localPort: port
|
|
118
|
+
},
|
|
119
|
+
healthCheck: buildHealthCheckConfig(config),
|
|
120
|
+
...buildRequiresConfig(config)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Builds volumes configuration for template data
|
|
126
|
+
* @param {string} appName - Application name
|
|
127
|
+
* @returns {Object} Volumes configuration
|
|
128
|
+
*/
|
|
129
|
+
function buildVolumesConfig(appName) {
|
|
130
|
+
// Use forward slashes for Docker paths (works on both Windows and Unix)
|
|
131
|
+
const volumePath = path.join(process.cwd(), 'data', appName);
|
|
132
|
+
return {
|
|
133
|
+
mountVolume: volumePath.replace(/\\/g, '/')
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Builds networks configuration for template data
|
|
139
|
+
* @param {Object} config - Application configuration
|
|
140
|
+
* @returns {Object} Networks configuration
|
|
141
|
+
*/
|
|
142
|
+
function buildNetworksConfig(config) {
|
|
143
|
+
// Get databases from requires.databases or top-level databases
|
|
144
|
+
const databases = config.requires?.databases || config.databases || [];
|
|
145
|
+
return {
|
|
146
|
+
databases: databases
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Generates Docker Compose configuration from template
|
|
152
|
+
* @param {string} appName - Application name
|
|
153
|
+
* @param {Object} config - Application configuration
|
|
154
|
+
* @param {Object} options - Run options
|
|
155
|
+
* @returns {Promise<string>} Generated compose content
|
|
156
|
+
*/
|
|
157
|
+
async function generateDockerCompose(appName, config, options) {
|
|
158
|
+
const language = config.build?.language || config.language || 'typescript';
|
|
159
|
+
const template = loadDockerComposeTemplate(language);
|
|
160
|
+
|
|
161
|
+
const port = options.port || config.build?.localPort || config.port || 3000;
|
|
162
|
+
|
|
163
|
+
const serviceConfig = buildServiceConfig(appName, config, port);
|
|
164
|
+
const volumesConfig = buildVolumesConfig(appName);
|
|
165
|
+
const networksConfig = buildNetworksConfig(config);
|
|
166
|
+
|
|
167
|
+
// Get absolute path to .env file for docker-compose
|
|
168
|
+
const envFilePath = path.join(process.cwd(), 'builder', appName, '.env');
|
|
169
|
+
const envFileAbsolutePath = envFilePath.replace(/\\/g, '/'); // Use forward slashes for Docker
|
|
170
|
+
|
|
171
|
+
const templateData = {
|
|
172
|
+
...serviceConfig,
|
|
173
|
+
...volumesConfig,
|
|
174
|
+
...networksConfig,
|
|
175
|
+
envFile: envFileAbsolutePath
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return template(templateData);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
generateDockerCompose,
|
|
183
|
+
getImageName
|
|
184
|
+
};
|
|
185
|
+
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Build Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module handles Docker image building with progress indicators.
|
|
5
|
+
* Separated from build.js to maintain file size limits.
|
|
6
|
+
*
|
|
7
|
+
* @fileoverview Docker build execution utilities
|
|
8
|
+
* @author AI Fabrix Team
|
|
9
|
+
* @version 2.0.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
const ora = require('ora');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Checks if error indicates Docker is not running or not installed
|
|
17
|
+
* @param {string} errorMessage - Error message to check
|
|
18
|
+
* @returns {boolean} True if Docker is not available
|
|
19
|
+
*/
|
|
20
|
+
function isDockerNotAvailableError(errorMessage) {
|
|
21
|
+
return errorMessage.includes('docker: command not found') ||
|
|
22
|
+
errorMessage.includes('Cannot connect to the Docker daemon') ||
|
|
23
|
+
errorMessage.includes('Is the docker daemon running') ||
|
|
24
|
+
errorMessage.includes('Cannot connect to Docker');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parses Docker build output to extract progress information
|
|
29
|
+
* @param {string} line - Single line of Docker build output
|
|
30
|
+
* @returns {string|null} Progress message or null if no progress info
|
|
31
|
+
*/
|
|
32
|
+
function parseDockerBuildProgress(line) {
|
|
33
|
+
// Match step progress: "Step 1/10 : FROM node:20-alpine"
|
|
34
|
+
const stepMatch = line.match(/^Step\s+(\d+)\/(\d+)\s*:/i);
|
|
35
|
+
if (stepMatch) {
|
|
36
|
+
return `Step ${stepMatch[1]}/${stepMatch[2]}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Match layer pulling: "Pulling from library/node"
|
|
40
|
+
const pullingMatch = line.match(/^Pulling\s+(.+)$/i);
|
|
41
|
+
if (pullingMatch) {
|
|
42
|
+
return `Pulling ${pullingMatch[1]}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Match layer extracting: "Extracting [====> ]"
|
|
46
|
+
const extractingMatch = line.match(/^Extracting/i);
|
|
47
|
+
if (extractingMatch) {
|
|
48
|
+
return 'Extracting layers';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Match build progress: " => [internal] load build context"
|
|
52
|
+
const buildMatch = line.match(/^=>\s*\[(.*?)\]\s*(.+)$/i);
|
|
53
|
+
if (buildMatch) {
|
|
54
|
+
return buildMatch[2].substring(0, 50);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Match progress bars: "[====> ] 10.5MB/50MB"
|
|
58
|
+
const progressMatch = line.match(/\[.*?\]\s+([\d.]+(MB|KB|GB))\/([\d.]+(MB|KB|GB))/i);
|
|
59
|
+
if (progressMatch) {
|
|
60
|
+
return `${progressMatch[1]}/${progressMatch[3]}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Executes Docker build command with progress indicator
|
|
68
|
+
* @param {string} imageName - Image name to build
|
|
69
|
+
* @param {string} dockerfilePath - Path to Dockerfile
|
|
70
|
+
* @param {string} contextPath - Build context path
|
|
71
|
+
* @param {string} tag - Image tag
|
|
72
|
+
* @returns {Promise<void>} Resolves when build completes
|
|
73
|
+
* @throws {Error} If build fails
|
|
74
|
+
*/
|
|
75
|
+
async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag) {
|
|
76
|
+
const spinner = ora({
|
|
77
|
+
text: 'Starting Docker build...',
|
|
78
|
+
spinner: 'dots'
|
|
79
|
+
}).start();
|
|
80
|
+
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
// Use spawn for streaming output
|
|
83
|
+
const dockerProcess = spawn('docker', [
|
|
84
|
+
'build',
|
|
85
|
+
'-t', `${imageName}:${tag}`,
|
|
86
|
+
'-f', dockerfilePath,
|
|
87
|
+
contextPath
|
|
88
|
+
], {
|
|
89
|
+
shell: process.platform === 'win32'
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let stdoutBuffer = '';
|
|
93
|
+
let stderrBuffer = '';
|
|
94
|
+
let lastProgressUpdate = Date.now();
|
|
95
|
+
|
|
96
|
+
dockerProcess.stdout.on('data', (data) => {
|
|
97
|
+
const output = data.toString();
|
|
98
|
+
stdoutBuffer += output;
|
|
99
|
+
|
|
100
|
+
// Parse progress from output lines
|
|
101
|
+
const lines = output.split('\n');
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
const progress = parseDockerBuildProgress(line.trim());
|
|
104
|
+
if (progress) {
|
|
105
|
+
// Update spinner text with progress (throttle updates)
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
if (now - lastProgressUpdate > 200) {
|
|
108
|
+
spinner.text = `Building image... ${progress}`;
|
|
109
|
+
lastProgressUpdate = now;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
dockerProcess.stderr.on('data', (data) => {
|
|
116
|
+
const output = data.toString();
|
|
117
|
+
stderrBuffer += output;
|
|
118
|
+
|
|
119
|
+
// Check for warnings vs errors
|
|
120
|
+
if (!output.toLowerCase().includes('warning')) {
|
|
121
|
+
// Parse progress from stderr too (Docker outputs progress to stderr)
|
|
122
|
+
const lines = output.split('\n');
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
const progress = parseDockerBuildProgress(line.trim());
|
|
125
|
+
if (progress) {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
if (now - lastProgressUpdate > 200) {
|
|
128
|
+
spinner.text = `Building image... ${progress}`;
|
|
129
|
+
lastProgressUpdate = now;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
dockerProcess.on('close', (code) => {
|
|
137
|
+
if (code === 0) {
|
|
138
|
+
spinner.succeed(`Image built: ${imageName}:${tag}`);
|
|
139
|
+
resolve();
|
|
140
|
+
} else {
|
|
141
|
+
spinner.fail('Build failed');
|
|
142
|
+
|
|
143
|
+
const errorMessage = stderrBuffer || stdoutBuffer || 'Docker build failed';
|
|
144
|
+
|
|
145
|
+
if (isDockerNotAvailableError(errorMessage)) {
|
|
146
|
+
reject(new Error('Docker is not running or not installed. Please start Docker Desktop and try again.'));
|
|
147
|
+
} else {
|
|
148
|
+
// Show last few lines of error output
|
|
149
|
+
const errorLines = errorMessage.split('\n').filter(line => line.trim());
|
|
150
|
+
const lastError = errorLines.slice(-5).join('\n');
|
|
151
|
+
reject(new Error(`Docker build failed: ${lastError}`));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
dockerProcess.on('error', (error) => {
|
|
157
|
+
spinner.fail('Build failed');
|
|
158
|
+
const errorMessage = error.message || String(error);
|
|
159
|
+
|
|
160
|
+
if (isDockerNotAvailableError(errorMessage)) {
|
|
161
|
+
reject(new Error('Docker is not running or not installed. Please start Docker Desktop and try again.'));
|
|
162
|
+
} else {
|
|
163
|
+
reject(new Error(`Docker build failed: ${errorMessage}`));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
executeDockerBuild,
|
|
171
|
+
isDockerNotAvailableError
|
|
172
|
+
};
|
|
173
|
+
|