@aifabrix/builder 2.0.3 → 2.0.5
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 +1 -0
- package/lib/app-run.js +4 -4
- package/lib/secrets.js +52 -194
- package/lib/utils/compose-generator.js +7 -3
- 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 +1 -1
- package/templates/infra/compose.yaml +0 -2
- package/templates/python/docker-compose.hbs +26 -13
- package/templates/typescript/docker-compose.hbs +26 -13
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@aifabrix/builder)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
5
6
|
Local development infrastructure + Azure deployment tool.
|
|
6
7
|
|
|
7
8
|
## Install
|
package/lib/app-run.js
CHANGED
|
@@ -281,11 +281,11 @@ async function startContainer(appName, composePath, port, config = null) {
|
|
|
281
281
|
await execAsync(`docker-compose -f "${composePath}" up -d`, { env });
|
|
282
282
|
logger.log(chalk.green(`✓ Container aifabrix-${appName} started`));
|
|
283
283
|
|
|
284
|
-
// Wait for health check
|
|
284
|
+
// Wait for health check - detect actual mapped port from Docker
|
|
285
|
+
// Don't pass port so waitForHealthCheck will auto-detect it from container
|
|
285
286
|
const healthCheckPath = config?.healthCheck?.path || '/health';
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
await waitForHealthCheck(appName, 90, port, config);
|
|
287
|
+
logger.log(chalk.blue(`Waiting for application to be healthy at http://localhost:${port}${healthCheckPath}...`));
|
|
288
|
+
await waitForHealthCheck(appName, 90, null, config);
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
/**
|
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,
|
|
@@ -107,14 +107,18 @@ function buildRequiresConfig(config) {
|
|
|
107
107
|
* @returns {Object} Service configuration
|
|
108
108
|
*/
|
|
109
109
|
function buildServiceConfig(appName, config, port) {
|
|
110
|
-
|
|
110
|
+
// Host port is the local port (from options.port or build.localPort)
|
|
111
|
+
const hostPort = port;
|
|
112
|
+
// Container port: use build.containerPort if specified, otherwise use port config
|
|
113
|
+
const containerPortValue = config.build?.containerPort || config.port || hostPort;
|
|
111
114
|
|
|
112
115
|
return {
|
|
113
116
|
app: buildAppConfig(appName, config),
|
|
114
117
|
image: buildImageConfig(config, appName),
|
|
115
|
-
port:
|
|
118
|
+
port: containerPortValue, // Container port (for health check and template)
|
|
119
|
+
containerPort: config.build?.containerPort || null, // Explicit containerPort if specified, null otherwise
|
|
116
120
|
build: {
|
|
117
|
-
localPort: port
|
|
121
|
+
localPort: hostPort // Host port
|
|
118
122
|
},
|
|
119
123
|
healthCheck: buildHealthCheckConfig(config),
|
|
120
124
|
...buildRequiresConfig(config)
|
|
@@ -71,10 +71,29 @@ async function waitForDbInit(appName) {
|
|
|
71
71
|
*/
|
|
72
72
|
async function getContainerPort(appName) {
|
|
73
73
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
// Try to get the actual mapped host port from Docker
|
|
75
|
+
// First try docker inspect for the container port mapping
|
|
76
|
+
const { stdout: portMapping } = await execAsync(`docker inspect --format='{{range $p, $conf := .NetworkSettings.Ports}}{{if $conf}}{{range $conf}}{{.HostPort}}{{end}}{{end}}{{end}}' aifabrix-${appName}`);
|
|
77
|
+
const ports = portMapping.trim().split('\n').filter(p => p && p !== '');
|
|
76
78
|
if (ports.length > 0) {
|
|
77
|
-
|
|
79
|
+
const port = parseInt(ports[0], 10);
|
|
80
|
+
if (!isNaN(port) && port > 0) {
|
|
81
|
+
return port;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fallback: try docker ps to get port mapping (format: "0.0.0.0:3010->3000/tcp")
|
|
86
|
+
try {
|
|
87
|
+
const { stdout: psOutput } = await execAsync(`docker ps --filter "name=aifabrix-${appName}" --format "{{.Ports}}"`);
|
|
88
|
+
const portMatch = psOutput.match(/:(\d+)->/);
|
|
89
|
+
if (portMatch) {
|
|
90
|
+
const port = parseInt(portMatch[1], 10);
|
|
91
|
+
if (!isNaN(port) && port > 0) {
|
|
92
|
+
return port;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// Fall through
|
|
78
97
|
}
|
|
79
98
|
} catch (error) {
|
|
80
99
|
// Fall through to default
|
|
@@ -152,12 +171,12 @@ async function checkHealthEndpoint(healthCheckUrl) {
|
|
|
152
171
|
async function waitForHealthCheck(appName, timeout = 90, port = null, config = null) {
|
|
153
172
|
await waitForDbInit(appName);
|
|
154
173
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
174
|
+
// Always detect the actual port from Docker to ensure we use the correct mapped port
|
|
175
|
+
const detectedPort = await getContainerPort(appName);
|
|
176
|
+
const healthCheckPort = port || detectedPort;
|
|
158
177
|
|
|
159
178
|
const healthCheckPath = config?.healthCheck?.path || '/health';
|
|
160
|
-
const healthCheckUrl = `http://localhost:${
|
|
179
|
+
const healthCheckUrl = `http://localhost:${healthCheckPort}${healthCheckPath}`;
|
|
161
180
|
const maxAttempts = timeout / 2;
|
|
162
181
|
|
|
163
182
|
for (let attempts = 0; attempts < maxAttempts; attempts++) {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Fabrix Builder Secrets Generation Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module handles secret generation and file management.
|
|
5
|
+
* Generates default secret values and manages secrets files.
|
|
6
|
+
*
|
|
7
|
+
* @fileoverview Secret generation utilities for AI Fabrix Builder
|
|
8
|
+
* @author AI Fabrix Team
|
|
9
|
+
* @version 2.0.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const yaml = require('js-yaml');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const logger = require('./logger');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Finds missing secret keys from template
|
|
21
|
+
* @function findMissingSecretKeys
|
|
22
|
+
* @param {string} envTemplate - Environment template content
|
|
23
|
+
* @param {Object} existingSecrets - Existing secrets object
|
|
24
|
+
* @returns {string[]} Array of missing secret keys
|
|
25
|
+
*/
|
|
26
|
+
function findMissingSecretKeys(envTemplate, existingSecrets) {
|
|
27
|
+
const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
|
|
28
|
+
const missingKeys = [];
|
|
29
|
+
const seenKeys = new Set();
|
|
30
|
+
|
|
31
|
+
let match;
|
|
32
|
+
while ((match = kvPattern.exec(envTemplate)) !== null) {
|
|
33
|
+
const secretKey = match[1];
|
|
34
|
+
if (!seenKeys.has(secretKey) && !(secretKey in existingSecrets)) {
|
|
35
|
+
missingKeys.push(secretKey);
|
|
36
|
+
seenKeys.add(secretKey);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return missingKeys;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generates secret value based on key name
|
|
45
|
+
* @function generateSecretValue
|
|
46
|
+
* @param {string} key - Secret key name
|
|
47
|
+
* @returns {string} Generated secret value
|
|
48
|
+
*/
|
|
49
|
+
function generateSecretValue(key) {
|
|
50
|
+
const keyLower = key.toLowerCase();
|
|
51
|
+
|
|
52
|
+
if (keyLower.includes('password')) {
|
|
53
|
+
const dbPasswordMatch = key.match(/^databases-([a-z0-9-_]+)-\d+-passwordKeyVault$/i);
|
|
54
|
+
if (dbPasswordMatch) {
|
|
55
|
+
const appName = dbPasswordMatch[1];
|
|
56
|
+
const dbName = appName.replace(/-/g, '_');
|
|
57
|
+
return `${dbName}_pass123`;
|
|
58
|
+
}
|
|
59
|
+
return crypto.randomBytes(32).toString('base64');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (keyLower.includes('url') || keyLower.includes('uri')) {
|
|
63
|
+
const dbUrlMatch = key.match(/^databases-([a-z0-9-_]+)-\d+-urlKeyVault$/i);
|
|
64
|
+
if (dbUrlMatch) {
|
|
65
|
+
const appName = dbUrlMatch[1];
|
|
66
|
+
const dbName = appName.replace(/-/g, '_');
|
|
67
|
+
return `postgresql://${dbName}_user:${dbName}_pass123@\${DB_HOST}:5432/${dbName}`;
|
|
68
|
+
}
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (keyLower.includes('key') || keyLower.includes('secret') || keyLower.includes('token')) {
|
|
73
|
+
return crypto.randomBytes(32).toString('base64');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Loads existing secrets from file
|
|
81
|
+
* @function loadExistingSecrets
|
|
82
|
+
* @param {string} resolvedPath - Path to secrets file
|
|
83
|
+
* @returns {Object} Existing secrets object
|
|
84
|
+
*/
|
|
85
|
+
function loadExistingSecrets(resolvedPath) {
|
|
86
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
92
|
+
const secrets = yaml.load(content) || {};
|
|
93
|
+
return typeof secrets === 'object' ? secrets : {};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logger.warn(`Warning: Could not read existing secrets file: ${error.message}`);
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Saves secrets file
|
|
102
|
+
* @function saveSecretsFile
|
|
103
|
+
* @param {string} resolvedPath - Path to secrets file
|
|
104
|
+
* @param {Object} secrets - Secrets object to save
|
|
105
|
+
* @throws {Error} If save fails
|
|
106
|
+
*/
|
|
107
|
+
function saveSecretsFile(resolvedPath, secrets) {
|
|
108
|
+
const dir = path.dirname(resolvedPath);
|
|
109
|
+
if (!fs.existsSync(dir)) {
|
|
110
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const yamlContent = yaml.dump(secrets, {
|
|
114
|
+
indent: 2,
|
|
115
|
+
lineWidth: 120,
|
|
116
|
+
noRefs: true,
|
|
117
|
+
sortKeys: false
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
fs.writeFileSync(resolvedPath, yamlContent, { mode: 0o600 });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Generates missing secret keys in secrets file
|
|
125
|
+
* Scans env.template for kv:// references and adds missing keys with secure defaults
|
|
126
|
+
*
|
|
127
|
+
* @async
|
|
128
|
+
* @function generateMissingSecrets
|
|
129
|
+
* @param {string} envTemplate - Environment template content
|
|
130
|
+
* @param {string} secretsPath - Path to secrets file
|
|
131
|
+
* @returns {Promise<string[]>} Array of newly generated secret keys
|
|
132
|
+
* @throws {Error} If generation fails
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* const newKeys = await generateMissingSecrets(template, '~/.aifabrix/secrets.yaml');
|
|
136
|
+
* // Returns: ['new-secret-key', 'another-secret']
|
|
137
|
+
*/
|
|
138
|
+
async function generateMissingSecrets(envTemplate, secretsPath) {
|
|
139
|
+
const resolvedPath = secretsPath || path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
|
|
140
|
+
const existingSecrets = loadExistingSecrets(resolvedPath);
|
|
141
|
+
const missingKeys = findMissingSecretKeys(envTemplate, existingSecrets);
|
|
142
|
+
|
|
143
|
+
if (missingKeys.length === 0) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const newSecrets = {};
|
|
148
|
+
for (const key of missingKeys) {
|
|
149
|
+
newSecrets[key] = generateSecretValue(key);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const updatedSecrets = { ...existingSecrets, ...newSecrets };
|
|
153
|
+
saveSecretsFile(resolvedPath, updatedSecrets);
|
|
154
|
+
|
|
155
|
+
logger.log(`✓ Generated ${missingKeys.length} missing secret key(s): ${missingKeys.join(', ')}`);
|
|
156
|
+
return missingKeys;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Creates default secrets file if it doesn't exist
|
|
161
|
+
* Generates template with common secrets for local development
|
|
162
|
+
*
|
|
163
|
+
* @async
|
|
164
|
+
* @function createDefaultSecrets
|
|
165
|
+
* @param {string} secretsPath - Path where to create secrets file
|
|
166
|
+
* @returns {Promise<void>} Resolves when file is created
|
|
167
|
+
* @throws {Error} If file creation fails
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* await createDefaultSecrets('~/.aifabrix/secrets.yaml');
|
|
171
|
+
* // Default secrets file is created
|
|
172
|
+
*/
|
|
173
|
+
async function createDefaultSecrets(secretsPath) {
|
|
174
|
+
const resolvedPath = secretsPath.startsWith('~')
|
|
175
|
+
? path.join(os.homedir(), secretsPath.slice(1))
|
|
176
|
+
: secretsPath;
|
|
177
|
+
|
|
178
|
+
const dir = path.dirname(resolvedPath);
|
|
179
|
+
if (!fs.existsSync(dir)) {
|
|
180
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const defaultSecrets = `# Local Development Secrets
|
|
184
|
+
# Production uses Azure KeyVault
|
|
185
|
+
|
|
186
|
+
# Database Secrets
|
|
187
|
+
postgres-passwordKeyVault: "admin123"
|
|
188
|
+
|
|
189
|
+
# Redis Secrets
|
|
190
|
+
redis-passwordKeyVault: ""
|
|
191
|
+
redis-urlKeyVault: "redis://\${REDIS_HOST}:6379"
|
|
192
|
+
|
|
193
|
+
# Keycloak Secrets
|
|
194
|
+
keycloak-admin-passwordKeyVault: "admin123"
|
|
195
|
+
keycloak-auth-server-urlKeyVault: "http://\${KEYCLOAK_HOST}:8082"
|
|
196
|
+
`;
|
|
197
|
+
|
|
198
|
+
fs.writeFileSync(resolvedPath, defaultSecrets, { mode: 0o600 });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
findMissingSecretKeys,
|
|
203
|
+
generateSecretValue,
|
|
204
|
+
loadExistingSecrets,
|
|
205
|
+
saveSecretsFile,
|
|
206
|
+
generateMissingSecrets,
|
|
207
|
+
createDefaultSecrets
|
|
208
|
+
};
|
|
209
|
+
|
package/lib/validator.js
CHANGED
|
@@ -185,13 +185,17 @@ async function validateEnvTemplate(appName) {
|
|
|
185
185
|
const lines = content.split('\n');
|
|
186
186
|
lines.forEach((line, index) => {
|
|
187
187
|
const trimmed = line.trim();
|
|
188
|
+
// Skip empty lines and comments
|
|
188
189
|
if (trimmed && !trimmed.startsWith('#')) {
|
|
189
190
|
if (!trimmed.includes('=')) {
|
|
190
191
|
errors.push(`Line ${index + 1}: Invalid environment variable format (missing =)`);
|
|
191
192
|
} else {
|
|
192
|
-
const [key,
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
const [key, _value] = trimmed.split('=', 2);
|
|
194
|
+
// Trim key to handle whitespace issues
|
|
195
|
+
// Empty values are allowed (_value can be empty string or undefined)
|
|
196
|
+
const trimmedKey = key ? key.trim() : '';
|
|
197
|
+
if (!trimmedKey) {
|
|
198
|
+
errors.push(`Line ${index + 1}: Invalid environment variable format (missing variable name)`);
|
|
195
199
|
}
|
|
196
200
|
}
|
|
197
201
|
}
|
package/package.json
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
# Generated by AI Fabrix Builder SDK
|
|
3
3
|
# Service definition for local development
|
|
4
4
|
|
|
5
|
-
version: "3.9"
|
|
6
|
-
|
|
7
5
|
services:
|
|
8
6
|
{{app.key}}:
|
|
9
7
|
image: {{image.name}}:{{image.tag}}
|
|
@@ -11,7 +9,11 @@ services:
|
|
|
11
9
|
env_file:
|
|
12
10
|
- {{envFile}}
|
|
13
11
|
ports:
|
|
12
|
+
{{#if containerPort}}
|
|
13
|
+
- "{{build.localPort}}:{{containerPort}}"
|
|
14
|
+
{{else}}
|
|
14
15
|
- "{{build.localPort}}:{{port}}"
|
|
16
|
+
{{/if}}
|
|
15
17
|
networks:
|
|
16
18
|
- infra_aifabrix-network
|
|
17
19
|
{{#if requiresStorage}}
|
|
@@ -48,23 +50,34 @@ services:
|
|
|
48
50
|
command: >
|
|
49
51
|
sh -c "
|
|
50
52
|
export PGHOST=postgres PGPORT=5432 PGUSER=pgadmin &&
|
|
51
|
-
export PGPASSWORD
|
|
53
|
+
export PGPASSWORD=\"${POSTGRES_PASSWORD}\" &&
|
|
54
|
+
echo 'Waiting for PostgreSQL to be ready...' &&
|
|
55
|
+
counter=0 &&
|
|
56
|
+
while [ $counter -lt 30 ]; do
|
|
57
|
+
if pg_isready -h postgres -p 5432 -U pgadmin >/dev/null 2>&1; then
|
|
58
|
+
echo 'PostgreSQL is ready!'
|
|
59
|
+
break
|
|
60
|
+
fi
|
|
61
|
+
echo 'Waiting for PostgreSQL...'
|
|
62
|
+
sleep 1
|
|
63
|
+
counter=$((counter + 1))
|
|
64
|
+
done &&
|
|
52
65
|
{{#if databases}}
|
|
53
66
|
{{#each databases}}
|
|
54
67
|
echo 'Creating {{name}} database and user...' &&
|
|
55
|
-
psql -d postgres -c 'CREATE DATABASE {{name}};' ||
|
|
56
|
-
psql -d postgres -c \"CREATE USER {{name}}_user WITH PASSWORD '{{name}}_pass123';\" ||
|
|
57
|
-
psql -d postgres -c 'GRANT ALL PRIVILEGES ON DATABASE {{name}} TO {{name}}_user;' &&
|
|
58
|
-
psql -d {{name}} -c 'ALTER SCHEMA public OWNER TO {{name}}_user;' &&
|
|
59
|
-
psql -d {{name}} -c 'GRANT ALL ON SCHEMA public TO {{name}}_user;' &&
|
|
68
|
+
(psql -d postgres -c 'CREATE DATABASE {{name}};' || true) &&
|
|
69
|
+
(psql -d postgres -c \"CREATE USER {{name}}_user WITH PASSWORD '{{name}}_pass123';\" || true) &&
|
|
70
|
+
psql -d postgres -c 'GRANT ALL PRIVILEGES ON DATABASE {{name}} TO {{name}}_user;' || true &&
|
|
71
|
+
psql -d {{name}} -c 'ALTER SCHEMA public OWNER TO {{name}}_user;' || true &&
|
|
72
|
+
psql -d {{name}} -c 'GRANT ALL ON SCHEMA public TO {{name}}_user;' || true &&
|
|
60
73
|
{{/each}}
|
|
61
74
|
{{else}}
|
|
62
75
|
echo 'Creating {{app.key}} database and user...' &&
|
|
63
|
-
psql -d postgres -c 'CREATE DATABASE {{app.key}};' ||
|
|
64
|
-
psql -d postgres -c \"CREATE USER {{app.key}}_user WITH PASSWORD '{{app.key}}_pass123';\" ||
|
|
65
|
-
psql -d postgres -c 'GRANT ALL PRIVILEGES ON DATABASE {{app.key}} TO {{app.key}}_user;' &&
|
|
66
|
-
psql -d {{app.key}} -c 'ALTER SCHEMA public OWNER TO {{app.key}}_user;' &&
|
|
67
|
-
psql -d {{app.key}} -c 'GRANT ALL ON SCHEMA public TO {{app.key}}_user;' &&
|
|
76
|
+
(psql -d postgres -c 'CREATE DATABASE {{app.key}};' || true) &&
|
|
77
|
+
(psql -d postgres -c \"CREATE USER {{app.key}}_user WITH PASSWORD '{{app.key}}_pass123';\" || true) &&
|
|
78
|
+
psql -d postgres -c 'GRANT ALL PRIVILEGES ON DATABASE {{app.key}} TO {{app.key}}_user;' || true &&
|
|
79
|
+
psql -d {{app.key}} -c 'ALTER SCHEMA public OWNER TO {{app.key}}_user;' || true &&
|
|
80
|
+
psql -d {{app.key}} -c 'GRANT ALL ON SCHEMA public TO {{app.key}}_user;' || true &&
|
|
68
81
|
{{/if}}
|
|
69
82
|
echo 'Database initialization complete!'
|
|
70
83
|
"
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
# Generated by AI Fabrix Builder SDK
|
|
3
3
|
# Service definition for local development
|
|
4
4
|
|
|
5
|
-
version: "3.9"
|
|
6
|
-
|
|
7
5
|
services:
|
|
8
6
|
{{app.key}}:
|
|
9
7
|
image: {{image.name}}:{{image.tag}}
|
|
@@ -11,7 +9,11 @@ services:
|
|
|
11
9
|
env_file:
|
|
12
10
|
- {{envFile}}
|
|
13
11
|
ports:
|
|
12
|
+
{{#if containerPort}}
|
|
13
|
+
- "{{build.localPort}}:{{containerPort}}"
|
|
14
|
+
{{else}}
|
|
14
15
|
- "{{build.localPort}}:{{port}}"
|
|
16
|
+
{{/if}}
|
|
15
17
|
networks:
|
|
16
18
|
- infra_aifabrix-network
|
|
17
19
|
{{#if requiresStorage}}
|
|
@@ -48,23 +50,34 @@ services:
|
|
|
48
50
|
command: >
|
|
49
51
|
sh -c "
|
|
50
52
|
export PGHOST=postgres PGPORT=5432 PGUSER=pgadmin &&
|
|
51
|
-
export PGPASSWORD
|
|
53
|
+
export PGPASSWORD=\"${POSTGRES_PASSWORD}\" &&
|
|
54
|
+
echo 'Waiting for PostgreSQL to be ready...' &&
|
|
55
|
+
counter=0 &&
|
|
56
|
+
while [ $counter -lt 30 ]; do
|
|
57
|
+
if pg_isready -h postgres -p 5432 -U pgadmin >/dev/null 2>&1; then
|
|
58
|
+
echo 'PostgreSQL is ready!'
|
|
59
|
+
break
|
|
60
|
+
fi
|
|
61
|
+
echo 'Waiting for PostgreSQL...'
|
|
62
|
+
sleep 1
|
|
63
|
+
counter=$((counter + 1))
|
|
64
|
+
done &&
|
|
52
65
|
{{#if databases}}
|
|
53
66
|
{{#each databases}}
|
|
54
67
|
echo 'Creating {{name}} database and user...' &&
|
|
55
|
-
psql -d postgres -c 'CREATE DATABASE {{name}};' ||
|
|
56
|
-
psql -d postgres -c \"CREATE USER {{name}}_user WITH PASSWORD '{{name}}_pass123';\" ||
|
|
57
|
-
psql -d postgres -c 'GRANT ALL PRIVILEGES ON DATABASE {{name}} TO {{name}}_user;' &&
|
|
58
|
-
psql -d {{name}} -c 'ALTER SCHEMA public OWNER TO {{name}}_user;' &&
|
|
59
|
-
psql -d {{name}} -c 'GRANT ALL ON SCHEMA public TO {{name}}_user;' &&
|
|
68
|
+
(psql -d postgres -c 'CREATE DATABASE {{name}};' || true) &&
|
|
69
|
+
(psql -d postgres -c \"CREATE USER {{name}}_user WITH PASSWORD '{{name}}_pass123';\" || true) &&
|
|
70
|
+
psql -d postgres -c 'GRANT ALL PRIVILEGES ON DATABASE {{name}} TO {{name}}_user;' || true &&
|
|
71
|
+
psql -d {{name}} -c 'ALTER SCHEMA public OWNER TO {{name}}_user;' || true &&
|
|
72
|
+
psql -d {{name}} -c 'GRANT ALL ON SCHEMA public TO {{name}}_user;' || true &&
|
|
60
73
|
{{/each}}
|
|
61
74
|
{{else}}
|
|
62
75
|
echo 'Creating {{app.key}} database and user...' &&
|
|
63
|
-
psql -d postgres -c 'CREATE DATABASE {{app.key}};' ||
|
|
64
|
-
psql -d postgres -c \"CREATE USER {{app.key}}_user WITH PASSWORD '{{app.key}}_pass123';\" ||
|
|
65
|
-
psql -d postgres -c 'GRANT ALL PRIVILEGES ON DATABASE {{app.key}} TO {{app.key}}_user;' &&
|
|
66
|
-
psql -d {{app.key}} -c 'ALTER SCHEMA public OWNER TO {{app.key}}_user;' &&
|
|
67
|
-
psql -d {{app.key}} -c 'GRANT ALL ON SCHEMA public TO {{app.key}}_user;' &&
|
|
76
|
+
(psql -d postgres -c 'CREATE DATABASE {{app.key}};' || true) &&
|
|
77
|
+
(psql -d postgres -c \"CREATE USER {{app.key}}_user WITH PASSWORD '{{app.key}}_pass123';\" || true) &&
|
|
78
|
+
psql -d postgres -c 'GRANT ALL PRIVILEGES ON DATABASE {{app.key}} TO {{app.key}}_user;' || true &&
|
|
79
|
+
psql -d {{app.key}} -c 'ALTER SCHEMA public OWNER TO {{app.key}}_user;' || true &&
|
|
80
|
+
psql -d {{app.key}} -c 'GRANT ALL ON SCHEMA public TO {{app.key}}_user;' || true &&
|
|
68
81
|
{{/if}}
|
|
69
82
|
echo 'Database initialization complete!'
|
|
70
83
|
"
|