@aws/ml-container-creator 0.2.5 → 0.3.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 (72) hide show
  1. package/bin/cli.js +45 -4
  2. package/config/bootstrap-stack.json +14 -0
  3. package/infra/ci-harness/package-lock.json +22 -9
  4. package/package.json +7 -8
  5. package/servers/base-image-picker/index.js +3 -3
  6. package/servers/base-image-picker/manifest.json +4 -2
  7. package/servers/instance-sizer/index.js +564 -0
  8. package/servers/instance-sizer/lib/instance-ranker.js +270 -0
  9. package/servers/instance-sizer/lib/model-resolver.js +269 -0
  10. package/servers/instance-sizer/lib/vram-estimator.js +177 -0
  11. package/servers/instance-sizer/manifest.json +17 -0
  12. package/servers/instance-sizer/package.json +15 -0
  13. package/servers/{instance-recommender → lib}/catalogs/instances.json +136 -34
  14. package/servers/{base-image-picker → lib}/catalogs/model-servers.json +302 -254
  15. package/servers/lib/catalogs/model-sizes.json +131 -0
  16. package/servers/lib/catalogs/models.json +632 -0
  17. package/servers/{model-picker → lib}/catalogs/popular-diffusors.json +32 -10
  18. package/servers/{model-picker → lib}/catalogs/popular-transformers.json +59 -26
  19. package/servers/{base-image-picker → lib}/catalogs/python-slim.json +12 -12
  20. package/servers/lib/schemas/image-catalog.schema.json +6 -12
  21. package/servers/lib/schemas/instances.schema.json +29 -0
  22. package/servers/lib/schemas/model-catalog.schema.json +12 -10
  23. package/servers/lib/schemas/unified-model-catalog.schema.json +129 -0
  24. package/servers/model-picker/index.js +4 -4
  25. package/servers/model-picker/manifest.json +2 -3
  26. package/servers/region-picker/index.js +1 -1
  27. package/servers/region-picker/manifest.json +1 -1
  28. package/src/app.js +36 -0
  29. package/src/lib/architecture-sync.js +171 -0
  30. package/src/lib/arn-detection.js +22 -0
  31. package/src/lib/bootstrap-command-handler.js +120 -0
  32. package/src/lib/cli-handler.js +3 -3
  33. package/src/lib/config-manager.js +47 -1
  34. package/src/lib/configuration-manager.js +2 -2
  35. package/src/lib/cross-cutting-checker.js +460 -0
  36. package/src/lib/deployment-entry-schema.js +1 -2
  37. package/src/lib/dry-run-validator.js +78 -0
  38. package/src/lib/generation-validator.js +102 -0
  39. package/src/lib/mcp-validator-config.js +89 -0
  40. package/src/lib/payload-builder.js +153 -0
  41. package/src/lib/prompt-runner.js +866 -149
  42. package/src/lib/prompts.js +2 -2
  43. package/src/lib/registry-command-handler.js +236 -0
  44. package/src/lib/registry-loader.js +5 -5
  45. package/src/lib/schema-sync.js +203 -0
  46. package/src/lib/schema-validation-engine.js +195 -0
  47. package/src/lib/secret-classification.js +56 -0
  48. package/src/lib/secrets-command-handler.js +550 -0
  49. package/src/lib/service-model-parser.js +102 -0
  50. package/src/lib/validate-runner.js +216 -0
  51. package/src/lib/validation-report.js +140 -0
  52. package/src/lib/validators/base-validator.js +36 -0
  53. package/src/lib/validators/catalog-validator.js +177 -0
  54. package/src/lib/validators/enum-validator.js +120 -0
  55. package/src/lib/validators/required-field-validator.js +150 -0
  56. package/src/lib/validators/type-validator.js +313 -0
  57. package/src/prompt-adapter.js +3 -2
  58. package/templates/Dockerfile +1 -1
  59. package/templates/do/build +37 -5
  60. package/templates/do/config +15 -3
  61. package/templates/do/deploy +60 -5
  62. package/templates/do/logs +18 -3
  63. package/templates/do/run +15 -1
  64. package/templates/do/validate +61 -0
  65. package/servers/instance-recommender/LICENSE +0 -202
  66. package/servers/instance-recommender/index.js +0 -284
  67. package/servers/instance-recommender/manifest.json +0 -16
  68. package/servers/instance-recommender/package.json +0 -15
  69. /package/servers/{model-picker → lib}/catalogs/jumpstart-public.json +0 -0
  70. /package/servers/{region-picker → lib}/catalogs/regions.json +0 -0
  71. /package/servers/{base-image-picker → lib}/catalogs/triton-backends.json +0 -0
  72. /package/servers/{base-image-picker → lib}/catalogs/triton.json +0 -0
