@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.
Files changed (46) hide show
  1. package/LICENSE-THIRD-PARTY +50760 -16218
  2. package/bin/cli.js +1 -1
  3. package/infra/ci-harness/buildspec.yml +4 -0
  4. package/package.json +3 -1
  5. package/servers/lib/catalogs/instances.json +52 -1275
  6. package/servers/lib/catalogs/model-servers.json +80 -0
  7. package/servers/lib/catalogs/models.json +0 -132
  8. package/servers/lib/catalogs/popular-diffusors.json +1 -110
  9. package/servers/model-picker/index.js +27 -16
  10. package/src/app.js +113 -23
  11. package/src/lib/cli-handler.js +1 -1
  12. package/src/lib/config-manager.js +39 -2
  13. package/src/lib/cross-cutting-checker.js +146 -33
  14. package/src/lib/deployment-config-resolver.js +10 -4
  15. package/src/lib/e2e-bootstrap.js +227 -0
  16. package/src/lib/e2e-catalog-validator.js +103 -0
  17. package/src/lib/e2e-quota-validator.js +135 -0
  18. package/src/lib/mcp-client.js +16 -1
  19. package/src/lib/mcp-command-handler.js +10 -2
  20. package/src/lib/prompt-runner.js +306 -24
  21. package/src/lib/prompts.js +9 -3
  22. package/src/lib/template-manager.js +10 -4
  23. package/src/lib/train-config-parser.js +136 -0
  24. package/src/lib/train-config-persistence.js +143 -0
  25. package/src/lib/train-config-validator.js +112 -0
  26. package/src/lib/train-feedback.js +46 -0
  27. package/src/lib/train-idempotency.js +97 -0
  28. package/src/lib/train-request-builder.js +120 -0
  29. package/src/lib/tune-catalog-validator.js +5 -5
  30. package/templates/code/serve +2 -2
  31. package/templates/code/serving.properties +2 -2
  32. package/templates/diffusors/serve +3 -3
  33. package/templates/do/.train_build_request.py +141 -0
  34. package/templates/do/.train_poll_parser.py +135 -0
  35. package/templates/do/.train_status_parser.py +187 -0
  36. package/templates/do/.tune_helper.py +2 -2
  37. package/templates/do/lib/feedback.sh +41 -0
  38. package/templates/do/register +8 -2
  39. package/templates/do/test +5 -5
  40. package/templates/do/train +786 -0
  41. package/templates/do/training/config.yaml +140 -0
  42. package/templates/do/training/train.py +463 -0
  43. package/templates/do/tune +2 -2
  44. package/templates/marketplace/config +118 -0
  45. package/templates/marketplace/deploy +890 -0
  46. 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, hub access config).
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 is 'jumpstart-hub', verify HubAccessConfig.HubContentArn is present
156
- if (modelSource === 'jumpstart-hub') {
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 total configs.
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 15 valid deployment-config strings.
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
+ }