@aws/ml-container-creator 0.9.1 → 0.10.3

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 (90) hide show
  1. package/LICENSE-THIRD-PARTY +9304 -0
  2. package/bin/cli.js +2 -0
  3. package/config/bootstrap-e2e-stack.json +341 -0
  4. package/config/bootstrap-stack.json +40 -3
  5. package/config/parameter-schema-v2.json +2049 -0
  6. package/config/tune-catalog.json +1781 -0
  7. package/infra/ci-harness/buildspec.yml +1 -0
  8. package/infra/ci-harness/lambda/path-prover/brain.ts +306 -0
  9. package/infra/ci-harness/lambda/path-prover/write-results.ts +152 -0
  10. package/infra/ci-harness/lib/ci-harness-stack.ts +837 -7
  11. package/infra/ci-harness/state-machines/path-prover.asl.json +496 -0
  12. package/package.json +53 -68
  13. package/servers/base-image-picker/index.js +121 -121
  14. package/servers/e2e-status/index.js +297 -0
  15. package/servers/e2e-status/manifest.json +14 -0
  16. package/servers/e2e-status/package.json +15 -0
  17. package/servers/endpoint-picker/LICENSE +202 -0
  18. package/servers/endpoint-picker/index.js +536 -0
  19. package/servers/endpoint-picker/manifest.json +14 -0
  20. package/servers/endpoint-picker/package.json +18 -0
  21. package/servers/hyperpod-cluster-picker/index.js +125 -125
  22. package/servers/instance-sizer/index.js +138 -138
  23. package/servers/instance-sizer/lib/instance-ranker.js +76 -76
  24. package/servers/instance-sizer/lib/model-resolver.js +61 -61
  25. package/servers/instance-sizer/lib/quota-resolver.js +113 -113
  26. package/servers/instance-sizer/lib/vram-estimator.js +31 -31
  27. package/servers/lib/bedrock-client.js +38 -38
  28. package/servers/lib/catalogs/jumpstart-public.json +101 -16
  29. package/servers/lib/catalogs/model-servers.json +201 -3
  30. package/servers/lib/catalogs/models.json +182 -26
  31. package/servers/lib/custom-validators.js +13 -13
  32. package/servers/lib/dynamic-resolver.js +4 -4
  33. package/servers/marketplace-picker/index.js +342 -0
  34. package/servers/marketplace-picker/manifest.json +14 -0
  35. package/servers/marketplace-picker/package.json +18 -0
  36. package/servers/model-picker/index.js +382 -382
  37. package/servers/region-picker/index.js +56 -56
  38. package/servers/workload-picker/LICENSE +202 -0
  39. package/servers/workload-picker/catalogs/workload-profiles.json +67 -0
  40. package/servers/workload-picker/index.js +171 -0
  41. package/servers/workload-picker/manifest.json +16 -0
  42. package/servers/workload-picker/package.json +16 -0
  43. package/src/app.js +4 -390
  44. package/src/lib/bootstrap-command-handler.js +710 -1148
  45. package/src/lib/bootstrap-config.js +36 -0
  46. package/src/lib/bootstrap-profile-manager.js +641 -0
  47. package/src/lib/bootstrap-provisioners.js +421 -0
  48. package/src/lib/ci-register-helpers.js +74 -0
  49. package/src/lib/config-loader.js +408 -0
  50. package/src/lib/config-manager.js +66 -1685
  51. package/src/lib/config-mcp-client.js +118 -0
  52. package/src/lib/config-validator.js +634 -0
  53. package/src/lib/cuda-resolver.js +149 -0
  54. package/src/lib/e2e-catalog-validator.js +251 -3
  55. package/src/lib/e2e-ci-recorder.js +103 -0
  56. package/src/lib/generated/cli-options.js +315 -311
  57. package/src/lib/generated/parameter-matrix.js +671 -0
  58. package/src/lib/generated/validation-rules.js +71 -71
  59. package/src/lib/marketplace-flow.js +276 -0
  60. package/src/lib/mcp-query-runner.js +768 -0
  61. package/src/lib/parameter-schema-validator.js +62 -18
  62. package/src/lib/path-prover-brain.js +607 -0
  63. package/src/lib/prompt-runner.js +41 -1504
  64. package/src/lib/prompts/feature-prompts.js +172 -0
  65. package/src/lib/prompts/index.js +48 -0
  66. package/src/lib/prompts/infrastructure-prompts.js +690 -0
  67. package/src/lib/prompts/model-prompts.js +552 -0
  68. package/src/lib/prompts/project-prompts.js +82 -0
  69. package/src/lib/prompts.js +2 -1446
  70. package/src/lib/registry-command-handler.js +135 -3
  71. package/src/lib/secrets-prompt-runner.js +251 -0
  72. package/src/lib/template-variable-resolver.js +422 -0
  73. package/src/lib/tune-catalog-validator.js +37 -4
  74. package/templates/Dockerfile +9 -0
  75. package/templates/code/adapter_sidecar.py +444 -0
  76. package/templates/code/serve +6 -0
  77. package/templates/code/serve.d/vllm.ejs +1 -1
  78. package/templates/do/.benchmark_writer.py +1476 -0
  79. package/templates/do/.tune_helper.py +982 -57
  80. package/templates/do/__pycache__/.benchmark_writer.cpython-312.pyc +0 -0
  81. package/templates/do/adapter +149 -0
  82. package/templates/do/benchmark +639 -85
  83. package/templates/do/config +108 -5
  84. package/templates/do/deploy.d/managed-inference.ejs +192 -11
  85. package/templates/do/optimize +106 -37
  86. package/templates/do/register +89 -0
  87. package/templates/do/test +13 -0
  88. package/templates/do/tune +378 -59
  89. package/templates/do/validate +44 -4
  90. package/config/parameter-schema.json +0 -88