@@ -0,0 +1,216 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Validation runner — the Node.js module invoked by do/validate.
6
+ * Loads config, constructs payloads, runs SchemaValidationEngine, and prints a report.
7
+ *
8
+ * - Prints structured report (text format by default)
9
+ * - Exits with code 1 if errors found, 0 if clean
10
+ * - Includes service model version date and fields validated count on success
11
+ * - Supports --smart flag for future MCP validator integration
12
+ *
13
+ * Requirements: 9.2, 9.3, 9.4
14
+ */
15
+
16
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import PayloadBuilder from './payload-builder.js';
20
+ import SchemaValidationEngine from './schema-validation-engine.js';
21
+ import ServiceModelParser from './service-model-parser.js';
22
+ import CrossCuttingChecker from './cross-cutting-checker.js';
23
+ import HuggingFaceClient from './huggingface-client.js';
24
+ import { getRegistryPath, loadManifest } from './schema-sync.js';
25
+
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = path.dirname(__filename);
28
+
29
+ /**
30
+ * Parse a do/config shell file into a key-value object.
31
+ * Extracts lines matching: export KEY="value" or export KEY=value
32
+ *
33
+ * @param {string} configPath - Path to the do/config file
34
+ * @returns {Object} Parsed configuration values
35
+ */
36
+ export function parseDoConfig(configPath) {
37
+ if (!existsSync(configPath)) {
38
+ return null;
39
+ }
40
+
41
+ const content = readFileSync(configPath, 'utf8');
42
+ const config = {};
43
+
44
+ for (const line of content.split('\n')) {
45
+ const match = line.match(/^export\s+([A-Z_][A-Z0-9_]*)=["']?([^"'\n]*)["']?/);
46
+ if (match) {
47
+ const [, key, value] = match;
48
+ config[key] = value;
49
+ }
50
+ }
51
+
52
+ return config;
53
+ }
54
+
55
+ /**
56
+ * Run the full validation pipeline.
57
+ *
58
+ * @param {Object} options
59
+ * @param {string} [options.configDir] - Path to the do/ directory containing config
60
+ * @param {string} [options.format] - Output format: 'text' (default) or 'json'
61
+ * @param {boolean} [options.smart] - Enable smart-mode validators
62
+ * @param {string} [options.registryPath] - Override schema registry path
63
+ * @param {Object} [options.config] - Pre-parsed config (overrides configDir loading)
64
+ * @returns {Promise<number>} Exit code (0 = pass, 1 = fail, 2 = cannot run)
65
+ */
66
+ export async function run(options = {}) {
67
+ const format = options.format || 'text';
68
+ const smart = options.smart || false;
69
+ const registryPath = options.registryPath || getRegistryPath();
70
+
71
+ // Check schema registry exists
72
+ if (!existsSync(registryPath) || !existsSync(path.join(registryPath, 'manifest.json'))) {
73
+ console.log('⚠️ Schema registry not found.');
74
+ console.log(' Run: ml-container-creator bootstrap sync-schemas');
75
+ process.exit(2);
76
+ return 2;
77
+ }
78
+
79
+ // Load config
80
+ let config = options.config;
81
+ if (!config && options.configDir) {
82
+ const configPath = path.join(options.configDir, 'config');
83
+ config = parseDoConfig(configPath);
84
+ if (!config) {
85
+ console.log('❌ Could not load do/config');
86
+ process.exit(2);
87
+ return 2;
88
+ }
89
+ }
90
+
91
+ if (!config) {
92
+ console.log('❌ No configuration provided');
93
+ process.exit(2);
94
+ return 2;
95
+ }
96
+
97
+ const deploymentTarget = config.DEPLOYMENT_TARGET || 'realtime-inference';
98
+
99
+ // Construct payloads
100
+ const builder = new PayloadBuilder();
101
+ const context = builder.build(config, deploymentTarget);
102
+
103
+ // Load and parse service models from registry
104
+ const parser = new ServiceModelParser();
105
+ const serviceModels = [];
106
+ try {
107
+ const entries = readdirSync(registryPath, { withFileTypes: true });
108
+ for (const entry of entries) {
109
+ if (entry.isDirectory()) {
110
+ const modelPath = path.join(registryPath, entry.name, 'service-2.json');
111
+ if (existsSync(modelPath)) {
112
+ const rawModel = JSON.parse(readFileSync(modelPath, 'utf8'));
113
+ serviceModels.push(parser.parse(rawModel));
114
+ }
115
+ }
116
+ }
117
+ } catch {
118
+ console.log('⚠️ Could not load service models from registry');
119
+ process.exit(2);
120
+ return 2;
121
+ }
122
+
123
+ // Run validation engine
124
+ const engine = new SchemaValidationEngine({
125
+ registryPath,
126
+ smartMode: smart,
127
+ serviceModels
128
+ });
129
+
130
+ const report = await engine.validate(context);
131
+
132
+ // Run model architecture compatibility check (Requirement 5.1-5.2)
133
+ if (config.MODEL_NAME) {
134
+ try {
135
+ const catalogPath = path.resolve(__dirname, '../../servers/lib/catalogs/model-servers.json');
136
+ if (existsSync(catalogPath)) {
137
+ const modelServersCatalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
138
+
139
+ // Fetch model's config.json from HuggingFace to get model_type
140
+ const hfClient = new HuggingFaceClient({ timeout: 10000 });
141
+ const modelConfig = await hfClient.fetchModelConfig(config.MODEL_NAME);
142
+ const modelType = modelConfig?.model_type || null;
143
+
144
+ if (modelType) {
145
+ // Extract baseImageVersion from BASE_IMAGE (e.g., "vllm/vllm-openai:v0.10.1" → "v0.10.1")
146
+ const baseImage = config.BASE_IMAGE || '';
147
+ const baseImageVersion = baseImage.includes(':') ? baseImage.split(':').pop() : '';
148
+ // Strip leading 'v' to match catalog's framework_version format (e.g., "v0.10.1" → "0.10.1")
149
+ const frameworkVersion = baseImageVersion.replace(/^v/, '');
150
+
151
+ const modelServer = config.MODEL_SERVER || '';
152
+
153
+ // Build context fields for the architecture checker
154
+ const archContext = {
155
+ config: {
156
+ modelType,
157
+ modelServer,
158
+ baseImageVersion: frameworkVersion
159
+ }
160
+ };
161
+
162
+ const checker = new CrossCuttingChecker();
163
+ const archFindings = checker.checkModelArchitectureCompatibility(archContext, modelServersCatalog);
164
+ for (const finding of archFindings) {
165
+ report.addFinding(finding);
166
+ }
167
+ }
168
+ }
169
+ } catch {
170
+ // Graceful degradation: if architecture check fails, continue without it
171
+ }
172
+ }
173
+
174
+ const summary = report.getSummary();
175
+
176
+ // Load manifest for version info
177
+ const manifest = loadManifest(registryPath);
178
+
179
+ // Output report
180
+ if (format === 'json') {
181
+ report.metadata.serviceModelVersionDate = manifest?.lastSynced || null;
182
+ const output = report.toJSON();
183
+ console.log(JSON.stringify(output, null, 2));
184
+ } else {
185
+ // Print static results immediately
186
+ const text = report.toText();
187
+ console.log(text);
188
+
189
+ // On success, print version info
190
+ if (summary.errors === 0) {
191
+ const versionDate = manifest?.lastSynced
192
+ ? new Date(manifest.lastSynced).toISOString().split('T')[0]
193
+ : 'unknown';
194
+ console.log('');
195
+ console.log('✅ Validation passed');
196
+ console.log(` Service model version: ${versionDate}`);
197
+ console.log(` Fields validated: ${summary.fieldsValidated}`);
198
+ }
199
+
200
+ // If smart mode and results are streaming, display them after static
201
+ if (smart && summary.advisory > 0) {
202
+ console.log('');
203
+ console.log('── Smart-mode findings ──');
204
+ for (const finding of report.advisoryFindings) {
205
+ console.log(` ℹ ${finding.fieldPath || finding.operation}: ${finding.remediationHint || ''}`);
206
+ }
207
+ }
208
+ }
209
+
210
+ // Exit code
211
+ const exitCode = summary.errors > 0 ? 1 : 0;
212
+ process.exit(exitCode);
213
+ return exitCode;
214
+ }
215
+
216
+ export default { run, parseDoConfig };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Structured validation report that categorizes findings by severity and source.
3
+ * Supports text (color-coded) and JSON output formats.
4
+ *
5
+ * Requirements: 11.1, 11.2, 11.3, 11.4, 11.5
6
+ */
7
+ export default class ValidationReport {
8
+ constructor() {
9
+ this.schemaErrors = [];
10
+ this.crossCuttingErrors = [];
11
+ this.advisoryFindings = [];
12
+ this.warnings = [];
13
+ this.metadata = {};
14
+ }
15
+
16
+ /**
17
+ * Add a finding to the appropriate category based on finding.source.
18
+ * Smart-mode findings are labeled advisory unless confidence is 'definitive' and severity is 'error'.
19
+ * @param {Object} finding - A Finding object with source, severity, confidence, etc.
20
+ */
21
+ addFinding(finding) {
22
+ const source = finding.source || '';
23
+
24
+ if (source === 'cross-cutting') {
25
+ // Cross-cutting findings with medium/low confidence are advisory, not errors
26
+ if (finding.confidence === 'medium' || finding.confidence === 'low') {
27
+ this.advisoryFindings.push(finding);
28
+ } else if (finding.severity === 'warning') {
29
+ this.warnings.push(finding);
30
+ } else {
31
+ this.crossCuttingErrors.push(finding);
32
+ }
33
+ } else if (source === 'smart-mode' || source.startsWith('smart:')) {
34
+ // Smart-mode findings are advisory UNLESS confidence is definitive AND severity is error
35
+ if (finding.confidence === 'definitive' && finding.severity === 'error') {
36
+ this.schemaErrors.push(finding);
37
+ } else {
38
+ this.advisoryFindings.push(finding);
39
+ }
40
+ } else if (finding.confidence === 'medium' || finding.confidence === 'low') {
41
+ this.advisoryFindings.push(finding);
42
+ } else if (finding.severity === 'warning') {
43
+ this.warnings.push(finding);
44
+ } else {
45
+ this.schemaErrors.push(finding);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Render report as formatted text with color-coded severity grouping by operation.
51
+ * @returns {string}
52
+ */
53
+ toText() {
54
+ const lines = [];
55
+
56
+ const groupByOperation = (findings) => {
57
+ const groups = {};
58
+ for (const f of findings) {
59
+ const key = f.operation || 'general';
60
+ if (!groups[key]) groups[key] = [];
61
+ groups[key].push(f);
62
+ }
63
+ return groups;
64
+ };
65
+
66
+ if (this.schemaErrors.length > 0) {
67
+ lines.push('\x1b[31m── Schema Errors ──\x1b[0m');
68
+ const groups = groupByOperation(this.schemaErrors);
69
+ for (const [op, findings] of Object.entries(groups)) {
70
+ lines.push(` ${op}:`);
71
+ for (const f of findings) {
72
+ lines.push(` \x1b[31m✗\x1b[0m ${f.fieldPath}: ${f.invalidValue} (${f.remediationHint || ''})`);
73
+ }
74
+ }
75
+ }
76
+
77
+ if (this.crossCuttingErrors.length > 0) {
78
+ lines.push('\x1b[31m── Cross-Cutting Errors ──\x1b[0m');
79
+ const groups = groupByOperation(this.crossCuttingErrors);
80
+ for (const [op, findings] of Object.entries(groups)) {
81
+ lines.push(` ${op}:`);
82
+ for (const f of findings) {
83
+ lines.push(` \x1b[31m✗\x1b[0m ${f.fieldPath}: ${f.remediationHint || ''}`);
84
+ }
85
+ }
86
+ }
87
+
88
+ if (this.advisoryFindings.length > 0) {
89
+ lines.push('\x1b[36m── Advisory Findings ──\x1b[0m');
90
+ const groups = groupByOperation(this.advisoryFindings);
91
+ for (const [op, findings] of Object.entries(groups)) {
92
+ lines.push(` ${op}:`);
93
+ for (const f of findings) {
94
+ lines.push(` \x1b[36mℹ\x1b[0m ${f.fieldPath}: ${f.remediationHint || ''}`);
95
+ }
96
+ }
97
+ }
98
+
99
+ if (this.warnings.length > 0) {
100
+ lines.push('\x1b[33m── Warnings ──\x1b[0m');
101
+ for (const f of this.warnings) {
102
+ lines.push(` \x1b[33m⚠\x1b[0m ${f.fieldPath || f.operation || ''}: ${f.remediationHint || ''}`);
103
+ }
104
+ }
105
+
106
+ const summary = this.getSummary();
107
+ lines.push('');
108
+ lines.push(`Summary: ${summary.errors} error(s), ${summary.warnings} warning(s), ${summary.advisory} advisory, ${summary.fieldsValidated} fields validated`);
109
+
110
+ return lines.join('\n');
111
+ }
112
+
113
+ /**
114
+ * Render report as JSON with full structured object.
115
+ * @returns {Object}
116
+ */
117
+ toJSON() {
118
+ return {
119
+ schemaErrors: this.schemaErrors,
120
+ crossCuttingErrors: this.crossCuttingErrors,
121
+ advisoryFindings: this.advisoryFindings,
122
+ warnings: this.warnings,
123
+ metadata: this.metadata,
124
+ summary: this.getSummary()
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Get summary counts.
130
+ * @returns {{ errors: number, warnings: number, advisory: number, fieldsValidated: number }}
131
+ */
132
+ getSummary() {
133
+ return {
134
+ errors: this.schemaErrors.length + this.crossCuttingErrors.length,
135
+ warnings: this.warnings.length,
136
+ advisory: this.advisoryFindings.length,
137
+ fieldsValidated: this.metadata.fieldsValidated || 0
138
+ };
139
+ }
140
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Base class for all validation plugins.
3
+ * Custom validators extend this class and implement the validate method.
4
+ *
5
+ * Requirements: 12.1, 12.3, 12.6
6
+ */
7
+ export default class BaseValidator {
8
+ /**
9
+ * Plugin name for source attribution in findings.
10
+ * @type {string}
11
+ */
12
+ get name() {
13
+ return 'base';
14
+ }
15
+
16
+ /**
17
+ * When this validator runs: 'static', 'smart', or 'both'.
18
+ * Static-only plugins run always; smart-only plugins run only when --smart is passed.
19
+ * @type {'static'|'smart'|'both'}
20
+ */
21
+ get mode() {
22
+ return 'static';
23
+ }
24
+
25
+ /**
26
+ * Validate the context and return findings.
27
+ * @param {Object} context - Full validation context (ValidationContext)
28
+ * @param {Object} options
29
+ * @param {Array} options.priorFindings - Findings from earlier validators
30
+ * @param {Array} options.serviceModels - Parsed service models
31
+ * @returns {Promise<Array>} Array of Finding objects
32
+ */
33
+ async validate(_context, _options) {
34
+ return [];
35
+ }
36
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Catalog enum validator.
3
+ * Validates that catalog entries (e.g., model-servers.json) contain valid
4
+ * enum values according to the AWS service model.
5
+ *
6
+ * Unlike other validators that check API payloads, this validator reads
7
+ * catalog files and validates their fields against the service model enums.
8
+ *
9
+ * Requirements: 14.1, 14.2, 14.3
10
+ */
11
+ import BaseValidator from './base-validator.js';
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import { resolve, dirname } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+
19
+ const DEFAULT_CATALOG_PATH = resolve(__dirname, '../../../servers/lib/catalogs/model-servers.json');
20
+
21
+ /**
22
+ * Map of catalog field names to their corresponding SageMaker service model shape names.
23
+ */
24
+ const CATALOG_ENUM_FIELDS = [
25
+ { field: 'inferenceAmiVersion', shapeName: 'InferenceAmiVersion' }
26
+ ];
27
+
28
+ export default class CatalogValidator extends BaseValidator {
29
+ get name() {
30
+ return 'catalog';
31
+ }
32
+
33
+ get mode() {
34
+ return 'static';
35
+ }
36
+
37
+ /**
38
+ * Validate catalog enum fields against the service model.
39
+ * @param {Object} context - ValidationContext (may be empty for catalog-only validation)
40
+ * @param {Object} options
41
+ * @param {Array} options.serviceModels - Parsed ServiceModelIndex objects
42
+ * @param {string} [options.catalogPath] - Path to model-servers.json (defaults to well-known location)
43
+ * @param {Object} [options.catalogData] - Pre-loaded catalog data (for testing)
44
+ * @returns {Promise<Array>} Array of Finding objects
45
+ */
46
+ async validate(context, options) {
47
+ const findings = [];
48
+ const serviceModels = options.serviceModels || [];
49
+
50
+ if (serviceModels.length === 0) {
51
+ return findings;
52
+ }
53
+
54
+ // Load catalog data
55
+ let catalogData;
56
+ let catalogFilePath;
57
+
58
+ if (options.catalogData) {
59
+ catalogData = options.catalogData;
60
+ catalogFilePath = options.catalogPath || 'servers/lib/catalogs/model-servers.json';
61
+ } else {
62
+ catalogFilePath = options.catalogPath || DEFAULT_CATALOG_PATH;
63
+
64
+ if (!existsSync(catalogFilePath)) {
65
+ return findings;
66
+ }
67
+
68
+ try {
69
+ catalogData = JSON.parse(readFileSync(catalogFilePath, 'utf8'));
70
+ } catch {
71
+ return findings;
72
+ }
73
+ }
74
+
75
+ // Build enum lookup from service models
76
+ const enumMap = this._buildEnumMap(serviceModels);
77
+
78
+ // Validate each server group and its entries
79
+ for (const [serverKey, entries] of Object.entries(catalogData)) {
80
+ if (!Array.isArray(entries)) continue;
81
+
82
+ for (let i = 0; i < entries.length; i++) {
83
+ const entry = entries[i];
84
+ const entryKey = `${serverKey}[${i}]`;
85
+ const entryLabel = entry.tag || entry.image || `index ${i}`;
86
+
87
+ this._validateEntry(
88
+ entry, entryKey, entryLabel, catalogFilePath, enumMap, findings
89
+ );
90
+ }
91
+ }
92
+
93
+ return findings;
94
+ }
95
+
96
+ /**
97
+ * Build a map of field names to their valid enum values from service models.
98
+ * @param {Array} serviceModels - Parsed ServiceModelIndex objects
99
+ * @returns {Map<string, string[]>} field name → valid enum values
100
+ */
101
+ _buildEnumMap(serviceModels) {
102
+ const enumMap = new Map();
103
+
104
+ for (const { field, shapeName } of CATALOG_ENUM_FIELDS) {
105
+ for (const model of serviceModels) {
106
+ const shape = model.shapes.get(shapeName);
107
+ if (shape && shape.enum && shape.enum.length > 0) {
108
+ enumMap.set(field, [...shape.enum]);
109
+ break;
110
+ }
111
+ }
112
+ }
113
+
114
+ return enumMap;
115
+ }
116
+
117
+ /**
118
+ * Validate a single catalog entry's enum fields.
119
+ * @param {Object} entry - Catalog entry object
120
+ * @param {string} entryKey - Key identifying the entry (e.g., "vllm[0]")
121
+ * @param {string} entryLabel - Human-readable label for the entry
122
+ * @param {string} catalogFilePath - Path to the catalog file
123
+ * @param {Map<string, string[]>} enumMap - field name → valid enum values
124
+ * @param {Array} findings - Accumulator for findings
125
+ */
126
+ _validateEntry(entry, entryKey, entryLabel, catalogFilePath, enumMap, findings) {
127
+ // Check top-level fields
128
+ for (const [field, validValues] of enumMap) {
129
+ if (entry[field] !== undefined) {
130
+ this._checkEnumValue(
131
+ entry[field], field, entryKey, catalogFilePath, validValues, findings
132
+ );
133
+ }
134
+ }
135
+
136
+ // Check nested defaults object
137
+ if (entry.defaults && typeof entry.defaults === 'object') {
138
+ for (const [field, validValues] of enumMap) {
139
+ if (entry.defaults[field] !== undefined) {
140
+ this._checkEnumValue(
141
+ entry.defaults[field], field, entryKey, catalogFilePath, validValues, findings
142
+ );
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Check a single enum value and add a finding if invalid.
150
+ * @param {string} value - The value to check
151
+ * @param {string} fieldName - The field name
152
+ * @param {string} entryKey - The entry key (e.g., "vllm[0]")
153
+ * @param {string} catalogFilePath - Path to the catalog file
154
+ * @param {string[]} validValues - Array of valid enum values
155
+ * @param {Array} findings - Accumulator for findings
156
+ */
157
+ _checkEnumValue(value, fieldName, entryKey, catalogFilePath, validValues, findings) {
158
+ if (typeof value !== 'string') return;
159
+
160
+ if (!validValues.includes(value)) {
161
+ findings.push({
162
+ service: 'sagemaker',
163
+ operation: 'catalog-validation',
164
+ fieldPath: `${entryKey}.${fieldName}`,
165
+ invalidValue: value,
166
+ constraint: { type: 'enum', values: validValues },
167
+ severity: 'error',
168
+ confidence: 'definitive',
169
+ source: this.name,
170
+ catalogFile: catalogFilePath,
171
+ entryKey,
172
+ fieldName,
173
+ remediationHint: `Value "${value}" is not a valid ${fieldName}. Allowed values: ${validValues.join(', ')}. Run \`bootstrap sync-schemas\` to update the enum set.`
174
+ });
175
+ }
176
+ }
177
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Enum constraint validator.
3
+ * Validates that payload field values are within the allowed enum set
4
+ * defined in the AWS service model.
5
+ *
6
+ * Requirements: 4.1, 4.2, 4.3, 4.5
7
+ */
8
+ import BaseValidator from './base-validator.js';
9
+
10
+ export default class EnumValidator extends BaseValidator {
11
+ get name() {
12
+ return 'enum';
13
+ }
14
+
15
+ get mode() {
16
+ return 'static';
17
+ }
18
+
19
+ /**
20
+ * Validate enum constraints for all payload fields.
21
+ * @param {Object} context - ValidationContext from PayloadBuilder
22
+ * @param {Object} options
23
+ * @param {Array} options.serviceModels - Parsed ServiceModelIndex objects
24
+ * @param {Array} options.priorFindings - Findings from earlier validators
25
+ * @returns {Promise<Array>} Array of Finding objects
26
+ */
27
+ async validate(context, options) {
28
+ const findings = [];
29
+ const serviceModels = options.serviceModels || [];
30
+
31
+ for (const [operationKey, payload] of Object.entries(context.payloads || {})) {
32
+ const [service, operation] = operationKey.split(':');
33
+
34
+ for (const model of serviceModels) {
35
+ const op = model.operations.get(operation);
36
+ if (!op || !op.input) continue;
37
+
38
+ const inputShape = model.shapes.get(op.input);
39
+ if (!inputShape || inputShape.type !== 'structure') continue;
40
+
41
+ this._validateStructure(
42
+ payload, inputShape, model, service, operation, '', findings
43
+ );
44
+ }
45
+ }
46
+
47
+ return findings;
48
+ }
49
+
50
+ /**
51
+ * Recursively validate enum constraints in a structure.
52
+ * @param {Object} payload - The payload object to validate
53
+ * @param {Object} shape - The structure shape definition
54
+ * @param {Object} model - The ServiceModelIndex
55
+ * @param {string} service - Service name
56
+ * @param {string} operation - Operation name
57
+ * @param {string} parentPath - Dot-notation path prefix
58
+ * @param {Array} findings - Accumulator for findings
59
+ */
60
+ _validateStructure(payload, shape, model, service, operation, parentPath, findings) {
61
+ if (!payload || typeof payload !== 'object' || !shape.members) return;
62
+
63
+ for (const [fieldName, value] of Object.entries(payload)) {
64
+ const memberDef = shape.members.get
65
+ ? shape.members.get(fieldName)
66
+ : shape.members[fieldName];
67
+ if (!memberDef) continue;
68
+
69
+ const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
70
+ const fieldShape = model.shapes.get(memberDef.shape);
71
+ if (!fieldShape) continue;
72
+
73
+ if (fieldShape.type === 'string' && fieldShape.enum && fieldShape.enum.length > 0) {
74
+ if (typeof value === 'string' && !fieldShape.enum.includes(value)) {
75
+ findings.push({
76
+ service,
77
+ operation,
78
+ fieldPath,
79
+ invalidValue: value,
80
+ constraint: { type: 'enum', values: [...fieldShape.enum] },
81
+ severity: 'error',
82
+ confidence: 'definitive',
83
+ source: this.name,
84
+ remediationHint: `Value "${value}" is not valid. Allowed values: ${fieldShape.enum.join(', ')}. Run \`bootstrap sync-schemas\` to update the enum set.`
85
+ });
86
+ }
87
+ } else if (fieldShape.type === 'structure') {
88
+ this._validateStructure(
89
+ value, fieldShape, model, service, operation, fieldPath, findings
90
+ );
91
+ } else if (fieldShape.type === 'list' && Array.isArray(value) && fieldShape.member) {
92
+ const elementShape = model.shapes.get(fieldShape.member.shape);
93
+ if (elementShape && elementShape.type === 'structure') {
94
+ for (let i = 0; i < value.length; i++) {
95
+ this._validateStructure(
96
+ value[i], elementShape, model, service, operation,
97
+ `${fieldPath}[${i}]`, findings
98
+ );
99
+ }
100
+ } else if (elementShape && elementShape.type === 'string' && elementShape.enum) {
101
+ for (let i = 0; i < value.length; i++) {
102
+ if (typeof value[i] === 'string' && !elementShape.enum.includes(value[i])) {
103
+ findings.push({
104
+ service,
105
+ operation,
106
+ fieldPath: `${fieldPath}[${i}]`,
107
+ invalidValue: value[i],
108
+ constraint: { type: 'enum', values: [...elementShape.enum] },
109
+ severity: 'error',
110
+ confidence: 'definitive',
111
+ source: this.name,
112
+ remediationHint: `Value "${value[i]}" is not valid. Allowed values: ${elementShape.enum.join(', ')}. Run \`bootstrap sync-schemas\` to update the enum set.`
113
+ });
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }