@aifabrix/builder 2.57.0 → 2.59.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 (106) hide show
  1. package/lib/agent-kit/codex-app-server.js +44 -0
  2. package/lib/agent-kit/init.js +4 -17
  3. package/lib/agent-kit/session-report.js +15 -10
  4. package/lib/agent-kit/setup-auth.js +131 -0
  5. package/lib/agent-kit/setup-context.js +118 -0
  6. package/lib/agent-kit/setup-github.js +70 -0
  7. package/lib/agent-kit/setup-managed.js +79 -0
  8. package/lib/agent-kit/setup-process.js +42 -0
  9. package/lib/agent-kit/setup-session.js +53 -0
  10. package/lib/agent-kit/setup-state.js +52 -0
  11. package/lib/agent-kit/setup-workspace.js +113 -0
  12. package/lib/agent-kit/setup.js +84 -0
  13. package/lib/agent-kit/start.js +4 -3
  14. package/lib/agent-kit/tmux-sessions.js +20 -3
  15. package/lib/api/dev.api.js +1 -1
  16. package/lib/api/miso-health.api.js +26 -0
  17. package/lib/api/types/dev.types.js +3 -3
  18. package/lib/app/deploy.js +0 -102
  19. package/lib/app/push.js +98 -41
  20. package/lib/build/standard-docker-build.js +13 -11
  21. package/lib/cli/index.js +4 -0
  22. package/lib/cli/setup-app.js +16 -5
  23. package/lib/cli/setup-dev-path-commands.js +53 -2
  24. package/lib/cli/setup-dev.js +7 -4
  25. package/lib/cli/setup-environment.js +17 -4
  26. package/lib/cli/setup-help.js +86 -0
  27. package/lib/cli/setup-onboarding.js +37 -0
  28. package/lib/cli/setup-utility-repair.js +7 -5
  29. package/lib/commands/agent-kit-help.js +21 -7
  30. package/lib/commands/agent-kit-sessions.js +126 -0
  31. package/lib/commands/agent-kit-setup.js +44 -0
  32. package/lib/commands/agent-kit.js +5 -79
  33. package/lib/commands/dev-init.js +21 -23
  34. package/lib/commands/dev-refresh.js +2 -2
  35. package/lib/commands/dev-registry.js +155 -0
  36. package/lib/commands/dev-show-display.js +10 -2
  37. package/lib/commands/governance-verify-external.js +12 -5
  38. package/lib/commands/repair-datasource.js +9 -9
  39. package/lib/commands/repair.js +45 -8
  40. package/lib/commands/setup-image-refresh.js +26 -0
  41. package/lib/commands/upload-qualification-client.js +56 -11
  42. package/lib/commands/upload-qualification-poll.js +51 -22
  43. package/lib/commands/wizard-dataplane.js +16 -52
  44. package/lib/core/config-attach-extensions.js +4 -1
  45. package/lib/datasource/datasource-validate-summary.js +8 -5
  46. package/lib/datasource/sync-defaults.js +53 -0
  47. package/lib/deployment/installation/azure-infra-stage.js +50 -9
  48. package/lib/deployment/installation/azure-preflight.js +63 -7
  49. package/lib/deployment/installation/azure-readiness.js +59 -7
  50. package/lib/deployment/installation/environment-auth.js +60 -20
  51. package/lib/deployment/installation/environment-stage.js +9 -5
  52. package/lib/deployment/installation/infra-catalog.js +57 -5
  53. package/lib/deployment/installation/infra-manifest.js +22 -2
  54. package/lib/deployment/installation/infra-projection.js +14 -3
  55. package/lib/deployment/installation/registry-auth-mode.js +121 -0
  56. package/lib/deployment/installation/stage-input.js +21 -6
  57. package/lib/deployment/marketplace-environment.js +26 -1
  58. package/lib/generator/builders.js +8 -9
  59. package/lib/generator/index.js +23 -12
  60. package/lib/integration-definition/apply.js +2 -2
  61. package/lib/internal/fs-real-sync.js +4 -1
  62. package/lib/programmatic/bearer-auth.js +3 -62
  63. package/lib/programmatic/builder-api-startup.js +51 -0
  64. package/lib/programmatic/builder-miso-runtime.js +167 -0
  65. package/lib/programmatic/openapi-descriptions.js +3 -3
  66. package/lib/programmatic/openapi-schema-descriptions-core.js +3 -3
  67. package/lib/programmatic/openapi-spec.js +2 -1
  68. package/lib/programmatic/route-handler-map.js +3 -2
  69. package/lib/schema/environment-deploy-request.schema.json +1 -1
  70. package/lib/schema/external-datasource.schema.json +20 -5
  71. package/lib/schema/infra-parameter.schema.json +1 -1
  72. package/lib/schema/infra.parameter.yaml +2 -1
  73. package/lib/schema/infrastructure-schema.json +200 -3
  74. package/lib/schema/installation-environment.schema.json +1 -1
  75. package/lib/schema/wizard-config.schema.json +1 -1
  76. package/lib/utils/app-register-config.js +2 -4
  77. package/lib/utils/build-resolve-image.js +2 -21
  78. package/lib/utils/cli-utils.js +8 -1
  79. package/lib/utils/config-paths.js +24 -29
  80. package/lib/utils/config-registry-preference.js +113 -0
  81. package/lib/utils/dev-init-ssh-merge.js +5 -4
  82. package/lib/utils/dev-user-groups.js +2 -1
  83. package/lib/utils/docker-build.js +53 -19
  84. package/lib/utils/docker-daemon-tls-ca.js +2 -2
  85. package/lib/utils/env-map.js +23 -6
  86. package/lib/utils/error-formatters/validation-errors.js +25 -5
  87. package/lib/utils/help-builder.js +4 -3
  88. package/lib/utils/image-tags.js +88 -0
  89. package/lib/utils/promote-dev-docker-image.js +6 -0
  90. package/lib/utils/registry-credentials.js +236 -0
  91. package/lib/utils/resolve-docker-image-ref.js +78 -9
  92. package/lib/utils/secrets-helpers.js +9 -1
  93. package/lib/utils/token-manager.js +29 -36
  94. package/lib/utils/variable-transformer.js +2 -4
  95. package/package.json +4 -2
  96. package/templates/agent-kit/instructions/AGENTKIT.md +39 -2
  97. package/templates/agent-kit/instructions/root.AGENTS.md +40 -1
  98. package/templates/agent-kit/skills/shared/hosts.md +23 -5
  99. package/templates/agent-kit/workspace/README.md +14 -1
  100. package/templates/applications/builder-api/application.yaml +1 -1
  101. package/templates/applications/dataplane/application.yaml +1 -1
  102. package/templates/applications/dataplane/env.template +1 -1
  103. package/templates/applications/miso-controller/application.yaml +1 -1
  104. package/templates/applications/miso-controller/env.template +1 -1
  105. package/templates/external-system/external-datasource.yaml.hbs +7 -6
  106. package/templates/marketplace/main.json +440 -82
