@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,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview AZURE infra readiness evidence: both web app identities hold AcrPull on the
5
+ * workspace registry with managed-identity image pull enabled, and Miso and Keycloak answer over
6
+ * HTTPS. Local `az acr login` success is never treated as readiness.
7
+ */
8
+
9
+ const APP_OUTPUTS = Object.freeze([
10
+ { name: 'webAppName', label: 'Miso' },
11
+ { name: 'keycloakWebAppName', label: 'Keycloak' }
12
+ ]);
13
+
14
+ function scoped(args, doc) {
15
+ return [...args, '--subscription', doc.subscriptionId.value];
16
+ }
17
+
18
+ function verifyAppImagePull(doc, app, acrId, az) {
19
+ const group = doc.resourceGroup.value;
20
+ const principalId = az(scoped([
21
+ 'webapp', 'identity', 'show', '--resource-group', group, '--name', app.value,
22
+ '--query', 'principalId', '--output', 'tsv'
23
+ ], doc));
24
+ if (!principalId) throw new Error(`${app.label} web app has no system-assigned identity`);
25
+ const assignments = az(scoped([
26
+ 'role', 'assignment', 'list', '--scope', acrId, '--role', 'AcrPull',
27
+ '--query', `length([?principalId=='${principalId}'])`, '--output', 'tsv'
28
+ ], doc));
29
+ if (!(Number(assignments) > 0)) {
30
+ throw new Error(`${app.label} web app identity has no AcrPull assignment on the workspace registry`);
31
+ }
32
+ const managedPull = az(scoped([
33
+ 'webapp', 'config', 'show', '--resource-group', group, '--name', app.value,
34
+ '--query', 'acrUseManagedIdentityCreds', '--output', 'tsv'
35
+ ], doc));
36
+ if (String(managedPull).toLowerCase() !== 'true') {
37
+ throw new Error(`${app.label} web app does not pull images with its managed identity`);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Verifies persistent managed-identity image-pull authorization for both web apps.
43
+ * @param {Object} doc - Infra manifest with outputs
44
+ * @param {Function} az - Azure CLI runner
45
+ */
46
+ function verifyRuntimeImagePull(doc, az) {
47
+ const acrId = doc.parameters.acrResourceId.value;
48
+ for (const { name, label } of APP_OUTPUTS) {
49
+ const value = doc.outputs[name].value;
50
+ if (!value) throw new Error(`ARM output ${name} is missing; cannot verify ${label} image pull`);
51
+ verifyAppImagePull(doc, { value, label }, acrId, az);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Waits for Miso and Keycloak to start after pulling their images.
57
+ * @param {Object} doc - Infra manifest with outputs
58
+ * @param {Function} probe - probeHttps(baseUrl, path, label)
59
+ * @returns {Promise<void>}
60
+ */
61
+ async function waitForApplications(doc, probe) {
62
+ await probe(doc.outputs.webAppUrl.value, '/api/v1/health/readiness', 'Miso');
63
+ const realmUrl = doc.outputs.keycloakRealmUrl.value;
64
+ if (typeof realmUrl !== 'string' || !realmUrl.startsWith('https://')) {
65
+ throw new Error('ARM output keycloakRealmUrl is not a valid HTTPS URL');
66
+ }
67
+ const parsed = new URL(realmUrl);
68
+ await probe(parsed.origin, parsed.pathname, 'Keycloak');
69
+ }
70
+
71
+ module.exports = { verifyRuntimeImagePull, waitForApplications };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview DOCKER `--stage register`: one idempotent Mori bootstrap request. The one-time
5
+ * Service API Key is written only to the local user secrets file (never the shared store or a
6
+ * remote secrets API) and then materialized into the ignored workspace `.env`.
7
+ */
8
+
9
+ const { materializeWorkspace, saveInfra } = require('./workspace');
10
+ const { missingRequiredFields } = require('./infra-manifest');
11
+ const { MORI_SERVICE_KEY } = require('./infra-catalog');
12
+ const { materializeWorkspaceSecrets } = require('./workspace-secrets');
13
+ const { assertIgnoredSecretFile } = require('./workspace-files');
14
+
15
+ const LOST_KEY_GUIDANCE =
16
+ `The one-time Mori installation key is not in local secrets (${MORI_SERVICE_KEY}). ` +
17
+ 'Mori issues it only once, so Builder will not request another. Restore it with ' +
18
+ `"aifabrix secret set ${MORI_SERVICE_KEY} <value>" or ask the Mori administrator to rotate the installation key.`;
19
+
20
+ function defaultDeps() {
21
+ const { bootstrapMarketplaceInstallation } = require('../../api/marketplace-bootstrap.api');
22
+ const { saveLocalSecret } = require('../../utils/local-secrets');
23
+ const { loadSecrets } = require('../../core/secrets');
24
+ return { bootstrap: bootstrapMarketplaceInstallation, saveLocalSecret, loadSecrets };
25
+ }
26
+
27
+ function setStatus(doc, value, evidence) {
28
+ doc.registration.status = { ...doc.registration.status, value };
29
+ if (evidence !== undefined) doc.registration.evidence = evidence;
30
+ }
31
+
32
+ async function hasLocalKey(deps) {
33
+ const secrets = (await deps.loadSecrets()) || {};
34
+ const value = secrets[MORI_SERVICE_KEY];
35
+ return typeof value === 'string' && value.trim() !== '' && !value.startsWith('kv://');
36
+ }
37
+
38
+ function bootstrapBody(doc) {
39
+ return {
40
+ subscriptionId: doc.subscriptionId.value,
41
+ tenantId: doc.tenantId.value,
42
+ resourceGroupName: doc.resourceGroup.value,
43
+ cloudVendor: 'DOCKER',
44
+ marketplaceSubscriptionId: doc.marketplaceSubscriptionId.value || ''
45
+ };
46
+ }
47
+
48
+ function evidenceFrom(data) {
49
+ return {
50
+ apiKeyId: data.apiKeyId || null,
51
+ keyPrefix: data.keyPrefix || null,
52
+ createdAt: data.createdAt || null,
53
+ installation: data.installation || null,
54
+ entitlement: data.entitlement || null
55
+ };
56
+ }
57
+
58
+ async function requestRegistration(workspace, doc, deps) {
59
+ setStatus(doc, 'requested');
60
+ saveInfra(workspace, doc);
61
+ const response = await deps.bootstrap(doc.moriBaseUrl.value.replace(/\/+$/, ''), bootstrapBody(doc));
62
+ if (!response || !response.success) {
63
+ const status = response?.status;
64
+ setStatus(doc, status === 409 ? 'conflict' : 'not-registered');
65
+ saveInfra(workspace, doc);
66
+ if (status === 409) throw new Error(`Mori reports this installation is already registered. ${LOST_KEY_GUIDANCE}`);
67
+ throw new Error(`Mori registration failed${status ? ` (HTTP ${status})` : ' (network error)'}; no key was issued. Rerun when Mori is reachable.`);
68
+ }
69
+ const data = response.data?.data || response.data || {};
70
+ if (typeof data.apiKey !== 'string' || data.apiKey === '') {
71
+ setStatus(doc, 'failed');
72
+ saveInfra(workspace, doc);
73
+ throw new Error('Mori registration returned no installation key; contact the Mori administrator before retrying');
74
+ }
75
+ await deps.saveLocalSecret(MORI_SERVICE_KEY, data.apiKey);
76
+ setStatus(doc, 'registered', evidenceFrom(data));
77
+ saveInfra(workspace, doc);
78
+ }
79
+
80
+ /**
81
+ * Runs DOCKER registration.
82
+ * @param {Object} workspace - Opened workspace
83
+ * @param {Object} input - Validated stage input
84
+ * @param {Object} [overrides] - Injected dependencies (bootstrap, saveLocalSecret, loadSecrets, secretsApi, templatePath)
85
+ * @returns {Promise<{ doc: Object, alreadyRegistered: boolean }>}
86
+ */
87
+ async function runDockerRegisterStage(workspace, input, overrides = {}) {
88
+ const deps = { ...defaultDeps(), ...overrides };
89
+ const { doc } = materializeWorkspace(workspace, input, overrides);
90
+ const missing = missingRequiredFields(doc);
91
+ if (missing.length > 0) {
92
+ throw new Error(`Set these fields in ${workspace.paths.infra}, then rerun:\n - ${missing.join('\n - ')}`);
93
+ }
94
+ assertIgnoredSecretFile(workspace.paths.env, workspace.root);
95
+ const status = doc.registration.status.value;
96
+ const alreadyRegistered = status === 'registered';
97
+ if (['registered', 'requested', 'conflict'].includes(status)) {
98
+ if (!(await hasLocalKey(deps))) throw new Error(LOST_KEY_GUIDANCE);
99
+ if (!alreadyRegistered) {
100
+ setStatus(doc, 'registered');
101
+ saveInfra(workspace, doc);
102
+ }
103
+ } else {
104
+ await requestRegistration(workspace, doc, deps);
105
+ }
106
+ await materializeWorkspaceSecrets({
107
+ vendor: 'DOCKER', envPath: workspace.paths.env, root: workspace.root, secretsApi: overrides.secretsApi
108
+ });
109
+ return { doc, alreadyRegistered };
110
+ }
111
+
112
+ module.exports = { runDockerRegisterStage, LOST_KEY_GUIDANCE };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Controller URL and Bearer authentication for dev/tst/pro workspace stages.
5
+ * Order: explicit `--miso-api-key-env` selector, `MISO_API_KEY` from the process, the workspace
6
+ * `.env`/secret system, then an existing Controller login. Key values never appear in output.
7
+ */
8
+
9
+ const { validateControllerUrl } = require('../../utils/deployment-validation');
10
+ const { readWorkspaceEnv } = require('./workspace-env');
11
+ const { materializeWorkspaceSecrets } = require('./workspace-secrets');
12
+
13
+ /**
14
+ * Resolves the Controller URL for an environment stage and persists an explicit override.
15
+ * @param {Object} params - Inputs
16
+ * @param {Object} params.envDoc - Environment document (mutated on override)
17
+ * @param {Object} params.infra - Infra manifest
18
+ * @param {Object.<string,string>} params.env - Workspace .env values
19
+ * @param {string} [params.override] - Validated --controller-url
20
+ * @returns {string} Controller URL
21
+ */
22
+ function resolveControllerUrl({ envDoc, infra, env, override }) {
23
+ if (override) {
24
+ envDoc.controllerUrl = { ...envDoc.controllerUrl, value: override, source: 'cli' };
25
+ }
26
+ const candidate = envDoc.controllerUrl.value || env.CONTROLLER_URL || infra.controllerUrl?.value;
27
+ if (!candidate) {
28
+ throw new Error('No Controller URL is recorded for this installation. Run --stage infra first or pass --controller-url https://<host>/miso.');
29
+ }
30
+ return validateControllerUrl(candidate).replace(/\/+$/, '');
31
+ }
32
+
33
+ async function workspaceApiKey(workspace, secretsApi) {
34
+ const existing = readWorkspaceEnv(workspace.paths.env).MISO_API_KEY;
35
+ if (existing) return existing;
36
+ const resolved = await materializeWorkspaceSecrets({
37
+ vendor: 'AZURE', envPath: workspace.paths.env, root: workspace.root, names: ['MISO_API_KEY'], secretsApi
38
+ });
39
+ return resolved.MISO_API_KEY;
40
+ }
41
+
42
+ /**
43
+ * Resolves Bearer authentication for Miso environment calls.
44
+ * @param {Object} params - Inputs
45
+ * @param {Object} params.workspace - Opened workspace
46
+ * @param {string} params.controllerUrl - Controller URL
47
+ * @param {string} [params.keyEnvName] - Explicit environment-variable selector
48
+ * @param {Object} [params.deps] - Injected getDeviceToken/secretsApi/env
49
+ * @returns {Promise<{ authConfig: Object, source: string }>}
50
+ */
51
+ async function resolveEnvironmentAuth({ workspace, controllerUrl, keyEnvName, deps = {} }) {
52
+ const processEnv = deps.env || process.env;
53
+ if (keyEnvName) {
54
+ const value = processEnv[keyEnvName];
55
+ if (!value) throw new Error(`Environment variable ${keyEnvName} selected by --miso-api-key-env is not set`);
56
+ return { authConfig: { type: 'bearer', token: value }, source: 'selector' };
57
+ }
58
+ if (processEnv.MISO_API_KEY) {
59
+ return { authConfig: { type: 'bearer', token: processEnv.MISO_API_KEY }, source: 'process' };
60
+ }
61
+ const fromWorkspace = await workspaceApiKey(workspace, deps.secretsApi);
62
+ if (fromWorkspace) {
63
+ return { authConfig: { type: 'bearer', token: fromWorkspace }, source: 'workspace' };
64
+ }
65
+ const getDeviceToken = deps.getDeviceToken || require('../../utils/token-manager').getOrRefreshDeviceToken;
66
+ const device = await getDeviceToken(controllerUrl);
67
+ if (device && device.token) {
68
+ return { authConfig: { type: 'bearer', token: device.token, controller: controllerUrl }, source: 'login' };
69
+ }
70
+ throw new Error(
71
+ `No Controller authentication for ${controllerUrl}. Run "aifabrix login -c ${controllerUrl}", ` +
72
+ 'or set MISO_API_KEY (or --miso-api-key-env <NAME>) to a temporary installation key.'
73
+ );
74
+ }
75
+
76
+ module.exports = { resolveControllerUrl, resolveEnvironmentAuth };
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview `environment-dev|tst|pro.json` generation and the minimal Miso request
5
+ * projections (create, update, validate/deploy). Workspace metadata wrappers never reach Miso.
6
+ */
7
+
8
+ // Real-sync adapter: installation workspace state must not disappear under Jest fs mocks.
9
+ const fs = require('../../internal/fs-real-sync');
10
+ const path = require('path');
11
+ const yaml = require('js-yaml');
12
+ const { normalizePreset } = require('../environment-config');
13
+
14
+ const ENVIRONMENT_SCHEMA_VERSION = '2.0.0';
15
+ const ENVIRONMENT_NAMES = Object.freeze({ dev: 'Development', tst: 'Test', pro: 'Production' });
16
+ const APPLICATION_TEMPLATES = path.resolve(__dirname, '../../../templates/applications');
17
+ const DEFAULT_APPLICATIONS = Object.freeze(['dataplane', 'builder-api']);
18
+
19
+ function entry(value, source, description, extra = {}) {
20
+ return { value, source, description, ...extra };
21
+ }
22
+
23
+ function defaultSections(envKey) {
24
+ return {
25
+ environment: entry(envKey, 'stage', 'Logical environment owned by this file.'),
26
+ name: entry(ENVIRONMENT_NAMES[envKey], 'generated', 'Display name sent to Miso.'),
27
+ preset: entry('s', 'cliOrFile', 'Environment sizing preset.'),
28
+ controllerUrl: { value: null, valueFromEnv: 'CONTROLLER_URL', source: 'infraOutput', description: 'Activated Miso Controller URL; an explicit value overrides the workspace .env.' },
29
+ serviceName: { inheritFrom: 'installId', source: 'environment-infra.json', description: 'Service name used for resource naming.' },
30
+ location: { inheritFrom: 'parameters.location', source: 'environment-infra.json', description: 'Azure region for this environment.' },
31
+ subscriptionId: { inheritFrom: 'subscriptionId', source: 'environment-infra.json', description: 'Azure subscription for this environment.' },
32
+ resourceGroupName: entry(null, 'generated', 'Azure resource group for this environment; null uses "<resource-group>-<environment>-rg".'),
33
+ networking: entry({ enablePrivateNetworking: false, enableAzureDns: false }, 'generated', 'Explicit environment networking sent to Miso.'),
34
+ allowedIPs: entry([], 'user', 'Environment API allowed CIDRs; independent of infra allowMyIp.'),
35
+ infrastructureAccess: entry([], 'user', 'Explicit environment infrastructure access entries.'),
36
+ applications: entry([...DEFAULT_APPLICATIONS], 'templateDefault', 'Application set whose top-level required variables this environment must provide.'),
37
+ variables: {},
38
+ request: entry(null, 'projection', 'Last non-secret request projection sent to Miso.'),
39
+ deployment: {
40
+ status: entry('not-deployed', 'controller', 'Latest deployment status.'),
41
+ id: entry(null, 'controller', 'Controller deployment identifier after submission.'),
42
+ updatedAt: null,
43
+ error: null
44
+ }
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Creates or normalizes an environment file, preserving user-owned values.
50
+ * @param {{ envKey: string, existing: Object|null }} params - Inputs
51
+ * @returns {Object} Environment document
52
+ */
53
+ function buildEnvironmentDocument({ envKey, existing }) {
54
+ const current = existing || {};
55
+ if (existing && existing.schemaVersion !== ENVIRONMENT_SCHEMA_VERSION) {
56
+ throw new Error(`environment-${envKey}.json schemaVersion ${existing.schemaVersion} is not supported; expected ${ENVIRONMENT_SCHEMA_VERSION}`);
57
+ }
58
+ if (existing && existing.environment?.value !== envKey) {
59
+ throw new Error(`environment-${envKey}.json records environment "${existing.environment?.value}"`);
60
+ }
61
+ const defaults = defaultSections(envKey);
62
+ const doc = { schemaVersion: ENVIRONMENT_SCHEMA_VERSION };
63
+ for (const [key, value] of Object.entries(defaults)) {
64
+ doc[key] = key === 'environment' ? value : (current[key] || value);
65
+ }
66
+ return doc;
67
+ }
68
+
69
+ function inherited(doc, field, infra) {
70
+ const item = doc[field];
71
+ if (item.value !== undefined && item.value !== null && item.value !== '') return item.value;
72
+ return String(item.inheritFrom).split('.').reduce((node, key) => node?.[key], infra)?.value;
73
+ }
74
+
75
+ /**
76
+ * Builds the explicit Miso placement for an environment from workspace files only.
77
+ * @param {Object} doc - Environment document
78
+ * @param {Object} infra - Infra manifest
79
+ * @returns {Object} Placement values
80
+ */
81
+ function buildPlacement(doc, infra) {
82
+ const envKey = doc.environment.value;
83
+ return {
84
+ key: envKey,
85
+ environment: envKey,
86
+ preset: normalizePreset(doc.preset.value),
87
+ serviceName: inherited(doc, 'serviceName', infra),
88
+ location: inherited(doc, 'location', infra),
89
+ resourceGroupName: doc.resourceGroupName.value || `${infra.resourceGroup.value}-${envKey}-rg`,
90
+ subscriptionId: inherited(doc, 'subscriptionId', infra)
91
+ };
92
+ }
93
+
94
+ /**
95
+ * @param {Object} doc - Environment document
96
+ * @param {Object} infra - Infra manifest
97
+ * @returns {{ create: Object, update: Object, deploy: Object }} Request bodies
98
+ */
99
+ function buildMisoRequests(doc, infra) {
100
+ const placement = buildPlacement(doc, infra);
101
+ const { key, environment, ...configuration } = placement;
102
+ const explicit = {
103
+ networking: doc.networking.value,
104
+ allowedIPs: doc.allowedIPs.value,
105
+ infrastructureAccess: doc.infrastructureAccess.value
106
+ };
107
+ return {
108
+ create: { key, name: doc.name.value, environment, configuration: { ...configuration, ...explicit } },
109
+ update: { name: doc.name.value, configuration: { ...configuration, ...explicit } },
110
+ deploy: { environmentConfig: placement }
111
+ };
112
+ }
113
+
114
+ function readApplicationContract(appKey, templatesDir) {
115
+ const dir = path.join(templatesDir, appKey);
116
+ const manifestPath = path.join(dir, 'application.yaml');
117
+ if (!/^[a-z0-9-]+$/.test(appKey) || !fs.existsSync(manifestPath)) {
118
+ throw new Error(`Application "${appKey}" listed in the environment file has no application template`);
119
+ }
120
+ const manifest = yaml.load(fs.readFileSync(manifestPath, 'utf8')) || {};
121
+ const envTemplatePath = path.join(dir, 'env.template');
122
+ const declared = new Set();
123
+ if (fs.existsSync(envTemplatePath)) {
124
+ for (const line of fs.readFileSync(envTemplatePath, 'utf8').split(/\r?\n/)) {
125
+ const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)=/);
126
+ if (match) declared.add(match[1]);
127
+ }
128
+ }
129
+ for (const item of manifest.configuration || []) if (item && item.name) declared.add(item.name);
130
+ return { required: Array.isArray(manifest.required) ? manifest.required : [], declared };
131
+ }
132
+
133
+ /**
134
+ * Rebuilds the application-required variable catalog with redacted resolution state.
135
+ * @param {Object} doc - Environment document (mutated)
136
+ * @param {Object.<string,string>} values - Workspace .env plus explicit process values
137
+ * @param {string} [templatesDir] - Application templates directory
138
+ * @returns {string[]} Unresolved or undeclared problems
139
+ */
140
+ function refreshVariableCatalog(doc, values, templatesDir = APPLICATION_TEMPLATES) {
141
+ const variables = {};
142
+ const problems = [];
143
+ for (const appKey of doc.applications.value) {
144
+ const contract = readApplicationContract(appKey, templatesDir);
145
+ for (const name of contract.required) {
146
+ if (!contract.declared.has(name)) problems.push(`${appKey} requires ${name}, which its env/configuration contract does not declare`);
147
+ const item = variables[name] || { valueFromEnv: name, requiredBy: [], source: 'secretEnv', description: `Required by the selected application set for ${doc.environment.value}.` };
148
+ item.requiredBy = [...new Set([...item.requiredBy, appKey])].sort();
149
+ variables[name] = item;
150
+ }
151
+ }
152
+ for (const [name, item] of Object.entries(variables)) {
153
+ item.resolution = values[name] ? 'resolved' : 'missing';
154
+ if (!values[name]) problems.push(`${name} (required by ${item.requiredBy.join(', ')}) is not set in the workspace .env or process environment`);
155
+ }
156
+ doc.variables = Object.fromEntries(Object.keys(variables).sort().map(name => [name, variables[name]]));
157
+ return problems;
158
+ }
159
+
160
+ module.exports = {
161
+ ENVIRONMENT_SCHEMA_VERSION,
162
+ buildEnvironmentDocument,
163
+ buildMisoRequests,
164
+ refreshVariableCatalog
165
+ };
@@ -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 };