@aifabrix/builder 2.62.0 → 2.63.1

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.
Files changed (34) hide show
  1. package/lib/api/onboarding-status.api.js +117 -0
  2. package/lib/app/register.js +24 -2
  3. package/lib/app/run-env-compose.js +39 -2
  4. package/lib/app/run-env-recovery.js +1 -0
  5. package/lib/app/run-helpers.js +1 -1
  6. package/lib/cli/infra-guided.js +30 -2
  7. package/lib/cli/setup-platform.js +18 -3
  8. package/lib/commands/setup-modes.js +2 -1
  9. package/lib/commands/setup-onboarding-readiness.js +83 -0
  10. package/lib/commands/setup.js +32 -10
  11. package/lib/commands/up-dataplane-bootstrap.js +115 -38
  12. package/lib/commands/up-dataplane.js +4 -31
  13. package/lib/core/local-env-overrides.js +23 -2
  14. package/lib/deployment/installation/index.js +4 -1
  15. package/lib/deployment/installation/infra-catalog.js +0 -2
  16. package/lib/deployment/installation/local-environment-authorize.js +221 -0
  17. package/lib/deployment/installation/local-environment-snapshot.js +124 -0
  18. package/lib/deployment/installation/local-environment-stage.js +43 -66
  19. package/lib/generator/index.js +32 -9
  20. package/lib/parameters/rsa-secret-values.js +14 -31
  21. package/lib/schema/infra.parameter.yaml +2 -45
  22. package/lib/schema/infrastructure-schema.json +0 -58
  23. package/lib/utils/dataplane-dimension-abac-setting.js +120 -0
  24. package/lib/utils/log-redaction.js +4 -1
  25. package/package.json +2 -1
  26. package/templates/README.md +6 -0
  27. package/templates/applications/builder-api/application.yaml +1 -1
  28. package/templates/applications/dataplane/application.yaml +1 -1
  29. package/templates/applications/dataplane/env.template +1 -1
  30. package/templates/applications/mirrored-templates.json +2 -2
  31. package/templates/applications/miso-controller/application.yaml +18 -1
  32. package/templates/applications/miso-controller/env.template +3 -21
  33. package/templates/applications/miso-controller/rbac.yaml +10 -6
  34. package/templates/marketplace/main.json +5 -69
@@ -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<void>}
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
- await handleBootstrapResponse(responseData, controllerUrl, environmentKey, containerPort);
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
- * Handle up-dataplane command: ensure logged in, environment dev, ensure dataplane,
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
- return runAppOpts;
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
- const { runEnvKey, portOverride } = await computeDataplaneRunOverrides(options);
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.'));
@@ -12,6 +12,8 @@
12
12
 
13
13
  'use strict';
14
14
 
15
+ const { buildDevUsernameForFrontDoorHost } = require('../utils/compose-traefik-ingress-base');
16
+
15
17
  /** Applications the CLI registers itself through `.../applications/bootstrap`. */
16
18
  const CLI_BOOTSTRAPPED_APP_KEYS = Object.freeze(['dataplane']);
17
19
 
@@ -43,12 +45,30 @@ const ONBOARDING_APPS_CONFIG_KEY = 'onboardingApps';
43
45
  * to everybody, which is what happened the last time it carried a value.
44
46
  */
45
47
  const MISO_CONTROLLER_LOCAL_ONLY_ENV = Object.freeze({
46
- INFRASTRUCTURE_NAME: '${DEV_USERNAME}.${DEV_ROOT_DOMAIN}',
47
48
  KEYCLOAK_PUBLIC_KEY: '',
48
49
  OTEL_SERVICE_VERSION: '',
49
50
  ACR_LOGIN_SERVER: ''
50
51
  });
51
52
 
