@aifabrix/builder 2.7.0 → 2.8.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/.cursor/rules/project-rules.mdc +680 -0
- package/lib/app-config.js +10 -0
- package/lib/app-deploy.js +18 -0
- package/lib/app-dockerfile.js +15 -0
- package/lib/app-prompts.js +172 -9
- package/lib/app-push.js +15 -0
- package/lib/app-register.js +14 -0
- package/lib/app-run.js +25 -0
- package/lib/app.js +30 -13
- package/lib/audit-logger.js +9 -4
- package/lib/build.js +8 -0
- package/lib/cli.js +48 -0
- package/lib/commands/login.js +40 -3
- package/lib/config.js +121 -114
- package/lib/environment-deploy.js +305 -0
- package/lib/external-system-deploy.js +262 -0
- package/lib/external-system-generator.js +187 -0
- package/lib/schema/application-schema.json +894 -865
- package/lib/schema/external-datasource.schema.json +49 -1
- package/lib/schema/external-system.schema.json +4 -4
- package/lib/schema/infrastructure-schema.json +1 -1
- package/lib/templates.js +32 -1
- package/lib/utils/device-code.js +10 -2
- package/lib/utils/token-encryption.js +68 -0
- package/lib/validator.js +47 -3
- package/package.json +1 -1
- package/tatus +181 -0
- package/templates/external-system/external-datasource.json.hbs +55 -0
- package/templates/external-system/external-system.json.hbs +37 -0
package/lib/config.js
CHANGED
|
@@ -12,6 +12,7 @@ const fs = require('fs').promises;
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const yaml = require('js-yaml');
|
|
14
14
|
const os = require('os');
|
|
15
|
+
const { encryptToken, decryptToken, isTokenEncrypted } = require('./utils/token-encryption');
|
|
15
16
|
// Avoid importing paths here to prevent circular dependency.
|
|
16
17
|
// Config location is always under OS home at ~/.aifabrix/config.yaml
|
|
17
18
|
|
|
@@ -26,11 +27,6 @@ const RUNTIME_CONFIG_FILE = path.join(RUNTIME_CONFIG_DIR, 'config.yaml');
|
|
|
26
27
|
// Cache for developer ID - loaded when getConfig() is first called
|
|
27
28
|
let cachedDeveloperId = null;
|
|
28
29
|
|
|
29
|
-
/**
|
|
30
|
-
* Get stored configuration
|
|
31
|
-
* Loads developer ID and caches it as a property for easy access
|
|
32
|
-
* @returns {Promise<Object>} Configuration object with new structure
|
|
33
|
-
*/
|
|
34
30
|
async function getConfig() {
|
|
35
31
|
try {
|
|
36
32
|
const configContent = await fs.readFile(RUNTIME_CONFIG_FILE, 'utf8');
|
|
@@ -156,61 +152,37 @@ async function getDeveloperId() {
|
|
|
156
152
|
*/
|
|
157
153
|
async function setDeveloperId(developerId) {
|
|
158
154
|
const DEV_ID_DIGITS_REGEX = /^[0-9]+$/;
|
|
155
|
+
const errorMsg = 'Developer ID must be a non-negative digit string or number (0 = default infra, > 0 = developer-specific)';
|
|
159
156
|
let devIdString;
|
|
160
157
|
if (typeof developerId === 'number') {
|
|
161
|
-
if (!Number.isFinite(developerId) || developerId < 0)
|
|
162
|
-
throw new Error('Developer ID must be a non-negative digit string or number (0 = default infra, > 0 = developer-specific)');
|
|
163
|
-
}
|
|
158
|
+
if (!Number.isFinite(developerId) || developerId < 0) throw new Error(errorMsg);
|
|
164
159
|
devIdString = String(developerId);
|
|
165
160
|
} else if (typeof developerId === 'string') {
|
|
166
|
-
if (!DEV_ID_DIGITS_REGEX.test(developerId))
|
|
167
|
-
throw new Error('Developer ID must be a non-negative digit string or number (0 = default infra, > 0 = developer-specific)');
|
|
168
|
-
}
|
|
161
|
+
if (!DEV_ID_DIGITS_REGEX.test(developerId)) throw new Error(errorMsg);
|
|
169
162
|
devIdString = developerId;
|
|
170
163
|
} else {
|
|
171
|
-
throw new Error(
|
|
164
|
+
throw new Error(errorMsg);
|
|
172
165
|
}
|
|
173
|
-
// Clear cache first to ensure we get fresh data from file
|
|
174
166
|
cachedDeveloperId = null;
|
|
175
|
-
// Read file directly to avoid any caching issues
|
|
176
167
|
const config = await getConfig();
|
|
177
|
-
// Update developer ID
|
|
178
168
|
config['developer-id'] = devIdString;
|
|
179
|
-
// Update cache before saving
|
|
180
169
|
cachedDeveloperId = devIdString;
|
|
181
|
-
// Save the entire config object to ensure all fields are preserved
|
|
182
170
|
await saveConfig(config);
|
|
183
|
-
// Verify the file was saved correctly by reading it back
|
|
184
|
-
// This ensures the file system has written the data
|
|
185
|
-
// Add a small delay to ensure file system has flushed the write
|
|
186
171
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
187
|
-
// Read file again with fresh file handle to avoid OS caching
|
|
188
172
|
const savedContent = await fs.readFile(RUNTIME_CONFIG_FILE, 'utf8');
|
|
189
173
|
const savedConfig = yaml.load(savedContent);
|
|
190
|
-
// YAML may parse numbers as numbers, so convert to string for comparison
|
|
191
174
|
const savedDevIdString = String(savedConfig['developer-id']);
|
|
192
175
|
if (savedDevIdString !== devIdString) {
|
|
193
176
|
throw new Error(`Failed to save developer ID: expected ${devIdString}, got ${savedDevIdString}. File content: ${savedContent.substring(0, 200)}`);
|
|
194
177
|
}
|
|
195
|
-
// Clear the cache to force reload from file on next getDeveloperId() call
|
|
196
|
-
// This ensures we get the value that was actually saved to disk
|
|
197
178
|
cachedDeveloperId = null;
|
|
198
179
|
}
|
|
199
180
|
|
|
200
|
-
/**
|
|
201
|
-
* Get current environment from root-level config
|
|
202
|
-
* @returns {Promise<string>} Current environment (defaults to 'dev')
|
|
203
|
-
*/
|
|
204
181
|
async function getCurrentEnvironment() {
|
|
205
182
|
const config = await getConfig();
|
|
206
183
|
return config.environment || 'dev';
|
|
207
184
|
}
|
|
208
185
|
|
|
209
|
-
/**
|
|
210
|
-
* Set current environment in root-level config
|
|
211
|
-
* @param {string} environment - Environment to set (e.g., 'miso', 'dev', 'tst', 'pro')
|
|
212
|
-
* @returns {Promise<void>}
|
|
213
|
-
*/
|
|
214
186
|
async function setCurrentEnvironment(environment) {
|
|
215
187
|
if (!environment || typeof environment !== 'string') {
|
|
216
188
|
throw new Error('Environment must be a non-empty string');
|
|
@@ -220,31 +192,45 @@ async function setCurrentEnvironment(environment) {
|
|
|
220
192
|
await saveConfig(config);
|
|
221
193
|
}
|
|
222
194
|
|
|
223
|
-
/**
|
|
224
|
-
* Check if token is expired
|
|
225
|
-
* @param {string} expiresAt - ISO timestamp string
|
|
226
|
-
* @returns {boolean} True if token is expired
|
|
227
|
-
*/
|
|
228
195
|
function isTokenExpired(expiresAt) {
|
|
229
196
|
if (!expiresAt) return true;
|
|
230
197
|
const expirationTime = new Date(expiresAt).getTime();
|
|
231
198
|
const now = Date.now();
|
|
232
|
-
return now >= (expirationTime - 5 * 60 * 1000);
|
|
199
|
+
return now >= (expirationTime - 5 * 60 * 1000);
|
|
233
200
|
}
|
|
234
201
|
|
|
235
|
-
/**
|
|
236
|
-
* Check if token should be refreshed proactively (within 15 minutes of expiry)
|
|
237
|
-
* Helps keep Keycloak sessions alive by refreshing before SSO Session Idle timeout (30 minutes)
|
|
238
|
-
* @param {string} expiresAt - ISO timestamp string
|
|
239
|
-
* @returns {boolean} True if token should be refreshed proactively
|
|
240
|
-
*/
|
|
241
202
|
function shouldRefreshToken(expiresAt) {
|
|
242
203
|
if (!expiresAt) return true;
|
|
243
204
|
const expirationTime = new Date(expiresAt).getTime();
|
|
244
205
|
const now = Date.now();
|
|
245
|
-
return now >= (expirationTime - 15 * 60 * 1000);
|
|
206
|
+
return now >= (expirationTime - 15 * 60 * 1000);
|
|
207
|
+
}
|
|
208
|
+
async function encryptTokenValue(value) {
|
|
209
|
+
if (!value || typeof value !== 'string') return value;
|
|
210
|
+
try {
|
|
211
|
+
const encryptionKey = await getSecretsEncryptionKey();
|
|
212
|
+
if (!encryptionKey) return value;
|
|
213
|
+
if (isTokenEncrypted(value)) return value;
|
|
214
|
+
const encrypted = encryptToken(value, encryptionKey);
|
|
215
|
+
// Ensure we never return undefined for valid inputs
|
|
216
|
+
return encrypted !== undefined && encrypted !== null ? encrypted : value;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function decryptTokenValue(value) {
|
|
222
|
+
if (!value || typeof value !== 'string') return value;
|
|
223
|
+
try {
|
|
224
|
+
const encryptionKey = await getSecretsEncryptionKey();
|
|
225
|
+
if (!encryptionKey) return value;
|
|
226
|
+
if (!isTokenEncrypted(value)) return value;
|
|
227
|
+
const decrypted = decryptToken(value, encryptionKey);
|
|
228
|
+
// Ensure we never return undefined for valid inputs
|
|
229
|
+
return decrypted !== undefined && decrypted !== null ? decrypted : value;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
246
233
|
}
|
|
247
|
-
|
|
248
234
|
/**
|
|
249
235
|
* Get device token for controller
|
|
250
236
|
* @param {string} controllerUrl - Controller URL
|
|
@@ -254,10 +240,37 @@ async function getDeviceToken(controllerUrl) {
|
|
|
254
240
|
const config = await getConfig();
|
|
255
241
|
if (!config.device || !config.device[controllerUrl]) return null;
|
|
256
242
|
const deviceToken = config.device[controllerUrl];
|
|
243
|
+
|
|
244
|
+
// Migration: If tokens are plain text and encryption key exists, encrypt them first
|
|
245
|
+
const encryptionKey = await getSecretsEncryptionKey();
|
|
246
|
+
if (encryptionKey) {
|
|
247
|
+
let needsSave = false;
|
|
248
|
+
|
|
249
|
+
if (deviceToken.token && !isTokenEncrypted(deviceToken.token)) {
|
|
250
|
+
// Token is plain text, encrypt it
|
|
251
|
+
deviceToken.token = await encryptTokenValue(deviceToken.token);
|
|
252
|
+
needsSave = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (deviceToken.refreshToken && !isTokenEncrypted(deviceToken.refreshToken)) {
|
|
256
|
+
// Refresh token is plain text, encrypt it
|
|
257
|
+
deviceToken.refreshToken = await encryptTokenValue(deviceToken.refreshToken);
|
|
258
|
+
needsSave = true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (needsSave) {
|
|
262
|
+
// Save encrypted tokens back to config
|
|
263
|
+
await saveConfig(config);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Decrypt tokens if encrypted (for return value)
|
|
267
|
+
const token = deviceToken.token ? await decryptTokenValue(deviceToken.token) : undefined;
|
|
268
|
+
const refreshToken = deviceToken.refreshToken ? await decryptTokenValue(deviceToken.refreshToken) : null;
|
|
269
|
+
|
|
257
270
|
return {
|
|
258
271
|
controller: controllerUrl,
|
|
259
|
-
token:
|
|
260
|
-
refreshToken:
|
|
272
|
+
token: token,
|
|
273
|
+
refreshToken: refreshToken,
|
|
261
274
|
expiresAt: deviceToken.expiresAt
|
|
262
275
|
};
|
|
263
276
|
}
|
|
@@ -272,7 +285,26 @@ async function getClientToken(environment, appName) {
|
|
|
272
285
|
const config = await getConfig();
|
|
273
286
|
if (!config.environments || !config.environments[environment]) return null;
|
|
274
287
|
if (!config.environments[environment].clients || !config.environments[environment].clients[appName]) return null;
|
|
275
|
-
|
|
288
|
+
|
|
289
|
+
const clientToken = config.environments[environment].clients[appName];
|
|
290
|
+
|
|
291
|
+
// Migration: If token is plain text and encryption key exists, encrypt it first
|
|
292
|
+
const encryptionKey = await getSecretsEncryptionKey();
|
|
293
|
+
if (encryptionKey && clientToken.token && !isTokenEncrypted(clientToken.token)) {
|
|
294
|
+
// Token is plain text, encrypt it
|
|
295
|
+
clientToken.token = await encryptTokenValue(clientToken.token);
|
|
296
|
+
// Save encrypted token back to config
|
|
297
|
+
await saveConfig(config);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Decrypt token if encrypted (for return value)
|
|
301
|
+
const token = await decryptTokenValue(clientToken.token);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
controller: clientToken.controller,
|
|
305
|
+
token: token,
|
|
306
|
+
expiresAt: clientToken.expiresAt
|
|
307
|
+
};
|
|
276
308
|
}
|
|
277
309
|
|
|
278
310
|
/**
|
|
@@ -286,7 +318,16 @@ async function getClientToken(environment, appName) {
|
|
|
286
318
|
async function saveDeviceToken(controllerUrl, token, refreshToken, expiresAt) {
|
|
287
319
|
const config = await getConfig();
|
|
288
320
|
if (!config.device) config.device = {};
|
|
289
|
-
|
|
321
|
+
|
|
322
|
+
// Encrypt tokens before saving
|
|
323
|
+
const encryptedToken = await encryptTokenValue(token);
|
|
324
|
+
const encryptedRefreshToken = refreshToken ? await encryptTokenValue(refreshToken) : null;
|
|
325
|
+
|
|
326
|
+
config.device[controllerUrl] = {
|
|
327
|
+
token: encryptedToken,
|
|
328
|
+
refreshToken: encryptedRefreshToken,
|
|
329
|
+
expiresAt
|
|
330
|
+
};
|
|
290
331
|
await saveConfig(config);
|
|
291
332
|
}
|
|
292
333
|
|
|
@@ -304,7 +345,15 @@ async function saveClientToken(environment, appName, controllerUrl, token, expir
|
|
|
304
345
|
if (!config.environments) config.environments = {};
|
|
305
346
|
if (!config.environments[environment]) config.environments[environment] = { clients: {} };
|
|
306
347
|
if (!config.environments[environment].clients) config.environments[environment].clients = {};
|
|
307
|
-
|
|
348
|
+
|
|
349
|
+
// Encrypt token before saving
|
|
350
|
+
const encryptedToken = await encryptTokenValue(token);
|
|
351
|
+
|
|
352
|
+
config.environments[environment].clients[appName] = {
|
|
353
|
+
controller: controllerUrl,
|
|
354
|
+
token: encryptedToken,
|
|
355
|
+
expiresAt
|
|
356
|
+
};
|
|
308
357
|
await saveConfig(config);
|
|
309
358
|
}
|
|
310
359
|
|
|
@@ -349,100 +398,56 @@ async function setSecretsEncryptionKey(key) {
|
|
|
349
398
|
await saveConfig(config);
|
|
350
399
|
}
|
|
351
400
|
|
|
352
|
-
/**
|
|
353
|
-
* Get general secrets path from configuration
|
|
354
|
-
* Returns aifabrix-secrets path from config.yaml if configured
|
|
355
|
-
* @returns {Promise<string|null>} Secrets path or null if not set
|
|
356
|
-
*/
|
|
357
401
|
async function getSecretsPath() {
|
|
358
402
|
const config = await getConfig();
|
|
359
|
-
// Backward compatibility: prefer new key, fallback to legacy
|
|
360
403
|
return config['aifabrix-secrets'] || config['secrets-path'] || null;
|
|
361
404
|
}
|
|
362
405
|
|
|
363
|
-
/**
|
|
364
|
-
* Set general secrets path in configuration
|
|
365
|
-
* @param {string} secretsPath - Path to general secrets file
|
|
366
|
-
* @returns {Promise<void>}
|
|
367
|
-
*/
|
|
368
406
|
async function setSecretsPath(secretsPath) {
|
|
369
407
|
if (!secretsPath || typeof secretsPath !== 'string') {
|
|
370
408
|
throw new Error('Secrets path is required and must be a string');
|
|
371
409
|
}
|
|
372
|
-
|
|
373
410
|
const config = await getConfig();
|
|
374
|
-
// Store under new canonical key
|
|
375
411
|
config['aifabrix-secrets'] = secretsPath;
|
|
376
412
|
await saveConfig(config);
|
|
377
413
|
}
|
|
378
414
|
|
|
379
|
-
|
|
380
|
-
* Get aifabrix-home override from configuration
|
|
381
|
-
* @returns {Promise<string|null>} Home override path or null if not set
|
|
382
|
-
*/
|
|
383
|
-
async function getAifabrixHomeOverride() {
|
|
415
|
+
async function getPathConfig(key) {
|
|
384
416
|
const config = await getConfig();
|
|
385
|
-
return config[
|
|
417
|
+
return config[key] || null;
|
|
386
418
|
}
|
|
387
419
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
* @returns {Promise<void>}
|
|
392
|
-
*/
|
|
393
|
-
async function setAifabrixHomeOverride(homePath) {
|
|
394
|
-
if (!homePath || typeof homePath !== 'string') {
|
|
395
|
-
throw new Error('Home path is required and must be a string');
|
|
420
|
+
async function setPathConfig(key, value, errorMsg) {
|
|
421
|
+
if (!value || typeof value !== 'string') {
|
|
422
|
+
throw new Error(errorMsg);
|
|
396
423
|
}
|
|
397
424
|
const config = await getConfig();
|
|
398
|
-
config[
|
|
425
|
+
config[key] = value;
|
|
399
426
|
await saveConfig(config);
|
|
400
427
|
}
|
|
401
428
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
429
|
+
async function getAifabrixHomeOverride() {
|
|
430
|
+
return getPathConfig('aifabrix-home');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function setAifabrixHomeOverride(homePath) {
|
|
434
|
+
await setPathConfig('aifabrix-home', homePath, 'Home path is required and must be a string');
|
|
435
|
+
}
|
|
436
|
+
|
|
406
437
|
async function getAifabrixSecretsPath() {
|
|
407
|
-
|
|
408
|
-
return config['aifabrix-secrets'] || null;
|
|
438
|
+
return getPathConfig('aifabrix-secrets');
|
|
409
439
|
}
|
|
410
440
|
|
|
411
|
-
/**
|
|
412
|
-
* Set aifabrix-secrets path in configuration (canonical)
|
|
413
|
-
* @param {string} secretsPath - Path to default secrets file
|
|
414
|
-
* @returns {Promise<void>}
|
|
415
|
-
*/
|
|
416
441
|
async function setAifabrixSecretsPath(secretsPath) {
|
|
417
|
-
|
|
418
|
-
throw new Error('Secrets path is required and must be a string');
|
|
419
|
-
}
|
|
420
|
-
const config = await getConfig();
|
|
421
|
-
config['aifabrix-secrets'] = secretsPath;
|
|
422
|
-
await saveConfig(config);
|
|
442
|
+
await setPathConfig('aifabrix-secrets', secretsPath, 'Secrets path is required and must be a string');
|
|
423
443
|
}
|
|
424
444
|
|
|
425
|
-
/**
|
|
426
|
-
* Get aifabrix-env-config path from configuration
|
|
427
|
-
* @returns {Promise<string|null>} Env config path or null if not set
|
|
428
|
-
*/
|
|
429
445
|
async function getAifabrixEnvConfigPath() {
|
|
430
|
-
|
|
431
|
-
return config['aifabrix-env-config'] || null;
|
|
446
|
+
return getPathConfig('aifabrix-env-config');
|
|
432
447
|
}
|
|
433
448
|
|
|
434
|
-
/**
|
|
435
|
-
* Set aifabrix-env-config path in configuration
|
|
436
|
-
* @param {string} envConfigPath - Path to user env-config file
|
|
437
|
-
* @returns {Promise<void>}
|
|
438
|
-
*/
|
|
439
449
|
async function setAifabrixEnvConfigPath(envConfigPath) {
|
|
440
|
-
|
|
441
|
-
throw new Error('Env config path is required and must be a string');
|
|
442
|
-
}
|
|
443
|
-
const config = await getConfig();
|
|
444
|
-
config['aifabrix-env-config'] = envConfigPath;
|
|
445
|
-
await saveConfig(config);
|
|
450
|
+
await setPathConfig('aifabrix-env-config', envConfigPath, 'Env config path is required and must be a string');
|
|
446
451
|
}
|
|
447
452
|
|
|
448
453
|
// Create exports object
|
|
@@ -461,6 +466,8 @@ const exportsObj = {
|
|
|
461
466
|
getClientToken,
|
|
462
467
|
saveDeviceToken,
|
|
463
468
|
saveClientToken,
|
|
469
|
+
encryptTokenValue,
|
|
470
|
+
decryptTokenValue,
|
|
464
471
|
getSecretsEncryptionKey,
|
|
465
472
|
setSecretsEncryptionKey,
|
|
466
473
|
getSecretsPath,
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Fabrix Builder Environment Deployment Module
|
|
3
|
+
*
|
|
4
|
+
* Handles environment deployment/setup in Miso Controller.
|
|
5
|
+
* Sets up environment infrastructure before applications can be deployed.
|
|
6
|
+
*
|
|
7
|
+
* @fileoverview Environment deployment for AI Fabrix Builder
|
|
8
|
+
* @author AI Fabrix Team
|
|
9
|
+
* @version 2.0.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const chalk = require('chalk');
|
|
13
|
+
const logger = require('./utils/logger');
|
|
14
|
+
const config = require('./config');
|
|
15
|
+
const { validateControllerUrl, validateEnvironmentKey } = require('./utils/deployment-validation');
|
|
16
|
+
const { getOrRefreshDeviceToken } = require('./utils/token-manager');
|
|
17
|
+
const { authenticatedApiCall } = require('./utils/api');
|
|
18
|
+
const { handleDeploymentErrors } = require('./utils/deployment-errors');
|
|
19
|
+
const auditLogger = require('./audit-logger');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validates environment deployment prerequisites
|
|
23
|
+
* @param {string} envKey - Environment key
|
|
24
|
+
* @param {string} controllerUrl - Controller URL
|
|
25
|
+
* @throws {Error} If prerequisites are not met
|
|
26
|
+
*/
|
|
27
|
+
function validateEnvironmentPrerequisites(envKey, controllerUrl) {
|
|
28
|
+
if (!envKey || typeof envKey !== 'string') {
|
|
29
|
+
throw new Error('Environment key is required');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!controllerUrl || typeof controllerUrl !== 'string') {
|
|
33
|
+
throw new Error('Controller URL is required');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Validate environment key format
|
|
37
|
+
validateEnvironmentKey(envKey);
|
|
38
|
+
|
|
39
|
+
// Validate controller URL
|
|
40
|
+
validateControllerUrl(controllerUrl);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Gets authentication for environment deployment
|
|
45
|
+
* Uses device token (not app-specific client credentials)
|
|
46
|
+
* @async
|
|
47
|
+
* @param {string} controllerUrl - Controller URL
|
|
48
|
+
* @returns {Promise<Object>} Authentication configuration
|
|
49
|
+
* @throws {Error} If authentication is not available
|
|
50
|
+
*/
|
|
51
|
+
async function getEnvironmentAuth(controllerUrl) {
|
|
52
|
+
const validatedUrl = validateControllerUrl(controllerUrl);
|
|
53
|
+
|
|
54
|
+
// Get or refresh device token
|
|
55
|
+
const deviceToken = await getOrRefreshDeviceToken(validatedUrl);
|
|
56
|
+
|
|
57
|
+
if (!deviceToken || !deviceToken.token) {
|
|
58
|
+
throw new Error('Device token is required for environment deployment. Run "aifabrix login" first to authenticate.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
type: 'device',
|
|
63
|
+
token: deviceToken.token,
|
|
64
|
+
controller: deviceToken.controller || validatedUrl
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sends environment deployment request to controller
|
|
70
|
+
* @async
|
|
71
|
+
* @param {string} controllerUrl - Controller URL
|
|
72
|
+
* @param {string} envKey - Environment key
|
|
73
|
+
* @param {Object} authConfig - Authentication configuration
|
|
74
|
+
* @param {Object} options - Deployment options
|
|
75
|
+
* @returns {Promise<Object>} Deployment result
|
|
76
|
+
* @throws {Error} If deployment fails
|
|
77
|
+
*/
|
|
78
|
+
async function sendEnvironmentDeployment(controllerUrl, envKey, authConfig, options = {}) {
|
|
79
|
+
const validatedUrl = validateControllerUrl(controllerUrl);
|
|
80
|
+
const validatedEnvKey = validateEnvironmentKey(envKey);
|
|
81
|
+
|
|
82
|
+
// Build environment deployment request
|
|
83
|
+
const deploymentRequest = {
|
|
84
|
+
key: validatedEnvKey,
|
|
85
|
+
displayName: `${validatedEnvKey.charAt(0).toUpperCase() + validatedEnvKey.slice(1)} Environment`,
|
|
86
|
+
description: `${validatedEnvKey.charAt(0).toUpperCase() + validatedEnvKey.slice(1)} environment for application deployments`
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Add configuration if provided
|
|
90
|
+
if (options.config) {
|
|
91
|
+
// TODO: Load and parse config file if provided
|
|
92
|
+
// For now, just include the config path in description
|
|
93
|
+
deploymentRequest.description += ` (config: ${options.config})`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// API endpoint: POST /api/v1/environments/{env}/deploy
|
|
97
|
+
// Alternative: POST /api/v1/environments/deploy with environment in body
|
|
98
|
+
const endpoint = `${validatedUrl}/api/v1/environments/${validatedEnvKey}/deploy`;
|
|
99
|
+
|
|
100
|
+
// Log deployment attempt for audit
|
|
101
|
+
await auditLogger.logDeploymentAttempt(validatedEnvKey, validatedUrl, options);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const response = await authenticatedApiCall(
|
|
105
|
+
endpoint,
|
|
106
|
+
{
|
|
107
|
+
method: 'POST',
|
|
108
|
+
body: JSON.stringify(deploymentRequest)
|
|
109
|
+
},
|
|
110
|
+
authConfig.token
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (!response.success) {
|
|
114
|
+
const error = new Error(response.formattedError || response.error || 'Environment deployment failed');
|
|
115
|
+
error.status = response.status;
|
|
116
|
+
error.data = response.errorData;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Handle response structure
|
|
121
|
+
const responseData = response.data || {};
|
|
122
|
+
return {
|
|
123
|
+
success: true,
|
|
124
|
+
environment: validatedEnvKey,
|
|
125
|
+
deploymentId: responseData.deploymentId || responseData.id,
|
|
126
|
+
status: responseData.status || 'initiated',
|
|
127
|
+
url: responseData.url || `${validatedUrl}/environments/${validatedEnvKey}`,
|
|
128
|
+
message: responseData.message
|
|
129
|
+
};
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// Use unified error handler
|
|
132
|
+
await handleDeploymentErrors(error, validatedEnvKey, validatedUrl, false);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Polls environment deployment status
|
|
139
|
+
* @async
|
|
140
|
+
* @param {string} deploymentId - Deployment ID
|
|
141
|
+
* @param {string} controllerUrl - Controller URL
|
|
142
|
+
* @param {string} envKey - Environment key
|
|
143
|
+
* @param {Object} authConfig - Authentication configuration
|
|
144
|
+
* @param {Object} options - Polling options
|
|
145
|
+
* @returns {Promise<Object>} Final deployment status
|
|
146
|
+
*/
|
|
147
|
+
async function pollEnvironmentStatus(deploymentId, controllerUrl, envKey, authConfig, options = {}) {
|
|
148
|
+
const validatedUrl = validateControllerUrl(controllerUrl);
|
|
149
|
+
const validatedEnvKey = validateEnvironmentKey(envKey);
|
|
150
|
+
|
|
151
|
+
const pollInterval = options.pollInterval || 5000;
|
|
152
|
+
const maxAttempts = options.maxAttempts || 60;
|
|
153
|
+
const statusEndpoint = `${validatedUrl}/api/v1/environments/${validatedEnvKey}/status`;
|
|
154
|
+
|
|
155
|
+
logger.log(chalk.blue(`⏳ Polling environment status (${pollInterval}ms intervals)...`));
|
|
156
|
+
|
|
157
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
158
|
+
try {
|
|
159
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
160
|
+
|
|
161
|
+
const response = await authenticatedApiCall(
|
|
162
|
+
statusEndpoint,
|
|
163
|
+
{
|
|
164
|
+
method: 'GET'
|
|
165
|
+
},
|
|
166
|
+
authConfig.token
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (response.success && response.data) {
|
|
170
|
+
const status = response.data.status || response.data.ready;
|
|
171
|
+
const isReady = status === 'ready' || status === 'completed' || response.data.ready === true;
|
|
172
|
+
|
|
173
|
+
if (isReady) {
|
|
174
|
+
return {
|
|
175
|
+
success: true,
|
|
176
|
+
environment: validatedEnvKey,
|
|
177
|
+
status: 'ready',
|
|
178
|
+
message: 'Environment is ready for application deployments'
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check for terminal failure states
|
|
183
|
+
if (status === 'failed' || status === 'error') {
|
|
184
|
+
throw new Error(`Environment deployment failed: ${response.data.message || 'Unknown error'}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// If it's a terminal error (not a timeout), throw it
|
|
189
|
+
if (error.message && error.message.includes('failed')) {
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
// Otherwise, continue polling
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (attempt < maxAttempts) {
|
|
196
|
+
logger.log(chalk.gray(` Attempt ${attempt}/${maxAttempts}...`));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Timeout
|
|
201
|
+
throw new Error(`Environment deployment timeout after ${maxAttempts} attempts. Check controller logs for status.`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Displays environment deployment results
|
|
206
|
+
* @param {Object} result - Deployment result
|
|
207
|
+
*/
|
|
208
|
+
function displayDeploymentResults(result) {
|
|
209
|
+
logger.log(chalk.green('\n✅ Environment deployed successfully'));
|
|
210
|
+
logger.log(chalk.blue(` Environment: ${result.environment}`));
|
|
211
|
+
logger.log(chalk.blue(` Status: ${result.status === 'ready' ? '✅ ready' : result.status}`));
|
|
212
|
+
if (result.url) {
|
|
213
|
+
logger.log(chalk.blue(` URL: ${result.url}`));
|
|
214
|
+
}
|
|
215
|
+
if (result.deploymentId) {
|
|
216
|
+
logger.log(chalk.blue(` Deployment ID: ${result.deploymentId}`));
|
|
217
|
+
}
|
|
218
|
+
logger.log(chalk.green('\n✓ Environment is ready for application deployments'));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Deploys/setups an environment in the controller
|
|
223
|
+
* @async
|
|
224
|
+
* @function deployEnvironment
|
|
225
|
+
* @param {string} envKey - Environment key (miso, dev, tst, pro)
|
|
226
|
+
* @param {Object} options - Deployment options
|
|
227
|
+
* @param {string} options.controller - Controller URL (required)
|
|
228
|
+
* @param {string} [options.config] - Environment configuration file (optional)
|
|
229
|
+
* @param {boolean} [options.skipValidation] - Skip validation checks
|
|
230
|
+
* @param {boolean} [options.poll] - Poll for deployment status (default: true)
|
|
231
|
+
* @param {boolean} [options.noPoll] - Do not poll for status
|
|
232
|
+
* @returns {Promise<Object>} Deployment result
|
|
233
|
+
* @throws {Error} If deployment fails
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* await deployEnvironment('dev', { controller: 'https://controller.aifabrix.ai' });
|
|
237
|
+
*/
|
|
238
|
+
async function deployEnvironment(envKey, options = {}) {
|
|
239
|
+
try {
|
|
240
|
+
// 1. Input validation
|
|
241
|
+
if (!envKey || typeof envKey !== 'string' || envKey.trim().length === 0) {
|
|
242
|
+
throw new Error('Environment key is required');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const controllerUrl = options.controller || options['controller-url'];
|
|
246
|
+
if (!controllerUrl) {
|
|
247
|
+
throw new Error('Controller URL is required. Use --controller flag');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 2. Validate prerequisites
|
|
251
|
+
if (!options.skipValidation) {
|
|
252
|
+
validateEnvironmentPrerequisites(envKey, controllerUrl);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 3. Update root-level environment in config.yaml
|
|
256
|
+
await config.setCurrentEnvironment(envKey);
|
|
257
|
+
|
|
258
|
+
// 4. Get authentication (device token)
|
|
259
|
+
logger.log(chalk.blue(`\n📋 Deploying environment '${envKey}' to ${controllerUrl}...`));
|
|
260
|
+
const authConfig = await getEnvironmentAuth(controllerUrl);
|
|
261
|
+
logger.log(chalk.green('✓ Environment validated'));
|
|
262
|
+
logger.log(chalk.green('✓ Authentication successful'));
|
|
263
|
+
|
|
264
|
+
// 5. Send environment deployment request
|
|
265
|
+
logger.log(chalk.blue('\n🚀 Deploying environment infrastructure...'));
|
|
266
|
+
const validatedControllerUrl = validateControllerUrl(authConfig.controller);
|
|
267
|
+
const result = await sendEnvironmentDeployment(validatedControllerUrl, envKey, authConfig, options);
|
|
268
|
+
|
|
269
|
+
logger.log(chalk.blue(`📤 Sending deployment request to ${validatedControllerUrl}/api/v1/environments/${envKey}/deploy...`));
|
|
270
|
+
|
|
271
|
+
// 6. Poll for status if enabled
|
|
272
|
+
const shouldPoll = options.poll !== false && !options.noPoll;
|
|
273
|
+
if (shouldPoll && result.deploymentId) {
|
|
274
|
+
const pollResult = await pollEnvironmentStatus(
|
|
275
|
+
result.deploymentId,
|
|
276
|
+
validatedControllerUrl,
|
|
277
|
+
envKey,
|
|
278
|
+
authConfig,
|
|
279
|
+
{
|
|
280
|
+
pollInterval: 5000,
|
|
281
|
+
maxAttempts: 60
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
result.status = pollResult.status;
|
|
285
|
+
result.message = pollResult.message;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 7. Display results
|
|
289
|
+
displayDeploymentResults(result);
|
|
290
|
+
|
|
291
|
+
return result;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
// Error handling is done in sendEnvironmentDeployment and pollEnvironmentStatus
|
|
294
|
+
// Re-throw with context
|
|
295
|
+
if (error._logged !== true) {
|
|
296
|
+
logger.error(chalk.red(`\n❌ Environment deployment failed: ${error.message}`));
|
|
297
|
+
}
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = {
|
|
303
|
+
deployEnvironment
|
|
304
|
+
};
|
|
305
|
+
|