@@ -74,17 +74,20 @@ function summarizeSyncLine(sync) {
74
74
  if (!sync || typeof sync !== 'object') {
75
75
  return 'Not configured';
76
76
  }
77
- const parts = [];
77
+ const disabled = sync.enabled === false;
78
+ const parts = [disabled ? 'disabled' : sync.enabled === true ? 'enabled' : 'enabled (legacy default)'];
79
+ if (sync.schedule) {
80
+ parts.push(`${disabled ? 'retained schedule' : 'scheduled'} ${JSON.stringify(sync.schedule)}`);
81
+ } else {
82
+ parts.push('manual-only');
83
+ }
78
84
  if (sync.mode) {
79
85
  parts.push(String(sync.mode));
80
86
  }
81
87
  if (sync.batchSize !== undefined && sync.batchSize !== null) {
82
88
  parts.push(`batch size ${sync.batchSize}`);
83
89
  }
84
- if (sync.schedule) {
85
- parts.push(`schedule ${JSON.stringify(sync.schedule)}`);
86
- }
87
- return parts.length ? parts.join(', ') : 'Present';
90
+ return parts.join(', ');
88
91
  }
89
92
 
90
93
  /**
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Datasource synchronization defaults for newly authored sync blocks.
3
+ * @fileoverview Keeps default creation separate from preservation decisions.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const DEFAULT_DATASOURCE_SYNC = Object.freeze({
9
+ enabled: true,
10
+ mode: 'pull',
11
+ schedule: '@monthly',
12
+ batchSize: 500
13
+ });
14
+
15
+ const DATASOURCE_SYNC_MODES = new Set(['pull', 'push', 'bidirectional']);
16
+
17
+ /**
18
+ * @param {Record<string, unknown>} [overrides]
19
+ * @returns {Record<string, unknown>}
20
+ */
21
+ function createDefaultDatasourceSync(overrides = {}) {
22
+ return { ...DEFAULT_DATASOURCE_SYNC, ...overrides };
23
+ }
24
+
25
+ /**
26
+ * Resolve sync for a newly materialized BID datasource.
27
+ * An explicit datasource block is preserved exactly, including no schedule.
28
+ * @param {Record<string, unknown>} datasource
29
+ * @param {Record<string, unknown>} bidSync
30
+ * @returns {unknown}
31
+ */
32
+ function resolveAuthoredDatasourceSync(datasource, bidSync) {
33
+ if (Object.prototype.hasOwnProperty.call(datasource, 'sync')) {
34
+ const explicit = datasource.sync;
35
+ return explicit && typeof explicit === 'object' ? { ...explicit } : explicit;
36
+ }
37
+ const overrides = {};
38
+ for (const key of ['enabled', 'schedule', 'batchSize']) {
39
+ if (Object.prototype.hasOwnProperty.call(bidSync, key)) {
40
+ overrides[key] = bidSync[key];
41
+ }
42
+ }
43
+ if (DATASOURCE_SYNC_MODES.has(String(bidSync.mode))) {
44
+ overrides.mode = bidSync.mode;
45
+ }
46
+ return createDefaultDatasourceSync(overrides);
47
+ }
48
+
49
+ module.exports = {
50
+ DEFAULT_DATASOURCE_SYNC,
51
+ createDefaultDatasourceSync,
52
+ resolveAuthoredDatasourceSync
53
+ };
@@ -2,8 +2,9 @@
2
2
 
