@aifabrix/builder 2.56.0 → 2.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/lib/agent-kit/session-report.js +76 -0
  2. package/lib/agent-kit/start.js +124 -57
  3. package/lib/agent-kit/tmux-sessions.js +163 -0
  4. package/lib/api/environments.api.js +38 -0
  5. package/lib/api/marketplace-bootstrap.api.js +31 -0
  6. package/lib/cli/setup-environment.js +76 -19
  7. package/lib/commands/agent-kit-help.js +32 -15
  8. package/lib/commands/agent-kit.js +90 -8
  9. package/lib/deployment/installation/azure-infra-stage.js +160 -0
  10. package/lib/deployment/installation/azure-preflight.js +102 -0
  11. package/lib/deployment/installation/azure-readiness.js +71 -0
  12. package/lib/deployment/installation/docker-register.js +112 -0
  13. package/lib/deployment/installation/environment-auth.js +76 -0
  14. package/lib/deployment/installation/environment-manifest.js +165 -0
  15. package/lib/deployment/installation/environment-stage.js +191 -0
  16. package/lib/deployment/installation/index.js +39 -0
  17. package/lib/deployment/installation/infra-catalog.js +165 -0
  18. package/lib/deployment/installation/infra-manifest.js +161 -0
  19. package/lib/deployment/installation/infra-projection.js +86 -0
  20. package/lib/deployment/installation/public-ip.js +65 -0
  21. package/lib/deployment/installation/stage-input.js +169 -0
  22. package/lib/deployment/installation/stage-output.js +53 -0
  23. package/lib/deployment/installation/workspace-env.js +76 -0
  24. package/lib/deployment/installation/workspace-files.js +178 -0
  25. package/lib/deployment/installation/workspace-schema.js +40 -0
  26. package/lib/deployment/installation/workspace-secrets.js +66 -0
  27. package/lib/deployment/installation/workspace.js +128 -0
  28. package/lib/deployment/marketplace-environment.js +305 -0
  29. package/lib/generator/builders.js +4 -0
  30. package/lib/generator/helpers.js +55 -56
  31. package/lib/generator/split-variables.js +11 -5
  32. package/lib/internal/fs-real-sync.js +29 -0
  33. package/lib/role-assistant/package-validate.js +4 -0
  34. package/lib/role-assistant/test-runner-clarification-resubmit.js +149 -0
  35. package/lib/role-assistant/test-runner.js +14 -17
  36. package/lib/schema/application-schema.json +20 -2
  37. package/lib/schema/infra-parameter.schema.json +7 -0
  38. package/lib/schema/infra.parameter.yaml +26 -36
  39. package/lib/schema/infrastructure-schema.json +1480 -399
  40. package/lib/schema/installation-environment.schema.json +601 -0
  41. package/lib/utils/variable-transformer.js +3 -0
  42. package/lib/validation/required-variables.js +41 -0
  43. package/lib/validation/validator.js +8 -5
  44. package/package.json +5 -1
  45. package/templates/README.md +4 -0
  46. package/templates/agent-kit/instructions/AGENTKIT.md +6 -6
  47. package/templates/agent-kit/skills/shared/hosts.md +10 -10
  48. package/templates/applications/dataplane/application.yaml +1 -1
  49. package/templates/applications/dataplane/env.template +1 -1
  50. package/templates/applications/miso-controller/application.yaml +3 -2
  51. package/templates/applications/miso-controller/env.template +15 -6
  52. package/templates/marketplace/createUiDefinition.json +77 -0
  53. package/templates/marketplace/install.parameters.example.json +9 -0
  54. package/templates/marketplace/main.json +4087 -0
@@ -7,39 +7,95 @@
7
7
  */
8
8
 
9
9
  const { handleCommandError } = require('../utils/cli-utils');
10
+ const { formatWarningLine } = require('../utils/cli-test-layout-chalk');
11
+ const { isWorkspaceMode, isLegacyMarketplaceMode } = require('../deployment/installation/stage-input');
10
12
 
11
13
  /**
12
- * Sets up environment deployment commands
13
- * @param {Command} program - Commander program instance
14
+ * Adds installation workspace and deprecated explicit Marketplace options.
15
+ * @param {Command} command - Commander command
16
+ * @returns {Command} The command
14
17
  */
