#!/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; }