Files
pikasTech-unidesk/scripts/native/cicd/feature-config-schema-warning.mjs
T

410 lines
16 KiB
JavaScript

#!/usr/bin/env node
import { randomBytes } from "node:crypto";
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const cwdRequire = createRequire(path.join(process.cwd(), "package.json"));
const YAML = globalThis.unideskYaml ?? optionalRequire("yaml") ?? optionalRequire("yaml", cwdRequire);
const VALIDATOR_UNAVAILABLE = "FEATURE_CONFIG_VALIDATOR_UNAVAILABLE";
export const FEATURE_CONFIG_SCHEMA_PATH = "config/feature-config.schema.json";
export function validateFeatureConfigSchema(options = {}) {
const repoDir = path.resolve(options.repoDir ?? process.cwd());
const schemaPath = FEATURE_CONFIG_SCHEMA_PATH;
const absoluteSchemaPath = path.join(repoDir, schemaPath);
if (!existsSync(absoluteSchemaPath)) return result("feature-config-schema-missing", "schema file is missing", 1, null);
let schema;
try {
schema = JSON.parse(readFileSync(absoluteSchemaPath, "utf8"));
} catch (error) {
return result("feature-config-schema-invalid", `schema JSON parse failed: ${errorMessage(error)}`, 1, null);
}
const featureErrors = featureDeclarationErrors(schema);
if (featureErrors.invalid.length > 0) return result("feature-config-schema-invalid", featureErrors.invalid[0], featureErrors.invalid.length, null);
if (featureErrors.duplicates.length > 0) return result("feature-config-variable-duplicate", featureErrors.duplicates[0], featureErrors.duplicates.length, null);
let validate;
try {
const Ajv2020 = ajv2020();
const ajv = new Ajv2020({ allErrors: true, strict: true, validateFormats: false });
ajv.addKeyword({ keyword: "x-unidesk-feature", schemaType: "string", valid: true });
validate = ajv.compile(schema);
} catch (error) {
return result(
error?.code === VALIDATOR_UNAVAILABLE ? "feature-config-validator-unavailable" : "feature-config-schema-invalid",
errorMessage(error),
1,
null,
);
}
let candidate;
try {
candidate = options.candidate ?? renderedWorkloadCandidate({
renderedRoot: options.renderedRoot,
propertyNames: Object.keys(schema.properties),
});
} catch (error) {
return result("feature-config-validator-failure", errorMessage(error), 1, null);
}
if (candidate === null || !isRecord(candidate.values) || Object.keys(candidate.values).length === 0) {
return result("feature-config-input-missing", "rendered workload candidate snapshot is missing", 1, candidate?.summary ?? null);
}
if (!validate(candidate.values)) {
const errors = Array.isArray(validate.errors) ? validate.errors : [];
return result("feature-config-value-mismatch", boundedAjvError(errors[0]), errors.length || 1, candidate.summary);
}
return result("feature-config-schema-valid", null, 0, candidate.summary);
}
export function emitFeatureConfigSchemaValidation(options = {}) {
const validation = validateFeatureConfigSchema(options);
process.stderr.write(`${JSON.stringify(validation)}\n`);
return validation;
}
export async function runFeatureConfigSchemaValidation(options = {}) {
let validation;
try {
validation = emitFeatureConfigSchemaValidation(options);
} catch (error) {
validation = result("feature-config-validator-failure", errorMessage(error), 1, null);
process.stderr.write(`${JSON.stringify(validation)}\n`);
}
try {
const endpoint = options.otelEndpoint ?? process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? "";
const timeoutMs = positiveInteger(options.otelTimeoutMs ?? process.env.OTEL_EXPORTER_TIMEOUT_MS);
const samplingRatio = samplingRatioValue(options.otelSamplingRatio ?? process.env.OTEL_TRACES_SAMPLER_ARG);
if (endpoint.length === 0) {
const exportWarning = otelWarning("ENDPOINT_MISSING", null, false, null, timeoutMs);
process.stderr.write(`${JSON.stringify(exportWarning)}\n`);
return { validation, otelPayload: null, otelWarning: exportWarning };
}
if (timeoutMs === null) {
const exportWarning = otelWarning("TIMEOUT_MISSING", null, false, null, null);
process.stderr.write(`${JSON.stringify(exportWarning)}\n`);
return { validation, otelPayload: null, otelWarning: exportWarning };
}
const otelPayload = featureConfigOtelPayload(validation, {
serviceName: options.otelServiceName ?? process.env.OTEL_SERVICE_NAME ?? "unidesk-cicd",
consumer: options.consumer ?? process.env.UNIDESK_PAC_CONSUMER ?? null,
samplingRatio,
traceparent: options.traceparent ?? process.env.TRACEPARENT ?? process.env.traceparent ?? "",
nowMs: options.nowMs,
traceId: options.traceId,
spanId: options.spanId,
});
const exportWarning = await exportFeatureConfigOtel(endpoint, otelPayload, timeoutMs, options.fetchImpl ?? fetch);
if (exportWarning !== null) process.stderr.write(`${JSON.stringify(exportWarning)}\n`);
return { validation, otelPayload, otelWarning: exportWarning };
} catch (error) {
const exportWarning = otelWarning("EXPORT_INTERNAL_FAILURE", null, false, error, positiveInteger(options.otelTimeoutMs ?? process.env.OTEL_EXPORTER_TIMEOUT_MS));
process.stderr.write(`${JSON.stringify(exportWarning)}\n`);
return { validation, otelPayload: null, otelWarning: exportWarning };
}
}
export function featureConfigOtelPayload(validation, options = {}) {
const parent = parseTraceparent(options.traceparent);
const traceId = options.traceId ?? parent.traceId ?? randomBytes(16).toString("hex");
const spanId = options.spanId ?? randomBytes(8).toString("hex");
const nowMs = options.nowMs ?? Date.now();
const startTimeUnixNano = String(BigInt(nowMs) * 1_000_000n);
return {
resourceSpans: [{
resource: { attributes: otelAttributes({
"service.name": options.serviceName ?? "unidesk-cicd",
"unidesk.pac.consumer": options.consumer ?? null,
"unidesk.otel.sampling_ratio": options.samplingRatio ?? null,
"unidesk.values_redacted": true,
}) },
scopeSpans: [{
scope: { name: "unidesk.cicd.feature-config", version: "v1" },
spans: [{
traceId,
spanId,
...(parent.spanId === null ? {} : { parentSpanId: parent.spanId }),
name: "cicd.feature_config.schema",
kind: 1,
startTimeUnixNano,
endTimeUnixNano: startTimeUnixNano,
attributes: otelAttributes({
"feature_config.warning": validation.warning,
"feature_config.blocking": false,
"feature_config.code": validation.code,
"feature_config.schema_path": validation.schemaPath,
"feature_config.error_count": validation.errorCount,
}),
events: [{
timeUnixNano: startTimeUnixNano,
name: "feature-config-schema-validation",
attributes: otelAttributes({
"feature_config.warning": validation.warning,
"feature_config.code": validation.code,
"feature_config.first_error": validation.firstError,
}),
}],
status: { code: 1 },
}],
}],
}],
};
}
async function exportFeatureConfigOtel(endpoint, payload, timeoutMs, fetchImpl) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
});
if (response.ok) return null;
return otelWarning(`HTTP_${response.status}`, response.status, false, null, timeoutMs);
} catch (error) {
return otelWarning(error instanceof Error && error.name === "AbortError" ? "TIMEOUT" : "EXPORT_FAILED", null, error instanceof Error && error.name === "AbortError", error, timeoutMs);
} finally {
clearTimeout(timeout);
}
}
function featureDeclarationErrors(schema) {
if (!isRecord(schema) || schema.type !== "object" || !isRecord(schema.properties)) {
return { invalid: ["schema root must be an object schema with properties"], duplicates: [] };
}
const invalid = [];
const variablesByFeature = new Map();
for (const [propertyName, propertySchema] of Object.entries(schema.properties)) {
if (!isRecord(propertySchema)) {
invalid.push(`property ${propertyName} schema must be an object`);
continue;
}
const feature = propertySchema["x-unidesk-feature"];
if (typeof feature !== "string" || feature.trim().length === 0) {
invalid.push(`property ${propertyName} must declare x-unidesk-feature`);
continue;
}
const variables = variablesByFeature.get(feature.trim()) ?? [];
variables.push(propertyName);
variablesByFeature.set(feature.trim(), variables);
}
const duplicates = [...variablesByFeature.entries()]
.filter(([, variables]) => variables.length > 1)
.map(([feature, variables]) => `feature ${feature} maps to multiple properties: ${variables.join(",")}`);
return { invalid, duplicates };
}
function renderedWorkloadCandidate({ renderedRoot, propertyNames }) {
if (typeof renderedRoot !== "string" || renderedRoot.length === 0 || !existsSync(renderedRoot) || YAML === null) return null;
const wanted = new Set(propertyNames);
const values = {};
let fileCount = 0;
let workloadCount = 0;
let matchedValueCount = 0;
for (const file of listStructuredFiles(renderedRoot)) {
fileCount += 1;
for (const document of parseDocuments(file)) {
for (const item of kubernetesItems(document)) {
const podSpec = item?.spec?.template?.spec;
if (!isRecord(podSpec)) continue;
workloadCount += 1;
for (const group of ["containers", "initContainers"]) {
for (const container of Array.isArray(podSpec[group]) ? podSpec[group] : []) {
for (const env of Array.isArray(container?.env) ? container.env : []) {
if (!wanted.has(env?.name) || typeof env?.value !== "string") continue;
values[env.name] = parseRenderedValue(env.value);
matchedValueCount += 1;
}
}
}
}
}
}
return {
values,
summary: {
source: "rendered-workload-env",
renderedRoot: path.relative(process.cwd(), path.resolve(renderedRoot)) || ".",
fileCount,
workloadCount,
matchedValueCount,
propertyCount: propertyNames.length,
valuesPrinted: false,
},
};
}
function listStructuredFiles(root) {
const maxFiles = 2_000;
const maxDirectories = 2_000;
const files = [];
const pending = [path.resolve(root)];
let directoryCount = 0;
while (pending.length > 0) {
const current = pending.pop();
directoryCount += 1;
if (directoryCount > maxDirectories) throw new Error(`rendered workload directory limit exceeded: ${maxDirectories}`);
for (const name of readdirSync(current)) {
const file = path.join(current, name);
const stat = lstatSync(file);
if (stat.isSymbolicLink()) continue;
if (stat.isDirectory()) pending.push(file);
else if (/\.(?:ya?ml|json)$/iu.test(name)) {
files.push(file);
if (files.length > maxFiles) throw new Error(`rendered workload file limit exceeded: ${maxFiles}`);
}
}
}
return files.sort();
}
function parseDocuments(file) {
const text = readFileSync(file, "utf8");
if (/\.json$/iu.test(file)) {
try { return [JSON.parse(text)]; } catch { return []; }
}
try { return YAML.parseAllDocuments(text).map((document) => document.toJS()).filter((document) => document !== null); } catch { return []; }
}
function kubernetesItems(document) {
return document?.kind === "List" && Array.isArray(document.items) ? document.items : [document];
}
function parseRenderedValue(value) {
try { return JSON.parse(value); } catch { return value; }
}
function boundedAjvError(error) {
if (!isRecord(error)) return "candidate does not match schema";
const instancePath = typeof error.instancePath === "string" ? error.instancePath : "";
const keyword = typeof error.keyword === "string" ? error.keyword : "validation";
const message = typeof error.message === "string" ? error.message : "failed";
return `${instancePath || "/"} ${keyword} ${message}`;
}
function result(code, firstError, errorCount, candidate) {
const warning = errorCount > 0;
const boundedError = typeof firstError === "string" ? firstError.replace(/\s+/gu, " ").trim().slice(0, 240) : null;
return {
event: "feature-config-schema-validation",
warning,
blocking: false,
code,
schemaPath: FEATURE_CONFIG_SCHEMA_PATH,
errorCount,
firstError: boundedError,
candidate,
mutation: false,
valuesPrinted: false,
otel: {
spanEvent: "cicd.feature_config.schema",
attributes: {
"feature_config.warning": warning,
"feature_config.code": code,
"feature_config.schema_path": FEATURE_CONFIG_SCHEMA_PATH,
"feature_config.error_count": errorCount,
"feature_config.blocking": false,
},
},
};
}
function optionalRequire(name, loader = require) {
try { return loader(name); } catch { return null; }
}
function ajv2020() {
if (globalThis.unideskAjv2020) return globalThis.unideskAjv2020;
if (typeof process.env.UNIDESK_AJV2020_BUNDLE === "string" && process.env.UNIDESK_AJV2020_BUNDLE.length > 0) {
return bundledAjv(process.env.UNIDESK_AJV2020_BUNDLE);
}
try {
return require("ajv-dist/dist/ajv2020.bundle.js");
} catch {
throw validatorUnavailable("Ajv 2020 validator dependency is unavailable");
}
}
function bundledAjv(file) {
if (!existsSync(file)) throw validatorUnavailable("Ajv 2020 validator bundle is missing");
try {
const module = { exports: {} };
Function("module", "exports", readFileSync(file, "utf8"))(module, module.exports);
const exported = module.exports.default ?? module.exports;
if (typeof exported !== "function") throw new TypeError("bundle export is not a constructor");
return exported;
} catch (error) {
if (error?.code === VALIDATOR_UNAVAILABLE) throw error;
throw validatorUnavailable(`Ajv 2020 validator bundle is corrupt (${error instanceof Error ? error.name : "Error"})`);
}
}
function validatorUnavailable(message) {
const error = new Error(message);
error.code = VALIDATOR_UNAVAILABLE;
return error;
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
function positiveInteger(value) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function samplingRatioValue(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null;
}
function parseTraceparent(value) {
const match = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/iu.exec(String(value));
return { traceId: match?.[1]?.toLowerCase() ?? null, spanId: match?.[2]?.toLowerCase() ?? null };
}
function otelAttributes(values) {
return Object.entries(values).filter(([, value]) => value !== null && value !== undefined).map(([key, value]) => ({
key,
value: typeof value === "boolean"
? { boolValue: value }
: typeof value === "number"
? { intValue: String(value) }
: { stringValue: String(value) },
}));
}
function otelWarning(causeCode, httpStatus, timedOut, error, timeoutMs) {
return {
event: "cicd.otel.export",
warning: true,
blocking: false,
causeCode,
...(httpStatus === null ? {} : { httpStatus }),
timedOut,
errorType: error instanceof Error ? error.name : null,
timeoutMs,
valuesRedacted: true,
};
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
const renderedRootIndex = process.argv.indexOf("--rendered-root");
await runFeatureConfigSchemaValidation({ renderedRoot: renderedRootIndex === -1 ? null : process.argv[renderedRootIndex + 1] });
process.exitCode = 0;
}