@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.
- 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/core/local-env-overrides.js +23 -2
- package/lib/deployment/installation/index.js +4 -1
- package/lib/deployment/installation/infra-catalog.js +0 -2
- package/lib/deployment/installation/local-environment-authorize.js +221 -0
- package/lib/deployment/installation/local-environment-snapshot.js +124 -0
- package/lib/deployment/installation/local-environment-stage.js +43 -66
- package/lib/generator/index.js +32 -9
- package/lib/parameters/rsa-secret-values.js +14 -31
- package/lib/schema/infra.parameter.yaml +2 -45
- package/lib/schema/infrastructure-schema.json +0 -58
- 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
- package/templates/applications/dataplane/application.yaml +1 -1
- package/templates/applications/dataplane/env.template +1 -1
- package/templates/applications/mirrored-templates.json +2 -2
- package/templates/applications/miso-controller/application.yaml +18 -1
- package/templates/applications/miso-controller/env.template +3 -21
- package/templates/applications/miso-controller/rbac.yaml +10 -6
- 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<
|
|
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.'));
|
|
@@ -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),
|
|
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
|
+
};
|