3
3
  /**
4
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.
5
+ * the workspace Marketplace template snapshot, verifies image pull (managed identity or registry
6
+ * credentials) and that both applications started, and records outputs. Mori registration and
7
+ * tenant activation stay with Miso. Registry credentials are never written to the workspace.
7
8
  */
8
9
 
9
10
  // Real-sync adapter: installation workspace state must not disappear under Jest fs mocks.
@@ -12,7 +13,7 @@ const os = require('os');
12
13
  const path = require('path');
13
14
  const marketplace = require('../marketplace-environment');
14
15
  const { normalizePreset } = require('../environment-config');
15
- const { normalizeLicenseEdition, ONBOARDING_MODES } = require('./infra-catalog');
16
+ const { normalizeLicenseEdition, normalizeAzureLocation, ONBOARDING_MODES } = require('./infra-catalog');
16
17
  const { materializeWorkspace, saveInfra } = require('./workspace');
17
18
  const { missingRequiredFields } = require('./infra-manifest');
18
19
  const { buildArmParameters, applyAllowMyIp } = require('./infra-projection');
@@ -21,6 +22,7 @@ const { mergeWorkspaceEnv } = require('./workspace-env');
21
22
  const { resolvePublicIpv4 } = require('./public-ip');
22
23
  const preflight = require('./azure-preflight');
23
24
  const readiness = require('./azure-readiness');
25
+ const registryAuth = require('./registry-auth-mode');
24
26
 
25
27
  const INFRA_SECRETS = Object.freeze(['ADMIN_PASSWORD', 'ENCRYPTION_KEY', 'MORI_CALLBACK_API_KEY']);
26
28
 
