@aws/ml-container-creator 0.7.1 → 0.9.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/LICENSE-THIRD-PARTY +50760 -16218
- package/bin/cli.js +1 -1
- package/infra/ci-harness/buildspec.yml +4 -0
- package/package.json +3 -1
- package/servers/lib/catalogs/instances.json +52 -1275
- package/servers/lib/catalogs/model-servers.json +80 -0
- package/servers/lib/catalogs/models.json +0 -132
- package/servers/lib/catalogs/popular-diffusors.json +1 -110
- package/servers/model-picker/index.js +27 -16
- package/src/app.js +113 -23
- package/src/lib/cli-handler.js +1 -1
- package/src/lib/config-manager.js +39 -2
- package/src/lib/cross-cutting-checker.js +146 -33
- package/src/lib/deployment-config-resolver.js +10 -4
- package/src/lib/e2e-bootstrap.js +227 -0
- package/src/lib/e2e-catalog-validator.js +103 -0
- package/src/lib/e2e-quota-validator.js +135 -0
- package/src/lib/mcp-client.js +16 -1
- package/src/lib/mcp-command-handler.js +10 -2
- package/src/lib/prompt-runner.js +306 -24
- package/src/lib/prompts.js +9 -3
- package/src/lib/template-manager.js +10 -4
- package/src/lib/train-config-parser.js +136 -0
- package/src/lib/train-config-persistence.js +143 -0
- package/src/lib/train-config-validator.js +112 -0
- package/src/lib/train-feedback.js +46 -0
- package/src/lib/train-idempotency.js +97 -0
- package/src/lib/train-request-builder.js +120 -0
- package/src/lib/tune-catalog-validator.js +5 -5
- package/templates/code/serve +2 -2
- package/templates/code/serving.properties +2 -2
- package/templates/diffusors/serve +3 -3
- package/templates/do/.train_build_request.py +141 -0
- package/templates/do/.train_poll_parser.py +135 -0
- package/templates/do/.train_status_parser.py +187 -0
- package/templates/do/.tune_helper.py +2 -2
- package/templates/do/lib/feedback.sh +41 -0
- package/templates/do/register +8 -2
- package/templates/do/test +5 -5
- package/templates/do/train +786 -0
- package/templates/do/training/config.yaml +140 -0
- package/templates/do/training/train.py +463 -0
- package/templates/do/tune +2 -2
- package/templates/marketplace/config +118 -0
- package/templates/marketplace/deploy +890 -0
- package/templates/marketplace/test +453 -0
|
@@ -1089,6 +1089,17 @@ export default class ConfigManager {
|
|
|
1089
1089
|
required: false,
|
|
1090
1090
|
default: 64,
|
|
1091
1091
|
valueSpace: 'bounded'
|
|
1092
|
+
},
|
|
1093
|
+
modelPackageArn: {
|
|
1094
|
+
cliOption: 'model-package-arn',
|
|
1095
|
+
envVar: null,
|
|
1096
|
+
configFile: true,
|
|
1097
|
+
packageJson: false,
|
|
1098
|
+
mcp: true,
|
|
1099
|
+
promptable: true,
|
|
1100
|
+
required: false,
|
|
1101
|
+
default: null,
|
|
1102
|
+
valueSpace: 'unbounded'
|
|
1092
1103
|
}
|
|
1093
1104
|
};
|
|
1094
1105
|
}
|
|
@@ -1860,6 +1871,14 @@ export default class ConfigManager {
|
|
|
1860
1871
|
}
|
|
1861
1872
|
}
|
|
1862
1873
|
|
|
1874
|
+
// Validate model package ARN format if provided
|
|
1875
|
+
if (this.config.modelPackageArn) {
|
|
1876
|
+
const modelPackageArnPattern = /^arn:aws:sagemaker:[a-z0-9-]+:\d{12}:model-package\/[a-zA-Z0-9]([a-zA-Z0-9-])*\/\d+$/;
|
|
1877
|
+
if (!modelPackageArnPattern.test(this.config.modelPackageArn)) {
|
|
1878
|
+
errors.push('❌ Invalid model package ARN format. Expected: arn:aws:sagemaker:<region>:<account>:model-package/<name>/<version>');
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1863
1882
|
// Only validate required parameters if we're skipping prompts
|
|
1864
1883
|
// If prompts are available, missing parameters can be collected later
|
|
1865
1884
|
if (this.skipPrompts) {
|
|
@@ -1946,10 +1965,15 @@ export default class ConfigManager {
|
|
|
1946
1965
|
const value = finalConfig[param];
|
|
1947
1966
|
const isEmpty = value === null || value === undefined || value === '';
|
|
1948
1967
|
|
|
1949
|
-
// Special case: modelFormat is not required for transformers/triton/diffusors
|
|
1950
|
-
if (param === 'modelFormat' && (finalConfig.architecture === 'transformers' || finalConfig.architecture === 'triton' || finalConfig.architecture === 'diffusors')) {
|
|
1968
|
+
// Special case: modelFormat is not required for transformers/triton/diffusors/marketplace
|
|
1969
|
+
if (param === 'modelFormat' && (finalConfig.architecture === 'transformers' || finalConfig.architecture === 'triton' || finalConfig.architecture === 'diffusors' || finalConfig.architecture === 'marketplace')) {
|
|
1951
1970
|
return; // Skip validation
|
|
1952
1971
|
}
|
|
1972
|
+
|
|
1973
|
+
// Special case: marketplace projects don't need container-related parameters
|
|
1974
|
+
if (finalConfig.architecture === 'marketplace' && (param === 'includeSampleModel' || param === 'buildTarget')) {
|
|
1975
|
+
return; // Skip validation — marketplace has no container to build
|
|
1976
|
+
}
|
|
1953
1977
|
|
|
1954
1978
|
// Special case: instanceType is not required for hyperpod-eks
|
|
1955
1979
|
// when not provided (backward compatibility) — but it IS prompted now
|
|
@@ -2368,6 +2392,19 @@ export default class ConfigManager {
|
|
|
2368
2392
|
}
|
|
2369
2393
|
}
|
|
2370
2394
|
break;
|
|
2395
|
+
|
|
2396
|
+
case 'modelPackageArn':
|
|
2397
|
+
if (value) {
|
|
2398
|
+
const modelPackageArnPattern = /^arn:aws:sagemaker:[a-z0-9-]+:\d{12}:model-package\/[a-zA-Z0-9]([a-zA-Z0-9-])*\/\d+$/;
|
|
2399
|
+
if (!modelPackageArnPattern.test(value)) {
|
|
2400
|
+
throw new ValidationError(
|
|
2401
|
+
'❌ Invalid model package ARN format. Expected: arn:aws:sagemaker:<region>:<account>:model-package/<name>/<version>',
|
|
2402
|
+
parameter,
|
|
2403
|
+
value
|
|
2404
|
+
);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
break;
|
|
2371
2408
|
}
|
|
2372
2409
|
}
|
|
2373
2410
|
|
|
@@ -23,6 +23,7 @@ export default class CrossCuttingChecker {
|
|
|
23
23
|
findings.push(...this.checkCudaCompatibility(context, instanceCatalog));
|
|
24
24
|
findings.push(...this.checkModelTypeInstanceAlignment(context, instanceCatalog));
|
|
25
25
|
findings.push(...this.checkKvCacheMemoryFit(context, instanceCatalog));
|
|
26
|
+
findings.push(...this.checkMarketplaceCompatibility(context));
|
|
26
27
|
|
|
27
28
|
return findings;
|
|
28
29
|
}
|
|
@@ -142,7 +143,7 @@ export default class CrossCuttingChecker {
|
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
/**
|
|
145
|
-
* Verify model source requirements (artifact URI
|
|
146
|
+
* Verify model source requirements (artifact URI).
|
|
146
147
|
* @param {Object} context - ValidationContext
|
|
147
148
|
* @returns {Array} Findings
|
|
148
149
|
*/
|
|
@@ -152,38 +153,8 @@ export default class CrossCuttingChecker {
|
|
|
152
153
|
|
|
153
154
|
const modelSource = config.modelSource || config.MODEL_SOURCE || '';
|
|
154
155
|
|
|
155
|
-
// When modelSource
|
|
156
|
-
|
|
157
|
-
const payloads = context.payloads || {};
|
|
158
|
-
let hubContentArnFound = false;
|
|
159
|
-
|
|
160
|
-
for (const payload of Object.values(payloads)) {
|
|
161
|
-
if (payload?.HubAccessConfig?.HubContentArn) {
|
|
162
|
-
hubContentArnFound = true;
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (!hubContentArnFound && !config.HUB_CONTENT_ARN) {
|
|
168
|
-
findings.push({
|
|
169
|
-
service: 'cross-cutting',
|
|
170
|
-
operation: 'configuration',
|
|
171
|
-
fieldPath: 'HubAccessConfig.HubContentArn',
|
|
172
|
-
invalidValue: null,
|
|
173
|
-
constraint: {
|
|
174
|
-
type: 'conditional-required',
|
|
175
|
-
condition: 'modelSource === jumpstart-hub'
|
|
176
|
-
},
|
|
177
|
-
severity: 'error',
|
|
178
|
-
confidence: 'high',
|
|
179
|
-
source: 'cross-cutting',
|
|
180
|
-
remediationHint: 'When modelSource is "jumpstart-hub", HubAccessConfig.HubContentArn must be present in the payload.'
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// When modelSource in {s3, jumpstart, jumpstart-hub, registry}, verify MODEL_ARTIFACT_URI is non-empty
|
|
186
|
-
const sourcesRequiringArtifact = ['s3', 'jumpstart', 'jumpstart-hub', 'registry'];
|
|
156
|
+
// When modelSource in {s3, registry}, verify MODEL_ARTIFACT_URI is non-empty
|
|
157
|
+
const sourcesRequiringArtifact = ['s3', 'registry'];
|
|
187
158
|
if (sourcesRequiringArtifact.includes(modelSource)) {
|
|
188
159
|
const artifactUri = config.MODEL_ARTIFACT_URI || '';
|
|
189
160
|
if (!artifactUri || artifactUri.trim() === '') {
|
|
@@ -457,4 +428,146 @@ export default class CrossCuttingChecker {
|
|
|
457
428
|
|
|
458
429
|
return findings;
|
|
459
430
|
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Validate marketplace model package compatibility.
|
|
434
|
+
* Checks ARN format, subscription status, instance type support,
|
|
435
|
+
* deployment target support, LoRA incompatibility, and adapter operations.
|
|
436
|
+
*
|
|
437
|
+
* For live AWS API checks (DescribeModelPackage), gracefully skips
|
|
438
|
+
* when credentials are unavailable — only format checks are enforced.
|
|
439
|
+
*
|
|
440
|
+
* @param {Object} context - ValidationContext
|
|
441
|
+
* @returns {Array} Findings
|
|
442
|
+
*/
|
|
443
|
+
checkMarketplaceCompatibility(context) {
|
|
444
|
+
const findings = [];
|
|
445
|
+
const config = context.config || {};
|
|
446
|
+
|
|
447
|
+
const architecture = config.architecture || config.DEPLOYMENT_CONFIG || '';
|
|
448
|
+
if (architecture !== 'marketplace') return findings;
|
|
449
|
+
|
|
450
|
+
// 1. Validate ARN format
|
|
451
|
+
const modelPackageArn = config.modelPackageArn || config.MODEL_PACKAGE_ARN || '';
|
|
452
|
+
if (modelPackageArn) {
|
|
453
|
+
const arnPattern = /^arn:aws:sagemaker:[a-z0-9-]+:\d{12}:model-package\/[a-zA-Z0-9]([a-zA-Z0-9-])*\/\d+$/;
|
|
454
|
+
if (!arnPattern.test(modelPackageArn)) {
|
|
455
|
+
findings.push({
|
|
456
|
+
service: 'cross-cutting',
|
|
457
|
+
operation: 'configuration',
|
|
458
|
+
fieldPath: 'MODEL_PACKAGE_ARN',
|
|
459
|
+
invalidValue: modelPackageArn,
|
|
460
|
+
constraint: {
|
|
461
|
+
type: 'arn-format',
|
|
462
|
+
pattern: 'arn:aws:sagemaker:<region>:<account>:model-package/<name>/<version>'
|
|
463
|
+
},
|
|
464
|
+
severity: 'error',
|
|
465
|
+
confidence: 'high',
|
|
466
|
+
source: 'cross-cutting',
|
|
467
|
+
remediationHint: '❌ Invalid model package ARN format. Expected: arn:aws:sagemaker:<region>:<account>:model-package/<name>/<version>'
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 2. Verify subscription is active (when package metadata is available)
|
|
473
|
+
const packageStatus = config._marketplacePackageStatus || config.marketplacePackageStatus || '';
|
|
474
|
+
if (packageStatus && packageStatus !== 'Active' && packageStatus !== 'Completed') {
|
|
475
|
+
findings.push({
|
|
476
|
+
service: 'cross-cutting',
|
|
477
|
+
operation: 'configuration',
|
|
478
|
+
fieldPath: 'MODEL_PACKAGE_ARN',
|
|
479
|
+
invalidValue: modelPackageArn,
|
|
480
|
+
constraint: {
|
|
481
|
+
type: 'subscription-status',
|
|
482
|
+
status: packageStatus
|
|
483
|
+
},
|
|
484
|
+
severity: 'error',
|
|
485
|
+
confidence: 'high',
|
|
486
|
+
source: 'cross-cutting',
|
|
487
|
+
remediationHint: `❌ Marketplace subscription is not active (status: ${packageStatus}). Renew at AWS Marketplace.`
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 3. Verify instance type is in package's supported list
|
|
492
|
+
const instanceType = config.INSTANCE_TYPE || config.instanceType || '';
|
|
493
|
+
const supportedInstanceTypes = config._supportedInstanceTypes || config.supportedInstanceTypes || [];
|
|
494
|
+
if (instanceType && supportedInstanceTypes.length > 0) {
|
|
495
|
+
if (!supportedInstanceTypes.includes(instanceType)) {
|
|
496
|
+
findings.push({
|
|
497
|
+
service: 'cross-cutting',
|
|
498
|
+
operation: 'configuration',
|
|
499
|
+
fieldPath: 'INSTANCE_TYPE',
|
|
500
|
+
invalidValue: instanceType,
|
|
501
|
+
constraint: {
|
|
502
|
+
type: 'marketplace-instance-type',
|
|
503
|
+
supportedInstanceTypes
|
|
504
|
+
},
|
|
505
|
+
severity: 'error',
|
|
506
|
+
confidence: 'high',
|
|
507
|
+
source: 'cross-cutting',
|
|
508
|
+
remediationHint: `❌ Instance type ${instanceType} is not supported by this model package. Supported: ${supportedInstanceTypes.join(', ')}`
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 4. Verify deployment target is supported by the package
|
|
514
|
+
const deploymentTarget = context.deploymentTarget || config.deploymentTarget || config.DEPLOYMENT_TARGET || '';
|
|
515
|
+
const supportedDeploymentTargets = config._supportedDeploymentTargets || config.supportedDeploymentTargets || [];
|
|
516
|
+
if (deploymentTarget && supportedDeploymentTargets.length > 0) {
|
|
517
|
+
if (!supportedDeploymentTargets.includes(deploymentTarget)) {
|
|
518
|
+
findings.push({
|
|
519
|
+
service: 'cross-cutting',
|
|
520
|
+
operation: 'configuration',
|
|
521
|
+
fieldPath: 'DEPLOYMENT_TARGET',
|
|
522
|
+
invalidValue: deploymentTarget,
|
|
523
|
+
constraint: {
|
|
524
|
+
type: 'marketplace-deployment-target',
|
|
525
|
+
supportedDeploymentTargets
|
|
526
|
+
},
|
|
527
|
+
severity: 'error',
|
|
528
|
+
confidence: 'high',
|
|
529
|
+
source: 'cross-cutting',
|
|
530
|
+
remediationHint: `❌ Deployment target ${deploymentTarget} is not supported by this model package.`
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 5. Reject LoRA with marketplace
|
|
536
|
+
const enableLora = config.enableLora || config.ENABLE_LORA || false;
|
|
537
|
+
if (enableLora === true || enableLora === 'true') {
|
|
538
|
+
findings.push({
|
|
539
|
+
service: 'cross-cutting',
|
|
540
|
+
operation: 'configuration',
|
|
541
|
+
fieldPath: 'enableLora',
|
|
542
|
+
invalidValue: true,
|
|
543
|
+
constraint: {
|
|
544
|
+
type: 'marketplace-lora-incompatible'
|
|
545
|
+
},
|
|
546
|
+
severity: 'error',
|
|
547
|
+
confidence: 'high',
|
|
548
|
+
source: 'cross-cutting',
|
|
549
|
+
remediationHint: '❌ LoRA adapters are not supported for Marketplace model packages (vendor controls the model).'
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// 6. Reject adapter operations on marketplace projects
|
|
554
|
+
const operation = config._operation || config.operation || '';
|
|
555
|
+
if (operation === 'adapter' || operation === 'do/adapter') {
|
|
556
|
+
findings.push({
|
|
557
|
+
service: 'cross-cutting',
|
|
558
|
+
operation: 'configuration',
|
|
559
|
+
fieldPath: 'operation',
|
|
560
|
+
invalidValue: operation,
|
|
561
|
+
constraint: {
|
|
562
|
+
type: 'marketplace-adapter-incompatible'
|
|
563
|
+
},
|
|
564
|
+
severity: 'error',
|
|
565
|
+
confidence: 'high',
|
|
566
|
+
source: 'cross-cutting',
|
|
567
|
+
remediationHint: '❌ Adapter operations are not available for Marketplace projects.'
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return findings;
|
|
572
|
+
}
|
|
460
573
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Canonical mapping from deployment-config strings to structured parts.
|
|
15
|
-
* 2 http + 5 transformers + 7 triton + 1 diffusors =
|
|
15
|
+
* 2 http + 5 transformers + 7 triton + 1 diffusors + 1 marketplace = 16 total configs.
|
|
16
16
|
*/
|
|
17
17
|
const CANONICAL_CONFIGS = new Map([
|
|
18
18
|
// HTTP architecture (2)
|
|
@@ -36,7 +36,10 @@ const CANONICAL_CONFIGS = new Map([
|
|
|
36
36
|
['triton-python', { architecture: 'triton', backend: 'python', engine: null }],
|
|
37
37
|
|
|
38
38
|
// Diffusors architecture (1)
|
|
39
|
-
['diffusors-vllm-omni', { architecture: 'diffusors', backend: 'vllm-omni', engine: null }]
|
|
39
|
+
['diffusors-vllm-omni', { architecture: 'diffusors', backend: 'vllm-omni', engine: null }],
|
|
40
|
+
|
|
41
|
+
// Marketplace architecture (1) — no backend, vendor controls the container
|
|
42
|
+
['marketplace', { architecture: 'marketplace', backend: null, engine: null }]
|
|
40
43
|
]);
|
|
41
44
|
|
|
42
45
|
export default class DeploymentConfigResolver {
|
|
@@ -62,15 +65,18 @@ export default class DeploymentConfigResolver {
|
|
|
62
65
|
* Compose a deployment-config string from structured parts.
|
|
63
66
|
* Inverse of decompose().
|
|
64
67
|
*
|
|
65
|
-
* @param {{ architecture: string, backend: string, engine?: string }} parts
|
|
68
|
+
* @param {{ architecture: string, backend: string|null, engine?: string }} parts
|
|
66
69
|
* @returns {string}
|
|
67
70
|
*/
|
|
68
71
|
compose(parts) {
|
|
72
|
+
if (!parts.backend) {
|
|
73
|
+
return parts.architecture;
|
|
74
|
+
}
|
|
69
75
|
return `${parts.architecture}-${parts.backend}`;
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
/**
|
|
73
|
-
* Get all
|
|
79
|
+
* Get all 16 valid deployment-config strings.
|
|
74
80
|
*
|
|
75
81
|
* @returns {string[]}
|
|
76
82
|
*/
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* E2E Bootstrap Integration
|
|
6
|
+
*
|
|
7
|
+
* Handles the `bootstrap --ci --e2e` flow:
|
|
8
|
+
* 1. Loads the e2e catalog
|
|
9
|
+
* 2. Runs quota validation for the CI tier and emits warnings
|
|
10
|
+
* 3. Deploys the config/bootstrap-e2e-stack.json CloudFormation stack
|
|
11
|
+
* 4. Stores e2e config (bucket, SNS ARN, CodeBuild project name) in bootstrap config
|
|
12
|
+
*
|
|
13
|
+
* Requirements: 3.3, 3.4
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { validateQuotas } from './e2e-quota-validator.js';
|
|
21
|
+
import { validateCatalog } from './e2e-catalog-validator.js';
|
|
22
|
+
import BootstrapConfig from './bootstrap-config.js';
|
|
23
|
+
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = path.dirname(__filename);
|
|
26
|
+
|
|
27
|
+
const E2E_STACK_NAME = 'mlcc-bootstrap-e2e';
|
|
28
|
+
const E2E_STACK_TEMPLATE_PATH = path.resolve(__dirname, '../../config/bootstrap-e2e-stack.json');
|
|
29
|
+
const DEFAULT_CATALOG_PATH = path.resolve(__dirname, '../../scripts/e2e-catalog.json');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Bootstrap E2E infrastructure.
|
|
33
|
+
*
|
|
34
|
+
* Loads the catalog, validates quotas for the CI tier, deploys the
|
|
35
|
+
* CloudFormation stack, and stores e2e config in the bootstrap profile.
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} options
|
|
38
|
+
* @param {string} options.region - AWS region
|
|
39
|
+
* @param {string} options.profile - AWS CLI profile name
|
|
40
|
+
* @param {string} [options.catalogPath] - Path to the e2e catalog JSON file
|
|
41
|
+
* @param {string} [options.profileName] - Bootstrap profile name (default: 'default')
|
|
42
|
+
* @param {Object} [options.bootstrapConfig] - Pre-configured BootstrapConfig instance (for testing)
|
|
43
|
+
* @returns {Promise<Object>} The e2e config object with bucket, SNS ARN, and CodeBuild project name
|
|
44
|
+
*/
|
|
45
|
+
export async function bootstrapE2E(options) {
|
|
46
|
+
const {
|
|
47
|
+
region,
|
|
48
|
+
profile,
|
|
49
|
+
catalogPath = DEFAULT_CATALOG_PATH,
|
|
50
|
+
profileName = 'default',
|
|
51
|
+
bootstrapConfig
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
console.log('\n🧪 E2E Validation Infrastructure Setup\n');
|
|
55
|
+
|
|
56
|
+
// Step 1: Load and validate the catalog
|
|
57
|
+
console.log(' 📋 Loading e2e catalog...');
|
|
58
|
+
const catalog = loadCatalog(catalogPath);
|
|
59
|
+
console.log(` ✅ Catalog loaded (${catalog.configs.length} configs)`);
|
|
60
|
+
|
|
61
|
+
// Step 2: Run quota validation for CI tier
|
|
62
|
+
console.log('\n 🔍 Checking service quotas for CI tier...');
|
|
63
|
+
const quotaResults = await runQuotaValidation('ci', catalog, region);
|
|
64
|
+
|
|
65
|
+
if (quotaResults.length === 0) {
|
|
66
|
+
console.log(' ℹ️ No instance types to validate for CI tier');
|
|
67
|
+
} else {
|
|
68
|
+
const insufficient = quotaResults.filter(r => !r.sufficient);
|
|
69
|
+
if (insufficient.length === 0) {
|
|
70
|
+
console.log(' ✅ All quotas sufficient for CI tier');
|
|
71
|
+
} else {
|
|
72
|
+
for (const result of insufficient) {
|
|
73
|
+
console.log(` ⚠️ ${result.instanceType} quota is ${result.available}, need ${result.required} for CI tier`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Step 3: Deploy the E2E CloudFormation stack
|
|
79
|
+
console.log('\n ☁️ Deploying E2E infrastructure stack...');
|
|
80
|
+
const stackOutputs = deployE2EStack(profile, region);
|
|
81
|
+
console.log(' ✅ E2E stack deployed successfully');
|
|
82
|
+
|
|
83
|
+
// Step 4: Store e2e config in bootstrap profile
|
|
84
|
+
const e2eConfig = {
|
|
85
|
+
e2eInfraProvisioned: true,
|
|
86
|
+
e2eCodeBuildProject: stackOutputs.CodeBuildProjectName || 'ml-container-creator-e2e',
|
|
87
|
+
e2eResultsBucket: stackOutputs.ResultsBucketName || `mlcc-e2e-results-unknown-${region}`,
|
|
88
|
+
e2eSnsTopicArn: stackOutputs.NotificationsTopicArn || ''
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
console.log('\n 💾 Saving e2e config to bootstrap profile...');
|
|
92
|
+
const config = bootstrapConfig || new BootstrapConfig();
|
|
93
|
+
storeE2EConfig(config, profileName, e2eConfig);
|
|
94
|
+
console.log(' ✅ E2E config saved');
|
|
95
|
+
|
|
96
|
+
// Display summary
|
|
97
|
+
console.log('\n 📋 E2E Infrastructure Summary:');
|
|
98
|
+
console.log(` CodeBuild project: ${e2eConfig.e2eCodeBuildProject}`);
|
|
99
|
+
console.log(` Results bucket: ${e2eConfig.e2eResultsBucket}`);
|
|
100
|
+
console.log(` SNS topic: ${e2eConfig.e2eSnsTopicArn}`);
|
|
101
|
+
|
|
102
|
+
return e2eConfig;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Load and validate the e2e catalog from a JSON file.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} catalogPath - Path to the catalog JSON file
|
|
109
|
+
* @returns {Object} The validated catalog object
|
|
110
|
+
* @throws {Error} If the catalog file cannot be read or is invalid
|
|
111
|
+
*/
|
|
112
|
+
export function loadCatalog(catalogPath) {
|
|
113
|
+
let raw;
|
|
114
|
+
try {
|
|
115
|
+
raw = readFileSync(catalogPath, 'utf8');
|
|
116
|
+
} catch (err) {
|
|
117
|
+
throw new Error(`Failed to read e2e catalog at ${catalogPath}: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let catalog;
|
|
121
|
+
try {
|
|
122
|
+
catalog = JSON.parse(raw);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
throw new Error(`Failed to parse e2e catalog JSON: ${err.message}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const validation = validateCatalog(catalog);
|
|
128
|
+
if (!validation.valid) {
|
|
129
|
+
const errorMessages = validation.errors.map(e => ` ${e.path}: ${e.message}`).join('\n');
|
|
130
|
+
throw new Error(`E2E catalog validation failed:\n${errorMessages}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return catalog;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run quota validation for a given tier and emit warnings.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} tier - The tier to validate (e.g., 'ci')
|
|
140
|
+
* @param {Object} catalog - The validated catalog object
|
|
141
|
+
* @param {string} region - AWS region
|
|
142
|
+
* @returns {Promise<Array<{instanceType: string, required: number, available: number, sufficient: boolean}>>}
|
|
143
|
+
*/
|
|
144
|
+
export async function runQuotaValidation(tier, catalog, region) {
|
|
145
|
+
try {
|
|
146
|
+
return await validateQuotas(tier, catalog, region);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.warn(` ⚠️ Quota validation failed: ${err.message}`);
|
|
149
|
+
console.warn(' Continuing without quota validation...');
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Deploy the E2E CloudFormation stack.
|
|
156
|
+
*
|
|
157
|
+
* Uses `aws cloudformation deploy` which handles both CREATE and UPDATE scenarios.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} awsProfile - AWS CLI profile name
|
|
160
|
+
* @param {string} region - AWS region
|
|
161
|
+
* @returns {Object} Map of stack output key → output value
|
|
162
|
+
* @throws {Error} If stack deployment fails
|
|
163
|
+
*/
|
|
164
|
+
export function deployE2EStack(awsProfile, region) {
|
|
165
|
+
const deployCommand = [
|
|
166
|
+
'aws cloudformation deploy',
|
|
167
|
+
`--template-file ${E2E_STACK_TEMPLATE_PATH}`,
|
|
168
|
+
`--stack-name ${E2E_STACK_NAME}`,
|
|
169
|
+
'--capabilities CAPABILITY_NAMED_IAM',
|
|
170
|
+
`--profile ${awsProfile}`,
|
|
171
|
+
`--region ${region}`
|
|
172
|
+
].join(' ');
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
execSync(deployCommand, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// "No changes to deploy" is a success case — CloudFormation deploy
|
|
178
|
+
// exits with code 255 when there's nothing to update
|
|
179
|
+
const stderr = error.stderr || error.message || '';
|
|
180
|
+
if (stderr.includes('No changes to deploy')) {
|
|
181
|
+
console.log(' ℹ️ E2E stack is up to date — no changes needed');
|
|
182
|
+
} else {
|
|
183
|
+
throw new Error(`E2E stack deployment failed: ${stderr}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Read stack outputs
|
|
188
|
+
const describeCommand = `aws cloudformation describe-stacks --stack-name ${E2E_STACK_NAME} --region ${region} --profile ${awsProfile} --output json`;
|
|
189
|
+
let describeOutput;
|
|
190
|
+
try {
|
|
191
|
+
describeOutput = execSync(describeCommand, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
192
|
+
} catch (err) {
|
|
193
|
+
throw new Error(`Failed to read E2E stack outputs: ${err.message}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const describeResult = JSON.parse(describeOutput.trim());
|
|
197
|
+
const stack = describeResult.Stacks && describeResult.Stacks[0];
|
|
198
|
+
if (!stack) {
|
|
199
|
+
throw new Error(`Stack "${E2E_STACK_NAME}" not found after deployment`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const outputs = {};
|
|
203
|
+
for (const output of (stack.Outputs || [])) {
|
|
204
|
+
outputs[output.OutputKey] = output.OutputValue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return outputs;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Store e2e config fields in the bootstrap profile.
|
|
212
|
+
*
|
|
213
|
+
* @param {BootstrapConfig} config - BootstrapConfig instance
|
|
214
|
+
* @param {string} profileName - The profile name to update
|
|
215
|
+
* @param {Object} e2eConfig - The e2e config fields to store
|
|
216
|
+
*/
|
|
217
|
+
export function storeE2EConfig(config, profileName, e2eConfig) {
|
|
218
|
+
const fullConfig = config.read();
|
|
219
|
+
if (!fullConfig || !fullConfig.profiles || !fullConfig.profiles[profileName]) {
|
|
220
|
+
throw new Error(`Bootstrap profile "${profileName}" not found. Run bootstrap first.`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const profileData = fullConfig.profiles[profileName];
|
|
224
|
+
Object.assign(profileData, e2eConfig);
|
|
225
|
+
fullConfig.profiles[profileName] = profileData;
|
|
226
|
+
config.write(fullConfig);
|
|
227
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* E2E Catalog Validator
|
|
6
|
+
*
|
|
7
|
+
* Validates the e2e test catalog against a JSON Schema and enforces
|
|
8
|
+
* additional constraints (unique IDs) that JSON Schema alone cannot express.
|
|
9
|
+
* Also provides tier-based filtering of catalog entries.
|
|
10
|
+
*
|
|
11
|
+
* Requirements: 1.1, 1.2, 1.3, 1.4
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import Ajv from 'ajv';
|
|
15
|
+
|
|
16
|
+
const catalogSchema = {
|
|
17
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
18
|
+
type: 'object',
|
|
19
|
+
required: ['configs'],
|
|
20
|
+
properties: {
|
|
21
|
+
configs: {
|
|
22
|
+
type: 'array',
|
|
23
|
+
items: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
required: ['id', 'tier', 'track', 'args', 'lifecycle', 'timeout'],
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
properties: {
|
|
28
|
+
id: { type: 'string', pattern: '^[a-z0-9-]+$' },
|
|
29
|
+
tier: { type: 'string', enum: ['ci', 'nightly', 'weekly'] },
|
|
30
|
+
track: { type: 'string', enum: ['realtime', 'hyperpod', 'async', 'batch'] },
|
|
31
|
+
args: { type: 'string' },
|
|
32
|
+
lifecycle: {
|
|
33
|
+
type: 'array',
|
|
34
|
+
items: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
|
|
35
|
+
minItems: 1
|
|
36
|
+
},
|
|
37
|
+
timeout: { type: 'integer', minimum: 60 }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate an e2e catalog object against the schema and uniqueness constraints.
|
|
46
|
+
*
|
|
47
|
+
* @param {Object} catalog - The catalog object to validate
|
|
48
|
+
* @returns {{ valid: boolean, errors?: Array<{ path: string, message: string }> }}
|
|
49
|
+
*/
|
|
50
|
+
export function validateCatalog(catalog) {
|
|
51
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
52
|
+
const validate = ajv.compile(catalogSchema);
|
|
53
|
+
|
|
54
|
+
const valid = validate(catalog);
|
|
55
|
+
const errors = [];
|
|
56
|
+
|
|
57
|
+
if (!valid) {
|
|
58
|
+
for (const err of validate.errors) {
|
|
59
|
+
errors.push({
|
|
60
|
+
path: err.instancePath || '/',
|
|
61
|
+
message: err.message
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Custom check: unique IDs across all entries
|
|
67
|
+
if (catalog && catalog.configs && Array.isArray(catalog.configs)) {
|
|
68
|
+
const seen = new Map();
|
|
69
|
+
for (let i = 0; i < catalog.configs.length; i++) {
|
|
70
|
+
const entry = catalog.configs[i];
|
|
71
|
+
if (entry && typeof entry.id === 'string') {
|
|
72
|
+
if (seen.has(entry.id)) {
|
|
73
|
+
errors.push({
|
|
74
|
+
path: `/configs/${i}/id`,
|
|
75
|
+
message: `duplicate id "${entry.id}" (first seen at index ${seen.get(entry.id)})`
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
seen.set(entry.id, i);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (errors.length > 0) {
|
|
85
|
+
return { valid: false, errors };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { valid: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Filter catalog configs by tier.
|
|
93
|
+
*
|
|
94
|
+
* @param {Object} catalog - The catalog object with a `configs` array
|
|
95
|
+
* @param {string} tier - The tier to filter by (e.g., 'ci', 'nightly', 'weekly')
|
|
96
|
+
* @returns {Array<Object>} Configs matching the given tier
|
|
97
|
+
*/
|
|
98
|
+
export function filterByTier(catalog, tier) {
|
|
99
|
+
if (!catalog || !Array.isArray(catalog.configs)) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
return catalog.configs.filter((config) => config.tier === tier);
|
|
103
|
+
}
|