15
- function setupEnvironmentCommands(program) {
16
- const deployEnvHandler = async(envKey, options) => {
17
- try {
18
+ function addMarketplaceOptions(command) {
19
+ return command
20
+ .option('--stage <stage>', 'Installation workspace stage: register, infra, dev, tst or pro (<env> is then the resource group)')
21
+ .option('--vendor <vendor>', 'Installation vendor: AZURE (default) or DOCKER (register only)')
22
+ .option('--allow-my-ip', 'Allow the caller public IPv4 /32 to reach PostgreSQL, Key Vault and Storage (infra only; default off)')
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('--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)')
26
+ .option('--marketplace-json <file>', 'Deprecated explicit mode: ARM Marketplace infrastructure template')
27
+ .option('--parameters <file>', 'Deprecated explicit mode: secure ARM deploymentParameters JSON file')
28
+ .option('--subscription <id>', 'Deprecated explicit mode: Azure subscription ID')
29
+ .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');
32
+ }
33
+
34
+ async function runLegacyMarketplace(envKey, options) {
35
+ process.stderr.write(`${formatWarningLine('Explicit Marketplace flags are deprecated; use af env deploy <resource-group> --stage infra')}\n`);
36
+ const installer = require('../deployment/marketplace-environment');
37
+ const result = await installer.deployMarketplaceEnvironment(envKey, options);
38
+ if (result.platformComplete) {
39
+ process.stdout.write(`Platform ready: ${result.publicControllerUrl}\n`);
40
+ } else {
41
+ process.stdout.write(`Infrastructure deployed: ${result.outputs.webAppUrl} (deployment ${result.deploymentId})\n`);
42
+ process.stdout.write(`Resume after controller login and wizard activation: af env deploy ${envKey} --install-id ${result.installId} --controller-url https://<ready-public-host>/miso --stage platform\n`);
43
+ }
44
+ }
45
+
46
+ async function runInstallationStage(resourceGroup, options) {
47
+ const { deployInstallationStage } = require('../deployment/installation');
48
+ const { formatStageResult } = require('../deployment/installation/stage-output');
49
+ const result = await deployInstallationStage(resourceGroup, options);
50
+ process.stdout.write(formatStageResult(result, resourceGroup));
51
+ }
52
+
53
+ async function deployEnvHandler(envKey, options) {
54
+ try {
55
+ if (isWorkspaceMode(options)) {
56
+ await runInstallationStage(envKey, options);
57
+ } else if (isLegacyMarketplaceMode(options)) {
58
+ await runLegacyMarketplace(envKey, options);
59
+ } else {
18
60
  const environmentDeploy = require('../deployment/environment');
19
61
  await environmentDeploy.deployEnvironment(envKey, options);
20
- } catch (error) {
21
- handleCommandError(error, 'env deploy');
22
- process.exit(1);
23
62
  }
24
- };
63
+ } catch (error) {
64
+ handleCommandError(error, 'env deploy');
65
+ process.exit(1);
66
+ }
67
+ }
25
68
 
26
- const deployExamples = `
69
+ const DEPLOY_EXAMPLES = `
27
70
  Examples:
28
71
  $ aifabrix env deploy dev
29
72
  $ aifabrix env deploy tst
30
73
  $ aifabrix env deploy pro --preset m
31
- $ aifabrix env deploy dev --preset xl
32
74
  $ aifabrix env deploy dev --config ./env-config.json
33
75
  $ aifabrix env deploy dev --no-poll
34
76
 
35
- Prerequisites: aifabrix login (device token; controller permission controller:deploy).
77
+ Installation workspace (<env> is the resource group; files under infrastructure/<resource-group>/):
78
+ $ af env deploy <resource-group> --stage infra
79
+ $ af env deploy <resource-group> --stage infra --allow-my-ip
80
+ $ af env deploy <resource-group> --stage infra --license-edition ENTERPRISE
81
+ $ af env deploy <resource-group> --stage dev
82
+ $ af env deploy <resource-group> --stage pro --preset m
83
+ $ af env deploy <resource-group> --stage register --vendor DOCKER
84
+
85
+ Vendor/stage matrix:
86
+ AZURE infra, dev, tst, pro (Mori bootstrap runs inside the Marketplace template)
87
+ DOCKER register only (deploy with aifabrix setup)
88
+
89
+ Prerequisites: aifabrix login (controller permission controller:deploy); infra also needs az login.
36
90
  Environment keys: miso, dev, tst, pro. Default size preset: s (use --preset s|m|l|xl).
91
+ Tenant activation after infra is a manual step in the Miso web application.
37
92
  Run this before aifabrix deploy <app> — the environment must exist first.`;
38
93
 
39
- const ENV_GROUP_HELP_AFTER = `
94
+ const ENV_GROUP_HELP_AFTER = `
40
95
  Examples:
41
96
  $ aifabrix env deploy dev
42
97
  $ aifabrix env deploy dev --preset m
98
+ $ af env deploy <resource-group> --stage infra
43
99
  $ aifabrix env deploy dev --help
44
100
 
45
101
  Typical workflow:
@@ -51,21 +107,22 @@ Subcommands:
51
107
  deploy <env> Provision/update environment in Miso Controller (see env deploy --help)
52
108
  `;
53
109
 
110
+ function setupEnvironmentCommands(program) {
54
111
  const env = program
55
112
  .command('env')
56
113
  .description('Miso Controller environments (primary: env deploy <env>)')
57
114
  .addHelpText('after', ENV_GROUP_HELP_AFTER);
58
115
 
59
- env
116
+ addMarketplaceOptions(env
60
117
  .command('deploy <env>')
61
- .description('Deploy environment infrastructure in Miso Controller (run before deploy <app>)')
118
+ .description('Deploy environment infrastructure in Miso Controller, or an installation workspace stage with --stage')
62
119
  .option('--config <file>', 'Environment configuration file (optional if --preset is used)')
63
- .option('--preset <size>', 'Environment size preset: s, m, l, xl (default: s)', 's')
120
+ .option('--preset <size>', 'Environment size preset: s, m, l, xl (default: s, or the workspace file value)')
64
121
  .option('--skip-validation', 'Skip environment validation')
65
122
  .option('--poll', 'Poll for deployment status', true)
66
- .option('--no-poll', 'Do not poll for status')
67
- .addHelpText('after', deployExamples)
123
+ .option('--no-poll', 'Do not poll for status'))
124
+ .addHelpText('after', DEPLOY_EXAMPLES)
68
125
  .action(deployEnvHandler);
69
126
  }
70
127
 
71
- module.exports = { setupEnvironmentCommands };
128
+ module.exports = { setupEnvironmentCommands, deployEnvHandler, addMarketplaceOptions };
@@ -21,9 +21,10 @@ Examples:
21
21
  $ aifabrix agent-kit install --host all --approve-all claude --yes
22
22
  $ aifabrix agent-kit update --host all --approve-all --yes
23
23
  $ aifabrix agent-kit remote create --org my-org --yes
24
- $ aifabrix agent-kit start claude --remote-control --approve-all --yes
25
- $ aifabrix agent-kit start claude --name aifabrix-docs --remote-control --approve-all --yes
26
- $ aifabrix agent-kit start codex --approve-all --yes
24
+ $ aifabrix agent-kit start claude
25
+ $ aifabrix agent-kit start codex
26
+ $ aifabrix agent-kit list
27
+ $ aifabrix agent-kit kill aifabrix-docs
27
28
  $ aifabrix agent-kit status
28
29
  $ aifabrix agent-kit validate
29
30
  `;
@@ -117,23 +118,37 @@ Interactive (TTY): omit flags to be prompted. Silent: --org <owner> --yes
117
118
  const START_HELP = `
118
119
  Does not install Agent Kit skills. Starts claude or codex in tmux in this git
119
120
  workspace so closing the terminal does not kill the session. Detach: Ctrl-b then
120
- d. --approve-all writes that app's local permission config only. --name is the
121
- tmux session (default: last folder, e.g. aifabrix-docs). Claude also gets
122
- --name and --remote-control hostname-user-folder, e.g.
123
- builder01-dev03-aifabrix-docs. Claude --tmux is for worktrees, not this wrapper.
124
- That is not Claude --cloud (a separate cloud VM). Codex has no phone/web attach.
121
+ d. The defaults enable that app's Remote Control and workspace auto-approval.
122
+ --name is the tmux session (default: last git-root folder, e.g. aifabrix-docs).
123
+ Claude gets --name and hostname-user-folder Remote Control. Codex uses its native
124
+ remote-control daemon. Use --no-remote-control and/or --skip-approve-all to opt
125
+ out. Claude --tmux is for worktrees, not this wrapper.
125
126
 
126
127
  Examples:
127
- $ aifabrix agent-kit start claude --remote-control --approve-all --yes
128
- $ aifabrix agent-kit start claude --name aifabrix-docs --remote-control --approve-all --yes
129
- $ aifabrix agent-kit start claude --name aifabrix-miso --remote-control --approve-all --yes
130
- $ aifabrix agent-kit start claude --remote-control --detach
131
128
  $ aifabrix agent-kit start claude
132
- $ aifabrix agent-kit start claude --approve-all --yes
133
- $ aifabrix agent-kit start codex --approve-all --yes
129
+ $ aifabrix agent-kit start codex
130
+ $ aifabrix agent-kit start claude --name aifabrix-miso
131
+ $ aifabrix agent-kit start codex --no-remote-control --skip-approve-all
132
+ $ aifabrix agent-kit start claude --detach
134
133
  $ aifabrix agent-kit start claude --dry-run --json
135
134
  `;
136
135
 
136
+ const LIST_HELP = `
137
+ Lists every active tmux session visible to the current Unix user.
138
+
139
+ Examples:
140
+ $ aifabrix agent-kit list
141
+ $ aifabrix agent-kit list --json
142
+ `;
143
+
144
+ const KILL_HELP = `
145
+ Stops exactly one tmux session. Use agent-kit list to find its name.
146
+
147
+ Examples:
148
+ $ aifabrix agent-kit kill aifabrix-miso
149
+ $ aifabrix agent-kit kill aifabrix-miso --json
150
+ `;
151
+
137
152
  module.exports = {
138
153
  HOST_OPTION_HELP,
139
154
  APPROVE_ALL_OPTION_HELP,
@@ -145,5 +160,7 @@ module.exports = {
145
160
  INIT_HELP,
146
161
  REMOTE_GROUP_HELP,
147
162
  REMOTE_HELP,
148
- START_HELP
163
+ START_HELP,
164
+ LIST_HELP,
165
+ KILL_HELP
149
166
  };
@@ -1,15 +1,18 @@
1
- /**
2
- * `aifabrix agent-kit` command group.
3
- * @fileoverview Install, init, status, update, validate, remote create, start
4
- */
1
+ /** `aifabrix agent-kit` command group. */
5
2
 
6
3
  'use strict';
7
4
 
8
5
  const logger = require('../utils/logger');
9
6
  const { handleCommandError } = require('../utils/cli-utils');
10
- const { formatSuccessLine, formatBlockingError, formatWarningLine } = require('../utils/cli-layout-chalk');
7
+ const {
8
+ formatSuccessLine,
9
+ formatBlockingError,
10
+ formatWarningLine
11
+ } = require('../utils/cli-layout-chalk');
11
12
  const { runInstall, runUpdate } = require('../agent-kit/run');
12
13
  const { runStart } = require('../agent-kit/start');
14
+ const { runList, runKill } = require('../agent-kit/tmux-sessions');
15
+ const { formatSessionList, formatMissingKillName } = require('../agent-kit/session-report');
13
16
  const path = require('path');
14
17
  const { runInit, runRemoteCreate } = require('../agent-kit/init');
15
18
  const { enrichInitHostOptions, enrichInitRemoteOptions, enrichRemoteOptions, enrichApproveAllOptions } = require('../agent-kit/remote-prompts');
@@ -30,6 +33,8 @@ const {
30
33
  REMOTE_GROUP_HELP,
31
34
  REMOTE_HELP,
32
35
  START_HELP,
36
+ LIST_HELP,
37
+ KILL_HELP,
33
38
  HOST_OPTION_HELP,
34
39
  APPROVE_ALL_OPTION_HELP
35
40
  } = require('./agent-kit-help');
@@ -110,6 +115,8 @@ function emitStartReport(json, asJson) {
110
115
  logger.log(`Session ${json.session}`);
111
116
  if (json.remoteControlName) {
112
117
  logger.log(`Remote ${json.remoteControlName}`);
118
+ } else if (json.remoteControl) {
119
+ logger.log('Remote Codex Remote Control');
113
120
  }
114
121
  logger.log(`Path ${json.path}`);
115
122
  logger.log(`Launch ${json.launch.join(' ')}`);
@@ -124,6 +131,32 @@ function emitStartReport(json, asJson) {
124
131
  logger.log(formatSuccessLine(json.dryRun ? 'Dry-run complete' : 'Ready'));
125
132
  }
126
133
 
134
+ /**
135
+ * @param {object} json
136
+ * @param {boolean} asJson
137
+ * @returns {void}
138
+ */
139
+ function emitListReport(json, asJson) {
140
+ if (asJson) {
141
+ logger.log(JSON.stringify(json, null, 2));
142
+ return;
143
+ }
144
+ logger.log(formatSessionList(json));
145
+ }
146
+
147
+ /**
148
+ * @param {object} json
149
+ * @param {boolean} asJson
150
+ * @returns {void}
151
+ */
152
+ function emitKillReport(json, asJson) {
153
+ if (asJson) {
154
+ logger.log(JSON.stringify(json, null, 2));
155
+ return;
156
+ }
157
+ logger.log(formatSuccessLine(`Stopped tmux session ${json.session}`));
158
+ }
159
+
127
160
  /**
128
161
  * @param {Function} runner
129
162
  * @param {object} options
@@ -159,6 +192,8 @@ function setupAgentKitCommands(program) {
159
192
  addValidateCommand(group);
160
193
  addRemoteCommand(group);
161
194
  addStartCommand(group);
195
+ addListCommand(group);
196
+ addKillCommand(group);
162
197
  }
163
198
 
164
199
  /**
@@ -387,8 +422,8 @@ async function runValidateLeaf(options) {
387
422
  function addStartCommand(group) {
388
423
  group
389
424
  .command('start <app>')
390
- .description('Start Claude Code or Codex in tmux (does not install skills)')
391
- .option('--approve-all', 'Write auto-approve config for this app only')
425
+ .description('Start Claude Code or Codex with Remote Control in named tmux')
426
+ .option('--approve-all', 'Write auto-approve config (default for start)')
392
427
  .option('--skip-approve-all', 'Do not write auto-approve configs')
393
428
  .option('--yes', 'Same as --approve-all')
394
429
  .option('--detach', 'Create the tmux session in the background (no attach)')
@@ -398,8 +433,10 @@ function addStartCommand(group) {
398
433
  )
399
434
  .option(
400
435
  '--remote-control',
401
- 'Claude Code only: same session on this PC, claude.ai/code, and the Claude mobile app'
436
+ 'Enable native Remote Control for Claude or Codex (default)',
437
+ true
402
438
  )
439
+ .option('--no-remote-control', 'Start a local tmux-only session')
403
440
  .option('--dry-run', 'Print the launch command without starting it')
404
441
  .option('--json', 'Machine-readable output')
405
442
  .addHelpText('after', START_HELP)
@@ -414,4 +451,49 @@ function addStartCommand(group) {
414
451
  });
415
452
  }
416
453
 
454
+ /**
455
+ * @param {import('commander').Command} group
456
+ */
457
+ function addListCommand(group) {
458
+ group
459
+ .command('list')
460
+ .description('List active tmux sessions for the current user')
461
+ .option('--json', 'Machine-readable output')
462
+ .addHelpText('after', LIST_HELP)
463
+ .action(async(options) => {
464
+ try {
465
+ emitListReport(runList(options), Boolean(options.json));
466
+ } catch (error) {
467
+ handleCommandError(error, 'agent-kit list');
468
+ process.exitCode = 1;
469
+ }
470
+ });
471
+ }
472
+
473
+ /**
474
+ * @param {import('commander').Command} group
475
+ */
476
+ function addKillCommand(group) {
477
+ group
478
+ .command('kill [name]')
479
+ .description('Stop one tmux session by exact name')
480
+ .option('--json', 'Machine-readable output')
481
+ .addHelpText('after', KILL_HELP)
482
+ .action(async(name, options) => {
483
+ if (!name) {
484
+ const report = formatMissingKillName(Boolean(options.json));
485
+ logger.error(report.stderr);
486
+ if (report.stdout) logger.log(report.stdout);
487
+ process.exitCode = 1;
488
+ return;
489
+ }
490
+ try {
491
+ emitKillReport(runKill({ ...options, name }), Boolean(options.json));
492
+ } catch (error) {
493
+ handleCommandError(error, 'agent-kit kill');
494
+ process.exitCode = 1;
495
+ }
496
+ });
497
+ }
498
+
417
499
  module.exports = { setupAgentKitCommands };
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview AZURE `--stage infra`: validates the workspace, resolves ACR and secrets, deploys
5
+ * the workspace Marketplace template snapshot, verifies managed-identity image pull and that both
6
+ * applications started, and records outputs. Mori registration and tenant activation stay with Miso.
7
+ */
8
+
9
+ // Real-sync adapter: installation workspace state must not disappear under Jest fs mocks.
10
+ const fs = require('../../internal/fs-real-sync');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const marketplace = require('../marketplace-environment');
14
+ const { normalizePreset } = require('../environment-config');
15
+ const { normalizeLicenseEdition, ONBOARDING_MODES } = require('./infra-catalog');
16
+ const { materializeWorkspace, saveInfra } = require('./workspace');
17
+ const { missingRequiredFields } = require('./infra-manifest');
18
+ const { buildArmParameters, applyAllowMyIp } = require('./infra-projection');
19
+ const { materializeWorkspaceSecrets } = require('./workspace-secrets');
20
+ const { mergeWorkspaceEnv } = require('./workspace-env');
21
+ const { resolvePublicIpv4 } = require('./public-ip');
22
+ const preflight = require('./azure-preflight');
23
+ const readiness = require('./azure-readiness');
24
+
25
+ const INFRA_SECRETS = Object.freeze(['ADMIN_PASSWORD', 'ENCRYPTION_KEY', 'MORI_CALLBACK_API_KEY']);
26
+
27
+ function missingInputError(file, fields) {
28
+ const error = new Error(
29
+ `Installation workspace needs input before infra can run. Set these fields in ${file}, then rerun:\n - ${fields.join('\n - ')}`
30
+ );
31
+ error.code = 'WORKSPACE_INPUT_REQUIRED';
32
+ return error;
33
+ }
34
+
35
+ function applyLicenseOptions(doc, input) {
36
+ const license = doc.parameters.licenseEdition;
37
+ if (input.licenseEdition !== undefined) {
38
+ doc.parameters.licenseEdition = { ...license, value: normalizeLicenseEdition(input.licenseEdition), source: 'cli' };
39
+ } else {
40
+ doc.parameters.licenseEdition = { ...license, value: normalizeLicenseEdition(license.value) };
41
+ }
42
+ if (!ONBOARDING_MODES.includes(doc.parameters.onboardingMode.value)) {
43
+ throw new Error(`parameters.onboardingMode.value must be one of ${ONBOARDING_MODES.join(', ')}`);
44
+ }
45
+ }
46
+
47
+ async function applyAccessOptions(doc, input, deps) {
48
+ if (input.preset !== undefined) {
49
+ doc.preset = { ...doc.preset, value: normalizePreset(input.preset), source: 'cli' };
50
+ }
51
+ applyLicenseOptions(doc, input);
52
+ const enabled = input.allowMyIp || doc.allowMyIp.value === true;
53
+ if (!enabled) {
54
+ applyAllowMyIp(doc, { enabled: false });
55
+ return;
56
+ }
57
+ const access = await (deps.resolvePublicIpv4 || resolvePublicIpv4)();
58
+ applyAllowMyIp(doc, { enabled: true, ...access });
59
+ }
60
+
61
+ function withTemporaryParameters(document, action) {
62
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aifabrix-arm-'));
63
+ const file = path.join(dir, 'parameters.json');
64
+ try {
65
+ fs.chmodSync(dir, 0o700);
66
+ fs.writeFileSync(file, JSON.stringify(document), { mode: 0o600 });
67
+ return action(file);
68
+ } finally {
69
+ fs.rmSync(dir, { recursive: true, force: true });
70
+ }
71
+ }
72
+
73
+ function recordDeployment(doc, record, now) {
74
+ doc.deployment = {
75
+ ...doc.deployment,
76
+ status: { ...doc.deployment.status, value: 'succeeded' },
77
+ name: record.deploymentName,
78
+ id: record.deploymentId,
79
+ correlationId: record.correlationId || null,
80
+ inputHash: record.inputHash,
81
+ completedAt: record.recordedAt || now().toISOString()
82
+ };
83
+ for (const name of Object.keys(doc.outputs)) {
84
+ doc.outputs[name].value = record.outputs[name] ?? null;
85
+ }
86
+ }
87
+
88
+ function deployTemplate(workspace, doc, context) {
89
+ const { document, inputHash } = buildArmParameters(doc, context);
90
+ return withTemporaryParameters(document, parametersFile => marketplace.deployInfra({
91
+ installId: doc.installId.value,
92
+ envKey: null,
93
+ subscription: doc.subscriptionId.value,
94
+ resourceGroup: doc.resourceGroup.value,
95
+ location: doc.parameters.location.value,
96
+ template: workspace.paths.template,
97
+ parameters: parametersFile,
98
+ templateHash: context.templateHash,
99
+ inputHash
100
+ }, context.az));
101
+ }
102
+
103
+ async function verifyAndRecordReadiness(workspace, doc, deps) {
104
+ readiness.verifyRuntimeImagePull(doc, deps.az);
105
+ await readiness.waitForApplications(doc, deps.probe || marketplace.probeHttps);
106
+ const controllerUrl = `${String(doc.outputs.webAppUrl.value).replace(/\/+$/, '')}/miso`;
107
+ doc.controllerUrl = { ...doc.controllerUrl, value: controllerUrl };
108
+ doc.tenantActivation.url = { ...doc.tenantActivation.url, value: controllerUrl };
109
+ saveInfra(workspace, doc);
110
+ mergeWorkspaceEnv(workspace.paths.env, { CONTROLLER_URL: controllerUrl }, { root: workspace.root, replaceable: ['CONTROLLER_URL'] });
111
+ return controllerUrl;
112
+ }
113
+
114
+ async function prepareInfra(workspace, input, deps, az) {
115
+ const { doc, templateHash } = materializeWorkspace(workspace, input, deps);
116
+ const missing = missingRequiredFields(doc);
117
+ if (missing.length > 0) throw missingInputError(workspace.paths.infra, missing);
118
+ preflight.assertActiveAccount(doc, az);
119
+ const acr = preflight.resolveAcr(doc, az);
120
+ preflight.assertDeploymentAuthority(doc, acr, az);
121
+ doc.parameters.acrLoginServer.value = acr.loginServer;
122
+ doc.parameters.acrResourceId.value = acr.id;
123
+ await applyAccessOptions(doc, input, deps);
124
+ saveInfra(workspace, doc);
125
+ return { doc, templateHash, acr };
126
+ }
127
+
128
+ function deployWithStatus(workspace, doc, context) {
129
+ doc.deployment.status = { ...doc.deployment.status, value: 'running' };
130
+ saveInfra(workspace, doc);
131
+ try {
132
+ return deployTemplate(workspace, doc, context);
133
+ } catch (error) {
134
+ doc.deployment.status = { ...doc.deployment.status, value: 'failed' };
135
+ saveInfra(workspace, doc);
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Runs the AZURE infra stage.
142
+ * @param {Object} workspace - Opened workspace
143
+ * @param {Object} input - Validated stage input
144
+ * @param {Object} [deps] - Injected dependencies (az, probe, resolvePublicIpv4, secretsApi, now, templatePath)
145
+ * @returns {Promise<{ doc: Object, controllerUrl: string, activationUrl: string }>}
146
+ */
147
+ async function runAzureInfraStage(workspace, input, deps = {}) {
148
+ const context = { az: deps.az || marketplace.runAz, now: deps.now || (() => new Date()), probe: deps.probe };
149
+ const { doc, templateHash, acr } = await prepareInfra(workspace, input, deps, context.az);
150
+ const env = await materializeWorkspaceSecrets({
151
+ vendor: 'AZURE', envPath: workspace.paths.env, root: workspace.root, names: INFRA_SECRETS, secretsApi: deps.secretsApi
152
+ });
153
+ const record = deployWithStatus(workspace, doc, { env, acr, templateHash, az: context.az });
154
+ recordDeployment(doc, record, context.now);
155
+ saveInfra(workspace, doc);
156
+ const controllerUrl = await verifyAndRecordReadiness(workspace, doc, context);
157
+ return { doc, controllerUrl, activationUrl: doc.tenantActivation.url.value };
158
+ }
159
+
160
+ module.exports = { runAzureInfraStage, INFRA_SECRETS };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview AZURE infra preflight before any Azure mutation: active account equals the
5
+ * workspace subscription/tenant, `acrName` resolves inside that subscription, and the caller can
6
+ * create deployments, role assignments and the registry AcrPull grant. Uses only the operator's Azure CLI session; no tokens
7
+ * are stored.
8
+ */
9
+
10
+ const ROLE_ASSIGNMENT_WRITE = 'Microsoft.Authorization/roleAssignments/write';
11
+ const DEPLOYMENT_WRITE = 'Microsoft.Resources/deployments/write';
12
+ const PERMISSIONS_API = '2022-04-01';
13
+
14
+ function parseJson(text, label) {
15
+ try {
16
+ return JSON.parse(text);
17
+ } catch (_) {
18
+ throw new Error(`Azure CLI returned an unreadable ${label} response`);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * @param {Object} doc - Infra manifest
24
+ * @param {Function} az - Azure CLI runner
25
+ * @throws {Error} When the active account differs from the workspace target
26
+ */
27
+ function assertActiveAccount(doc, az) {
28
+ const account = parseJson(az(['account', 'show', '--output', 'json']), 'account');
29
+ const expectedSub = String(doc.subscriptionId.value).toLowerCase();
30
+ const expectedTenant = String(doc.tenantId.value).toLowerCase();
31
+ if (String(account.id).toLowerCase() !== expectedSub || String(account.tenantId).toLowerCase() !== expectedTenant) {
32
+ throw new Error(
33
+ `Active Azure account (subscription ${account.id}, tenant ${account.tenantId}) does not match the workspace ` +
34
+ `(subscription ${doc.subscriptionId.value}, tenant ${doc.tenantId.value}). ` +
35
+ `Run "az login --tenant ${doc.tenantId.value}" and "az account set --subscription ${doc.subscriptionId.value}".`
36
+ );
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Resolves `acrName` to its resource ID and login server within the workspace subscription.
42
+ * @param {Object} doc - Infra manifest
43
+ * @param {Function} az - Azure CLI runner
44
+ * @returns {{ id: string, loginServer: string }}
45
+ */
46
+ function resolveAcr(doc, az) {
47
+ const subscription = String(doc.subscriptionId.value);
48
+ const acr = parseJson(az([
49
+ 'acr', 'show', '--name', doc.acrName.value, '--subscription', subscription, '--output', 'json'
50
+ ]), 'registry');
51
+ if (typeof acr.id !== 'string' || typeof acr.loginServer !== 'string' || !acr.loginServer) {
52
+ throw new Error(`Registry ${doc.acrName.value} did not return a resource ID and login server`);
53
+ }
54
+ if (!acr.id.toLowerCase().startsWith(`/subscriptions/${subscription.toLowerCase()}/`)) {
55
+ throw new Error(`Registry ${doc.acrName.value} is outside the workspace subscription; use a registry in ${subscription}`);
56
+ }
57
+ return { id: acr.id, loginServer: acr.loginServer };
58
+ }
59
+
60
+ function actionMatches(pattern, action) {
61
+ const expression = new RegExp(`^${String(pattern).split('*').map(part => part.replace(/[.+?^${}()|[\]\\/]/g, '\\$&')).join('.*')}$`, 'i');
62
+ return expression.test(action);
63
+ }
64
+
65
+ /**
66
+ * True when Azure RBAC grants `action` at `scope` to the signed-in caller.
67
+ * @param {Function} az - Azure CLI runner
68
+ * @param {string} scope - Resource scope ID
69
+ * @param {string} action - Control-plane action
70
+ * @returns {boolean}
71
+ */
72
+ function hasPermission(az, scope, action) {
73
+ const result = parseJson(az([
74
+ 'rest', '--method', 'get',
75
+ '--url', `https://management.azure.com${scope}/providers/Microsoft.Authorization/permissions?api-version=${PERMISSIONS_API}`,
76
+ '--output', 'json'
77
+ ]), 'permissions');
78
+ return (result.value || []).some(permission =>
79
+ (permission.actions || []).some(pattern => actionMatches(pattern, action)) &&
80
+ !(permission.notActions || []).some(pattern => actionMatches(pattern, action)));
81
+ }
82
+
83
+ /**
84
+ * @param {Object} doc - Infra manifest
85
+ * @param {{ id: string }} acr - Resolved registry
86
+ * @param {Function} az - Azure CLI runner
87
+ * @throws {Error} When deployment, role-assignment or AcrPull grant authority is missing
88
+ */
89
+ function assertDeploymentAuthority(doc, acr, az) {
90
+ const subscriptionScope = `/subscriptions/${doc.subscriptionId.value}`;
91
+ if (!hasPermission(az, subscriptionScope, DEPLOYMENT_WRITE)) {
92
+ throw new Error(`The signed-in Azure account cannot create deployments in subscription ${doc.subscriptionId.value}`);
93
+ }
94
+ if (doc.parameters.deployRoleAssignments.value !== false && !hasPermission(az, subscriptionScope, ROLE_ASSIGNMENT_WRITE)) {
95
+ throw new Error(`The signed-in Azure account cannot create role assignments in subscription ${doc.subscriptionId.value}; ask for Owner or User Access Administrator, or set parameters.deployRoleAssignments.value to false when roles already exist`);
96
+ }
97
+ if (!hasPermission(az, acr.id, ROLE_ASSIGNMENT_WRITE)) {
98
+ throw new Error(`The signed-in Azure account cannot assign AcrPull on registry ${doc.acrName.value}; ask for Owner or User Access Administrator on that registry`);
99
+ }
100
+ }
101
+
102
+ module.exports = { assertActiveAccount, resolveAcr, hasPermission, actionMatches, assertDeploymentAuthority };