@aifabrix/builder 2.60.0 → 2.62.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 (177) hide show
  1. package/README.md +14 -11
  2. package/docs/README.md +80 -0
  3. package/docs/builder-help/evidence-patterns.json +155 -0
  4. package/docs/builder-help/golden-examples/crm-company.json +30 -0
  5. package/docs/builder-help/golden-examples/crm-deal.json +29 -0
  6. package/docs/builder-help/golden-examples/document-storage-keyed-get.json +85 -0
  7. package/docs/builder-help/golden-examples/document-storage.json +30 -0
  8. package/docs/builder-help/golden-examples/meeting-transcript.json +29 -0
  9. package/docs/builder-help/golden-examples/repository-template.json +29 -0
  10. package/docs/builder-help/golden-examples/service-ticket.json +29 -0
  11. package/docs/builder-help/platform-roles.json +98 -0
  12. package/docs/builder-help/resource-type-catalog.json +402 -0
  13. package/lib/api/applications.api.js +22 -2
  14. package/lib/api/bootstrap-snapshot.api.js +115 -0
  15. package/lib/api/configuration.api.js +131 -0
  16. package/lib/api/system-secrets.api.js +72 -0
  17. package/lib/app/deploy.js +14 -5
  18. package/lib/app/run-docker-fallback.js +6 -1
  19. package/lib/app/run-env-compose.js +5 -6
  20. package/lib/app/run-helpers.js +2 -1
  21. package/lib/app/run-parameter-sync.js +142 -0
  22. package/lib/app/run.js +6 -4
  23. package/lib/build/docker-build-args.js +5 -2
  24. package/lib/build/index.js +3 -2
  25. package/lib/build/standard-docker-build.js +6 -2
  26. package/lib/channels/channel-artifact-generator.js +13 -3
  27. package/lib/cli/infra-guided-footers.js +10 -8
  28. package/lib/cli/infra-guided.js +30 -10
  29. package/lib/cli/setup-app.js +5 -0
  30. package/lib/cli/setup-environment.js +156 -0
  31. package/lib/cli/setup-infra-up-platform-action.js +14 -1
  32. package/lib/cli/setup-platform.js +2 -0
  33. package/lib/commands/auth-config.js +2 -0
  34. package/lib/commands/datasource-capability-upsert-cli.js +112 -0
  35. package/lib/commands/datasource-capability.js +4 -2
  36. package/lib/commands/env-secret-context.js +113 -0
  37. package/lib/commands/env-secret-list.js +200 -0
  38. package/lib/commands/env-secret-push-confirm.js +54 -0
  39. package/lib/commands/env-secret-push-run.js +227 -0
  40. package/lib/commands/env-secret-push.js +168 -0
  41. package/lib/commands/login.js +4 -0
  42. package/lib/commands/repair-datasource-apply.js +5 -1
  43. package/lib/commands/repair-datasource-keyed-document.js +122 -0
  44. package/lib/commands/repair-datasource-legacy.js +94 -0
  45. package/lib/commands/repair-datasource-run.js +1 -0
  46. package/lib/commands/setup-image-refresh.js +26 -0
  47. package/lib/commands/setup-modes.js +8 -9
  48. package/lib/commands/setup-prompts-platform-mode.js +34 -0
  49. package/lib/commands/setup-prompts.js +2 -180
  50. package/lib/commands/setup.js +3 -0
  51. package/lib/commands/up-builder-api.js +24 -8
  52. package/lib/commands/up-common.js +16 -1
  53. package/lib/commands/up-dataplane-bootstrap.js +70 -14
  54. package/lib/commands/up-dataplane-credentials.js +21 -0
  55. package/lib/commands/up-dataplane.js +37 -18
  56. package/lib/commands/up-integration-server.js +112 -0
  57. package/lib/commands/verify-operations-skip-e2e.js +25 -1
  58. package/lib/commands/verify-operations-steps.js +13 -1
  59. package/lib/commands/wizard-config-normalizer.js +12 -5
  60. package/lib/commands/wizard-core.js +5 -157
  61. package/lib/commands/wizard-file-saving.js +163 -0
  62. package/lib/core/admin-secrets-env-overlay.js +8 -3
  63. package/lib/core/env-platform-expand.js +5 -1
  64. package/lib/core/environment-access-policy.js +121 -0
  65. package/lib/core/environment-mode-policy.js +177 -0
  66. package/lib/core/local-env-overrides.js +149 -0
  67. package/lib/core/secrets-ensure.js +4 -2
  68. package/lib/core/secrets-env-content.js +17 -10
  69. package/lib/core/secrets-env-write.js +11 -4
  70. package/lib/core/secrets-load.js +4 -2
  71. package/lib/datasource/binary-documents-validator.js +190 -0
  72. package/lib/datasource/capability/capability-manifest.js +1 -0
  73. package/lib/datasource/capability/run-capability-upsert.js +202 -0
  74. package/lib/datasource/capability/upsert-ingredients.js +291 -0
  75. package/lib/datasource/capability/upsert-operations.js +138 -0
  76. package/lib/datasource/capability/upsert-test-scaffold.js +189 -0
  77. package/lib/datasource/validate.js +12 -5
  78. package/lib/deployment/installation/index.js +14 -1
  79. package/lib/deployment/installation/infra-catalog.js +3 -0
  80. package/lib/deployment/installation/local-environment-stage.js +285 -0
  81. package/lib/deployment/installation/local-installation.js +76 -0
  82. package/lib/deployment/installation/stage-input.js +5 -1
  83. package/lib/generator/builders.js +3 -1
  84. package/lib/generator/index.js +3 -0
  85. package/lib/generator/split-variables.js +2 -2
  86. package/lib/lifecycle/product-model.js +4 -3
  87. package/lib/lifecycle/report-display.js +3 -2
  88. package/lib/parameters/database-adoption.js +125 -0
  89. package/lib/parameters/infra-parameter-catalog.js +1 -1
  90. package/lib/parameters/physical-database-naming.js +136 -0
  91. package/lib/parameters/physical-secret-name.js +194 -0
  92. package/lib/parameters/rsa-secret-values.js +165 -0
  93. package/lib/programmatic/builder-help-enterprise-sync-fabrix.js +1 -1
  94. package/lib/programmatic/builder-help-governance.js +1 -1
  95. package/lib/programmatic/builder-help.js +1 -1
  96. package/lib/programmatic/run-channel-install-artifacts.js +28 -2
  97. package/lib/programmatic/run-channel-package.js +3 -1
  98. package/lib/programmatic/workspace-context.js +37 -2
  99. package/lib/role-assistant/test-runner-workhub-answers.js +4 -1
  100. package/lib/role-assistant/test-runner-workhub-missing-fields.js +42 -0
  101. package/lib/role-assistant/test-runner-workhub-wait-stop.js +94 -0
  102. package/lib/role-assistant/test-runner-workhub.js +28 -19
  103. package/lib/schema/application-schema.json +14 -2
  104. package/lib/schema/external-datasource.schema.json +23 -3
  105. package/lib/schema/infra.parameter.yaml +440 -56
  106. package/lib/schema/infrastructure-schema.json +213 -120
  107. package/lib/utils/compose-generate-docker-compose.js +9 -14
  108. package/lib/utils/compose-generator.js +4 -7
  109. package/lib/utils/compose-miso-env.js +2 -2
  110. package/lib/utils/compose-traefik-ingress-base.js +3 -4
  111. package/lib/utils/datasource-binary-evidence.js +92 -0
  112. package/lib/utils/datasource-test-run-capability-scope.js +44 -1
  113. package/lib/utils/datasource-test-run-debug-display.js +2 -0
  114. package/lib/utils/datasource-test-run-display.js +8 -2
  115. package/lib/utils/datasource-test-run-issue-guidance.js +176 -0
  116. package/lib/utils/datasource-test-run-tty-log.js +2 -0
  117. package/lib/utils/declarative-url-ports.js +3 -0
  118. package/lib/utils/env-copy.js +11 -10
  119. package/lib/utils/env-environment-file-paths.js +105 -0
  120. package/lib/utils/environment-scoped-resources.js +48 -14
  121. package/lib/utils/external-system-system-test-tty.js +3 -2
  122. package/lib/utils/image-tags.js +2 -2
  123. package/lib/utils/paths-system-builder-keys.js +7 -1
  124. package/lib/utils/paths.js +7 -2
  125. package/lib/utils/platform-kv-ref.js +1 -1
  126. package/lib/utils/postgres-platform-bootstrap.js +35 -1
  127. package/lib/utils/prepare-local-data-mount.js +58 -0
  128. package/lib/utils/redis-env-scope.js +21 -1
  129. package/lib/utils/registry-auth-sources.js +154 -0
  130. package/lib/utils/registry-credentials.js +131 -14
  131. package/lib/utils/secrets-generator.js +30 -21
  132. package/lib/utils/secrets-helpers.js +5 -8
  133. package/lib/utils/secrets-kv-scope.js +127 -28
  134. package/lib/utils/secrets-materialize-local.js +9 -0
  135. package/lib/utils/secrets-missing-error.js +35 -1
  136. package/lib/utils/system-secret-mapping.js +125 -0
  137. package/lib/utils/test-log-writer.js +2 -1
  138. package/lib/utils/token-manager.js +14 -8
  139. package/lib/utils/url-public-path-prefix.js +6 -10
  140. package/lib/validation/external-manifest-validator.js +5 -0
  141. package/lib/validation/openapi-contract-surface-validator.js +3 -1
  142. package/lib/validation/validate-external-file.js +5 -1
  143. package/package.json +6 -4
  144. package/templates/agent-kit/agent-kit.yaml +1 -1
  145. package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +2 -1
  146. package/templates/agent-kit/skills/aifabrix-connected-system/references/delivery-gates.md +15 -0
  147. package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +3 -2
  148. package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +6 -2
  149. package/templates/agent-kit/skills/aifabrix-prove/references/evidence-lifecycle.md +22 -0
  150. package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +7 -1
  151. package/templates/agent-kit/skills/aifabrix-role-assistant/references/testing-playbook.md +117 -0
  152. package/templates/agent-kit/skills/shared/interaction.md +83 -0
  153. package/templates/agent-kit/skills/shared/status.md +3 -12
  154. package/templates/applications/builder-api/application.yaml +1 -1
  155. package/templates/applications/builder-api/env.template +5 -1
  156. package/templates/applications/dataplane/application.yaml +2 -2
  157. package/templates/applications/dataplane/env.template +10 -12
  158. package/templates/applications/integration-server/application.yaml +91 -0
  159. package/templates/applications/integration-server/env.template +215 -0
  160. package/templates/applications/keycloak/application.yaml +6 -1
  161. package/templates/applications/mirrored-templates.json +52 -0
  162. package/templates/applications/miso-controller/application.yaml +116 -2
  163. package/templates/applications/miso-controller/env.template +62 -17
  164. package/templates/applications/miso-controller/rbac.yaml +17 -0
  165. package/templates/external-system/external-datasource.yaml.hbs +7 -1
  166. package/templates/marketplace/createUiDefinition.json +52 -4
  167. package/templates/marketplace/main.json +88 -10
  168. package/templates/python/docker-compose.hbs +1 -1
  169. package/templates/agent-kit/skills/aifabrix-plan/references/interaction.md +0 -41
  170. /package/{lib/programmatic/help-content → docs/builder-help/content}/channel-onboarding.md +0 -0
  171. /package/{lib/programmatic/help-content → docs/builder-help/content}/cip-overview.md +0 -0
  172. /package/{lib/programmatic/help-content → docs/builder-help/content}/connected-system-ui.md +0 -0
  173. /package/{lib/programmatic/help-content → docs/builder-help/content}/dimensions-guide.md +0 -0
  174. /package/{lib/programmatic/help-content → docs/builder-help/content}/enterprise-sync-fabrix.md +0 -0
  175. /package/{lib/programmatic/help-content → docs/builder-help/content}/overview.md +0 -0
  176. /package/{lib/programmatic/help-content → docs/builder-help/content}/subscription-guide.md +0 -0
  177. /package/{lib/programmatic/help-content → docs/builder-help/content}/workflow.md +0 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Build a no-duplicate upsert proof from author-owned scenario facts.
