@aifabrix/builder 2.33.0 → 2.33.3
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 +13 -0
- package/integration/hubspot/README.md +7 -7
- package/lib/api/index.js +6 -2
- package/lib/app/deploy-config.js +161 -0
- package/lib/app/deploy.js +28 -153
- package/lib/app/register.js +6 -5
- package/lib/app/run-helpers.js +23 -17
- package/lib/cli.js +31 -1
- package/lib/commands/logout.js +3 -4
- package/lib/commands/up-common.js +72 -0
- package/lib/commands/up-dataplane.js +109 -0
- package/lib/commands/up-miso.js +134 -0
- package/lib/core/config.js +32 -9
- package/lib/core/secrets-docker-env.js +88 -0
- package/lib/core/secrets.js +142 -115
- package/lib/datasource/deploy.js +31 -3
- package/lib/datasource/list.js +102 -15
- package/lib/infrastructure/helpers.js +82 -1
- package/lib/infrastructure/index.js +2 -0
- package/lib/schema/env-config.yaml +7 -0
- package/lib/utils/api.js +70 -2
- package/lib/utils/compose-generator.js +13 -13
- package/lib/utils/config-paths.js +13 -0
- package/lib/utils/device-code.js +2 -2
- package/lib/utils/env-endpoints.js +2 -5
- package/lib/utils/env-map.js +4 -5
- package/lib/utils/error-formatters/network-errors.js +13 -3
- package/lib/utils/parse-image-ref.js +27 -0
- package/lib/utils/paths.js +28 -4
- package/lib/utils/secrets-generator.js +34 -12
- package/lib/utils/secrets-helpers.js +1 -2
- package/lib/utils/token-manager-refresh.js +5 -0
- package/package.json +1 -1
- package/templates/applications/dataplane/Dockerfile +16 -0
- package/templates/applications/dataplane/README.md +205 -0
- package/templates/applications/dataplane/env.template +143 -0
- package/templates/applications/dataplane/rbac.yaml +283 -0
- package/templates/applications/dataplane/variables.yaml +143 -0
- package/templates/applications/keycloak/Dockerfile +1 -1
- package/templates/applications/keycloak/README.md +193 -0
- package/templates/applications/keycloak/variables.yaml +5 -6
- package/templates/applications/miso-controller/Dockerfile +8 -8
- package/templates/applications/miso-controller/README.md +369 -0
- package/templates/applications/miso-controller/env.template +114 -6
- package/templates/applications/miso-controller/rbac.yaml +74 -0
- package/templates/applications/miso-controller/variables.yaml +93 -5
- package/templates/github/ci.yaml.hbs +44 -1
- package/templates/github/release.yaml.hbs +44 -0
- package/templates/infra/compose.yaml.hbs +2 -1
- package/templates/applications/miso-controller/test.yaml +0 -1
package/lib/core/secrets.js
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
|
-
const yaml = require('js-yaml');
|
|
15
14
|
const logger = require('../utils/logger');
|
|
16
15
|
const config = require('./config');
|
|
17
16
|
const {
|
|
@@ -30,6 +29,7 @@ const {
|
|
|
30
29
|
const { processEnvVariables } = require('../utils/env-copy');
|
|
31
30
|
const { buildEnvVarMap } = require('../utils/env-map');
|
|
32
31
|
const { resolveServicePortsInEnvContent } = require('../utils/secrets-url');
|
|
32
|
+
const { updatePortForDocker } = require('./secrets-docker-env');
|
|
33
33
|
const {
|
|
34
34
|
generateMissingSecrets,
|
|
35
35
|
createDefaultSecrets
|
|
@@ -44,7 +44,6 @@ const {
|
|
|
44
44
|
} = require('../utils/secrets-utils');
|
|
45
45
|
const { decryptSecret, isEncrypted } = require('../utils/secrets-encryption');
|
|
46
46
|
const pathsUtil = require('../utils/paths');
|
|
47
|
-
const { getContainerPortFromPath } = require('../utils/port-resolver');
|
|
48
47
|
|
|
49
48
|
/**
|
|
50
49
|
* Generates a canonical secret name from an environment variable key.
|
|
@@ -113,7 +112,7 @@ async function decryptSecretsObject(secrets) {
|
|
|
113
112
|
/**
|
|
114
113
|
* Loads secrets with cascading lookup
|
|
115
114
|
* Supports both user secrets (~/.aifabrix/secrets.local.yaml) and project overrides
|
|
116
|
-
*
|
|
115
|
+
* When aifabrix-secrets (or secrets-path) is set in config.yaml and that file exists, it is used as base; user's file (local) is strongest and overrides project for same key. Otherwise user's file first, then aifabrix-secrets as fallback.
|
|
117
116
|
* Automatically decrypts values with secure:// prefix
|
|
118
117
|
*
|
|
119
118
|
* @async
|
|
@@ -126,9 +125,40 @@ async function decryptSecretsObject(secrets) {
|
|
|
126
125
|
* @example
|
|
127
126
|
* const secrets = await loadSecrets('../../secrets.local.yaml');
|
|
128
127
|
* // Returns: { 'postgres-passwordKeyVault': 'admin123', ... }
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* // When config.yaml has aifabrix-secrets: ./secrets.local.yaml, project file is base;
|
|
131
|
+
* // ~/.aifabrix/secrets.local.yaml overrides project for same key (local strongest).
|
|
132
|
+
* const secrets = await loadSecrets(undefined, 'myapp');
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Loads config secrets path, merges with user secrets (user overrides). Used by loadSecrets cascade.
|
|
137
|
+
* @async
|
|
138
|
+
* @returns {Promise<Object|null>} Merged secrets object or null
|
|
129
139
|
*/
|
|
140
|
+
async function loadMergedConfigAndUserSecrets() {
|
|
141
|
+
try {
|
|
142
|
+
const configSecretsPath = await config.getSecretsPath();
|
|
143
|
+
if (!configSecretsPath) return null;
|
|
144
|
+
const resolvedConfigPath = path.isAbsolute(configSecretsPath)
|
|
145
|
+
? configSecretsPath
|
|
146
|
+
: path.resolve(process.cwd(), configSecretsPath);
|
|
147
|
+
if (!fs.existsSync(resolvedConfigPath)) return null;
|
|
148
|
+
const configSecrets = readYamlAtPath(resolvedConfigPath);
|
|
149
|
+
if (!configSecrets || typeof configSecrets !== 'object') return null;
|
|
150
|
+
const merged = { ...configSecrets };
|
|
151
|
+
const userSecrets = loadUserSecrets();
|
|
152
|
+
for (const key of Object.keys(userSecrets)) {
|
|
153
|
+
merged[key] = userSecrets[key];
|
|
154
|
+
}
|
|
155
|
+
return merged;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
130
161
|
async function loadSecrets(secretsPath, _appName) {
|
|
131
|
-
// Explicit path branch
|
|
132
162
|
if (secretsPath) {
|
|
133
163
|
const resolvedPath = resolveSecretsPath(secretsPath);
|
|
134
164
|
if (!fs.existsSync(resolvedPath)) {
|
|
@@ -141,9 +171,11 @@ async function loadSecrets(secretsPath, _appName) {
|
|
|
141
171
|
return await decryptSecretsObject(explicitSecrets);
|
|
142
172
|
}
|
|
143
173
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
let mergedSecrets = await loadMergedConfigAndUserSecrets();
|
|
175
|
+
if (!mergedSecrets || Object.keys(mergedSecrets).length === 0) {
|
|
176
|
+
mergedSecrets = loadUserSecrets();
|
|
177
|
+
mergedSecrets = await applyCanonicalSecretsOverride(mergedSecrets);
|
|
178
|
+
}
|
|
147
179
|
if (Object.keys(mergedSecrets).length === 0) {
|
|
148
180
|
mergedSecrets = loadDefaultSecrets();
|
|
149
181
|
}
|
|
@@ -174,14 +206,12 @@ async function loadSecrets(secretsPath, _appName) {
|
|
|
174
206
|
async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePaths = null, appName = null) {
|
|
175
207
|
const os = require('os');
|
|
176
208
|
|
|
177
|
-
// Get developer-id for local
|
|
209
|
+
// Get developer-id for port variables (local and docker: *_PUBLIC_PORT = base + devId*100)
|
|
178
210
|
let developerId = null;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// ignore, will use null (buildEnvVarMap will fetch it)
|
|
184
|
-
}
|
|
211
|
+
try {
|
|
212
|
+
developerId = await config.getDeveloperId();
|
|
213
|
+
} catch {
|
|
214
|
+
// ignore, buildEnvVarMap will use default
|
|
185
215
|
}
|
|
186
216
|
|
|
187
217
|
let envVars = await buildEnvVarMap(environment, os, developerId);
|
|
@@ -199,103 +229,6 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local',
|
|
|
199
229
|
return replaceKvInContent(resolved, secrets, envVars);
|
|
200
230
|
}
|
|
201
231
|
|
|
202
|
-
// resolveServicePortsInEnvContent, loadEnvTemplate, and processEnvVariables
|
|
203
|
-
// are imported from ./utils/secrets-helpers above.
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Generates .env file from template and secrets
|
|
207
|
-
* Creates environment file for local development
|
|
208
|
-
*
|
|
209
|
-
* @async
|
|
210
|
-
* @function generateEnvFile
|
|
211
|
-
* @param {string} appName - Name of the application
|
|
212
|
-
* @param {string} [secretsPath] - Path to secrets file (optional)
|
|
213
|
-
* @param {string} [environment='local'] - Environment context
|
|
214
|
-
* @param {boolean} [force=false] - Generate missing secret keys in secrets file
|
|
215
|
-
* @param {boolean} [skipOutputPath=false] - Skip processing envOutputPath (to avoid recursion)
|
|
216
|
-
* @returns {Promise<string>} Path to generated .env file
|
|
217
|
-
* @throws {Error} If generation fails
|
|
218
|
-
*
|
|
219
|
-
* @example
|
|
220
|
-
* const envPath = await generateEnvFile('myapp', '../../secrets.local.yaml', 'local', true);
|
|
221
|
-
* // Returns: './builder/myapp/.env'
|
|
222
|
-
*/
|
|
223
|
-
/**
|
|
224
|
-
* Gets base docker environment config
|
|
225
|
-
* @async
|
|
226
|
-
* @function getBaseDockerEnv
|
|
227
|
-
* @returns {Promise<Object>} Docker environment config
|
|
228
|
-
*/
|
|
229
|
-
async function getBaseDockerEnv() {
|
|
230
|
-
const { getEnvHosts } = require('../utils/env-endpoints');
|
|
231
|
-
return await getEnvHosts('docker');
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Applies config.yaml override to docker environment
|
|
236
|
-
* @function applyDockerEnvOverride
|
|
237
|
-
* @param {Object} dockerEnv - Base docker environment config
|
|
238
|
-
* @returns {Object} Updated docker environment config
|
|
239
|
-
*/
|
|
240
|
-
function applyDockerEnvOverride(dockerEnv) {
|
|
241
|
-
try {
|
|
242
|
-
const os = require('os');
|
|
243
|
-
const cfgPath = path.join(os.homedir(), '.aifabrix', 'config.yaml');
|
|
244
|
-
if (fs.existsSync(cfgPath)) {
|
|
245
|
-
const cfgContent = fs.readFileSync(cfgPath, 'utf8');
|
|
246
|
-
const cfg = yaml.load(cfgContent) || {};
|
|
247
|
-
if (cfg && cfg.environments && cfg.environments.docker) {
|
|
248
|
-
return { ...dockerEnv, ...cfg.environments.docker };
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
} catch {
|
|
252
|
-
// Ignore config.yaml read errors, continue with env-config values
|
|
253
|
-
}
|
|
254
|
-
return dockerEnv;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Gets container port from docker environment config
|
|
259
|
-
* @function getContainerPortFromDockerEnv
|
|
260
|
-
* @param {Object} dockerEnv - Docker environment config
|
|
261
|
-
* @returns {number} Container port (defaults to 3000)
|
|
262
|
-
*/
|
|
263
|
-
function getContainerPortFromDockerEnv(dockerEnv) {
|
|
264
|
-
if (dockerEnv.PORT === undefined || dockerEnv.PORT === null) {
|
|
265
|
-
return 3000;
|
|
266
|
-
}
|
|
267
|
-
const portVal = typeof dockerEnv.PORT === 'number' ? dockerEnv.PORT : parseInt(dockerEnv.PORT, 10);
|
|
268
|
-
return Number.isNaN(portVal) ? 3000 : portVal;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Updates PORT in resolved content for docker environment
|
|
273
|
-
* Sets PORT to container port (build.containerPort or port from variables.yaml)
|
|
274
|
-
* NOT the host port (which includes developer-id offset)
|
|
275
|
-
* @async
|
|
276
|
-
* @function updatePortForDocker
|
|
277
|
-
* @param {string} resolved - Resolved environment content
|
|
278
|
-
* @param {string} variablesPath - Path to variables.yaml file
|
|
279
|
-
* @returns {Promise<string>} Updated content with PORT set
|
|
280
|
-
*/
|
|
281
|
-
async function updatePortForDocker(resolved, variablesPath) {
|
|
282
|
-
// Step 1: Get base config from env-config.yaml
|
|
283
|
-
let dockerEnv = await getBaseDockerEnv();
|
|
284
|
-
|
|
285
|
-
// Step 2: Apply config.yaml → environments.docker override (if exists)
|
|
286
|
-
dockerEnv = applyDockerEnvOverride(dockerEnv);
|
|
287
|
-
|
|
288
|
-
// Step 3: Get PORT value for container (should be container port, NOT host port)
|
|
289
|
-
let containerPort = getContainerPortFromPath(variablesPath);
|
|
290
|
-
if (containerPort === null) {
|
|
291
|
-
containerPort = getContainerPortFromDockerEnv(dockerEnv);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// PORT in container should be the container port (no developer-id adjustment)
|
|
295
|
-
// Docker will map container port to host port via port mapping
|
|
296
|
-
return resolved.replace(/^PORT\s*=\s*.*$/m, `PORT=${containerPort}`);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
232
|
/**
|
|
300
233
|
* Applies environment-specific transformations to resolved content
|
|
301
234
|
* @async
|
|
@@ -348,7 +281,7 @@ async function applyEnvironmentTransformations(resolved, environment, variablesP
|
|
|
348
281
|
* @throws {Error} If generation fails
|
|
349
282
|
*/
|
|
350
283
|
async function generateEnvContent(appName, secretsPath, environment = 'local', force = false) {
|
|
351
|
-
const builderPath =
|
|
284
|
+
const builderPath = pathsUtil.getBuilderPath(appName);
|
|
352
285
|
const templatePath = path.join(builderPath, 'env.template');
|
|
353
286
|
const variablesPath = path.join(builderPath, 'variables.yaml');
|
|
354
287
|
|
|
@@ -375,14 +308,108 @@ async function generateEnvContent(appName, secretsPath, environment = 'local', f
|
|
|
375
308
|
return resolved;
|
|
376
309
|
}
|
|
377
310
|
|
|
378
|
-
|
|
379
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Parses .env file content into a key-to-value map.
|
|
313
|
+
* Only includes lines that look like KEY=value (first = separates key and value).
|
|
314
|
+
*
|
|
315
|
+
* @function parseEnvContentToMap
|
|
316
|
+
* @param {string} content - Raw .env file content
|
|
317
|
+
* @returns {Object.<string, string>} Map of variable name to value
|
|
318
|
+
*/
|
|
319
|
+
function parseEnvContentToMap(content) {
|
|
320
|
+
if (!content || typeof content !== 'string') {
|
|
321
|
+
return {};
|
|
322
|
+
}
|
|
323
|
+
const map = {};
|
|
324
|
+
const lines = content.split(/\r?\n/);
|
|
325
|
+
for (const line of lines) {
|
|
326
|
+
const trimmed = line.trim();
|
|
327
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const eq = trimmed.indexOf('=');
|
|
331
|
+
if (eq > 0) {
|
|
332
|
+
const key = trimmed.substring(0, eq).trim();
|
|
333
|
+
const value = trimmed.substring(eq + 1);
|
|
334
|
+
map[key] = value;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return map;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Merges new .env content with existing .env: newly resolved content wins for keys it
|
|
342
|
+
* defines (so project secrets take effect when re-running). Keys only in existing .env
|
|
343
|
+
* are appended so manual additions are kept.
|
|
344
|
+
*
|
|
345
|
+
* @function mergeEnvContentPreservingExisting
|
|
346
|
+
* @param {string} newContent - Newly generated .env content (from template + loadSecrets)
|
|
347
|
+
* @param {Object.<string, string>} existingMap - Existing key-to-value map from current .env
|
|
348
|
+
* @returns {string} Merged content: new values for keys in newContent, plus extra existing keys
|
|
349
|
+
*/
|
|
350
|
+
function mergeEnvContentPreservingExisting(newContent, existingMap) {
|
|
351
|
+
const lines = newContent.split(/\r?\n/);
|
|
352
|
+
const newKeys = new Set();
|
|
353
|
+
const out = [];
|
|
354
|
+
for (const line of lines) {
|
|
355
|
+
const trimmed = line.trim();
|
|
356
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
357
|
+
out.push(line);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const eq = trimmed.indexOf('=');
|
|
361
|
+
if (eq > 0) {
|
|
362
|
+
const key = trimmed.substring(0, eq).trim();
|
|
363
|
+
newKeys.add(key);
|
|
364
|
+
}
|
|
365
|
+
out.push(line);
|
|
366
|
+
}
|
|
367
|
+
if (existingMap && Object.keys(existingMap).length > 0) {
|
|
368
|
+
for (const key of Object.keys(existingMap)) {
|
|
369
|
+
if (!newKeys.has(key)) {
|
|
370
|
+
out.push(`${key}=${existingMap[key]}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return out.join('\n');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Generates and writes .env file. Newly resolved values (from template + secrets) win over
|
|
379
|
+
* existing .env so that project secrets (e.g. from config aifabrix-secrets) take effect on
|
|
380
|
+
* re-run. Extra variables only in existing .env are kept.
|
|
381
|
+
*
|
|
382
|
+
* @async
|
|
383
|
+
* @function generateEnvFile
|
|
384
|
+
* @param {string} appName - Name of the application
|
|
385
|
+
* @param {string} [secretsPath] - Path to secrets file (optional)
|
|
386
|
+
* @param {string} [environment='local'] - Environment context ('local' or 'docker')
|
|
387
|
+
* @param {boolean} [force=false] - Generate missing secret keys in secrets file
|
|
388
|
+
* @param {boolean} [skipOutputPath=false] - Skip copying to envOutputPath
|
|
389
|
+
* @param {string} [preserveFromPath=null] - Path to existing .env to preserve values from (defaults to builder .env)
|
|
390
|
+
* @returns {Promise<string>} Path to generated .env file
|
|
391
|
+
* @throws {Error} If generation fails
|
|
392
|
+
*
|
|
393
|
+
* @example
|
|
394
|
+
* const envPath = await generateEnvFile('myapp', undefined, 'docker');
|
|
395
|
+
* // When builder/myapp/.env already exists, existing values are preserved
|
|
396
|
+
*/
|
|
397
|
+
async function generateEnvFile(appName, secretsPath, environment = 'local', force = false, skipOutputPath = false, preserveFromPath = null) {
|
|
398
|
+
const builderPath = pathsUtil.getBuilderPath(appName);
|
|
380
399
|
const variablesPath = path.join(builderPath, 'variables.yaml');
|
|
381
400
|
const envPath = path.join(builderPath, '.env');
|
|
382
401
|
|
|
383
402
|
const resolved = await generateEnvContent(appName, secretsPath, environment, force);
|
|
384
403
|
|
|
385
|
-
|
|
404
|
+
let toWrite = resolved;
|
|
405
|
+
const pathToPreserve = preserveFromPath || envPath;
|
|
406
|
+
if (pathToPreserve && fs.existsSync(pathToPreserve)) {
|
|
407
|
+
const existingContent = fs.readFileSync(pathToPreserve, 'utf8');
|
|
408
|
+
const existingMap = parseEnvContentToMap(existingContent);
|
|
409
|
+
toWrite = mergeEnvContentPreservingExisting(resolved, existingMap);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
fs.writeFileSync(envPath, toWrite, { mode: 0o600 });
|
|
386
413
|
|
|
387
414
|
// Process and copy to envOutputPath if configured (uses localPort for copied file)
|
|
388
415
|
if (!skipOutputPath) {
|
package/lib/datasource/deploy.js
CHANGED
|
@@ -118,10 +118,27 @@ async function setupDeploymentAuth(controllerUrl, environment, appKey) {
|
|
|
118
118
|
logger.log(chalk.green('✓ Authentication successful'));
|
|
119
119
|
|
|
120
120
|
logger.log(chalk.blue('🌐 Resolving dataplane URL...'));
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
let dataplaneUrl;
|
|
122
|
+
try {
|
|
123
|
+
dataplaneUrl = await resolveDataplaneUrl(controllerUrl, environment, authConfig);
|
|
124
|
+
logger.log(chalk.green(`✓ Dataplane URL: ${dataplaneUrl}`));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logger.error(chalk.red('❌ Failed to resolve dataplane URL:'), error.message);
|
|
127
|
+
logger.error(chalk.gray('\nThe dataplane URL is automatically discovered from the controller.'));
|
|
128
|
+
logger.error(chalk.gray('If discovery fails, ensure you are logged in and the controller is accessible:'));
|
|
129
|
+
logger.error(chalk.gray(' aifabrix login'));
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Validate dataplane URL
|
|
134
|
+
if (!dataplaneUrl || !dataplaneUrl.trim()) {
|
|
135
|
+
logger.error(chalk.red('❌ Dataplane URL is empty.'));
|
|
136
|
+
logger.error(chalk.gray('The dataplane URL could not be discovered from the controller.'));
|
|
137
|
+
logger.error(chalk.gray('Ensure the dataplane service is registered in the controller.'));
|
|
138
|
+
throw new Error('Dataplane URL is empty');
|
|
139
|
+
}
|
|
123
140
|
|
|
124
|
-
return { authConfig, dataplaneUrl };
|
|
141
|
+
return { authConfig, dataplaneUrl: dataplaneUrl.trim() };
|
|
125
142
|
}
|
|
126
143
|
|
|
127
144
|
/**
|
|
@@ -143,6 +160,17 @@ async function publishDatasourceToDataplane(dataplaneUrl, systemKey, authConfig,
|
|
|
143
160
|
const formattedError = publishResponse.formattedError || formatApiError(publishResponse);
|
|
144
161
|
logger.error(chalk.red('❌ Publish failed:'));
|
|
145
162
|
logger.error(formattedError);
|
|
163
|
+
|
|
164
|
+
// Show dataplane URL and endpoint information
|
|
165
|
+
if (publishResponse.errorData && publishResponse.errorData.endpointUrl) {
|
|
166
|
+
logger.error(chalk.gray(`\nEndpoint URL: ${publishResponse.errorData.endpointUrl}`));
|
|
167
|
+
} else if (dataplaneUrl) {
|
|
168
|
+
logger.error(chalk.gray(`\nDataplane URL: ${dataplaneUrl}`));
|
|
169
|
+
logger.error(chalk.gray(`System Key: ${systemKey}`));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
logger.error(chalk.gray('\nFull response for debugging:'));
|
|
173
|
+
logger.error(chalk.gray(JSON.stringify(publishResponse, null, 2)));
|
|
146
174
|
throw new Error(`Dataplane publish failed: ${formattedError}`);
|
|
147
175
|
}
|
|
148
176
|
|
package/lib/datasource/list.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Datasource List Command
|
|
3
3
|
*
|
|
4
|
-
* Lists datasources from an environment via
|
|
4
|
+
* Lists datasources from an environment via dataplane API.
|
|
5
|
+
* Gets dataplane URL from controller, then lists datasources from dataplane.
|
|
5
6
|
*
|
|
6
7
|
* @fileoverview Datasource listing for AI Fabrix Builder
|
|
7
8
|
* @author AI Fabrix Team
|
|
@@ -11,7 +12,8 @@
|
|
|
11
12
|
const chalk = require('chalk');
|
|
12
13
|
const { getConfig, resolveEnvironment } = require('../core/config');
|
|
13
14
|
const { getOrRefreshDeviceToken } = require('../utils/token-manager');
|
|
14
|
-
const {
|
|
15
|
+
const { listDatasources: listDatasourcesFromDataplane } = require('../api/datasources-core.api');
|
|
16
|
+
const { resolveDataplaneUrl } = require('../utils/dataplane-resolver');
|
|
15
17
|
const { formatApiError } = require('../utils/api-error-handler');
|
|
16
18
|
const logger = require('../utils/logger');
|
|
17
19
|
|
|
@@ -103,14 +105,19 @@ function extractDatasources(response) {
|
|
|
103
105
|
* @function displayDatasources
|
|
104
106
|
* @param {Array} datasources - Array of datasource objects
|
|
105
107
|
* @param {string} environment - Environment key
|
|
108
|
+
* @param {string} dataplaneUrl - Dataplane URL for header display
|
|
106
109
|
*/
|
|
107
|
-
function displayDatasources(datasources, environment) {
|
|
110
|
+
function displayDatasources(datasources, environment, dataplaneUrl) {
|
|
111
|
+
const environmentName = environment || 'dev';
|
|
112
|
+
const header = `Datasources in ${environmentName} environment${dataplaneUrl ? ` (${dataplaneUrl})` : ''}`;
|
|
113
|
+
|
|
108
114
|
if (datasources.length === 0) {
|
|
109
|
-
logger.log(chalk.
|
|
115
|
+
logger.log(chalk.bold(`\n📋 ${header}:\n`));
|
|
116
|
+
logger.log(chalk.gray(' No datasources found in this environment.\n'));
|
|
110
117
|
return;
|
|
111
118
|
}
|
|
112
119
|
|
|
113
|
-
logger.log(chalk.
|
|
120
|
+
logger.log(chalk.bold(`\n📋 ${header}:\n`));
|
|
114
121
|
logger.log(chalk.gray('Key'.padEnd(30) + 'Display Name'.padEnd(30) + 'System Key'.padEnd(20) + 'Version'.padEnd(15) + 'Status'));
|
|
115
122
|
logger.log(chalk.gray('-'.repeat(120)));
|
|
116
123
|
|
|
@@ -169,7 +176,7 @@ async function getDeviceTokenFromConfig(config) {
|
|
|
169
176
|
* @param {string|null} controllerUrl - Controller URL
|
|
170
177
|
*/
|
|
171
178
|
function validateDatasourceListingAuth(token, controllerUrl) {
|
|
172
|
-
if (!token || !controllerUrl) {
|
|
179
|
+
if (!token || !controllerUrl || (typeof controllerUrl === 'string' && !controllerUrl.trim())) {
|
|
173
180
|
logger.error(chalk.red('❌ Not logged in. Run: aifabrix login'));
|
|
174
181
|
logger.error(chalk.gray(' Use device code flow: aifabrix login --method device --controller <url>'));
|
|
175
182
|
process.exit(1);
|
|
@@ -181,14 +188,92 @@ function validateDatasourceListingAuth(token, controllerUrl) {
|
|
|
181
188
|
* @function handleDatasourceApiError
|
|
182
189
|
* @param {Object} response - API response
|
|
183
190
|
*/
|
|
184
|
-
function handleDatasourceApiError(response) {
|
|
191
|
+
function handleDatasourceApiError(response, dataplaneUrl = null) {
|
|
185
192
|
const formattedError = response.formattedError || formatApiError(response);
|
|
186
193
|
logger.error(formattedError);
|
|
194
|
+
|
|
195
|
+
// Show endpoint URL from error data if available (more specific than dataplane URL)
|
|
196
|
+
if (response.errorData && response.errorData.endpointUrl) {
|
|
197
|
+
logger.error(chalk.gray(`\nEndpoint URL: ${response.errorData.endpointUrl}`));
|
|
198
|
+
} else if (response.errorData && response.errorData.controllerUrl) {
|
|
199
|
+
logger.error(chalk.gray(`\nDataplane URL: ${response.errorData.controllerUrl}`));
|
|
200
|
+
} else if (dataplaneUrl) {
|
|
201
|
+
logger.error(chalk.gray(`\nDataplane URL: ${dataplaneUrl}`));
|
|
202
|
+
}
|
|
203
|
+
|
|
187
204
|
logger.error(chalk.gray('\nFull response for debugging:'));
|
|
188
205
|
logger.error(chalk.gray(JSON.stringify(response, null, 2)));
|
|
189
206
|
process.exit(1);
|
|
190
207
|
}
|
|
191
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Validates and trims controller URL
|
|
211
|
+
* @function validateControllerUrl
|
|
212
|
+
* @param {string} controllerUrl - Controller URL to validate
|
|
213
|
+
* @returns {string} Trimmed controller URL
|
|
214
|
+
*/
|
|
215
|
+
function validateControllerUrl(controllerUrl) {
|
|
216
|
+
const trimmed = controllerUrl.trim();
|
|
217
|
+
if (!trimmed) {
|
|
218
|
+
logger.error(chalk.red('❌ Controller URL is empty.'));
|
|
219
|
+
logger.error(chalk.gray(` Controller URL from config: ${JSON.stringify(controllerUrl)}`));
|
|
220
|
+
logger.error(chalk.gray(' Run: aifabrix login --method device --controller <url>'));
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
return trimmed;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolves and validates dataplane URL from controller
|
|
228
|
+
* @async
|
|
229
|
+
* @function resolveAndValidateDataplaneUrl
|
|
230
|
+
* @param {string} controllerUrl - Controller URL
|
|
231
|
+
* @param {string} environment - Environment key
|
|
232
|
+
* @param {Object} authConfig - Authentication configuration
|
|
233
|
+
* @returns {Promise<string>} Validated dataplane URL
|
|
234
|
+
*/
|
|
235
|
+
async function resolveAndValidateDataplaneUrl(controllerUrl, environment, authConfig) {
|
|
236
|
+
let dataplaneUrl;
|
|
237
|
+
try {
|
|
238
|
+
// discoverDataplaneUrl already logs progress and success messages
|
|
239
|
+
dataplaneUrl = await resolveDataplaneUrl(controllerUrl, environment, authConfig);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
logger.error(chalk.red('❌ Failed to resolve dataplane URL:'), error.message);
|
|
242
|
+
logger.error(chalk.gray('\nThe dataplane URL is automatically discovered from the controller.'));
|
|
243
|
+
logger.error(chalk.gray('If discovery fails, ensure you are logged in and the controller is accessible:'));
|
|
244
|
+
logger.error(chalk.gray(' aifabrix login'));
|
|
245
|
+
process.exit(1);
|
|
246
|
+
// eslint-disable-next-line no-unreachable
|
|
247
|
+
throw error; // Never reached in production, but needed for tests when process.exit is mocked
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!dataplaneUrl || typeof dataplaneUrl !== 'string' || !dataplaneUrl.trim()) {
|
|
251
|
+
logger.error(chalk.red('❌ Dataplane URL is empty.'));
|
|
252
|
+
logger.error(chalk.gray('The dataplane URL could not be discovered from the controller.'));
|
|
253
|
+
logger.error(chalk.gray('Ensure the dataplane service is registered in the controller.'));
|
|
254
|
+
process.exit(1);
|
|
255
|
+
// eslint-disable-next-line no-unreachable
|
|
256
|
+
throw new Error('Dataplane URL is empty'); // Never reached in production, but needed for tests
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return dataplaneUrl.trim();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Sets up authentication configuration for dataplane API calls
|
|
264
|
+
* @function setupAuthConfig
|
|
265
|
+
* @param {string} token - Authentication token
|
|
266
|
+
* @param {string} controllerUrl - Controller URL
|
|
267
|
+
* @returns {Object} Authentication configuration
|
|
268
|
+
*/
|
|
269
|
+
function setupAuthConfig(token, controllerUrl) {
|
|
270
|
+
return {
|
|
271
|
+
type: 'bearer',
|
|
272
|
+
token: token,
|
|
273
|
+
controller: controllerUrl
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
192
277
|
async function listDatasources(_options) {
|
|
193
278
|
const config = await getConfig();
|
|
194
279
|
|
|
@@ -199,22 +284,24 @@ async function listDatasources(_options) {
|
|
|
199
284
|
const authInfo = await getDeviceTokenFromConfig(config);
|
|
200
285
|
validateDatasourceListingAuth(authInfo?.token, authInfo?.controllerUrl);
|
|
201
286
|
|
|
202
|
-
// Call controller API using centralized API client
|
|
203
|
-
// Note: validateDatasourceListingAuth will exit if auth is missing, so this check is defensive
|
|
204
287
|
if (!authInfo || !authInfo.token || !authInfo.controllerUrl) {
|
|
205
|
-
validateDatasourceListingAuth(null, null);
|
|
206
|
-
return;
|
|
288
|
+
validateDatasourceListingAuth(null, null);
|
|
289
|
+
return;
|
|
207
290
|
}
|
|
208
|
-
|
|
209
|
-
const
|
|
291
|
+
|
|
292
|
+
const controllerUrl = validateControllerUrl(authInfo.controllerUrl);
|
|
293
|
+
const authConfig = setupAuthConfig(authInfo.token, controllerUrl);
|
|
294
|
+
const dataplaneUrl = await resolveAndValidateDataplaneUrl(controllerUrl, environment, authConfig);
|
|
295
|
+
|
|
296
|
+
const response = await listDatasourcesFromDataplane(dataplaneUrl, authConfig);
|
|
210
297
|
|
|
211
298
|
if (!response.success || !response.data) {
|
|
212
|
-
handleDatasourceApiError(response);
|
|
299
|
+
handleDatasourceApiError(response, dataplaneUrl);
|
|
213
300
|
return; // Ensure we don't continue after exit
|
|
214
301
|
}
|
|
215
302
|
|
|
216
303
|
const datasources = extractDatasources(response);
|
|
217
|
-
displayDatasources(datasources, environment);
|
|
304
|
+
displayDatasources(datasources, environment, dataplaneUrl);
|
|
218
305
|
}
|
|
219
306
|
|
|
220
307
|
module.exports = {
|
|
@@ -73,7 +73,7 @@ async function ensureAdminSecrets() {
|
|
|
73
73
|
* @param {string} postgresPassword - PostgreSQL password
|
|
74
74
|
*/
|
|
75
75
|
function generatePgAdminConfig(infraDir, postgresPassword) {
|
|
76
|
-
const serversJsonTemplatePath = path.join(__dirname, '..', 'templates', 'infra', 'servers.json.hbs');
|
|
76
|
+
const serversJsonTemplatePath = path.join(__dirname, '..', '..', 'templates', 'infra', 'servers.json.hbs');
|
|
77
77
|
if (!fs.existsSync(serversJsonTemplatePath)) {
|
|
78
78
|
return;
|
|
79
79
|
}
|
|
@@ -89,6 +89,86 @@ function generatePgAdminConfig(infraDir, postgresPassword) {
|
|
|
89
89
|
fs.writeFileSync(pgpassPath, pgpassContent, { mode: 0o600 });
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Escape single quotes for use in PostgreSQL string literal (double each single quote)
|
|
94
|
+
* @param {string} s - Raw string
|
|
95
|
+
* @returns {string} Escaped string
|
|
96
|
+
*/
|
|
97
|
+
function escapePgString(s) {
|
|
98
|
+
if (s === null || s === undefined || typeof s !== 'string') return '';
|
|
99
|
+
return s.replace(/'/g, '\'\'');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract password from a URL string or plain password value
|
|
104
|
+
* @param {string} urlOrPassword - URL (with ://) or plain password
|
|
105
|
+
* @returns {string|null} Extracted password or null to keep default
|
|
106
|
+
*/
|
|
107
|
+
function extractPasswordFromUrlOrValue(urlOrPassword) {
|
|
108
|
+
if (typeof urlOrPassword !== 'string' || urlOrPassword.length === 0) return null;
|
|
109
|
+
if (!urlOrPassword.includes('://')) return urlOrPassword;
|
|
110
|
+
try {
|
|
111
|
+
const u = new URL(urlOrPassword.replace(/\$\{DB_HOST\}/g, 'postgres').replace(/\$\{DB_PORT\}/g, '5432'));
|
|
112
|
+
return u.password || null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Ensures Postgres init script exists for miso-controller app (database miso, user miso_user).
|
|
120
|
+
* Uses password from secrets (databases-miso-controller-0-passwordKeyVault) or default miso_pass123.
|
|
121
|
+
* Init scripts run only when the Postgres data volume is first created. If infra was already
|
|
122
|
+
* started before this script existed, run `aifabrix down -v` then `aifabrix up` to re-init, or
|
|
123
|
+
* create the database and user manually (e.g. via pgAdmin or psql).
|
|
124
|
+
*
|
|
125
|
+
* @async
|
|
126
|
+
* @param {string} infraDir - Infrastructure directory path
|
|
127
|
+
* @returns {Promise<void>}
|
|
128
|
+
*/
|
|
129
|
+
async function ensureMisoInitScript(infraDir) {
|
|
130
|
+
const initScriptsDir = path.join(infraDir, 'init-scripts');
|
|
131
|
+
if (!fs.existsSync(initScriptsDir)) {
|
|
132
|
+
fs.mkdirSync(initScriptsDir, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let password = 'miso_pass123';
|
|
136
|
+
try {
|
|
137
|
+
const loaded = await secrets.loadSecrets(undefined);
|
|
138
|
+
const urlOrPassword = loaded['databases-miso-controller-0-passwordKeyVault'] ||
|
|
139
|
+
loaded['databases-miso-controller-0-urlKeyVault'];
|
|
140
|
+
const extracted = extractPasswordFromUrlOrValue(urlOrPassword);
|
|
141
|
+
if (extracted !== null) password = extracted;
|
|
142
|
+
} catch {
|
|
143
|
+
// No secrets or load failed; use default
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const passwordEscaped = escapePgString(password);
|
|
147
|
+
|
|
148
|
+
const sh = `#!/bin/bash
|
|
149
|
+
set -e
|
|
150
|
+
# Miso-controller app database and user (matches DATABASE_URL in env)
|
|
151
|
+
# Runs on first Postgres volume init only
|
|
152
|
+
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<EOSQL
|
|
153
|
+
DO \\$\\$
|
|
154
|
+
BEGIN
|
|
155
|
+
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'miso_user') THEN
|
|
156
|
+
CREATE USER miso_user WITH PASSWORD '${passwordEscaped}';
|
|
157
|
+
ELSE
|
|
158
|
+
ALTER USER miso_user WITH PASSWORD '${passwordEscaped}';
|
|
159
|
+
END IF;
|
|
160
|
+
END
|
|
161
|
+
\\$\\$;
|
|
162
|
+
EOSQL
|
|
163
|
+
if ! psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -tAc "SELECT 1 FROM pg_database WHERE datname = 'miso'" | grep -q 1; then
|
|
164
|
+
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -c "CREATE DATABASE miso OWNER miso_user;"
|
|
165
|
+
fi
|
|
166
|
+
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "miso" -c "GRANT ALL ON SCHEMA public TO miso_user; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO miso_user;"
|
|
167
|
+
`;
|
|
168
|
+
const initPath = path.join(initScriptsDir, '02-miso-app.sh');
|
|
169
|
+
fs.writeFileSync(initPath, sh, { mode: 0o755 });
|
|
170
|
+
}
|
|
171
|
+
|
|
92
172
|
/**
|
|
93
173
|
* Prepare infrastructure directory and extract postgres password
|
|
94
174
|
* @param {string} devId - Developer ID
|
|
@@ -135,5 +215,6 @@ module.exports = {
|
|
|
135
215
|
ensureAdminSecrets,
|
|
136
216
|
generatePgAdminConfig,
|
|
137
217
|
prepareInfraDirectory,
|
|
218
|
+
ensureMisoInitScript,
|
|
138
219
|
registerHandlebarsHelper
|
|
139
220
|
};
|
|
@@ -23,6 +23,7 @@ const {
|
|
|
23
23
|
checkDockerAvailability,
|
|
24
24
|
ensureAdminSecrets,
|
|
25
25
|
prepareInfraDirectory,
|
|
26
|
+
ensureMisoInitScript,
|
|
26
27
|
registerHandlebarsHelper
|
|
27
28
|
} = require('./helpers');
|
|
28
29
|
const {
|
|
@@ -59,6 +60,7 @@ async function prepareInfrastructureEnvironment(developerId) {
|
|
|
59
60
|
|
|
60
61
|
// Prepare infrastructure directory
|
|
61
62
|
const { infraDir } = prepareInfraDirectory(devId, adminSecretsPath);
|
|
63
|
+
await ensureMisoInitScript(infraDir);
|
|
62
64
|
|
|
63
65
|
return { devId, idNum, ports, templatePath, infraDir, adminSecretsPath };
|
|
64
66
|
}
|