@aifabrix/builder 2.56.0 → 2.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/agent-kit/session-report.js +76 -0
- package/lib/agent-kit/start.js +124 -57
- package/lib/agent-kit/tmux-sessions.js +163 -0
- package/lib/api/environments.api.js +38 -0
- package/lib/api/marketplace-bootstrap.api.js +31 -0
- package/lib/app/push.js +98 -41
- package/lib/build/standard-docker-build.js +13 -11
- package/lib/cli/setup-app.js +16 -5
- package/lib/cli/setup-dev-path-commands.js +49 -2
- package/lib/cli/setup-environment.js +89 -19
- package/lib/commands/agent-kit-help.js +32 -15
- package/lib/commands/agent-kit.js +90 -8
- package/lib/commands/dev-registry.js +155 -0
- package/lib/commands/dev-show-display.js +10 -2
- package/lib/commands/setup-image-refresh.js +26 -0
- package/lib/core/config-attach-extensions.js +4 -1
- package/lib/deployment/installation/azure-infra-stage.js +191 -0
- package/lib/deployment/installation/azure-preflight.js +147 -0
- package/lib/deployment/installation/azure-readiness.js +101 -0
- package/lib/deployment/installation/docker-register.js +112 -0
- package/lib/deployment/installation/environment-auth.js +76 -0
- package/lib/deployment/installation/environment-manifest.js +165 -0
- package/lib/deployment/installation/environment-stage.js +191 -0
- package/lib/deployment/installation/index.js +39 -0
- package/lib/deployment/installation/infra-catalog.js +211 -0
- package/lib/deployment/installation/infra-manifest.js +176 -0
- package/lib/deployment/installation/infra-projection.js +97 -0
- package/lib/deployment/installation/public-ip.js +65 -0
- package/lib/deployment/installation/registry-auth-mode.js +98 -0
- package/lib/deployment/installation/stage-input.js +184 -0
- package/lib/deployment/installation/stage-output.js +53 -0
- package/lib/deployment/installation/workspace-env.js +76 -0
- package/lib/deployment/installation/workspace-files.js +178 -0
- package/lib/deployment/installation/workspace-schema.js +40 -0
- package/lib/deployment/installation/workspace-secrets.js +66 -0
- package/lib/deployment/installation/workspace.js +128 -0
- package/lib/deployment/marketplace-environment.js +305 -0
- package/lib/generator/builders.js +12 -9
- package/lib/generator/helpers.js +55 -56
- package/lib/generator/split-variables.js +11 -5
- package/lib/internal/fs-real-sync.js +29 -0
- package/lib/role-assistant/package-validate.js +4 -0
- package/lib/role-assistant/test-runner-clarification-resubmit.js +149 -0
- package/lib/role-assistant/test-runner.js +14 -17
- package/lib/schema/application-schema.json +20 -2
- package/lib/schema/infra-parameter.schema.json +7 -0
- package/lib/schema/infra.parameter.yaml +26 -36
- package/lib/schema/infrastructure-schema.json +1531 -397
- package/lib/schema/installation-environment.schema.json +601 -0
- package/lib/utils/app-register-config.js +2 -4
- package/lib/utils/build-resolve-image.js +2 -21
- package/lib/utils/cli-utils.js +8 -1
- package/lib/utils/config-registry-preference.js +106 -0
- package/lib/utils/docker-build.js +53 -19
- package/lib/utils/env-map.js +23 -6
- package/lib/utils/image-tags.js +88 -0
- package/lib/utils/promote-dev-docker-image.js +6 -0
- package/lib/utils/registry-credentials.js +236 -0
- package/lib/utils/resolve-docker-image-ref.js +78 -9
- package/lib/utils/secrets-helpers.js +9 -1
- package/lib/utils/variable-transformer.js +5 -4
- package/lib/validation/required-variables.js +41 -0
- package/lib/validation/validator.js +8 -5
- package/package.json +6 -1
- package/templates/README.md +4 -0
- package/templates/agent-kit/instructions/AGENTKIT.md +6 -6
- package/templates/agent-kit/skills/shared/hosts.md +10 -10
- package/templates/applications/dataplane/application.yaml +1 -1
- package/templates/applications/dataplane/env.template +1 -1
- package/templates/applications/miso-controller/application.yaml +3 -2
- package/templates/applications/miso-controller/env.template +15 -6
- package/templates/marketplace/createUiDefinition.json +77 -0
- package/templates/marketplace/install.parameters.example.json +9 -0
- package/templates/marketplace/main.json +4087 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview AZURE infra preflight before any Azure mutation: active account equals the
|
|
5
|
+
* workspace subscription/tenant, `acrName` resolves inside that subscription, and the caller can
|
|
6
|
+
* create deployments. Managed-identity mode also requires role-assignment and AcrPull authority;
|
|
7
|
+
* password mode fetches ACR admin credentials instead. Uses only the operator's Azure CLI session.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ROLE_ASSIGNMENT_WRITE = 'Microsoft.Authorization/roleAssignments/write';
|
|
11
|
+
const DEPLOYMENT_WRITE = 'Microsoft.Resources/deployments/write';
|
|
12
|
+
const PERMISSIONS_API = '2022-04-01';
|
|
13
|
+
|
|
14
|
+
function parseJson(text, label) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(text);
|
|
17
|
+
} catch (_) {
|
|
18
|
+
throw new Error(`Azure CLI returned an unreadable ${label} response`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {Object} doc - Infra manifest
|
|
24
|
+
* @returns {boolean} True when image pull uses registry username/password (effective mode)
|
|
25
|
+
*/
|
|
26
|
+
function isAdminAcrAuth(doc) {
|
|
27
|
+
return doc.acrAuth?.effective === 'credentials';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {Object} doc - Infra manifest
|
|
32
|
+
* @param {Function} az - Azure CLI runner
|
|
33
|
+
* @throws {Error} When the active account differs from the workspace target
|
|
34
|
+
*/
|
|
35
|
+
function assertActiveAccount(doc, az) {
|
|
36
|
+
const account = parseJson(az(['account', 'show', '--output', 'json']), 'account');
|
|
37
|
+
const expectedSub = String(doc.subscriptionId.value).toLowerCase();
|
|
38
|
+
const expectedTenant = String(doc.tenantId.value).toLowerCase();
|
|
39
|
+
if (String(account.id).toLowerCase() !== expectedSub || String(account.tenantId).toLowerCase() !== expectedTenant) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Active Azure account (subscription ${account.id}, tenant ${account.tenantId}) does not match the workspace ` +
|
|
42
|
+
`(subscription ${doc.subscriptionId.value}, tenant ${doc.tenantId.value}). ` +
|
|
43
|
+
`Run "az login --tenant ${doc.tenantId.value}" and "az account set --subscription ${doc.subscriptionId.value}".`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolves `acrName` to its resource ID and login server within the workspace subscription.
|
|
50
|
+
* @param {Object} doc - Infra manifest
|
|
51
|
+
* @param {Function} az - Azure CLI runner
|
|
52
|
+
* @returns {{ id: string, loginServer: string, adminUserEnabled: boolean }}
|
|
53
|
+
*/
|
|
54
|
+
function resolveAcr(doc, az) {
|
|
55
|
+
const subscription = String(doc.subscriptionId.value);
|
|
56
|
+
const acr = parseJson(az([
|
|
57
|
+
'acr', 'show', '--name', doc.acrName.value, '--subscription', subscription, '--output', 'json'
|
|
58
|
+
]), 'registry');
|
|
59
|
+
if (typeof acr.id !== 'string' || typeof acr.loginServer !== 'string' || !acr.loginServer) {
|
|
60
|
+
throw new Error(`Registry ${doc.acrName.value} did not return a resource ID and login server`);
|
|
61
|
+
}
|
|
62
|
+
if (!acr.id.toLowerCase().startsWith(`/subscriptions/${subscription.toLowerCase()}/`)) {
|
|
63
|
+
throw new Error(`Registry ${doc.acrName.value} is outside the workspace subscription; use a registry in ${subscription}`);
|
|
64
|
+
}
|
|
65
|
+
return { id: acr.id, loginServer: acr.loginServer, adminUserEnabled: acr.adminUserEnabled === true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Loads ACR admin username/password for password registry auth. Never persists the password.
|
|
70
|
+
* @param {Object} doc - Infra manifest
|
|
71
|
+
* @param {{ adminUserEnabled?: boolean }} acr - Resolved registry metadata
|
|
72
|
+
* @param {Function} az - Azure CLI runner
|
|
73
|
+
* @returns {{ username: string, password: string }}
|
|
74
|
+
*/
|
|
75
|
+
function resolveAcrAdminCredentials(doc, acr, az) {
|
|
76
|
+
if (acr.adminUserEnabled !== true) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Registry ${doc.acrName.value} does not have the admin user enabled; enable admin user on the ACR ` +
|
|
79
|
+
'or save a local pair with "aifabrix dev set-registry"'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const creds = parseJson(az([
|
|
83
|
+
'acr', 'credential', 'show', '--name', doc.acrName.value,
|
|
84
|
+
'--subscription', String(doc.subscriptionId.value), '--output', 'json'
|
|
85
|
+
]), 'registry credentials');
|
|
86
|
+
const username = typeof creds.username === 'string' ? creds.username : '';
|
|
87
|
+
const password = Array.isArray(creds.passwords)
|
|
88
|
+
? (creds.passwords.find(item => item && item.value)?.value || '')
|
|
89
|
+
: '';
|
|
90
|
+
if (!username || !password) {
|
|
91
|
+
throw new Error(`Registry ${doc.acrName.value} did not return admin credentials; check ACR admin user access`);
|
|
92
|
+
}
|
|
93
|
+
return { username, password };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function actionMatches(pattern, action) {
|
|
97
|
+
const expression = new RegExp(`^${String(pattern).split('*').map(part => part.replace(/[.+?^${}()|[\]\\/]/g, '\\$&')).join('.*')}$`, 'i');
|
|
98
|
+
return expression.test(action);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* True when Azure RBAC grants `action` at `scope` to the signed-in caller.
|
|
103
|
+
* @param {Function} az - Azure CLI runner
|
|
104
|
+
* @param {string} scope - Resource scope ID
|
|
105
|
+
* @param {string} action - Control-plane action
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
function hasPermission(az, scope, action) {
|
|
109
|
+
const result = parseJson(az([
|
|
110
|
+
'rest', '--method', 'get',
|
|
111
|
+
'--url', `https://management.azure.com${scope}/providers/Microsoft.Authorization/permissions?api-version=${PERMISSIONS_API}`,
|
|
112
|
+
'--output', 'json'
|
|
113
|
+
]), 'permissions');
|
|
114
|
+
return (result.value || []).some(permission =>
|
|
115
|
+
(permission.actions || []).some(pattern => actionMatches(pattern, action)) &&
|
|
116
|
+
!(permission.notActions || []).some(pattern => actionMatches(pattern, action)));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {Object} doc - Infra manifest
|
|
121
|
+
* @param {{ id: string }} acr - Resolved registry
|
|
122
|
+
* @param {Function} az - Azure CLI runner
|
|
123
|
+
* @throws {Error} When deployment or (mi) role-assignment authority is missing
|
|
124
|
+
*/
|
|
125
|
+
function assertDeploymentAuthority(doc, acr, az) {
|
|
126
|
+
const subscriptionScope = `/subscriptions/${doc.subscriptionId.value}`;
|
|
127
|
+
if (!hasPermission(az, subscriptionScope, DEPLOYMENT_WRITE)) {
|
|
128
|
+
throw new Error(`The signed-in Azure account cannot create deployments in subscription ${doc.subscriptionId.value}`);
|
|
129
|
+
}
|
|
130
|
+
if (isAdminAcrAuth(doc)) return;
|
|
131
|
+
if (doc.parameters.deployRoleAssignments.value !== false && !hasPermission(az, subscriptionScope, ROLE_ASSIGNMENT_WRITE)) {
|
|
132
|
+
throw new Error(`The signed-in Azure account cannot create role assignments in subscription ${doc.subscriptionId.value}; ask for Owner or User Access Administrator, or set parameters.deployRoleAssignments.value to false when roles already exist, or use --acr-auth credentials with a pair saved by "aifabrix dev set-registry"`);
|
|
133
|
+
}
|
|
134
|
+
if (!hasPermission(az, acr.id, ROLE_ASSIGNMENT_WRITE)) {
|
|
135
|
+
throw new Error(`The signed-in Azure account cannot assign AcrPull on registry ${doc.acrName.value}; ask for Owner or User Access Administrator on that registry, or use --acr-auth credentials with a pair saved by "aifabrix dev set-registry"`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
assertActiveAccount,
|
|
141
|
+
resolveAcr,
|
|
142
|
+
resolveAcrAdminCredentials,
|
|
143
|
+
isAdminAcrAuth,
|
|
144
|
+
hasPermission,
|
|
145
|
+
actionMatches,
|
|
146
|
+
assertDeploymentAuthority
|
|
147
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview AZURE infra readiness evidence: image pull authorization for both web apps, then
|
|
5
|
+
* HTTPS probes. Managed-identity mode requires AcrPull + acrUseManagedIdentityCreds; password mode
|
|
6
|
+
* requires registry credentials on the web app and acrUseManagedIdentityCreds false. Both modes
|
|
7
|
+
* then require the applications to start (proof that the image was pulled). Local
|
|
8
|
+
* `az acr login` success is never treated as readiness.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { isAdminAcrAuth } = require('./azure-preflight');
|
|
12
|
+
|
|
13
|
+
const APP_OUTPUTS = Object.freeze([
|
|
14
|
+
{ name: 'webAppName', label: 'Miso' },
|
|
15
|
+
{ name: 'keycloakWebAppName', label: 'Keycloak' }
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function scoped(args, doc) {
|
|
19
|
+
return [...args, '--subscription', doc.subscriptionId.value];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function verifyManagedIdentityPull(doc, app, acrId, az) {
|
|
23
|
+
const group = doc.resourceGroup.value;
|
|
24
|
+
const principalId = az(scoped([
|
|
25
|
+
'webapp', 'identity', 'show', '--resource-group', group, '--name', app.value,
|
|
26
|
+
'--query', 'principalId', '--output', 'tsv'
|
|
27
|
+
], doc));
|
|
28
|
+
if (!principalId) throw new Error(`${app.label} web app has no system-assigned identity`);
|
|
29
|
+
const assignments = az(scoped([
|
|
30
|
+
'role', 'assignment', 'list', '--scope', acrId, '--role', 'AcrPull',
|
|
31
|
+
'--query', `length([?principalId=='${principalId}'])`, '--output', 'tsv'
|
|
32
|
+
], doc));
|
|
33
|
+
if (!(Number(assignments) > 0)) {
|
|
34
|
+
throw new Error(`${app.label} web app identity has no AcrPull assignment on the workspace registry`);
|
|
35
|
+
}
|
|
36
|
+
const managedPull = az(scoped([
|
|
37
|
+
'webapp', 'config', 'show', '--resource-group', group, '--name', app.value,
|
|
38
|
+
'--query', 'acrUseManagedIdentityCreds', '--output', 'tsv'
|
|
39
|
+
], doc));
|
|
40
|
+
if (String(managedPull).toLowerCase() !== 'true') {
|
|
41
|
+
throw new Error(`${app.label} web app does not pull images with its managed identity`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function verifyPasswordPull(doc, app, az) {
|
|
46
|
+
const group = doc.resourceGroup.value;
|
|
47
|
+
const managedPull = az(scoped([
|
|
48
|
+
'webapp', 'config', 'show', '--resource-group', group, '--name', app.value,
|
|
49
|
+
'--query', 'acrUseManagedIdentityCreds', '--output', 'tsv'
|
|
50
|
+
], doc));
|
|
51
|
+
if (String(managedPull).toLowerCase() === 'true') {
|
|
52
|
+
throw new Error(`${app.label} web app still uses managed-identity image pull; expected registry username/password`);
|
|
53
|
+
}
|
|
54
|
+
const username = az(scoped([
|
|
55
|
+
'webapp', 'config', 'appsettings', 'list', '--resource-group', group, '--name', app.value,
|
|
56
|
+
'--query', '[?name==\'DOCKER_REGISTRY_SERVER_USERNAME\'].value | [0]', '--output', 'tsv'
|
|
57
|
+
], doc));
|
|
58
|
+
if (!username) {
|
|
59
|
+
throw new Error(`${app.label} web app is missing DOCKER_REGISTRY_SERVER_USERNAME for password registry auth`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function verifyAppImagePull(doc, app, acrId, az) {
|
|
64
|
+
if (isAdminAcrAuth(doc)) {
|
|
65
|
+
verifyPasswordPull(doc, app, az);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
verifyManagedIdentityPull(doc, app, acrId, az);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verifies image-pull authorization for both web apps per acrAuth mode.
|
|
73
|
+
* @param {Object} doc - Infra manifest with outputs
|
|
74
|
+
* @param {Function} az - Azure CLI runner
|
|
75
|
+
*/
|
|
76
|
+
function verifyRuntimeImagePull(doc, az) {
|
|
77
|
+
const acrId = doc.parameters.acrResourceId.value;
|
|
78
|
+
for (const { name, label } of APP_OUTPUTS) {
|
|
79
|
+
const value = doc.outputs[name].value;
|
|
80
|
+
if (!value) throw new Error(`ARM output ${name} is missing; cannot verify ${label} image pull`);
|
|
81
|
+
verifyAppImagePull(doc, { value, label }, acrId, az);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Waits for Miso and Keycloak to start after pulling their images.
|
|
87
|
+
* @param {Object} doc - Infra manifest with outputs
|
|
88
|
+
* @param {Function} probe - probeHttps(baseUrl, path, label)
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
*/
|
|
91
|
+
async function waitForApplications(doc, probe) {
|
|
92
|
+
await probe(doc.outputs.webAppUrl.value, '/api/v1/health/readiness', 'Miso');
|
|
93
|
+
const realmUrl = doc.outputs.keycloakRealmUrl.value;
|
|
94
|
+
if (typeof realmUrl !== 'string' || !realmUrl.startsWith('https://')) {
|
|
95
|
+
throw new Error('ARM output keycloakRealmUrl is not a valid HTTPS URL');
|
|
96
|
+
}
|
|
97
|
+
const parsed = new URL(realmUrl);
|
|
98
|
+
await probe(parsed.origin, parsed.pathname, 'Keycloak');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
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
|
+
};
|