@aifabrix/builder 2.57.0 → 2.58.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 (33) hide show
  1. package/lib/app/push.js +98 -41
  2. package/lib/build/standard-docker-build.js +13 -11
  3. package/lib/cli/setup-app.js +16 -5
  4. package/lib/cli/setup-dev-path-commands.js +49 -2
  5. package/lib/cli/setup-environment.js +17 -4
  6. package/lib/commands/dev-registry.js +155 -0
  7. package/lib/commands/dev-show-display.js +10 -2
  8. package/lib/commands/setup-image-refresh.js +26 -0
  9. package/lib/core/config-attach-extensions.js +4 -1
  10. package/lib/deployment/installation/azure-infra-stage.js +40 -9
  11. package/lib/deployment/installation/azure-preflight.js +53 -8
  12. package/lib/deployment/installation/azure-readiness.js +35 -5
  13. package/lib/deployment/installation/infra-catalog.js +50 -4
  14. package/lib/deployment/installation/infra-manifest.js +17 -2
  15. package/lib/deployment/installation/infra-projection.js +14 -3
  16. package/lib/deployment/installation/registry-auth-mode.js +98 -0
  17. package/lib/deployment/installation/stage-input.js +21 -6
  18. package/lib/generator/builders.js +8 -9
  19. package/lib/schema/infrastructure-schema.json +55 -2
  20. package/lib/utils/app-register-config.js +2 -4
  21. package/lib/utils/build-resolve-image.js +2 -21
  22. package/lib/utils/cli-utils.js +8 -1
  23. package/lib/utils/config-registry-preference.js +106 -0
  24. package/lib/utils/docker-build.js +53 -19
  25. package/lib/utils/env-map.js +23 -6
  26. package/lib/utils/image-tags.js +88 -0
  27. package/lib/utils/promote-dev-docker-image.js +6 -0
  28. package/lib/utils/registry-credentials.js +236 -0
  29. package/lib/utils/resolve-docker-image-ref.js +78 -9
  30. package/lib/utils/secrets-helpers.js +9 -1
  31. package/lib/utils/variable-transformer.js +2 -4
  32. package/package.json +2 -1
  33. package/templates/marketplace/main.json +2 -2
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Attach token, path, format, and scoped-resources helpers to config exports.
2
+ * Attach token, path, format, scoped-resources, and registry helpers to config exports.
3
3
  *
4
4
  * @fileoverview Keeps lib/core/config.js under max-lines
5
5
  * @author AI Fabrix Team
@@ -42,6 +42,9 @@ function attachConfigExtensions(exportsObj, deps) {
42
42
  const { createScopedResourcesPreferenceFunctions } = require('../utils/config-scoped-resources-preference');
43
43
  Object.assign(exportsObj, createScopedResourcesPreferenceFunctions(getConfig, saveConfig));
44
44
 
45
+ const { createRegistryPreferenceFunctions } = require('../utils/config-registry-preference');
46
+ Object.assign(exportsObj, createRegistryPreferenceFunctions(getConfig, saveConfig));
47
+
45
48
  const { createSecretsPathFunctions } = require('../utils/config-secrets-path');
46
49
  Object.assign(exportsObj, createSecretsPathFunctions(getConfig, saveConfig));
47
50
  }
@@ -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
 
@@ -111,18 +113,41 @@ async function verifyAndRecordReadiness(workspace, doc, deps) {
111
113
  return controllerUrl;
112
114
  }
113
115
 
