fix(cicd): vendor Ajv bundle for warning stage

This commit is contained in:
Codex
2026-07-14 05:20:52 +02:00
parent 5b24a47443
commit a3d64ac8c4
13 changed files with 207 additions and 14 deletions
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const repositoryRoot = resolve(import.meta.dir, "../../..");
const manifestPath = resolve(repositoryRoot, "scripts/vendor/ajv-dist/8.17.1/manifest.json");
test("repo-owned Ajv 2020 bundle matches the locked dependency and source artifact", () => {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const packageJson = JSON.parse(readFileSync(resolve(repositoryRoot, "package.json"), "utf8"));
const vendor = readFileSync(resolve(repositoryRoot, manifest.vendorPath));
const source = readFileSync(resolve(repositoryRoot, manifest.sourcePath));
expect(packageJson.dependencies[manifest.package]).toBe(manifest.version);
expect(vendor.byteLength).toBe(manifest.bytes);
expect(createHash("sha256").update(vendor).digest("hex")).toBe(manifest.sha256);
expect(vendor.equals(source)).toBe(true);
});
@@ -8,6 +8,7 @@ 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";
@@ -35,7 +36,12 @@ export function validateFeatureConfigSchema(options = {}) {
ajv.addKeyword({ keyword: "x-unidesk-feature", schemaType: "string", valid: true });
validate = ajv.compile(schema);
} catch (error) {
return result("feature-config-schema-invalid", errorMessage(error), 1, null);
return result(
error?.code === VALIDATOR_UNAVAILABLE ? "feature-config-validator-unavailable" : "feature-config-schema-invalid",
errorMessage(error),
1,
null,
);
}
let candidate;
@@ -317,14 +323,35 @@ function optionalRequire(name, loader = require) {
}
function ajv2020() {
return globalThis.unideskAjv2020 ?? bundledAjv(process.env.UNIDESK_AJV2020_BUNDLE) ?? require("ajv-dist/dist/ajv2020.bundle.js");
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 (typeof file !== "string" || file.length === 0 || !existsSync(file)) return null;
const module = { exports: {} };
Function("module", "exports", readFileSync(file, "utf8"))(module, module.exports);
return module.exports.default ?? module.exports;
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) {
@@ -1,7 +1,8 @@
import { afterEach, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import {
featureConfigOtelPayload,
runFeatureConfigSchemaValidation,
@@ -9,6 +10,8 @@ import {
} from "./feature-config-schema-warning.mjs";
const roots: string[] = [];
const validatorScript = resolve(import.meta.dir, "feature-config-schema-warning.mjs");
const vendorBundle = resolve(import.meta.dir, "../../vendor/ajv-dist/8.17.1/ajv2020.min.js");
afterEach(() => {
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
@@ -36,6 +39,27 @@ test("valid nested Draft 2020-12 candidate remains warning-free", () => {
});
});
test("repo-owned Ajv 2020 bundle performs real validation without workspace dependencies", () => {
const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "boolean" }) }), { tracePanel: "true" });
const run = runValidator(root, vendorBundle);
expect(run.status).toBe(0);
expect(run.stderr).toContain('"code":"feature-config-schema-valid"');
expect(run.stderr).not.toContain("feature-config-validator-unavailable");
});
test("missing or corrupt Ajv bundle is a typed warning and exits zero", () => {
const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "boolean" }) }), { tracePanel: "true" });
const missing = runValidator(root, join(root, "missing-ajv2020.min.js"));
expect(missing.status).toBe(0);
expect(missing.stderr).toContain('"code":"feature-config-validator-unavailable"');
const corruptBundle = join(root, "corrupt-ajv2020.min.js");
writeFileSync(corruptBundle, "this is not JavaScript");
const corrupt = runValidator(root, corruptBundle);
expect(corrupt.status).toBe(0);
expect(corrupt.stderr).toContain('"code":"feature-config-validator-unavailable"');
});
test("missing schema is a typed non-blocking warning", () => {
const root = fixture();
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
@@ -192,6 +216,19 @@ function fixture(content?: string, env: Record<string, string> = {}) {
return root;
}
function runValidator(root: string, bundlePath: string) {
return spawnSync(process.execPath, [validatorScript, "--rendered-root", join(root, "rendered")], {
cwd: root,
encoding: "utf8",
env: {
...process.env,
UNIDESK_AJV2020_BUNDLE: bundlePath,
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "",
OTEL_EXPORTER_TIMEOUT_MS: "300",
},
});
}
function schema(properties: Record<string, unknown>) {
return JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
@@ -76,7 +76,7 @@ export function renderGitOpsResources(gitops, sourceRoot) {
overlay,
scriptsDir: resolve(sourceRoot, "scripts/native/hwlab"),
featureConfigValidatorPath: resolve(sourceRoot, "scripts/native/cicd/feature-config-schema-warning.mjs"),
ajvBundlePath: resolve(sourceRoot, "node_modules/ajv-dist/dist/ajv2020.min.js"),
ajvBundlePath: resolve(sourceRoot, "scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js"),
namespace: required(resource.namespace, `${pathLabel}.namespace`),
configMapName,
}));
@@ -16,8 +16,10 @@ export function runtimeGitopsScriptsConfigMap({ overlay, scriptsDir, featureConf
]) {
data[name] = readFileSync(path.join(scriptsDir, name), "utf8");
}
data["feature-config-schema-warning.mjs"] = readFileSync(featureConfigValidatorPath ?? path.join(scriptsDir, "feature-config-schema-warning.mjs"), "utf8");
data["ajv2020.min.js"] = readFileSync(ajvBundlePath ?? path.join(scriptsDir, "ajv2020.min.js"), "utf8");
const featureConfigValidator = optionalFile(featureConfigValidatorPath ?? path.join(scriptsDir, "feature-config-schema-warning.mjs"));
if (featureConfigValidator !== null) data["feature-config-schema-warning.mjs"] = featureConfigValidator;
const ajvBundle = optionalFile(ajvBundlePath ?? path.join(scriptsDir, "ajv2020.min.js"));
if (ajvBundle !== null) data["ajv2020.min.js"] = ajvBundle;
return {
apiVersion: "v1",
kind: "ConfigMap",
@@ -44,3 +46,11 @@ function requiredString(value, pathLabel) {
function objectOr(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function optionalFile(file) {
try {
return readFileSync(file, "utf8");
} catch {
return null;
}
}
@@ -47,3 +47,26 @@ test("preserves codeAgentRuntime in the owning runtime GitOps overlay", () => {
expect(configMap.data["feature-config-schema-warning.mjs"]).toBe("feature-config-schema-warning.mjs\n");
expect(configMap.data["ajv2020.min.js"]).toBe("ajv2020.min.js\n");
});
test("missing optional validator artifacts do not block ConfigMap rendering", () => {
const scriptsDir = mkdtempSync(join(tmpdir(), "runtime-gitops-configmap-"));
roots.push(scriptsDir);
for (const name of [
"runtime-gitops-observability.mjs",
"runtime-gitops-postprocess.mjs",
"runtime-gitops-verify.mjs",
]) {
writeFileSync(join(scriptsDir, name), `${name}\n`);
}
const configMap = runtimeGitopsScriptsConfigMap({
overlay: { runtimePath: "runtime" },
scriptsDir,
namespace: "hwlab",
configMapName: "runtime-gitops-scripts",
});
expect(configMap.data["runtime-gitops-verify.mjs"]).toBe("runtime-gitops-verify.mjs\n");
expect(configMap.data["feature-config-schema-warning.mjs"]).toBeUndefined();
expect(configMap.data["ajv2020.min.js"]).toBeUndefined();
});