@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,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview AZURE `--stage dev|tst|pro`: server-reported activation check, then Miso
|
|
5
|
+
* create-or-update, validate, deploy (resuming an active deployment on 409) and poll to a terminal
|
|
6
|
+
* state. Makes no Mori, license, token-issuance or Azure calls.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { maskSensitiveData } = require('../../utils/log-redaction');
|
|
10
|
+
const { normalizePreset } = require('../environment-config');
|
|
11
|
+
const { readJsonIfExists, environmentFileName } = require('./workspace-files');
|
|
12
|
+
const { saveInfra, saveEnvironment } = require('./workspace');
|
|
13
|
+
const { buildEnvironmentDocument, buildMisoRequests, refreshVariableCatalog } = require('./environment-manifest');
|
|
14
|
+
const { readWorkspaceEnv, removeWorkspaceEnvKey } = require('./workspace-env');
|
|
15
|
+
const { resolveControllerUrl, resolveEnvironmentAuth } = require('./environment-auth');
|
|
16
|
+
|
|
17
|
+
const FAILED_STATES = Object.freeze(['failed', 'error', 'cancelled']);
|
|
18
|
+
|
|
19
|
+
function defaultApi() {
|
|
20
|
+
const environments = require('../../api/environments.api');
|
|
21
|
+
const deployments = require('../../api/deployments.api');
|
|
22
|
+
return { ...environments, deployEnvironment: deployments.deployEnvironment, getDeployment: deployments.getDeployment };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function payload(response) {
|
|
26
|
+
const body = response?.data;
|
|
27
|
+
return body && typeof body === 'object' && body.data !== undefined ? body.data : body;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function apiFailure(step, response) {
|
|
31
|
+
const detail = maskSensitiveData(String(response?.formattedError || response?.error || 'request failed'));
|
|
32
|
+
const error = new Error(`Miso ${step} failed${response?.status ? ` (HTTP ${response.status})` : ''}: ${detail}`);
|
|
33
|
+
error.status = response?.status;
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function assertTenantActive(ctx) {
|
|
38
|
+
const response = await ctx.api.getTenantStatus(ctx.controllerUrl, ctx.auth);
|
|
39
|
+
if (!response.success) throw apiFailure('tenant status', response);
|
|
40
|
+
const status = String(payload(response)?.status || 'UNKNOWN').toUpperCase();
|
|
41
|
+
const recorded = ['PENDING', 'ACTIVE', 'INACTIVE'].includes(status) ? status.toLowerCase() : 'unknown';
|
|
42
|
+
ctx.infra.tenantActivation.status = { ...ctx.infra.tenantActivation.status, value: recorded, checkedAt: ctx.now().toISOString() };
|
|
43
|
+
saveInfra(ctx.workspace, ctx.infra);
|
|
44
|
+
if (status !== 'ACTIVE') {
|
|
45
|
+
const url = ctx.infra.tenantActivation.url?.value || ctx.controllerUrl;
|
|
46
|
+
throw new Error(`Tenant activation is ${status}. Open ${url}, sign in and complete activation, then rerun this stage.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function needsUpdate(current, update) {
|
|
51
|
+
const existing = current?.configuration || {};
|
|
52
|
+
return Object.entries(update.configuration).some(([key, value]) => JSON.stringify(existing[key]) !== JSON.stringify(value)) ||
|
|
53
|
+
(update.name && current?.name !== update.name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function updateExisting(ctx, current, requests) {
|
|
57
|
+
if (current?.environment && current.environment !== ctx.envKey) {
|
|
58
|
+
throw new Error(`Controller environment ${ctx.envKey} has environment type "${current.environment}"; refusing to change it`);
|
|
59
|
+
}
|
|
60
|
+
if (!needsUpdate(current, requests.update)) return 'unchanged';
|
|
61
|
+
const patched = await ctx.api.updateEnvironment(ctx.controllerUrl, ctx.envKey, ctx.auth, requests.update);
|
|
62
|
+
if (!patched.success) throw apiFailure('environment update', patched);
|
|
63
|
+
return 'updated';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function createOrUpdate(ctx, requests) {
|
|
67
|
+
const found = await ctx.api.getEnvironment(ctx.controllerUrl, ctx.envKey, ctx.auth);
|
|
68
|
+
if (found.success) return updateExisting(ctx, payload(found), requests);
|
|
69
|
+
if (found.status !== 404) throw apiFailure('environment read', found);
|
|
70
|
+
const created = await ctx.api.createEnvironment(ctx.controllerUrl, ctx.auth, requests.create);
|
|
71
|
+
if (created.success) return 'created';
|
|
72
|
+
if (created.status !== 409) throw apiFailure('environment create', created);
|
|
73
|
+
const reread = await ctx.api.getEnvironment(ctx.controllerUrl, ctx.envKey, ctx.auth);
|
|
74
|
+
if (!reread.success) throw apiFailure('environment read', reread);
|
|
75
|
+
return updateExisting(ctx, payload(reread), requests);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function validateRequest(ctx, request) {
|
|
79
|
+
const response = await ctx.api.validateEnvironmentDeployment(ctx.controllerUrl, ctx.envKey, ctx.auth, request);
|
|
80
|
+
if (!response.success) throw apiFailure('environment validation', response);
|
|
81
|
+
const result = payload(response) || {};
|
|
82
|
+
if (result.valid !== true) {
|
|
83
|
+
const errors = (result.errors || []).map(item => maskSensitiveData(typeof item === 'string' ? item : JSON.stringify(item)));
|
|
84
|
+
throw new Error(`Miso rejected the environment configuration:\n - ${errors.join('\n - ') || 'no details returned'}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function submitDeployment(ctx, request) {
|
|
89
|
+
const response = await ctx.api.deployEnvironment(ctx.controllerUrl, ctx.envKey, ctx.auth, { ...request, dryRun: false });
|
|
90
|
+
if (response.success) {
|
|
91
|
+
const data = payload(response) || {};
|
|
92
|
+
return { deploymentId: data.deploymentId || data.id, resumed: false };
|
|
93
|
+
}
|
|
94
|
+
const activeId = response.errorData?.deploymentId || response.errorData?.extensions?.deploymentId;
|
|
95
|
+
if (response.status === 409 && activeId) return { deploymentId: activeId, resumed: true };
|
|
96
|
+
throw apiFailure('environment deploy', response);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function pollDeployment(ctx, deploymentId) {
|
|
100
|
+
for (let attempt = 1; attempt <= ctx.maxAttempts; attempt++) {
|
|
101
|
+
const response = await ctx.api.getDeployment(ctx.controllerUrl, ctx.envKey, deploymentId, ctx.auth);
|
|
102
|
+
if (!response.success && response.status !== 404) throw apiFailure('deployment status', response);
|
|
103
|
+
const status = String(payload(response)?.status || 'pending').toLowerCase();
|
|
104
|
+
if (status === 'completed') return { status };
|
|
105
|
+
if (FAILED_STATES.includes(status)) {
|
|
106
|
+
return { status, error: maskSensitiveData(String(payload(response)?.message || payload(response)?.error || status)) };
|
|
107
|
+
}
|
|
108
|
+
await ctx.sleep(ctx.pollInterval);
|
|
109
|
+
}
|
|
110
|
+
return { status: 'timeout', error: `No terminal state after ${ctx.maxAttempts} checks` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function recordDeployment(ctx, envDoc, state) {
|
|
114
|
+
envDoc.deployment = {
|
|
115
|
+
...envDoc.deployment,
|
|
116
|
+
status: { ...envDoc.deployment.status, value: state.status },
|
|
117
|
+
id: { ...envDoc.deployment.id, value: state.deploymentId ?? envDoc.deployment.id.value },
|
|
118
|
+
updatedAt: ctx.now().toISOString(),
|
|
119
|
+
error: state.error || null
|
|
120
|
+
};
|
|
121
|
+
saveEnvironment(ctx.workspace, ctx.envKey, envDoc);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function prepareEnvironmentDocument(ctx, input, env) {
|
|
125
|
+
const envDoc = buildEnvironmentDocument({ envKey: ctx.envKey, existing: readJsonIfExists(ctx.envFile) });
|
|
126
|
+
if (input.preset !== undefined) envDoc.preset = { ...envDoc.preset, value: normalizePreset(input.preset), source: 'cli' };
|
|
127
|
+
const problems = refreshVariableCatalog(envDoc, { ...env, ...ctx.processEnv }, ctx.templatesDir);
|
|
128
|
+
const requests = buildMisoRequests(envDoc, ctx.infra);
|
|
129
|
+
envDoc.request = { ...envDoc.request, value: requests.deploy };
|
|
130
|
+
saveEnvironment(ctx.workspace, ctx.envKey, envDoc);
|
|
131
|
+
if (problems.length > 0) throw new Error(`Environment ${ctx.envKey} is missing required variables:\n - ${problems.join('\n - ')}`);
|
|
132
|
+
return { envDoc, requests };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function assertInfraReady(workspace, envKey) {
|
|
136
|
+
const infra = workspace.infra;
|
|
137
|
+
if (!infra || infra.vendor?.value !== 'AZURE' || infra.deployment?.status?.value !== 'succeeded' || !infra.controllerUrl?.value) {
|
|
138
|
+
throw new Error(`Shared infrastructure is not deployed for this workspace. Run "af env deploy ${workspace.infra?.resourceGroup?.value || '<resource-group>'} --stage infra" before --stage ${envKey}.`);
|
|
139
|
+
}
|
|
140
|
+
return infra;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildContext(workspace, input, deps) {
|
|
144
|
+
const envKey = input.stage;
|
|
145
|
+
return {
|
|
146
|
+
workspace, envKey,
|
|
147
|
+
infra: assertInfraReady(workspace, envKey),
|
|
148
|
+
envFile: workspace.paths.environment(envKey),
|
|
149
|
+
api: deps.api || defaultApi(),
|
|
150
|
+
now: deps.now || (() => new Date()),
|
|
151
|
+
sleep: deps.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))),
|
|
152
|
+
pollInterval: deps.pollInterval ?? 5000,
|
|
153
|
+
maxAttempts: deps.maxAttempts ?? 360,
|
|
154
|
+
processEnv: deps.env || process.env,
|
|
155
|
+
templatesDir: deps.templatesDir
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Runs a dev, tst or pro environment stage.
|
|
161
|
+
* @param {Object} workspace - Opened workspace
|
|
162
|
+
* @param {Object} input - Validated stage input
|
|
163
|
+
* @param {Object} [deps] - Injected api, getDeviceToken, secretsApi, env, now, sleep, pollInterval, maxAttempts, templatesDir
|
|
164
|
+
* @returns {Promise<{ envKey: string, deploymentId: string, status: string, change: string, resumed: boolean }>}
|
|
165
|
+
*/
|
|
166
|
+
async function runEnvironmentStage(workspace, input, deps = {}) {
|
|
167
|
+
const ctx = buildContext(workspace, input, deps);
|
|
168
|
+
const env = readWorkspaceEnv(workspace.paths.env);
|
|
169
|
+
const { envDoc, requests } = prepareEnvironmentDocument(ctx, input, env);
|
|
170
|
+
ctx.controllerUrl = resolveControllerUrl({ envDoc, infra: ctx.infra, env, override: input.controllerUrl });
|
|
171
|
+
saveEnvironment(ctx.workspace, ctx.envKey, envDoc);
|
|
172
|
+
const { authConfig, source } = await resolveEnvironmentAuth({
|
|
173
|
+
workspace, controllerUrl: ctx.controllerUrl, keyEnvName: input.misoApiKeyEnv, deps: { ...deps, env: ctx.processEnv }
|
|
174
|
+
});
|
|
175
|
+
ctx.auth = authConfig;
|
|
176
|
+
await assertTenantActive(ctx);
|
|
177
|
+
const change = await createOrUpdate(ctx, requests);
|
|
178
|
+
await validateRequest(ctx, requests.deploy);
|
|
179
|
+
const { deploymentId, resumed } = await submitDeployment(ctx, requests.deploy);
|
|
180
|
+
if (!deploymentId) throw new Error('Miso accepted the deployment without a deploymentId');
|
|
181
|
+
recordDeployment(ctx, envDoc, { status: 'running', deploymentId });
|
|
182
|
+
const final = await pollDeployment(ctx, deploymentId);
|
|
183
|
+
recordDeployment(ctx, envDoc, { ...final, deploymentId });
|
|
184
|
+
if (final.status !== 'completed') {
|
|
185
|
+
throw new Error(`Environment ${ctx.envKey} deployment ${deploymentId} ended as ${final.status}: ${final.error}. Rerun the stage to resume.`);
|
|
186
|
+
}
|
|
187
|
+
if (source === 'workspace') removeWorkspaceEnvKey(workspace.paths.env, 'MISO_API_KEY');
|
|
188
|
+
return { envKey: ctx.envKey, deploymentId, status: final.status, change, resumed, fileName: environmentFileName(ctx.envKey) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { runEnvironmentStage };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Resource-group installation workspace dispatcher for
|
|
5
|
+
* `env deploy <resource-group> --stage register|infra|dev|tst|pro`.
|
|
6
|
+
* All input validation happens before any filesystem, network or cloud mutation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { resolveStageInput, validateResourceGroupKey, ENVIRONMENT_STAGES } = require('./stage-input');
|
|
10
|
+
const { openWorkspace } = require('./workspace');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Runs one installation workspace stage.
|
|
14
|
+
* @param {string} resourceGroupKey - Positional resource-group key
|
|
15
|
+
* @param {Object} options - Commander options
|
|
16
|
+
* @param {Object} [deps] - Injected dependencies for stage runners
|
|
17
|
+
* @returns {Promise<Object>} Stage result with `stage`, `vendor` and `workspaceDir`
|
|
18
|
+
*/
|
|
19
|
+
async function deployInstallationStage(resourceGroupKey, options, deps = {}) {
|
|
20
|
+
const resourceGroup = validateResourceGroupKey(resourceGroupKey);
|
|
21
|
+
const workspace = openWorkspace(resourceGroup, { root: deps.root });
|
|
22
|
+
const input = resolveStageInput(resourceGroup, options, workspace.infra?.vendor?.value);
|
|
23
|
+
const base = { stage: input.stage, vendor: input.vendor, workspaceDir: workspace.dir };
|
|
24
|
+
if (input.stage === 'register') {
|
|
25
|
+
const { runDockerRegisterStage } = require('./docker-register');
|
|
26
|
+
return { ...base, ...(await runDockerRegisterStage(workspace, input, deps)) };
|
|
27
|
+
}
|
|
28
|
+
if (input.stage === 'infra') {
|
|
29
|
+
const { runAzureInfraStage } = require('./azure-infra-stage');
|
|
30
|
+
return { ...base, ...(await runAzureInfraStage(workspace, input, deps)) };
|
|
31
|
+
}
|
|
32
|
+
if (ENVIRONMENT_STAGES.includes(input.stage)) {
|
|
33
|
+
const { runEnvironmentStage } = require('./environment-stage');
|
|
34
|
+
return { ...base, ...(await runEnvironmentStage(workspace, input, deps)) };
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`Unsupported stage ${input.stage}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { deployInstallationStage };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Classification of every shipped Marketplace ARM parameter and output for the
|
|
5
|
+
* installation workspace, plus the canonical `env.template` secret contract. The catalog must
|
|
6
|
+
* cover the vendor template exactly; drift fails closed.
|
|
7
|
+
*
|
|
8
|
+
* Parameter kinds:
|
|
9
|
+
* - file: value lives in environment-infra.json (seeded from the template default when present)
|
|
10
|
+
* - secretEnv: resolved from the workspace .env at deploy time (`valueFromEnv`)
|
|
11
|
+
* - acrLookup: derived from `acrName` through the authenticated Azure account
|
|
12
|
+
* - fixed: value forced by the installation workflow
|
|
13
|
+
* - allowMyIp: computed from the default-off current-IP access option
|
|
14
|
+
* - license: installer-chosen license (edition name or Marketplace subscription ID)
|
|
15
|
+
* - onboarding: Miso onboarding mode, file-owned with a Builder default
|
|
16
|
+
* - deferred: tenant-activation/provider setting; never resolved or sent by infra
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const LICENSE_EDITIONS = Object.freeze(['COMMUNITY', 'STANDARD', 'ENTERPRISE']);
|
|
20
|
+
const DEFAULT_LICENSE_EDITION = 'COMMUNITY';
|
|
21
|
+
const ONBOARDING_MODES = Object.freeze(['saasDirect', 'customerOwned']);
|
|
22
|
+
const DEFAULT_ONBOARDING_MODE = 'saasDirect';
|
|
23
|
+
const ACR_AUTH_MODES = Object.freeze(['auto', 'mi', 'credentials']);
|
|
24
|
+
const DEFAULT_ACR_AUTH = 'auto';
|
|
25
|
+
const AZURE_LOCATION = /^[a-z0-9]{2,40}$/;
|
|
26
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalizes a license choice: an edition name (case-insensitive) or a Marketplace subscription ID.
|
|
30
|
+
* @param {string} value - Raw value
|
|
31
|
+
* @returns {string} Canonical edition name or lower-case UUID
|
|
32
|
+
* @throws {Error} When the value is neither
|
|
33
|
+
*/
|
|
34
|
+
function normalizeLicenseEdition(value) {
|
|
35
|
+
const raw = String(value ?? '').trim();
|
|
36
|
+
if (UUID.test(raw)) return raw.toLowerCase();
|
|
37
|
+
const edition = raw.toUpperCase();
|
|
38
|
+
if (LICENSE_EDITIONS.includes(edition)) return edition;
|
|
39
|
+
throw new Error(`License edition must be one of ${LICENSE_EDITIONS.join(', ')} or an Azure Marketplace subscription ID`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalizes requested ACR image-pull auth: auto (default), mi, or credentials.
|
|
44
|
+
* `admin` (and its spellings) is a deprecated alias for `credentials`.
|
|
45
|
+
* @param {string} value - Raw value
|
|
46
|
+
* @returns {string} Canonical mode
|
|
47
|
+
*/
|
|
48
|
+
function normalizeAcrAuth(value) {
|
|
49
|
+
const raw = String(value ?? '').trim();
|
|
50
|
+
const aliases = {
|
|
51
|
+
auto: 'auto',
|
|
52
|
+
mi: 'mi',
|
|
53
|
+
'managed-identity': 'mi',
|
|
54
|
+
managedidentity: 'mi',
|
|
55
|
+
credentials: 'credentials',
|
|
56
|
+
admin: 'credentials',
|
|
57
|
+
adminuser: 'credentials',
|
|
58
|
+
'admin-user': 'credentials',
|
|
59
|
+
password: 'credentials'
|
|
60
|
+
};
|
|
61
|
+
const mode = aliases[raw.toLowerCase()];
|
|
62
|
+
if (!mode) {
|
|
63
|
+
throw new Error(`ACR auth must be one of ${ACR_AUTH_MODES.join(', ')} (deprecated alias: admin = credentials)`);
|
|
64
|
+
}
|
|
65
|
+
return mode;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validates an Azure region name (for example swedencentral).
|
|
70
|
+
* @param {string} value - Raw value
|
|
71
|
+
* @returns {string} Lower-case region
|
|
72
|
+
*/
|
|
73
|
+
function normalizeAzureLocation(value) {
|
|
74
|
+
const location = String(value ?? '').trim().toLowerCase();
|
|
75
|
+
if (!AZURE_LOCATION.test(location)) {
|
|
76
|
+
throw new Error(`--location must be an Azure region name such as swedencentral (letters and digits only); got "${value}"`);
|
|
77
|
+
}
|
|
78
|
+
return location;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const PARAMETER_CATALOG = Object.freeze({
|
|
82
|
+
infrastructureNameOverride: { kind: 'file', source: 'user', description: 'Optional infrastructure-name override; empty uses generated naming.' },
|
|
83
|
+
location: { kind: 'file', source: 'user', required: true, description: 'Azure region for shared infrastructure.' },
|
|
84
|
+
tags: { kind: 'file', source: 'generated', description: 'Tags applied to shared Azure resources.' },
|
|
85
|
+
enableAppInsights: { kind: 'file', source: 'templateDefault', description: 'Deploy Application Insights resources.' },
|
|
86
|
+
enableAzureOpenAI: { kind: 'file', source: 'templateDefault', description: 'Deploy Azure OpenAI resources.' },
|
|
87
|
+
enablePartnerCenterAttribution: { kind: 'file', source: 'templateDefault', description: 'Enable Marketplace partner attribution.' },
|
|
88
|
+
enablePostgresPublicAccess: { kind: 'allowMyIp', source: 'allowMyIp', description: 'Default off; enabled only while allowMyIp grants the validated caller IPv4 /32 for testing.' },
|
|
89
|
+
allowedIPs: { kind: 'allowMyIp', source: 'allowMyIpOrUser', description: 'Explicit CIDRs for PostgreSQL, Key Vault, and Storage; allowMyIp merges the resolved caller IPv4 /32.' },
|
|
90
|
+
adminPassword: { kind: 'secretEnv', env: 'ADMIN_PASSWORD', required: true, description: 'Installation administrator password.' },
|
|
91
|
+
deploymentEnvironment: { kind: 'file', source: 'templateDefault', description: 'Vendor deployment profile.' },
|
|
92
|
+
keycloakVerifyAudience: { kind: 'file', source: 'templateDefault', description: 'Enable Keycloak audience verification.' },
|
|
93
|
+
encryptionKey: { kind: 'secretEnv', env: 'ENCRYPTION_KEY', required: true, description: 'Application/data encryption key.' },
|
|
94
|
+
acrLoginServer: { kind: 'acrLookup', field: 'loginServer', description: 'Resolved from acrName; not entered separately.' },
|
|
95
|
+
acrAdminUsername: { kind: 'fixed', value: '', source: 'managedIdentityMode', description: 'Empty in the workspace; credential mode fills it only in the temporary ARM parameters file (never committed).' },
|
|
96
|
+
acrAdminPassword: { kind: 'fixed', value: '', source: 'managedIdentityMode', description: 'Empty in the workspace; credential mode fills it only in the temporary ARM parameters file (never committed).' },
|
|
97
|
+
acrResourceId: { kind: 'acrLookup', field: 'id', description: 'Resolved from acrName; mi mode grants AcrPull to both web app identities.' },
|
|
98
|
+
deployRoleAssignments: { kind: 'file', source: 'templateDefault', description: 'Deploy required Azure role assignments (false while credential image pull is in effect; restored for managed identity).' },
|
|
99
|
+
moriBaseUrl: { kind: 'file', source: 'registration', required: true, description: 'Mori service base URL.' },
|
|
100
|
+
moriCallbackApiKey: { kind: 'secretEnv', env: 'MORI_CALLBACK_API_KEY', required: true, description: 'Mori callback authentication key.' },
|
|
101
|
+
entraCallbackStateSecret: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
|
|
102
|
+
entraIdClientId: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
|
|
103
|
+
entraIdClientSecret: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
|
|
104
|
+
entraIdTenantId: { kind: 'deferred', description: 'Customer Entra provider tenant; distinct from the Azure subscription directory tenant and configured later.' },
|
|
105
|
+
licenseEdition: { kind: 'license', source: 'cliOrFile', description: 'License requested from Mori during tenant activation: COMMUNITY, STANDARD or ENTERPRISE, or an Azure Marketplace subscription ID. Standard/Enterprise by name is a 30-day evaluation.' },
|
|
106
|
+
onboardingMode: { kind: 'onboarding', source: 'workspacePolicy', description: 'Miso onboarding mode (ONBOARDING_MODE); Builder installations use saasDirect, Marketplace customer installations use customerOwned.' },
|
|
107
|
+
misoImageTag: { kind: 'file', source: 'templateDefault', description: 'Miso container image tag.' },
|
|
108
|
+
keycloakImageTag: { kind: 'file', source: 'templateDefault', description: 'Keycloak container image tag.' }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const OUTPUT_CATALOG = Object.freeze({
|
|
112
|
+
resourceGroupName: 'Deployed Azure resource group.',
|
|
113
|
+
vnetId: 'Shared virtual-network resource ID.',
|
|
114
|
+
keyVaultName: 'Installation Key Vault name.',
|
|
115
|
+
scriptStorageAccountName: 'Deployment-script storage account.',
|
|
116
|
+
flowiseFileShareName: 'Flowise file share.',
|
|
117
|
+
managedIdentityName: 'Shared managed identity name.',
|
|
118
|
+
appServicePlanName: 'App Service plan name.',
|
|
119
|
+
webAppName: 'Miso web application name.',
|
|
120
|
+
webAppUrl: 'Miso web application URL.',
|
|
121
|
+
postgresServerName: 'PostgreSQL server name.',
|
|
122
|
+
postgresServerFqdn: 'PostgreSQL server FQDN.',
|
|
123
|
+
postgresMisoDatabaseName: 'Miso database name.',
|
|
124
|
+
postgresLogsDatabaseName: 'Logs database name.',
|
|
125
|
+
postgresKeycloakDatabaseName: 'Keycloak database name.',
|
|
126
|
+
keycloakWebAppName: 'Keycloak web application name.',
|
|
127
|
+
keycloakWebAppUrl: 'Keycloak web application URL.',
|
|
128
|
+
keycloakRealmUrl: 'Keycloak realm URL.',
|
|
129
|
+
keycloakAdminUser: 'Keycloak administrator user name.',
|
|
130
|
+
portalUrl: 'Azure portal link for the installation resource group.'
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** Mori service key name written by DOCKER registration. */
|
|
134
|
+
const MORI_SERVICE_KEY = 'mori-controller-api-keyKeyVault';
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Canonical secret contract. `generate` keys may be created by the Builder secret system;
|
|
138
|
+
* `required` keys must resolve to a non-empty value before use.
|
|
139
|
+
*/
|
|
140
|
+
const SECRET_BINDINGS = Object.freeze({
|
|
141
|
+
ADMIN_PASSWORD: { kv: 'miso-controller-admin-passwordKeyVault', required: true, generate: false, comment: 'Required installation administrator password; same canonical secret used by Miso onboarding.' },
|
|
142
|
+
ENCRYPTION_KEY: { kv: 'miso-controller-secrets-encryptionKeyVault', required: true, generate: true, comment: 'Application/data encryption key.' },
|
|
143
|
+
MORI_CALLBACK_API_KEY: { kv: 'mori-callback-api-keyKeyVault', required: true, generate: true, comment: 'Mori callback credential.' },
|
|
144
|
+
MISO_API_KEY: { kv: 'miso-controller-secrets-apiKeyVault', required: false, generate: false, comment: 'Temporary Miso deployment key; an existing login may be used instead.' },
|
|
145
|
+
MORI_API_KEY: { kv: MORI_SERVICE_KEY, required: true, generate: false, comment: 'Mori installation service key stored by DOCKER registration.' }
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const ENV_TEMPLATE_KEYS = Object.freeze({
|
|
149
|
+
AZURE: ['ADMIN_PASSWORD', 'ENCRYPTION_KEY', 'MORI_CALLBACK_API_KEY', 'MISO_API_KEY'],
|
|
150
|
+
DOCKER: ['ADMIN_PASSWORD', 'ENCRYPTION_KEY', 'MORI_CALLBACK_API_KEY', 'MORI_API_KEY']
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Committed env.template content for a vendor.
|
|
155
|
+
* @param {string} vendor - AZURE or DOCKER
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
function buildEnvTemplate(vendor) {
|
|
159
|
+
const lines = ['# Installation workspace secret contract. Values resolve from the Builder secret system into the ignored .env.'];
|
|
160
|
+
for (const name of ENV_TEMPLATE_KEYS[vendor]) {
|
|
161
|
+
lines.push(`# ${SECRET_BINDINGS[name].comment}`, `${name}=kv://${SECRET_BINDINGS[name].kv}`);
|
|
162
|
+
}
|
|
163
|
+
if (vendor === 'AZURE') {
|
|
164
|
+
lines.push('# Controller URL written by successful infra deployment.', 'CONTROLLER_URL=');
|
|
165
|
+
}
|
|
166
|
+
return `${lines.join('\n')}\n`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sameKeys(actual, expected) {
|
|
170
|
+
const missing = expected.filter(key => !actual.includes(key));
|
|
171
|
+
const unclassified = actual.filter(key => !expected.includes(key));
|
|
172
|
+
return { missing, unclassified };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Fails closed when the vendor template has parameters or outputs the catalog does not classify.
|
|
177
|
+
* @param {Object} template - Parsed ARM template
|
|
178
|
+
* @throws {Error} On drift
|
|
179
|
+
*/
|
|
180
|
+
function assertTemplateCoverage(template) {
|
|
181
|
+
const params = sameKeys(Object.keys(template.parameters || {}), Object.keys(PARAMETER_CATALOG));
|
|
182
|
+
const outputs = sameKeys(Object.keys(template.outputs || {}), Object.keys(OUTPUT_CATALOG));
|
|
183
|
+
const problems = [
|
|
184
|
+
...params.unclassified.map(key => `unclassified parameter ${key}`),
|
|
185
|
+
...params.missing.map(key => `missing parameter ${key}`),
|
|
186
|
+
...outputs.unclassified.map(key => `unclassified output ${key}`),
|
|
187
|
+
...outputs.missing.map(key => `missing output ${key}`)
|
|
188
|
+
];
|
|
189
|
+
if (problems.length > 0) {
|
|
190
|
+
throw new Error(`Marketplace template does not match the supported installation contract: ${problems.join(', ')}. Update Builder before deploying this template.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
PARAMETER_CATALOG,
|
|
196
|
+
OUTPUT_CATALOG,
|
|
197
|
+
SECRET_BINDINGS,
|
|
198
|
+
ENV_TEMPLATE_KEYS,
|
|
199
|
+
MORI_SERVICE_KEY,
|
|
200
|
+
LICENSE_EDITIONS,
|
|
201
|
+
DEFAULT_LICENSE_EDITION,
|
|
202
|
+
ONBOARDING_MODES,
|
|
203
|
+
DEFAULT_ONBOARDING_MODE,
|
|
204
|
+
ACR_AUTH_MODES,
|
|
205
|
+
DEFAULT_ACR_AUTH,
|
|
206
|
+
normalizeLicenseEdition,
|
|
207
|
+
normalizeAcrAuth,
|
|
208
|
+
normalizeAzureLocation,
|
|
209
|
+
buildEnvTemplate,
|
|
210
|
+
assertTemplateCoverage
|
|
211
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Generation, normalization and projection of the committed, non-secret
|
|
5
|
+
* `environment-infra.json` installation manifest. Every operational value is explicit in the
|
|
6
|
+
* file with its source and description; secrets appear only as `valueFromEnv` references.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const {
|
|
11
|
+
PARAMETER_CATALOG, OUTPUT_CATALOG, DEFAULT_LICENSE_EDITION, DEFAULT_ONBOARDING_MODE, DEFAULT_ACR_AUTH
|
|
12
|
+
} = require('./infra-catalog');
|
|
13
|
+
|
|
14
|
+
const ACR_AUTH_DESCRIPTION = 'Image pull auth: auto (default; local dev set-registry pair for the ACR login server, else managed identity), mi (AcrPull), or credentials (local pair, then authorized ACR admin lookup). Credentials are never committed.';
|
|
15
|
+
const INFRA_SCHEMA_VERSION = '2.0.0';
|
|
16
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
17
|
+
|
|
18
|
+
function entry(value, source, description, extra = {}) {
|
|
19
|
+
return { value, source, description, ...extra };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function templateDefault(template, name) {
|
|
23
|
+
const value = template.parameters[name].defaultValue;
|
|
24
|
+
if (typeof value === 'string' && value.startsWith('[')) return '';
|
|
25
|
+
return value === undefined ? '' : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parameterEntry(name, template) {
|
|
29
|
+
const spec = PARAMETER_CATALOG[name];
|
|
30
|
+
switch (spec.kind) {
|
|
31
|
+
case 'secretEnv':
|
|
32
|
+
return { valueFromEnv: spec.env, source: 'secretEnv', required: spec.required, description: spec.description };
|
|
33
|
+
case 'acrLookup':
|
|
34
|
+
return entry(null, 'azureAcrLookup', spec.description);
|
|
35
|
+
case 'fixed':
|
|
36
|
+
return entry(spec.value, spec.source, spec.description);
|
|
37
|
+
case 'deferred':
|
|
38
|
+
return entry(null, 'deferred', spec.description, { requiredAt: 'tenantActivation' });
|
|
39
|
+
case 'allowMyIp':
|
|
40
|
+
return entry(name === 'allowedIPs' ? [] : false, spec.source, spec.description);
|
|
41
|
+
case 'license':
|
|
42
|
+
return entry(DEFAULT_LICENSE_EDITION, spec.source, spec.description);
|
|
43
|
+
case 'onboarding':
|
|
44
|
+
return entry(DEFAULT_ONBOARDING_MODE, spec.source, spec.description);
|
|
45
|
+
default:
|
|
46
|
+
return entry(templateDefault(template, name), spec.source, spec.description, spec.required ? { required: true } : {});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Sources written by Builder workflows that must survive regeneration. */
|
|
51
|
+
const STICKY_SOURCES = Object.freeze(['cli', 'acrAuthCredentials', 'acrAuthAdmin']);
|
|
52
|
+
|
|
53
|
+
function preservedState(previous) {
|
|
54
|
+
const kept = { value: previous.value };
|
|
55
|
+
if (STICKY_SOURCES.includes(previous.source)) kept.source = previous.source;
|
|
56
|
+
for (const key of ['restoreValue', 'restoreSource']) {
|
|
57
|
+
if (previous[key] !== undefined) kept[key] = previous[key];
|
|
58
|
+
}
|
|
59
|
+
return kept;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildParameters(template, existing = {}) {
|
|
63
|
+
const parameters = {};
|
|
64
|
+
for (const name of Object.keys(PARAMETER_CATALOG)) {
|
|
65
|
+
const fresh = parameterEntry(name, template);
|
|
66
|
+
const workflowOwned = ['fixed', 'deferred', 'secretEnv'].includes(PARAMETER_CATALOG[name].kind);
|
|
67
|
+
parameters[name] = existing[name] && !workflowOwned ? { ...fresh, ...preservedState(existing[name]) } : fresh;
|
|
68
|
+
}
|
|
69
|
+
return parameters;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildOutputs(existing = {}) {
|
|
73
|
+
return Object.fromEntries(Object.entries(OUTPUT_CATALOG).map(([name, description]) =>
|
|
74
|
+
[name, { value: existing[name]?.value ?? null, description }]));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function azureSections(template, existing) {
|
|
78
|
+
return {
|
|
79
|
+
acrName: existing.acrName || entry('', 'user', 'Required private ACR name. Builder resolves and persists its login server and resource ID using the authenticated Azure account.', { required: true }),
|
|
80
|
+
acrAuth: existing.acrAuth || entry(DEFAULT_ACR_AUTH, 'cliOrFile', ACR_AUTH_DESCRIPTION),
|
|
81
|
+
allowMyIp: existing.allowMyIp || entry(false, 'cliOrFile', 'Default-off internal test access. When enabled, Builder resolves and audits one public IPv4 /32.', { resolvedCidr: null, resolvedAt: null, resolver: null }),
|
|
82
|
+
preset: existing.preset || entry('s', 'cliOrFile', 'Shared infrastructure sizing preset.'),
|
|
83
|
+
parameters: buildParameters(template, existing.parameters),
|
|
84
|
+
outputs: buildOutputs(existing.outputs),
|
|
85
|
+
controllerUrl: existing.controllerUrl || entry(null, 'infraOutput', 'Activated Miso Controller URL derived from the Miso web application output.'),
|
|
86
|
+
tenantActivation: existing.tenantActivation || {
|
|
87
|
+
status: entry('pending', 'controller', 'Server-reported tenant activation state; Builder never marks activation complete.'),
|
|
88
|
+
url: entry(null, 'infraOutput', 'URL the user opens to sign in and complete tenant activation.')
|
|
89
|
+
},
|
|
90
|
+
deployment: existing.deployment || {
|
|
91
|
+
status: entry('not-deployed', 'azureDeployment', 'Latest shared infrastructure deployment status.'),
|
|
92
|
+
name: null, id: null, inputHash: null, completedAt: null
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function dockerSections(template, existing) {
|
|
98
|
+
const moriDefault = templateDefault(template, 'moriBaseUrl');
|
|
99
|
+
return {
|
|
100
|
+
moriBaseUrl: existing.moriBaseUrl || entry(moriDefault, 'templateDefault', 'Mori service base URL used for registration.', { required: true }),
|
|
101
|
+
marketplaceSubscriptionId: existing.marketplaceSubscriptionId || entry('', 'workspace', 'Marketplace subscription ID; empty selects Mori Community.'),
|
|
102
|
+
registration: existing.registration || {
|
|
103
|
+
status: entry('not-registered', 'mori', 'Mori registration status; the one-time key is stored only in local user secrets.'),
|
|
104
|
+
evidence: null
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function identitySections(vendor, resourceGroup, existing) {
|
|
110
|
+
const docker = vendor === 'DOCKER';
|
|
111
|
+
const idEntry = (description) => docker
|
|
112
|
+
? entry(crypto.randomUUID(), 'generated', description)
|
|
113
|
+
: entry('', 'user', description, { required: true });
|
|
114
|
+
return {
|
|
115
|
+
schemaVersion: INFRA_SCHEMA_VERSION,
|
|
116
|
+
vendor: existing.vendor || entry(vendor, 'cli', 'Installation vendor; immutable once recorded.'),
|
|
117
|
+
resourceGroup: existing.resourceGroup || entry(resourceGroup, 'command', 'Workspace and Azure resource-group key.'),
|
|
118
|
+
installId: existing.installId || entry(resourceGroup, 'command', 'Installation identifier and deployment journal key.'),
|
|
119
|
+
subscriptionId: existing.subscriptionId || idEntry(docker
|
|
120
|
+
? 'Stable Docker installation UUID sent to Mori.'
|
|
121
|
+
: 'Approved installation subscription recorded by this workspace; active Azure account must match.'),
|
|
122
|
+
tenantId: existing.tenantId || idEntry(docker
|
|
123
|
+
? 'Stable Docker owner UUID sent to Mori.'
|
|
124
|
+
: 'Approved installation directory tenant recorded by this workspace; not an Entra provider setting.')
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Creates or normalizes the infra manifest for a vendor, preserving user-owned values.
|
|
130
|
+
* @param {Object} params - Inputs
|
|
131
|
+
* @param {string} params.vendor - AZURE or DOCKER
|
|
132
|
+
* @param {string} params.resourceGroup - Resource-group key
|
|
133
|
+
* @param {Object} params.template - Parsed shipped Marketplace template
|
|
134
|
+
* @param {Object|null} params.existing - Existing manifest
|
|
135
|
+
* @returns {Object} Manifest document
|
|
136
|
+
*/
|
|
137
|
+
function buildInfraDocument({ vendor, resourceGroup, template, existing }) {
|
|
138
|
+
const current = existing || {};
|
|
139
|
+
if (existing && existing.schemaVersion !== INFRA_SCHEMA_VERSION) {
|
|
140
|
+
throw new Error(`environment-infra.json schemaVersion ${existing.schemaVersion} is not supported; expected ${INFRA_SCHEMA_VERSION}`);
|
|
141
|
+
}
|
|
142
|
+
const sections = vendor === 'DOCKER' ? dockerSections(template, current) : azureSections(template, current);
|
|
143
|
+
return {
|
|
144
|
+
...identitySections(vendor, resourceGroup, current),
|
|
145
|
+
...sections,
|
|
146
|
+
variables: current.variables || {},
|
|
147
|
+
generated: current.generated || {}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Lists user-owned required fields that are still empty or invalid.
|
|
153
|
+
* @param {Object} doc - Manifest
|
|
154
|
+
* @returns {string[]} JSON paths inside environment-infra.json
|
|
155
|
+
*/
|
|
156
|
+
function missingRequiredFields(doc) {
|
|
157
|
+
const missing = [];
|
|
158
|
+
for (const key of ['subscriptionId', 'tenantId']) {
|
|
159
|
+
if (!UUID.test(String(doc[key].value || ''))) missing.push(`${key}.value (UUID)`);
|
|
160
|
+
}
|
|
161
|
+
if (doc.vendor.value === 'DOCKER') {
|
|
162
|
+
if (!/^https:\/\//.test(String(doc.moriBaseUrl.value || ''))) missing.push('moriBaseUrl.value (https URL)');
|
|
163
|
+
return missing;
|
|
164
|
+
}
|
|
165
|
+
if (!/^[a-zA-Z0-9]{5,50}$/.test(String(doc.acrName.value || ''))) missing.push('acrName.value');
|
|
166
|
+
if (!/^[a-z0-9]+$/.test(String(doc.parameters.location.value || ''))) missing.push('parameters.location.value');
|
|
167
|
+
if (!/^https:\/\//.test(String(doc.parameters.moriBaseUrl.value || ''))) missing.push('parameters.moriBaseUrl.value (https URL)');
|
|
168
|
+
return missing;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
INFRA_SCHEMA_VERSION,
|
|
173
|
+
ACR_AUTH_DESCRIPTION,
|
|
174
|
+
buildInfraDocument,
|
|
175
|
+
missingRequiredFields
|
|
176
|
+
};
|