@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,165 @@
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 UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+
25
+ /**
26
+ * Normalizes a license choice: an edition name (case-insensitive) or a Marketplace subscription ID.
27
+ * @param {string} value - Raw value
28
+ * @returns {string} Canonical edition name or lower-case UUID
29
+ * @throws {Error} When the value is neither
30
+ */
31
+ function normalizeLicenseEdition(value) {
32
+ const raw = String(value ?? '').trim();
33
+ if (UUID.test(raw)) return raw.toLowerCase();
34
+ const edition = raw.toUpperCase();
35
+ if (LICENSE_EDITIONS.includes(edition)) return edition;
36
+ throw new Error(`License edition must be one of ${LICENSE_EDITIONS.join(', ')} or an Azure Marketplace subscription ID`);
37
+ }
38
+
39
+ const PARAMETER_CATALOG = Object.freeze({
40
+ infrastructureNameOverride: { kind: 'file', source: 'user', description: 'Optional infrastructure-name override; empty uses generated naming.' },
41
+ location: { kind: 'file', source: 'user', required: true, description: 'Azure region for shared infrastructure.' },
42
+ tags: { kind: 'file', source: 'generated', description: 'Tags applied to shared Azure resources.' },
43
+ enableAppInsights: { kind: 'file', source: 'templateDefault', description: 'Deploy Application Insights resources.' },
44
+ enableAzureOpenAI: { kind: 'file', source: 'templateDefault', description: 'Deploy Azure OpenAI resources.' },
45
+ enablePartnerCenterAttribution: { kind: 'file', source: 'templateDefault', description: 'Enable Marketplace partner attribution.' },
46
+ enablePostgresPublicAccess: { kind: 'allowMyIp', source: 'allowMyIp', description: 'Default off; enabled only while allowMyIp grants the validated caller IPv4 /32 for testing.' },
47
+ allowedIPs: { kind: 'allowMyIp', source: 'allowMyIpOrUser', description: 'Explicit CIDRs for PostgreSQL, Key Vault, and Storage; allowMyIp merges the resolved caller IPv4 /32.' },
48
+ adminPassword: { kind: 'secretEnv', env: 'ADMIN_PASSWORD', required: true, description: 'Installation administrator password.' },
49
+ deploymentEnvironment: { kind: 'file', source: 'templateDefault', description: 'Vendor deployment profile.' },
50
+ keycloakVerifyAudience: { kind: 'file', source: 'templateDefault', description: 'Enable Keycloak audience verification.' },
51
+ encryptionKey: { kind: 'secretEnv', env: 'ENCRYPTION_KEY', required: true, description: 'Application/data encryption key.' },
52
+ acrLoginServer: { kind: 'acrLookup', field: 'loginServer', description: 'Resolved from acrName; not entered separately.' },
53
+ acrAdminUsername: { kind: 'fixed', value: '', source: 'managedIdentityMode', description: 'Unused: Azure web apps pull images with their managed identities.' },
54
+ acrAdminPassword: { kind: 'fixed', value: '', source: 'managedIdentityMode', description: 'Unused: Azure image pull never uses a registry password.' },
55
+ acrResourceId: { kind: 'acrLookup', field: 'id', description: 'Resolved from acrName; both web app identities receive AcrPull on it and the deployment fails if the grant fails.' },
56
+ deployRoleAssignments: { kind: 'file', source: 'templateDefault', description: 'Deploy required Azure role assignments.' },
57
+ moriBaseUrl: { kind: 'file', source: 'registration', required: true, description: 'Mori service base URL.' },
58
+ moriCallbackApiKey: { kind: 'secretEnv', env: 'MORI_CALLBACK_API_KEY', required: true, description: 'Mori callback authentication key.' },
59
+ entraCallbackStateSecret: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
60
+ entraIdClientId: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
61
+ entraIdClientSecret: { kind: 'deferred', description: 'Not collected or sent by infra; issued/configured during later Entra provider onboarding.' },
62
+ entraIdTenantId: { kind: 'deferred', description: 'Customer Entra provider tenant; distinct from the Azure subscription directory tenant and configured later.' },
63
+ 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.' },
64
+ onboardingMode: { kind: 'onboarding', source: 'workspacePolicy', description: 'Miso onboarding mode (ONBOARDING_MODE); Builder installations use saasDirect, Marketplace customer installations use customerOwned.' },
65
+ misoImageTag: { kind: 'file', source: 'templateDefault', description: 'Miso container image tag.' },
66
+ keycloakImageTag: { kind: 'file', source: 'templateDefault', description: 'Keycloak container image tag.' }
67
+ });
68
+
69
+ const OUTPUT_CATALOG = Object.freeze({
70
+ resourceGroupName: 'Deployed Azure resource group.',
71
+ vnetId: 'Shared virtual-network resource ID.',
72
+ keyVaultName: 'Installation Key Vault name.',
73
+ scriptStorageAccountName: 'Deployment-script storage account.',
74
+ flowiseFileShareName: 'Flowise file share.',
75
+ managedIdentityName: 'Shared managed identity name.',
76
+ appServicePlanName: 'App Service plan name.',
77
+ webAppName: 'Miso web application name.',
78
+ webAppUrl: 'Miso web application URL.',
79
+ postgresServerName: 'PostgreSQL server name.',
80
+ postgresServerFqdn: 'PostgreSQL server FQDN.',
81
+ postgresMisoDatabaseName: 'Miso database name.',
82
+ postgresLogsDatabaseName: 'Logs database name.',
83
+ postgresKeycloakDatabaseName: 'Keycloak database name.',
84
+ keycloakWebAppName: 'Keycloak web application name.',
85
+ keycloakWebAppUrl: 'Keycloak web application URL.',
86
+ keycloakRealmUrl: 'Keycloak realm URL.',
87
+ keycloakAdminUser: 'Keycloak administrator user name.',
88
+ portalUrl: 'Azure portal link for the installation resource group.'
89
+ });
90
+
91
+ /** Mori service key name written by DOCKER registration. */
92
+ const MORI_SERVICE_KEY = 'mori-controller-api-keyKeyVault';
93
+
94
+ /**
95
+ * Canonical secret contract. `generate` keys may be created by the Builder secret system;
96
+ * `required` keys must resolve to a non-empty value before use.
97
+ */
98
+ const SECRET_BINDINGS = Object.freeze({
99
+ ADMIN_PASSWORD: { kv: 'miso-controller-admin-passwordKeyVault', required: true, generate: false, comment: 'Required installation administrator password; same canonical secret used by Miso onboarding.' },
100
+ ENCRYPTION_KEY: { kv: 'miso-controller-secrets-encryptionKeyVault', required: true, generate: true, comment: 'Application/data encryption key.' },
101
+ MORI_CALLBACK_API_KEY: { kv: 'mori-callback-api-keyKeyVault', required: true, generate: true, comment: 'Mori callback credential.' },
102
+ MISO_API_KEY: { kv: 'miso-controller-secrets-apiKeyVault', required: false, generate: false, comment: 'Temporary Miso deployment key; an existing login may be used instead.' },
103
+ MORI_API_KEY: { kv: MORI_SERVICE_KEY, required: true, generate: false, comment: 'Mori installation service key stored by DOCKER registration.' }
104
+ });
105
+
106
+ const ENV_TEMPLATE_KEYS = Object.freeze({
107
+ AZURE: ['ADMIN_PASSWORD', 'ENCRYPTION_KEY', 'MORI_CALLBACK_API_KEY', 'MISO_API_KEY'],
108
+ DOCKER: ['ADMIN_PASSWORD', 'ENCRYPTION_KEY', 'MORI_CALLBACK_API_KEY', 'MORI_API_KEY']
109
+ });
110
+
111
+ /**
112
+ * Committed env.template content for a vendor.
113
+ * @param {string} vendor - AZURE or DOCKER
114
+ * @returns {string}
115
+ */
116
+ function buildEnvTemplate(vendor) {
117
+ const lines = ['# Installation workspace secret contract. Values resolve from the Builder secret system into the ignored .env.'];
118
+ for (const name of ENV_TEMPLATE_KEYS[vendor]) {
119
+ lines.push(`# ${SECRET_BINDINGS[name].comment}`, `${name}=kv://${SECRET_BINDINGS[name].kv}`);
120
+ }
121
+ if (vendor === 'AZURE') {
122
+ lines.push('# Controller URL written by successful infra deployment.', 'CONTROLLER_URL=');
123
+ }
124
+ return `${lines.join('\n')}\n`;
125
+ }
126
+
127
+ function sameKeys(actual, expected) {
128
+ const missing = expected.filter(key => !actual.includes(key));
129
+ const unclassified = actual.filter(key => !expected.includes(key));
130
+ return { missing, unclassified };
131
+ }
132
+
133
+ /**
134
+ * Fails closed when the vendor template has parameters or outputs the catalog does not classify.
135
+ * @param {Object} template - Parsed ARM template
136
+ * @throws {Error} On drift
137
+ */
138
+ function assertTemplateCoverage(template) {
139
+ const params = sameKeys(Object.keys(template.parameters || {}), Object.keys(PARAMETER_CATALOG));
140
+ const outputs = sameKeys(Object.keys(template.outputs || {}), Object.keys(OUTPUT_CATALOG));
141
+ const problems = [
142
+ ...params.unclassified.map(key => `unclassified parameter ${key}`),
143
+ ...params.missing.map(key => `missing parameter ${key}`),
144
+ ...outputs.unclassified.map(key => `unclassified output ${key}`),
145
+ ...outputs.missing.map(key => `missing output ${key}`)
146
+ ];
147
+ if (problems.length > 0) {
148
+ throw new Error(`Marketplace template does not match the supported installation contract: ${problems.join(', ')}. Update Builder before deploying this template.`);
149
+ }
150
+ }
151
+
152
+ module.exports = {
153
+ PARAMETER_CATALOG,
154
+ OUTPUT_CATALOG,
155
+ SECRET_BINDINGS,
156
+ ENV_TEMPLATE_KEYS,
157
+ MORI_SERVICE_KEY,
158
+ LICENSE_EDITIONS,
159
+ DEFAULT_LICENSE_EDITION,
160
+ ONBOARDING_MODES,
161
+ DEFAULT_ONBOARDING_MODE,
162
+ normalizeLicenseEdition,
163
+ buildEnvTemplate,
164
+ assertTemplateCoverage
165
+ };
@@ -0,0 +1,161 @@
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
12
+ } = require('./infra-catalog');
13
+
14
+ const INFRA_SCHEMA_VERSION = '2.0.0';
15
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
+
17
+ function entry(value, source, description, extra = {}) {
18
+ return { value, source, description, ...extra };
19
+ }
20
+
21
+ function templateDefault(template, name) {
22
+ const value = template.parameters[name].defaultValue;
23
+ if (typeof value === 'string' && value.startsWith('[')) return '';
24
+ return value === undefined ? '' : value;
25
+ }
26
+
27
+ function parameterEntry(name, template) {
28
+ const spec = PARAMETER_CATALOG[name];
29
+ switch (spec.kind) {
30
+ case 'secretEnv':
31
+ return { valueFromEnv: spec.env, source: 'secretEnv', required: spec.required, description: spec.description };
32
+ case 'acrLookup':
33
+ return entry(null, 'azureAcrLookup', spec.description);
34
+ case 'fixed':
35
+ return entry(spec.value, spec.source, spec.description);
36
+ case 'deferred':
37
+ return entry(null, 'deferred', spec.description, { requiredAt: 'tenantActivation' });
38
+ case 'allowMyIp':
39
+ return entry(name === 'allowedIPs' ? [] : false, spec.source, spec.description);
40
+ case 'license':
41
+ return entry(DEFAULT_LICENSE_EDITION, spec.source, spec.description);
42
+ case 'onboarding':
43
+ return entry(DEFAULT_ONBOARDING_MODE, spec.source, spec.description);
44
+ default:
45
+ return entry(templateDefault(template, name), spec.source, spec.description, spec.required ? { required: true } : {});
46
+ }
47
+ }
48
+
49
+ function buildParameters(template, existing = {}) {
50
+ const parameters = {};
51
+ for (const name of Object.keys(PARAMETER_CATALOG)) {
52
+ const fresh = parameterEntry(name, template);
53
+ const workflowOwned = ['fixed', 'deferred', 'secretEnv'].includes(PARAMETER_CATALOG[name].kind);
54
+ parameters[name] = existing[name] && !workflowOwned ? { ...fresh, value: existing[name].value } : fresh;
55
+ }
56
+ return parameters;
57
+ }
58
+
59
+ function buildOutputs(existing = {}) {
60
+ return Object.fromEntries(Object.entries(OUTPUT_CATALOG).map(([name, description]) =>
61
+ [name, { value: existing[name]?.value ?? null, description }]));
62
+ }
63
+
64
+ function azureSections(template, existing) {
65
+ return {
66
+ 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 }),
67
+ 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 }),
68
+ preset: existing.preset || entry('s', 'cliOrFile', 'Shared infrastructure sizing preset.'),
69
+ parameters: buildParameters(template, existing.parameters),
70
+ outputs: buildOutputs(existing.outputs),
71
+ controllerUrl: existing.controllerUrl || entry(null, 'infraOutput', 'Activated Miso Controller URL derived from the Miso web application output.'),
72
+ tenantActivation: existing.tenantActivation || {
73
+ status: entry('pending', 'controller', 'Server-reported tenant activation state; Builder never marks activation complete.'),
74
+ url: entry(null, 'infraOutput', 'URL the user opens to sign in and complete tenant activation.')
75
+ },
76
+ deployment: existing.deployment || {
77
+ status: entry('not-deployed', 'azureDeployment', 'Latest shared infrastructure deployment status.'),
78
+ name: null, id: null, inputHash: null, completedAt: null
79
+ }
80
+ };
81
+ }
82
+
83
+ function dockerSections(template, existing) {
84
+ const moriDefault = templateDefault(template, 'moriBaseUrl');
85
+ return {
86
+ moriBaseUrl: existing.moriBaseUrl || entry(moriDefault, 'templateDefault', 'Mori service base URL used for registration.', { required: true }),
87
+ marketplaceSubscriptionId: existing.marketplaceSubscriptionId || entry('', 'workspace', 'Marketplace subscription ID; empty selects Mori Community.'),
88
+ registration: existing.registration || {
89
+ status: entry('not-registered', 'mori', 'Mori registration status; the one-time key is stored only in local user secrets.'),
90
+ evidence: null
91
+ }
92
+ };
93
+ }
94
+
95
+ function identitySections(vendor, resourceGroup, existing) {
96
+ const docker = vendor === 'DOCKER';
97
+ const idEntry = (description) => docker
98
+ ? entry(crypto.randomUUID(), 'generated', description)
99
+ : entry('', 'user', description, { required: true });
100
+ return {
101
+ schemaVersion: INFRA_SCHEMA_VERSION,
102
+ vendor: existing.vendor || entry(vendor, 'cli', 'Installation vendor; immutable once recorded.'),
103
+ resourceGroup: existing.resourceGroup || entry(resourceGroup, 'command', 'Workspace and Azure resource-group key.'),
104
+ installId: existing.installId || entry(resourceGroup, 'command', 'Installation identifier and deployment journal key.'),
105
+ subscriptionId: existing.subscriptionId || idEntry(docker
106
+ ? 'Stable Docker installation UUID sent to Mori.'
107
+ : 'Approved installation subscription recorded by this workspace; active Azure account must match.'),
108
+ tenantId: existing.tenantId || idEntry(docker
109
+ ? 'Stable Docker owner UUID sent to Mori.'
110
+ : 'Approved installation directory tenant recorded by this workspace; not an Entra provider setting.')
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Creates or normalizes the infra manifest for a vendor, preserving user-owned values.
116
+ * @param {Object} params - Inputs
117
+ * @param {string} params.vendor - AZURE or DOCKER
118
+ * @param {string} params.resourceGroup - Resource-group key
119
+ * @param {Object} params.template - Parsed shipped Marketplace template
120
+ * @param {Object|null} params.existing - Existing manifest
121
+ * @returns {Object} Manifest document
122
+ */
123
+ function buildInfraDocument({ vendor, resourceGroup, template, existing }) {
124
+ const current = existing || {};
125
+ if (existing && existing.schemaVersion !== INFRA_SCHEMA_VERSION) {
126
+ throw new Error(`environment-infra.json schemaVersion ${existing.schemaVersion} is not supported; expected ${INFRA_SCHEMA_VERSION}`);
127
+ }
128
+ const sections = vendor === 'DOCKER' ? dockerSections(template, current) : azureSections(template, current);
129
+ return {
130
+ ...identitySections(vendor, resourceGroup, current),
131
+ ...sections,
132
+ variables: current.variables || {},
133
+ generated: current.generated || {}
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Lists user-owned required fields that are still empty or invalid.
139
+ * @param {Object} doc - Manifest
140
+ * @returns {string[]} JSON paths inside environment-infra.json
141
+ */
142
+ function missingRequiredFields(doc) {
143
+ const missing = [];
144
+ for (const key of ['subscriptionId', 'tenantId']) {
145
+ if (!UUID.test(String(doc[key].value || ''))) missing.push(`${key}.value (UUID)`);
146
+ }
147
+ if (doc.vendor.value === 'DOCKER') {
148
+ if (!/^https:\/\//.test(String(doc.moriBaseUrl.value || ''))) missing.push('moriBaseUrl.value (https URL)');
149
+ return missing;
150
+ }
151
+ if (!/^[a-zA-Z0-9]{5,50}$/.test(String(doc.acrName.value || ''))) missing.push('acrName.value');
152
+ if (!/^[a-z0-9]+$/.test(String(doc.parameters.location.value || ''))) missing.push('parameters.location.value');
153
+ if (!/^https:\/\//.test(String(doc.parameters.moriBaseUrl.value || ''))) missing.push('parameters.moriBaseUrl.value (https URL)');
154
+ return missing;
155
+ }
156
+
157
+ module.exports = {
158
+ INFRA_SCHEMA_VERSION,
159
+ buildInfraDocument,
160
+ missingRequiredFields
161
+ };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Projects `environment-infra.json` plus resolved workspace secrets into standard
5
+ * ARM deploymentParameters, and applies the default-off current-IP access option.
6
+ * Deferred tenant-activation parameters are never projected.
7
+ */
8
+
9
+ const { PARAMETER_CATALOG } = require('./infra-catalog');
10
+ const { sha256 } = require('./workspace-files');
11
+
12
+ function projectParameter(name, doc, context) {
13
+ const spec = PARAMETER_CATALOG[name];
14
+ switch (spec.kind) {
15
+ case 'deferred':
16
+ return undefined;
17
+ case 'secretEnv': {
18
+ const value = context.env[spec.env];
19
+ if (spec.required && (typeof value !== 'string' || value === '')) {
20
+ throw new Error(`Workspace .env is missing required ${spec.env}; rerun after the secret resolves`);
21
+ }
22
+ return value;
23
+ }
24
+ case 'acrLookup':
25
+ if (!context.acr || !context.acr[spec.field]) {
26
+ throw new Error(`ACR ${spec.field} was not resolved from acrName`);
27
+ }
28
+ return context.acr[spec.field];
29
+ case 'fixed':
30
+ return spec.value;
31
+ default:
32
+ return doc.parameters[name].value;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Builds the owner-only ARM parameters document and the non-secret input hash.
38
+ * @param {Object} doc - Infra manifest
39
+ * @param {Object} context - Resolution context
40
+ * @param {Object.<string,string>} context.env - Resolved workspace .env values
41
+ * @param {{ id: string, loginServer: string }} context.acr - Resolved registry
42
+ * @param {string} context.templateHash - SHA-256 of the deployed template
43
+ * @returns {{ document: Object, inputHash: string }}
44
+ */
45
+ function buildArmParameters(doc, context) {
46
+ const parameters = {};
47
+ const nonSecret = {};
48
+ for (const name of Object.keys(PARAMETER_CATALOG)) {
49
+ const value = projectParameter(name, doc, context);
50
+ if (value === undefined) continue;
51
+ parameters[name] = { value };
52
+ // Secrets never enter the hash.
53
+ if (PARAMETER_CATALOG[name].kind !== 'secretEnv') nonSecret[name] = value;
54
+ }
55
+ return {
56
+ document: {
57
+ $schema: 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#',
58
+ contentVersion: '1.0.0.0',
59
+ parameters
60
+ },
61
+ inputHash: sha256(JSON.stringify({ templateHash: context.templateHash, parameters: nonSecret }))
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Applies or revokes the generated caller `/32` rule. User-entered rules are preserved.
67
+ * @param {Object} doc - Infra manifest (mutated)
68
+ * @param {{ enabled: boolean, cidr?: string, resolvedAt?: string, resolver?: string }} access
69
+ */
70
+ function applyAllowMyIp(doc, access) {
71
+ const previous = doc.allowMyIp.resolvedCidr;
72
+ const current = Array.isArray(doc.parameters.allowedIPs.value) ? doc.parameters.allowedIPs.value : [];
73
+ const userRules = current.filter(rule => rule !== previous);
74
+ doc.allowMyIp.value = access.enabled;
75
+ if (access.enabled) {
76
+ doc.parameters.allowedIPs.value = [...new Set([...userRules, access.cidr])];
77
+ doc.parameters.enablePostgresPublicAccess.value = true;
78
+ Object.assign(doc.allowMyIp, { resolvedCidr: access.cidr, resolvedAt: access.resolvedAt, resolver: access.resolver });
79
+ return;
80
+ }
81
+ doc.parameters.allowedIPs.value = userRules;
82
+ doc.parameters.enablePostgresPublicAccess.value = false;
83
+ Object.assign(doc.allowMyIp, { resolvedCidr: null, resolvedAt: null, resolver: null });
84
+ }
85
+
86
+ module.exports = { buildArmParameters, applyAllowMyIp };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Resolves the caller's single public IPv4 for default-off, invocation-specific
5
+ * infrastructure test access. Sends no credentials or cookies, follows no redirects and never
6
+ * retries against another host.
7
+ */
8
+
9
+ const net = require('net');
10
+ const { makeApiCall } = require('../../utils/api');
11
+
12
+ const PUBLIC_IP_RESOLVER = 'https://api.ipify.org?format=json';
13
+ const TIMEOUT_MS = 5000;
14
+
15
+ const NON_PUBLIC_IPV4 = [
16
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
17
+ ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16],
18
+ ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]
19
+ ];
20
+
21
+ function ipv4ToInt(ip) {
22
+ return ip.split('.').reduce((acc, octet) => ((acc << 8) + Number(octet)) >>> 0, 0);
23
+ }
24
+
25
+ /**
26
+ * True for one strictly formatted, globally routable IPv4 address.
27
+ * @param {string} ip - Candidate address
28
+ * @returns {boolean}
29
+ */
30
+ function isPublicIpv4(ip) {
31
+ if (typeof ip !== 'string' || net.isIPv4(ip) !== true || !/^(\d{1,3})(\.\d{1,3}){3}$/.test(ip)) return false;
32
+ if (ip.split('.').some(octet => octet.length > 1 && octet.startsWith('0'))) return false;
33
+ const value = ipv4ToInt(ip);
34
+ return !NON_PUBLIC_IPV4.some(([base, bits]) => {
35
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
36
+ return ((value & mask) >>> 0) === ((ipv4ToInt(base) & mask) >>> 0);
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Resolves the caller's public IPv4 as a `/32` rule.
42
+ * @param {Function} [call] - HTTP call (defaults to the shared API utility)
43
+ * @param {Function} [now] - Clock
44
+ * @returns {Promise<{ cidr: string, resolvedAt: string, resolver: string }>}
45
+ */
46
+ async function resolvePublicIpv4(call = makeApiCall, now = () => new Date()) {
47
+ const response = await call(PUBLIC_IP_RESOLVER, {
48
+ method: 'GET',
49
+ headers: { Accept: 'application/json' },
50
+ redirect: 'error',
51
+ credentials: 'omit',
52
+ timeoutMs: TIMEOUT_MS
53
+ });
54
+ if (!response || !response.success) {
55
+ throw new Error('Could not resolve the caller public IPv4 for --allow-my-ip (resolver unavailable, redirected or timed out)');
56
+ }
57
+ const data = response.data;
58
+ const ip = data && typeof data === 'object' && !Array.isArray(data) ? data.ip : undefined;
59
+ if (!isPublicIpv4(ip)) {
60
+ throw new Error('Public IP resolver did not return exactly one public IPv4 address; --allow-my-ip was not applied');
61
+ }
62
+ return { cidr: `${ip}/32`, resolvedAt: now().toISOString(), resolver: PUBLIC_IP_RESOLVER };
63
+ }
64
+
65
+ module.exports = { PUBLIC_IP_RESOLVER, isPublicIpv4, resolvePublicIpv4 };
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Workspace-mode `env deploy <resource-group> --stage <stage>` input validation:
5
+ * resource-group key, stage, vendor matrix and stage-scoped option checks. Runs before any
6
+ * filesystem, network or cloud mutation.
7
+ */
8
+
9
+ const path = require('path');
10
+ const { normalizeLicenseEdition } = require('./infra-catalog');
11
+
12
+ const WORKSPACE_STAGES = Object.freeze(['register', 'infra', 'dev', 'tst', 'pro']);
13
+ const ENVIRONMENT_STAGES = Object.freeze(['dev', 'tst', 'pro']);
14
+ const VENDORS = Object.freeze(['AZURE', 'DOCKER']);
15
+ const DEFAULT_VENDOR = 'AZURE';
16
+ const RESOURCE_GROUP_KEY = /^[a-z][a-z0-9-]{2,39}$/;
17
+ const ENV_VAR_NAME = /^[A-Z_][A-Z0-9_]*$/;
18
+ const LEGACY_MARKETPLACE_FLAGS = Object.freeze([
19
+ 'marketplaceJson', 'parameters', 'subscription', 'resourceGroup', 'installId'
20
+ ]);
21
+ const DOCKER_SETUP_GUIDANCE = 'DOCKER installations deploy through "aifabrix setup"; only --stage register is supported for DOCKER.';
22
+
23
+ /**
24
+ * True when options select the deprecated explicit Marketplace/journal mode.
25
+ * @param {Object} options - Commander options
26
+ * @returns {boolean}
27
+ */
28
+ function isLegacyMarketplaceMode(options) {
29
+ if (LEGACY_MARKETPLACE_FLAGS.some(flag => options[flag] !== undefined && options[flag] !== null)) {
30
+ return true;
31
+ }
32
+ return !options.stage && Boolean(options.controllerUrl);
33
+ }
34
+
35
+ /**
36
+ * True when options select the resource-group installation workspace mode.
37
+ * @param {Object} options - Commander options
38
+ * @returns {boolean}
39
+ */
40
+ function isWorkspaceMode(options) {
41
+ return Boolean(options.stage) && !isLegacyMarketplaceMode(options);
42
+ }
43
+
44
+ /**
45
+ * Validates the positional resource-group key before it is used as a path segment.
46
+ * @param {string} value - Positional value
47
+ * @returns {string} The validated key
48
+ * @throws {Error} On absolute paths, separators, traversal, normalization changes or bad format
49
+ */
50
+ function validateResourceGroupKey(value) {
51
+ const raw = typeof value === 'string' ? value : '';
52
+ if (!raw || path.isAbsolute(raw) || /[\\/]/.test(raw) || raw.includes('..') ||
53
+ path.normalize(raw) !== raw || raw.normalize('NFC') !== raw || raw.trim() !== raw) {
54
+ throw new Error('Resource group must be a plain workspace key without paths, separators or traversal');
55
+ }
56
+ if (!RESOURCE_GROUP_KEY.test(raw)) {
57
+ throw new Error('Resource group must be a lowercase Azure-safe name (3-40 characters: a-z, 0-9, -; starts with a letter)');
58
+ }
59
+ return raw;
60
+ }
61
+
62
+ /**
63
+ * Normalizes a vendor value case-insensitively.
64
+ * @param {string|undefined} value - Raw vendor value
65
+ * @returns {string|undefined} Canonical vendor or undefined when absent
66
+ */
67
+ function normalizeVendor(value) {
68
+ if (value === undefined || value === null || value === '') return undefined;
69
+ const vendor = String(value).trim().toUpperCase();
70
+ if (!VENDORS.includes(vendor)) {
71
+ throw new Error(`Unsupported vendor "${value}". Use one of: ${VENDORS.join(', ')}`);
72
+ }
73
+ return vendor;
74
+ }
75
+
76
+ function validateStage(stage) {
77
+ const value = String(stage || '').trim().toLowerCase();
78
+ if (!WORKSPACE_STAGES.includes(value)) {
79
+ throw new Error(`Unsupported stage "${stage}". Use one of: ${WORKSPACE_STAGES.join(', ')}`);
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function resolveVendor(stage, requested, persisted) {
85
+ if (requested && persisted && requested !== persisted) {
86
+ throw new Error(`Workspace vendor is ${persisted}; --vendor ${requested} conflicts with the existing installation`);
87
+ }
88
+ if (stage === 'register') {
89
+ if (requested !== 'DOCKER') {
90
+ throw new Error('--stage register requires explicit --vendor DOCKER; AZURE installations register with Mori during tenant activation in Miso');
91
+ }
92
+ return requested;
93
+ }
94
+ const vendor = requested || persisted || DEFAULT_VENDOR;
95
+ if (vendor === 'DOCKER') {
96
+ throw new Error(`--stage ${stage} is not supported for DOCKER. ${DOCKER_SETUP_GUIDANCE}`);
97
+ }
98
+ return vendor;
99
+ }
100
+
101
+ function assertInfraOnlyOptions(stage, options) {
102
+ if (options.allowMyIp && stage !== 'infra') {
103
+ throw new Error('--allow-my-ip is supported only with --stage infra for AZURE installations');
104
+ }
105
+ if (options.licenseEdition === undefined) return;
106
+ if (stage !== 'infra') {
107
+ throw new Error('--license-edition is supported only with --stage infra for AZURE installations');
108
+ }
109
+ normalizeLicenseEdition(options.licenseEdition);
110
+ }
111
+
112
+ function assertStageScopedOptions(stage, options) {
113
+ assertInfraOnlyOptions(stage, options);
114
+ const envOnly = [['controllerUrl', '--controller-url'], ['misoApiKeyEnv', '--miso-api-key-env']];
115
+ for (const [key, flag] of envOnly) {
116
+ if (options[key] !== undefined && !ENVIRONMENT_STAGES.includes(stage)) {
117
+ throw new Error(`${flag} is supported only with --stage dev, tst or pro`);
118
+ }
119
+ }
120
+ if (options.config !== undefined) {
121
+ throw new Error('--config is not supported with --stage; environment settings live in the workspace environment files');
122
+ }
123
+ if (options.preset !== undefined && stage === 'register') {
124
+ throw new Error('--preset is not supported with --stage register');
125
+ }
126
+ if (options.misoApiKeyEnv !== undefined && !ENV_VAR_NAME.test(String(options.misoApiKeyEnv))) {
127
+ throw new Error('--miso-api-key-env must be an environment-variable name (for example MISO_API_KEY), never a key value');
128
+ }
129
+ if (options.controllerUrl !== undefined && !/^https:\/\/[^/\s]+(\/[^\s]*)?$/.test(String(options.controllerUrl))) {
130
+ throw new Error('--controller-url must be an https:// URL');
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Resolves validated workspace-mode input.
136
+ * @param {string} resourceGroupKey - Positional resource-group key
137
+ * @param {Object} options - Commander options
138
+ * @param {string|undefined} persistedVendor - Vendor recorded in an existing workspace
139
+ * @returns {{ resourceGroup: string, stage: string, vendor: string, allowMyIp: boolean, licenseEdition: string|undefined,
140
+ * preset: string|undefined, controllerUrl: string|undefined, misoApiKeyEnv: string|undefined }}
141
+ */
142
+ function resolveStageInput(resourceGroupKey, options, persistedVendor) {
143
+ const resourceGroup = validateResourceGroupKey(resourceGroupKey);
144
+ const stage = validateStage(options.stage);
145
+ assertStageScopedOptions(stage, options);
146
+ const vendor = resolveVendor(stage, normalizeVendor(options.vendor), normalizeVendor(persistedVendor));
147
+ return {
148
+ resourceGroup,
149
+ stage,
150
+ vendor,
151
+ allowMyIp: options.allowMyIp === true,
152
+ licenseEdition: options.licenseEdition,
153
+ preset: options.preset,
154
+ controllerUrl: options.controllerUrl,
155
+ misoApiKeyEnv: options.misoApiKeyEnv
156
+ };
157
+ }
158
+
159
+ module.exports = {
160
+ WORKSPACE_STAGES,
161
+ ENVIRONMENT_STAGES,
162
+ VENDORS,
163
+ DEFAULT_VENDOR,
164
+ isLegacyMarketplaceMode,
165
+ isWorkspaceMode,
166
+ validateResourceGroupKey,
167
+ normalizeVendor,
168
+ resolveStageInput
169
+ };