3
+ *
4
+ * @fileoverview Safe testPayload scaffolding for datasource capability upsert
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { deepClone } = require('./copy-operations');
10
+
11
+ const RUN_TOKEN = /\{\{\s*testRunId\s*\}\}/i;
12
+
13
+ function scenarioInput(scenario) {
14
+ const input = scenario?.input;
15
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
16
+ const properties = input.properties;
17
+ return properties && typeof properties === 'object' && !Array.isArray(properties)
18
+ ? properties
19
+ : input;
20
+ }
21
+
22
+ function scalar(value) {
23
+ return value !== null && ['string', 'number', 'boolean'].includes(typeof value);
24
+ }
25
+
26
+ function identityFromInput(input, identityFields) {
27
+ if (!input) return null;
28
+ const entries = identityFields.map((field) => [field, input[field]]);
29
+ if (entries.some(([, value]) => !scalar(value) || value === '')) return null;
30
+ return Object.fromEntries(entries);
31
+ }
32
+
33
+ function sameIdentity(first, second, identityFields) {
34
+ return identityFields.every((field) => first[field] === second[field]);
35
+ }
36
+
37
+ function isCollisionSafe(identity) {
38
+ return Object.values(identity).some((value) =>
39
+ typeof value === 'string' && RUN_TOKEN.test(value)
40
+ );
41
+ }
42
+
43
+ function profileFields(doc, targetKey) {
44
+ const profile = doc.exposed?.profiles?.[targetKey];
45
+ if (Array.isArray(profile)) return profile;
46
+ return Array.isArray(profile?.attributes) ? profile.attributes : [];
47
+ }
48
+
49
+ function changedPublishedField(doc, targetKey, first, second, identityFields) {
50
+ const identity = new Set(identityFields);
51
+ const published = new Set(profileFields(doc, targetKey));
52
+ return Object.keys(first).find((field) =>
53
+ !identity.has(field) && published.has(field) && scalar(first[field]) &&
54
+ scalar(second[field]) && first[field] !== second[field]
55
+ ) || null;
56
+ }
57
+
58
+ function analyzeWritePair(doc, targetKey, identityFields, firstRow, secondRow) {
59
+ const first = scenarioInput(firstRow);
60
+ const second = scenarioInput(secondRow);
61
+ const firstIdentity = identityFromInput(first, identityFields);
62
+ const secondIdentity = identityFromInput(second, identityFields);
63
+ if (!firstIdentity || !secondIdentity) return { reason: 'both writes must contain every business identity field' };
64
+ if (!sameIdentity(firstIdentity, secondIdentity, identityFields)) {
65
+ return { reason: 'the two writes must use the same business identity values' };
66
+ }
67
+ if (!isCollisionSafe(firstIdentity)) {
68
+ return { reason: 'the business identity must include {{testRunId}} to isolate each live run' };
69
+ }
70
+ const changedField = changedPublishedField(
71
+ doc, targetKey, first, second, identityFields
72
+ );
73
+ if (!changedField) {
74
+ return { reason: 'the second write must change one published non-identity scalar field' };
75
+ }
76
+ return { first, second, identity: firstIdentity, changedField };
77
+ }
78
+
79
+ function exactLookup(operation, input, expectedRows, identityKey) {
80
+ return {
81
+ operation,
82
+ input: deepClone(input),
83
+ identityKey,
84
+ matchMode: 'exactIdentities',
85
+ expectedRows: deepClone(expectedRows)
86
+ };
87
+ }
88
+
89
+ function expectedRow(identity, changedField, value) {
90
+ return { ...deepClone(identity), [changedField]: value };
91
+ }
92
+
93
+ function hasExactProof(scenarios, index, targetKey, lookupKey, facts) {
94
+ const rows = scenarios.slice(index, index + 5);
95
+ if (rows.length !== 5) return false;
96
+ const operations = rows.map((row) => row?.operation);
97
+ if (operations.join('|') !== [lookupKey, targetKey, lookupKey, targetKey, lookupKey].join('|')) {
98
+ return false;
99
+ }
100
+ const [before, , afterFirst, , afterSecond] = rows;
101
+ return [before, afterFirst, afterSecond].every((row) =>
102
+ row?.matchMode === 'exactIdentities' && row?.identityKey === facts.changedField
103
+ ) && Array.isArray(before.expectedRows) && before.expectedRows.length === 0 &&
104
+ afterFirst.expectedRows?.length === 1 && afterSecond.expectedRows?.length === 1 &&
105
+ afterFirst.expectedRows?.[0]?.[facts.changedField] === facts.first[facts.changedField] &&
106
+ afterSecond.expectedRows?.[0]?.[facts.changedField] === facts.second[facts.changedField];
107
+ }
108
+
109
+ function findExistingProof(doc, scenarios, targetKey, lookupKey, identityFields) {
110
+ for (let index = 0; index <= scenarios.length - 5; index += 1) {
111
+ const facts = analyzeWritePair(
112
+ doc, targetKey, identityFields, scenarios[index + 1], scenarios[index + 3]
113
+ );
114
+ if (!facts.reason && hasExactProof(scenarios, index, targetKey, lookupKey, facts)) return true;
115
+ }
116
+ return false;
117
+ }
118
+
119
+ function findAdjacentWrites(doc, scenarios, targetKey, identityFields) {
120
+ let lastReason = 'two adjacent enabled upsert scenarios are required';
121
+ for (let index = 0; index < scenarios.length - 1; index += 1) {
122
+ const first = scenarios[index];
123
+ const second = scenarios[index + 1];
124
+ if (first?.operation !== targetKey || second?.operation !== targetKey) continue;
125
+ if (first.enabled === false || second.enabled === false) continue;
126
+ const facts = analyzeWritePair(doc, targetKey, identityFields, first, second);
127
+ if (!facts.reason) return { index, facts };
128
+ lastReason = facts.reason;
129
+ }
130
+ return { reason: lastReason };
131
+ }
132
+
133
+ function insufficientIssue(reason) {
134
+ return {
135
+ code: 'UPSERT-TEST-DATA-INSUFFICIENT',
136
+ severity: 'blocking',
137
+ ownership: 'test-payload',
138
+ aiFixable: false,
139
+ message: `Safe --test scaffolding was not generated: ${reason}; no values were fabricated`,
140
+ consequence: 'A weaker generated case could provide false no-duplicate confidence.',
141
+ remediation: ['Author two adjacent upsert inputs with the same isolated identity and one changed published field, then rerun --test.'],
142
+ prevention: 'Keep collision-safe two-write facts with the datasource test payload.',
143
+ residualRisk: 'Upsert remains unproven until the exact live chain passes.'
144
+ };
145
+ }
146
+
147
+ function buildProofRows(lookupKey, facts) {
148
+ const firstExpected = expectedRow(
149
+ facts.identity, facts.changedField, facts.first[facts.changedField]
150
+ );
151
+ const secondExpected = expectedRow(
152
+ facts.identity, facts.changedField, facts.second[facts.changedField]
153
+ );
154
+ return [
155
+ exactLookup(lookupKey, facts.identity, [], facts.changedField),
156
+ exactLookup(lookupKey, facts.identity, [firstExpected], facts.changedField),
157
+ exactLookup(lookupKey, facts.identity, [secondExpected], facts.changedField)
158
+ ];
159
+ }
160
+
161
+ function scaffoldUpsertTests(originalDoc, analysis, targetKey = 'upsert') {
162
+ const scenarios = originalDoc.testPayload?.scenarios;
163
+ const lookupKey = analysis?.selectedLookup?.operationKey;
164
+ const identities = Array.isArray(analysis?.identityFields) ? analysis.identityFields : [];
165
+ if (!Array.isArray(scenarios) || !lookupKey || identities.length === 0) {
166
+ return { changed: false, issue: insufficientIssue('required scenarios or exact lookup facts are missing') };
167
+ }
168
+ if (findExistingProof(originalDoc, scenarios, targetKey, lookupKey, identities)) {
169
+ return { changed: false, doc: originalDoc, patchOperations: [], updatedSections: [] };
170
+ }
171
+ const pair = findAdjacentWrites(originalDoc, scenarios, targetKey, identities);
172
+ if (pair.reason) return { changed: false, issue: insufficientIssue(pair.reason) };
173
+ const [before, afterFirst, afterSecond] = buildProofRows(lookupKey, pair.facts);
174
+ const doc = deepClone(originalDoc);
175
+ doc.testPayload.scenarios.splice(pair.index, 2,
176
+ before, scenarios[pair.index], afterFirst, scenarios[pair.index + 1], afterSecond);
177
+ return {
178
+ changed: true,
179
+ doc,
180
+ patchOperations: [{ op: 'replace', path: '/testPayload/scenarios', value: doc.testPayload.scenarios }],
181
+ updatedSections: ['testPayload.scenarios: upsert no-duplicate proof']
182
+ };
183
+ }
184
+
185
+ module.exports = {
186
+ scaffoldUpsertTests,
187
+ analyzeWritePair,
188
+ insufficientIssue
189
+ };
@@ -23,6 +23,8 @@ const { validateFieldReferences } = require('./field-reference-validator');
23
23
  const { validateAbac } = require('./abac-validator');
