@aifabrix/builder 2.55.2 → 2.57.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.
Files changed (88) hide show
  1. package/lib/agent-kit/session-report.js +76 -0
  2. package/lib/agent-kit/start.js +124 -57
  3. package/lib/agent-kit/tmux-sessions.js +163 -0
  4. package/lib/api/environments.api.js +38 -0
  5. package/lib/api/marketplace-bootstrap.api.js +31 -0
  6. package/lib/api/pipeline.api.js +32 -3
  7. package/lib/app/deploy.js +6 -5
  8. package/lib/cli/setup-app.js +7 -6
  9. package/lib/cli/setup-environment.js +76 -19
  10. package/lib/cli/setup-external-system-help.js +15 -3
  11. package/lib/cli/setup-external-system.js +21 -3
  12. package/lib/commands/agent-kit-help.js +32 -15
  13. package/lib/commands/agent-kit.js +90 -8
  14. package/lib/commands/repair-datasource-auto-rbac.js +4 -9
  15. package/lib/commands/upload-openapi-sync-followup.js +59 -0
  16. package/lib/commands/upload-qualification-client.js +131 -0
  17. package/lib/commands/upload-qualification-followups.js +104 -0
  18. package/lib/commands/upload-qualification-poll.js +143 -0
  19. package/lib/commands/upload.js +37 -156
  20. package/lib/deployment/deploy-poll-ui.js +14 -10
  21. package/lib/deployment/deployer-status.js +4 -4
  22. package/lib/deployment/installation/azure-infra-stage.js +160 -0
  23. package/lib/deployment/installation/azure-preflight.js +102 -0
  24. package/lib/deployment/installation/azure-readiness.js +71 -0
  25. package/lib/deployment/installation/docker-register.js +112 -0
  26. package/lib/deployment/installation/environment-auth.js +76 -0
  27. package/lib/deployment/installation/environment-manifest.js +165 -0
  28. package/lib/deployment/installation/environment-stage.js +191 -0
  29. package/lib/deployment/installation/index.js +39 -0
  30. package/lib/deployment/installation/infra-catalog.js +165 -0
  31. package/lib/deployment/installation/infra-manifest.js +161 -0
  32. package/lib/deployment/installation/infra-projection.js +86 -0
  33. package/lib/deployment/installation/public-ip.js +65 -0
  34. package/lib/deployment/installation/stage-input.js +169 -0
  35. package/lib/deployment/installation/stage-output.js +53 -0
  36. package/lib/deployment/installation/workspace-env.js +76 -0
  37. package/lib/deployment/installation/workspace-files.js +178 -0
  38. package/lib/deployment/installation/workspace-schema.js +40 -0
  39. package/lib/deployment/installation/workspace-secrets.js +66 -0
  40. package/lib/deployment/installation/workspace.js +128 -0
  41. package/lib/deployment/marketplace-environment.js +305 -0
  42. package/lib/external-system/deploy.js +46 -51
  43. package/lib/generator/builders.js +4 -0
  44. package/lib/generator/helpers.js +55 -99
  45. package/lib/generator/index.js +0 -4
  46. package/lib/generator/split-variables.js +11 -5
  47. package/lib/internal/fs-real-sync.js +29 -0
  48. package/lib/programmatic/builder-api-request.js +8 -4
  49. package/lib/programmatic/capability-registry.js +2 -0
  50. package/lib/programmatic/get-deploy-status.js +45 -0
  51. package/lib/programmatic/get-upload-status.js +45 -0
  52. package/lib/programmatic/http-accepted-response.js +31 -0
  53. package/lib/programmatic/openapi-descriptions.js +14 -4
  54. package/lib/programmatic/request-timeout.js +21 -15
  55. package/lib/programmatic/route-handler-map.js +6 -0
  56. package/lib/programmatic/run-deploy.js +7 -3
  57. package/lib/programmatic/run-upload.js +11 -7
  58. package/lib/role-assistant/package-validate.js +4 -0
  59. package/lib/role-assistant/test-runner-clarification-resubmit.js +149 -0
  60. package/lib/role-assistant/test-runner.js +14 -17
  61. package/lib/schema/application-schema.json +20 -2
  62. package/lib/schema/infra-parameter.schema.json +7 -0
  63. package/lib/schema/infra.parameter.yaml +32 -43
  64. package/lib/schema/infrastructure-schema.json +1480 -399
  65. package/lib/schema/installation-environment.schema.json +601 -0
  66. package/lib/utils/cli-utils.js +3 -2
  67. package/lib/utils/qualification-poll-ui.js +136 -0
  68. package/lib/utils/variable-transformer.js +3 -0
  69. package/lib/validation/required-variables.js +41 -0
  70. package/lib/validation/validator.js +8 -5
  71. package/package.json +7 -2
  72. package/templates/README.md +4 -0
  73. package/templates/agent-kit/instructions/AGENTKIT.md +6 -6
  74. package/templates/agent-kit/skills/shared/hosts.md +10 -10
  75. package/templates/applications/builder-api/env.template +4 -0
  76. package/templates/applications/dataplane/application.yaml +1 -1
  77. package/templates/applications/dataplane/env.template +1 -1
  78. package/templates/applications/keycloak/Dockerfile +12 -5
  79. package/templates/applications/keycloak/application.yaml +2 -2
  80. package/templates/applications/keycloak/cimd-lenient-parse/AbstractClientIdMetadataDocumentExecutor.java +3 -3
  81. package/templates/applications/keycloak/cimd-lenient-parse/SOURCE.md +6 -6
  82. package/templates/applications/keycloak/env.template +13 -1
  83. package/templates/applications/miso-controller/application.yaml +3 -2
  84. package/templates/applications/miso-controller/env.template +28 -22
  85. package/templates/marketplace/createUiDefinition.json +77 -0
  86. package/templates/marketplace/install.parameters.example.json +9 -0
  87. package/templates/marketplace/main.json +4087 -0
  88. package/lib/generator/deploy-manifest-azure-kv.js +0 -81
