@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
@@ -0,0 +1,305 @@
1
+ 'use strict';
2
+
3
+ // Marketplace journals are durable runtime state. Use the repository's real-sync
4
+ // adapter so a caller (or another Jest suite) mocking `fs` cannot make an
5
+ // existing completed journal disappear and trigger a duplicate ARM deployment.
6
+ const fs = require('../internal/fs-real-sync');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { spawnSync } = require('child_process');
10
+ const config = require('../core/config');
11
+ const { getRuntimeConfigDir } = require('../core/config-runtime-paths');
12
+
13
+ const SHIPPED_TEMPLATE = path.resolve(__dirname, '../../templates/marketplace/main.json');
14
+ // Resolved per call (not a module-load constant) so it honors AIFABRIX_HOME/AIFABRIX_CONFIG
15
+ // the same way the rest of the CLI's config does -- including the Jest sandbox tests use.
16
+ function journalDir() {
17
+ return path.join(getRuntimeConfigDir(), 'installations');
18
+ }
19
+ const SAFE_NAME = /^[a-z][a-z0-9-]{2,39}$/;
20
+ const SUBSCRIPTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+
22
+ function readJson(file, label) {
23
+ const resolved = path.resolve(file);
24
+ let document;
25
+ try {
26
+ document = JSON.parse(fs.readFileSync(resolved, 'utf8'));
27
+ } catch (_) {
28
+ throw new Error(`${label} is missing or invalid JSON: ${resolved}`);
29
+ }
30
+ return { document, resolved };
31
+ }
32
+
33
+ function parameterValue(parameters, name) {
34
+ return parameters?.parameters?.[name]?.value;
35
+ }
36
+
37
+ function validateStageTarget(envKey, options) {
38
+ if (!['dev', 'tst', 'pro'].includes(envKey)) {
39
+ throw new Error('Marketplace platform stage requires an entitled workload environment: dev, tst or pro');
40
+ }
41
+ const installId = String(options.installId || '');
42
+ const resourceGroup = String(options.resourceGroup || '');
43
+ if (!SAFE_NAME.test(installId) || (resourceGroup && !SAFE_NAME.test(resourceGroup))) {
44
+ throw new Error('Install ID and resource group must be lowercase Azure-safe names (3-40 characters)');
45
+ }
46
+ const stage = options.stage || 'infra';
47
+ if (!['infra', 'platform', 'all'].includes(stage)) {
48
+ throw new Error('Stage must be infra, platform or all');
49
+ }
50
+ if (stage === 'all' && !options.controllerUrl) {
51
+ throw new Error('--stage all requires --controller-url from a completed wizard; use --stage infra for a new installation');
52
+ }
53
+ if (stage === 'platform') {
54
+ return { envKey, installId, stage, controllerUrl: options.controllerUrl };
55
+ }
56
+ return { envKey, installId, resourceGroup, stage };
57
+ }
58
+
59
+ function validateInfraFiles(target, options) {
60
+ const { resourceGroup } = target;
61
+ if (!resourceGroup || !SUBSCRIPTION_ID.test(String(options.subscription || ''))) {
62
+ throw new Error('Infrastructure stage requires --subscription and --resource-group');
63
+ }
64
+ if (!options.parameters) {
65
+ throw new Error('Infrastructure stage requires a secure --parameters file');
66
+ }
67
+ const template = readJson(options.marketplaceJson || SHIPPED_TEMPLATE, 'Marketplace template');
68
+ const params = readJson(options.parameters, 'ARM parameter file');
69
+ if (fs.statSync(params.resolved).mode & 0o077) {
70
+ throw new Error('ARM parameter file may contain secrets and must be owner-only (chmod 600)');
71
+ }
72
+ if (!template.document.resources || !template.document.parameters || !params.document.parameters) {
73
+ throw new Error('Expected an ARM deployment template and deploymentParameters JSON');
74
+ }
75
+ return { template, params };
76
+ }
77
+
78
+ function validateInfraParameters(target, options, files) {
79
+ const { installId, resourceGroup } = target;
80
+ const { params } = files;
81
+ if (typeof parameterValue(params.document, 'adminPassword') !== 'string' ||
82
+ !parameterValue(params.document, 'adminPassword')) {
83
+ throw new Error('Secure ARM parameter file must supply adminPassword');
84
+ }
85
+ // Bicep module 14_marketplace-mori-bootstrap.bicep's own contract: empty/absent
86
+ // marketplaceSubscriptionId is a valid, intentional direct deploy -- Mori falls back
87
+ // to Community. Only reject a wrong *type*; do not require a non-empty value here,
88
+ // that would force a fabricated id past Mori's real Marketplace Fulfillment check.
89
+ const marketplaceSubscriptionId = parameterValue(params.document, 'marketplaceSubscriptionId');
90
+ if (marketplaceSubscriptionId !== undefined && typeof marketplaceSubscriptionId !== 'string') {
91
+ throw new Error('marketplaceSubscriptionId must be a string when present (empty string means a direct deploy, Community fallback)');
92
+ }
93
+ const nameOverride = parameterValue(params.document, 'infrastructureNameOverride');
94
+ if ((nameOverride && nameOverride !== installId) || (!nameOverride && resourceGroup !== installId)) {
95
+ throw new Error('Installation naming mismatch: infrastructureNameOverride must equal --install-id, or install ID must equal resource group');
96
+ }
97
+ const location = parameterValue(params.document, 'location') || options.location;
98
+ if (typeof location !== 'string' || !/^[a-z0-9]+$/i.test(location)) {
99
+ throw new Error('Infrastructure stage requires a valid Azure location in the parameter file or --location');
100
+ }
101
+ return location;
102
+ }
103
+
104
+ function preflight(envKey, options) {
105
+ const target = validateStageTarget(envKey, options);
106
+ if (target.stage === 'platform') return target;
107
+ const files = validateInfraFiles(target, options);
108
+ const location = validateInfraParameters(target, options, files);
109
+ return {
110
+ ...target, location,
111
+ subscription: options.subscription,
112
+ template: files.template.resolved,
113
+ parameters: files.params.resolved,
114
+ templateHash: crypto.createHash('sha256').update(fs.readFileSync(files.template.resolved)).digest('hex')
115
+ };
116
+ }
117
+
118
+ function journalPath(installId) {
119
+ return path.join(journalDir(), `${installId}.json`);
120
+ }
121
+
122
+ function readJournal(installId) {
123
+ const file = journalPath(installId);
124
+ if (!fs.existsSync(file)) throw new Error(`No infrastructure journal for ${installId}. Run --stage infra first.`);
125
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
126
+ }
127
+
128
+ function writeJournal(record) {
129
+ fs.mkdirSync(journalDir(), { recursive: true, mode: 0o700 });
130
+ fs.writeFileSync(journalPath(record.installId), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
131
+ }
132
+
133
+ function runAz(args) {
134
+ const result = spawnSync('az', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
135
+ if (result.error || result.status !== 0) {
136
+ throw new Error(`Azure CLI ${args.slice(0, 3).join(' ')} failed (exit ${result.status ?? 'unavailable'}). Check Azure deployment diagnostics; secret-bearing output was withheld.`);
137
+ }
138
+ return result.stdout.trim();
139
+ }
140
+
141
+ function azureArgs(base, subscription) {
142
+ return [...base, '--subscription', subscription];
143
+ }
144
+
145
+ function checkExistingJournal(input) {
146
+ const old = fs.existsSync(journalPath(input.installId)) ? readJournal(input.installId) : null;
147
+ if (old) {
148
+ // Workspace mode (inputHash present) owns template/parameter updates through the input hash;
149
+ // the legacy explicit mode still rejects any template change for an existing install ID.
150
+ if (old.subscription !== input.subscription || old.resourceGroup !== input.resourceGroup ||
151
+ (!input.inputHash && old.templateHash !== input.templateHash)) {
152
+ throw new Error('Existing install journal has a different subscription, group or template; use a new install ID');
153
+ }
154
+ if (old.infraComplete && old.inputHash === input.inputHash) return old;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function isOwnResumableDeployment(input, az) {
160
+ // ARM deployments are declarative/idempotent by name: re-running `deployment group
161
+ // create` with the same name only touches resources that still need work (e.g. a
162
+ // nested deployment script that failed) and leaves already-succeeded ones alone. A
163
+ // resource group that already has resources from *our own* prior attempt at this
164
+ // install ID is therefore safe to resume into, not "not empty" in the rejecting sense.
165
+ const deploymentName = `${input.installId}-marketplace-infra`;
166
+ const deployments = JSON.parse(az(azureArgs([
167
+ 'deployment', 'group', 'list', '--resource-group', input.resourceGroup, '--output', 'json'
168
+ ], input.subscription)));
169
+ return deployments.some(deployment => deployment.name === deploymentName);
170
+ }
171
+
172
+ function ensureEmptyGroup(input, az) {
173
+ const account = JSON.parse(az(['account', 'show', '--output', 'json']));
174
+ if (account.id !== input.subscription) {
175
+ throw new Error('Selected az account subscription differs from --subscription; run az account set before deployment');
176
+ }
177
+ const exists = az(azureArgs(['group', 'exists', '--name', input.resourceGroup, '--output', 'tsv'], input.subscription)) === 'true';
178
+ if (exists) {
179
+ const resources = JSON.parse(az(azureArgs([
180
+ 'resource', 'list', '--resource-group', input.resourceGroup, '--output', 'json'
181
+ ], input.subscription)));
182
+ if (resources.length && !isOwnResumableDeployment(input, az)) {
183
+ throw new Error('Target resource group is not empty; choose a new isolated group');
184
+ }
185
+ } else {
186
+ az(azureArgs(['group', 'create', '--name', input.resourceGroup, '--location', input.location, '--output', 'none'], input.subscription));
187
+ }
188
+ }
189
+
190
+ function runArmDeployment(input, az) {
191
+ const deploymentName = `${input.installId}-marketplace-infra`;
192
+ const common = [
193
+ '--resource-group', input.resourceGroup, '--name', deploymentName,
194
+ '--template-file', input.template, '--parameters', `@${input.parameters}`,
195
+ '--no-prompt', 'true'
196
+ ];
197
+ az(azureArgs(['deployment', 'group', 'validate', ...common, '--output', 'none'], input.subscription));
198
+ az(azureArgs(['deployment', 'group', 'what-if', ...common,
199
+ '--result-format', 'ResourceIdOnly', '--output', 'none'], input.subscription));
200
+ return { deploymentName, deployment: JSON.parse(az(azureArgs([
201
+ 'deployment', 'group', 'create', ...common, '--output', 'json'
202
+ ], input.subscription))) };
203
+ }
204
+
205
+ function deployInfra(input, az = runAz) {
206
+ const old = checkExistingJournal(input);
207
+ if (old) return old;
208
+ ensureEmptyGroup(input, az);
209
+ const { deploymentName, deployment } = runArmDeployment(input, az);
210
+ const outputMap = deployment.properties?.outputs || {};
211
+ const outputs = Object.fromEntries(Object.entries(outputMap).map(([key, item]) => [key, item.value]));
212
+ if (!outputs.webAppUrl || !outputs.keyVaultName || !outputs.resourceGroupName) {
213
+ throw new Error('ARM deployment succeeded without required controller/vault/group outputs; inspect deployment before resume');
214
+ }
215
+ const record = {
216
+ installId: input.installId, envKey: input.envKey, subscription: input.subscription,
217
+ resourceGroup: input.resourceGroup, templateHash: input.templateHash, inputHash: input.inputHash,
218
+ deploymentName, deploymentId: deployment.id, correlationId: deployment.properties?.correlationId || null,
219
+ outputs, infraComplete: true,
220
+ platformComplete: false, recordedAt: new Date().toISOString()
221
+ };
222
+ writeJournal(record);
223
+ return record;
224
+ }
225
+
226
+ function assertAppDeploymentComplete(appName, deployment) {
227
+ if (deployment?.usedExternalDeploy || deployment?.result?.status?.status !== 'completed') {
228
+ throw new Error(`${appName} deployment did not reach completed status; resume platform stage after inspecting controller deployment`);
229
+ }
230
+ }
231
+
232
+ async function deployWorkloadApps(input, dependencies) {
233
+ const deployApp = dependencies.deployApp || require('../app').deployApp;
234
+ const deployEnvironment = dependencies.deployEnvironment || require('./environment').deployEnvironment;
235
+ const environment = await deployEnvironment(input.envKey, { poll: true });
236
+ if (environment?.status !== 'ready') {
237
+ throw new Error(`Environment ${input.envKey} did not reach ready status; resume platform stage after controller deployment completes`);
238
+ }
239
+ await (dependencies.setEnvironment || config.setCurrentEnvironment)(input.envKey);
240
+ assertAppDeploymentComplete('dataplane', await deployApp('dataplane', { yes: true, poll: true }));
241
+ assertAppDeploymentComplete('builder-api', await deployApp('builder-api', { yes: true, poll: true }));
242
+ }
243
+
244
+ async function deployPlatform(input, dependencies = {}) {
245
+ const record = (dependencies.readJournal || readJournal)(input.installId);
246
+ if (record.envKey !== input.envKey) throw new Error('Journal workload environment differs from requested environment');
247
+ if (record.platformComplete) return record;
248
+ const controller = input.controllerUrl || record.publicControllerUrl;
249
+ if (typeof controller !== 'string' || !/^https:\/\/[^/]+\/miso\/?$/.test(controller)) {
250
+ throw new Error('Platform stage requires --controller-url https://<ready-public-host>/miso from the completed wizard');
251
+ }
252
+ if (!record.runtimeReady) {
253
+ throw new Error('Controller runtime is not ready; rerun infrastructure readiness before the platform stage');
254
+ }
255
+ await (dependencies.setControllerUrl || config.setControllerUrl)(controller);
256
+ await deployWorkloadApps(input, dependencies);
257
+ record.publicControllerUrl = controller.replace(/\/$/, '');
258
+ record.platformComplete = true;
259
+ record.platformRecordedAt = new Date().toISOString();
260
+ (dependencies.writeJournal || writeJournal)(record);
261
+ return record;
262
+ }
263
+
264
+ async function deployMarketplaceEnvironment(envKey, options, dependencies = {}) {
265
+ const input = preflight(envKey, options);
266
+ if (input.stage === 'platform') return deployPlatform(input, dependencies);
267
+ const record = deployInfra(input, dependencies.az);
268
+ const probe = dependencies.probe || probeController;
269
+ await probe(record.outputs.webAppUrl);
270
+ record.runtimeReady = true;
271
+ writeJournal(record);
272
+ if (input.stage === 'infra') return record;
273
+ return deployPlatform({ ...input, controllerUrl: options.controllerUrl }, dependencies);
274
+ }
275
+
276
+ async function probeController(controllerUrl) {
277
+ return probeHttps(controllerUrl, '/api/v1/health/readiness', 'Controller');
278
+ }
279
+
280
+ /**
281
+ * Waits until an HTTPS endpoint answers successfully (App Service startup lags ARM success).
282
+ * @param {string} baseUrl - HTTPS base URL from ARM outputs
283
+ * @param {string} pathname - Probe path
284
+ * @param {string} label - Service label for errors
285
+ * @param {number} [attempts=30] - Attempts at 10-second intervals
286
+ * @returns {Promise<void>}
287
+ */
288
+ async function probeHttps(baseUrl, pathname, label, attempts = 30) {
289
+ if (typeof baseUrl !== 'string' || !baseUrl.startsWith('https://')) {
290
+ throw new Error(`ARM output does not contain a valid HTTPS ${label} URL`);
291
+ }
292
+ const url = new URL(pathname, baseUrl);
293
+ for (let attempt = 0; attempt < attempts; attempt++) {
294
+ try {
295
+ const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
296
+ if (response.ok) return;
297
+ } catch (_) {
298
+ // App Service startup can lag successful ARM provisioning.
299
+ }
300
+ await new Promise(resolve => setTimeout(resolve, 10000));
301
+ }
302
+ throw new Error(`${label} readiness did not pass at ${url.origin}; inspect App Service before resuming`);
303
+ }
304
+
305
+ module.exports = { preflight, deployInfra, deployPlatform, deployMarketplaceEnvironment, runAz, probeHttps, SAFE_NAME };
@@ -359,6 +359,10 @@ function addSimpleOptionalFields(deployment, variables) {
359
359
  if (variables.environmentScopedResources === true) {
360
360
  deployment.environmentScopedResources = true;
361
361
  }
362
+ // Non-arrays pass through so schema validation reports them; an empty list stays omitted.
363
+ if (variables.required !== undefined && !(Array.isArray(variables.required) && variables.required.length === 0)) {
364
+ deployment.required = variables.required;
365
+ }
362
366
  }
363
367
 
364
368
  function buildOptionalFields(deployment, variables, rbac) {
@@ -11,6 +11,24 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const { loadConfigFile } = require('../utils/config-format');
14
+ const { getInfraParameterCatalog } = require('../parameters/infra-parameter-catalog');
15
+
16
+ /**
17
+ * Baseline `required` for a keyvault-located key, from infra.parameter.yaml's
18
+ * `requiredOnSystemOnboarding` (matched by exact key or keyPattern). Returns false when the
19
+ * catalog can't be loaded or has no matching entry, so generation never hard-fails on this.
20
+ * @param {string} vaultKey - Key with any `kv://` prefix already stripped
21
+ * @returns {boolean}
22
+ */
23
+ function baselineRequiredForVaultKey(vaultKey) {
24
+ try {
25
+ const catalog = getInfraParameterCatalog();
26
+ const entry = catalog.findEntryForKey(vaultKey);
27
+ return entry?.requiredOnSystemOnboarding === true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
14
32
 
15
33
  /**
16
34
  * Loads application config file (application.yaml, application.json, or legacy path) via converter.
@@ -148,34 +166,41 @@ function validatePortalInput(portalInput, variableName) {
148
166
  }
149
167
 
150
168
  /**
151
- * Parses environment variables from env.template and merges portalInput from application config
169
+ * Parses environment variables from env.template and merges explicit application config
152
170
  * @param {string} envTemplate - Content of env.template file
153
171
  * @param {Object|null} [variablesConfig=null] - Optional configuration from application.yaml
154
172
  * @returns {Array<Object>} Configuration array with merged portalInput
155
173
  * @throws {Error} If portalInput structure is invalid
156
174
  */
157
175
  /**
158
- * Creates a map of portal overlay metadata from variables config
159
- * @function createPortalInputMap
176
+ * Creates a map of configuration metadata from application.yaml.
177
+ * `required` is declarative: env names and kv:// values never imply it.
178
+ * @function createApplicationConfigMap
160
179
  * @param {Object|null} variablesConfig - Configuration from application.yaml
161
- * @returns {Map} Map of variable names to { portalInput, category? }
180
+ * @returns {Map} Map of variable names to { portalInput?, category?, required? }
162
181
  */
163
- function createPortalInputMap(variablesConfig) {
164
- const portalInputMap = new Map();
182
+ function createApplicationConfigMap(variablesConfig) {
183
+ const applicationConfigMap = new Map();
165
184
  if (variablesConfig && variablesConfig.configuration && Array.isArray(variablesConfig.configuration)) {
166
185
  for (const configItem of variablesConfig.configuration) {
167
- if (configItem.name && configItem.portalInput) {
168
- // Validate portalInput before adding to map
186
+ if (!configItem.name) continue;
187
+ const overlay = {};
188
+ if (configItem.portalInput) {
169
189
  validatePortalInput(configItem.portalInput, configItem.name);
170
- const overlay = { portalInput: configItem.portalInput };
171
- if (typeof configItem.category === 'string' && configItem.category.trim().length > 0) {
172
- overlay.category = configItem.category.trim();
173
- }
174
- portalInputMap.set(configItem.name, overlay);
190
+ overlay.portalInput = configItem.portalInput;
191
+ }
192
+ if (typeof configItem.category === 'string' && configItem.category.trim().length > 0) {
193
+ overlay.category = configItem.category.trim();
194
+ }
195
+ if (typeof configItem.required === 'boolean') {
196
+ overlay.required = configItem.required;
197
+ }
198
+ if (Object.keys(overlay).length > 0) {
199
+ applicationConfigMap.set(configItem.name, overlay);
175
200
  }
176
201
  }
177
202
  }
178
- return portalInputMap;
203
+ return applicationConfigMap;
179
204
  }
180
205
 
181
206
  /**
@@ -209,40 +234,13 @@ function parseEnvironmentVariableLine(line) {
209
234
  }
210
235
 
211
236
  /**
212
- * Determines location and required status for a variable
237
+ * Determines storage location for a variable.
213
238
  * @function determineVariableLocation
214
239
  * @param {string} value - Variable value
215
- * @param {string} key - Variable key
216
- * @returns {Object} Object with location and required properties
240
+ * @returns {string} Deployment configuration location
217
241
  */
218
- function determineVariableLocation(value, key) {
219
- let location = 'variable';
220
- let required = false;
221
-
222
- if (value.startsWith('kv://')) {
223
- location = 'keyvault';
224
- required = true;
225
- }
226
-
227
- // Check if it's a sensitive variable
228
- const sensitiveKeys = ['password', 'secret', 'key', 'token', 'auth'];
229
- if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive))) {
230
- required = true;
231
- }
232
-
233
- if (location === 'keyvault' && value.startsWith('kv://')) {
234
- try {
235
- const { getInfraParameterCatalog } = require('../parameters/infra-parameter-catalog');
236
- const catalog = getInfraParameterCatalog();
237
- if (catalog.isKeyAllowedEmpty(value.slice('kv://'.length))) {
238
- required = false;
239
- }
240
- } catch {
241
- // Keep the conservative required=true default when the catalog is unavailable.
242
- }
243
- }
244
-
245
- return { location, required };
242
+ function determineVariableLocation(value) {
243
+ return value.startsWith('kv://') ? 'keyvault' : 'variable';
246
244
  }
247
245
 
248
246
  /**
@@ -251,11 +249,10 @@ function determineVariableLocation(value, key) {
251
249
  * @param {string} key - Variable key
252
250
  * @param {string} value - Variable value
253
251
  * @param {string} location - Variable location
254
- * @param {boolean} required - Whether variable is required
255
- * @param {Map} portalInputMap - Map of portalInput configurations
252
+ * @param {Map} applicationConfigMap - Explicit application.yaml configuration
256
253
  * @returns {Object} Configuration item
257
254
  */
258
- function createConfigItem(key, value, location, required, portalInputMap) {
255
+ function createConfigItem(key, value, location, applicationConfigMap) {
259
256
  let storedValue = value;
260
257
  if (location === 'keyvault') {
261
258
  if (value.startsWith('kv://')) {
@@ -263,6 +260,11 @@ function createConfigItem(key, value, location, required, portalInputMap) {
263
260
  }
264
261
  }
265
262
 
263
+ const overlay = applicationConfigMap.get(key);
264
+ const required =
265
+ typeof overlay?.required === 'boolean'
266
+ ? overlay.required
267
+ : location === 'keyvault' && baselineRequiredForVaultKey(storedValue);
266
268
  const configItem = {
267
269
  name: key,
268
270
  value: storedValue,
@@ -270,10 +272,8 @@ function createConfigItem(key, value, location, required, portalInputMap) {
270
272
  required
271
273
  };
272
274
 
273
- // Merge portalInput / category if present in application config
274
- if (portalInputMap.has(key)) {
275
- const overlay = portalInputMap.get(key);
276
- configItem.portalInput = overlay.portalInput;
275
+ if (overlay) {
276
+ if (overlay.portalInput) configItem.portalInput = overlay.portalInput;
277
277
  if (typeof overlay.category === 'string' && overlay.category.length > 0) {
278
278
  configItem.category = overlay.category;
279
279
  }
@@ -285,20 +285,19 @@ function createConfigItem(key, value, location, required, portalInputMap) {
285
285
  function parseEnvironmentVariables(envTemplate, variablesConfig = null) {
286
286
  const configuration = [];
287
287
  const lines = envTemplate.split('\n');
288
- const portalInputMap = createPortalInputMap(variablesConfig);
288
+ const applicationConfigMap = createApplicationConfigMap(variablesConfig);
289
289
  for (const line of lines) {
290
290
  const parsed = parseEnvironmentVariableLine(line);
291
291
  if (!parsed) {
292
292
  continue;
293
293
  }
294
294
 
295
- const { location, required } = determineVariableLocation(parsed.value, parsed.key);
295
+ const location = determineVariableLocation(parsed.value);
296
296
  const configItem = createConfigItem(
297
297
  parsed.key,
298
298
  parsed.value,
299
299
  location,
300
- required,
301
- portalInputMap
300
+ applicationConfigMap
302
301
  );
303
302
  configuration.push(configItem);
304
303
  }
@@ -57,7 +57,7 @@ function extractOptionalSections(deployment) {
57
57
  const names = [
58
58
  'healthCheck', 'authentication', 'build', 'repository', 'deployment',
59
59
  'startupCommand', 'runtimeVersion', 'scaling', 'frontDoorRouting',
60
- 'environmentScopedResources'
60
+ 'environmentScopedResources', 'required'
61
61
  ];
62
62
  for (const sectionName of names) {
63
63
  extractOptionalSection(deployment, sectionName, optional);
@@ -66,19 +66,25 @@ function extractOptionalSections(deployment) {
66
66
  }
67
67
 
68
68
  /**
69
- * Portal-only configuration for application.yaml (name + optional category + portalInput).
69
+ * Explicit application configuration metadata for application.yaml.
70
+ * required=false remains the default and is omitted.
70
71
  * @param {Array} configuration - Configuration array from deployment JSON
71
- * @returns {Array<{ name: string, category?: string, portalInput: Object }>}
72
+ * @returns {Array<{ name: string, category?: string, required?: boolean, portalInput?: Object }>}
72
73
  */
73
74
  function extractPortalInputConfiguration(configuration) {
74
75
  if (!Array.isArray(configuration) || configuration.length === 0) return [];
75
76
  return configuration
76
- .filter(item => item && item.portalInput && typeof item.name === 'string')
77
+ .filter(item => item && typeof item.name === 'string' && (
78
+ item.portalInput || item.required === true ||
79
+ (typeof item.category === 'string' && item.category.trim().length > 0)
80
+ ))
77
81
  .map(item => {
78
- const entry = { name: item.name, portalInput: item.portalInput };
82
+ const entry = { name: item.name };
83
+ if (item.portalInput) entry.portalInput = item.portalInput;
79
84
  if (typeof item.category === 'string' && item.category.trim().length > 0) {
80
85
  entry.category = item.category.trim();
81
86
  }
87
+ if (item.required === true) entry.required = true;
82
88
  return entry;
83
89
  });
84
90
  }
@@ -137,8 +137,37 @@ function appendFileSync(p, data, opts) {
137
137
  return delegate('appendFileSync', opts !== undefined ? [p, data, opts] : [p, data]);
138
138
  }
139
139
 
140
+ /**
141
+ * @param {string} p
142
+ * @param {number|string} mode
143
+ * @returns {void}
144
+ */
145
+ function chmodSync(p, mode) {
146
+ return delegate('chmodSync', [p, mode]);
147
+ }
148
+
149
+ /**
150
+ * @param {string} p
151
+ * @returns {import('node:fs').Stats}
152
+ */
153
+ function lstatSync(p) {
154
+ return delegate('lstatSync', [p]);
155
+ }
156
+
157
+ /**
158
+ * @param {string} from
159
+ * @param {string} to
160
+ * @returns {void}
161
+ */
162
+ function renameSync(from, to) {
163
+ return delegate('renameSync', [from, to]);
164
+ }
165
+
140
166
  module.exports = {
141
167
  existsSync,
168
+ chmodSync,
169
+ lstatSync,
170
+ renameSync,
142
171
  readFileSync,
143
172
  readFileSyncDirect,
144
173
  writeFileSync,
@@ -10,6 +10,7 @@ const fs = require('../internal/fs-real-sync');
10
10
  const { validateExternalFile, parseJsonFileContent } = require('../validation/validate-external-file');
11
11
  const { listTestCaseFiles } = require('./test-cases');
12
12
  const { validateTestCaseFile } = require('./test-case-validate');
13
+ const { shouldSkipEvidencePayload } = require('./evidence-sync');
13
14
 
14
15
  const SETTINGS_TYPE = 'role-assistant-settings';
15
16
  const EMPTY_TESTS_SKIP = 'tests/: skipped (no test cases)';
@@ -59,6 +60,9 @@ function validateEvidenceJsonFile(filePath) {
59
60
  if (parseResult.error) {
60
61
  return { ...parseResult.error, file: filePath, type: 'evidence' };
61
62
  }
63
+ if (shouldSkipEvidencePayload(filePath, parseResult.parsed)) {
64
+ return { valid: true, errors: [], warnings: [], file: filePath, type: 'evidence' };
65
+ }
62
66
  const rows = evidenceRows(parseResult.parsed);
63
67
  const errors = [];
64
68
  if (!rows) {