116
+ function applyLocation(doc, input) {
117
+ if (input.location === undefined) return;
118
+ doc.parameters.location = { ...doc.parameters.location, value: normalizeAzureLocation(input.location), source: 'cli' };
119
+ }
120
+
121
+ async function resolvePullAuth(doc, acr, deps, az) {
122
+ const loadRegistryCredentials = deps.loadRegistryCredentials ||
123
+ require('../../utils/registry-credentials').loadRegistryCredentials;
124
+ const resolved = await registryAuth.resolveRegistryAuth(doc, acr, az, {
125
+ loadRegistryCredentials,
126
+ resolveAcrAdminCredentials: preflight.resolveAcrAdminCredentials
127
+ });
128
+ registryAuth.applyEffectiveAcrAuth(doc, resolved.mode);
129
+ return resolved.credentials;
130
+ }
131
+
114
132
  async function prepareInfra(workspace, input, deps, az) {
115
133
  const { doc, templateHash } = materializeWorkspace(workspace, input, deps);
134
+ applyLocation(doc, input);
116
135
  const missing = missingRequiredFields(doc);
117
- if (missing.length > 0) throw missingInputError(workspace.paths.infra, missing);
136
+ if (missing.length > 0) {
137
+ if (input.location !== undefined) saveInfra(workspace, doc);
138
+ throw missingInputError(workspace.paths.infra, missing);
139
+ }
140
+ registryAuth.applyRequestedAcrAuth(doc, input);
118
141
  preflight.assertActiveAccount(doc, az);
119
142
  const acr = preflight.resolveAcr(doc, az);
120
- preflight.assertDeploymentAuthority(doc, acr, az);
121
143
  doc.parameters.acrLoginServer.value = acr.loginServer;
122
144
  doc.parameters.acrResourceId.value = acr.id;
145
+ // Local pair first (a partial pair fails closed); Azure admin lookup only for explicit credentials.
146
+ const registryCredentials = await resolvePullAuth(doc, acr, deps, az);
147
+ preflight.assertDeploymentAuthority(doc, acr, az);
123
148
  await applyAccessOptions(doc, input, deps);
124
149
  saveInfra(workspace, doc);
125
- return { doc, templateHash, acr };
150
+ return { doc, templateHash, acr, registryCredentials };
126
151
  }
127
152
 
128
153
  function deployWithStatus(workspace, doc, context) {
@@ -141,17 +166,23 @@ function deployWithStatus(workspace, doc, context) {
141
166
  * Runs the AZURE infra stage.
142
167
  * @param {Object} workspace - Opened workspace
143
168
  * @param {Object} input - Validated stage input
144
- * @param {Object} [deps] - Injected dependencies (az, probe, resolvePublicIpv4, secretsApi, now, templatePath)
169
+ * @param {Object} [deps] - Injected dependencies (az, probe, resolvePublicIpv4, secretsApi, now, templatePath,
170
+ * loadRegistryCredentials)
145
171
  * @returns {Promise<{ doc: Object, controllerUrl: string, activationUrl: string }>}
146
172
  */
147
173
  async function runAzureInfraStage(workspace, input, deps = {}) {
148
174
  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);
175
+ const { doc, templateHash, acr, registryCredentials } = await prepareInfra(workspace, input, deps, context.az);
150
176
  const env = await materializeWorkspaceSecrets({
151
177
  vendor: 'AZURE', envPath: workspace.paths.env, root: workspace.root, names: INFRA_SECRETS, secretsApi: deps.secretsApi
152
178
  });
153
- const record = deployWithStatus(workspace, doc, { env, acr, templateHash, az: context.az });
179
+ const record = deployWithStatus(workspace, doc, {
180
+ env, acr, templateHash, az: context.az, registryCredentials
181
+ });
154
182
  recordDeployment(doc, record, context.now);
183
+ // Never persist registry credentials in the workspace manifest.
184
+ doc.parameters.acrAdminUsername = { ...doc.parameters.acrAdminUsername, value: '', source: 'managedIdentityMode' };
185
+ doc.parameters.acrAdminPassword = { ...doc.parameters.acrAdminPassword, value: '', source: 'managedIdentityMode' };
155
186
  saveInfra(workspace, doc);
156
187
  const controllerUrl = await verifyAndRecordReadiness(workspace, doc, context);
157
188
  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,28 @@ 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
+ if (isAdminAcrAuth(doc)) return;
94
131
  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`);
132
+ 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, or use --acr-auth credentials with a pair saved by "aifabrix dev set-registry"`);
96
133
  }
97
134
  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`);
135
+ 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
136
  }
100
137
  }
101
138
 
102
- module.exports = { assertActiveAccount, resolveAcr, hasPermission, actionMatches, assertDeploymentAuthority };
139
+ module.exports = {
140
+ assertActiveAccount,
141
+ resolveAcr,
142
+ resolveAcrAdminCredentials,
143
+ isAdminAcrAuth,
144
+ hasPermission,
145
+ actionMatches,
146
+ assertDeploymentAuthority
147
+ };
@@ -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
  */
@@ -20,6 +20,9 @@ const LICENSE_EDITIONS = Object.freeze(['COMMUNITY', 'STANDARD', 'ENTERPRISE']);
20
20
  const DEFAULT_LICENSE_EDITION = 'COMMUNITY';
21
21
  const ONBOARDING_MODES = Object.freeze(['saasDirect', 'customerOwned']);
22
22
  const DEFAULT_ONBOARDING_MODE = 'saasDirect';
23
+ const ACR_AUTH_MODES = Object.freeze(['auto', 'mi', 'credentials']);
24
+ const DEFAULT_ACR_AUTH = 'auto';
25
+ const AZURE_LOCATION = /^[a-z0-9]{2,40}$/;
23
26
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
27
 
25
28
  /**
@@ -36,6 +39,45 @@ function normalizeLicenseEdition(value) {
36
39
  throw new Error(`License edition must be one of ${LICENSE_EDITIONS.join(', ')} or an Azure Marketplace subscription ID`);
37
40
  }
38
41
 
42
+ /**
43
+ * Normalizes requested ACR image-pull auth: auto (default), mi, or credentials.
44
+ * `admin` (and its spellings) is a deprecated alias for `credentials`.
45
+ * @param {string} value - Raw value
46
+ * @returns {string} Canonical mode
47
+ */
48
+ function normalizeAcrAuth(value) {
49
+ const raw = String(value ?? '').trim();
50
+ const aliases = {
51
+ auto: 'auto',
52
+ mi: 'mi',
53
+ 'managed-identity': 'mi',
54
+ managedidentity: 'mi',
55
+ credentials: 'credentials',
56
+ admin: 'credentials',
57
+ adminuser: 'credentials',
58
+ 'admin-user': 'credentials',
59
+ password: 'credentials'
60
+ };
61
+ const mode = aliases[raw.toLowerCase()];
62
+ if (!mode) {
63
+ throw new Error(`ACR auth must be one of ${ACR_AUTH_MODES.join(', ')} (deprecated alias: admin = credentials)`);
64
+ }
65
+ return mode;
66
+ }
67
+
68
+ /**
69
+ * Validates an Azure region name (for example swedencentral).
70
+ * @param {string} value - Raw value
71
+ * @returns {string} Lower-case region
72
+ */
73
+ function normalizeAzureLocation(value) {
74
+ const location = String(value ?? '').trim().toLowerCase();
75
+ if (!AZURE_LOCATION.test(location)) {
76
+ throw new Error(`--location must be an Azure region name such as swedencentral (letters and digits only); got "${value}"`);
77
+ }
78
+ return location;
79
+ }
80
+
39
81
  const PARAMETER_CATALOG = Object.freeze({
40
82
  infrastructureNameOverride: { kind: 'file', source: 'user', description: 'Optional infrastructure-name override; empty uses generated naming.' },
41
83
  location: { kind: 'file', source: 'user', required: true, description: 'Azure region for shared infrastructure.' },
@@ -50,10 +92,10 @@ const PARAMETER_CATALOG = Object.freeze({
50
92
  keycloakVerifyAudience: { kind: 'file', source: 'templateDefault', description: 'Enable Keycloak audience verification.' },
51
93
  encryptionKey: { kind: 'secretEnv', env: 'ENCRYPTION_KEY', required: true, description: 'Application/data encryption key.' },
52
94
  acrLoginServer: { kind: 'acrLookup', field: 'loginServer', description: 'Resolved from acrName; not entered separately.' },
53
- acrAdminUsername: { kind: 'fixed', value: '', source: 'managedIdentityMode', description: 'Unused: Azure web apps pull images with their managed identities.' },
54
- acrAdminPassword: { kind: 'fixed', value: '', source: 'managedIdentityMode', description: 'Unused: Azure image pull never uses a registry password.' },
55
- acrResourceId: { kind: 'acrLookup', field: 'id', description: 'Resolved from acrName; both web app identities receive AcrPull on it and the deployment fails if the grant fails.' },
56
- deployRoleAssignments: { kind: 'file', source: 'templateDefault', description: 'Deploy required Azure role assignments.' },
95
+ acrAdminUsername: { kind: 'fixed', value: '', source: 'managedIdentityMode', description: 'Empty in the workspace; credential mode fills it only in the temporary ARM parameters file (never committed).' },
96
+ acrAdminPassword: { kind: 'fixed', value: '', source: 'managedIdentityMode', description: 'Empty in the workspace; credential mode fills it only in the temporary ARM parameters file (never committed).' },
97
+ acrResourceId: { kind: 'acrLookup', field: 'id', description: 'Resolved from acrName; mi mode grants AcrPull to both web app identities.' },
98
+ deployRoleAssignments: { kind: 'file', source: 'templateDefault', description: 'Deploy required Azure role assignments (false while credential image pull is in effect; restored for managed identity).' },
57
99
  moriBaseUrl: { kind: 'file', source: 'registration', required: true, description: 'Mori service base URL.' },
58
100
  moriCallbackApiKey: { kind: 'secretEnv', env: 'MORI_CALLBACK_API_KEY', required: true, description: 'Mori callback authentication key.' },
59
101
  entraCallbackStateSecret: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
@@ -159,7 +201,11 @@ module.exports = {
159
201
  DEFAULT_LICENSE_EDITION,
160
202
  ONBOARDING_MODES,
161
203
  DEFAULT_ONBOARDING_MODE,
204
+ ACR_AUTH_MODES,
205
+ DEFAULT_ACR_AUTH,
162
206
  normalizeLicenseEdition,
207
+ normalizeAcrAuth,
208
+ normalizeAzureLocation,
163
209
  buildEnvTemplate,
164
210
  assertTemplateCoverage
165
211
  };
@@ -8,9 +8,10 @@
8
8
 
9
9
  const crypto = require('crypto');
10
10
  const {
11
- PARAMETER_CATALOG, OUTPUT_CATALOG, DEFAULT_LICENSE_EDITION, DEFAULT_ONBOARDING_MODE
11
+ PARAMETER_CATALOG, OUTPUT_CATALOG, DEFAULT_LICENSE_EDITION, DEFAULT_ONBOARDING_MODE, DEFAULT_ACR_AUTH
12
12
  } = require('./infra-catalog');
13
13
 
14
+ const ACR_AUTH_DESCRIPTION = 'Image pull auth: auto (default; local dev set-registry pair for the ACR login server, else managed identity), mi (AcrPull), or credentials (local pair, then authorized ACR admin lookup). Credentials are never committed.';
14
15
  const INFRA_SCHEMA_VERSION = '2.0.0';
15
16
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
17
 
@@ -46,12 +47,24 @@ function parameterEntry(name, template) {
46
47
  }
47
48
  }
48
49
 
50
+ /** Sources written by Builder workflows that must survive regeneration. */
51
+ const STICKY_SOURCES = Object.freeze(['cli', 'acrAuthCredentials', 'acrAuthAdmin']);
52
+
53
+ function preservedState(previous) {
54
+ const kept = { value: previous.value };
55
+ if (STICKY_SOURCES.includes(previous.source)) kept.source = previous.source;
56
+ for (const key of ['restoreValue', 'restoreSource']) {
57
+ if (previous[key] !== undefined) kept[key] = previous[key];
58
+ }
59
+ return kept;
60
+ }
61
+
49
62
  function buildParameters(template, existing = {}) {
50
63
  const parameters = {};
51
64
  for (const name of Object.keys(PARAMETER_CATALOG)) {
52
65
  const fresh = parameterEntry(name, template);
53
66
  const workflowOwned = ['fixed', 'deferred', 'secretEnv'].includes(PARAMETER_CATALOG[name].kind);
54
- parameters[name] = existing[name] && !workflowOwned ? { ...fresh, value: existing[name].value } : fresh;
67
+ parameters[name] = existing[name] && !workflowOwned ? { ...fresh, ...preservedState(existing[name]) } : fresh;
55
68
  }
56
69
  return parameters;
57
70
  }
@@ -64,6 +77,7 @@ function buildOutputs(existing = {}) {
64
77
  function azureSections(template, existing) {
65
78
  return {
66
79
  acrName: existing.acrName || entry('', 'user', 'Required private ACR name. Builder resolves and persists its login server and resource ID using the authenticated Azure account.', { required: true }),
80
+ acrAuth: existing.acrAuth || entry(DEFAULT_ACR_AUTH, 'cliOrFile', ACR_AUTH_DESCRIPTION),
67
81
  allowMyIp: existing.allowMyIp || entry(false, 'cliOrFile', 'Default-off internal test access. When enabled, Builder resolves and audits one public IPv4 /32.', { resolvedCidr: null, resolvedAt: null, resolver: null }),
68
82
  preset: existing.preset || entry('s', 'cliOrFile', 'Shared infrastructure sizing preset.'),
69
83
  parameters: buildParameters(template, existing.parameters),
@@ -156,6 +170,7 @@ function missingRequiredFields(doc) {
156
170
 
157
171
  module.exports = {
158
172
  INFRA_SCHEMA_VERSION,
173
+ ACR_AUTH_DESCRIPTION,
159
174
  buildInfraDocument,
160
175
  missingRequiredFields
161
176
  };
@@ -3,12 +3,15 @@
3
3
  /**
4
4
  * @fileoverview Projects `environment-infra.json` plus resolved workspace secrets into standard
5
5
  * ARM deploymentParameters, and applies the default-off current-IP access option.
6
- * Deferred tenant-activation parameters are never projected.
6
+ * Deferred tenant-activation parameters are never projected. ACR admin credentials (password mode)
7
+ * are injected only into the temporary parameters file and never enter the input hash.
7
8
  */
8
9
 
9
10
  const { PARAMETER_CATALOG } = require('./infra-catalog');
10
11
  const { sha256 } = require('./workspace-files');
11
12
 
13
+ const ARM_CREDENTIAL_PARAMS = Object.freeze(new Set(['acrAdminUsername', 'acrAdminPassword']));
14
+
12
15
  function projectParameter(name, doc, context) {
13
16
  const spec = PARAMETER_CATALOG[name];
14
17
  switch (spec.kind) {
@@ -27,6 +30,12 @@ function projectParameter(name, doc, context) {
27
30
  }
28
31
  return context.acr[spec.field];
29
32
  case 'fixed':
33
+ if (name === 'acrAdminUsername' && context.registryCredentials) {
34
+ return context.registryCredentials.username;
35
+ }
36
+ if (name === 'acrAdminPassword' && context.registryCredentials) {
37
+ return context.registryCredentials.password;
38
+ }
30
39
  return spec.value;
31
40
  default:
32
41
  return doc.parameters[name].value;
@@ -39,6 +48,7 @@ function projectParameter(name, doc, context) {
39
48
  * @param {Object} context - Resolution context
40
49
  * @param {Object.<string,string>} context.env - Resolved workspace .env values
41
50
  * @param {{ id: string, loginServer: string }} context.acr - Resolved registry
51
+ * @param {{ username: string, password: string }|null|undefined} context.registryCredentials - Credential-mode pull creds (never hashed)
42
52
  * @param {string} context.templateHash - SHA-256 of the deployed template
43
53
  * @returns {{ document: Object, inputHash: string }}
44
54
  */
@@ -49,9 +59,10 @@ function buildArmParameters(doc, context) {
49
59
  const value = projectParameter(name, doc, context);
50
60
  if (value === undefined) continue;
51
61
  parameters[name] = { value };
52
- // Secrets never enter the hash.
53
- if (PARAMETER_CATALOG[name].kind !== 'secretEnv') nonSecret[name] = value;
62
+ const catalog = PARAMETER_CATALOG[name];
63
+ if (catalog.kind !== 'secretEnv' && !ARM_CREDENTIAL_PARAMS.has(name)) nonSecret[name] = value;
54
64
  }
65
+ nonSecret.acrAuth = doc.acrAuth?.effective || doc.acrAuth?.value || 'auto';
55
66
  return {
56
67
  document: {
57
68
  $schema: 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#',
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Image-pull authentication for CLI-managed AZURE infra. The requested mode
5
+ * (`auto|mi|credentials`, deprecated `admin` = credentials) resolves to an effective `mi` or
6
+ * `credentials` mode. Credential values come from the primary user's `dev set-registry` pair for the
7
+ * resolved ACR login server, or (explicit credentials only) from an authorized ACR admin lookup.
8
+ * They live only in memory and in the temporary ARM parameters file.
9
+ */
10
+
11
+ const { normalizeAcrAuth } = require('./infra-catalog');
12
+ const { ACR_AUTH_DESCRIPTION } = require('./infra-manifest');
13
+
14
+ const FORCED_ROLE_SOURCES = Object.freeze(['acrAuthCredentials', 'acrAuthAdmin']);
15
+
16
+ /**
17
+ * Normalizes and records the requested mode (CLI wins over the workspace value).
18
+ * @param {Object} doc - Infra manifest (mutated)
19
+ * @param {Object} input - Stage input (`acrAuth` from CLI)
20
+ * @returns {string} Requested mode
21
+ */
22
+ function applyRequestedAcrAuth(doc, input) {
23
+ const fromCli = input.acrAuth !== undefined;
24
+ const mode = normalizeAcrAuth(fromCli ? input.acrAuth : (doc.acrAuth?.value || 'auto'));
25
+ doc.acrAuth = {
26
+ ...(doc.acrAuth || {}),
27
+ value: mode,
28
+ source: fromCli ? 'cli' : (doc.acrAuth?.source || 'cliOrFile'),
29
+ description: ACR_AUTH_DESCRIPTION
30
+ };
31
+ return mode;
32
+ }
33
+
34
+ function localSetupHint(host) {
35
+ return `Run "aifabrix dev set-registry ${host} --username <username> --password-stdin", or use --acr-auth mi`;
36
+ }
37
+
38
+ async function resolveAzureFallback(doc, acr, az, fetchAdmin) {
39
+ try {
40
+ return fetchAdmin(doc, acr, az);
41
+ } catch (error) {
42
+ throw new Error(`No registry credentials are available for ${acr.loginServer}: ${error.message}. ${localSetupHint(acr.loginServer)}`);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Resolves the effective pull mode and ephemeral credentials. A partial local pair always fails.
48
+ * @param {Object} doc - Infra manifest with a normalized `acrAuth`
49
+ * @param {{ loginServer: string, adminUserEnabled?: boolean }} acr - Resolved registry
50
+ * @param {Function} az - Azure CLI runner
51
+ * @param {Object} deps - `loadRegistryCredentials(host)` and `resolveAcrAdminCredentials(doc, acr, az)`
52
+ * @returns {Promise<{ mode: 'mi'|'credentials', credentials: ({username: string, password: string}|null), source: string }>}
53
+ */
54
+ async function resolveRegistryAuth(doc, acr, az, deps) {
55
+ const requested = normalizeAcrAuth(doc.acrAuth?.value || 'auto');
56
+ if (requested === 'mi') return { mode: 'mi', credentials: null, source: 'managedIdentity' };
57
+ const local = await deps.loadRegistryCredentials(acr.loginServer);
58
+ if (local) return { mode: 'credentials', credentials: local, source: 'localRegistryCredentials' };
59
+ if (requested === 'auto') return { mode: 'mi', credentials: null, source: 'managedIdentity' };
60
+ const azure = await resolveAzureFallback(doc, acr, az, deps.resolveAcrAdminCredentials);
61
+ return { mode: 'credentials', credentials: azure, source: 'acrAdminLookup' };
62
+ }
63
+
64
+ /**
65
+ * Records the effective mode and keeps role assignments consistent with it. Credential mode turns
66
+ * ARM role assignments off; returning to managed identity restores the value it replaced.
67
+ * @param {Object} doc - Infra manifest (mutated)
68
+ * @param {'mi'|'credentials'} mode - Effective mode
69
+ */
70
+ function applyEffectiveAcrAuth(doc, mode) {
71
+ doc.acrAuth = { ...doc.acrAuth, effective: mode };
72
+ const roles = doc.parameters.deployRoleAssignments;
73
+ const forced = FORCED_ROLE_SOURCES.includes(roles.source);
74
+ if (mode === 'credentials') {
75
+ if (forced) return;
76
+ doc.parameters.deployRoleAssignments = {
77
+ ...roles, value: false, source: 'acrAuthCredentials', restoreValue: roles.value, restoreSource: roles.source
78
+ };
79
+ return;
80
+ }
81
+ if (!forced) return;
82
+ const { restoreValue, restoreSource, ...rest } = roles;
83
+ doc.parameters.deployRoleAssignments = {
84
+ ...rest,
85
+ value: restoreValue === undefined ? true : restoreValue,
86
+ source: restoreSource || 'templateDefault'
87
+ };
88
+ }
89
+
90
+ /**
91
+ * @param {Object} doc - Infra manifest
92
+ * @returns {boolean} True when the last resolved pull mode uses registry credentials
93
+ */
94
+ function usesRegistryCredentials(doc) {
95
+ return doc.acrAuth?.effective === 'credentials';
96
+ }
97
+
98
+ module.exports = { applyRequestedAcrAuth, resolveRegistryAuth, applyEffectiveAcrAuth, usesRegistryCredentials };
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  const path = require('path');
10
- const { normalizeLicenseEdition } = require('./infra-catalog');
10
+ const { normalizeLicenseEdition, normalizeAcrAuth, normalizeAzureLocation } = require('./infra-catalog');
11
11
 
12
12
  const WORKSPACE_STAGES = Object.freeze(['register', 'infra', 'dev', 'tst', 'pro']);
13
13
  const ENVIRONMENT_STAGES = Object.freeze(['dev', 'tst', 'pro']);
@@ -102,11 +102,24 @@ function assertInfraOnlyOptions(stage, options) {
102
102
  if (options.allowMyIp && stage !== 'infra') {
103
103
  throw new Error('--allow-my-ip is supported only with --stage infra for AZURE installations');
104
104
  }
105
- if (options.licenseEdition === undefined) return;
106
- if (stage !== 'infra') {
107
- throw new Error('--license-edition is supported only with --stage infra for AZURE installations');
105
+ if (options.licenseEdition !== undefined) {
106
+ if (stage !== 'infra') {
107
+ throw new Error('--license-edition is supported only with --stage infra for AZURE installations');
108
+ }
109
+ normalizeLicenseEdition(options.licenseEdition);
110
+ }
111
+ if (options.acrAuth !== undefined) {
112
+ if (stage !== 'infra') {
113
+ throw new Error('--acr-auth is supported only with --stage infra for AZURE installations');
114
+ }
115
+ normalizeAcrAuth(options.acrAuth);
116
+ }
117
+ if (options.location !== undefined) {
118
+ if (stage !== 'infra') {
119
+ throw new Error('--location is supported only with --stage infra (later environments inherit the saved region)');
120
+ }
121
+ normalizeAzureLocation(options.location);
108
122
  }
109
- normalizeLicenseEdition(options.licenseEdition);
110
123
  }
111
124
 
112
125
  function assertStageScopedOptions(stage, options) {
@@ -137,7 +150,7 @@ function assertStageScopedOptions(stage, options) {
137
150
  * @param {Object} options - Commander options
138
151
  * @param {string|undefined} persistedVendor - Vendor recorded in an existing workspace
139
152
  * @returns {{ resourceGroup: string, stage: string, vendor: string, allowMyIp: boolean, licenseEdition: string|undefined,
140
- * preset: string|undefined, controllerUrl: string|undefined, misoApiKeyEnv: string|undefined }}
153
+ * acrAuth: string|undefined, location: string|undefined, preset: string|undefined, controllerUrl: string|undefined, misoApiKeyEnv: string|undefined }}
141
154
  */
142
155
  function resolveStageInput(resourceGroupKey, options, persistedVendor) {
143
156
  const resourceGroup = validateResourceGroupKey(resourceGroupKey);
@@ -150,6 +163,8 @@ function resolveStageInput(resourceGroupKey, options, persistedVendor) {
150
163
  vendor,
151
164
  allowMyIp: options.allowMyIp === true,
152
165
  licenseEdition: options.licenseEdition,
166
+ acrAuth: options.acrAuth,
167
+ location: options.location,
153
168
  preset: options.preset,
154
169
  controllerUrl: options.controllerUrl,
155
170
  misoApiKeyEnv: options.misoApiKeyEnv
@@ -41,16 +41,15 @@ function filterConfigurationByRegistryMode(configuration, _registryMode) {
41
41
  return Array.isArray(configuration) ? configuration : [];
42
42
  }
43
43
 
44
+ /**
45
+ * Deployment image reference using the shared registry precedence
46
+ * (installation `registry` in config.yaml, then manifest `image.registry`).
47
+ * @param {Object} variables - Application variables
48
+ * @returns {string}
49
+ */
44
50
  function buildImageReference(variables) {
45
- const imageName = variables.image?.name || variables.app?.key || 'app';
46
- const registry = variables.image?.registry;
47
- const tag = variables.image?.tag || 'latest';
48
-
49
- if (registry) {
50
- return `${registry}/${imageName}:${tag}`;
51
- }
52
-
53
- return `${imageName}:${tag}`;
51
+ const { buildManifestImageReference } = require('../utils/resolve-docker-image-ref');
52
+ return buildManifestImageReference(variables, 'app');
54
53
  }
55
54
 
56
55
  /**