@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
package/lib/app/push.js CHANGED
@@ -93,23 +93,24 @@ function extractImageName(config, appName) {
93
93
  */
94
94
  async function loadPushConfig(appName, options) {
95
95
  const { loadConfigFile } = require('../utils/config-format');
96
+ const { resolveEffectiveRegistry, stripRegistryHostFromRepository } = require('../utils/resolve-docker-image-ref');
97
+ let config;
96
98
  try {
97
99
  const { appPath } = await paths.detectAppType(appName);
98
- const configPath = paths.resolveApplicationConfigPath(appPath);
99
- const config = loadConfigFile(configPath);
100
- const effective = getEffectiveConfig(config);
101
- const registry = options.registry || effective.image?.registry;
102
- if (!registry) {
103
- throw new Error('Registry URL is required. Provide via --registry flag or configure in application config under image.registry');
104
- }
105
- const imageName = extractImageName(config, appName);
106
- return { registry, imageName };
100
+ config = loadConfigFile(paths.resolveApplicationConfigPath(appPath));
107
101
  } catch (error) {
108
- if (error.message.includes('Registry URL')) {
109
- throw error;
110
- }
111
102
  throw new Error(`Failed to load configuration: ${error.message}\nRun 'aifabrix create ${appName}' first`);
112
103
  }
104
+ const effective = getEffectiveConfig(config);
105
+ const { registry } = resolveEffectiveRegistry(effective, { registry: options.registry });
106
+ if (!registry) {
107
+ throw new Error(
108
+ 'Registry URL is required. Provide --registry, run "aifabrix dev set-registry <registry>", ' +
109
+ 'or configure image.registry in the application config'
110
+ );
111
+ }
112
+ const imageName = stripRegistryHostFromRepository(extractImageName(config, appName));
113
+ return { registry, imageName, appVersion: require('../utils/image-tags').readAppVersion(effective) };
113
114
  }
114
115
 
115
116
  /**
@@ -129,10 +130,43 @@ async function validatePushConfig(registry, imageName, appName) {
129
130
  throw new Error(`Invalid registry URL format: ${registry}. Expected format: *.azurecr.io`);
130
131
  }
131
132
 
132
- if (!await pushUtils.checkLocalImageExists(imageName, 'latest')) {
133
- throw new Error(`Docker image ${imageName}:latest not found locally.\nRun 'aifabrix build ${appName}' first`);
133
+ const sourceRef = await resolveLocalSourceRef(registry, imageName);
134
+ if (!sourceRef) {
135
+ throw new Error(`Docker image ${registry}/${imageName}:latest or ${imageName}:latest not found locally.\nRun 'aifabrix build ${appName}' first`);
136
+ }
137
+
138
+ const { getRegistryCredentialStatus } = require('../utils/registry-credentials');
139
+ const credentialStatus = getRegistryCredentialStatus(registry);
140
+ if (credentialStatus === 'partial') {
141
+ await require('../utils/registry-credentials').loadRegistryCredentials(registry);
142
+ }
143
+ if (credentialStatus === 'complete') {
144
+ return sourceRef;
145
+ }
146
+ await validateAzureCli();
147
+ return sourceRef;
148
+ }
149
+
150
+ /**
151
+ * Local image to push from: the registry-qualified build output first, else the plain repository.
152
+ * @param {string} registry - Registry host
153
+ * @param {string} imageName - Repository without host
154
+ * @returns {Promise<string|null>} `repository:latest` or null
155
+ */
156
+ async function resolveLocalSourceRef(registry, imageName) {
157
+ for (const repository of [`${registry}/${imageName}`, imageName]) {
158
+ if (await pushUtils.checkLocalImageExists(repository, 'latest')) {
159
+ return `${repository}:latest`;
160
+ }
134
161
  }
162
+ return null;
163
+ }
135
164
 
165
+ /**
166
+ * Azure CLI prerequisites for the ACR login fallback (no stored registry credentials).
167
+ * @returns {Promise<void>}
168
+ */
169
+ async function validateAzureCli() {
136
170
  if (!await pushUtils.checkAzureCLIInstalled()) {
137
171
  throw new Error('Azure CLI is not installed. Install from: https://docs.microsoft.com/cli/azure/install-azure-cli');
138
172
  }
@@ -149,7 +183,7 @@ async function validatePushConfig(registry, imageName, appName) {
149
183
  * @async
150
184
  * @param {string} registry - Registry URL
151
185
  */
152
- async function authenticateWithRegistry(registry) {
186
+ async function authenticateWithAzureCli(registry) {
153
187
  if (await pushUtils.checkACRAuthentication(registry)) {
154
188
  logger.log(formatSuccessLine(`Already authenticated with ${registry}`));
155
189
  } else {
@@ -157,6 +191,16 @@ async function authenticateWithRegistry(registry) {
157
191
  }
158
192
  }
159
193
 
194
+ /**
195
+ * Stored host-scoped credentials first (stdin login), else Azure CLI. Once per command.
196
+ * @param {string} registry - Registry host
197
+ * @returns {Promise<void>}
198
+ */
199
+ async function authenticateWithRegistry(registry) {
200
+ const { ensureRegistryLogin } = require('../utils/registry-credentials');
201
+ await ensureRegistryLogin(registry, { fallback: authenticateWithAzureCli });
202
+ }
203
+
160
204
  /**
161
205
  * Layout-aligned notice when push is skipped for external integrations.
162
206
  * @param {string} appName
@@ -191,30 +235,43 @@ function logPushCommandHeader(appName, registry, imageName) {
191
235
  * @param {string} registry - Registry URL
192
236
  * @param {Array<string>} tags - Image tags
193
237
  */
194
- async function pushImageTags(imageName, registry, tags) {
195
- try {
196
- await Promise.all(tags.map(async(tag) => {
197
- await pushUtils.tagImage(`${imageName}:latest`, `${registry}/${imageName}:${tag}`);
198
- await pushUtils.pushImage(`${registry}/${imageName}:${tag}`, registry);
199
- }));
200
- } catch (error) {
201
- // If authentication error, try to re-authenticate and retry once
202
- const errorMessage = error.message || String(error);
203
- const isAuthError = errorMessage.includes('Authentication required') ||
204
- errorMessage.includes('authentication required') ||
205
- (errorMessage.includes('authentication') && errorMessage.includes('401'));
238
+ function isAuthenticationError(error) {
239
+ const message = (error && error.message) || String(error);
240
+ return message.includes('Authentication required') ||
241
+ message.includes('authentication required') ||
242
+ (message.includes('authentication') && message.includes('401'));
243
+ }
244
+
245
+ async function tagAndPushOne(sourceRef, targetRef, registry) {
246
+ if (sourceRef !== targetRef) {
247
+ await pushUtils.tagImage(sourceRef, targetRef);
248
+ }
249
+ await pushUtils.pushImage(targetRef, registry);
250
+ }
206
251
 
207
- if (isAuthError) {
208
- logger.log(
209
- formatWarningLine('Registry authentication expired; re-authenticating, then retrying push.')
210
- );
252
+ /**
253
+ * Tags and pushes every unique target once, re-authenticating once on an auth failure.
254
+ * @async
255
+ * @param {string} sourceRef - Local source image
256
+ * @param {string} imageName - Repository without host
257
+ * @param {string} registry - Registry host
258
+ * @param {Array<string>} tags - Resolved tags
259
+ */
260
+ async function pushImageTags(sourceRef, imageName, registry, tags) {
261
+ let reauthenticated = false;
262
+ for (const tag of tags) {
263
+ const targetRef = `${registry}/${imageName}:${tag}`;
264
+ try {
265
+ await tagAndPushOne(sourceRef, targetRef, registry);
266
+ } catch (error) {
267
+ if (reauthenticated || !isAuthenticationError(error)) {
268
+ throw new Error(`Push failed for ${targetRef}: ${error.message}`);
269
+ }
270
+ logger.log(formatWarningLine('Registry authentication expired; re-authenticating, then retrying push.'));
271
+ require('../utils/registry-credentials').resetRegistryLoginCache(registry);
211
272
  await authenticateWithRegistry(registry);
212
- // Retry push after re-authentication
213
- await Promise.all(tags.map(async(tag) => {
214
- await pushUtils.pushImage(`${registry}/${imageName}:${tag}`, registry);
215
- }));
216
- } else {
217
- throw error;
273
+ reauthenticated = true;
274
+ await tagAndPushOne(sourceRef, targetRef, registry);
218
275
  }
219
276
  }
220
277
  }
@@ -252,12 +309,12 @@ async function pushApp(appName, options = {}) {
252
309
  }
253
310
  try {
254
311
  validateAppName(appName);
255
- const { registry, imageName } = await loadPushConfig(appName, options);
256
- await validatePushConfig(registry, imageName, appName);
312
+ const { registry, imageName, appVersion } = await loadPushConfig(appName, options);
313
+ const tags = require('../utils/image-tags').resolveImageTags(options.tag, appVersion);
314
+ const sourceRef = await validatePushConfig(registry, imageName, appName);
257
315
  logPushCommandHeader(appName, registry, imageName);
258
316
  await authenticateWithRegistry(registry);
259
- const tags = options.tag ? options.tag.split(',').map(t => t.trim()) : ['latest'];
260
- await pushImageTags(imageName, registry, tags);
317
+ await pushImageTags(sourceRef, imageName, registry, tags);
261
318
  displayPushResults(registry, imageName, tags);
262
319
  } catch (error) {
263
320
  throw new Error(`Failed to push application: ${error.message}`);
@@ -80,21 +80,18 @@ async function executeDockerImageBuild(appName, options, buildConfig) {
80
80
  );
81
81
  await ensureRunSecretsForApp(appName);
82
82
  const buildArgs = await resolveDockerBuildArgsForApp(appName, contextPath);
83
- const tag = options.tag || 'latest';
84
- if (typeof tag === 'string' && tag.includes(',')) {
85
- throw new Error('Use a single image tag per build (comma-separated multiple tags are not supported).');
86
- }
83
+ const { primary, aliases, tags } = options.tagPlan;
87
84
  await dockerBuild.executeDockerBuildWithTag(
88
85
  effectiveImageName,
89
86
  imageName,
90
87
  dockerfilePath,
91
88
  contextPath,
92
- tag,
93
- { ...options, buildArgs }
89
+ primary,
90
+ { ...options, buildArgs, tagAliases: aliases }
94
91
  );
95
92
  await postBuildTasks(appName, buildConfig);
96
93
  logger.log(formatSuccessParagraph('Build completed successfully!'));
97
- return `${effectiveImageName}:${tag}`;
94
+ return tags.map((tag) => `${effectiveImageName}:${tag}`).join(', ');
98
95
  }
99
96
 
100
97
  /**
@@ -104,12 +101,17 @@ async function executeDockerImageBuild(appName, options, buildConfig) {
104
101
  * @returns {Promise<string>} Built image ref e.g. name:tag
105
102
  */
106
103
  async function runStandardDockerBuild(appName, options) {
107
- const promotedRef = await tryCompleteDevPromoteBuild(appName, options);
104
+ const { resolveBuildTagPlan, readAppVersion } = require('../utils/image-tags');
105
+ const { buildConfig, config: appConfig } = await buildHelpers.loadAndValidateConfig(appName);
106
+ // Resolve and validate every tag before any Docker build, tag, login or promote.
107
+ const planned = { ...options, tagPlan: resolveBuildTagPlan(options.tag, readAppVersion(appConfig)) };
108
+ const promotedRef = await tryCompleteDevPromoteBuild(appName, planned);
108
109
  if (promotedRef) {
109
- return promotedRef;
110
+ const { resolvePromoteBaseTags } = require('../utils/promote-dev-docker-image');
111
+ const baseName = promotedRef.slice(0, promotedRef.lastIndexOf(':'));
112
+ return resolvePromoteBaseTags(planned).map((tag) => `${baseName}:${tag}`).join(', ');
110
113
  }
111
- const { buildConfig } = await buildHelpers.loadAndValidateConfig(appName);
112
- return executeDockerImageBuild(appName, options, buildConfig);
114
+ return executeDockerImageBuild(appName, planned, buildConfig);
113
115
  }
114
116
 
115
117
  module.exports = { runStandardDockerBuild };
@@ -208,6 +208,7 @@ Examples:
208
208
  .option('-p, --port <port>', 'Override local port')
209
209
  .option('-d, --debug', 'Enable debug output with detailed container information')
210
210
  .option('-t, --tag <tag>', 'Image tag to run (e.g. v1.0.0); overrides application.yaml image.tag')
211
+ .option('-r, --registry <registry>', 'Registry host for the image (overrides dev set-registry and application.yaml)')
211
212
  .option('-e, --env <env>', 'Environment: dev (default), tst, or pro', 'dev')
212
213
  .option('--base', 'Use manifest base image only (skip local developer-scoped tag preference)')
213
214
  .option('--reload', 'In dev: mount workspace into container (Mutagen only if docker-endpoint is a remote host)')
@@ -237,7 +238,11 @@ function registerBuildCommand(program) {
237
238
  .option('-l, --language <lang>', 'Override language detection')
238
239
  .option('-f, --force-template', 'Force rebuild from template')
239
240
  .option('--no-cache', 'Full Docker rebuild (disable layer cache); use after Dockerfile or context fixes')
240
- .option('-t, --tag <tag>', 'Image tag (default: latest). Set image.tag in application.yaml to match for deploy.')
241
+ .option(
242
+ '-t, --tag <tags>',
243
+ 'Image tag or comma-separated tags (default: latest); {version} expands to app.version, e.g. "latest,{version}"'
244
+ )
245
+ .option('-r, --registry <registry>', 'Registry host for the image name (overrides dev set-registry and application.yaml)')
241
246
  .option(
242
247
  '--base',
243
248
  'Build/run the manifest base image (no -devN suffix). Default local build uses a developer-scoped repository when developer-id > 0.'
@@ -246,10 +251,16 @@ function registerBuildCommand(program) {
246
251
  '--from-dev',
247
252
  'With --base: tag the existing developer-scoped image as the base image instead of rebuilding when possible'
248
253
  )
254
+ .addHelpText('after', `
255
+ Examples:
256
+ $ aifabrix build myapp
257
+ $ aifabrix build myapp --tag v1.0.0 # also tags latest
258
+ $ aifabrix build myapp --base --from-dev --tag 'latest,{version}'
259
+ $ aifabrix build myapp --registry myregistry.azurecr.io`)
249
260
  .action(async(appName, options) => {
250
261
  try {
251
- const imageTag = await app.buildApp(appName, options);
252
- logger.log(`✔ Built image: ${imageTag}`);
262
+ const imageRefs = await app.buildApp(appName, options);
263
+ logger.log(`✔ Built image: ${imageRefs}`);
253
264
  } catch (error) {
254
265
  handleCommandError(error, 'build');
255
266
  process.exit(1);
@@ -403,8 +414,8 @@ function setupPushDeployDockerfileCommands(program) {
403
414
  program.command('push <appKey>')
404
415
  .description('Push builder appKey image to Azure Container Registry')
405
416
  .addHelpText('after', PUSH_HELP_AFTER)
406
- .option('-r, --registry <registry>', 'ACR registry URL (overrides application.yaml)')
407
- .option('-t, --tag <tag>', 'Image tag(s) - comma-separated for multiple (default: latest)')
417
+ .option('-r, --registry <registry>', 'Registry host (overrides dev set-registry and application.yaml)')
418
+ .option('-t, --tag <tags>', 'Image tag or comma-separated tags (default: latest); {version} expands to app.version')
408
419
  .action(async(appName, options) => {
409
420
  try {
410
421
  await app.pushApp(appName, options);
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Dev subcommands: set-home, set-work, print-home, print-work, shell-env, set-format.
2
+ * Dev subcommands: set-home, set-work, print-home, print-work, shell-env, set-format,
3
+ * set-registry, print-registry.
3
4
  *
4
5
  * @fileoverview Path and format CLI registration for `aifabrix dev`
5
6
  * @author AI Fabrix Team
@@ -178,8 +179,53 @@ function addSetFormatCommand(dev, handleSetFormat) {
178
179
  });
179
180
  }
180
181
 
182
+ const SET_REGISTRY_HELP = `
183
+ Examples:
184
+ $ aifabrix dev set-registry myregistry.azurecr.io
185
+ $ printf '%s' "$TOKEN" | aifabrix dev set-registry myregistry.azurecr.io --username puller --password-stdin
186
+ $ aifabrix dev set-registry myregistry.azurecr.io --clear-credentials
187
+ $ aifabrix dev set-registry --clear
188
+
189
+ Registry precedence for build, run, push and deploy: --registry, then this value, then the
190
+ application image.registry. Credentials are stored encrypted in your local secrets file as
191
+ <registry>-username and <registry>-password and are used for docker login (password on stdin).`;
192
+
193
+ function addRegistryCommands(dev) {
194
+ const { runSetRegistry, runPrintRegistry } = require('../commands/dev-registry');
195
+ dev
196
+ .command('set-registry [registry]')
197
+ .description('Set, clear or add credentials for the default container registry of this CLI installation')
198
+ .option('--username <username>', 'Registry username (requires a password option)')
199
+ .option('--password <password>', 'Registry password or token (visible in shell history; prefer --password-stdin)')
200
+ .option('--password-stdin', 'Read the registry password or token from stdin')
201
+ .option('--clear', 'Remove the saved default registry (credentials are kept)')
202
+ .option('--clear-credentials', 'Remove only the stored credentials for <registry>')
203
+ .addHelpText('after', SET_REGISTRY_HELP)
204
+ .action(async(registry, cmdOpts) => {
205
+ try {
206
+ await runSetRegistry(registry, cmdOpts);
207
+ } catch (error) {
208
+ handleCommandError(error, 'dev set-registry');
209
+ process.exit(1);
210
+ }
211
+ });
212
+
213
+ dev
214
+ .command('print-registry')
215
+ .description('Print the saved default registry or an empty line (stdout only; for scripts)')
216
+ .addHelpText('after', '\nExamples:\n $ aifabrix dev print-registry\n')
217
+ .action(async() => {
218
+ try {
219
+ await runPrintRegistry();
220
+ } catch (error) {
221
+ handleCommandError(error, 'dev print-registry');
222
+ process.exit(1);
223
+ }
224
+ });
225
+ }
226
+
181
227
  /**
182
- * Register dev set-home, set-work, print-*, set-format.
228
+ * Register dev set-home, set-work, print-*, set-format, set-registry, print-registry.
183
229
  * @param {import('commander').Command} dev - dev subcommand group
184
230
  * @param {function(string): Promise<void>} handleSetFormat - handler that updates format and refreshes display
185
231
  * @returns {void}
@@ -190,6 +236,7 @@ function setupDevPathAndFormatCommands(dev, handleSetFormat) {
190
236
  addSetWorkCommand(dev);
191
237
  addPrintHomeWorkCommands(dev);
192
238
  addSetFormatCommand(dev, handleSetFormat);
239
+ addRegistryCommands(dev);
193
240
  }
194
241
 
195
242
  module.exports = { setupDevPathAndFormatCommands };
@@ -21,14 +21,21 @@ function addMarketplaceOptions(command) {
21
21
  .option('--vendor <vendor>', 'Installation vendor: AZURE (default) or DOCKER (register only)')
22
22
  .option('--allow-my-ip', 'Allow the caller public IPv4 /32 to reach PostgreSQL, Key Vault and Storage (infra only; default off)')
23
23
  .option('--license-edition <edition>', 'License requested at activation: COMMUNITY, STANDARD, ENTERPRISE or a Marketplace subscription ID (infra only; saved in the workspace)')
24
+ .option('--location <region>', 'Azure region for infra (saved to the workspace; dev, tst and pro inherit it)')
25
+ .option(
26
+ '--acr-auth <mode>',
27
+ 'Image pull auth for infra: auto (default: saved dev set-registry pair, else managed identity), mi, or credentials (admin is a deprecated alias)'
28
+ )
24
29
  .option('--miso-api-key-env <name>', 'Environment variable holding a temporary Miso API key (dev, tst, pro)')
25
- .option('--controller-url <url>', 'Controller URL override for dev, tst or pro (deprecated platform stage also uses it)')
30
+ .option(
31
+ '--controller-url <url>',
32
+ 'Controller endpoint override for dev, tst or pro (normally recorded by infra); does not authenticate'
33
+ )
26
34
  .option('--marketplace-json <file>', 'Deprecated explicit mode: ARM Marketplace infrastructure template')
27
35
  .option('--parameters <file>', 'Deprecated explicit mode: secure ARM deploymentParameters JSON file')
28
36
  .option('--subscription <id>', 'Deprecated explicit mode: Azure subscription ID')
29
37
  .option('--resource-group <name>', 'Deprecated explicit mode: new isolated Azure resource group')
30
- .option('--install-id <id>', 'Deprecated explicit mode: installation ID and resume-journal key')
31
- .option('--location <region>', 'Deprecated explicit mode: Azure region when not set in the parameter file');
38
+ .option('--install-id <id>', 'Deprecated explicit mode: installation ID and resume-journal key');
32
39
  }
33
40
 
34
41
  async function runLegacyMarketplace(envKey, options) {
@@ -78,6 +85,8 @@ Installation workspace (<env> is the resource group; files under infrastructure/
78
85
  $ af env deploy <resource-group> --stage infra
79
86
  $ af env deploy <resource-group> --stage infra --allow-my-ip
80
87
  $ af env deploy <resource-group> --stage infra --license-edition ENTERPRISE
88
+ $ af env deploy <resource-group> --stage infra --location swedencentral
89
+ $ af env deploy <resource-group> --stage infra --acr-auth credentials
81
90
  $ af env deploy <resource-group> --stage dev
82
91
  $ af env deploy <resource-group> --stage pro --preset m
83
92
  $ af env deploy <resource-group> --stage register --vendor DOCKER
@@ -86,7 +95,11 @@ Vendor/stage matrix:
86
95
  AZURE infra, dev, tst, pro (Mori bootstrap runs inside the Marketplace template)
87
96
  DOCKER register only (deploy with aifabrix setup)
88
97
 
89
- Prerequisites: aifabrix login (controller permission controller:deploy); infra also needs az login.
98
+ Prerequisites: aifabrix login or MISO_API_KEY / --miso-api-key-env (controller permission controller:deploy);
99
+ infra also needs az login. --controller-url only selects the endpoint; it never replaces authentication.
100
+ Registry pull auth for infra: save a pair once with aifabrix dev set-registry <acr-login-server> --username <user> --password-stdin.
101
+ Deprecated explicit Marketplace flags: --marketplace-json, --parameters, --subscription, --resource-group, --install-id
102
+ (that route also accepts --location).
90
103
  Environment keys: miso, dev, tst, pro. Default size preset: s (use --preset s|m|l|xl).
91
104
  Tenant activation after infra is a manual step in the Miso web application.
92
105
  Run this before aifabrix deploy <app> — the environment must exist first.`;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `aifabrix dev set-registry` / `dev print-registry` logic.
3
+ *
4
+ * @fileoverview Installation registry preference and host-scoped credential management
5
+ * @author AI Fabrix Team
6
+ * @version 1.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const config = require('../core/config');
12
+ const logger = require('../utils/logger');
13
+ const { formatSuccessLine, formatWarningLine } = require('../utils/cli-test-layout-chalk');
14
+ const { normalizeRegistryHost } = require('../utils/config-registry-preference');
15
+ const registryCredentials = require('../utils/registry-credentials');
16
+
17
+ /**
18
+ * Reads the whole of stdin (for --password-stdin).
19
+ * @param {NodeJS.ReadableStream} [stream] - Input stream
20
+ * @returns {Promise<string>}
21
+ */
22
+ function readAllStdin(stream = process.stdin) {
23
+ return new Promise((resolve, reject) => {
24
+ let data = '';
25
+ stream.setEncoding('utf8');
26
+ stream.on('data', (chunk) => {
27
+ data += chunk;
28
+ });
29
+ stream.on('end', () => resolve(data));
30
+ stream.on('error', reject);
31
+ });
32
+ }
33
+
34
+ function hasOption(value) {
35
+ return value !== undefined && value !== null;
36
+ }
37
+
38
+ function assertModeOptions(registry, opts) {
39
+ const credentialOption = hasOption(opts.username) || hasOption(opts.password) || opts.passwordStdin === true;
40
+ if (opts.clear && opts.clearCredentials) {
41
+ throw new Error('Use either --clear or --clear-credentials, not both');
42
+ }
43
+ if ((opts.clear || opts.clearCredentials) && credentialOption) {
44
+ throw new Error('--clear and --clear-credentials cannot be combined with --username or a password option');
45
+ }
46
+ if (opts.clear && hasOption(registry)) {
47
+ throw new Error('--clear removes the saved registry and takes no registry argument');
48
+ }
49
+ if (!opts.clear && !(typeof registry === 'string' && registry.trim())) {
50
+ throw new Error('Registry is required (for example: aifabrix dev set-registry myregistry.azurecr.io)');
51
+ }
52
+ }
53
+
54
+ function assertCredentialOptions(opts) {
55
+ if (hasOption(opts.password) && opts.passwordStdin === true) {
56
+ throw new Error('Use either --password or --password-stdin, not both');
57
+ }
58
+ const passwordGiven = hasOption(opts.password) || opts.passwordStdin === true;
59
+ if (hasOption(opts.username) !== passwordGiven) {
60
+ throw new Error('--username and a password (--password or --password-stdin) must be supplied together');
61
+ }
62
+ if (hasOption(opts.username) && !String(opts.username).trim()) {
63
+ throw new Error('--username must not be empty');
64
+ }
65
+ if (hasOption(opts.password) && String(opts.password) === '') {
66
+ throw new Error('--password must not be empty');
67
+ }
68
+ return passwordGiven;
69
+ }
70
+
71
+ async function resolvePassword(opts, readStdin) {
72
+ if (opts.passwordStdin !== true) return String(opts.password);
73
+ const password = (await readStdin()).replace(/\r?\n$/, '');
74
+ if (!password) {
75
+ throw new Error('--password-stdin received an empty password');
76
+ }
77
+ return password;
78
+ }
79
+
80
+ async function saveWithCredentials(host, username, password) {
81
+ const saved = await registryCredentials.saveRegistryCredentials(host, username, password);
82
+ try {
83
+ await config.setRegistry(host);
84
+ } catch (error) {
85
+ saved.rollback();
86
+ throw new Error(`Could not save the registry preference; credentials for ${host} were restored (${error.message})`);
87
+ }
88
+ }
89
+
90
+ async function runClearMode(registry, opts) {
91
+ if (opts.clear) {
92
+ await config.clearRegistry();
93
+ logger.log(formatSuccessLine('Registry cleared (application image.registry applies again)'));
94
+ return { action: 'clear', registry: null, credentials: false };
95
+ }
96
+ const host = normalizeRegistryHost(registry);
97
+ const removed = registryCredentials.removeRegistryCredentials(host);
98
+ logger.log(formatSuccessLine(removed ? `Credentials removed for ${host}` : `No stored credentials for ${host}`));
99
+ return { action: 'clear-credentials', registry: host, credentials: false };
100
+ }
101
+
102
+ /**
103
+ * Runs `dev set-registry`.
104
+ * @param {string|undefined} registry - Positional registry
105
+ * @param {Object} opts - Commander options
106
+ * @param {Object} [deps] - `readStdin` override (tests)
107
+ * @returns {Promise<{ action: string, registry: string|null, credentials: boolean }>}
108
+ */
109
+ async function runSetRegistry(registry, opts = {}, deps = {}) {
110
+ assertModeOptions(registry, opts);
111
+ if (opts.clear || opts.clearCredentials) return runClearMode(registry, opts);
112
+ const host = normalizeRegistryHost(registry);
113
+ const withCredentials = assertCredentialOptions(opts);
114
+ if (!withCredentials) {
115
+ await config.setRegistry(host);
116
+ logger.log(formatSuccessLine(`Registry set to ${host}`));
117
+ return { action: 'set', registry: host, credentials: false };
118
+ }
119
+ const password = await resolvePassword(opts, deps.readStdin || readAllStdin);
120
+ await saveWithCredentials(host, String(opts.username).trim(), password);
121
+ logger.log(formatSuccessLine(`Registry set to ${host}`));
122
+ logger.log(formatSuccessLine(`Credentials saved for ${host} in your local secrets file (encrypted)`));
123
+ if (opts.passwordStdin !== true) {
124
+ logger.log(formatWarningLine('--password can appear in shell history and process listings; prefer --password-stdin'));
125
+ }
126
+ return { action: 'set', registry: host, credentials: true };
127
+ }
128
+
129
+ /**
130
+ * `dev print-registry`: the saved value plus a newline, or an empty line. Stdout only.
131
+ * @returns {Promise<void>}
132
+ */
133
+ async function runPrintRegistry() {
134
+ const registry = await config.getRegistry();
135
+ process.stdout.write(`${registry || ''}\n`);
136
+ }
137
+
138
+ /**
139
+ * Safe credential status label for `dev show` (never decrypts).
140
+ * @param {string|null} registry - Saved registry
141
+ * @returns {string|null}
142
+ */
143
+ function registryCredentialLabel(registry) {
144
+ if (!registry) return null;
145
+ try {
146
+ const status = registryCredentials.getRegistryCredentialStatus(registry);
147
+ if (status === 'complete') return 'configured';
148
+ if (status === 'absent') return 'not configured';
149
+ return `incomplete (run: aifabrix dev set-registry ${registry} --clear-credentials)`;
150
+ } catch {
151
+ return 'unknown';
152
+ }
153
+ }
154
+
155
+ module.exports = { runSetRegistry, runPrintRegistry, registryCredentialLabel, readAllStdin };
@@ -336,6 +336,8 @@ function logConfigurationSection(p) {
336
336
  logRow('Environment', p.environment);
337
337
  logRow('Controller', p.controller);
338
338
  logRow('Format', p.formatVal);
339
+ logRow('Registry', p.registry);
340
+ if (p.registry) logRow('Registry login', p.registryCredentials);
339
341
  logRow('Platform mode', p.setupPlatformMode);
340
342
  logRow('Traefik', p.traefikEnabled ? 'on' : 'off');
341
343
  logRow('Scoped resources', p.scopedResourcesLabel);
@@ -389,14 +391,16 @@ async function loadDevShowData(devIdStr) {
389
391
  const ports = devConfig.getDevPorts(Number.isNaN(devIdNum) ? 0 : devIdNum);
390
392
  const [
391
393
  environment, tlsEnabled, controller, formatRaw, useScoped, secretsRaw, setupPlatformMode, traefikEnabled,
392
- remoteServer, dockerEndpoint, dockerTlsSkipVerify, mutagenFolder, syncSshUser, syncSshHost
394
+ remoteServer, dockerEndpoint, dockerTlsSkipVerify, mutagenFolder, syncSshUser, syncSshHost, registry
393
395
  ] = await Promise.all([
394
396
  config.getCurrentEnvironment(), config.getTlsEnabled(), config.getControllerUrl(),
395
397
  config.getFormat(), config.getUseEnvironmentScopedResources(), config.getAifabrixSecretsPath(),
396
398
  config.getSetupPlatformMode(), config.getTraefikEnabled(),
397
399
  config.getRemoteServer(), config.getDockerEndpoint(), config.getDockerTlsSkipVerify(),
398
- config.getUserMutagenFolder(), config.getSyncSshUser(), config.getSyncSshHost()
400
+ config.getUserMutagenFolder(), config.getSyncSshUser(), config.getSyncSshHost(),
401
+ config.getRegistry ? config.getRegistry() : null
399
402
  ]);
403
+ const { registryCredentialLabel } = require('./dev-registry');
400
404
  const urlsLocalPath = getUrlsLocalYamlPath();
401
405
  const scopedResourcesLabel = useScoped ? 'on' : 'off (default)';
402
406
  const workResolved = paths.getAifabrixWork();
@@ -412,6 +416,8 @@ async function loadDevShowData(devIdStr) {
412
416
  tlsEnabled,
413
417
  controller,
414
418
  formatVal: formatRaw ?? null,
419
+ registry: registry || null,
420
+ registryCredentials: registryCredentialLabel(registry || null),
415
421
  scopedResourcesLabel,
416
422
  setupPlatformMode,
417
423
  traefikEnabled,
@@ -451,6 +457,8 @@ function renderDevShow(data) {
451
457
  tlsEnabled: data.tlsEnabled,
452
458
  controller: data.controller,
453
459
  formatVal: data.formatVal,
460
+ registry: data.registry,
461
+ registryCredentials: data.registryCredentials,
454
462
  setupPlatformMode: data.setupPlatformMode,
455
463
  traefikEnabled: data.traefikEnabled,
456
464
  scopedResourcesLabel: data.scopedResourcesLabel,
@@ -21,9 +21,30 @@ const infraHelpers = require('../infrastructure/helpers');
21
21
  const upCommon = require('./up-common');
22
22
  const { sectionTitle, formatDatasourceListRow } = require('../utils/cli-test-layout-chalk');
23
23
  const { startSpinner, stopSpinnerSuccess } = require('./setup-spinners');
24
+ const { ensureLoginForImageRefs } = require('../utils/registry-credentials');
24
25
 
25
26
  const PLATFORM_APPS = ['keycloak', 'miso-controller', 'dataplane'];
26
27
 
28
+ /**
29
+ * `image:` values of a compose file (plain scan; variables are left unresolved and skipped).
30
+ * @param {string} composePath - Compose file path
31
+ * @returns {string[]}
32
+ */
33
+ function readComposeImageRefs(composePath) {
34
+ let text = '';
35
+ try {
36
+ text = String(fs.readFileSync(composePath, 'utf8') || '');
37
+ } catch {
38
+ return [];
39
+ }
40
+ const refs = [];
41
+ for (const line of text.split(/\r?\n/)) {
42
+ const match = line.match(/^\s*image:\s*["']?([^"'\s#]+)["']?/);
43
+ if (match && !match[1].includes('$')) refs.push(match[1]);
44
+ }
45
+ return refs;
46
+ }
47
+
27
48
  /**
28
49
  * Pull infra-compose images (postgres, redis, optional pgadmin/redis-commander/traefik).
29
50
  *
@@ -42,6 +63,8 @@ async function pullInfraImages(opts = {}) {
42
63
  }
43
64
  return;
44
65
  }
66
+ // Authenticate once per private registry host before compose pulls (stored pairs only).
67
+ await ensureLoginForImageRefs(readComposeImageRefs(composePath));
45
68
  const project = infraHelpers.getInfraProjectName(devId);
46
69
  const composeCmd = await dockerUtils.getComposeCommand();
47
70
  if (!quiet) {
@@ -91,6 +114,8 @@ async function pullPlatformAppImage(appName, opts = {}) {
91
114
  }
92
115
  return false;
93
116
  }
117
+ // A partial stored credential pair fails closed here instead of degrading to a warning.
118
+ await ensureLoginForImageRefs([imageRef]);
94
119
  try {
95
120
  if (!quiet) {
96
121
  const spin = startSpinner(`Pulling ${appName} image...`);
@@ -142,5 +167,6 @@ module.exports = {
142
167
  PLATFORM_APPS,
143
168
  pullInfraImages,
144
169
  pullPlatformImages,
170
+ readComposeImageRefs,
145
171
  runSetupImageRefresh
146
172
  };