@@ -0,0 +1,149 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * CUDA Resolver - Handles CUDA version selection and AMI resolution.
6
+ * Uses delegation pattern: receives parent PromptRunner reference to access shared state.
7
+ */
8
+
9
+ export const CUDA_AMI_MAP = {
10
+ '11.0': 'al2-ami-sagemaker-inference-gpu-2',
11
+ '11.4': 'al2-ami-sagemaker-inference-gpu-2-1',
12
+ '11.8': 'al2-ami-sagemaker-inference-gpu-2-1',
13
+ '12.1': 'al2-ami-sagemaker-inference-gpu-3-1',
14
+ '12.2': 'al2-ami-sagemaker-inference-gpu-3-1',
15
+ '12.4': 'al2-ami-sagemaker-inference-gpu-3-1',
16
+ '12.6': 'al2-ami-sagemaker-inference-gpu-3-1',
17
+ '13.0': 'al2023-ami-sagemaker-inference-gpu-4-1'
18
+ };
19
+
20
+ export default class CudaResolver {
21
+ constructor(runner) {
22
+ this.runner = runner;
23
+ }
24
+
25
+ /**
26
+ * Prompt the user to select a CUDA version when the selected GPU instance
27
+ * supports multiple versions.
28
+ *
29
+ * @param {string} instanceType - Selected instance type (e.g. "ml.g5.2xlarge")
30
+ * @param {string} framework - Selected framework name
31
+ * @param {string} frameworkVersion - Selected framework version
32
+ * @param {string} [baseImageCuda] - CUDA version from selected base image (for auto-resolution)
33
+ * @returns {Promise<{cudaVersion: string, inferenceAmiVersion: string}|null>}
34
+ */
35
+ async _promptCudaVersion(instanceType, framework, frameworkVersion, baseImageCuda) {
36
+ if (!instanceType) return null;
37
+
38
+ const instanceInfo = this.runner._instanceAcceleratorMapping[instanceType];
39
+ if (!instanceInfo || instanceInfo.accelerator.type !== 'cuda') return null;
40
+
41
+ const instanceCudaVersions = instanceInfo.accelerator.versions;
42
+ if (!instanceCudaVersions || instanceCudaVersions.length === 0) return null;
43
+
44
+ // Auto-resolution: when base image specifies a CUDA version, intersect with instance support
45
+ if (baseImageCuda) {
46
+ const majorRequired = baseImageCuda.split('.')[0];
47
+ const intersection = instanceCudaVersions.filter(v => {
48
+ if (v === baseImageCuda) return true;
49
+ if (v.startsWith(`${majorRequired}.`)) return true;
50
+ return false;
51
+ });
52
+
53
+ if (intersection.length > 0) {
54
+ const exactMatch = intersection.find(v => v === baseImageCuda);
55
+ const selectedVersion = exactMatch || intersection.sort().pop();
56
+ const inferenceAmiVersion = CUDA_AMI_MAP[selectedVersion];
57
+ if (inferenceAmiVersion) {
58
+ console.log(`\n🔧 CUDA ${selectedVersion} auto-resolved from base image (requires ${baseImageCuda})`);
59
+ console.log(` AMI: ${inferenceAmiVersion}`);
60
+ return { cudaVersion: selectedVersion, inferenceAmiVersion };
61
+ }
62
+ } else {
63
+ console.log(`\n ⚠️ Base image requires CUDA ${baseImageCuda} but instance ${instanceType} supports: ${instanceCudaVersions.join(', ')}`);
64
+ console.log(' No compatible CUDA version found. Falling back to manual selection.');
65
+ }
66
+ }
67
+
68
+ // Get framework CUDA requirements (if available)
69
+ const registryConfigManager = this.runner.registryConfigManager;
70
+ const frameworkConfig = registryConfigManager?.frameworkRegistry?.[framework]?.[frameworkVersion];
71
+ const frameworkAccel = frameworkConfig?.accelerator;
72
+
73
+ // Compute compatible CUDA versions
74
+ let compatibleVersions;
75
+ if (frameworkAccel?.versionRange) {
76
+ const { min, max } = frameworkAccel.versionRange;
77
+ compatibleVersions = instanceCudaVersions.filter(v => {
78
+ return v >= min && v <= max;
79
+ });
80
+ } else {
81
+ compatibleVersions = [...instanceCudaVersions];
82
+ }
83
+
84
+ if (compatibleVersions.length === 0) {
85
+ compatibleVersions = [...instanceCudaVersions];
86
+ }
87
+
88
+ // If only one option, auto-select it silently
89
+ if (compatibleVersions.length === 1) {
90
+ const cudaVersion = compatibleVersions[0];
91
+ const inferenceAmiVersion = CUDA_AMI_MAP[cudaVersion];
92
+ if (inferenceAmiVersion) {
93
+ console.log(`\n🔧 CUDA ${cudaVersion} auto-selected (only compatible version for ${instanceType})`);
94
+ console.log(` AMI: ${inferenceAmiVersion}`);
95
+ }
96
+ return inferenceAmiVersion ? { cudaVersion, inferenceAmiVersion } : null;
97
+ }
98
+
99
+ // Multiple options — determine the best default
100
+ const defaultVersion = frameworkAccel?.version
101
+ && compatibleVersions.includes(frameworkAccel.version)
102
+ ? frameworkAccel.version
103
+ : instanceInfo.accelerator.default || compatibleVersions[compatibleVersions.length - 1];
104
+
105
+ // Auto-select when we have a reliable default — no need to prompt the user about
106
+ // AMI internals they shouldn't need to care about. The default is derived from:
107
+ // 1. Framework's declared CUDA version (highest confidence)
108
+ // 2. Instance catalog's defaultCudaVersion (hardware-appropriate)
109
+ // 3. Highest compatible version (safe fallback)
110
+ // Only prompt if none of these sources provide a default (shouldn't happen in practice).
111
+ if (defaultVersion && CUDA_AMI_MAP[defaultVersion]) {
112
+ const inferenceAmiVersion = CUDA_AMI_MAP[defaultVersion];
113
+ const source = frameworkAccel?.version && compatibleVersions.includes(frameworkAccel.version)
114
+ ? 'framework requirement'
115
+ : instanceInfo.accelerator.default === defaultVersion
116
+ ? 'instance default'
117
+ : 'highest compatible';
118
+ console.log(`\n🔧 CUDA ${defaultVersion} auto-selected (${source})`);
119
+ console.log(` AMI: ${inferenceAmiVersion}`);
120
+ return { cudaVersion: defaultVersion, inferenceAmiVersion };
121
+ }
122
+
123
+ // Fallback: prompt only when no reliable default exists (edge case)
124
+ const choices = compatibleVersions.map(v => {
125
+ const ami = CUDA_AMI_MAP[v] || 'unknown';
126
+ const isDefault = v === defaultVersion ? ' (recommended)' : '';
127
+ return {
128
+ name: `CUDA ${v}${isDefault} → AMI: ${ami}`,
129
+ value: v,
130
+ short: `CUDA ${v}`
131
+ };
132
+ });
133
+
134
+ const { cudaVersion } = await this.runner._runPrompts([{
135
+ type: 'list',
136
+ name: 'cudaVersion',
137
+ message: `Select CUDA version for ${instanceType} (${instanceInfo.accelerator.hardware}):`,
138
+ choices,
139
+ default: defaultVersion
140
+ }]);
141
+
142
+ const inferenceAmiVersion = CUDA_AMI_MAP[cudaVersion];
143
+ if (inferenceAmiVersion) {
144
+ console.log(` ✅ CUDA ${cudaVersion} → AMI: ${inferenceAmiVersion}`);
145
+ }
146
+
147
+ return inferenceAmiVersion ? { cudaVersion, inferenceAmiVersion } : null;
148
+ }
149
+ }
@@ -12,6 +12,8 @@
12
12
  */