@@ -48,6 +50,16 @@ async function applyAccessOptions(doc, input, deps) {
48
50
  if (input.preset !== undefined) {
49
51
  doc.preset = { ...doc.preset, value: normalizePreset(input.preset), source: 'cli' };
50
52
  }
53
+ // The template sizes compute and PostgreSQL from capacityProfile, and the workspace already
54
+ // records a preset with the same values. Deriving one from the other keeps a single source:
55
+ // an installation cannot end up recording preset 'm' while deploying 's'.
56
+ if (doc.parameters.capacityProfile) {
57
+ doc.parameters.capacityProfile = {
58
+ ...doc.parameters.capacityProfile,
59
+ value: normalizePreset(doc.preset.value),
60
+ source: 'workspacePreset'
61
+ };
62
+ }
51
63
  applyLicenseOptions(doc, input);
52
64
  const enabled = input.allowMyIp || doc.allowMyIp.value === true;
53
65
  if (!enabled) {
@@ -111,18 +123,41 @@ async function verifyAndRecordReadiness(workspace, doc, deps) {
111
123
  return controllerUrl;
112
124
  }
113
125
 
126
+ function applyLocation(doc, input) {
127
+ if (input.location === undefined) return;
128
+ doc.parameters.location = { ...doc.parameters.location, value: normalizeAzureLocation(input.location), source: 'cli' };
129
+ }
130
+
131
+ async function resolvePullAuth(doc, acr, deps, az) {
132
+ const loadRegistryCredentials = deps.loadRegistryCredentials ||
133
+ require('../../utils/registry-credentials').loadRegistryCredentials;
134
+ const resolved = await registryAuth.resolveRegistryAuth(doc, acr, az, {
135
+ loadRegistryCredentials,
136
+ resolveAcrAdminCredentials: preflight.resolveAcrAdminCredentials
137
+ });
138
+ registryAuth.applyEffectiveAcrAuth(doc, resolved.mode);
139
+ return resolved.credentials;
140
+ }
141
+
114
142
  async function prepareInfra(workspace, input, deps, az) {
115
143
  const { doc, templateHash } = materializeWorkspace(workspace, input, deps);
144
+ applyLocation(doc, input);
116
145
  const missing = missingRequiredFields(doc);
117
- if (missing.length > 0) throw missingInputError(workspace.paths.infra, missing);
146
+ if (missing.length > 0) {
147
+ if (input.location !== undefined) saveInfra(workspace, doc);
148
+ throw missingInputError(workspace.paths.infra, missing);
149
+ }
150
+ registryAuth.applyRequestedAcrAuth(doc, input);
118
151
  preflight.assertActiveAccount(doc, az);
119
152
  const acr = preflight.resolveAcr(doc, az);
120
- preflight.assertDeploymentAuthority(doc, acr, az);
121
153
  doc.parameters.acrLoginServer.value = acr.loginServer;
122
154
  doc.parameters.acrResourceId.value = acr.id;
155
+ // Local pair first (a partial pair fails closed); Azure admin lookup only for explicit credentials.
156
+ const registryCredentials = await resolvePullAuth(doc, acr, deps, az);
157
+ preflight.assertDeploymentAuthority(doc, acr, az);
123
158
  await applyAccessOptions(doc, input, deps);
124
159
  saveInfra(workspace, doc);
125
- return { doc, templateHash, acr };
160
+ return { doc, templateHash, acr, registryCredentials };
126
161
  }
127
162
 
128
163
  function deployWithStatus(workspace, doc, context) {
@@ -141,17 +176,23 @@ function deployWithStatus(workspace, doc, context) {
141
176
  * Runs the AZURE infra stage.
142
177
  * @param {Object} workspace - Opened workspace
143
178
  * @param {Object} input - Validated stage input
144
- * @param {Object} [deps] - Injected dependencies (az, probe, resolvePublicIpv4, secretsApi, now, templatePath)
179
+ * @param {Object} [deps] - Injected dependencies (az, probe, resolvePublicIpv4, secretsApi, now, templatePath,
180
+ * loadRegistryCredentials)
145
181
  * @returns {Promise<{ doc: Object, controllerUrl: string, activationUrl: string }>}
146
182
  */
147
183
  async function runAzureInfraStage(workspace, input, deps = {}) {
148
184
  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);
185
+ const { doc, templateHash, acr, registryCredentials } = await prepareInfra(workspace, input, deps, context.az);
150
186
  const env = await materializeWorkspaceSecrets({
151
187
  vendor: 'AZURE', envPath: workspace.paths.env, root: workspace.root, names: INFRA_SECRETS, secretsApi: deps.secretsApi
152
188
  });
153
- const record = deployWithStatus(workspace, doc, { env, acr, templateHash, az: context.az });
189
+ const record = deployWithStatus(workspace, doc, {
190
+ env, acr, templateHash, az: context.az, registryCredentials
191
+ });
154
192
  recordDeployment(doc, record, context.now);
193
+ // Never persist registry credentials in the workspace manifest.
194
+ doc.parameters.acrAdminUsername = { ...doc.parameters.acrAdminUsername, value: '', source: 'managedIdentityMode' };
195
+ doc.parameters.acrAdminPassword = { ...doc.parameters.acrAdminPassword, value: '', source: 'managedIdentityMode' };
155
196
  saveInfra(workspace, doc);
156
197
  const controllerUrl = await verifyAndRecordReadiness(workspace, doc, context);
157
198
  return { doc, controllerUrl, activationUrl: doc.tenantActivation.url.value };
@@ -3,8 +3,8 @@
3
3
  /**
4
4
  * @fileoverview AZURE infra preflight before any Azure mutation: active account equals the
5
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.
6
+ * create deployments. Managed-identity mode also requires role-assignment and AcrPull authority;
7
+ * password mode fetches ACR admin credentials instead. Uses only the operator's Azure CLI session.
8
8
  */
9
9
 
10
10
  const ROLE_ASSIGNMENT_WRITE = 'Microsoft.Authorization/roleAssignments/write';
@@ -19,6 +19,14 @@ function parseJson(text, label) {
19
19
  }
20
20
  }
21
21
 
22
+ /**
23
+ * @param {Object} doc - Infra manifest
24
+ * @returns {boolean} True when image pull uses registry username/password (effective mode)
25
+ */
26
+ function isAdminAcrAuth(doc) {
27
+ return doc.acrAuth?.effective === 'credentials';
28
+ }
29
+
22
30
  /**
23
31
  * @param {Object} doc - Infra manifest
24
32
  * @param {Function} az - Azure CLI runner
@@ -41,7 +49,7 @@ function assertActiveAccount(doc, az) {
41
49
  * Resolves `acrName` to its resource ID and login server within the workspace subscription.
42
50
  * @param {Object} doc - Infra manifest
43
51
  * @param {Function} az - Azure CLI runner
44
- * @returns {{ id: string, loginServer: string }}
52
+ * @returns {{ id: string, loginServer: string, adminUserEnabled: boolean }}
45
53
  */
46
54
  function resolveAcr(doc, az) {
47
55
  const subscription = String(doc.subscriptionId.value);
@@ -54,7 +62,35 @@ function resolveAcr(doc, az) {
54
62
  if (!acr.id.toLowerCase().startsWith(`/subscriptions/${subscription.toLowerCase()}/`)) {
55
63
  throw new Error(`Registry ${doc.acrName.value} is outside the workspace subscription; use a registry in ${subscription}`);
56
64
  }
57
- return { id: acr.id, loginServer: acr.loginServer };
65
+ return { id: acr.id, loginServer: acr.loginServer, adminUserEnabled: acr.adminUserEnabled === true };
66
+ }
67
+
68
+ /**
69
+ * Loads ACR admin username/password for password registry auth. Never persists the password.
70
+ * @param {Object} doc - Infra manifest
71
+ * @param {{ adminUserEnabled?: boolean }} acr - Resolved registry metadata
72
+ * @param {Function} az - Azure CLI runner
73
+ * @returns {{ username: string, password: string }}
74
+ */
75
+ function resolveAcrAdminCredentials(doc, acr, az) {
76
+ if (acr.adminUserEnabled !== true) {
77
+ throw new Error(
78
+ `Registry ${doc.acrName.value} does not have the admin user enabled; enable admin user on the ACR ` +
79
+ 'or save a local pair with "aifabrix dev set-registry"'
80
+ );
81
+ }
82
+ const creds = parseJson(az([
83
+ 'acr', 'credential', 'show', '--name', doc.acrName.value,
84
+ '--subscription', String(doc.subscriptionId.value), '--output', 'json'
85
+ ]), 'registry credentials');
86
+ const username = typeof creds.username === 'string' ? creds.username : '';
87
+ const password = Array.isArray(creds.passwords)
88
+ ? (creds.passwords.find(item => item && item.value)?.value || '')
89
+ : '';
90
+ if (!username || !password) {
91
+ throw new Error(`Registry ${doc.acrName.value} did not return admin credentials; check ACR admin user access`);
92
+ }
93
+ return { username, password };
58
94
  }
59
95
 
60
96
  function actionMatches(pattern, action) {
@@ -84,19 +120,39 @@ function hasPermission(az, scope, action) {
84
120
  * @param {Object} doc - Infra manifest
85
121
  * @param {{ id: string }} acr - Resolved registry
86
122
  * @param {Function} az - Azure CLI runner
87
- * @throws {Error} When deployment, role-assignment or AcrPull grant authority is missing
123
+ * @throws {Error} When deployment or (mi) role-assignment authority is missing
88
124
  */
89
125
  function assertDeploymentAuthority(doc, acr, az) {
90
126
  const subscriptionScope = `/subscriptions/${doc.subscriptionId.value}`;
91
127
  if (!hasPermission(az, subscriptionScope, DEPLOYMENT_WRITE)) {
92
128
  throw new Error(`The signed-in Azure account cannot create deployments in subscription ${doc.subscriptionId.value}`);
93
129
  }
130
+ // Checked separately, because they are different permissions on different resources.
131
+ //
132
+ // Credential mode used to return here and skip both. That was wrong for the first one: the
133
+ // installation still needs Contributor granted in the resource group it is creating, and
134
+ // skipping the check hid the fact that the grant was being skipped too. The controller then
135
+ // could not create anything, and the failure surfaced three layers away as "Environment 'dev'
136
+ // has no recorded placement".
94
137
  if (doc.parameters.deployRoleAssignments.value !== false && !hasPermission(az, subscriptionScope, ROLE_ASSIGNMENT_WRITE)) {
95
138
  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
139
  }
140
+ // The AcrPull grant reaches into the registry's own resource group, which a customer
141
+ // installation cannot touch at all. Credential mode does not request it, so do not require
142
+ // permission for it.
143
+ if (isAdminAcrAuth(doc)) return;
144
+ if (doc.parameters.deployRegistryRoleAssignment?.value === false) return;
97
145
  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`);
146
+ 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, or use --acr-auth credentials with a pair saved by "aifabrix dev set-registry"`);
99
147
  }
100
148
  }
101
149
 
102
- module.exports = { assertActiveAccount, resolveAcr, hasPermission, actionMatches, assertDeploymentAuthority };
150
+ module.exports = {
151
+ assertActiveAccount,
152
+ resolveAcr,
153
+ resolveAcrAdminCredentials,
154
+ isAdminAcrAuth,
155
+ hasPermission,
156
+ actionMatches,
157
+ assertDeploymentAuthority
158
+ };
@@ -1,11 +1,15 @@
1
1
  'use strict';
2
2
 
3
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.
4
+ * @fileoverview AZURE infra readiness evidence: image pull authorization for both web apps, then
5
+ * HTTPS probes. Managed-identity mode requires AcrPull + acrUseManagedIdentityCreds; password mode
6
+ * requires registry credentials on the web app and acrUseManagedIdentityCreds false. Both modes
7
+ * then require the applications to start (proof that the image was pulled). Local
8
+ * `az acr login` success is never treated as readiness.
7
9
  */
8
10
 
11
+ const { isAdminAcrAuth } = require('./azure-preflight');
12
+
9
13
  const APP_OUTPUTS = Object.freeze([
10
14
  { name: 'webAppName', label: 'Miso' },
11
15
  { name: 'keycloakWebAppName', label: 'Keycloak' }
@@ -15,7 +19,7 @@ function scoped(args, doc) {
15
19
  return [...args, '--subscription', doc.subscriptionId.value];
16
20
  }
17
21
 
18
- function verifyAppImagePull(doc, app, acrId, az) {
22
+ function verifyManagedIdentityPull(doc, app, acrId, az) {
19
23
  const group = doc.resourceGroup.value;
20
24
  const principalId = az(scoped([
21
25
  'webapp', 'identity', 'show', '--resource-group', group, '--name', app.value,
@@ -38,8 +42,34 @@ function verifyAppImagePull(doc, app, acrId, az) {
38
42
  }
39
43
  }
40
44
 
45
+ function verifyPasswordPull(doc, app, az) {
46
+ const group = doc.resourceGroup.value;
47
+ const managedPull = az(scoped([
48
+ 'webapp', 'config', 'show', '--resource-group', group, '--name', app.value,
49
+ '--query', 'acrUseManagedIdentityCreds', '--output', 'tsv'
50
+ ], doc));
51
+ if (String(managedPull).toLowerCase() === 'true') {
52
+ throw new Error(`${app.label} web app still uses managed-identity image pull; expected registry username/password`);
53
+ }
54
+ const username = az(scoped([
55
+ 'webapp', 'config', 'appsettings', 'list', '--resource-group', group, '--name', app.value,
56
+ '--query', '[?name==\'DOCKER_REGISTRY_SERVER_USERNAME\'].value | [0]', '--output', 'tsv'
57
+ ], doc));
58
+ if (!username) {
59
+ throw new Error(`${app.label} web app is missing DOCKER_REGISTRY_SERVER_USERNAME for password registry auth`);
60
+ }
61
+ }
62
+
63
+ function verifyAppImagePull(doc, app, acrId, az) {
64
+ if (isAdminAcrAuth(doc)) {
65
+ verifyPasswordPull(doc, app, az);
66
+ return;
67
+ }
68
+ verifyManagedIdentityPull(doc, app, acrId, az);
69
+ }
70
+
41
71
  /**
42
- * Verifies persistent managed-identity image-pull authorization for both web apps.
72
+ * Verifies image-pull authorization for both web apps per acrAuth mode.
43
73
  * @param {Object} doc - Infra manifest with outputs
44
74
  * @param {Function} az - Azure CLI runner
45
75
  */
@@ -52,14 +82,36 @@ function verifyRuntimeImagePull(doc, az) {
52
82
  }
53
83
  }
54
84
 
85
+ /**
86
+ * Attempts allowed for Miso readiness, at 10-second intervals.
87
+ *
88
+ * Miso runs first-time onboarding inside startup, and on Azure that onboarding is normally
89
+ * interrupted once — the template applies post-Key-Vault app settings and restarts the site — so
90
+ * the retry runs too. A clean installation was measured taking 720 seconds to pass its warm-up
91
+ * probe, and ce-10 was failed at 300 by the old default of 30 attempts.
92
+ *
93
+ * Reporting `readiness did not pass` for a healthy installation is worse than waiting: it leaves
94
+ * `controllerUrl` unrecorded, and `--stage dev` then refuses with "Shared infrastructure is not
95
+ * deployed for this workspace" on infrastructure that is deployed and serving.
96
+ *
97
+ * 180 attempts is 30 minutes, matching WEBSITES_CONTAINER_START_TIME_LIMIT. Keycloak keeps the
98
+ * shorter default: it does not run onboarding, and it is already up by the time Miso answers.
99
+ */
100
+ const MISO_READINESS_ATTEMPTS = 180;
101
+
55
102
  /**
56
103
  * Waits for Miso and Keycloak to start after pulling their images.
57
104
  * @param {Object} doc - Infra manifest with outputs
58
- * @param {Function} probe - probeHttps(baseUrl, path, label)
105
+ * @param {Function} probe - probeHttps(baseUrl, path, label, attempts)
59
106
  * @returns {Promise<void>}
60
107
  */
61
108
  async function waitForApplications(doc, probe) {
62
- await probe(doc.outputs.webAppUrl.value, '/api/v1/health/readiness', 'Miso');
109
+ await probe(
110
+ doc.outputs.webAppUrl.value,
111
+ '/api/v1/health/readiness',
112
+ 'Miso',
113
+ MISO_READINESS_ATTEMPTS
114
+ );
63
115
  const realmUrl = doc.outputs.keycloakRealmUrl.value;
64
116
  if (typeof realmUrl !== 'string' || !realmUrl.startsWith('https://')) {
65
117
  throw new Error('ARM output keycloakRealmUrl is not a valid HTTPS URL');
@@ -2,8 +2,8 @@
2
2
 
3
3
  /**
4
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.
5
+ * Order: exclusive explicit selector, otherwise Controller login, process key, then
6
+ * workspace `.env`/secret system. Key values never appear in output.
7
7
  */
8
8
 
9
9
  const { validateControllerUrl } = require('../../utils/deployment-validation');
@@ -40,37 +40,77 @@ async function workspaceApiKey(workspace, secretsApi) {
40
40
  }
41
41
 
42
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 }>}
43
+ * Lazily yield credentials without resolving lower-priority secrets unnecessarily.
44
+ * @param {Object} params - Workspace, controller URL, selector and dependencies
45
+ * @yields {Object} Authentication candidate
50
46
  */
51
- async function resolveEnvironmentAuth({ workspace, controllerUrl, keyEnvName, deps = {} }) {
47
+ async function* environmentAuthCandidates({ workspace, controllerUrl, keyEnvName, deps = {} }) {
52
48
  const processEnv = deps.env || process.env;
53
49
  if (keyEnvName) {
54
50
  const value = processEnv[keyEnvName];
55
51
  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' };
52
+ yield { authConfig: { type: 'bearer', token: value }, source: 'selector' };
53
+ return;
54
+ }
55
+ const getDeviceToken = deps.getDeviceToken || require('../../utils/token-manager').getOrRefreshDeviceToken;
56
+ const device = await getDeviceToken(controllerUrl);
57
+ if (device?.token) {
58
+ yield { authConfig: { type: 'bearer', token: device.token, controller: controllerUrl }, source: 'login' };
57
59
  }
58
60
  if (processEnv.MISO_API_KEY) {
59
- return { authConfig: { type: 'bearer', token: processEnv.MISO_API_KEY }, source: 'process' };
61
+ yield { authConfig: { type: 'bearer', token: processEnv.MISO_API_KEY }, source: 'process' };
60
62
  }
61
63
  const fromWorkspace = await workspaceApiKey(workspace, deps.secretsApi);
62
- if (fromWorkspace) {
63
- return { authConfig: { type: 'bearer', token: fromWorkspace }, source: 'workspace' };
64
+ if (fromWorkspace) yield { authConfig: { type: 'bearer', token: fromWorkspace }, source: 'workspace' };
65
+ }
66
+
67
+ /**
68
+ * Normalize preflight failures without exposing server-echoed credentials.
69
+ * @param {Function} validateAuth - Initial read-only request
70
+ * @param {Object} auth - Candidate auth config
71
+ * @returns {Promise<Object>} API response (only 401 permits fallback)
72
+ */
73
+ async function validateCandidate(validateAuth, auth) {
74
+ let response;
75
+ try {
76
+ response = await validateAuth(auth);
77
+ } catch (error) {
78
+ const status = error?.status ?? error?.statusCode;
79
+ response = { success: false, status: Number.isInteger(status) ? status : undefined };
64
80
  }
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' };
81
+ if (response?.success === true || response?.status === 401) return response;
82
+ const status = Number.isInteger(response?.status) ? response.status : undefined;
83
+ const error = new Error(`Miso tenant status failed${status ? ` (HTTP ${status})` : ''}. Check Controller connectivity and permissions.`);
84
+ error.status = status;
85
+ throw error;
86
+ }
87
+
88
+ /**
89
+ * Resolve auth, optionally selecting it through the initial read-only preflight.
90
+ * @param {Object} params - Workspace, controllerUrl, keyEnvName and injected deps
91
+ * @param {Function} [params.validateAuth] - Read-only request returning an API response
92
+ * @returns {Promise<Object>} Selected auth/source and optional validationResponse
93
+ */
94
+ async function resolveEnvironmentAuth(params) {
95
+ const seen = new Set();
96
+ for await (const candidate of environmentAuthCandidates(params)) {
97
+ if (seen.has(candidate.authConfig.token)) continue;
98
+ seen.add(candidate.authConfig.token);
99
+ if (!params.validateAuth) return candidate;
100
+ const response = await validateCandidate(params.validateAuth, candidate.authConfig);
101
+ if (response.success === true) return { ...candidate, validationResponse: response };
102
+ if (candidate.source === 'selector') {
103
+ const error = new Error('Explicit Controller key rejected (HTTP 401). Check --miso-api-key-env; no other credential was tried.');
104
+ error.status = 401;
105
+ throw error;
106
+ }
69
107
  }
70
- throw new Error(
71
- `No Controller authentication for ${controllerUrl}. Run "aifabrix login -c ${controllerUrl}", ` +
108
+ const error = new Error(
109
+ `No Controller authentication for ${params.controllerUrl}. Run "aifabrix login -c ${params.controllerUrl}", ` +
72
110
  'or set MISO_API_KEY (or --miso-api-key-env <NAME>) to a temporary installation key.'
73
111
  );
112
+ error.status = 401;
113
+ throw error;
74
114
  }
75
115
 
76
116
  module.exports = { resolveControllerUrl, resolveEnvironmentAuth };
@@ -14,6 +14,9 @@ const { buildEnvironmentDocument, buildMisoRequests, refreshVariableCatalog } =
14
14
  const { readWorkspaceEnv, removeWorkspaceEnvKey } = require('./workspace-env');
15
15
  const { resolveControllerUrl, resolveEnvironmentAuth } = require('./environment-auth');
16
16
 
17
+ const logger = require('../../utils/logger');
18
+ const { metadata } = require('../../utils/cli-test-layout-chalk');
19
+
17
20
  const FAILED_STATES = Object.freeze(['failed', 'error', 'cancelled']);
18
21
 
19
22
  function defaultApi() {
@@ -34,8 +37,7 @@ function apiFailure(step, response) {
34
37
  return error;
35
38
  }
36
39
 
37
- async function assertTenantActive(ctx) {
38
- const response = await ctx.api.getTenantStatus(ctx.controllerUrl, ctx.auth);
40
+ async function assertTenantActive(ctx, response) {
39
41
  if (!response.success) throw apiFailure('tenant status', response);
40
42
  const status = String(payload(response)?.status || 'UNKNOWN').toUpperCase();
41
43
  const recorded = ['PENDING', 'ACTIVE', 'INACTIVE'].includes(status) ? status.toLowerCase() : 'unknown';
@@ -169,11 +171,13 @@ async function runEnvironmentStage(workspace, input, deps = {}) {
169
171
  const { envDoc, requests } = prepareEnvironmentDocument(ctx, input, env);
170
172
  ctx.controllerUrl = resolveControllerUrl({ envDoc, infra: ctx.infra, env, override: input.controllerUrl });
171
173
  saveEnvironment(ctx.workspace, ctx.envKey, envDoc);
172
- const { authConfig, source } = await resolveEnvironmentAuth({
173
- workspace, controllerUrl: ctx.controllerUrl, keyEnvName: input.misoApiKeyEnv, deps: { ...deps, env: ctx.processEnv }
174
+ const { authConfig, source, validationResponse } = await resolveEnvironmentAuth({
175
+ workspace, controllerUrl: ctx.controllerUrl, keyEnvName: input.misoApiKeyEnv, deps: { ...deps, env: ctx.processEnv },
176
+ validateAuth: auth => ctx.api.getTenantStatus(ctx.controllerUrl, auth)
174
177
  });
175
178
  ctx.auth = authConfig;
176
- await assertTenantActive(ctx);
179
+ await assertTenantActive(ctx, validationResponse);
180
+ logger.log(metadata(`Controller authentication source: ${source}`));
177
181
  const change = await createOrUpdate(ctx, requests);
178
182
  await validateRequest(ctx, requests.deploy);
179
183
  const { deploymentId, resumed } = await submitDeployment(ctx, requests.deploy);