vcluster-yaml-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +292 -0
- package/package.json +52 -0
- package/src/cli-handlers.js +290 -0
- package/src/cli.js +128 -0
- package/src/formatters.js +168 -0
- package/src/github.js +170 -0
- package/src/http-server.js +67 -0
- package/src/index.js +11 -0
- package/src/middleware/auth.js +25 -0
- package/src/schema-validator.js +351 -0
- package/src/server.js +1006 -0
- package/src/snippet-validator.js +370 -0
- package/src/snippet-validator.test.js +366 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic schema validation - validates user config against GitHub schema
|
|
3
|
+
* NO hardcoded schema structures - everything is dynamic
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate user config against vCluster JSON schema
|
|
8
|
+
* @param {Object} userConfig - User's parsed YAML configuration
|
|
9
|
+
* @param {Object} schema - JSON schema from GitHub (chart/values.schema.json)
|
|
10
|
+
* @param {string} version - vCluster version being validated against
|
|
11
|
+
* @returns {Object} Validation result
|
|
12
|
+
*/
|
|
13
|
+
export function validateConfigAgainstSchema(userConfig, schema, version) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
const warnings = [];
|
|
16
|
+
const contextErrors = [];
|
|
17
|
+
|
|
18
|
+
// Extract all paths from user config
|
|
19
|
+
const userPaths = extractAllPaths(userConfig);
|
|
20
|
+
|
|
21
|
+
// Validate each path against the schema
|
|
22
|
+
for (const { path, value } of userPaths) {
|
|
23
|
+
const validation = validatePath(path, value, schema);
|
|
24
|
+
|
|
25
|
+
if (!validation.valid) {
|
|
26
|
+
errors.push({
|
|
27
|
+
path,
|
|
28
|
+
severity: 'error',
|
|
29
|
+
error: validation.error,
|
|
30
|
+
suggestion: validation.suggestion,
|
|
31
|
+
correct_alternatives: validation.alternatives || []
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// If path is valid, check type and enum constraints
|
|
36
|
+
if (validation.valid && validation.schemaNode) {
|
|
37
|
+
const typeCheck = validateType(value, validation.schemaNode, path);
|
|
38
|
+
if (!typeCheck.valid) {
|
|
39
|
+
errors.push({
|
|
40
|
+
path,
|
|
41
|
+
severity: 'error',
|
|
42
|
+
error: typeCheck.error,
|
|
43
|
+
expected_type: typeCheck.expectedType,
|
|
44
|
+
actual_type: typeCheck.actualType,
|
|
45
|
+
fix: typeCheck.fix
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check enum constraints
|
|
50
|
+
if (validation.schemaNode.enum) {
|
|
51
|
+
const enumCheck = validateEnum(value, validation.schemaNode.enum, path);
|
|
52
|
+
if (!enumCheck.valid) {
|
|
53
|
+
errors.push({
|
|
54
|
+
path,
|
|
55
|
+
severity: 'error',
|
|
56
|
+
error: enumCheck.error,
|
|
57
|
+
allowed_values: validation.schemaNode.enum,
|
|
58
|
+
actual_value: value
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check for conflicting configurations
|
|
66
|
+
const conflicts = detectConflicts(userConfig);
|
|
67
|
+
contextErrors.push(...conflicts);
|
|
68
|
+
|
|
69
|
+
// Check required fields
|
|
70
|
+
const missing = checkRequiredFields(userConfig, schema);
|
|
71
|
+
errors.push(...missing);
|
|
72
|
+
|
|
73
|
+
const schema_valid = errors.length === 0;
|
|
74
|
+
const deploy_safe = errors.length === 0 && contextErrors.length === 0;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
syntax_valid: true,
|
|
78
|
+
schema_valid,
|
|
79
|
+
deploy_safe,
|
|
80
|
+
version,
|
|
81
|
+
errors,
|
|
82
|
+
warnings,
|
|
83
|
+
context_errors: contextErrors,
|
|
84
|
+
summary: generateSummary(errors, contextErrors, deploy_safe)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract all paths from user config with values
|
|
90
|
+
*/
|
|
91
|
+
function extractAllPaths(obj, prefix = '') {
|
|
92
|
+
const paths = [];
|
|
93
|
+
|
|
94
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
95
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
96
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
97
|
+
paths.push({ path, value });
|
|
98
|
+
|
|
99
|
+
// Recurse for nested objects
|
|
100
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
101
|
+
paths.push(...extractAllPaths(value, path));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return paths;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validate if a path exists in schema
|
|
111
|
+
*/
|
|
112
|
+
function validatePath(path, value, schema) {
|
|
113
|
+
const parts = path.split('.');
|
|
114
|
+
let current = schema.properties || schema;
|
|
115
|
+
let schemaNode = null;
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < parts.length; i++) {
|
|
118
|
+
const part = parts[i];
|
|
119
|
+
|
|
120
|
+
if (!current || !current[part]) {
|
|
121
|
+
// Path doesn't exist in schema
|
|
122
|
+
return {
|
|
123
|
+
valid: false,
|
|
124
|
+
error: `Path does not exist in schema`,
|
|
125
|
+
suggestion: `Path '${path}' not found in schema`,
|
|
126
|
+
alternatives: findSimilarPaths(path, schema)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
schemaNode = current[part];
|
|
131
|
+
|
|
132
|
+
// Navigate deeper
|
|
133
|
+
if (schemaNode.properties) {
|
|
134
|
+
current = schemaNode.properties;
|
|
135
|
+
} else if (schemaNode.additionalProperties) {
|
|
136
|
+
current = schemaNode.additionalProperties;
|
|
137
|
+
} else if (i < parts.length - 1) {
|
|
138
|
+
// Can't go deeper but we're not at the end
|
|
139
|
+
if (typeof value === 'object' && value !== null) {
|
|
140
|
+
return {
|
|
141
|
+
valid: false,
|
|
142
|
+
error: `Path '${parts.slice(0, i + 1).join('.')}' is a leaf node`,
|
|
143
|
+
suggestion: `Remove nested properties under '${parts.slice(0, i + 1).join('.')}'`
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
valid: true,
|
|
151
|
+
schemaNode
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Find similar paths in schema for suggestions
|
|
157
|
+
*/
|
|
158
|
+
function findSimilarPaths(targetPath, schema, maxSuggestions = 3) {
|
|
159
|
+
const allPaths = extractSchemaPaths(schema);
|
|
160
|
+
const targetParts = targetPath.toLowerCase().split('.');
|
|
161
|
+
const targetLast = targetParts[targetParts.length - 1];
|
|
162
|
+
|
|
163
|
+
const scored = allPaths.map(schemaPath => {
|
|
164
|
+
const parts = schemaPath.toLowerCase().split('.');
|
|
165
|
+
const last = parts[parts.length - 1];
|
|
166
|
+
|
|
167
|
+
let score = 0;
|
|
168
|
+
if (last === targetLast) score += 10;
|
|
169
|
+
if (last.includes(targetLast) || targetLast.includes(last)) score += 5;
|
|
170
|
+
|
|
171
|
+
// Shared prefix
|
|
172
|
+
const sharedPrefix = getSharedPrefix(targetParts, parts);
|
|
173
|
+
score += sharedPrefix * 2;
|
|
174
|
+
|
|
175
|
+
return { path: schemaPath, score };
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return scored
|
|
179
|
+
.filter(s => s.score > 0)
|
|
180
|
+
.sort((a, b) => b.score - a.score)
|
|
181
|
+
.slice(0, maxSuggestions)
|
|
182
|
+
.map(s => s.path);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Extract all paths from schema
|
|
187
|
+
*/
|
|
188
|
+
function extractSchemaPaths(schema, prefix = '') {
|
|
189
|
+
const paths = [];
|
|
190
|
+
const props = schema.properties || schema;
|
|
191
|
+
|
|
192
|
+
if (props && typeof props === 'object') {
|
|
193
|
+
for (const [key, value] of Object.entries(props)) {
|
|
194
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
195
|
+
paths.push(path);
|
|
196
|
+
|
|
197
|
+
if (value && value.properties) {
|
|
198
|
+
paths.push(...extractSchemaPaths(value, path));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return paths;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get shared prefix length
|
|
208
|
+
*/
|
|
209
|
+
function getSharedPrefix(arr1, arr2) {
|
|
210
|
+
let count = 0;
|
|
211
|
+
const minLen = Math.min(arr1.length, arr2.length);
|
|
212
|
+
|
|
213
|
+
for (let i = 0; i < minLen; i++) {
|
|
214
|
+
if (arr1[i] === arr2[i]) count++;
|
|
215
|
+
else break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return count;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Validate value type
|
|
223
|
+
*/
|
|
224
|
+
function validateType(value, schemaNode, path) {
|
|
225
|
+
const expectedType = schemaNode.type;
|
|
226
|
+
const actualType = getValueType(value);
|
|
227
|
+
|
|
228
|
+
if (!expectedType) {
|
|
229
|
+
return { valid: true };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const expectedTypes = Array.isArray(expectedType) ? expectedType : [expectedType];
|
|
233
|
+
|
|
234
|
+
if (!expectedTypes.includes(actualType)) {
|
|
235
|
+
if (value === null && expectedTypes.includes('null')) {
|
|
236
|
+
return { valid: true };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
valid: false,
|
|
241
|
+
error: `Type mismatch`,
|
|
242
|
+
expectedType: expectedTypes.join(' or '),
|
|
243
|
+
actualType,
|
|
244
|
+
fix: `Change value to type: ${expectedTypes.join(' or ')}`
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { valid: true };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get JavaScript type
|
|
253
|
+
*/
|
|
254
|
+
function getValueType(value) {
|
|
255
|
+
if (value === null) return 'null';
|
|
256
|
+
if (Array.isArray(value)) return 'array';
|
|
257
|
+
if (typeof value === 'boolean') return 'boolean';
|
|
258
|
+
if (typeof value === 'number') return 'number';
|
|
259
|
+
if (typeof value === 'string') return 'string';
|
|
260
|
+
if (typeof value === 'object') return 'object';
|
|
261
|
+
return 'unknown';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Validate enum constraints
|
|
266
|
+
*/
|
|
267
|
+
function validateEnum(value, allowedValues, path) {
|
|
268
|
+
if (!allowedValues.includes(value)) {
|
|
269
|
+
return {
|
|
270
|
+
valid: false,
|
|
271
|
+
error: `Value '${value}' not in allowed values: ${allowedValues.join(', ')}`
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { valid: true };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Detect conflicting configurations
|
|
280
|
+
*/
|
|
281
|
+
function detectConflicts(userConfig) {
|
|
282
|
+
const conflicts = [];
|
|
283
|
+
|
|
284
|
+
// Check multiple distros enabled
|
|
285
|
+
if (userConfig.controlPlane?.distro) {
|
|
286
|
+
const distros = userConfig.controlPlane.distro;
|
|
287
|
+
const enabled = [];
|
|
288
|
+
|
|
289
|
+
if (distros.k3s?.enabled === true) enabled.push('k3s');
|
|
290
|
+
if (distros.k8s?.enabled === true) enabled.push('k8s');
|
|
291
|
+
if (distros.k0s?.enabled === true) enabled.push('k0s');
|
|
292
|
+
if (distros.eks?.enabled === true) enabled.push('eks');
|
|
293
|
+
|
|
294
|
+
if (enabled.length > 1) {
|
|
295
|
+
conflicts.push({
|
|
296
|
+
issue: 'Multiple distros enabled',
|
|
297
|
+
severity: 'error',
|
|
298
|
+
conflicting_paths: enabled.map(d => `controlPlane.distro.${d}.enabled: true`),
|
|
299
|
+
error: 'Only one distro can be enabled at a time',
|
|
300
|
+
fix: `Set only one distro to enabled: true. Currently: ${enabled.join(', ')}`
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return conflicts;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Check required fields
|
|
310
|
+
*/
|
|
311
|
+
function checkRequiredFields(userConfig, schema) {
|
|
312
|
+
const errors = [];
|
|
313
|
+
const required = schema.required || [];
|
|
314
|
+
|
|
315
|
+
for (const field of required) {
|
|
316
|
+
if (!(field in userConfig)) {
|
|
317
|
+
errors.push({
|
|
318
|
+
path: field,
|
|
319
|
+
severity: 'error',
|
|
320
|
+
error: `Required field '${field}' is missing`,
|
|
321
|
+
fix: `Add '${field}' to configuration`
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return errors;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Generate summary
|
|
331
|
+
*/
|
|
332
|
+
function generateSummary(errors, contextErrors, deploySafe) {
|
|
333
|
+
const errorCount = errors.length;
|
|
334
|
+
const contextErrorCount = contextErrors.length;
|
|
335
|
+
const totalIssues = errorCount + contextErrorCount;
|
|
336
|
+
|
|
337
|
+
if (deploySafe) {
|
|
338
|
+
return {
|
|
339
|
+
deploy_safe: true,
|
|
340
|
+
message: 'Configuration is valid and safe to deploy',
|
|
341
|
+
total_errors: 0
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
deploy_safe: false,
|
|
347
|
+
message: `Configuration contains ${totalIssues} error(s) and will fail deployment. Fix required before creating vCluster.`,
|
|
348
|
+
total_errors: totalIssues,
|
|
349
|
+
severity: 'error'
|
|
350
|
+
};
|
|
351
|
+
}
|