13
13
 
14
14
  import Ajv from 'ajv';
15
+ import { readFileSync } from 'node:fs';
16
+ import path from 'node:path';
15
17
 
16
18
  const catalogSchema = {
17
19
  $schema: 'http://json-schema.org/draft-07/schema#',
@@ -34,20 +36,214 @@ const catalogSchema = {
34
36
  items: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
35
37
  minItems: 1
36
38
  },
37
- timeout: { type: 'integer', minimum: 60 }
39
+ timeout: { type: 'integer', minimum: 60 },
40
+ tuneTimeout: { type: 'integer', minimum: 60 },
41
+ tuneConfig: {
42
+ type: 'object',
43
+ required: ['tuneId', 'technique', 'trainingType', 'dataset'],
44
+ properties: {
45
+ tuneId: { type: 'string' },
46
+ technique: { type: 'string', enum: ['sft', 'dpo', 'rlaif', 'rlvr'] },
47
+ trainingType: { type: 'string', enum: ['lora', 'full-rank'] },
48
+ dataset: { type: 'string' }
49
+ },
50
+ additionalProperties: false
51
+ }
38
52
  }
39
53
  }
40
54
  }
41
55
  }
42
56
  };
43
57
 
58
+ /**
59
+ * Custom validation rules for tune lifecycle entries.
60
+ *
61
+ * Enforces:
62
+ * - Entries with tune lifecycle steps (steps starting with "tune-") must have a `tuneConfig` object
63
+ * - Entries with tune lifecycle steps must include `--enable-lora` in their `args` field
64
+ * - If `tuneTimeout` is present, it must be a positive integer >= 60
65
+ *
66
+ * @param {Object} catalog - The catalog object with a `configs` array
67
+ * @param {Array<{ path: string, message: string }>} errors - Errors array to append to
68
+ */
69
+ export function validateTuneConstraints(catalog, errors) {
70
+ if (!catalog || !Array.isArray(catalog.configs)) {
71
+ return;
72
+ }
73
+
74
+ for (let i = 0; i < catalog.configs.length; i++) {
75
+ const entry = catalog.configs[i];
76
+ if (!entry || !Array.isArray(entry.lifecycle)) {
77
+ continue;
78
+ }
79
+
80
+ const hasTuneSteps = entry.lifecycle.some((s) => typeof s === 'string' && s.startsWith('tune-'));
81
+
82
+ if (hasTuneSteps) {
83
+ // Must have tuneConfig
84
+ if (!entry.tuneConfig) {
85
+ errors.push({
86
+ path: `/configs/${i}`,
87
+ message: `entry "${entry.id}" has tune lifecycle steps but no tuneConfig`
88
+ });
89
+ continue;
90
+ }
91
+
92
+ // Must have --enable-lora in args
93
+ if (!entry.args || !entry.args.includes('--enable-lora')) {
94
+ errors.push({
95
+ path: `/configs/${i}/args`,
96
+ message: `entry "${entry.id}" has tune steps but args missing --enable-lora`
97
+ });
98
+ }
99
+ }
100
+
101
+ // tuneTimeout validation (if present)
102
+ if (entry.tuneTimeout !== undefined) {
103
+ if (typeof entry.tuneTimeout !== 'number' || !Number.isInteger(entry.tuneTimeout) || entry.tuneTimeout < 60) {
104
+ errors.push({
105
+ path: `/configs/${i}/tuneTimeout`,
106
+ message: `entry "${entry.id}": tuneTimeout must be a positive integer >= 60`
107
+ });
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Validate lifecycle ordering for tune-group steps.
115
+ *
116
+ * Tune-group steps are: any step starting with "tune-", "adapter-add", and "test-adapter".
117
+ * These must appear AFTER the "test" step and BEFORE the "clean" step in the lifecycle array.
118
+ *
119
+ * @param {Object} catalog - The catalog object with a `configs` array
120
+ * @param {Array<{ path: string, message: string }>} errors - Errors array to append to
121
+ */
122
+ export function validateLifecycleOrdering(catalog, errors) {
123
+ if (!catalog || !Array.isArray(catalog.configs)) {
124
+ return;
125
+ }
126
+
127
+ for (let i = 0; i < catalog.configs.length; i++) {
128
+ const entry = catalog.configs[i];
129
+ if (!entry || !Array.isArray(entry.lifecycle)) {
130
+ continue;
131
+ }
132
+
133
+ // Identify tune-group steps in this entry's lifecycle
134
+ const tuneGroupSteps = entry.lifecycle.filter(
135
+ (s) => typeof s === 'string' && (s.startsWith('tune-') || s === 'adapter-add' || s === 'test-adapter')
136
+ );
137
+
138
+ if (tuneGroupSteps.length === 0) {
139
+ continue;
140
+ }
141
+
142
+ const testIdx = entry.lifecycle.indexOf('test');
143
+ const cleanIdx = entry.lifecycle.indexOf('clean');
144
+
145
+ for (const step of tuneGroupSteps) {
146
+ const stepIdx = entry.lifecycle.indexOf(step);
147
+
148
+ if (testIdx >= 0 && stepIdx <= testIdx) {
149
+ errors.push({
150
+ path: `/configs/${i}/lifecycle`,
151
+ message: `entry "${entry.id}": "${step}" must come after "test"`
152
+ });
153
+ }
154
+
155
+ if (cleanIdx >= 0 && stepIdx >= cleanIdx) {
156
+ errors.push({
157
+ path: `/configs/${i}/lifecycle`,
158
+ message: `entry "${entry.id}": "${step}" must come before "clean"`
159
+ });
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Validate tuneConfig entries against tune-catalog.json.
167
+ * This is a "soft" validation — returns an empty errors array if tune-catalog is unavailable.
168
+ *
169
+ * For each catalog entry with a tuneConfig:
170
+ * - Checks that tuneConfig.tuneId exists as a key in the tune-catalog's models object
171
+ * - Checks that the specified technique is supported for that model
172
+ * - Checks that the specified trainingType is in the technique's trainingTypes array
173
+ *
174
+ * @param {Object} catalog - The catalog object with a configs array
175
+ * @param {string} tuneCatalogPath - Path to the tune-catalog.json file
176
+ * @returns {Array<{ path: string, message: string }>} Array of validation errors
177
+ */
178
+ export function validateTuneCatalogReferences(catalog, tuneCatalogPath) {
179
+ const errors = [];
180
+
181
+ if (!catalog || !Array.isArray(catalog.configs)) {
182
+ return errors;
183
+ }
184
+
185
+ let tuneCatalog;
186
+ try {
187
+ const raw = readFileSync(tuneCatalogPath, 'utf8');
188
+ tuneCatalog = JSON.parse(raw);
189
+ } catch {
190
+ // tune-catalog not available or unparseable — skip cross-reference validation
191
+ return errors;
192
+ }
193
+
194
+ if (!tuneCatalog || !tuneCatalog.models) {
195
+ return errors;
196
+ }
197
+
198
+ for (let i = 0; i < catalog.configs.length; i++) {
199
+ const entry = catalog.configs[i];
200
+ if (!entry || !entry.tuneConfig) {
201
+ continue;
202
+ }
203
+
204
+ const { tuneId, technique, trainingType } = entry.tuneConfig;
205
+
206
+ // Check tuneId exists in tune-catalog models
207
+ const tuneModel = tuneCatalog.models[tuneId];
208
+ if (!tuneModel) {
209
+ errors.push({
210
+ path: `/configs/${i}/tuneConfig/tuneId`,
211
+ message: `entry "${entry.id}": tuneId "${tuneId}" not found in tune-catalog`
212
+ });
213
+ continue;
214
+ }
215
+
216
+ // Check technique is supported for this model
217
+ if (!tuneModel.techniques || !tuneModel.techniques[technique]) {
218
+ errors.push({
219
+ path: `/configs/${i}/tuneConfig/technique`,
220
+ message: `entry "${entry.id}": technique "${technique}" not supported for model "${tuneId}"`
221
+ });
222
+ continue;
223
+ }
224
+
225
+ // Check trainingType is in the technique's trainingTypes array
226
+ const supportedTrainingTypes = tuneModel.techniques[technique].trainingTypes;
227
+ if (!Array.isArray(supportedTrainingTypes) || !supportedTrainingTypes.includes(trainingType)) {
228
+ errors.push({
229
+ path: `/configs/${i}/tuneConfig/trainingType`,
230
+ message: `entry "${entry.id}": trainingType "${trainingType}" not supported for ${tuneId}/${technique}`
231
+ });
232
+ }
233
+ }
234
+
235
+ return errors;
236
+ }
237
+
44
238
  /**
45
239
  * Validate an e2e catalog object against the schema and uniqueness constraints.
46
240
  *
47
241
  * @param {Object} catalog - The catalog object to validate
242
+ * @param {Object} [options] - Optional configuration
243
+ * @param {string} [options.tuneCatalogPath] - Custom path to tune-catalog.json for cross-reference validation
48
244
  * @returns {{ valid: boolean, errors?: Array<{ path: string, message: string }> }}
49
245
  */
50
- export function validateCatalog(catalog) {
246
+ export function validateCatalog(catalog, options = {}) {
51
247
  const ajv = new Ajv({ allErrors: true, strict: false });
52
248
  const validate = ajv.compile(catalogSchema);
53
249
 
@@ -56,9 +252,19 @@ export function validateCatalog(catalog) {
56
252
 
57
253
  if (!valid) {
58
254
  for (const err of validate.errors) {
255
+ // Try to include the entry id in the error message for better diagnostics
256
+ let message = err.message;
257
+ const pathMatch = (err.instancePath || '').match(/^\/configs\/(\d+)/);
258
+ if (pathMatch && catalog && Array.isArray(catalog.configs)) {
259
+ const idx = parseInt(pathMatch[1], 10);
260
+ const entry = catalog.configs[idx];
261
+ if (entry && typeof entry.id === 'string') {
262
+ message = `entry "${entry.id}": ${err.message}`;
263
+ }
264
+ }
59
265
  errors.push({
60
266
  path: err.instancePath || '/',
61
- message: err.message
267
+ message
62
268
  });
63
269
  }
64
270
  }
@@ -81,6 +287,17 @@ export function validateCatalog(catalog) {
81
287
  }
82
288
  }
83
289
 
290
+ // Custom check: tune constraints (runs after Ajv schema validation)
291
+ validateTuneConstraints(catalog, errors);
292
+
293
+ // Custom check: lifecycle ordering for tune-group steps
294
+ validateLifecycleOrdering(catalog, errors);
295
+
296
+ // Cross-reference validation against tune-catalog.json
297
+ const tuneCatalogPath = options.tuneCatalogPath || path.resolve(process.cwd(), 'config/tune-catalog.json');
298
+ const crossRefErrors = validateTuneCatalogReferences(catalog, tuneCatalogPath);
299
+ errors.push(...crossRefErrors);
300
+
84
301
  if (errors.length > 0) {
85
302
  return { valid: false, errors };
86
303
  }
@@ -101,3 +318,34 @@ export function filterByTier(catalog, tier) {
101
318
  }
102
319
  return catalog.configs.filter((config) => config.tier === tier);
103
320
  }
321
+
322
+ /**
323
+ * Filter configs by a specific config id.
324
+ *
325
+ * First attempts to find the config within the provided (tier-filtered) configs array.
326
+ * If not found there, falls back to searching the full catalog — this is a convenience
327
+ * for re-runs where the user specifies --config without matching the tier.
328
+ *
329
+ * @param {Array<Object>} configs - Pre-filtered configs (e.g., from filterByTier)
330
+ * @param {Object} catalog - The full catalog object with a `configs` array
331
+ * @param {string} configId - The config id to filter by
332
+ * @returns {Array<Object>} Matching configs (0 or 1 element)
333
+ */
334
+ export function filterByConfig(configs, catalog, configId) {
335
+ if (!configId) {
336
+ return configs;
337
+ }
338
+
339
+ // First try within the already-filtered set (tier-filtered)
340
+ const inTier = configs.filter(c => c.id === configId);
341
+ if (inTier.length > 0) {
342
+ return inTier;
343
+ }
344
+
345
+ // Fallback: search the full catalog (convenience for --config without matching tier)
346
+ if (catalog && Array.isArray(catalog.configs)) {
347
+ return catalog.configs.filter(c => c.id === configId);
348
+ }
349
+
350
+ return [];
351
+ }
@@ -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 CI Recorder
6
+ *
7
+ * Records E2E validation results to the DynamoDB CI table.
8
+ * Gracefully degrades if the CI table is not provisioned —
9
+ * all recording calls become no-ops.
10
+ */
11
+
12
+ import { computeConfigId } from './ci-register-helpers.js';
13
+ import BootstrapConfig from './bootstrap-config.js';
14
+
15
+ export class E2ECIRecorder {
16
+ constructor() {
17
+ this.config = new BootstrapConfig();
18
+ this.client = null;
19
+ this.tableName = null;
20
+ }
21
+
22
+ /**
23
+ * Initialize the recorder by checking if the CI table is provisioned.
24
+ * If not provisioned, logs a warning and returns false — all subsequent
25
+ * recordConfigResult calls become no-ops.
26
+ *
27
+ * @returns {Promise<boolean>} true if ready to record, false otherwise
28
+ */
29
+ async init() {
30
+ const profile = this.config.getActiveProfileWithDefaults();
31
+ if (!profile || !profile.config.ciInfraProvisioned) {
32
+ console.warn('⚠️ CI table not provisioned — skipping result recording');
33
+ return false;
34
+ }
35
+ this.tableName = profile.config.ciTableName;
36
+ const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb');
37
+ this.client = new DynamoDBClient({ region: profile.config.awsRegion });
38
+ return true;
39
+ }
40
+
41
+ /**
42
+ * Record a config's E2E result to the DynamoDB CI table.
43
+ * No-op if init() returned false or was not called.
44
+ *
45
+ * @param {object} catalogEntry - The catalog entry that was tested
46
+ * @param {object} configResult - The result of running the config
47
+ */
48
+ async recordConfigResult(catalogEntry, configResult) {
49
+ if (!this.client) return;
50
+
51
+ const configId = this.deriveConfigId(catalogEntry);
52
+ const item = {
53
+ configId,
54
+ schemaVersion: 2,
55
+ testStatus: configResult.status === 'pass'
56
+ ? 'pass'
57
+ : `fail-${configResult.steps.find(s => s.status === 'fail')?.name || 'unknown'}`,
58
+ lastTestTimestamp: new Date().toISOString(),
59
+ stageResults: Object.fromEntries(
60
+ configResult.steps.map(s => [s.name, { status: s.status, duration: s.duration, error: s.error || '' }])
61
+ ),
62
+ e2eCatalogId: catalogEntry.id,
63
+ tier: catalogEntry.tier,
64
+ duration: configResult.duration
65
+ };
66
+
67
+ try {
68
+ const { PutItemCommand } = await import('@aws-sdk/client-dynamodb');
69
+ const { marshall } = await import('@aws-sdk/util-dynamodb');
70
+ await this.client.send(new PutItemCommand({
71
+ TableName: this.tableName,
72
+ Item: marshall(item, { removeUndefinedValues: true })
73
+ }));
74
+ } catch (err) {
75
+ console.warn(`⚠️ Failed to record ${catalogEntry.id} to CI table: ${err.message}`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Derive a deterministic configId from a catalog entry's args field,
81
+ * using the same SHA256 algorithm as `do/register --ci`.
82
+ *
83
+ * @param {object} catalogEntry - A catalog entry with an `args` string
84
+ * @returns {string} 16-character hex configId
85
+ */
86
+ deriveConfigId(catalogEntry) {
87
+ const args = Object.fromEntries(
88
+ catalogEntry.args.split(/\s+/)
89
+ .filter(a => a.startsWith('--'))
90
+ .map(a => a.replace(/^--/, '').split('='))
91
+ );
92
+ const deploymentTarget = catalogEntry.track === 'realtime'
93
+ ? 'realtime-inference'
94
+ : catalogEntry.track;
95
+ return computeConfigId(
96
+ args['deployment-config'] || '',
97
+ args['model-name'] || 'none',
98
+ args['instance-type'] || '',
99
+ args['region'] || 'us-west-2',
100
+ deploymentTarget
101
+ );
102
+ }
103
+ }