@aws/ml-container-creator 0.2.5 → 0.2.6
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/bin/cli.js +7 -2
- package/package.json +7 -8
- package/servers/base-image-picker/index.js +3 -3
- package/servers/base-image-picker/manifest.json +4 -2
- package/servers/instance-sizer/index.js +561 -0
- package/servers/instance-sizer/lib/instance-ranker.js +245 -0
- package/servers/instance-sizer/lib/model-resolver.js +265 -0
- package/servers/instance-sizer/lib/vram-estimator.js +177 -0
- package/servers/instance-sizer/manifest.json +17 -0
- package/servers/instance-sizer/package.json +15 -0
- package/servers/{instance-recommender → lib}/catalogs/instances.json +136 -34
- package/servers/{base-image-picker → lib}/catalogs/model-servers.json +19 -249
- package/servers/lib/catalogs/model-sizes.json +131 -0
- package/servers/lib/catalogs/models.json +602 -0
- package/servers/{model-picker → lib}/catalogs/popular-diffusors.json +32 -10
- package/servers/{model-picker → lib}/catalogs/popular-transformers.json +59 -26
- package/servers/{base-image-picker → lib}/catalogs/python-slim.json +12 -12
- package/servers/lib/schemas/image-catalog.schema.json +0 -12
- package/servers/lib/schemas/instances.schema.json +29 -0
- package/servers/lib/schemas/model-catalog.schema.json +12 -10
- package/servers/lib/schemas/unified-model-catalog.schema.json +129 -0
- package/servers/model-picker/index.js +2 -3
- package/servers/model-picker/manifest.json +2 -3
- package/servers/region-picker/index.js +1 -1
- package/servers/region-picker/manifest.json +1 -1
- package/src/app.js +17 -0
- package/src/lib/bootstrap-command-handler.js +38 -0
- package/src/lib/cli-handler.js +3 -3
- package/src/lib/config-manager.js +4 -1
- package/src/lib/configuration-manager.js +2 -2
- package/src/lib/cross-cutting-checker.js +341 -0
- package/src/lib/dry-run-validator.js +78 -0
- package/src/lib/generation-validator.js +102 -0
- package/src/lib/mcp-validator-config.js +89 -0
- package/src/lib/payload-builder.js +153 -0
- package/src/lib/prompt-runner.js +445 -135
- package/src/lib/prompts.js +1 -1
- package/src/lib/registry-loader.js +5 -5
- package/src/lib/schema-sync.js +203 -0
- package/src/lib/schema-validation-engine.js +195 -0
- package/src/lib/service-model-parser.js +102 -0
- package/src/lib/validate-runner.js +167 -0
- package/src/lib/validation-report.js +133 -0
- package/src/lib/validators/base-validator.js +36 -0
- package/src/lib/validators/catalog-validator.js +177 -0
- package/src/lib/validators/enum-validator.js +120 -0
- package/src/lib/validators/required-field-validator.js +150 -0
- package/src/lib/validators/type-validator.js +313 -0
- package/templates/Dockerfile +1 -1
- package/templates/do/build +15 -5
- package/templates/do/run +5 -1
- package/templates/do/validate +61 -0
- package/servers/instance-recommender/LICENSE +0 -202
- package/servers/instance-recommender/index.js +0 -284
- package/servers/instance-recommender/manifest.json +0 -16
- package/servers/instance-recommender/package.json +0 -15
- /package/servers/{model-picker → lib}/catalogs/jumpstart-public.json +0 -0
- /package/servers/{region-picker → lib}/catalogs/regions.json +0 -0
- /package/servers/{base-image-picker → lib}/catalogs/triton-backends.json +0 -0
- /package/servers/{base-image-picker → lib}/catalogs/triton.json +0 -0
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
this.crossCuttingErrors.push(finding);
|
|
26
|
+
} else if (source === 'smart-mode' || source.startsWith('smart:')) {
|
|
27
|
+
// Smart-mode findings are advisory UNLESS confidence is definitive AND severity is error
|
|
28
|
+
if (finding.confidence === 'definitive' && finding.severity === 'error') {
|
|
29
|
+
this.schemaErrors.push(finding);
|
|
30
|
+
} else {
|
|
31
|
+
this.advisoryFindings.push(finding);
|
|
32
|
+
}
|
|
33
|
+
} else if (finding.confidence === 'medium' || finding.confidence === 'low') {
|
|
34
|
+
this.advisoryFindings.push(finding);
|
|
35
|
+
} else if (finding.severity === 'warning') {
|
|
36
|
+
this.warnings.push(finding);
|
|
37
|
+
} else {
|
|
38
|
+
this.schemaErrors.push(finding);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render report as formatted text with color-coded severity grouping by operation.
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
toText() {
|
|
47
|
+
const lines = [];
|
|
48
|
+
|
|
49
|
+
const groupByOperation = (findings) => {
|
|
50
|
+
const groups = {};
|
|
51
|
+
for (const f of findings) {
|
|
52
|
+
const key = f.operation || 'general';
|
|
53
|
+
if (!groups[key]) groups[key] = [];
|
|
54
|
+
groups[key].push(f);
|
|
55
|
+
}
|
|
56
|
+
return groups;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (this.schemaErrors.length > 0) {
|
|
60
|
+
lines.push('\x1b[31m── Schema Errors ──\x1b[0m');
|
|
61
|
+
const groups = groupByOperation(this.schemaErrors);
|
|
62
|
+
for (const [op, findings] of Object.entries(groups)) {
|
|
63
|
+
lines.push(` ${op}:`);
|
|
64
|
+
for (const f of findings) {
|
|
65
|
+
lines.push(` \x1b[31m✗\x1b[0m ${f.fieldPath}: ${f.invalidValue} (${f.remediationHint || ''})`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (this.crossCuttingErrors.length > 0) {
|
|
71
|
+
lines.push('\x1b[31m── Cross-Cutting Errors ──\x1b[0m');
|
|
72
|
+
const groups = groupByOperation(this.crossCuttingErrors);
|
|
73
|
+
for (const [op, findings] of Object.entries(groups)) {
|
|
74
|
+
lines.push(` ${op}:`);
|
|
75
|
+
for (const f of findings) {
|
|
76
|
+
lines.push(` \x1b[31m✗\x1b[0m ${f.fieldPath}: ${f.remediationHint || ''}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.advisoryFindings.length > 0) {
|
|
82
|
+
lines.push('\x1b[36m── Advisory Findings ──\x1b[0m');
|
|
83
|
+
const groups = groupByOperation(this.advisoryFindings);
|
|
84
|
+
for (const [op, findings] of Object.entries(groups)) {
|
|
85
|
+
lines.push(` ${op}:`);
|
|
86
|
+
for (const f of findings) {
|
|
87
|
+
lines.push(` \x1b[36mℹ\x1b[0m ${f.fieldPath}: ${f.remediationHint || ''}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.warnings.length > 0) {
|
|
93
|
+
lines.push('\x1b[33m── Warnings ──\x1b[0m');
|
|
94
|
+
for (const f of this.warnings) {
|
|
95
|
+
lines.push(` \x1b[33m⚠\x1b[0m ${f.fieldPath || f.operation || ''}: ${f.remediationHint || ''}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const summary = this.getSummary();
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push(`Summary: ${summary.errors} error(s), ${summary.warnings} warning(s), ${summary.advisory} advisory, ${summary.fieldsValidated} fields validated`);
|
|
102
|
+
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Render report as JSON with full structured object.
|
|
108
|
+
* @returns {Object}
|
|
109
|
+
*/
|
|
110
|
+
toJSON() {
|
|
111
|
+
return {
|
|
112
|
+
schemaErrors: this.schemaErrors,
|
|
113
|
+
crossCuttingErrors: this.crossCuttingErrors,
|
|
114
|
+
advisoryFindings: this.advisoryFindings,
|
|
115
|
+
warnings: this.warnings,
|
|
116
|
+
metadata: this.metadata,
|
|
117
|
+
summary: this.getSummary()
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get summary counts.
|
|
123
|
+
* @returns {{ errors: number, warnings: number, advisory: number, fieldsValidated: number }}
|
|
124
|
+
*/
|
|
125
|
+
getSummary() {
|
|
126
|
+
return {
|
|
127
|
+
errors: this.schemaErrors.length + this.crossCuttingErrors.length,
|
|
128
|
+
warnings: this.warnings.length,
|
|
129
|
+
advisory: this.advisoryFindings.length,
|
|
130
|
+
fieldsValidated: this.metadata.fieldsValidated || 0
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Required field validator.
|
|
3
|
+
* Validates that all required fields in an operation's input shape
|
|
4
|
+
* are present and non-empty in the payload.
|
|
5
|
+
*
|
|
6
|
+
* Requirements: 5.1, 5.2, 5.3
|
|
7
|
+
*/
|
|
8
|
+
import BaseValidator from './base-validator.js';
|
|
9
|
+
|
|
10
|
+
export default class RequiredFieldValidator extends BaseValidator {
|
|
11
|
+
get name() {
|
|
12
|
+
return 'required-field';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get mode() {
|
|
16
|
+
return 'static';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate required field presence for all payload operations.
|
|
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._validateRequiredFields(
|
|
42
|
+
payload, inputShape, model, service, operation, '', findings
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return findings;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Recursively validate required fields 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
|
+
_validateRequiredFields(payload, shape, model, service, operation, parentPath, findings) {
|
|
61
|
+
if (!shape || shape.type !== 'structure') return;
|
|
62
|
+
|
|
63
|
+
const requiredFields = shape.required || [];
|
|
64
|
+
|
|
65
|
+
for (const fieldName of requiredFields) {
|
|
66
|
+
const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
|
67
|
+
const memberDef = shape.members.get
|
|
68
|
+
? shape.members.get(fieldName)
|
|
69
|
+
: shape.members[fieldName];
|
|
70
|
+
|
|
71
|
+
const value = payload ? payload[fieldName] : undefined;
|
|
72
|
+
|
|
73
|
+
if (value === undefined || value === null || value === '') {
|
|
74
|
+
const description = memberDef && memberDef.documentation
|
|
75
|
+
? memberDef.documentation
|
|
76
|
+
: `Required field for ${operation}`;
|
|
77
|
+
|
|
78
|
+
findings.push({
|
|
79
|
+
service,
|
|
80
|
+
operation,
|
|
81
|
+
fieldPath,
|
|
82
|
+
invalidValue: value === undefined ? 'undefined' : value === null ? 'null' : '(empty string)',
|
|
83
|
+
constraint: { type: 'required', field: fieldName },
|
|
84
|
+
severity: 'error',
|
|
85
|
+
confidence: 'definitive',
|
|
86
|
+
source: this.name,
|
|
87
|
+
remediationHint: `Required field "${fieldName}" is missing or empty in ${operation}. ${description}`
|
|
88
|
+
});
|
|
89
|
+
} else if (typeof value === 'object' && !Array.isArray(value) && memberDef) {
|
|
90
|
+
// Recursively validate nested structures
|
|
91
|
+
const nestedShape = model.shapes.get(memberDef.shape);
|
|
92
|
+
if (nestedShape && nestedShape.type === 'structure') {
|
|
93
|
+
this._validateRequiredFields(
|
|
94
|
+
value, nestedShape, model, service, operation, fieldPath, findings
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Also recursively validate nested structures that are present (even if optional)
|
|
101
|
+
if (payload && typeof payload === 'object' && shape.members) {
|
|
102
|
+
for (const [fieldName, value] of Object.entries(payload)) {
|
|
103
|
+
if (value === null || value === undefined) continue;
|
|
104
|
+
if (typeof value !== 'object' || Array.isArray(value)) continue;
|
|
105
|
+
|
|
106
|
+
const memberDef = shape.members.get
|
|
107
|
+
? shape.members.get(fieldName)
|
|
108
|
+
: shape.members[fieldName];
|
|
109
|
+
if (!memberDef) continue;
|
|
110
|
+
|
|
111
|
+
const nestedShape = model.shapes.get(memberDef.shape);
|
|
112
|
+
if (!nestedShape || nestedShape.type !== 'structure') continue;
|
|
113
|
+
|
|
114
|
+
// Skip if already validated as a required field above
|
|
115
|
+
if (requiredFields.includes(fieldName)) continue;
|
|
116
|
+
|
|
117
|
+
const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
|
118
|
+
this._validateRequiredFields(
|
|
119
|
+
value, nestedShape, model, service, operation, fieldPath, findings
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Recursively validate list elements that are structures
|
|
125
|
+
if (payload && typeof payload === 'object' && shape.members) {
|
|
126
|
+
for (const [fieldName, value] of Object.entries(payload)) {
|
|
127
|
+
if (!Array.isArray(value)) continue;
|
|
128
|
+
|
|
129
|
+
const memberDef = shape.members.get
|
|
130
|
+
? shape.members.get(fieldName)
|
|
131
|
+
: shape.members[fieldName];
|
|
132
|
+
if (!memberDef) continue;
|
|
133
|
+
|
|
134
|
+
const listShape = model.shapes.get(memberDef.shape);
|
|
135
|
+
if (!listShape || listShape.type !== 'list' || !listShape.member) continue;
|
|
136
|
+
|
|
137
|
+
const elementShape = model.shapes.get(listShape.member.shape);
|
|
138
|
+
if (!elementShape || elementShape.type !== 'structure') continue;
|
|
139
|
+
|
|
140
|
+
const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
|
141
|
+
for (let i = 0; i < value.length; i++) {
|
|
142
|
+
this._validateRequiredFields(
|
|
143
|
+
value[i], elementShape, model, service, operation,
|
|
144
|
+
`${fieldPath}[${i}]`, findings
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|