feat(reporting-governance): schema-validate profile generation
This commit is contained in:
@@ -1,170 +1,65 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import Ajv2020 from 'ajv/dist/2020.js';
|
||||
import YAML from 'yaml';
|
||||
|
||||
const packageRoot = path.resolve(import.meta.dirname, '..', '..');
|
||||
const repoRoot = path.resolve(packageRoot, '..', '..');
|
||||
const schemaPath = '../../../schemas/reporting-governance/deployment-profile.schema.json';
|
||||
const schemaPath = path.resolve(repoRoot, 'schemas', 'reporting-governance', 'deployment-profile.schema.json');
|
||||
const artifactSchemaRefPath = '../../../schemas/reporting-governance/deployment-profile.schema.json';
|
||||
|
||||
function readText(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function countIndent(line) {
|
||||
let count = 0;
|
||||
while (count < line.length && line[count] === ' ') count += 1;
|
||||
return count;
|
||||
function loadDeploymentProfileSchema() {
|
||||
return JSON.parse(readText(schemaPath));
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const value = raw.trim();
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10);
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
const validateDeploymentProfile = ajv.compile(loadDeploymentProfileSchema());
|
||||
|
||||
function parseBlock(lines, startIndex = 0, baseIndent = 0) {
|
||||
const firstMeaningful = lines.slice(startIndex).find((line) => line.trim() !== '');
|
||||
const container = firstMeaningful?.trim().startsWith('- ') ? [] : {};
|
||||
let index = startIndex;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const indent = countIndent(line);
|
||||
if (indent < baseIndent) break;
|
||||
if (indent > baseIndent) {
|
||||
throw new Error(`unexpected indentation at line ${index + 1}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(container)) {
|
||||
if (!trimmed.startsWith('- ')) {
|
||||
throw new Error(`expected array item at line ${index + 1}`);
|
||||
}
|
||||
const itemBody = trimmed.slice(2);
|
||||
if (itemBody === '') {
|
||||
const nested = parseBlock(lines, index + 1, baseIndent + 2);
|
||||
container.push(nested.value);
|
||||
index = nested.nextIndex;
|
||||
continue;
|
||||
}
|
||||
if (itemBody.includes(':')) {
|
||||
const separator = itemBody.indexOf(':');
|
||||
const key = itemBody.slice(0, separator).trim();
|
||||
const remainder = itemBody.slice(separator + 1).trim();
|
||||
const objectItem = {};
|
||||
if (remainder === '') {
|
||||
const nested = parseBlock(lines, index + 1, baseIndent + 4);
|
||||
objectItem[key] = nested.value;
|
||||
container.push(objectItem);
|
||||
index = nested.nextIndex;
|
||||
continue;
|
||||
}
|
||||
objectItem[key] = parseScalar(remainder);
|
||||
container.push(objectItem);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
container.push(parseScalar(itemBody));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const separator = trimmed.indexOf(':');
|
||||
if (separator === -1) {
|
||||
throw new Error(`expected key/value pair at line ${index + 1}`);
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
let remainder = trimmed.slice(separator + 1).trim();
|
||||
|
||||
if (remainder === '>-') {
|
||||
const blockLines = [];
|
||||
index += 1;
|
||||
while (index < lines.length) {
|
||||
const nestedLine = lines[index];
|
||||
const nestedTrimmed = nestedLine.trim();
|
||||
if (nestedTrimmed === '') {
|
||||
blockLines.push('');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const nestedIndent = countIndent(nestedLine);
|
||||
if (nestedIndent <= baseIndent) break;
|
||||
blockLines.push(nestedLine.slice(baseIndent + 2).trim());
|
||||
index += 1;
|
||||
}
|
||||
container[key] = blockLines.join(' ').replace(/\s+/g, ' ').trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (remainder === '') {
|
||||
const nested = parseBlock(lines, index + 1, baseIndent + 2);
|
||||
container[key] = nested.value;
|
||||
index = nested.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
container[key] = parseScalar(remainder);
|
||||
index += 1;
|
||||
function formatAjvErrors(errors = []) {
|
||||
if (!Array.isArray(errors) || errors.length === 0) {
|
||||
return 'schema validation failed';
|
||||
}
|
||||
|
||||
return { value: container, nextIndex: index };
|
||||
return errors.map((error) => {
|
||||
const keyword = error.keyword;
|
||||
const instancePath = error.instancePath || '/';
|
||||
|
||||
if (keyword === 'required') {
|
||||
const missing = error.params?.missingProperty ?? 'unknown';
|
||||
return `${instancePath} missing required property ${missing}`;
|
||||
}
|
||||
|
||||
if (keyword === 'additionalProperties') {
|
||||
const extra = error.params?.additionalProperty ?? 'unknown';
|
||||
return `${instancePath} must not include additional property ${extra}`;
|
||||
}
|
||||
|
||||
if (keyword === 'const') {
|
||||
return `${instancePath} must equal ${JSON.stringify(error.params?.allowedValue)}`;
|
||||
}
|
||||
|
||||
return `${instancePath} ${error.message}`.trim();
|
||||
}).join('; ');
|
||||
}
|
||||
|
||||
export function parseDeploymentProfileYaml(yamlText) {
|
||||
const lines = yamlText.replace(/\r\n/g, '\n').split('\n');
|
||||
return parseBlock(lines, 0, 0).value;
|
||||
return YAML.parse(yamlText);
|
||||
}
|
||||
|
||||
export function validateDeploymentProfileSchema(profile) {
|
||||
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
||||
throw new Error('deployment profile must be an object');
|
||||
}
|
||||
if (profile.apiVersion !== 'reporting-governance/v1alpha1') {
|
||||
throw new Error('deployment profile apiVersion must be reporting-governance/v1alpha1');
|
||||
}
|
||||
if (profile.kind !== 'DeploymentProfile') {
|
||||
throw new Error('deployment profile kind must be DeploymentProfile');
|
||||
}
|
||||
if (typeof profile?.metadata?.id !== 'string' || profile.metadata.id.trim() === '') {
|
||||
throw new Error('deployment profile metadata.id must be a non-empty string');
|
||||
}
|
||||
if (typeof profile?.metadata?.version !== 'string' || profile.metadata.version.trim() === '') {
|
||||
throw new Error('deployment profile metadata.version must be a non-empty string');
|
||||
}
|
||||
if (typeof profile?.metadata?.runtime !== 'string' || profile.metadata.runtime.trim() === '') {
|
||||
throw new Error('deployment profile metadata.runtime must be a non-empty string');
|
||||
}
|
||||
if (typeof profile?.spec?.package?.pluginVersion !== 'string' || profile.spec.package.pluginVersion.trim() === '') {
|
||||
throw new Error('deployment profile spec.package.pluginVersion must be a non-empty string');
|
||||
}
|
||||
if (!profile?.spec?.adapters || typeof profile.spec.adapters !== 'object' || Array.isArray(profile.spec.adapters)) {
|
||||
throw new Error('deployment profile spec.adapters must be an object');
|
||||
}
|
||||
if (!profile?.spec?.notifications || typeof profile.spec.notifications !== 'object' || Array.isArray(profile.spec.notifications)) {
|
||||
throw new Error('deployment profile spec.notifications must be an object');
|
||||
}
|
||||
if (!profile?.spec?.audit || typeof profile.spec.audit !== 'object' || Array.isArray(profile.spec.audit)) {
|
||||
throw new Error('deployment profile spec.audit must be an object');
|
||||
}
|
||||
if (!Array.isArray(profile?.spec?.audit?.requiredArtifacts) || profile.spec.audit.requiredArtifacts.length === 0) {
|
||||
throw new Error('deployment profile spec.audit.requiredArtifacts must be a non-empty array');
|
||||
}
|
||||
if (!profile?.spec?.capability_expectations || typeof profile.spec.capability_expectations !== 'object' || Array.isArray(profile.spec.capability_expectations)) {
|
||||
throw new Error('deployment profile spec.capability_expectations must be an object');
|
||||
}
|
||||
if (!Array.isArray(profile.spec.capability_expectations.required)) {
|
||||
throw new Error('deployment profile spec.capability_expectations.required must be an array');
|
||||
|
||||
if (!validateDeploymentProfile(profile)) {
|
||||
throw new Error(`deployment profile schema validation failed: ${formatAjvErrors(validateDeploymentProfile.errors)}`);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
@@ -174,7 +69,7 @@ export function generateDeploymentProfileArtifact(profile, { sourceProfile } = {
|
||||
const sourceProfilePath = sourceProfile ?? path.join('profiles', `${id}.yaml`);
|
||||
|
||||
return {
|
||||
$schema: schemaPath,
|
||||
$schema: artifactSchemaRefPath,
|
||||
apiVersion: validated.apiVersion,
|
||||
kind: 'DeploymentProfileArtifact',
|
||||
metadata: {
|
||||
|
||||
Reference in New Issue
Block a user