410 lines
16 KiB
JavaScript
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;
|
|
}
|