@aifabrix/builder 2.56.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.
- package/lib/agent-kit/session-report.js +76 -0
- package/lib/agent-kit/start.js +124 -57
- package/lib/agent-kit/tmux-sessions.js +163 -0
- package/lib/api/environments.api.js +38 -0
- package/lib/api/marketplace-bootstrap.api.js +31 -0
- package/lib/app/push.js +98 -41
- package/lib/build/standard-docker-build.js +13 -11
- package/lib/cli/setup-app.js +16 -5
- package/lib/cli/setup-dev-path-commands.js +49 -2
- package/lib/cli/setup-environment.js +89 -19
- package/lib/commands/agent-kit-help.js +32 -15
- package/lib/commands/agent-kit.js +90 -8
- package/lib/commands/dev-registry.js +155 -0
- package/lib/commands/dev-show-display.js +10 -2
- package/lib/commands/setup-image-refresh.js +26 -0
- package/lib/core/config-attach-extensions.js +4 -1
- package/lib/deployment/installation/azure-infra-stage.js +191 -0
- package/lib/deployment/installation/azure-preflight.js +147 -0
- package/lib/deployment/installation/azure-readiness.js +101 -0
- package/lib/deployment/installation/docker-register.js +112 -0
- package/lib/deployment/installation/environment-auth.js +76 -0
- package/lib/deployment/installation/environment-manifest.js +165 -0
- package/lib/deployment/installation/environment-stage.js +191 -0
- package/lib/deployment/installation/index.js +39 -0
- package/lib/deployment/installation/infra-catalog.js +211 -0
- package/lib/deployment/installation/infra-manifest.js +176 -0
- package/lib/deployment/installation/infra-projection.js +97 -0
- package/lib/deployment/installation/public-ip.js +65 -0
- package/lib/deployment/installation/registry-auth-mode.js +98 -0
- package/lib/deployment/installation/stage-input.js +184 -0
- package/lib/deployment/installation/stage-output.js +53 -0
- package/lib/deployment/installation/workspace-env.js +76 -0
- package/lib/deployment/installation/workspace-files.js +178 -0
- package/lib/deployment/installation/workspace-schema.js +40 -0
- package/lib/deployment/installation/workspace-secrets.js +66 -0
- package/lib/deployment/installation/workspace.js +128 -0
- package/lib/deployment/marketplace-environment.js +305 -0
- package/lib/generator/builders.js +12 -9
- package/lib/generator/helpers.js +55 -56
- package/lib/generator/split-variables.js +11 -5
- package/lib/internal/fs-real-sync.js +29 -0
- package/lib/role-assistant/package-validate.js +4 -0
- package/lib/role-assistant/test-runner-clarification-resubmit.js +149 -0
- package/lib/role-assistant/test-runner.js +14 -17
- package/lib/schema/application-schema.json +20 -2
- package/lib/schema/infra-parameter.schema.json +7 -0
- package/lib/schema/infra.parameter.yaml +26 -36
- package/lib/schema/infrastructure-schema.json +1531 -397
- package/lib/schema/installation-environment.schema.json +601 -0
- package/lib/utils/app-register-config.js +2 -4
- package/lib/utils/build-resolve-image.js +2 -21
- package/lib/utils/cli-utils.js +8 -1
- package/lib/utils/config-registry-preference.js +106 -0
- package/lib/utils/docker-build.js +53 -19
- package/lib/utils/env-map.js +23 -6
- package/lib/utils/image-tags.js +88 -0
- package/lib/utils/promote-dev-docker-image.js +6 -0
- package/lib/utils/registry-credentials.js +236 -0
- package/lib/utils/resolve-docker-image-ref.js +78 -9
- package/lib/utils/secrets-helpers.js +9 -1
- package/lib/utils/variable-transformer.js +5 -4
- package/lib/validation/required-variables.js +41 -0
- package/lib/validation/validator.js +8 -5
- package/package.json +6 -1
- package/templates/README.md +4 -0
- package/templates/agent-kit/instructions/AGENTKIT.md +6 -6
- package/templates/agent-kit/skills/shared/hosts.md +10 -10
- package/templates/applications/dataplane/application.yaml +1 -1
- package/templates/applications/dataplane/env.template +1 -1
- package/templates/applications/miso-controller/application.yaml +3 -2
- package/templates/applications/miso-controller/env.template +15 -6
- package/templates/marketplace/createUiDefinition.json +77 -0
- package/templates/marketplace/install.parameters.example.json +9 -0
- 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 };
|
|
@@ -41,16 +41,15 @@ function filterConfigurationByRegistryMode(configuration, _registryMode) {
|
|
|
41
41
|
return Array.isArray(configuration) ? configuration : [];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Deployment image reference using the shared registry precedence
|
|
46
|
+
* (installation `registry` in config.yaml, then manifest `image.registry`).
|
|
47
|
+
* @param {Object} variables - Application variables
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
44
50
|
function buildImageReference(variables) {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const tag = variables.image?.tag || 'latest';
|
|
48
|
-
|
|
49
|
-
if (registry) {
|
|
50
|
-
return `${registry}/${imageName}:${tag}`;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return `${imageName}:${tag}`;
|
|
51
|
+
const { buildManifestImageReference } = require('../utils/resolve-docker-image-ref');
|
|
52
|
+
return buildManifestImageReference(variables, 'app');
|
|
54
53
|
}
|
|
55
54
|
|
|
56
55
|
/**
|
|
@@ -359,6 +358,10 @@ function addSimpleOptionalFields(deployment, variables) {
|
|
|
359
358
|
if (variables.environmentScopedResources === true) {
|
|
360
359
|
deployment.environmentScopedResources = true;
|
|
361
360
|
}
|
|
361
|
+
// Non-arrays pass through so schema validation reports them; an empty list stays omitted.
|
|
362
|
+
if (variables.required !== undefined && !(Array.isArray(variables.required) && variables.required.length === 0)) {
|
|
363
|
+
deployment.required = variables.required;
|
|
364
|
+
}
|
|
362
365
|
}
|
|
363
366
|
|
|
364
367
|
function buildOptionalFields(deployment, variables, rbac) {
|
package/lib/generator/helpers.js
CHANGED
|
@@ -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
|
|
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
|
|
159
|
-
*
|
|
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
|
|
180
|
+
* @returns {Map} Map of variable names to { portalInput?, category?, required? }
|
|
162
181
|
*/
|
|
163
|
-
function
|
|
164
|
-
const
|
|
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
|
|
168
|
-
|
|
186
|
+
if (!configItem.name) continue;
|
|
187
|
+
const overlay = {};
|
|
188
|
+
if (configItem.portalInput) {
|
|
169
189
|
validatePortalInput(configItem.portalInput, configItem.name);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
|
237
|
+
* Determines storage location for a variable.
|
|
213
238
|
* @function determineVariableLocation
|
|
214
239
|
* @param {string} value - Variable value
|
|
215
|
-
* @
|
|
216
|
-
* @returns {Object} Object with location and required properties
|
|
240
|
+
* @returns {string} Deployment configuration location
|
|
217
241
|
*/
|
|
218
|
-
function determineVariableLocation(value
|
|
219
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
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
|
|
295
|
+
const location = determineVariableLocation(parsed.value);
|
|
296
296
|
const configItem = createConfigItem(
|
|
297
297
|
parsed.key,
|
|
298
298
|
parsed.value,
|
|
299
299
|
location,
|
|
300
|
-
|
|
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
|
-
*
|
|
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
|
|
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 &&
|
|
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
|
|
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) {
|