fix(cicd): vendor Ajv bundle for warning stage
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user