24
24
  const { buildDatasourceValidateSummary } = require('./datasource-validate-summary');
25
25
 
26
+ const { validateBinaryDocumentsContract } = require('./binary-documents-validator');
27
+
26
28
  const DATASOURCE_CONFIG_EXTENSIONS = new Set(['.json', '.yaml', '.yml']);
27
29
  const EXCLUDE_CONFIG_NAMES = new Set([
28
30
  'application.json',
@@ -242,12 +244,14 @@ async function validateDatasourceFile(filePathOrKey) {
242
244
  };
243
245
  }
244
246
 
247
+ const binary = validateBinaryDocumentsContract(parsed);
248
+ const warnings = binary.warnings.map(issue => `${issue.code}: ${issue.message}`);
245
249
  const postSchemaErrors = collectPostSchemaErrors(parsed);
246
250
  if (postSchemaErrors.length > 0) {
247
251
  return {
248
252
  valid: false,
249
253
  errors: postSchemaErrors,
250
- warnings: [],
254
+ warnings,
251
255
  resolvedPath: filePath,
252
256
  summary
253
257
  };
@@ -256,7 +260,7 @@ async function validateDatasourceFile(filePathOrKey) {
256
260
  return {
257
261
  valid: true,
258
262
  errors: [],
259
- warnings: [],
263
+ warnings,
260
264
  resolvedPath: filePath,
261
265
  summary
262
266
  };
@@ -269,7 +273,8 @@ async function validateDatasourceFile(filePathOrKey) {
269
273
  function collectPostSchemaErrors(parsed) {
270
274
  return [
271
275
  ...validateFieldReferences(parsed),
272
- ...validateAbac(parsed)
276
+ ...validateAbac(parsed),
277
+ ...validateBinaryDocumentsContract(parsed).errors.map(issue => `${issue.code}: ${issue.message}`)
273
278
  ];
274
279
  }
275
280
 
@@ -303,12 +308,14 @@ function validateDatasourceParsed(parsed) {
303
308
  };
304
309
  }
305
310
 
311
+ const binary = validateBinaryDocumentsContract(parsed);
312
+ const warnings = binary.warnings.map(issue => `${issue.code}: ${issue.message}`);
306
313
  const postSchemaErrors = collectPostSchemaErrors(parsed);
307
314
  if (postSchemaErrors.length > 0) {
308
315
  return {
309
316
  valid: false,
310
317
  errors: postSchemaErrors,
311
- warnings: [],
318
+ warnings,
312
319
  summary
313
320
  };
314
321
  }
@@ -316,7 +323,7 @@ function validateDatasourceParsed(parsed) {
316
323
  return {
317
324
  valid: true,
318
325
  errors: [],
319
- warnings: [],
326
+ warnings,
320
327
  summary
321
328
  };
322
329
  }
@@ -6,8 +6,14 @@
6
6
  * All input validation happens before any filesystem, network or cloud mutation.
7
7
  */
8
8
 
9
- const { resolveStageInput, validateResourceGroupKey, ENVIRONMENT_STAGES } = require('./stage-input');
9
+ const {
10
+ resolveStageInput,
11
+ validateResourceGroupKey,
12
+ validateStage,
13
+ ENVIRONMENT_STAGES
14
+ } = require('./stage-input');
10
15
  const { openWorkspace } = require('./workspace');
16
+ const { isLocalInstallationKey } = require('./local-installation');
11
17
 
12
18
  /**
13
19
  * Runs one installation workspace stage.
@@ -18,6 +24,13 @@ const { openWorkspace } = require('./workspace');
18
24
  */
19
25
  async function deployInstallationStage(resourceGroupKey, options, deps = {}) {
20
26
  const resourceGroup = validateResourceGroupKey(resourceGroupKey);
27
+ // A local installation owns its environments itself: no workspace, no Azure template and
28
+ // no Controller round trip. `register` still goes the shared route, because a local
29
+ // install registers with Mori exactly as any other does.
30
+ if (isLocalInstallationKey(resourceGroup) && ENVIRONMENT_STAGES.includes(validateStage(options.stage))) {
31
+ const { runLocalEnvironmentStage } = require('./local-environment-stage');
32
+ return runLocalEnvironmentStage(resourceGroup, validateStage(options.stage), deps);
33
+ }
21
34
  const workspace = openWorkspace(resourceGroup, { root: deps.root });
22
35
  const input = resolveStageInput(resourceGroup, options, workspace.infra?.vendor?.value);
23
36
  const base = { stage: input.stage, vendor: input.vendor, workspaceDir: workspace.dir };
@@ -88,6 +88,7 @@ const PARAMETER_CATALOG = Object.freeze({
88
88
  enablePostgresPublicAccess: { kind: 'allowMyIp', source: 'allowMyIp', description: 'Default off; enabled only while allowMyIp grants the validated caller IPv4 /32 for testing.' },
89
89
  allowedIPs: { kind: 'allowMyIp', source: 'allowMyIpOrUser', description: 'Explicit CIDRs for PostgreSQL, Key Vault, and Storage; allowMyIp merges the resolved caller IPv4 /32.' },
90
90
  adminPassword: { kind: 'secretEnv', env: 'ADMIN_PASSWORD', required: true, description: 'Installation administrator password.' },
91
+ adminEmail: { kind: 'file', source: 'user', required: true, description: 'Email of the initial platform administrator, used for the installation welcome. Required: the template sets no default and enforces minLength.' },
91
92
  deploymentEnvironment: { kind: 'file', source: 'templateDefault', description: 'Vendor deployment profile.' },
92
93
  keycloakVerifyAudience: { kind: 'file', source: 'templateDefault', description: 'Enable Keycloak audience verification.' },
93
94
  encryptionKey: { kind: 'secretEnv', env: 'ENCRYPTION_KEY', required: true, description: 'Application/data encryption key.' },
@@ -99,11 +100,13 @@ const PARAMETER_CATALOG = Object.freeze({
99
100
  deployRegistryRoleAssignment: { kind: 'parameterDefault', defaultFrom: 'deployRoleAssignments', source: 'templateDefault', description: 'Deploy the AcrPull grant on the container registry. False while credential image pull is in effect, because that grant lives in the registry resource group, which a customer installation cannot reach; restored for managed identity.' },
100
101
  moriBaseUrl: { kind: 'file', source: 'registration', required: true, description: 'Mori service base URL.' },
101
102
  moriLicensePublicKey: { kind: 'file', source: 'registration', required: false, description: 'Mori licence issuer public key (SPKI PEM); empty leaves the installation unpinned and it then refuses every licence.' },
103
+ moriPolicyPublicKey: { kind: 'file', source: 'registration', required: false, description: 'Mori policy signing anchor (SPKI PEM), a different pair from the licence key; empty leaves the policy bundle checkable only against the key it carries, which proves nothing.' },
102
104
  moriLicenseIssuer: { kind: 'file', source: 'registration', required: false, description: 'Expected iss claim on a Mori licence.' },
103
105
  entraIdClientId: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
104
106
  entraIdClientSecret: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
105
107
  entraIdTenantId: { kind: 'deferred', description: 'Customer Entra provider tenant; distinct from the Azure subscription directory tenant and configured later.' },
106
108
  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.' },
109
+ installationResourceModel: { kind: 'file', source: 'templateDefault', description: 'Initial installation topology: shared for Community and Standard, dedicated for Enterprise DEV. Defaults to shared and is protected by Azure application configuration. It records the topology the installation starts in and grants no later licence entitlement, so it is not a licence input.' },
107
110
  onboardingMode: { kind: 'onboarding', source: 'workspacePolicy', description: 'Miso onboarding mode (ONBOARDING_MODE); Builder installations use saasDirect, Marketplace customer installations use customerOwned.' },
108
111
  capacityProfile: { kind: 'capacity', source: 'workspacePreset', description: 'Infrastructure sizing profile (s, m, l, xl) selecting compute and PostgreSQL SKUs. Derived from the workspace preset so the two cannot drift; set it with --preset.' },
109
112
  misoImageTag: { kind: 'file', source: 'templateDefault', description: 'Miso container image tag.' },
@@ -0,0 +1,285 @@
1
+ /**
2
+ * `env deploy local --stage dev|tst|pro` — the local environment stage (plan 210.0).
3
+ *
4
+ * The Azure path creates an environment in the Miso Controller for an Azure installation.
5
+ * This is the same sentence spoken locally: create this environment's resources inside the
6
+ * local installation. Increment 1 covers the databases and roles; the promoted container and
7
+ * the front-door routing follow.
8
+ *
9
+ * Every environment shares one Postgres server here, so the names carry the environment —
10
+ * `{env}_{appKey}_{logical}` with a `_user` role, mirroring Miso 227.3 exactly rather than
11
+ * inventing a local scheme. See `lib/parameters/physical-database-naming.js`.
12
+ *
13
+ * @fileoverview Local environment provisioning for env deploy
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const logger = require('../../utils/logger');
19
+ const { resolveDatabaseNamesWithAdoption } = require('../../parameters/database-adoption');
20
+ const { parseDatabaseSecretKey } = require('../../parameters/database-secret-values');
21
+ const { resolveLocalInstallationName } = require('./local-installation');
22
+
23
+ /**
24
+ * Applications that exist **once per environment**.
25
+ *
26
+ * The dataplane and nothing else. Keycloak, the Miso Controller, builder-api and
27
+ * integration-server are **platform services**: one per platform, living in `miso`, shared by
28
+ * every environment. There is one ACR and one Keycloak, not one each per environment.
29
+ *
30
+ * This is why the list also bounds the databases. Creating `pro_keycloak_keycloak` would
31
+ * stand up a second Keycloak's database for a Keycloak that does not exist, and nothing
32
+ * would ever connect to it.
33
+ */
34
+ const ENVIRONMENT_WORKLOAD_APP_KEYS = Object.freeze(['dataplane']);
35
+
36
+ /**
37
+ * The databases this environment needs, with their physical names resolved together.
38
+ *
39
+ * Resolved as one list so a caller cannot take the database name and leave the role
40
+ * verbatim, which is the shape of the defect 227.3 fixed.
41
+ *
42
+ * Platform-service databases are **excluded**: they belong to the single shared platform in
43
+ * `miso`, which `setup` installs, not to a workload environment.
44
+ *
45
+ * An existing unprefixed database is **adopted** rather than renamed: renaming on a live
46
+ * server means dropping and recreating, which means losing the data. Only `dev` may adopt,
47
+ * because everything a pre-environment install created belongs to it.
48
+ *
49
+ * @param {string} envKey - Environment key
50
+ * @param {{ dbName: string, secretKey: string }[]} slots - Platform database slots
51
+ * @param {Iterable<string>} [existingDatabaseNames] - Databases the server already has
52
+ * @returns {{ logicalName: string, appKey: string, databaseName: string, roleName: string, secretKey: string, origin: string }[]}
53
+ * @throws {Error} When a slot's secret key cannot be parsed for its owning application
54
+ */
55
+ function resolveEnvironmentDatabasePlan(envKey, slots, existingDatabaseNames) {
56
+ const forEnvironment = slots.filter((slot) => {
57
+ const parsed = parseDatabaseSecretKey(slot.secretKey);
58
+ return parsed && ENVIRONMENT_WORKLOAD_APP_KEYS.includes(parsed.appKey);
59
+ });
60
+ return forEnvironment.map((slot) => {
61
+ const parsed = parseDatabaseSecretKey(slot.secretKey);
62
+ if (!parsed) {
63
+ throw new Error(
64
+ `Cannot determine the owning application for database "${slot.dbName}" from secret ` +
65
+ `key "${slot.secretKey}".`
66
+ );
67
+ }
68
+ const names = resolveDatabaseNamesWithAdoption({
69
+ logicalName: slot.dbName,
70
+ appKey: parsed.appKey,
71
+ environment: envKey,
72
+ existingDatabaseNames
73
+ });
74
+ return {
75
+ logicalName: names.logicalName,
76
+ appKey: parsed.appKey,
77
+ databaseName: names.databaseName,
78
+ roleName: names.roleName,
79
+ origin: names.origin,
80
+ // The catalog key, which the strict resolver already scopes per environment. Not
81
+ // `names.passwordSecretKey`: which grammar names this secret is open with Miso, and
82
+ // the catalog key is the one every existing consumer reads.
83
+ secretKey: slot.secretKey
84
+ };
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Create the databases and roles for one environment.
90
+ *
91
+ * @async
92
+ * @param {string} envKey - Environment key
93
+ * @param {Object} deps - Injected collaborators
94
+ * @returns {Promise<string[]>} Database names ensured
95
+ */
96
+ async function ensureEnvironmentDatabases(envKey, deps) {
97
+ const bootstrap = deps.bootstrap || require('../../utils/postgres-platform-bootstrap');
98
+ const { container, adminPassword, secrets } = await bootstrap.resolveBootstrapContext();
99
+ const existing = await bootstrap.listExistingDatabaseNames(container, adminPassword);
100
+ const plan = resolveEnvironmentDatabasePlan(envKey, bootstrap.PLATFORM_DATABASE_SLOTS, existing);
101
+ const ensured = [];
102
+ for (const item of plan) {
103
+ const password = bootstrap.extractPasswordFromUrlOrValue(secrets[item.secretKey]);
104
+ if (!password || !password.trim()) {
105
+ // Name the physical secret, which for an application-scoped key carries the owning
106
+ // application before the key. Quoting `<env>-<key>` here would send someone to create
107
+ // a name nothing reads.
108
+ const { computePhysicalSecretName } = require('../../parameters/physical-secret-name');
109
+ const physical = computePhysicalSecretName(item.secretKey, { environment: envKey });
110
+ throw new Error(
111
+ `Secret ${item.secretKey} is missing or empty for environment ${envKey}. ` +
112
+ `Add it with: aifabrix secret set ${physical} <value>`
113
+ );
114
+ }
115
+ await bootstrap.ensureRoleAndDatabase(
116
+ container,
117
+ item.databaseName,
118
+ item.roleName,
119
+ password.trim(),
120
+ adminPassword
121
+ );
122
+ if (item.origin === 'adopted') {
123
+ logger.log(`Adopted existing database ${item.databaseName} for ${envKey}`);
124
+ }
125
+ ensured.push(item.databaseName);
126
+ }
127
+ return ensured;
128
+ }
129
+
130
+ /**
131
+ * Ask Miso whether this environment may exist, before anything is created.
132
+ *
133
+ * **Editions are Miso's, not Builder's.** Whether a licence permits `tst` or `pro` is not a
134
+ * question this repository answers or caches: it asks, and Miso accepts or declines. Builder
135
+ * holding its own copy of the entitlement rules would be a second answer to drift from the
136
+ * licence, and a local one at that — the easiest possible thing to edit.
137
+ *
138
+ * This runs **first**. A decline after the databases exist would leave an environment's
139
+ * objects behind for an environment that is not licensed, and nothing would later remove
140
+ * them.
141
+ *
142
+ * @async
143
+ * @param {string} envKey - Environment key
144
+ * @param {Object} deps - Injected collaborators
145
+ * @returns {Promise<void>}
146
+ * @throws {Error} When Miso declines, or cannot be reached to ask
147
+ */
148
+ async function authorizeEnvironmentWithController(envKey, deps) {
149
+ if (typeof deps.authorizeEnvironment === 'function') {
150
+ await deps.authorizeEnvironment(envKey);
151
+ return;
152
+ }
153
+ const { resolveControllerUrl } = require('../../utils/controller-url');
154
+ const { getOrRefreshDeviceToken } = require('../../utils/token-manager');
155
+ const { createEnvironment } = require('../../api/environments.api');
156
+
157
+ const controllerUrl = await resolveControllerUrl();
158
+ if (!controllerUrl) {
159
+ throw new Error(
160
+ `Cannot create ${envKey}: no controller is configured, so whether your licence ` +
161
+ 'permits this environment cannot be checked. Run "aifabrix login" first.'
162
+ );
163
+ }
164
+ const deviceToken = await getOrRefreshDeviceToken(controllerUrl);
165
+ if (!deviceToken || !deviceToken.token) {
166
+ throw new Error(
167
+ `Cannot create ${envKey}: not signed in, so whether your licence permits this ` +
168
+ 'environment cannot be checked. Run "aifabrix login" first.'
169
+ );
170
+ }
171
+ const authConfig = {
172
+ type: 'device',
173
+ token: deviceToken.token,
174
+ controller: deviceToken.controller || controllerUrl
175
+ };
176
+ try {
177
+ await createEnvironment(controllerUrl, authConfig, { key: envKey, environment: envKey });
178
+ } catch (error) {
179
+ // Miso owns the edition rules, so its refusal is reported as given rather than
180
+ // reinterpreted here. Guessing at the reason would put a second, stale explanation of
181
+ // the licence in front of the operator.
182
+ throw new Error(
183
+ `Miso declined environment ${envKey}: ${error.message}\n` +
184
+ 'Editions are licensed in Miso. Nothing was created locally.'
185
+ );
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Workloads `env deploy` promotes into a promotion-only environment.
191
+ *
192
+ * The same set that owns the environment's databases: an application exists per environment
193
+ * or it does not, and its containers and its databases cannot disagree about which.
194
+ */
195
+ const PROMOTED_WORKLOAD_APP_KEYS = ENVIRONMENT_WORKLOAD_APP_KEYS;
196
+
197
+ /**
198
+ * Run the promoted image as this environment's container.
199
+ *
200
+ * `base: true` is the whole point. Without it the run path prefers a local developer-scoped
201
+ * tag, which is the image someone happens to have built — the opposite of promotion, where
202
+ * what runs in `pro` must be the artifact that was promoted out of dev.
203
+ *
204
+ * Nothing is built here, and image resolution stays with the run path rather than being
205
+ * recomputed: a second answer to "which image" is how the promoted artifact and the running
206
+ * one come apart.
207
+ *
208
+ * @async
209
+ * @param {string} envKey - Environment key
210
+ * @param {Object} deps - Injected collaborators
211
+ * @returns {Promise<string[]>} Application keys promoted
212
+ */
213
+ async function promoteWorkloadsForEnvironment(envKey, deps) {
214
+ const policy = deps.policy || require('../../core/environment-mode-policy');
215
+ // dev is a working environment: its containers come from `setup` and the `up-*` commands,
216
+ // and promoting over them would replace what a developer is working on.
217
+ if (!policy.isPromotionOnlyEnvironment(envKey)) {
218
+ return [];
219
+ }
220
+ const runApp = deps.runApp || require('../../app').runApp;
221
+ const promoted = [];
222
+ for (const appKey of PROMOTED_WORKLOAD_APP_KEYS) {
223
+ try {
224
+ await runApp(appKey, { env: envKey, base: true });
225
+ } catch (error) {
226
+ throw new Error(
227
+ `Could not promote ${appKey} into ${envKey}: ${error.message}\n` +
228
+ `${envKey} runs a promoted image and never builds one. Produce it in dev first ` +
229
+ `with: aifabrix build ${appKey} --base --from-dev`
230
+ );
231
+ }
232
+ promoted.push(appKey);
233
+ }
234
+ return promoted;
235
+ }
236
+
237
+ /**
238
+ * Run the local environment stage.
239
+ *
240
+ * @async
241
+ * @param {string} installationKey - Positional installation key (`local` or `local-…`)
242
+ * @param {string} envKey - Validated stage, one of dev|tst|pro
243
+ * @param {Object} [deps] - Injected collaborators
244
+ * @returns {Promise<{ stage: string, vendor: string, installation: string, databases: string[], promoted: string[] }>}
245
+ */
246
+ async function runLocalEnvironmentStage(installationKey, envKey, deps = {}) {
247
+ const config = deps.config || require('../../core/config');
248
+ const policy = deps.policy || require('../../core/environment-mode-policy');
249
+
250
+ // Miso first: it owns editions, and it accepts or declines this environment. Everything
251
+ // below creates something, so the licence answer has to come before any of it.
252
+ await authorizeEnvironmentWithController(envKey, deps);
253
+
254
+ // Then the local precondition. tst and pro are unreachable outside the front-door mode,
255
+ // so creating their databases would leave resources behind for an environment that cannot
256
+ // serve a request.
257
+ await policy.assertEnvironmentAllowedForCurrentMode(envKey);
258
+
259
+ const developerId = await config.getDeveloperId();
260
+ const installation = resolveLocalInstallationName(installationKey, developerId);
261
+
262
+ logger.log(`Deploying environment ${envKey} into local installation ${installation}`);
263
+
264
+ // Databases before containers: a workload that starts without its database fails on its
265
+ // first request rather than at startup, which is the harder failure to read.
266
+ const databases = await ensureEnvironmentDatabases(envKey, deps);
267
+ logger.log(`Ensured ${databases.length} database(s) for ${envKey}: ${databases.join(', ')}`);
268
+
269
+ const promoted = await promoteWorkloadsForEnvironment(envKey, deps);
270
+ if (promoted.length > 0) {
271
+ logger.log(`Promoted into ${envKey}: ${promoted.join(', ')}`);
272
+ }
273
+
274
+ return { stage: envKey, vendor: 'DOCKER', installation, databases, promoted };
275
+ }
276
+
277
+ module.exports = {
278
+ ENVIRONMENT_WORKLOAD_APP_KEYS,
279
+ PROMOTED_WORKLOAD_APP_KEYS,
280
+ authorizeEnvironmentWithController,
281
+ resolveEnvironmentDatabasePlan,
282
+ ensureEnvironmentDatabases,
283
+ promoteWorkloadsForEnvironment,
284
+ runLocalEnvironmentStage
285
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The `local` installation key for `env deploy` (plan 210.0).
3
+ *
4
+ * `env deploy` is dual-mode, and the installation name is what chooses the mode. Miso
5
+ * already names a local installation `local-dev{NN}`, so Builder mirrors that rather than
6
+ * inventing a second convention. The operator types the bare word `local`; the CLI knows its
7
+ * own developer id and supplies the rest.
8
+ *
9
+ * Naming the mode in the argument, rather than inferring it from machine state, means the
10
+ * positional means the same thing on both paths — an installation — and the command reads
11
+ * the same whichever installation it names.
12
+ *
13
+ * @fileoverview Local installation key detection and name derivation
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ /** The bare key an operator types. */
19
+ const LOCAL_INSTALLATION_KEY = 'local';
20
+
21
+ /** Prefix of a fully qualified local installation name. */
22
+ const LOCAL_INSTALLATION_PREFIX = 'local-';
23
+
24
+ /**
25
+ * Whether this installation key names a local installation.
26
+ *
27
+ * @param {unknown} key - Positional installation key
28
+ * @returns {boolean}
29
+ */
30
+ function isLocalInstallationKey(key) {
31
+ const value = String(key ?? '').trim().toLowerCase();
32
+ return value === LOCAL_INSTALLATION_KEY || value.startsWith(LOCAL_INSTALLATION_PREFIX);
33
+ }
34
+
35
+ /**
36
+ * The installation name for a local key.
37
+ *
38
+ * `local` expands to `local-dev{developerId}`; an already-qualified `local-…` is kept as
39
+ * given, so an operator can name an installation whose id is not this machine's.
40
+ *
41
+ * **The developer id must be the stored string, never a parsed number.** It is persisted
42
+ * zero-padded (`'03'`), and `config.js` preserves that string deliberately. Surrounding code
43
+ * carries `parseInt(developerId, 10)` for numeric comparisons such as `idNum === 0`; deriving
44
+ * the name from that would produce `local-dev3`, which disagrees with Miso's `local-dev03`
45
+ * and surfaces as a missing installation rather than as a naming bug.
46
+ *
47
+ * @param {string} key - Positional installation key
48
+ * @param {string|number} developerId - Stored developer id
49
+ * @returns {string} Qualified installation name
50
+ * @throws {Error} When the key is not a local key, or the id is unusable
51
+ */
52
+ function resolveLocalInstallationName(key, developerId) {
53
+ const value = String(key ?? '').trim().toLowerCase();
54
+ if (!isLocalInstallationKey(value)) {
55
+ throw new Error(`Installation "${key}" is not a local installation key`);
56
+ }
57
+ if (value !== LOCAL_INSTALLATION_KEY) {
58
+ return value;
59
+ }
60
+ const id = String(developerId ?? '').trim();
61
+ if (!id || !/^\d+$/.test(id)) {
62
+ throw new Error(
63
+ 'Cannot derive the local installation name: no developer id is configured. ' +
64
+ 'Set one with "aifabrix setup", or name the installation explicitly, for example ' +
65
+ '"aifabrix env deploy local-dev03".'
66
+ );
67
+ }
68
+ return `${LOCAL_INSTALLATION_PREFIX}dev${id}`;
69
+ }
70
+
71
+ module.exports = {
72
+ LOCAL_INSTALLATION_KEY,
73
+ LOCAL_INSTALLATION_PREFIX,
74
+ isLocalInstallationKey,
75
+ resolveLocalInstallationName
76
+ };