53
+ /**
54
+ * Miso's infrastructure name on a local install: `local-devNN`, the installation name the licence
55
+ * binds to (Miso plan 266.0). Miso names the customer's Entra SSO and SCIM applications from it
56
+ * (`AIF-local-dev03-SSO`) and also looks them up by that name.
57
+ *
58
+ * A concrete value, not a placeholder. These overrides are merged after the template is
59
+ * resolved, so the previous `${DEV_USERNAME}.${DEV_ROOT_DOMAIN}` was never interpolated by
60
+ * Builder; Docker substituted the unset variables with empty strings and Miso received `.`,
61
+ * creating `AIF-.-SSO` / `AIF-.-SCIM` in the customer's directory (2026-10-06). Developer id 0
62
+ * has no `devNN`, so nothing is injected there rather than an invalid `local-`.
63
+ *
64
+ * @param {Object} [cfg] - Parsed `~/.aifabrix/config.yaml`
65
+ * @returns {Object.<string, string>} `{ INFRASTRUCTURE_NAME }` or empty
66
+ */
67
+ function localInfrastructureNameEnv(cfg) {
68
+ const devUsername = buildDevUsernameForFrontDoorHost(cfg ? cfg['developer-id'] : undefined);
69
+ return devUsername ? { INFRASTRUCTURE_NAME: `local-${devUsername}` } : {};
70
+ }
71
+
52
72
  function normalizeAppKeyList(value) {
53
73
  const raw = Array.isArray(value) ? value : String(value ?? '').split(',');
54
74
  const seen = new Set();
@@ -91,7 +111,8 @@ function localEnvOverridesForApp(appName, cfg) {
91
111
  if (key === 'miso-controller') {
92
112
  return {
93
113
  ONBOARDING_APPS: resolveLocalOnboardingApps(cfg).join(','),
94
- ...MISO_CONTROLLER_LOCAL_ONLY_ENV
114
+ ...MISO_CONTROLLER_LOCAL_ONLY_ENV,
115
+ ...localInfrastructureNameEnv(cfg)
95
116
  };
96
117
  }
97
118
  if (key === 'builder-api' || key === 'integration-server') {
@@ -29,7 +29,10 @@ async function deployInstallationStage(resourceGroupKey, options, deps = {}) {
29
29
  // install registers with Mori exactly as any other does.
30
30
  if (isLocalInstallationKey(resourceGroup) && ENVIRONMENT_STAGES.includes(validateStage(options.stage))) {
31
31
  const { runLocalEnvironmentStage } = require('./local-environment-stage');
32
- return runLocalEnvironmentStage(resourceGroup, validateStage(options.stage), deps);
32
+ return runLocalEnvironmentStage(resourceGroup, validateStage(options.stage), {
33
+ ...deps,
34
+ preset: options.preset
35
+ });
33
36
  }
34
37
  const workspace = openWorkspace(resourceGroup, { root: deps.root });
35
38
  const input = resolveStageInput(resourceGroup, options, workspace.infra?.vendor?.value);
@@ -102,8 +102,6 @@ const PARAMETER_CATALOG = Object.freeze({
102
102
  moriLicensePublicKey: { kind: 'file', source: 'registration', required: false, description: 'Mori licence issuer public key (SPKI PEM); empty leaves the installation unpinned and it then refuses every licence.' },
103
103
  moriPolicyPublicKey: { kind: 'file', source: 'registration', required: false, description: 'Mori policy signing anchor (SPKI PEM), a different pair from the licence key; empty leaves the policy bundle checkable only against the key it carries, which proves nothing.' },
104
104
  moriLicenseIssuer: { kind: 'file', source: 'registration', required: false, description: 'Expected iss claim on a Mori licence.' },
105
- entraIdClientId: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
106
- entraIdClientSecret: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
107
105
  entraIdTenantId: { kind: 'deferred', description: 'Customer Entra provider tenant; distinct from the Azure subscription directory tenant and configured later.' },
108
106
  licenseEdition: { kind: 'license', source: 'cliOrFile', description: 'License requested from Mori during tenant activation: COMMUNITY, STANDARD or ENTERPRISE, or an Azure Marketplace subscription ID. Standard/Enterprise by name is a 30-day evaluation.' },
109
107
  installationResourceModel: { kind: 'file', source: 'templateDefault', description: 'Initial installation topology: shared for Community and Standard, dedicated for Enterprise DEV. Defaults to shared and is protected by Azure application configuration. It records the topology the installation starts in and grants no later licence entitlement, so it is not a licence input.' },
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Ask Miso whether a local environment may exist, with honest failure labels.
3
+ *
4
+ * Local create uses the same request shape as Azure: key, environment, and
5
+ * configuration.preset / serviceName / location. Docker values are real
6
+ * placement for that system (location local, serviceName local-devNN), not a
7
+ * stripped body. Schema rejection is not a licence decline.
8
+ *
9
+ * @fileoverview Local env deploy — Miso authorize with contract vs licence split
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const { resolveLocalInstallationName } = require('./local-installation');
15
+ const { normalizePreset } = require('../environment-config');
16
+
17
+ /** Placement location stored for Docker / local / database installs. */
18
+ const LOCAL_LOCATION = 'local';
19
+
20
+ /**
21
+ * @param {Object|Error|null|undefined} errorOrResponse
22
+ * @returns {number}
23
+ */
24
+ function statusOf(errorOrResponse) {
25
+ if (!errorOrResponse || typeof errorOrResponse !== 'object') {
26
+ return 0;
27
+ }
28
+ const raw =
29
+ errorOrResponse.status ??
30
+ errorOrResponse.statusCode ??
31
+ (errorOrResponse.errorData && errorOrResponse.errorData.statusCode);
32
+ const n = Number(raw);
33
+ return Number.isFinite(n) && n > 0 ? n : 0;
34
+ }
35
+
36
+ /**
37
+ * @param {Object|Error|null|undefined} errorOrResponse
38
+ * @returns {string}
39
+ */
40
+ function detailOf(errorOrResponse) {
41
+ if (!errorOrResponse) {
42
+ return 'request failed';
43
+ }
44
+ if (typeof errorOrResponse === 'string') {
45
+ return errorOrResponse;
46
+ }
47
+ return (
48
+ errorOrResponse.formattedError ||
49
+ errorOrResponse.detail ||
50
+ (typeof errorOrResponse.error === 'string' ? errorOrResponse.error : null) ||
51
+ errorOrResponse.message ||
52
+ 'request failed'
53
+ );
54
+ }
55
+
56
+ /**
57
+ * True when the controller refused the body as invalid, before any licence decision.
58
+ *
59
+ * @param {Object|Error|null|undefined} errorOrResponse
60
+ * @returns {boolean}
61
+ */
62
+ function isContractValidationFailure(errorOrResponse) {
63
+ const status = statusOf(errorOrResponse);
64
+ if (status === 400 || status === 422) {
65
+ return true;
66
+ }
67
+ const text = [
68
+ detailOf(errorOrResponse),
69
+ errorOrResponse && errorOrResponse.title,
70
+ errorOrResponse && errorOrResponse.type,
71
+ JSON.stringify((errorOrResponse && errorOrResponse.errorData) || {})
72
+ ]
73
+ .filter(Boolean)
74
+ .join(' ')
75
+ .toLowerCase();
76
+ return /configuration\.|["']?preset["']?|servicename|service.?name|["']?location["']?/.test(
77
+ text
78
+ );
79
+ }
80
+
81
+ /**
82
+ * @param {string} envKey
83
+ * @param {Object|Error} errorOrResponse
84
+ * @returns {Error}
85
+ */
86
+ function formatAuthorizeFailure(envKey, errorOrResponse) {
87
+ const detail = detailOf(errorOrResponse);
88
+ if (isContractValidationFailure(errorOrResponse)) {
89
+ return new Error(
90
+ `Cannot create environment ${envKey}: the controller rejected the request as invalid ` +
91
+ `(not a licence decision).\n${detail}\n` +
92
+ 'Local create uses the same configuration fields as Azure (preset, serviceName, ' +
93
+ 'location). Nothing was created locally.'
94
+ );
95
+ }
96
+ return new Error(
97
+ `Miso declined environment ${envKey}: ${detail}\n` +
98
+ 'Editions are licensed in Miso. Nothing was created locally.'
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Same create body shape as Azure environment create.
104
+ *
105
+ * Docker still sends a licensed size preset (default `s`). `location` is `local`
106
+ * and `serviceName` is the installation name. Optional `--preset` overrides size.
107
+ *
108
+ * @param {string} envKey
109
+ * @param {{ serviceName: string, preset?: string, location?: string }} placement
110
+ * @returns {{ key: string, environment: string, configuration: Object }}
111
+ */
112
+ function buildLocalEnvironmentCreateBody(envKey, placement) {
113
+ const key = String(envKey || '').trim();
114
+ return {
115
+ key,
116
+ environment: key,
117
+ configuration: {
118
+ preset: normalizePreset(placement.preset),
119
+ serviceName: String(placement.serviceName || '').trim(),
120
+ location: String(placement.location || LOCAL_LOCATION).trim() || LOCAL_LOCATION
121
+ }
122
+ };
123
+ }
124
+
125
+ /**
126
+ * @async
127
+ * @param {Object} deps
128
+ * @param {string} installationKey
129
+ * @returns {Promise<string>}
130
+ */
131
+ async function resolveAuthorizeServiceName(deps, installationKey) {
132
+ if (deps.serviceName) {
133
+ return String(deps.serviceName).trim();
134
+ }
135
+ const config = deps.config || require('../../core/config');
136
+ const developerId = await config.getDeveloperId();
137
+ return resolveLocalInstallationName(installationKey || 'local', developerId);
138
+ }
139
+
140
+ /**
141
+ * @async
142
+ * @param {string} envKey
143
+ * @param {Object} deps
144
+ * @returns {Promise<{ controllerUrl: string, authConfig: Object }>}
145
+ */
146
+ async function resolveAuthorizeSession(envKey, deps) {
147
+ const resolveControllerUrl =
148
+ deps.resolveControllerUrl || require('../../utils/controller-url').resolveControllerUrl;
149
+ const getOrRefreshDeviceToken =
150
+ deps.getOrRefreshDeviceToken ||
151
+ require('../../utils/token-manager').getOrRefreshDeviceToken;
152
+ const controllerUrl = await resolveControllerUrl();
153
+ if (!controllerUrl) {
154
+ throw new Error(
155
+ `Cannot create ${envKey}: no controller is configured, so whether your licence ` +
156
+ 'permits this environment cannot be checked. Run "aifabrix login" first.'
157
+ );
158
+ }
159
+ const deviceToken = await getOrRefreshDeviceToken(controllerUrl);
160
+ if (!deviceToken || !deviceToken.token) {
161
+ throw new Error(
162
+ `Cannot create ${envKey}: not signed in, so whether your licence permits this ` +
163
+ 'environment cannot be checked. Run "aifabrix login" first.'
164
+ );
165
+ }
166
+ return {
167
+ controllerUrl,
168
+ authConfig: {
169
+ type: 'device',
170
+ token: deviceToken.token,
171
+ controller: deviceToken.controller || controllerUrl
172
+ }
173
+ };
174
+ }
175
+
176
+ /**
177
+ * @async
178
+ * @param {string} envKey
179
+ * @param {Object} [deps]
180
+ * @returns {Promise<void>}
181
+ */
182
+ async function authorizeEnvironmentWithController(envKey, deps = {}) {
183
+ if (typeof deps.authorizeEnvironment === 'function') {
184
+ await deps.authorizeEnvironment(envKey);
185
+ return;
186
+ }
187
+ const createEnvironment =
188
+ deps.createEnvironment || require('../../api/environments.api').createEnvironment;
189
+ const { controllerUrl, authConfig } = await resolveAuthorizeSession(envKey, deps);
190
+ const serviceName = await resolveAuthorizeServiceName(deps, deps.installationKey);
191
+ if (!serviceName) {
192
+ throw new Error(
193
+ `Cannot create ${envKey}: local serviceName is empty. Name the installation ` +
194
+ '(for example local-dev03) or set a developer id with "aifabrix setup".'
195
+ );
196
+ }
197
+ const body = buildLocalEnvironmentCreateBody(envKey, {
198
+ serviceName,
199
+ preset: deps.preset,
200
+ location: deps.location
201
+ });
202
+ let response;
203
+ try {
204
+ response = await createEnvironment(controllerUrl, authConfig, body);
205
+ } catch (error) {
206
+ throw formatAuthorizeFailure(envKey, error);
207
+ }
208
+ if (response && response.success === false) {
209
+ throw formatAuthorizeFailure(envKey, response);
210
+ }
211
+ }
212
+
213
+ module.exports = {
214
+ LOCAL_LOCATION,
215
+ statusOf,
216
+ detailOf,
217
+ isContractValidationFailure,
218
+ formatAuthorizeFailure,
219
+ buildLocalEnvironmentCreateBody,
220
+ authorizeEnvironmentWithController
221
+ };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Load an environment's bootstrap snapshot after its dataplane credential exists.
3
+ *
4
+ * Each environment holds its own application credential. `pro` must never authenticate
5
+ * with `dev`'s client secret. The snapshot is the settings source; this module does not
6
+ * write a parallel env file.
7
+ *
8
+ * @fileoverview Local env deploy — per-environment bootstrap snapshot
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { resolveStrictSecretName } = require('../../parameters/physical-secret-name');
14
+
15
+ const DATAPLANE_CLIENT_ID_KEY = 'dataplane-client-idKeyVault';
16
+ const DATAPLANE_CLIENT_SECRET_KEY = 'dataplane-client-secretKeyVault';
17
+
18
+ /**
19
+ * @param {Object} secrets
20
+ * @param {string} key
21
+ * @returns {string|null}
22
+ */
23
+ function nonEmptySecret(secrets, key) {
24
+ if (!secrets || typeof secrets !== 'object') {
25
+ return null;
26
+ }
27
+ const value = secrets[key];
28
+ if (value === null || value === undefined) {
29
+ return null;
30
+ }
31
+ const text = String(value).trim();
32
+ return text ? text : null;
33
+ }
34
+
35
+ /**
36
+ * Resolve this environment's dataplane client credentials.
37
+ *
38
+ * Uses `resolveStrictSecretName` so an application-scoped secret is never shared between
39
+ * environments, even when the catalog still pins an unprefixed literal.
40
+ *
41
+ * @param {string} envKey
42
+ * @param {Object} secrets
43
+ * @returns {{ clientId: string, clientSecret: string, clientIdKey: string, clientSecretKey: string }}
44
+ * @throws {Error} When either credential is missing for this environment
45
+ */
46
+ function resolveEnvironmentDataplaneCredentials(envKey, secrets) {
47
+ const env = String(envKey || '').trim().toLowerCase();
48
+ const clientIdKey = resolveStrictSecretName(DATAPLANE_CLIENT_ID_KEY, env);
49
+ const clientSecretKey = resolveStrictSecretName(DATAPLANE_CLIENT_SECRET_KEY, env);
50
+ let clientId = nonEmptySecret(secrets, clientIdKey);
51
+ let clientSecret = nonEmptySecret(secrets, clientSecretKey);
52
+ let resolvedIdKey = clientIdKey;
53
+ let resolvedSecretKey = clientSecretKey;
54
+ // Same adoption rule as databases: a pre-environment install's unprefixed credential
55
+ // belongs to `dev`. tst and pro never adopt — that would share isolation away.
56
+ if (env === 'dev' && (!clientId || !clientSecret)) {
57
+ const adoptedId = nonEmptySecret(secrets, DATAPLANE_CLIENT_ID_KEY);
58
+ const adoptedSecret = nonEmptySecret(secrets, DATAPLANE_CLIENT_SECRET_KEY);
59
+ if (adoptedId && adoptedSecret) {
60
+ clientId = adoptedId;
61
+ clientSecret = adoptedSecret;
62
+ resolvedIdKey = DATAPLANE_CLIENT_ID_KEY;
63
+ resolvedSecretKey = DATAPLANE_CLIENT_SECRET_KEY;
64
+ }
65
+ }
66
+ if (!clientId || !clientSecret) {
67
+ const missing = [
68
+ !clientId ? clientIdKey : null,
69
+ !clientSecret ? clientSecretKey : null
70
+ ].filter(Boolean);
71
+ throw new Error(
72
+ `Cannot load a configuration snapshot for ${env}: missing ${missing.join(' and ')}. ` +
73
+ 'Each environment uses its own dataplane credential. Add ' +
74
+ `aifabrix secret set ${missing[0]} <value>` +
75
+ (missing[1] ? ` (and ${missing[1]})` : '') +
76
+ '.'
77
+ );
78
+ }
79
+ return {
80
+ clientId,
81
+ clientSecret,
82
+ clientIdKey: resolvedIdKey,
83
+ clientSecretKey: resolvedSecretKey
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Fetch the registered dataplane's bootstrap snapshot for this environment.
89
+ *
90
+ * @async
91
+ * @param {string} envKey
92
+ * @param {Object} [deps]
93
+ * @returns {Promise<{ configuration: Object, context: Object, issuedAt?: string }>}
94
+ */
95
+ async function loadEnvironmentBootstrapSnapshot(envKey, deps = {}) {
96
+ const resolveControllerUrl =
97
+ deps.resolveControllerUrl || require('../../utils/controller-url').resolveControllerUrl;
98
+ const loadSecrets = deps.loadSecrets || require('../../core/secrets').loadSecrets;
99
+ const fetchBootstrapSnapshot =
100
+ deps.fetchBootstrapSnapshot ||
101
+ require('../../api/bootstrap-snapshot.api').fetchBootstrapSnapshot;
102
+
103
+ const controllerUrl = await resolveControllerUrl();
104
+ if (!controllerUrl) {
105
+ throw new Error(
106
+ `Cannot load a configuration snapshot for ${envKey}: no controller is configured. ` +
107
+ 'Run "aifabrix login" first.'
108
+ );
109
+ }
110
+ const secrets = deps.secrets || (await loadSecrets(undefined));
111
+ const credentials = resolveEnvironmentDataplaneCredentials(envKey, secrets);
112
+ return fetchBootstrapSnapshot(
113
+ controllerUrl,
114
+ { clientId: credentials.clientId, clientSecret: credentials.clientSecret },
115
+ deps
116
+ );
117
+ }
118
+
119
+ module.exports = {
120
+ DATAPLANE_CLIENT_ID_KEY,
121
+ DATAPLANE_CLIENT_SECRET_KEY,
122
+ resolveEnvironmentDataplaneCredentials,
123
+ loadEnvironmentBootstrapSnapshot
124
+ };