@@ -23,7 +23,10 @@ function shouldUseDeployPollSpinner() {
23
23
  * @returns {{ onPollProgress: Function, finish: () => void }}
24
24
  */
25
25
  function createDeployPollHandlers(maxAttempts, options = {}) {
26
+ void maxAttempts;
26
27
  const silent = options && options.silent === true;
28
+ const startedAt = Date.now();
29
+ const elapsedMs = () => Date.now() - startedAt;
27
30
  const noop = () => {};
28
31
  if (silent) {
29
32
  return {
@@ -49,11 +52,11 @@ function createDeployPollHandlers(maxAttempts, options = {}) {
49
52
  setText(text) {
50
53
  ensureSpinner(text);
51
54
  },
52
- onPollProgress: (deploymentData, attempt, maxA) => {
53
- ensureSpinner(buildDeployPollSpinnerText(deploymentData, attempt, maxA));
55
+ onPollProgress: (deploymentData) => {
56
+ ensureSpinner(buildDeployPollSpinnerText(deploymentData, elapsedMs()));
54
57
  },
55
- onPollWait: (attempt, maxA) => {
56
- ensureSpinner(buildDeployPollWaitText(attempt, maxA));
58
+ onPollWait: () => {
59
+ ensureSpinner(buildDeployPollWaitText(elapsedMs()));
57
60
  },
58
61
  finish: () => {
59
62
  if (spinner) {
@@ -64,13 +67,14 @@ function createDeployPollHandlers(maxAttempts, options = {}) {
64
67
  }
65
68
  return {
66
69
  setText: noop,
67
- onPollProgress: (deploymentData, attempt, maxA) => {
70
+ onPollProgress: (deploymentData) => {
68
71
  const status = deploymentData?.status ?? 'pending';
69
72
  const progress =
70
73
  deploymentData && Number.isFinite(Number(deploymentData.progress))
71
74
  ? Number(deploymentData.progress)
72
75
  : 0;
73
- logger.log(chalk.gray(`Status: ${status} (${progress}%) (attempt ${attempt + 1}/${maxA})`));
76
+ const elapsedSeconds = Math.max(0, Math.floor(elapsedMs() / 1000));
77
+ logger.log(chalk.gray(`Status: ${status} (${progress}%) — ${elapsedSeconds}s elapsed`));
74
78
  },
75
79
  onPollWait: noop,
76
80
  finish: noop
@@ -79,12 +83,12 @@ function createDeployPollHandlers(maxAttempts, options = {}) {
79
83
 
80
84
  /**
81
85
  * Plain progress while awaiting the next status poll (HTTP in flight).
82
- * @param {number} attempt
83
- * @param {number} maxAttempts
86
+ * @param {number} elapsedMs
84
87
  * @returns {string}
85
88
  */
86
- function buildDeployPollWaitText(attempt, maxAttempts) {
87
- return `Deploying application... Checking status (attempt ${attempt + 1}/${maxAttempts})`;
89
+ function buildDeployPollWaitText(elapsedMs) {
90
+ const elapsedSeconds = Math.max(0, Math.floor(Number(elapsedMs || 0) / 1000));
91
+ return `Deploying application — ${elapsedSeconds}s elapsed`;
88
92
  }
89
93
 
90
94
  /**
@@ -89,14 +89,14 @@ function logDeploymentProgress(deploymentData, attempt, maxAttempts) {
89
89
  /**
90
90
  * Single-line ora text for deploy polling (updates in place; same style as guided infra spinners).
91
91
  * @param {Object|null|undefined} deploymentData - Latest deployment payload (optional before first response)
92
- * @param {number} attempt - Zero-based attempt index
93
- * @param {number} maxAttempts - Max polling attempts
92
+ * @param {number} elapsedMs - Time elapsed since deployment polling began
94
93
  * @returns {string}
95
94
  */
96
- function buildDeployPollSpinnerText(deploymentData, attempt, maxAttempts) {
95
+ function buildDeployPollSpinnerText(deploymentData, elapsedMs) {
97
96
  const status = deploymentData?.status ?? 'pending';
98
97
  const progress = deploymentData && Number.isFinite(Number(deploymentData.progress)) ? Number(deploymentData.progress) : 0;
99
- return `Deploying application... Status: ${status} (${progress}%) (attempt ${attempt + 1}/${maxAttempts})`;
98
+ const elapsedSeconds = Math.max(0, Math.floor(Number(elapsedMs || 0) / 1000));
99
+ return `Deploying application — ${elapsedSeconds}s elapsed · ${status} (${progress}%)`;
100
100
  }
101
101
 
102
102
  /**
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview AZURE `--stage infra`: validates the workspace, resolves ACR and secrets, deploys
5
+ * the workspace Marketplace template snapshot, verifies managed-identity image pull and that both
6
+ * applications started, and records outputs. Mori registration and tenant activation stay with Miso.
7
+ */
8
+
9
+ // Real-sync adapter: installation workspace state must not disappear under Jest fs mocks.
10
+ const fs = require('../../internal/fs-real-sync');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const marketplace = require('../marketplace-environment');
14
+ const { normalizePreset } = require('../environment-config');
15
+ const { normalizeLicenseEdition, ONBOARDING_MODES } = require('./infra-catalog');
16
+ const { materializeWorkspace, saveInfra } = require('./workspace');
17
+ const { missingRequiredFields } = require('./infra-manifest');
18
+ const { buildArmParameters, applyAllowMyIp } = require('./infra-projection');
19
+ const { materializeWorkspaceSecrets } = require('./workspace-secrets');
20
+ const { mergeWorkspaceEnv } = require('./workspace-env');
21
+ const { resolvePublicIpv4 } = require('./public-ip');
22
+ const preflight = require('./azure-preflight');
23
+ const readiness = require('./azure-readiness');
24
+
25
+ const INFRA_SECRETS = Object.freeze(['ADMIN_PASSWORD', 'ENCRYPTION_KEY', 'MORI_CALLBACK_API_KEY']);
26
+
27
+ function missingInputError(file, fields) {
28
+ const error = new Error(
29
+ `Installation workspace needs input before infra can run. Set these fields in ${file}, then rerun:\n - ${fields.join('\n - ')}`
30
+ );
31
+ error.code = 'WORKSPACE_INPUT_REQUIRED';
32
+ return error;
33
+ }
34
+
35
+ function applyLicenseOptions(doc, input) {
36
+ const license = doc.parameters.licenseEdition;
37
+ if (input.licenseEdition !== undefined) {
38
+ doc.parameters.licenseEdition = { ...license, value: normalizeLicenseEdition(input.licenseEdition), source: 'cli' };
39
+ } else {
40
+ doc.parameters.licenseEdition = { ...license, value: normalizeLicenseEdition(license.value) };
41
+ }
42
+ if (!ONBOARDING_MODES.includes(doc.parameters.onboardingMode.value)) {
43
+ throw new Error(`parameters.onboardingMode.value must be one of ${ONBOARDING_MODES.join(', ')}`);
44
+ }
45
+ }
46
+
47
+ async function applyAccessOptions(doc, input, deps) {
48
+ if (input.preset !== undefined) {
49
+ doc.preset = { ...doc.preset, value: normalizePreset(input.preset), source: 'cli' };
50
+ }
51
+ applyLicenseOptions(doc, input);
52
+ const enabled = input.allowMyIp || doc.allowMyIp.value === true;
53
+ if (!enabled) {
54
+ applyAllowMyIp(doc, { enabled: false });
55
+ return;
56
+ }
57
+ const access = await (deps.resolvePublicIpv4 || resolvePublicIpv4)();
58
+ applyAllowMyIp(doc, { enabled: true, ...access });
59
+ }
60
+
61
+ function withTemporaryParameters(document, action) {
62
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aifabrix-arm-'));
63
+ const file = path.join(dir, 'parameters.json');
64
+ try {
65
+ fs.chmodSync(dir, 0o700);
66
+ fs.writeFileSync(file, JSON.stringify(document), { mode: 0o600 });
67
+ return action(file);
68
+ } finally {
69
+ fs.rmSync(dir, { recursive: true, force: true });
70
+ }
71
+ }
72
+
73
+ function recordDeployment(doc, record, now) {
74
+ doc.deployment = {
75
+ ...doc.deployment,
76
+ status: { ...doc.deployment.status, value: 'succeeded' },
77
+ name: record.deploymentName,
78
+ id: record.deploymentId,
79
+ correlationId: record.correlationId || null,
80
+ inputHash: record.inputHash,
81
+ completedAt: record.recordedAt || now().toISOString()
82
+ };
83
+ for (const name of Object.keys(doc.outputs)) {
84
+ doc.outputs[name].value = record.outputs[name] ?? null;
85
+ }
86
+ }
87
+
88
+ function deployTemplate(workspace, doc, context) {
89
+ const { document, inputHash } = buildArmParameters(doc, context);
90
+ return withTemporaryParameters(document, parametersFile => marketplace.deployInfra({
91
+ installId: doc.installId.value,
92
+ envKey: null,
93
+ subscription: doc.subscriptionId.value,
94
+ resourceGroup: doc.resourceGroup.value,
95
+ location: doc.parameters.location.value,
96
+ template: workspace.paths.template,
97
+ parameters: parametersFile,
98
+ templateHash: context.templateHash,
99
+ inputHash
100
+ }, context.az));
101
+ }
102
+
103
+ async function verifyAndRecordReadiness(workspace, doc, deps) {
104
+ readiness.verifyRuntimeImagePull(doc, deps.az);
105
+ await readiness.waitForApplications(doc, deps.probe || marketplace.probeHttps);
106
+ const controllerUrl = `${String(doc.outputs.webAppUrl.value).replace(/\/+$/, '')}/miso`;
107
+ doc.controllerUrl = { ...doc.controllerUrl, value: controllerUrl };
108
+ doc.tenantActivation.url = { ...doc.tenantActivation.url, value: controllerUrl };
109
+ saveInfra(workspace, doc);
110
+ mergeWorkspaceEnv(workspace.paths.env, { CONTROLLER_URL: controllerUrl }, { root: workspace.root, replaceable: ['CONTROLLER_URL'] });
111
+ return controllerUrl;
112
+ }
113
+
114
+ async function prepareInfra(workspace, input, deps, az) {
115
+ const { doc, templateHash } = materializeWorkspace(workspace, input, deps);
116
+ const missing = missingRequiredFields(doc);
117
+ if (missing.length > 0) throw missingInputError(workspace.paths.infra, missing);
118
+ preflight.assertActiveAccount(doc, az);
119
+ const acr = preflight.resolveAcr(doc, az);
120
+ preflight.assertDeploymentAuthority(doc, acr, az);
121
+ doc.parameters.acrLoginServer.value = acr.loginServer;
122
+ doc.parameters.acrResourceId.value = acr.id;
123
+ await applyAccessOptions(doc, input, deps);
124
+ saveInfra(workspace, doc);
125
+ return { doc, templateHash, acr };
126
+ }
127
+
128
+ function deployWithStatus(workspace, doc, context) {
129
+ doc.deployment.status = { ...doc.deployment.status, value: 'running' };
130
+ saveInfra(workspace, doc);
131
+ try {
132
+ return deployTemplate(workspace, doc, context);
133
+ } catch (error) {
134
+ doc.deployment.status = { ...doc.deployment.status, value: 'failed' };
135
+ saveInfra(workspace, doc);
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Runs the AZURE infra stage.
142
+ * @param {Object} workspace - Opened workspace
143
+ * @param {Object} input - Validated stage input
144
+ * @param {Object} [deps] - Injected dependencies (az, probe, resolvePublicIpv4, secretsApi, now, templatePath)
145
+ * @returns {Promise<{ doc: Object, controllerUrl: string, activationUrl: string }>}
146
+ */
147
+ async function runAzureInfraStage(workspace, input, deps = {}) {
148
+ const context = { az: deps.az || marketplace.runAz, now: deps.now || (() => new Date()), probe: deps.probe };
149
+ const { doc, templateHash, acr } = await prepareInfra(workspace, input, deps, context.az);
150
+ const env = await materializeWorkspaceSecrets({
151
+ vendor: 'AZURE', envPath: workspace.paths.env, root: workspace.root, names: INFRA_SECRETS, secretsApi: deps.secretsApi
152
+ });
153
+ const record = deployWithStatus(workspace, doc, { env, acr, templateHash, az: context.az });
154
+ recordDeployment(doc, record, context.now);
155
+ saveInfra(workspace, doc);
156
+ const controllerUrl = await verifyAndRecordReadiness(workspace, doc, context);
157
+ return { doc, controllerUrl, activationUrl: doc.tenantActivation.url.value };
158
+ }
159
+
160
+ module.exports = { runAzureInfraStage, INFRA_SECRETS };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview AZURE infra preflight before any Azure mutation: active account equals the
5
+ * workspace subscription/tenant, `acrName` resolves inside that subscription, and the caller can
6
+ * create deployments, role assignments and the registry AcrPull grant. Uses only the operator's Azure CLI session; no tokens
7
+ * are stored.
8
+ */
9
+
10
+ const ROLE_ASSIGNMENT_WRITE = 'Microsoft.Authorization/roleAssignments/write';
11
+ const DEPLOYMENT_WRITE = 'Microsoft.Resources/deployments/write';
12
+ const PERMISSIONS_API = '2022-04-01';
13
+
14
+ function parseJson(text, label) {
15
+ try {
16
+ return JSON.parse(text);
17
+ } catch (_) {
18
+ throw new Error(`Azure CLI returned an unreadable ${label} response`);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * @param {Object} doc - Infra manifest
24
+ * @param {Function} az - Azure CLI runner
25
+ * @throws {Error} When the active account differs from the workspace target
26
+ */
27
+ function assertActiveAccount(doc, az) {
28
+ const account = parseJson(az(['account', 'show', '--output', 'json']), 'account');
29
+ const expectedSub = String(doc.subscriptionId.value).toLowerCase();
30
+ const expectedTenant = String(doc.tenantId.value).toLowerCase();
31
+ if (String(account.id).toLowerCase() !== expectedSub || String(account.tenantId).toLowerCase() !== expectedTenant) {
32
+ throw new Error(
33
+ `Active Azure account (subscription ${account.id}, tenant ${account.tenantId}) does not match the workspace ` +
34
+ `(subscription ${doc.subscriptionId.value}, tenant ${doc.tenantId.value}). ` +
35
+ `Run "az login --tenant ${doc.tenantId.value}" and "az account set --subscription ${doc.subscriptionId.value}".`
36
+ );
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Resolves `acrName` to its resource ID and login server within the workspace subscription.
42
+ * @param {Object} doc - Infra manifest
43
+ * @param {Function} az - Azure CLI runner
44
+ * @returns {{ id: string, loginServer: string }}
45
+ */
46
+ function resolveAcr(doc, az) {
47
+ const subscription = String(doc.subscriptionId.value);
48
+ const acr = parseJson(az([
49
+ 'acr', 'show', '--name', doc.acrName.value, '--subscription', subscription, '--output', 'json'
50
+ ]), 'registry');
51
+ if (typeof acr.id !== 'string' || typeof acr.loginServer !== 'string' || !acr.loginServer) {
52
+ throw new Error(`Registry ${doc.acrName.value} did not return a resource ID and login server`);
53
+ }
54
+ if (!acr.id.toLowerCase().startsWith(`/subscriptions/${subscription.toLowerCase()}/`)) {
55
+ throw new Error(`Registry ${doc.acrName.value} is outside the workspace subscription; use a registry in ${subscription}`);
56
+ }
57
+ return { id: acr.id, loginServer: acr.loginServer };
58
+ }
59
+
60
+ function actionMatches(pattern, action) {
61
+ const expression = new RegExp(`^${String(pattern).split('*').map(part => part.replace(/[.+?^${}()|[\]\\/]/g, '\\$&')).join('.*')}$`, 'i');
62
+ return expression.test(action);
63
+ }
64
+
65
+ /**
66
+ * True when Azure RBAC grants `action` at `scope` to the signed-in caller.
67
+ * @param {Function} az - Azure CLI runner
68
+ * @param {string} scope - Resource scope ID
69
+ * @param {string} action - Control-plane action
70
+ * @returns {boolean}
71
+ */
72
+ function hasPermission(az, scope, action) {
73
+ const result = parseJson(az([
74
+ 'rest', '--method', 'get',
75
+ '--url', `https://management.azure.com${scope}/providers/Microsoft.Authorization/permissions?api-version=${PERMISSIONS_API}`,
76
+ '--output', 'json'
77
+ ]), 'permissions');
78
+ return (result.value || []).some(permission =>
79
+ (permission.actions || []).some(pattern => actionMatches(pattern, action)) &&
80
+ !(permission.notActions || []).some(pattern => actionMatches(pattern, action)));
81
+ }
82
+
83
+ /**
84
+ * @param {Object} doc - Infra manifest
85
+ * @param {{ id: string }} acr - Resolved registry
86
+ * @param {Function} az - Azure CLI runner
87
+ * @throws {Error} When deployment, role-assignment or AcrPull grant authority is missing
88
+ */
89
+ function assertDeploymentAuthority(doc, acr, az) {
90
+ const subscriptionScope = `/subscriptions/${doc.subscriptionId.value}`;
91
+ if (!hasPermission(az, subscriptionScope, DEPLOYMENT_WRITE)) {
92
+ throw new Error(`The signed-in Azure account cannot create deployments in subscription ${doc.subscriptionId.value}`);
93
+ }
94
+ if (doc.parameters.deployRoleAssignments.value !== false && !hasPermission(az, subscriptionScope, ROLE_ASSIGNMENT_WRITE)) {
95
+ throw new Error(`The signed-in Azure account cannot create role assignments in subscription ${doc.subscriptionId.value}; ask for Owner or User Access Administrator, or set parameters.deployRoleAssignments.value to false when roles already exist`);
96
+ }
97
+ if (!hasPermission(az, acr.id, ROLE_ASSIGNMENT_WRITE)) {
98
+ throw new Error(`The signed-in Azure account cannot assign AcrPull on registry ${doc.acrName.value}; ask for Owner or User Access Administrator on that registry`);
99
+ }
100
+ }
101
+
102
+ module.exports = { assertActiveAccount, resolveAcr, hasPermission, actionMatches, assertDeploymentAuthority };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview AZURE infra readiness evidence: both web app identities hold AcrPull on the
5
+ * workspace registry with managed-identity image pull enabled, and Miso and Keycloak answer over
6
+ * HTTPS. Local `az acr login` success is never treated as readiness.
7
+ */
8
+
9
+ const APP_OUTPUTS = Object.freeze([
10
+ { name: 'webAppName', label: 'Miso' },
11
+ { name: 'keycloakWebAppName', label: 'Keycloak' }
12
+ ]);
13
+
14
+ function scoped(args, doc) {
15
+ return [...args, '--subscription', doc.subscriptionId.value];
16
+ }
17
+
18
+ function verifyAppImagePull(doc, app, acrId, az) {
19
+ const group = doc.resourceGroup.value;
20
+ const principalId = az(scoped([
21
+ 'webapp', 'identity', 'show', '--resource-group', group, '--name', app.value,
22
+ '--query', 'principalId', '--output', 'tsv'
23
+ ], doc));
24
+ if (!principalId) throw new Error(`${app.label} web app has no system-assigned identity`);
25
+ const assignments = az(scoped([
26
+ 'role', 'assignment', 'list', '--scope', acrId, '--role', 'AcrPull',
27
+ '--query', `length([?principalId=='${principalId}'])`, '--output', 'tsv'
28
+ ], doc));
29
+ if (!(Number(assignments) > 0)) {
30
+ throw new Error(`${app.label} web app identity has no AcrPull assignment on the workspace registry`);
31
+ }
32
+ const managedPull = az(scoped([
33
+ 'webapp', 'config', 'show', '--resource-group', group, '--name', app.value,
34
+ '--query', 'acrUseManagedIdentityCreds', '--output', 'tsv'
35
+ ], doc));
36
+ if (String(managedPull).toLowerCase() !== 'true') {
37
+ throw new Error(`${app.label} web app does not pull images with its managed identity`);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Verifies persistent managed-identity image-pull authorization for both web apps.
43
+ * @param {Object} doc - Infra manifest with outputs
44
+ * @param {Function} az - Azure CLI runner
45
+ */
46
+ function verifyRuntimeImagePull(doc, az) {
47
+ const acrId = doc.parameters.acrResourceId.value;
48
+ for (const { name, label } of APP_OUTPUTS) {
49
+ const value = doc.outputs[name].value;
50
+ if (!value) throw new Error(`ARM output ${name} is missing; cannot verify ${label} image pull`);
51
+ verifyAppImagePull(doc, { value, label }, acrId, az);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Waits for Miso and Keycloak to start after pulling their images.
57
+ * @param {Object} doc - Infra manifest with outputs
58
+ * @param {Function} probe - probeHttps(baseUrl, path, label)
59
+ * @returns {Promise<void>}
60
+ */
61
+ async function waitForApplications(doc, probe) {
62
+ await probe(doc.outputs.webAppUrl.value, '/api/v1/health/readiness', 'Miso');
63
+ const realmUrl = doc.outputs.keycloakRealmUrl.value;
64
+ if (typeof realmUrl !== 'string' || !realmUrl.startsWith('https://')) {
65
+ throw new Error('ARM output keycloakRealmUrl is not a valid HTTPS URL');
66
+ }
67
+ const parsed = new URL(realmUrl);
68
+ await probe(parsed.origin, parsed.pathname, 'Keycloak');
69
+ }
70
+
71
+ module.exports = { verifyRuntimeImagePull, waitForApplications };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview DOCKER `--stage register`: one idempotent Mori bootstrap request. The one-time
5
+ * Service API Key is written only to the local user secrets file (never the shared store or a
6
+ * remote secrets API) and then materialized into the ignored workspace `.env`.
7
+ */
8
+
9
+ const { materializeWorkspace, saveInfra } = require('./workspace');
10
+ const { missingRequiredFields } = require('./infra-manifest');
11
+ const { MORI_SERVICE_KEY } = require('./infra-catalog');
12
+ const { materializeWorkspaceSecrets } = require('./workspace-secrets');
13
+ const { assertIgnoredSecretFile } = require('./workspace-files');
14
+
15
+ const LOST_KEY_GUIDANCE =
16
+ `The one-time Mori installation key is not in local secrets (${MORI_SERVICE_KEY}). ` +
17
+ 'Mori issues it only once, so Builder will not request another. Restore it with ' +
18
+ `"aifabrix secret set ${MORI_SERVICE_KEY} <value>" or ask the Mori administrator to rotate the installation key.`;
19
+
20
+ function defaultDeps() {
21
+ const { bootstrapMarketplaceInstallation } = require('../../api/marketplace-bootstrap.api');
22
+ const { saveLocalSecret } = require('../../utils/local-secrets');
23
+ const { loadSecrets } = require('../../core/secrets');
24
+ return { bootstrap: bootstrapMarketplaceInstallation, saveLocalSecret, loadSecrets };
25
+ }
26
+
27
+ function setStatus(doc, value, evidence) {
28
+ doc.registration.status = { ...doc.registration.status, value };
29
+ if (evidence !== undefined) doc.registration.evidence = evidence;
30
+ }
31
+
32
+ async function hasLocalKey(deps) {
33
+ const secrets = (await deps.loadSecrets()) || {};
34
+ const value = secrets[MORI_SERVICE_KEY];
35
+ return typeof value === 'string' && value.trim() !== '' && !value.startsWith('kv://');
36
+ }
37
+
38
+ function bootstrapBody(doc) {
39
+ return {
40
+ subscriptionId: doc.subscriptionId.value,
41
+ tenantId: doc.tenantId.value,
42
+ resourceGroupName: doc.resourceGroup.value,
43
+ cloudVendor: 'DOCKER',
44
+ marketplaceSubscriptionId: doc.marketplaceSubscriptionId.value || ''
45
+ };
46
+ }
47
+
48
+ function evidenceFrom(data) {
49
+ return {
50
+ apiKeyId: data.apiKeyId || null,
51
+ keyPrefix: data.keyPrefix || null,
52
+ createdAt: data.createdAt || null,
53
+ installation: data.installation || null,
54
+ entitlement: data.entitlement || null
55
+ };
56
+ }
57
+
58
+ async function requestRegistration(workspace, doc, deps) {
59
+ setStatus(doc, 'requested');
60
+ saveInfra(workspace, doc);
61
+ const response = await deps.bootstrap(doc.moriBaseUrl.value.replace(/\/+$/, ''), bootstrapBody(doc));
62
+ if (!response || !response.success) {
63
+ const status = response?.status;
64
+ setStatus(doc, status === 409 ? 'conflict' : 'not-registered');
65
+ saveInfra(workspace, doc);
66
+ if (status === 409) throw new Error(`Mori reports this installation is already registered. ${LOST_KEY_GUIDANCE}`);
67
+ throw new Error(`Mori registration failed${status ? ` (HTTP ${status})` : ' (network error)'}; no key was issued. Rerun when Mori is reachable.`);
68
+ }
69
+ const data = response.data?.data || response.data || {};
70
+ if (typeof data.apiKey !== 'string' || data.apiKey === '') {
71
+ setStatus(doc, 'failed');
72
+ saveInfra(workspace, doc);
73
+ throw new Error('Mori registration returned no installation key; contact the Mori administrator before retrying');
74
+ }
75
+ await deps.saveLocalSecret(MORI_SERVICE_KEY, data.apiKey);
76
+ setStatus(doc, 'registered', evidenceFrom(data));
77
+ saveInfra(workspace, doc);
78
+ }
79
+
80
+ /**
81
+ * Runs DOCKER registration.
82
+ * @param {Object} workspace - Opened workspace
83
+ * @param {Object} input - Validated stage input
84
+ * @param {Object} [overrides] - Injected dependencies (bootstrap, saveLocalSecret, loadSecrets, secretsApi, templatePath)
85
+ * @returns {Promise<{ doc: Object, alreadyRegistered: boolean }>}
86
+ */
87
+ async function runDockerRegisterStage(workspace, input, overrides = {}) {
88
+ const deps = { ...defaultDeps(), ...overrides };
89
+ const { doc } = materializeWorkspace(workspace, input, overrides);
90
+ const missing = missingRequiredFields(doc);
91
+ if (missing.length > 0) {
92
+ throw new Error(`Set these fields in ${workspace.paths.infra}, then rerun:\n - ${missing.join('\n - ')}`);
93
+ }
94
+ assertIgnoredSecretFile(workspace.paths.env, workspace.root);
95
+ const status = doc.registration.status.value;
96
+ const alreadyRegistered = status === 'registered';
97
+ if (['registered', 'requested', 'conflict'].includes(status)) {
98
+ if (!(await hasLocalKey(deps))) throw new Error(LOST_KEY_GUIDANCE);
99
+ if (!alreadyRegistered) {
100
+ setStatus(doc, 'registered');
101
+ saveInfra(workspace, doc);
102
+ }
103
+ } else {
104
+ await requestRegistration(workspace, doc, deps);
105
+ }
106
+ await materializeWorkspaceSecrets({
107
+ vendor: 'DOCKER', envPath: workspace.paths.env, root: workspace.root, secretsApi: overrides.secretsApi
108
+ });
109
+ return { doc, alreadyRegistered };
110
+ }
111
+
112
+ module.exports = { runDockerRegisterStage, LOST_KEY_GUIDANCE };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Controller URL and Bearer authentication for dev/tst/pro workspace stages.
5
+ * Order: explicit `--miso-api-key-env` selector, `MISO_API_KEY` from the process, the workspace
6
+ * `.env`/secret system, then an existing Controller login. Key values never appear in output.
7
+ */
8
+
9
+ const { validateControllerUrl } = require('../../utils/deployment-validation');
10
+ const { readWorkspaceEnv } = require('./workspace-env');
11
+ const { materializeWorkspaceSecrets } = require('./workspace-secrets');
12
+
13
+ /**
14
+ * Resolves the Controller URL for an environment stage and persists an explicit override.
15
+ * @param {Object} params - Inputs
16
+ * @param {Object} params.envDoc - Environment document (mutated on override)
17
+ * @param {Object} params.infra - Infra manifest
18
+ * @param {Object.<string,string>} params.env - Workspace .env values
19
+ * @param {string} [params.override] - Validated --controller-url
20
+ * @returns {string} Controller URL
21
+ */
22
+ function resolveControllerUrl({ envDoc, infra, env, override }) {
23
+ if (override) {
24
+ envDoc.controllerUrl = { ...envDoc.controllerUrl, value: override, source: 'cli' };
25
+ }
26
+ const candidate = envDoc.controllerUrl.value || env.CONTROLLER_URL || infra.controllerUrl?.value;
27
+ if (!candidate) {
28
+ throw new Error('No Controller URL is recorded for this installation. Run --stage infra first or pass --controller-url https://<host>/miso.');
29
+ }
30
+ return validateControllerUrl(candidate).replace(/\/+$/, '');
31
+ }
32
+
33
+ async function workspaceApiKey(workspace, secretsApi) {
34
+ const existing = readWorkspaceEnv(workspace.paths.env).MISO_API_KEY;
35
+ if (existing) return existing;
36
+ const resolved = await materializeWorkspaceSecrets({
37
+ vendor: 'AZURE', envPath: workspace.paths.env, root: workspace.root, names: ['MISO_API_KEY'], secretsApi
38
+ });
39
+ return resolved.MISO_API_KEY;
40
+ }
41
+
42
+ /**
43
+ * Resolves Bearer authentication for Miso environment calls.
44
+ * @param {Object} params - Inputs
45
+ * @param {Object} params.workspace - Opened workspace
46
+ * @param {string} params.controllerUrl - Controller URL
47
+ * @param {string} [params.keyEnvName] - Explicit environment-variable selector
48
+ * @param {Object} [params.deps] - Injected getDeviceToken/secretsApi/env
49
+ * @returns {Promise<{ authConfig: Object, source: string }>}
50
+ */
51
+ async function resolveEnvironmentAuth({ workspace, controllerUrl, keyEnvName, deps = {} }) {
52
+ const processEnv = deps.env || process.env;
53
+ if (keyEnvName) {
54
+ const value = processEnv[keyEnvName];
55
+ if (!value) throw new Error(`Environment variable ${keyEnvName} selected by --miso-api-key-env is not set`);
56
+ return { authConfig: { type: 'bearer', token: value }, source: 'selector' };
57
+ }
58
+ if (processEnv.MISO_API_KEY) {
59
+ return { authConfig: { type: 'bearer', token: processEnv.MISO_API_KEY }, source: 'process' };
60
+ }
61
+ const fromWorkspace = await workspaceApiKey(workspace, deps.secretsApi);
62
+ if (fromWorkspace) {
63
+ return { authConfig: { type: 'bearer', token: fromWorkspace }, source: 'workspace' };
64
+ }
65
+ const getDeviceToken = deps.getDeviceToken || require('../../utils/token-manager').getOrRefreshDeviceToken;
66
+ const device = await getDeviceToken(controllerUrl);
67
+ if (device && device.token) {
68
+ return { authConfig: { type: 'bearer', token: device.token, controller: controllerUrl }, source: 'login' };
69
+ }
70
+ throw new Error(
71
+ `No Controller authentication for ${controllerUrl}. Run "aifabrix login -c ${controllerUrl}", ` +
72
+ 'or set MISO_API_KEY (or --miso-api-key-env <NAME>) to a temporary installation key.'
73
+ );
74
+ }
75
+
76
+ module.exports = { resolveControllerUrl, resolveEnvironmentAuth };