@aifabrix/builder 2.62.0 → 2.63.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/lib/api/onboarding-status.api.js +117 -0
- package/lib/app/register.js +24 -2
- package/lib/app/run-env-compose.js +39 -2
- package/lib/app/run-env-recovery.js +1 -0
- package/lib/app/run-helpers.js +1 -1
- package/lib/cli/infra-guided.js +30 -2
- package/lib/cli/setup-platform.js +18 -3
- package/lib/commands/setup-modes.js +2 -1
- package/lib/commands/setup-onboarding-readiness.js +83 -0
- package/lib/commands/setup.js +32 -10
- package/lib/commands/up-dataplane-bootstrap.js +115 -38
- package/lib/commands/up-dataplane.js +4 -31
- package/lib/generator/index.js +32 -9
- package/lib/utils/dataplane-dimension-abac-setting.js +120 -0
- package/lib/utils/log-redaction.js +4 -1
- package/package.json +2 -1
- package/templates/README.md +6 -0
- package/templates/applications/builder-api/application.yaml +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the public Miso required-baseline onboarding projection.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Public Miso onboarding readiness client
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { ApiClient } = require('./index');
|
|
12
|
+
|
|
13
|
+
const ENDPOINT = '/api/v1/health/onboarding-status';
|
|
14
|
+
const PROJECTION_KEYS = ['code', 'retryable', 'state'];
|
|
15
|
+
const VALID_PROJECTIONS = new Set([
|
|
16
|
+
'pending|null|true',
|
|
17
|
+
'complete|null|false',
|
|
18
|
+
'incomplete|baseline-dimensions-incomplete|true',
|
|
19
|
+
'incomplete|baseline-roles-incomplete|true'
|
|
20
|
+
]);
|
|
21
|
+
const VALID_PAIRS = new Set([
|
|
22
|
+
'pending|null|true::pending|null|true',
|
|
23
|
+
'incomplete|baseline-dimensions-incomplete|true::pending|null|true',
|
|
24
|
+
'complete|null|false::pending|null|true',
|
|
25
|
+
'complete|null|false::incomplete|baseline-roles-incomplete|true',
|
|
26
|
+
'complete|null|false::complete|null|false'
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function onboardingStatusError(message, code = 'MISO_ONBOARDING_STATUS_INVALID') {
|
|
30
|
+
const error = new Error(message);
|
|
31
|
+
error.code = code;
|
|
32
|
+
return error;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function projectionSignature(projection) {
|
|
36
|
+
return `${projection.state}|${projection.code === null ? 'null' : projection.code}|${projection.retryable}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasValidProjectionTypes(projection) {
|
|
40
|
+
return typeof projection.state === 'string' &&
|
|
41
|
+
(projection.code === null || typeof projection.code === 'string') &&
|
|
42
|
+
typeof projection.retryable === 'boolean';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseProjection(value, name) {
|
|
46
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
47
|
+
throw onboardingStatusError(`Miso ${name} readiness projection is missing or malformed.`);
|
|
48
|
+
}
|
|
49
|
+
const keys = Object.keys(value).sort();
|
|
50
|
+
if (keys.length !== PROJECTION_KEYS.length || keys.some((key, index) => key !== PROJECTION_KEYS[index])) {
|
|
51
|
+
throw onboardingStatusError(`Miso ${name} readiness projection has unsupported fields.`);
|
|
52
|
+
}
|
|
53
|
+
const projection = { state: value.state, code: value.code, retryable: value.retryable };
|
|
54
|
+
if (!hasValidProjectionTypes(projection)) {
|
|
55
|
+
throw onboardingStatusError(`Miso ${name} readiness projection has invalid field types.`);
|
|
56
|
+
}
|
|
57
|
+
if (!VALID_PROJECTIONS.has(projectionSignature(projection))) {
|
|
58
|
+
throw onboardingStatusError(`Miso ${name} readiness projection has an unsupported state.`);
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze(projection);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseOnboardingStatusResponse(response) {
|
|
64
|
+
if (!response || response.success !== true || response.status !== 200) {
|
|
65
|
+
throw onboardingStatusError(
|
|
66
|
+
'Miso onboarding readiness is unavailable.',
|
|
67
|
+
'MISO_ONBOARDING_STATUS_UNAVAILABLE'
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const wireBody = response.data;
|
|
71
|
+
if (!wireBody || typeof wireBody !== 'object' || Array.isArray(wireBody) ||
|
|
72
|
+
Object.prototype.hasOwnProperty.call(wireBody, 'success')) {
|
|
73
|
+
throw onboardingStatusError('Miso onboarding readiness response envelope is malformed.');
|
|
74
|
+
}
|
|
75
|
+
const data = wireBody.data;
|
|
76
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) ||
|
|
77
|
+
Object.prototype.hasOwnProperty.call(data, 'success')) {
|
|
78
|
+
throw onboardingStatusError('Miso onboarding readiness data envelope is malformed.');
|
|
79
|
+
}
|
|
80
|
+
const baselineDimensions = parseProjection(data.baselineDimensions, 'Dimension');
|
|
81
|
+
const baselineRoles = parseProjection(data.baselineRoles, 'Business Role');
|
|
82
|
+
const pair = `${projectionSignature(baselineDimensions)}::${projectionSignature(baselineRoles)}`;
|
|
83
|
+
if (!VALID_PAIRS.has(pair)) {
|
|
84
|
+
throw onboardingStatusError('Miso onboarding readiness contains an unsupported step ordering.');
|
|
85
|
+
}
|
|
86
|
+
return Object.freeze({ baselineDimensions, baselineRoles });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fetch required-baseline readiness from Miso Controller.
|
|
91
|
+
* GET /api/v1/health/onboarding-status
|
|
92
|
+
* @requiresPermission {Controller} Public
|
|
93
|
+
* @param {string} controllerUrl
|
|
94
|
+
* @param {{timeoutMs?: number, client?: ApiClient}} [options]
|
|
95
|
+
* @returns {Promise<{baselineDimensions: Object, baselineRoles: Object}>}
|
|
96
|
+
*/
|
|
97
|
+
async function fetchOnboardingStatus(controllerUrl, options = {}) {
|
|
98
|
+
const client = options.client || new ApiClient(controllerUrl);
|
|
99
|
+
const requestOptions = {};
|
|
100
|
+
if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs;
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
response = await client.get(ENDPOINT, requestOptions);
|
|
104
|
+
} catch {
|
|
105
|
+
throw onboardingStatusError(
|
|
106
|
+
'Miso onboarding readiness request failed.',
|
|
107
|
+
'MISO_ONBOARDING_STATUS_UNAVAILABLE'
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return parseOnboardingStatusResponse(response);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
ENDPOINT,
|
|
115
|
+
fetchOnboardingStatus,
|
|
116
|
+
parseOnboardingStatusResponse
|
|
117
|
+
};
|
package/lib/app/register.js
CHANGED
|
@@ -28,6 +28,24 @@ const { refreshUrlsLocalRegistryFromBuilder } = require('../utils/urls-local-reg
|
|
|
28
28
|
const { resolveControllerUrl } = require('../utils/controller-url');
|
|
29
29
|
const config = require('../core/config');
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Handle local credential persistence failure without exposing details in strict mode.
|
|
33
|
+
* @param {Error} error
|
|
34
|
+
* @param {boolean} required
|
|
35
|
+
* @returns {false}
|
|
36
|
+
*/
|
|
37
|
+
function handleCredentialPersistenceFailure(error, required) {
|
|
38
|
+
if (required) {
|
|
39
|
+
const persistenceError = new Error(
|
|
40
|
+
'Application credentials could not be saved to the supported local secret store.'
|
|
41
|
+
);
|
|
42
|
+
persistenceError.code = 'LOCAL_CREDENTIAL_PERSISTENCE_FAILED';
|
|
43
|
+
throw persistenceError;
|
|
44
|
+
}
|
|
45
|
+
logger.warn(chalk.yellow(`⚠ Could not save credentials locally: ${error.message}`));
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
/**
|
|
32
50
|
* Build registration data payload from app configuration
|
|
33
51
|
* @param {Object} appConfig - Application configuration
|
|
@@ -83,8 +101,11 @@ function buildRegistrationData(appConfig, options) {
|
|
|
83
101
|
* @async
|
|
84
102
|
* @param {Object} responseData - Registration response data
|
|
85
103
|
* @param {string} apiUrl - API URL
|
|
104
|
+
* @param {Object} [options] - Persistence behavior
|
|
105
|
+
* @param {boolean} [options.required=false] - Throw a safe typed error when persistence fails
|
|
106
|
+
* @returns {Promise<boolean>} True when credentials were persisted
|
|
86
107
|
*/
|
|
87
|
-
async function saveLocalCredentials(responseData, apiUrl) {
|
|
108
|
+
async function saveLocalCredentials(responseData, apiUrl, options = {}) {
|
|
88
109
|
const registeredAppKey = responseData.application.key;
|
|
89
110
|
const clientIdKey = `${registeredAppKey}-client-idKeyVault`;
|
|
90
111
|
const clientSecretKey = `${registeredAppKey}-client-secretKeyVault`;
|
|
@@ -113,8 +134,9 @@ async function saveLocalCredentials(responseData, apiUrl) {
|
|
|
113
134
|
logger.log(formatSuccessParagraph('Credentials saved to ~/.aifabrix/secrets.local.yaml\n'));
|
|
114
135
|
}
|
|
115
136
|
} catch (error) {
|
|
116
|
-
|
|
137
|
+
return handleCredentialPersistenceFailure(error, options.required === true);
|
|
117
138
|
}
|
|
139
|
+
return true;
|
|
118
140
|
}
|
|
119
141
|
|
|
120
142
|
/**
|
|
@@ -20,6 +20,11 @@ const secretsEnsure = require('../core/secrets-ensure');
|
|
|
20
20
|
const secretsEnvWrite = require('../core/secrets-env-write');
|
|
21
21
|
const { getContainerPort } = require('../utils/port-resolver');
|
|
22
22
|
const { applyAdminSecretsPlatformOverlay } = require('../core/admin-secrets-env-overlay');
|
|
23
|
+
const {
|
|
24
|
+
resolveDimensionAbacSetting,
|
|
25
|
+
applyDimensionAbacToEnvMap
|
|
26
|
+
} = require('../utils/dataplane-dimension-abac-setting');
|
|
27
|
+
const { resolveEffectiveRegistry } = require('../utils/resolve-docker-image-ref');
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* Merge app env with admin-secrets; overlay ensures platform keys come from admin file.
|
|
@@ -38,6 +43,13 @@ async function applyOnboardingPlatformModeIfNeeded(appName, merged) {
|
|
|
38
43
|
merged.ONBOARDING_FULL_PLATFORM = mode === 'full' ? 'true' : 'false';
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
async function applyDimensionAbacSettingIfNeeded(appName, merged) {
|
|
47
|
+
if (appName !== 'dataplane') return;
|
|
48
|
+
const userConfig = await config.getConfig();
|
|
49
|
+
const resolved = resolveDimensionAbacSetting(userConfig);
|
|
50
|
+
applyDimensionAbacToEnvMap(appName, merged, resolved.value);
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
/**
|
|
42
54
|
* Backfill only secrets needed for `aifabrix run <app>`: kv:// keys from that app's env.template
|
|
43
55
|
* (catalog defaults via placeholderContext). Does not run full `ensureInfraSecrets`.
|
|
@@ -260,6 +272,29 @@ async function applyPostgresPasswordFromPgpass(merged, developerId) {
|
|
|
260
272
|
if (pgpassPwd !== undefined) merged.POSTGRES_PASSWORD = pgpassPwd;
|
|
261
273
|
}
|
|
262
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Deliver existing local installation inputs to privileged Miso bootstrap only.
|
|
277
|
+
* @param {string} appName
|
|
278
|
+
* @param {Object} merged
|
|
279
|
+
* @param {Object} appConfig
|
|
280
|
+
* @param {Object|null} scopeOpts
|
|
281
|
+
*/
|
|
282
|
+
function applyMisoBootstrapInputsIfNeeded(appName, merged, appConfig, scopeOpts) {
|
|
283
|
+
if (appName !== 'miso-controller') return;
|
|
284
|
+
const postgresPassword = merged.POSTGRES_PASSWORD;
|
|
285
|
+
if (typeof postgresPassword !== 'string' || postgresPassword.trim().length === 0) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
'Local PostgreSQL administrator password is unavailable. Re-run "aifabrix up-infra" before starting Miso Controller.'
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
merged.POSTGRES_ADMIN_PASSWORD = postgresPassword;
|
|
291
|
+
const { registry } = resolveEffectiveRegistry(appConfig, {
|
|
292
|
+
registry: scopeOpts?.registry
|
|
293
|
+
});
|
|
294
|
+
if (registry) merged.ACR_LOGIN_SERVER = registry;
|
|
295
|
+
else delete merged.ACR_LOGIN_SERVER;
|
|
296
|
+
}
|
|
297
|
+
|
|
263
298
|
/**
|
|
264
299
|
* @param {string} runEnvPath
|
|
265
300
|
* @param {string} runEnvAdminPath
|
|
@@ -300,8 +335,10 @@ async function buildMergedRunEnvAndWrite(appName, appConfig, devDir, developerId
|
|
|
300
335
|
mergeRunEnvWithAdminOverlay(merged, adminObj, appName);
|
|
301
336
|
await applyOnboardingPlatformModeIfNeeded(appName, merged);
|
|
302
337
|
await applyPostgresPasswordFromPgpass(merged, developerId);
|
|
338
|
+
applyMisoBootstrapInputsIfNeeded(appName, merged, appConfig, scopeOpts);
|
|
303
339
|
injectDatabaseNamesAndUsers(merged, appConfig, scopeOpts);
|
|
304
340
|
injectContainerPortForRun(merged, appConfig, appName);
|
|
341
|
+
await applyDimensionAbacSettingIfNeeded(appName, merged);
|
|
305
342
|
|
|
306
343
|
const runEnvPath = path.join(devDir, '.env.run');
|
|
307
344
|
const runEnvAdminPath = path.join(devDir, '.env.run.admin');
|
|
@@ -351,9 +388,9 @@ async function ensureRunEnvFilesPresent(
|
|
|
351
388
|
* @throws {Error} If password keys appear in environment-like assignment
|
|
352
389
|
*/
|
|
353
390
|
function assertNoPasswordLiteralsInCompose(composeContent) {
|
|
354
|
-
const badPattern = /\n\s+(-?\s*)(POSTGRES_PASSWORD|DB_\d+_PASSWORD)\s*[:=]/;
|
|
391
|
+
const badPattern = /\n\s+(-?\s*)(POSTGRES_PASSWORD|POSTGRES_ADMIN_PASSWORD|DB_\d+_PASSWORD)\s*[:=]/;
|
|
355
392
|
if (badPattern.test(composeContent)) {
|
|
356
|
-
throw new Error('Generated compose must not contain password literals (
|
|
393
|
+
throw new Error('Generated compose must not contain password literals (PostgreSQL or application database passwords). Use env_file only.');
|
|
357
394
|
}
|
|
358
395
|
}
|
|
359
396
|
|
|
@@ -26,6 +26,7 @@ function buildRunEnvRecovery(appName, prepared) {
|
|
|
26
26
|
prepared.appConfig.developerId,
|
|
27
27
|
{
|
|
28
28
|
runEnvKey: prepared.mergedRunOptions.env,
|
|
29
|
+
registry: prepared.mergedRunOptions.registry,
|
|
29
30
|
effectiveEnvironmentScopedResources:
|
|
30
31
|
prepared.mergedRunOptions.effectiveEnvironmentScopedResources
|
|
31
32
|
}
|
package/lib/app/run-helpers.js
CHANGED
|
@@ -366,7 +366,7 @@ async function prepareEnvironment(appName, appConfig, options) {
|
|
|
366
366
|
appConfig,
|
|
367
367
|
devDir,
|
|
368
368
|
developerId,
|
|
369
|
-
{ runEnvKey, effectiveEnvironmentScopedResources }
|
|
369
|
+
{ runEnvKey, effectiveEnvironmentScopedResources, registry: options.registry }
|
|
370
370
|
);
|
|
371
371
|
const { hydrateMissingLocalParameters } = require('./run-parameter-sync');
|
|
372
372
|
await hydrateMissingLocalParameters(appName, runEnvPath);
|
package/lib/cli/infra-guided.js
CHANGED
|
@@ -49,6 +49,34 @@ const {
|
|
|
49
49
|
emitSystemBuilderAppManifestLineIfTTY
|
|
50
50
|
} = require('../utils/manifest-source-emit');
|
|
51
51
|
const { flushDeferredPublicUrlWarnings } = require('./infra-guided-public-url-warn');
|
|
52
|
+
const { fetchOnboardingStatus } = require('../api/onboarding-status.api');
|
|
53
|
+
const {
|
|
54
|
+
waitForRequiredBaselineReadiness
|
|
55
|
+
} = require('../commands/setup-onboarding-readiness');
|
|
56
|
+
|
|
57
|
+
async function runRequiredBaselineReadinessIfNeeded(options, authOpts) {
|
|
58
|
+
if (!options || options.requireBaselineReadiness !== true) return;
|
|
59
|
+
const controllerUrl = await resolveGuidedAuthControllerUrl(authOpts);
|
|
60
|
+
const spin = startSpinner('Waiting for required Miso baseline onboarding...');
|
|
61
|
+
const onProgress = createSpinnerTextUpdater(spin);
|
|
62
|
+
try {
|
|
63
|
+
await waitForRequiredBaselineReadiness({
|
|
64
|
+
request: requestOptions => fetchOnboardingStatus(controllerUrl, requestOptions),
|
|
65
|
+
onProgress
|
|
66
|
+
});
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (spin && typeof spin.fail === 'function') spin.fail('Miso baseline onboarding not ready');
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
stopSpinnerSuccess(spin, 'Miso baseline onboarding ready');
|
|
72
|
+
logger.log('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runGuidedMisoAndReadinessPhase(options, handleUpMiso, authOpts) {
|
|
76
|
+
await withMutedLogger(() => prepareUrlsLocalRegistryForUpPlatform());
|
|
77
|
+
await runGuidedKeycloakAndMisoControllerPhase(options, handleUpMiso);
|
|
78
|
+
await runRequiredBaselineReadinessIfNeeded(options, authOpts);
|
|
79
|
+
}
|
|
52
80
|
|
|
53
81
|
/**
|
|
54
82
|
* Guided Keycloak → Miso Controller start with one spinner per app (no second health wait).
|
|
@@ -327,8 +355,7 @@ async function runGuidedUpPlatform(options, handleUpMiso, handleUpDataplane, han
|
|
|
327
355
|
skipLoginIfAuthenticated: options && options.skipLoginIfAuthenticated === true
|
|
328
356
|
};
|
|
329
357
|
|
|
330
|
-
await
|
|
331
|
-
await runGuidedKeycloakAndMisoControllerPhase(options, handleUpMiso);
|
|
358
|
+
await runGuidedMisoAndReadinessPhase(options, handleUpMiso, authOpts);
|
|
332
359
|
await runGuidedKeycloakRealmPhase(options);
|
|
333
360
|
await runGuidedAuthStep(handleLogin, authOpts, { startSpinner, stopSpinnerSuccess });
|
|
334
361
|
const controllerUrl = await resolveGuidedAuthControllerUrl(authOpts);
|
|
@@ -388,6 +415,7 @@ module.exports = {
|
|
|
388
415
|
runGuidedUpMiso,
|
|
389
416
|
runGuidedUpDataplane,
|
|
390
417
|
runGuidedUpPlatform,
|
|
418
|
+
runRequiredBaselineReadinessIfNeeded,
|
|
391
419
|
runGuidedDownInfra,
|
|
392
420
|
stripHealthPrefix
|
|
393
421
|
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `setup` is the one-shot platform installer. When no infrastructure is
|
|
5
5
|
* detected it runs a small wizard (admin email/password, AI tool keys) and
|
|
6
6
|
* then `up-infra` + `up-platform`. When infra is already up, it offers a
|
|
7
|
-
* mode menu (re-install, wipe data,
|
|
7
|
+
* mode menu (re-install, wipe data, update images).
|
|
8
8
|
*
|
|
9
9
|
* `teardown` is the symmetrical inverse of `setup`: `down-infra -v` plus a
|
|
10
10
|
* full clean of the AI Fabrix system directory (everything except
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
const { handleCommandError } = require('../utils/cli-utils');
|
|
21
21
|
const { handleSetup } = require('../commands/setup');
|
|
22
22
|
const { handleTeardown } = require('../commands/teardown');
|
|
23
|
+
const { parseDimensionAbacOption } = require('../utils/dataplane-dimension-abac-setting');
|
|
23
24
|
|
|
24
25
|
const SETUP_HELP_AFTER = `
|
|
25
26
|
What this command does:
|
|
@@ -27,16 +28,25 @@ What this command does:
|
|
|
27
28
|
- With infra running: shows a mode menu:
|
|
28
29
|
1) Re-install — stop infra and remove all volumes, then up-infra + up-platform --force.
|
|
29
30
|
2) Wipe data — drop every database and DB user, then up-infra + up-platform --force.
|
|
30
|
-
3)
|
|
31
|
-
4) Update images — pull the latest infra and platform images, then up-infra + up-platform.
|
|
31
|
+
3) Update images — pull the latest infra and platform images, then up-infra + up-platform.
|
|
32
32
|
- With --mode fresh: first-time install when no infra is running (non-interactive; use with --admin-email and --admin-password).
|
|
33
33
|
- With --mode update-images: refresh images when infra is already running (non-interactive).
|
|
34
34
|
|
|
35
35
|
AI tool keys are read from the user-local file (~/.aifabrix/secrets.local.yaml) merged with the shared aifabrix-secrets file. If either source already provides keys, the AI tool prompt is skipped.
|
|
36
36
|
|
|
37
|
+
Dimension ABAC defaults to off. Use --dimension-abac on|off to persist an explicit Dataplane selection. The saved selection is reused by later setup and redeploy flows; changing it takes effect after the normal Dataplane restart/redeploy boundary.
|
|
38
|
+
|
|
37
39
|
Use 'aifabrix teardown' to fully remove the local installation.
|
|
38
40
|
`;
|
|
39
41
|
|
|
42
|
+
function parseRepeatedDimensionAbacOption(raw, previous) {
|
|
43
|
+
const value = parseDimensionAbacOption(raw);
|
|
44
|
+
if (previous !== undefined && previous !== value) {
|
|
45
|
+
throw new Error('Conflicting --dimension-abac values. Supply only on or only off.');
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
const TEARDOWN_HELP_AFTER = `
|
|
41
51
|
What this command does:
|
|
42
52
|
- Runs down-infra -v (stops all infra + apps, removes every Docker volume).
|
|
@@ -64,6 +74,11 @@ function registerSetup(program) {
|
|
|
64
74
|
.option('--integration-server', 'Install the integration server (runs once in the miso environment)')
|
|
65
75
|
.option('--no-integration-server', 'Do not install the integration server (default)')
|
|
66
76
|
.option('--mode <mode>', 'Non-interactive mode: fresh (no infra) or update-images (infra running)')
|
|
77
|
+
.option(
|
|
78
|
+
'--dimension-abac <off|on>',
|
|
79
|
+
'Persist Dataplane Dimension ABAC selection (default: off)',
|
|
80
|
+
parseRepeatedDimensionAbacOption
|
|
81
|
+
)
|
|
67
82
|
.option('-y, --yes', 'Skip destructive confirmation prompts (re-install, wipe data, teardown)')
|
|
68
83
|
.addHelpText('after', SETUP_HELP_AFTER)
|
|
69
84
|
.action(async(options) => {
|
|
@@ -296,7 +296,8 @@ async function runUpPlatform(opts = {}) {
|
|
|
296
296
|
base: true,
|
|
297
297
|
platformControllerUrl: authCtx.platformControllerUrl,
|
|
298
298
|
skipLoginIfAuthenticated: authCtx.skipLoginIfAuthenticated,
|
|
299
|
-
requireKeycloakRealm: opts.requireKeycloakRealm === true
|
|
299
|
+
requireKeycloakRealm: opts.requireKeycloakRealm === true,
|
|
300
|
+
requireBaselineReadiness: true
|
|
300
301
|
},
|
|
301
302
|
upMiso.handleUpMiso,
|
|
302
303
|
upDataplane.handleUpDataplane,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded observer for required Miso baseline onboarding.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Setup-only Miso baseline readiness observer
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { performance } = require('node:perf_hooks');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_INTERVAL_MS = 2000;
|
|
14
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
|
|
15
|
+
const DEFAULT_DEADLINE_MS = 90000;
|
|
16
|
+
|
|
17
|
+
function monotonicNow() {
|
|
18
|
+
return performance.now();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readinessError(message, code) {
|
|
22
|
+
const error = new Error(message);
|
|
23
|
+
error.code = code;
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function classifyReadiness(snapshot) {
|
|
28
|
+
const dimensions = snapshot.baselineDimensions;
|
|
29
|
+
const roles = snapshot.baselineRoles;
|
|
30
|
+
if (dimensions.state === 'incomplete') {
|
|
31
|
+
throw readinessError(
|
|
32
|
+
`Required baseline Dimensions are incomplete (${dimensions.code}). Retry setup after reconciliation completes.`,
|
|
33
|
+
dimensions.code
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (roles.state === 'incomplete') {
|
|
37
|
+
throw readinessError(
|
|
38
|
+
`Required baseline Business Roles are incomplete (${roles.code}). Retry setup after reconciliation completes.`,
|
|
39
|
+
roles.code
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
if (dimensions.state === 'complete' && roles.state === 'complete') return 'complete';
|
|
43
|
+
return dimensions.state === 'pending' ? 'dimensions' : 'roles';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function timeoutError() {
|
|
47
|
+
return readinessError(
|
|
48
|
+
'Timed out waiting for required Miso baseline onboarding. Retry setup after reconciliation completes.',
|
|
49
|
+
'MISO_BASELINE_READINESS_TIMEOUT'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function waitForRequiredBaselineReadiness(options) {
|
|
54
|
+
const request = options.request;
|
|
55
|
+
const now = options.now || monotonicNow;
|
|
56
|
+
const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
57
|
+
const onProgress = options.onProgress || (() => {});
|
|
58
|
+
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
59
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
60
|
+
const deadlineMs = options.deadlineMs ?? DEFAULT_DEADLINE_MS;
|
|
61
|
+
const startedAt = now();
|
|
62
|
+
while (now() - startedAt < deadlineMs) {
|
|
63
|
+
const remaining = deadlineMs - (now() - startedAt);
|
|
64
|
+
if (remaining <= 0) throw timeoutError();
|
|
65
|
+
const snapshot = await request({ timeoutMs: Math.min(requestTimeoutMs, remaining) });
|
|
66
|
+
const state = classifyReadiness(snapshot);
|
|
67
|
+
if (state === 'complete') return snapshot;
|
|
68
|
+
onProgress(state === 'dimensions'
|
|
69
|
+
? 'Waiting for required baseline Dimensions...'
|
|
70
|
+
: 'Waiting for required baseline Business Roles...');
|
|
71
|
+
const afterRequest = deadlineMs - (now() - startedAt);
|
|
72
|
+
if (afterRequest <= 0) throw timeoutError();
|
|
73
|
+
await sleep(Math.min(intervalMs, afterRequest));
|
|
74
|
+
}
|
|
75
|
+
throw timeoutError();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
DEFAULT_INTERVAL_MS,
|
|
80
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
81
|
+
DEFAULT_DEADLINE_MS,
|
|
82
|
+
waitForRequiredBaselineReadiness
|
|
83
|
+
};
|
package/lib/commands/setup.js
CHANGED
|
@@ -36,6 +36,11 @@ const setupModes = require('./setup-modes');
|
|
|
36
36
|
const { syncPlatformControllerUrlsInConfig } = require('./setup-platform-auth');
|
|
37
37
|
const installationLog = require('../utils/installation-log');
|
|
38
38
|
const { beginSetupCommandFlow, endSetupCommandFlow } = require('./setup-run-context');
|
|
39
|
+
const {
|
|
40
|
+
parseDimensionAbacOption,
|
|
41
|
+
resolveDimensionAbacSetting,
|
|
42
|
+
persistExplicitDimensionAbacSetting
|
|
43
|
+
} = require('../utils/dataplane-dimension-abac-setting');
|
|
39
44
|
|
|
40
45
|
const MODE = setupPrompts.MODE;
|
|
41
46
|
|
|
@@ -87,7 +92,20 @@ function normalizeCliMode(options) {
|
|
|
87
92
|
* @param {'fresh'|'update-images'} cliMode
|
|
88
93
|
* @returns {Promise<{ outcome: string, setupMode: string }>}
|
|
89
94
|
*/
|
|
90
|
-
async function
|
|
95
|
+
async function persistDimensionAbacSelection(settingContext) {
|
|
96
|
+
await persistExplicitDimensionAbacSetting(config, settingContext.explicitValue);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function prepareDimensionAbacSetting(options) {
|
|
100
|
+
const explicitValue = parseDimensionAbacOption(options.dimensionAbac);
|
|
101
|
+
const userConfig = await config.getConfig();
|
|
102
|
+
const resolved = resolveDimensionAbacSetting(userConfig, explicitValue);
|
|
103
|
+
const displayValue = resolved.value ? 'on' : 'off';
|
|
104
|
+
logger.log(infoLine(`Dimension ABAC: ${displayValue} (${resolved.source}).`));
|
|
105
|
+
return { explicitValue, ...resolved };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function runSetupWithCliMode(options, cliMode, settingContext) {
|
|
91
109
|
logSetupHeader();
|
|
92
110
|
logger.log(formatProgress('Detecting current installation state...'));
|
|
93
111
|
const running = await isInfraRunning();
|
|
@@ -97,12 +115,13 @@ async function runSetupWithCliMode(options, cliMode) {
|
|
|
97
115
|
'Infrastructure is already running; use --mode update-images or run teardown first.'
|
|
98
116
|
);
|
|
99
117
|
}
|
|
100
|
-
await runFreshInstallFlow(options);
|
|
118
|
+
await runFreshInstallFlow(options, settingContext);
|
|
101
119
|
return { outcome: 'success', setupMode: CLI_SETUP_MODES.FRESH };
|
|
102
120
|
}
|
|
103
121
|
if (!running) {
|
|
104
122
|
throw new Error('No infrastructure running; use --mode fresh for first-time install.');
|
|
105
123
|
}
|
|
124
|
+
await persistDimensionAbacSelection(settingContext);
|
|
106
125
|
await dispatchMode(MODE.UPDATE_IMAGES);
|
|
107
126
|
logger.log(formatSuccessParagraph(`aifabrix setup complete (mode: ${cliMode}).`));
|
|
108
127
|
return { outcome: 'success', setupMode: cliMode };
|
|
@@ -120,12 +139,13 @@ async function applySetupPlatformMode(options) {
|
|
|
120
139
|
return mode;
|
|
121
140
|
}
|
|
122
141
|
|
|
123
|
-
async function runFreshInstallFlow(options) {
|
|
142
|
+
async function runFreshInstallFlow(options, settingContext) {
|
|
124
143
|
await maybePinDeveloperIdForFreshInstall(options.developer);
|
|
125
144
|
logger.log(infoLine('No infrastructure detected; starting fresh install.'));
|
|
126
145
|
await applySetupPlatformMode(options);
|
|
127
146
|
const adminCreds = await setupPrompts.promptAdminCredentials(options);
|
|
128
147
|
await config.setAdminEmail(adminCreds.adminEmail);
|
|
148
|
+
await persistDimensionAbacSelection(settingContext);
|
|
129
149
|
await setupModes.runFreshInstall(adminCreds);
|
|
130
150
|
logger.log(formatSuccessParagraph('aifabrix setup complete (fresh install).'));
|
|
131
151
|
}
|
|
@@ -134,7 +154,7 @@ async function runFreshInstallFlow(options) {
|
|
|
134
154
|
* @param {boolean} assumeYes
|
|
135
155
|
* @returns {Promise<{ aborted: boolean, mode: string|null }>}
|
|
136
156
|
*/
|
|
137
|
-
async function runExistingInfraFlow(assumeYes) {
|
|
157
|
+
async function runExistingInfraFlow(assumeYes, settingContext) {
|
|
138
158
|
const mode = await setupPrompts.promptModeSelection();
|
|
139
159
|
const proceed = await setupPrompts.confirmDestructiveMode(mode, assumeYes);
|
|
140
160
|
if (!proceed) {
|
|
@@ -145,6 +165,7 @@ async function runExistingInfraFlow(assumeYes) {
|
|
|
145
165
|
if (mode === MODE.REINSTALL || mode === MODE.WIPE_DATA) {
|
|
146
166
|
await applySetupPlatformMode({});
|
|
147
167
|
}
|
|
168
|
+
await persistDimensionAbacSelection(settingContext);
|
|
148
169
|
await dispatchMode(mode);
|
|
149
170
|
logger.log(formatSuccessParagraph(`aifabrix setup complete (mode: ${mode}).`));
|
|
150
171
|
return { aborted: false, mode };
|
|
@@ -197,20 +218,19 @@ async function dispatchMode(mode) {
|
|
|
197
218
|
* @param {boolean} assumeYes
|
|
198
219
|
* @returns {Promise<{ outcome: string, setupMode: string|null }>}
|
|
199
220
|
*/
|
|
200
|
-
async function runSetupMainPath(options, assumeYes) {
|
|
201
|
-
const cliMode = normalizeCliMode(options);
|
|
221
|
+
async function runSetupMainPath(options, assumeYes, cliMode, settingContext) {
|
|
202
222
|
if (cliMode) {
|
|
203
|
-
return runSetupWithCliMode(options, cliMode);
|
|
223
|
+
return runSetupWithCliMode(options, cliMode, settingContext);
|
|
204
224
|
}
|
|
205
225
|
|
|
206
226
|
logSetupHeader();
|
|
207
227
|
logger.log(formatProgress('Detecting current installation state...'));
|
|
208
228
|
const running = await isInfraRunning();
|
|
209
229
|
if (!running) {
|
|
210
|
-
await runFreshInstallFlow(options);
|
|
230
|
+
await runFreshInstallFlow(options, settingContext);
|
|
211
231
|
return { outcome: 'success', setupMode: 'fresh' };
|
|
212
232
|
}
|
|
213
|
-
const r = await runExistingInfraFlow(assumeYes);
|
|
233
|
+
const r = await runExistingInfraFlow(assumeYes, settingContext);
|
|
214
234
|
const outcome = r.aborted ? 'aborted' : 'success';
|
|
215
235
|
return { outcome, setupMode: r.mode };
|
|
216
236
|
}
|
|
@@ -300,7 +320,9 @@ async function handleSetup(options = {}) {
|
|
|
300
320
|
|
|
301
321
|
beginSetupCommandFlow();
|
|
302
322
|
try {
|
|
303
|
-
const
|
|
323
|
+
const cliMode = normalizeCliMode(options);
|
|
324
|
+
const settingContext = await prepareDimensionAbacSetting(options);
|
|
325
|
+
const main = await runSetupMainPath(options, assumeYes, cliMode, settingContext);
|
|
304
326
|
outcome = main.outcome;
|
|
305
327
|
setupMode = main.setupMode;
|
|
306
328
|
} catch (e) {
|
|
@@ -19,6 +19,11 @@ const {
|
|
|
19
19
|
extractAppConfiguration
|
|
20
20
|
} = require('../utils/app-register-config');
|
|
21
21
|
const { buildRegistrationData, saveLocalCredentials, applyLocalhostPortAdjustment } = require('../app/register');
|
|
22
|
+
const {
|
|
23
|
+
loadDataplanePipelineCredentialsFromSecretsFile,
|
|
24
|
+
credentialsAcceptToken,
|
|
25
|
+
waitForDataplaneCredentials
|
|
26
|
+
} = require('./up-dataplane-credentials');
|
|
22
27
|
const { formatSuccessLine } = require('../utils/cli-test-layout-chalk');
|
|
23
28
|
const { getToken } = require('../api/auth.api');
|
|
24
29
|
const { updateApplicationSelfStatus } = require('../api/applications.api');
|
|
@@ -31,6 +36,111 @@ const ENCRYPTION_VAULT_KEYS = [
|
|
|
31
36
|
'secrets-encryptionKeyVault'
|
|
32
37
|
];
|
|
33
38
|
|
|
39
|
+
const BOOTSTRAP_CREDENTIAL_ERROR_CODE = 'DATAPLANE_BOOTSTRAP_CREDENTIALS_UNAVAILABLE';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a stable, secret-safe bootstrap credential error.
|
|
43
|
+
* @param {string} message
|
|
44
|
+
* @returns {Error}
|
|
45
|
+
*/
|
|
46
|
+
function createBootstrapCredentialError(message) {
|
|
47
|
+
const error = new Error(message);
|
|
48
|
+
error.code = BOOTSTRAP_CREDENTIAL_ERROR_CODE;
|
|
49
|
+
return error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {Object|null|undefined} credentials
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function hasUsablePipelineCredentials(credentials) {
|
|
57
|
+
return Boolean(
|
|
58
|
+
credentials &&
|
|
59
|
+
typeof credentials.clientId === 'string' && credentials.clientId.trim() &&
|
|
60
|
+
typeof credentials.clientSecret === 'string' && credentials.clientSecret.trim()
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Persist newly issued credentials and prove they can be loaded before deployment.
|
|
66
|
+
* @async
|
|
67
|
+
* @param {Object} responseData
|
|
68
|
+
* @param {string} controllerUrl
|
|
69
|
+
* @param {string} environmentKey
|
|
70
|
+
* @param {number} containerPort
|
|
71
|
+
* @returns {Promise<{status: string, credentialsAvailable: true}>}
|
|
72
|
+
*/
|
|
73
|
+
async function persistBootstrapCredentials(responseData, controllerUrl, environmentKey, containerPort) {
|
|
74
|
+
try {
|
|
75
|
+
await saveLocalCredentials(responseData, controllerUrl, { required: true });
|
|
76
|
+
} catch {
|
|
77
|
+
throw createBootstrapCredentialError(
|
|
78
|
+
'Dataplane bootstrap credentials could not be persisted locally; deployment was not started.'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const localCredentials = await loadDataplanePipelineCredentialsFromSecretsFile();
|
|
82
|
+
if (!hasUsablePipelineCredentials(localCredentials)) {
|
|
83
|
+
throw createBootstrapCredentialError(
|
|
84
|
+
'Dataplane bootstrap credentials are not available from the supported local secret store; ' +
|
|
85
|
+
'deployment was not started.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
await syncDataplaneApplicationUrls(
|
|
89
|
+
controllerUrl,
|
|
90
|
+
environmentKey,
|
|
91
|
+
responseData.credentials,
|
|
92
|
+
containerPort
|
|
93
|
+
);
|
|
94
|
+
if (!(await waitForDataplaneCredentials(controllerUrl))) {
|
|
95
|
+
throw createBootstrapCredentialError(
|
|
96
|
+
'Dataplane bootstrap credentials were issued but the controller did not accept them in time. ' +
|
|
97
|
+
'Deployment was not started; retry the command after checking the controller token endpoint.'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
logger.log(formatSuccessLine('Dataplane bootstrap credentials saved locally'));
|
|
101
|
+
return { status: 'provisioned', credentialsAvailable: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Require existing supported local credentials for an idempotent response.
|
|
106
|
+
* @async
|
|
107
|
+
* @returns {Promise<{status: string, credentialsAvailable: true}>}
|
|
108
|
+
*/
|
|
109
|
+
async function acceptAlreadyProvisionedDataplane(controllerUrl) {
|
|
110
|
+
const localCredentials = await loadDataplanePipelineCredentialsFromSecretsFile();
|
|
111
|
+
const accepted = hasUsablePipelineCredentials(localCredentials) &&
|
|
112
|
+
await credentialsAcceptToken(controllerUrl);
|
|
113
|
+
if (!accepted) {
|
|
114
|
+
throw createBootstrapCredentialError(
|
|
115
|
+
'Dataplane is registered on the controller but pipeline credentials are missing locally. ' +
|
|
116
|
+
'Use an authorized session to run: aifabrix app rotate-secret dataplane, then retry setup.'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
logger.log(formatSuccessLine('Dataplane already provisioned (bootstrap idempotent)'));
|
|
120
|
+
return { status: 'already_provisioned', credentialsAvailable: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @async
|
|
125
|
+
* @param {Object} responseData
|
|
126
|
+
* @param {string} controllerUrl
|
|
127
|
+
* @param {string} environmentKey
|
|
128
|
+
* @param {number} containerPort
|
|
129
|
+
* @returns {Promise<{status: string, credentialsAvailable: true}>}
|
|
130
|
+
*/
|
|
131
|
+
async function handleBootstrapResponse(responseData, controllerUrl, environmentKey, containerPort) {
|
|
132
|
+
if (hasUsablePipelineCredentials(responseData.credentials)) {
|
|
133
|
+
return persistBootstrapCredentials(responseData, controllerUrl, environmentKey, containerPort);
|
|
134
|
+
}
|
|
135
|
+
if (responseData.status === 'already_provisioned') {
|
|
136
|
+
return acceptAlreadyProvisionedDataplane(controllerUrl);
|
|
137
|
+
}
|
|
138
|
+
throw createBootstrapCredentialError(
|
|
139
|
+
'Dataplane bootstrap returned no usable credentials or supported already-provisioned status; ' +
|
|
140
|
+
'deployment was not started.'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
34
144
|
/**
|
|
35
145
|
* @param {Record<string, unknown>} secrets
|
|
36
146
|
* @returns {string|null}
|
|
@@ -118,41 +228,6 @@ async function syncDataplaneApplicationUrls(controllerUrl, environmentKey, crede
|
|
|
118
228
|
}
|
|
119
229
|
}
|
|
120
230
|
|
|
121
|
-
async function handleBootstrapResponse(responseData, controllerUrl, environmentKey, containerPort) {
|
|
122
|
-
if (responseData.credentials && responseData.credentials.clientSecret) {
|
|
123
|
-
await saveLocalCredentials(responseData, controllerUrl);
|
|
124
|
-
await syncDataplaneApplicationUrls(
|
|
125
|
-
controllerUrl,
|
|
126
|
-
environmentKey,
|
|
127
|
-
responseData.credentials,
|
|
128
|
-
containerPort
|
|
129
|
-
);
|
|
130
|
-
logger.log(formatSuccessLine('Dataplane bootstrap credentials saved locally'));
|
|
131
|
-
const { waitForDataplaneCredentials } = require('./up-dataplane-credentials');
|
|
132
|
-
if (!(await waitForDataplaneCredentials(controllerUrl))) {
|
|
133
|
-
throw new Error(
|
|
134
|
-
'Dataplane bootstrap credentials were issued but the controller did not accept them in time. ' +
|
|
135
|
-
'The deploy that follows gets one attempt, so it would fail with a less specific error. ' +
|
|
136
|
-
'Retry the command; if it persists, check the controller is serving /api/v1/auth/token.'
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
if (responseData.status === 'already_provisioned') {
|
|
142
|
-
logger.log(formatSuccessLine('Dataplane already provisioned (bootstrap idempotent)'));
|
|
143
|
-
const { credentialsAcceptToken } = require('./up-dataplane-credentials');
|
|
144
|
-
if (await credentialsAcceptToken(controllerUrl)) return;
|
|
145
|
-
throw new Error(
|
|
146
|
-
'Dataplane is registered on the controller but local pipeline credentials are missing or ' +
|
|
147
|
-
'not accepted by it. Run: aifabrix login && aifabrix app rotate-secret dataplane'
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
throw new Error(
|
|
151
|
-
'Dataplane bootstrap returned no credentials and no "already_provisioned" status ' +
|
|
152
|
-
`(status: ${responseData.status || 'none'}). The controller may be a version this CLI does not support.`
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
231
|
/**
|
|
157
232
|
* Register dataplane via bootstrap endpoint (idempotent).
|
|
158
233
|
* @async
|
|
@@ -160,7 +235,7 @@ async function handleBootstrapResponse(responseData, controllerUrl, environmentK
|
|
|
160
235
|
* @param {string} controllerUrl
|
|
161
236
|
* @param {string} environmentKey
|
|
162
237
|
* @param {Function} buildDataplaneImageRefFn
|
|
163
|
-
* @returns {Promise<
|
|
238
|
+
* @returns {Promise<{status: string, credentialsAvailable: true}>}
|
|
164
239
|
*/
|
|
165
240
|
async function bootstrapDataplaneApplication(options, controllerUrl, environmentKey, buildDataplaneImageRefFn) {
|
|
166
241
|
const encryptionKey = await resolveBootstrapEncryptionKey();
|
|
@@ -193,7 +268,7 @@ async function bootstrapDataplaneApplication(options, controllerUrl, environment
|
|
|
193
268
|
registrationData
|
|
194
269
|
);
|
|
195
270
|
|
|
196
|
-
|
|
271
|
+
return handleBootstrapResponse(responseData, controllerUrl, environmentKey, containerPort);
|
|
197
272
|
}
|
|
198
273
|
|
|
199
274
|
module.exports = {
|
|
@@ -201,5 +276,7 @@ module.exports = {
|
|
|
201
276
|
resolveBootstrapEncryptionKey,
|
|
202
277
|
bootstrapDataplaneApplication,
|
|
203
278
|
syncDataplaneApplicationUrls,
|
|
204
|
-
pickBootstrapEncryptionKeyFromSecrets
|
|
279
|
+
pickBootstrapEncryptionKeyFromSecrets,
|
|
280
|
+
hasUsablePipelineCredentials,
|
|
281
|
+
BOOTSTRAP_CREDENTIAL_ERROR_CODE
|
|
205
282
|
};
|
|
@@ -344,34 +344,8 @@ async function provisionAndDeployDataplane(options, environmentKey, controllerUr
|
|
|
344
344
|
logger.log('');
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
* register or rotate (if already registered), deploy (send manifest to controller),
|
|
350
|
-
* then run dataplane app locally (always local deployment).
|
|
351
|
-
*
|
|
352
|
-
* @async
|
|
353
|
-
* @function handleUpDataplane
|
|
354
|
-
* @param {Object} options - Commander options
|
|
355
|
-
* @param {string} [options.registry] - Override registry for dataplane
|
|
356
|
-
* @param {string} [options.registryMode] - Override registry mode (acr|external)
|
|
357
|
-
* @param {string} [options.image] - Override image reference for dataplane
|
|
358
|
-
* @param {boolean} [options.skipInfraCheck] - When true, skip Postgres/Redis gate (caller already verified, e.g. guided up-dataplane).
|
|
359
|
-
* @returns {Promise<void>}
|
|
360
|
-
* @throws {Error} If infra not up, controller unavailable, not logged in, environment not dev, or any step fails
|
|
361
|
-
*/
|
|
362
|
-
/**
|
|
363
|
-
* Run options for the local dataplane container.
|
|
364
|
-
*
|
|
365
|
-
* A guided platform install already shows one spinner for the whole dataplane step, so it
|
|
366
|
-
* either forwards its own progress callback or asks for silence; a standalone run prints
|
|
367
|
-
* normally.
|
|
368
|
-
*
|
|
369
|
-
* @param {Object} options - Command options
|
|
370
|
-
* @param {string} runEnvKey - Resolved run environment
|
|
371
|
-
* @param {number|undefined} portOverride - Resolved port override
|
|
372
|
-
* @returns {Object} Options for app.runApp
|
|
373
|
-
*/
|
|
374
|
-
function buildDataplaneRunOptions(options, runEnvKey, portOverride) {
|
|
347
|
+
async function runDataplaneLocally(options) {
|
|
348
|
+
const { runEnvKey, portOverride } = await computeDataplaneRunOverrides(options);
|
|
375
349
|
const runAppOpts = {
|
|
376
350
|
skipEnvOutputPath: true,
|
|
377
351
|
env: runEnvKey,
|
|
@@ -387,7 +361,7 @@ function buildDataplaneRunOptions(options, runEnvKey, portOverride) {
|
|
|
387
361
|
runAppOpts.silentPoll = true;
|
|
388
362
|
}
|
|
389
363
|
}
|
|
390
|
-
|
|
364
|
+
await app.runApp('dataplane', runAppOpts);
|
|
391
365
|
}
|
|
392
366
|
|
|
393
367
|
async function handleUpDataplane(options = {}) {
|
|
@@ -420,8 +394,7 @@ async function handleUpDataplane(options = {}) {
|
|
|
420
394
|
controllerUrl
|
|
421
395
|
);
|
|
422
396
|
|
|
423
|
-
|
|
424
|
-
await app.runApp('dataplane', buildDataplaneRunOptions(options, runEnvKey, portOverride));
|
|
397
|
+
await runDataplaneLocally(options);
|
|
425
398
|
|
|
426
399
|
if (options.platformInstall !== true) {
|
|
427
400
|
logger.log(formatSuccessParagraph('up-dataplane complete. Dataplane is registered, deployed in dev, and running locally.'));
|
package/lib/generator/index.js
CHANGED
|
@@ -22,6 +22,22 @@ const { generateControllerManifest } = require('./external-controller-manifest')
|
|
|
22
22
|
const { resolveVersionForApp } = require('../utils/image-version');
|
|
23
23
|
const { getContainerPort } = require('../utils/port-resolver');
|
|
24
24
|
const { buildEnvVarMap } = require('../utils/env-map');
|
|
25
|
+
const config = require('../core/config');
|
|
26
|
+
const {
|
|
27
|
+
resolveDimensionAbacSetting,
|
|
28
|
+
applyDimensionAbacToConfiguration
|
|
29
|
+
} = require('../utils/dataplane-dimension-abac-setting');
|
|
30
|
+
|
|
31
|
+
async function resolveDimensionAbacValue(appName) {
|
|
32
|
+
if (appName !== 'dataplane') return undefined;
|
|
33
|
+
const userConfig = await config.getConfig();
|
|
34
|
+
return resolveDimensionAbacSetting(userConfig).value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function applyResolvedDimensionAbacToConfiguration(appName, configuration) {
|
|
38
|
+
const value = await resolveDimensionAbacValue(appName);
|
|
39
|
+
if (value !== undefined) applyDimensionAbacToConfiguration(appName, configuration, value);
|
|
40
|
+
}
|
|
25
41
|
const { mergeAdditionalPublishedPorts } = require('../utils/docker-manifest-public-port');
|
|
26
42
|
|
|
27
43
|
/**
|
|
@@ -115,6 +131,13 @@ function substitutePortInDeployment(obj, portNumber) {
|
|
|
115
131
|
}
|
|
116
132
|
}
|
|
117
133
|
|
|
134
|
+
function normalizeDeploymentPort(deployment, effectivePort) {
|
|
135
|
+
if (deployment.port === undefined) return;
|
|
136
|
+
deployment.port = typeof deployment.port === 'string' && /^\d+$/.test(deployment.port)
|
|
137
|
+
? parseInt(deployment.port, 10)
|
|
138
|
+
: (typeof deployment.port === 'number' ? deployment.port : effectivePort);
|
|
139
|
+
}
|
|
140
|
+
|
|
118
141
|
/** Regex to find ${VAR} placeholders for env substitution */
|
|
119
142
|
const ENV_VAR_PLACEHOLDER_REGEX = /\$\{([^}]+)\}/g;
|
|
120
143
|
|
|
@@ -179,6 +202,9 @@ function buildAndValidateDeployment(appName, variables, envTemplate, rbac, optio
|
|
|
179
202
|
// Parse environment variables from template and merge portalInput from application config.
|
|
180
203
|
// A known deployment target resolves manifest {NAME} values; otherwise they stay for the Controller.
|
|
181
204
|
const configuration = parseEnvironmentVariables(envTemplate, variables, options && options.target);
|
|
205
|
+
if (options && options.dimensionAbacValue !== undefined) {
|
|
206
|
+
applyDimensionAbacToConfiguration(appName, configuration, options.dimensionAbacValue);
|
|
207
|
+
}
|
|
182
208
|
|
|
183
209
|
// Build deployment manifest (Controller computes deploymentKey from schema)
|
|
184
210
|
const deployment = builders.buildManifestStructure(appName, variables, configuration, rbac);
|
|
@@ -193,10 +219,7 @@ function buildAndValidateDeployment(appName, variables, envTemplate, rbac, optio
|
|
|
193
219
|
// Replace ${PORT} with port from application.yaml so manifest deploys correctly
|
|
194
220
|
const effectivePort = getEffectivePortForSubstitution(variables, 3000);
|
|
195
221
|
substitutePortInDeployment(deployment, effectivePort);
|
|
196
|
-
|
|
197
|
-
deployment.port = typeof deployment.port === 'string' && /^\d+$/.test(deployment.port)
|
|
198
|
-
? parseInt(deployment.port, 10) : (typeof deployment.port === 'number' ? deployment.port : effectivePort);
|
|
199
|
-
}
|
|
222
|
+
normalizeDeploymentPort(deployment, effectivePort);
|
|
200
223
|
|
|
201
224
|
// Resolve ${REDIS_HOST}, ${DB_HOST}, etc. from env-config.yaml so manifest has no unresolved vars
|
|
202
225
|
const envVarMap = options && options.envVarMap;
|
|
@@ -239,14 +262,12 @@ async function buildDeploymentManifestInMemory(appName, options = {}) {
|
|
|
239
262
|
app: { ...variables.app, version: resolved.version }
|
|
240
263
|
};
|
|
241
264
|
const configuration = parseEnvironmentVariables(envTemplate, variables, options && options.target);
|
|
265
|
+
await applyResolvedDimensionAbacToConfiguration(appName, configuration);
|
|
242
266
|
const deployment = builders.buildManifestStructure(appName, variablesWithVersion, configuration, rbac);
|
|
243
267
|
|
|
244
268
|
const effectivePort = getEffectivePortForSubstitution(variablesWithVersion, 3000);
|
|
245
269
|
substitutePortInDeployment(deployment, effectivePort);
|
|
246
|
-
|
|
247
|
-
deployment.port = typeof deployment.port === 'string' && /^\d+$/.test(deployment.port)
|
|
248
|
-
? parseInt(deployment.port, 10) : (typeof deployment.port === 'number' ? deployment.port : effectivePort);
|
|
249
|
-
}
|
|
270
|
+
normalizeDeploymentPort(deployment, effectivePort);
|
|
250
271
|
const envVarMap = await buildEnvVarMap('docker', null, null, { appPort: effectivePort });
|
|
251
272
|
await mergeAdditionalPublishedPorts(envVarMap, variablesWithVersion);
|
|
252
273
|
substituteEnvVarsInDeployment(deployment, envVarMap);
|
|
@@ -302,10 +323,12 @@ async function writeRegularDeployJson(appName, appPath, appType, options = {}) {
|
|
|
302
323
|
};
|
|
303
324
|
const effectivePort = getEffectivePortForSubstitution(variablesWithVersion, 3000);
|
|
304
325
|
const envVarMap = await buildEnvVarMap('docker', null, null, { appPort: effectivePort });
|
|
326
|
+
const dimensionAbacValue = await resolveDimensionAbacValue(appName);
|
|
305
327
|
await mergeAdditionalPublishedPorts(envVarMap, variablesWithVersion);
|
|
306
328
|
const deployment = buildAndValidateDeployment(appName, variablesWithVersion, envTemplate, rbac, {
|
|
307
329
|
envVarMap,
|
|
308
|
-
target: options.target
|
|
330
|
+
target: options.target,
|
|
331
|
+
dimensionAbacValue
|
|
309
332
|
});
|
|
310
333
|
const jsonContent = JSON.stringify(deployment, null, 2);
|
|
311
334
|
fs.writeFileSync(jsonPath, jsonContent, { mode: 0o644 });
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve and materialize the Dataplane Dimension ABAC installer setting.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Strict non-secret Dimension ABAC setting helpers
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const SETTING_NAME = 'DIMENSION_ABAC_ENFORCEMENT_ENABLED';
|
|
12
|
+
const CONFIG_KEY = 'dimensionAbacEnforcementEnabled';
|
|
13
|
+
const DATAPLANE_APP = 'dataplane';
|
|
14
|
+
|
|
15
|
+
function settingError(message) {
|
|
16
|
+
const error = new Error(message);
|
|
17
|
+
error.code = 'DIMENSION_ABAC_SETTING_INVALID';
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseDimensionAbacOption(raw) {
|
|
22
|
+
if (raw === undefined || raw === null) return undefined;
|
|
23
|
+
if (raw === true || raw === false) return raw;
|
|
24
|
+
const value = String(raw).trim().toLowerCase();
|
|
25
|
+
if (value === 'on') return true;
|
|
26
|
+
if (value === 'off') return false;
|
|
27
|
+
throw settingError('Invalid --dimension-abac value. Use on or off.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readStoredValue(userConfig) {
|
|
31
|
+
const applications = userConfig && userConfig.applications;
|
|
32
|
+
if (applications === undefined || applications === null) return undefined;
|
|
33
|
+
if (typeof applications !== 'object' || Array.isArray(applications)) {
|
|
34
|
+
throw settingError('Invalid applications configuration; expected an object.');
|
|
35
|
+
}
|
|
36
|
+
const entry = applications[DATAPLANE_APP];
|
|
37
|
+
if (entry === undefined || entry === null) return undefined;
|
|
38
|
+
if (typeof entry !== 'object' || Array.isArray(entry)) {
|
|
39
|
+
throw settingError('Invalid applications.dataplane configuration; expected an object.');
|
|
40
|
+
}
|
|
41
|
+
if (!Object.prototype.hasOwnProperty.call(entry, CONFIG_KEY)) return undefined;
|
|
42
|
+
if (typeof entry[CONFIG_KEY] !== 'boolean') {
|
|
43
|
+
throw settingError(`Invalid applications.dataplane.${CONFIG_KEY}; expected a boolean.`);
|
|
44
|
+
}
|
|
45
|
+
return entry[CONFIG_KEY];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveDimensionAbacSetting(userConfig, explicitValue) {
|
|
49
|
+
const stored = readStoredValue(userConfig);
|
|
50
|
+
if (explicitValue !== undefined && typeof explicitValue !== 'boolean') {
|
|
51
|
+
throw settingError('Explicit Dimension ABAC selection must be a boolean.');
|
|
52
|
+
}
|
|
53
|
+
if (explicitValue !== undefined) return { value: explicitValue, source: 'explicit' };
|
|
54
|
+
if (stored !== undefined) return { value: stored, source: 'stored' };
|
|
55
|
+
return { value: false, source: 'default' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function persistExplicitDimensionAbacSetting(configApi, value) {
|
|
59
|
+
if (value === undefined) return undefined;
|
|
60
|
+
if (!configApi || typeof configApi.getConfig !== 'function' || typeof configApi.saveConfig !== 'function') {
|
|
61
|
+
throw settingError('Configuration API is unavailable.');
|
|
62
|
+
}
|
|
63
|
+
if (typeof value !== 'boolean') {
|
|
64
|
+
throw settingError('Persisted Dimension ABAC selection must be a boolean.');
|
|
65
|
+
}
|
|
66
|
+
const userConfig = await configApi.getConfig();
|
|
67
|
+
readStoredValue(userConfig);
|
|
68
|
+
const applications = { ...(userConfig.applications || {}) };
|
|
69
|
+
const dataplane = { ...(applications[DATAPLANE_APP] || {}), [CONFIG_KEY]: value };
|
|
70
|
+
const nextConfig = { ...userConfig, applications: { ...applications, [DATAPLANE_APP]: dataplane } };
|
|
71
|
+
await configApi.saveConfig(nextConfig);
|
|
72
|
+
return nextConfig;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toDimensionAbacEnvValue(value) {
|
|
76
|
+
if (typeof value !== 'boolean') {
|
|
77
|
+
throw settingError('Dimension ABAC value must be a boolean.');
|
|
78
|
+
}
|
|
79
|
+
return value ? 'true' : 'false';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function applyDimensionAbacToEnvMap(appName, envMap, value) {
|
|
83
|
+
if (appName !== DATAPLANE_APP) return envMap;
|
|
84
|
+
if (!envMap || typeof envMap !== 'object' || Array.isArray(envMap)) {
|
|
85
|
+
throw settingError('Dataplane environment map must be an object.');
|
|
86
|
+
}
|
|
87
|
+
envMap[SETTING_NAME] = toDimensionAbacEnvValue(value);
|
|
88
|
+
return envMap;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function applyDimensionAbacToConfiguration(appName, configuration, value) {
|
|
92
|
+
if (appName !== DATAPLANE_APP) return configuration;
|
|
93
|
+
if (!Array.isArray(configuration)) {
|
|
94
|
+
throw settingError('Dataplane deployment configuration must be an array.');
|
|
95
|
+
}
|
|
96
|
+
const matches = configuration.filter(item => item && item.name === SETTING_NAME);
|
|
97
|
+
if (matches.length > 1) {
|
|
98
|
+
throw settingError(`Dataplane deployment configuration contains duplicate ${SETTING_NAME} entries.`);
|
|
99
|
+
}
|
|
100
|
+
const entry = {
|
|
101
|
+
name: SETTING_NAME,
|
|
102
|
+
value: toDimensionAbacEnvValue(value),
|
|
103
|
+
location: 'variable',
|
|
104
|
+
required: false
|
|
105
|
+
};
|
|
106
|
+
if (matches.length === 0) configuration.push(entry);
|
|
107
|
+
else Object.assign(matches[0], entry);
|
|
108
|
+
return configuration;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
SETTING_NAME,
|
|
113
|
+
CONFIG_KEY,
|
|
114
|
+
parseDimensionAbacOption,
|
|
115
|
+
resolveDimensionAbacSetting,
|
|
116
|
+
persistExplicitDimensionAbacSetting,
|
|
117
|
+
toDimensionAbacEnvValue,
|
|
118
|
+
applyDimensionAbacToEnvMap,
|
|
119
|
+
applyDimensionAbacToConfiguration
|
|
120
|
+
};
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
/** Env key patterns that indicate a secret (mask value) — same rules as aifabrix logs <app>. */
|
|
14
14
|
const SECRET_KEY_PATTERN = /password|secret|token|credential|api[_-]?key/i;
|
|
15
15
|
|
|
16
|
+
/** Bounded encryption/master-key names; avoids treating every `key` variable as secret. */
|
|
17
|
+
const ENCRYPTION_KEY_PATTERN = /(?:^|[_-])(?:encryption|master)[_-]?key(?:$|[_-])/i;
|
|
18
|
+
|
|
16
19
|
/** Prefixes to strip before checking key (avoids masking KEYCLOAK_SERVER_URL etc.) */
|
|
17
20
|
const KEY_PREFIXES_TO_STRIP = /^KEYCLOAK_|^KEY_VAULT_/;
|
|
18
21
|
|
|
@@ -50,7 +53,7 @@ function maskEnvLine(line) {
|
|
|
50
53
|
const value = line.slice(eq + 1);
|
|
51
54
|
|
|
52
55
|
const keyForCheck = key.replace(KEY_PREFIXES_TO_STRIP, '');
|
|
53
|
-
const isSecretKey = SECRET_KEY_PATTERN.test(keyForCheck);
|
|
56
|
+
const isSecretKey = SECRET_KEY_PATTERN.test(keyForCheck) || ENCRYPTION_KEY_PATTERN.test(keyForCheck);
|
|
54
57
|
|
|
55
58
|
const maskedValue = maskUrlEmbeddedCredentials(value);
|
|
56
59
|
const hasUrlCredentials = maskedValue !== value;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aifabrix/builder",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.63.0",
|
|
4
4
|
"description": "AI Fabrix Builder — CLI and developer scripts (pnpm + af)",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"check:cli-help": "node scripts/check-cli-help-examples.js",
|
|
43
43
|
"check:mirrored-templates": "node scripts/check-mirrored-templates.js",
|
|
44
44
|
"test:manual": "jest --config jest.config.manual.js --runInBand",
|
|
45
|
+
"test:manual:public": "jest --config jest.config.manual-public.js --runInBand",
|
|
45
46
|
"test:integration": "jest --config jest.config.integration.js --runInBand",
|
|
46
47
|
"test:integration:fixtures": "jest --config jest.config.integration.fixtures.js --runInBand",
|
|
47
48
|
"test:hubspot-wizard": "node integration/hubspot-test/test.js",
|
package/templates/README.md
CHANGED
|
@@ -26,6 +26,12 @@ Application templates are folder-based and located under `templates/applications
|
|
|
26
26
|
| `aifabrix-miso/packages/miso-controller/configs/infra.parameter.yaml` | `lib/schema/infra.parameter.yaml` |
|
|
27
27
|
| `aifabrix-dataplane/builder/dataplane/` (`env.template`, `rbac.yaml`, `application.yaml`) | `templates/applications/dataplane/` |
|
|
28
28
|
|
|
29
|
+
The Dataplane `env.template` is the source of truth for
|
|
30
|
+
`DIMENSION_ABAC_ENFORCEMENT_ENABLED` and its default `false`. Do not hand-edit
|
|
31
|
+
the bundled mirror. `aifabrix setup --dimension-abac on|off` stores an operator
|
|
32
|
+
selection outside the template and overlays the resolved lowercase value during
|
|
33
|
+
local runtime and deployment-manifest materialization.
|
|
34
|
+
|
|
29
35
|
**Schema master (dataplane):** Also copies dataplane `app/schemas/json/` core integration schemas + `type/*` and `configs/deployment-rules.yaml` into `lib/schema/` (same contract as dataplane `make sync-json`). Do not hand-edit those mirrors — edit dataplane SSOT, then sync. `lib/schema/infra.parameter.yaml` is the Miso catalog mirror above; other Builder-owned schemas in `lib/schema/` (wizard and the rest) are left alone.
|
|
30
36
|
|
|
31
37
|
Overrides: `AIFABRIX_MISO_SRC`, `AIFABRIX_DATAPLANE_SRC` / `AIFABRIX_DATAPLANE_ROOT` (repo roots). Install with `aifabrix up-platform` / `up-builder-api` — do not hand-edit the template copies as primary.
|
|
@@ -5,7 +5,7 @@ app:
|
|
|
5
5
|
description: 'Business Transformation services — HTTP orchestration for connected-system workspace lifecycle via Dataplane Enterprise MCP and governance APIs.'
|
|
6
6
|
type: webapp
|
|
7
7
|
language: typescript
|
|
8
|
-
version: 2.
|
|
8
|
+
version: 2.63.0
|
|
9
9
|
|
|
10
10
|
# Image Configuration
|
|
11
11
|
image:
|