feat(cicd): add warning-only feature config validation
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
#!/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);
|
||||
|
||||
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("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() {
|
||||
return globalThis.unideskAjv2020 ?? bundledAjv(process.env.UNIDESK_AJV2020_BUNDLE) ?? require("ajv-dist/dist/ajv2020.bundle.js");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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 {
|
||||
featureConfigOtelPayload,
|
||||
runFeatureConfigSchemaValidation,
|
||||
validateFeatureConfigSchema,
|
||||
} from "./feature-config-schema-warning.mjs";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("valid nested Draft 2020-12 candidate remains warning-free", () => {
|
||||
const root = fixture(schema({
|
||||
tracePanel: feature("trace-panel", {
|
||||
type: "object",
|
||||
required: ["enabled", "views"],
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
views: { type: "array", minItems: 1, items: { type: "string", pattern: "^[a-z-]+$" } },
|
||||
},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
}), { tracePanel: '{"enabled":true,"views":["timeline"]}' });
|
||||
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
|
||||
warning: false,
|
||||
blocking: false,
|
||||
code: "feature-config-schema-valid",
|
||||
errorCount: 0,
|
||||
mutation: false,
|
||||
candidate: { source: "rendered-workload-env", matchedValueCount: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("missing schema is a typed non-blocking warning", () => {
|
||||
const root = fixture();
|
||||
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
|
||||
warning: true,
|
||||
blocking: false,
|
||||
code: "feature-config-schema-missing",
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid JSON and unsupported Draft 2020-12 schema are typed warnings", () => {
|
||||
const invalidJsonRoot = fixture("{");
|
||||
expect(validateFeatureConfigSchema({ repoDir: invalidJsonRoot, renderedRoot: join(invalidJsonRoot, "rendered") }).code).toBe("feature-config-schema-invalid");
|
||||
|
||||
const invalidSchemaRoot = fixture(JSON.stringify({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { tracePanel: { type: "not-a-json-schema-type", "x-unidesk-feature": "trace-panel" } } }));
|
||||
expect(validateFeatureConfigSchema({ repoDir: invalidSchemaRoot, renderedRoot: join(invalidSchemaRoot, "rendered") }).code).toBe("feature-config-schema-invalid");
|
||||
});
|
||||
|
||||
test("missing rendered candidate is distinct from schema failure", () => {
|
||||
const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "boolean" }) }));
|
||||
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "missing") })).toMatchObject({
|
||||
warning: true,
|
||||
blocking: false,
|
||||
code: "feature-config-input-missing",
|
||||
});
|
||||
});
|
||||
|
||||
test("nested value mismatch is bounded and does not disclose the value", () => {
|
||||
const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "object", required: ["enabled"], properties: { enabled: { const: true } } }) }), {
|
||||
tracePanel: '{"enabled":false,"secret":"do-not-print"}',
|
||||
});
|
||||
const result = validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") });
|
||||
expect(result).toMatchObject({ warning: true, blocking: false, code: "feature-config-value-mismatch", valuesPrinted: false });
|
||||
expect(result.firstError).not.toContain("do-not-print");
|
||||
});
|
||||
|
||||
test("duplicate feature properties are reported without blocking", () => {
|
||||
const root = fixture(schema({
|
||||
tracePanel: feature("trace-panel", { type: "boolean" }),
|
||||
tracePanelAlias: feature("trace-panel", { type: "boolean" }),
|
||||
}));
|
||||
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
|
||||
warning: true,
|
||||
blocking: false,
|
||||
code: "feature-config-variable-duplicate",
|
||||
errorCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("actual OTLP span and event carry redacted validation attributes", () => {
|
||||
const root = fixture();
|
||||
const validation = validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") });
|
||||
const payload = featureConfigOtelPayload(validation, {
|
||||
serviceName: "unidesk-cicd",
|
||||
consumer: "hwlab-nc01-v03",
|
||||
samplingRatio: 1,
|
||||
nowMs: 1,
|
||||
traceId: "1".repeat(32),
|
||||
spanId: "2".repeat(16),
|
||||
});
|
||||
const span = payload.resourceSpans[0].scopeSpans[0].spans[0];
|
||||
expect(span.name).toBe("cicd.feature_config.schema");
|
||||
expect(span.events[0].name).toBe("feature-config-schema-validation");
|
||||
expect(JSON.stringify(payload)).not.toContain("do-not-print");
|
||||
expect(JSON.stringify(payload)).toContain("unidesk.otel.sampling_ratio");
|
||||
});
|
||||
|
||||
test("missing endpoint remains a non-blocking OTel warning", async () => {
|
||||
const root = fixture();
|
||||
const result = await runFeatureConfigSchemaValidation({
|
||||
repoDir: root,
|
||||
renderedRoot: join(root, "rendered"),
|
||||
otelEndpoint: "",
|
||||
otelTimeoutMs: 300,
|
||||
});
|
||||
expect(result.otelWarning).toMatchObject({
|
||||
event: "cicd.otel.export",
|
||||
warning: true,
|
||||
blocking: false,
|
||||
causeCode: "ENDPOINT_MISSING",
|
||||
timeoutMs: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test("export failure uses configured timeout and remains non-blocking", async () => {
|
||||
const root = fixture();
|
||||
let requestSignal: AbortSignal | null = null;
|
||||
const result = await runFeatureConfigSchemaValidation({
|
||||
repoDir: root,
|
||||
renderedRoot: join(root, "rendered"),
|
||||
otelEndpoint: "http://otel.example.test/v1/traces",
|
||||
otelServiceName: "unidesk-cicd",
|
||||
otelSamplingRatio: 1,
|
||||
otelTimeoutMs: 300,
|
||||
fetchImpl: async (_input: URL | RequestInfo, init?: RequestInit) => {
|
||||
requestSignal = init?.signal ?? null;
|
||||
throw new Error("collector unavailable");
|
||||
},
|
||||
});
|
||||
expect(requestSignal).not.toBeNull();
|
||||
expect(result.otelWarning).toMatchObject({
|
||||
event: "cicd.otel.export",
|
||||
warning: true,
|
||||
blocking: false,
|
||||
causeCode: "EXPORT_FAILED",
|
||||
timeoutMs: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test("unexpected validator failure is typed and never rejects", async () => {
|
||||
const options: Record<string, unknown> = {
|
||||
repoDir: fixture(schema({ webProbeSentinel: feature("web-probe-sentinel", { type: "object" }) })),
|
||||
renderedRoot: "unused",
|
||||
otelEndpoint: "",
|
||||
otelTimeoutMs: 300,
|
||||
};
|
||||
Object.defineProperty(options, "candidate", { get: () => { throw new Error("unexpected candidate failure"); } });
|
||||
const result = await runFeatureConfigSchemaValidation(options);
|
||||
expect(result.validation).toMatchObject({
|
||||
warning: true,
|
||||
blocking: false,
|
||||
code: "feature-config-validator-failure",
|
||||
mutation: false,
|
||||
});
|
||||
expect(result.otelWarning).toMatchObject({ warning: true, blocking: false, causeCode: "ENDPOINT_MISSING" });
|
||||
});
|
||||
|
||||
test("candidate scan ignores directory symlinks and bounds directory traversal", () => {
|
||||
const root = fixture(schema({ webProbeSentinel: feature("web-probe-sentinel", { type: "boolean" }) }), {
|
||||
webProbeSentinel: "true",
|
||||
});
|
||||
symlinkSync(join(root, "rendered"), join(root, "rendered", "loop"), "dir");
|
||||
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
|
||||
warning: false,
|
||||
code: "feature-config-schema-valid",
|
||||
});
|
||||
for (let index = 0; index <= 2_000; index += 1) mkdirSync(join(root, "rendered", `directory-${index}`));
|
||||
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
|
||||
warning: true,
|
||||
blocking: false,
|
||||
code: "feature-config-validator-failure",
|
||||
firstError: "rendered workload directory limit exceeded: 2000",
|
||||
});
|
||||
});
|
||||
|
||||
function fixture(content?: string, env: Record<string, string> = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "feature-config-schema-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "rendered"));
|
||||
if (content !== undefined) {
|
||||
mkdirSync(join(root, "config"));
|
||||
writeFileSync(join(root, "config", "feature-config.schema.json"), content);
|
||||
}
|
||||
writeFileSync(join(root, "rendered", "deployment.yaml"), deployment(env));
|
||||
return root;
|
||||
}
|
||||
|
||||
function schema(properties: Record<string, unknown>) {
|
||||
return JSON.stringify({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
properties,
|
||||
additionalProperties: false,
|
||||
});
|
||||
}
|
||||
|
||||
function feature(name: string, value: Record<string, unknown>) {
|
||||
return { ...value, "x-unidesk-feature": name };
|
||||
}
|
||||
|
||||
function deployment(env: Record<string, string>) {
|
||||
const rows = Object.entries(env).map(([name, value]) => ` - name: ${name}\n value: ${JSON.stringify(value)}`).join("\n");
|
||||
return `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: fixture\nspec:\n template:\n spec:\n containers:\n - name: fixture\n env:\n${rows || " []"}\n`;
|
||||
}
|
||||
@@ -28,8 +28,7 @@ export interface CicdOtelExportWarning {
|
||||
}
|
||||
|
||||
export function loadCicdOtelRuntimeConfig(
|
||||
node: string,
|
||||
lane: string,
|
||||
repositoryId: string,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): CicdOtelRuntimeConfig {
|
||||
const root = materializeYamlComposition(
|
||||
@@ -37,7 +36,7 @@ export function loadCicdOtelRuntimeConfig(
|
||||
{ label: "platform-infra-pipelines-as-code", stripTemplateKeys: true },
|
||||
).value;
|
||||
const observability = record(root.observability, `${pacConfigPath}#observability`);
|
||||
const repository = repositoryFor(root.repositories, node, lane);
|
||||
const repository = selectCicdOtelRepository(root.repositories, repositoryId);
|
||||
const params = record(repository.params, `${pacConfigPath}#repositories.${repository.id}.params`);
|
||||
const fallbackCodes: string[] = [];
|
||||
const endpoint = authoritativeValue(
|
||||
@@ -117,12 +116,12 @@ export async function exportCicdOtelPayload(
|
||||
}
|
||||
}
|
||||
|
||||
function repositoryFor(value: unknown, node: string, lane: string): Record<string, unknown> {
|
||||
export function selectCicdOtelRepository(value: unknown, repositoryId: string): Record<string, unknown> {
|
||||
if (!Array.isArray(value)) throw new Error(`${pacConfigPath}#repositories must be an array`);
|
||||
const expectedId = `sentinel-${node.toLowerCase()}-${lane.toLowerCase()}`;
|
||||
const repository = value.find((item) => recordOrNull(item)?.id === expectedId);
|
||||
if (repository === undefined) throw new Error(`${pacConfigPath} has no repository ${expectedId}`);
|
||||
return record(repository, `${pacConfigPath}#repositories.${expectedId}`);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(repositoryId)) throw new Error(`invalid PaC repository id ${repositoryId}`);
|
||||
const repositories = value.filter((item) => recordOrNull(item)?.id === repositoryId);
|
||||
if (repositories.length !== 1) throw new Error(`${pacConfigPath} requires exactly one repository ${repositoryId}; matched ${repositories.length}`);
|
||||
return record(repositories[0], `${pacConfigPath}#repositories.${repositoryId}`);
|
||||
}
|
||||
|
||||
function authoritativeValue(
|
||||
|
||||
@@ -31,6 +31,35 @@ function exactRuntimeGitopsVerifyMarker(records) {
|
||||
&& item.reason === "no-build-no-rollout-plan-gitops-verify") || null;
|
||||
}
|
||||
|
||||
function featureConfigSchemaObservation(records) {
|
||||
const item = [...records].reverse().find((recordValue) => recordValue.event === "feature-config-schema-validation") || null;
|
||||
if (item === null) return null;
|
||||
const candidate = record(item.candidate);
|
||||
const code = stringOrNull(item.code);
|
||||
const schemaPath = stringOrNull(item.schemaPath);
|
||||
const firstError = stringOrNull(item.firstError);
|
||||
return {
|
||||
warning: item.warning === true,
|
||||
blocking: false,
|
||||
code: code === null ? "feature-config-schema-invalid" : code.slice(0, 100),
|
||||
schemaPath: schemaPath === null ? "config/feature-config.schema.json" : schemaPath.slice(0, 200),
|
||||
errorCount: Number.isInteger(item.errorCount) ? Math.max(0, item.errorCount) : null,
|
||||
firstError: firstError === null ? null : firstError.replace(/\s+/gu, " ").trim().slice(0, 240),
|
||||
candidate: Object.keys(candidate).length === 0 ? null : {
|
||||
source: stringOrNull(candidate.source),
|
||||
renderedRoot: stringOrNull(candidate.renderedRoot),
|
||||
fileCount: Number.isInteger(candidate.fileCount) ? candidate.fileCount : null,
|
||||
workloadCount: Number.isInteger(candidate.workloadCount) ? candidate.workloadCount : null,
|
||||
matchedValueCount: Number.isInteger(candidate.matchedValueCount) ? candidate.matchedValueCount : null,
|
||||
propertyCount: Number.isInteger(candidate.propertyCount) ? candidate.propertyCount : null,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
spanEvent: "cicd.feature_config.schema",
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const pacContractTasks = new Set(["plan-artifacts", "collect-artifacts", "gitops-promote"]);
|
||||
|
||||
function firstString(...values) {
|
||||
@@ -368,6 +397,7 @@ function terminalSource(task, marker, markerStatus = "skipped", markerSource = "
|
||||
|
||||
function extractPacSourceObservation(recordsInput) {
|
||||
const records = Array.isArray(recordsInput) ? recordsInput.map(record) : [];
|
||||
const featureConfigSchema = featureConfigSchemaObservation(records);
|
||||
const plan = [...records].reverse().find((item) => item.event === "pac-delivery-plan" || item.event === "g14-ci-plan") || null;
|
||||
const collectMarker = exactSkipMarker(records, "collect-artifacts");
|
||||
const promoteMarker = exactSkipMarker(records, "gitops-promote");
|
||||
@@ -461,6 +491,7 @@ function extractPacSourceObservation(recordsInput) {
|
||||
? terminalSource(promoteTask, promoteMarker)
|
||||
: terminalSource(promoteTask, runtimeGitopsVerifyMarker, "continuing", "runtime-gitops-verify-structured-log"),
|
||||
},
|
||||
featureConfigSchema,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ export function renderGitOpsResources(gitops, sourceRoot) {
|
||||
const manifest = Bun.YAML.stringify(runtimeGitopsScriptsConfigMap({
|
||||
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"),
|
||||
namespace: required(resource.namespace, `${pathLabel}.namespace`),
|
||||
configMapName,
|
||||
}));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { rootPath } from "../../src/config";
|
||||
import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig } from "./otel-runtime-config";
|
||||
import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig, selectCicdOtelRepository } from "./otel-runtime-config";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
@@ -12,7 +12,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
test("unexpanded Repository inputs fall back to materialized owning YAML", () => {
|
||||
const config = loadCicdOtelRuntimeConfig("NC01", "v03", {
|
||||
const config = loadCicdOtelRuntimeConfig("sentinel-nc01-v03", {
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "{{otel_traces_endpoint}}",
|
||||
OTEL_SERVICE_NAME: "{{otel_service_name}}",
|
||||
OTEL_TRACES_SAMPLER_ARG: "{{otel_sampling_ratio}}",
|
||||
@@ -34,7 +34,7 @@ test("unexpanded Repository inputs fall back to materialized owning YAML", () =>
|
||||
});
|
||||
|
||||
test("valid but mismatched runtime inputs cannot override owning YAML", () => {
|
||||
const config = loadCicdOtelRuntimeConfig("NC01", "v03", {
|
||||
const config = loadCicdOtelRuntimeConfig("sentinel-nc01-v03", {
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://collector.example.invalid:4318/v1/traces",
|
||||
OTEL_SERVICE_NAME: "different-service",
|
||||
OTEL_TRACES_SAMPLER_ARG: "0.5",
|
||||
@@ -53,7 +53,7 @@ test("valid but mismatched runtime inputs cannot override owning YAML", () => {
|
||||
});
|
||||
|
||||
test("legal owning endpoint and non-blocking exporter failure share the bounded helper", async () => {
|
||||
const config = loadCicdOtelRuntimeConfig("NC01", "v03", {
|
||||
const config = loadCicdOtelRuntimeConfig("sentinel-nc01-v03", {
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces",
|
||||
OTEL_SERVICE_NAME: "unidesk-cicd-sentinel-nc01",
|
||||
OTEL_TRACES_SAMPLER_ARG: "1",
|
||||
@@ -81,6 +81,14 @@ test("legal owning endpoint and non-blocking exporter failure share the bounded
|
||||
});
|
||||
});
|
||||
|
||||
test("repository identity selection fails closed on zero or multiple exact matches", () => {
|
||||
expect(() => selectCicdOtelRepository([], "sentinel-nc01-v03")).toThrow("matched 0");
|
||||
expect(() => selectCicdOtelRepository([
|
||||
{ id: "sentinel-nc01-v03", params: {} },
|
||||
{ id: "sentinel-nc01-v03", params: {} },
|
||||
], "sentinel-nc01-v03")).toThrow("matched 2");
|
||||
});
|
||||
|
||||
test("actual renderer replaces unexpanded inputs without changing business exit", async () => {
|
||||
const result = await render({ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "{{otel_traces_endpoint}}" }, false);
|
||||
expect(result.exitCode).toBe(0);
|
||||
@@ -89,6 +97,12 @@ test("actual renderer replaces unexpanded inputs without changing business exit"
|
||||
expect(result.stderr).toContain('"endpointHostname":"otel-collector.platform-infra.svc.cluster.local"');
|
||||
expect(result.stderr).not.toContain("{{otel_traces_endpoint}}");
|
||||
expect(readFileSync(join(result.outputDir, "source.sh"), "utf8")).toContain("http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces");
|
||||
const publish = readFileSync(join(result.outputDir, "publish.sh"), "utf8");
|
||||
expect(publish).toContain("UNIDESK_PAC_CONSUMER='sentinel-nc01-v03'");
|
||||
expect(publish).toContain("feature-config-schema-validation");
|
||||
expect(publish).toContain("cicd.feature_config.schema");
|
||||
expect(publish).toContain("OTEL_EXPORTER_TIMEOUT_MS='300'");
|
||||
expect(publish).toContain("feature-config-validator-process-failure");
|
||||
});
|
||||
|
||||
async function render(env: Record<string, string>, traced = true) {
|
||||
@@ -100,6 +114,7 @@ async function render(env: Record<string, string>, traced = true) {
|
||||
"scripts/native/cicd/render-sentinel-publish-task.ts",
|
||||
"--node", "NC01",
|
||||
"--lane", "v03",
|
||||
"--repository-id", "sentinel-nc01-v03",
|
||||
"--sentinel", "nc01-web-probe-sentinel",
|
||||
"--pipeline-run", "hwlab-web-probe-sentinel-nc01-test",
|
||||
"--source-commit", "a".repeat(40),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
sentinelPublishSourceShell,
|
||||
} from "../../src/hwlab-node-web-sentinel-cicd-jobs";
|
||||
import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig } from "./otel-runtime-config";
|
||||
import { renderPacFeatureConfigSchemaStage } from "../../src/pac-feature-config-schema-stage";
|
||||
|
||||
const options = parseOptions(process.argv.slice(2));
|
||||
const sourceAuthority = options.sourceAuthority === "gitea-snapshot" ? "gitea-snapshot" : fail("--source-authority must be gitea-snapshot");
|
||||
@@ -26,7 +27,7 @@ const state = loadSentinelCicdState(
|
||||
);
|
||||
const outputDir = resolve(options.outputDir);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const otelConfig = loadCicdOtelRuntimeConfig(options.node, options.lane);
|
||||
const otelConfig = loadCicdOtelRuntimeConfig(options.repositoryId);
|
||||
const otelSetup = [
|
||||
`export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${shellQuote(otelConfig.endpoint)}`,
|
||||
`export OTEL_EXPORTER_TIMEOUT_MS=${shellQuote(String(otelConfig.timeoutMs))}`,
|
||||
@@ -38,6 +39,12 @@ const traceSetup = [
|
||||
"if [ -f /workspace/meta/tracestate ]; then TRACESTATE=$(cat /workspace/meta/tracestate); export TRACESTATE; fi",
|
||||
].join("\n");
|
||||
const proxySetup = sentinelImageBuildProxyEnv(state).map(({ name, value }) => `export ${name}=${shellQuote(value)}`).join("\n");
|
||||
const featureConfigSchemaStage = renderPacFeatureConfigSchemaStage({
|
||||
repositoryId: options.repositoryId,
|
||||
consumer: options.repositoryId,
|
||||
repoDirExpression: "'/workspace/source'",
|
||||
renderedRootExpression: '"$gitops_worktree"',
|
||||
});
|
||||
writeExecutable("source.sh", [
|
||||
"#!/bin/sh",
|
||||
"set -eu",
|
||||
@@ -49,7 +56,7 @@ writeExecutable("source.sh", [
|
||||
"if [ -n \"$incoming_tracestate\" ]; then printf '%s' \"$incoming_tracestate\" > /workspace/meta/tracestate; fi",
|
||||
].join("\n"));
|
||||
writeExecutable("image-build.sh", ["#!/bin/sh", "set -eu", otelSetup, traceSetup, proxySetup, sentinelPublishImageBuildShell(state, options.pipelineRun)].join("\n"));
|
||||
writeExecutable("publish.sh", ["#!/bin/sh", "set -eu", otelSetup, traceSetup, proxySetup, sentinelPublishShell(state, options.pipelineRun, true)].join("\n"));
|
||||
writeExecutable("publish.sh", ["#!/bin/sh", "set -eu", otelSetup, traceSetup, proxySetup, sentinelPublishShell(state, options.pipelineRun, true, featureConfigSchemaStage)].join("\n"));
|
||||
emitConfigFallbackWarning();
|
||||
await emitOuterSpan();
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, action: "render-sentinel-publish-task", node: options.node, lane: options.lane, sentinel: options.sentinel, pipelineRun: options.pipelineRun, sourceCommit: options.sourceCommit, outputDir, traceId: traceId(process.env.TRACEPARENT), valuesRedacted: true })}\n`);
|
||||
@@ -60,7 +67,7 @@ function writeExecutable(name: string, content: string): void {
|
||||
chmodSync(path, 0o755);
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): Record<"node" | "lane" | "sentinel" | "pipelineRun" | "sourceCommit" | "sourceStageRef" | "sourceAuthority" | "outputDir", string> {
|
||||
function parseOptions(args: string[]): Record<"node" | "lane" | "repositoryId" | "sentinel" | "pipelineRun" | "sourceCommit" | "sourceStageRef" | "sourceAuthority" | "outputDir", string> {
|
||||
const values = new Map<string, string>();
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const key = args[index];
|
||||
@@ -72,6 +79,7 @@ function parseOptions(args: string[]): Record<"node" | "lane" | "sentinel" | "pi
|
||||
const result = {
|
||||
node: required("node"),
|
||||
lane: required("lane"),
|
||||
repositoryId: required("repository-id"),
|
||||
sentinel: required("sentinel"),
|
||||
pipelineRun: required("pipeline-run"),
|
||||
sourceCommit: required("source-commit"),
|
||||
@@ -80,7 +88,7 @@ function parseOptions(args: string[]): Record<"node" | "lane" | "sentinel" | "pi
|
||||
outputDir: required("output-dir"),
|
||||
};
|
||||
if (!/^[0-9a-f]{40}$/u.test(result.sourceCommit)) fail("--source-commit must be a 40-character lowercase Git commit");
|
||||
for (const key of ["node", "lane", "sentinel", "pipelineRun"] as const) if (!/^[A-Za-z0-9._-]+$/u.test(result[key])) fail(`--${key} must be a simple id`);
|
||||
for (const key of ["node", "lane", "repositoryId", "sentinel", "pipelineRun"] as const) if (!/^[A-Za-z0-9._-]+$/u.test(result[key])) fail(`--${key.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)} must be a simple id`);
|
||||
if (!result.sourceStageRef.startsWith("refs/unidesk/snapshots/")) fail("--source-stage-ref must be an immutable UniDesk snapshot ref");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -62,11 +62,6 @@ function patchCodeAgentRuntimeWorkloads() {
|
||||
if (workloadName === "hwlab-cloud-api" && container.name === "hwlab-cloud-api") {
|
||||
fileChanged = patchCloudApiKafkaEnv(container, kafka) || fileChanged;
|
||||
}
|
||||
if (workloadName === "hwlab-cloud-web" && container.name === "hwlab-cloud-web") {
|
||||
fileChanged = setEnvValue(container, "HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafka.enabled && kafka.features.liveKafkaSse)) || fileChanged;
|
||||
fileChanged = setEnvValue(container, "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(kafka.enabled && kafka.features.kafkaRefreshReplay)) || fileChanged;
|
||||
fileChanged = setEnvValue(container, "HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafka.enabled && kafka.features.projectionRealtime)) || fileChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,27 +76,20 @@ function patchCodeAgentRuntimeWorkloads() {
|
||||
function patchCloudApiKafkaEnv(container, kafka) {
|
||||
let changed = false;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_ENABLED", String(kafka.enabled)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector))) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED", String(kafka.enabled && kafka.features.directPublish)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafka.enabled && kafka.features.liveKafkaSse)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(kafka.enabled && kafka.features.kafkaRefreshReplay)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED", String(kafka.enabled && kafka.features.transactionalProjector)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED", String(kafka.enabled && kafka.features.projectionOutboxRelay)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafka.enabled && kafka.features.projectionRealtime)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID", String(kafka.directPublishConsumerGroupId)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafka.enabled)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_PROJECTOR_GROUP_ID", String(kafka.transactionalProjectorConsumerGroupId)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID", String(kafka.hwlabEventConsumerGroupId)) || changed;
|
||||
changed = patchOptionalEnvGroup(container, kafka.enabled && kafka.features.transactionalProjector, {
|
||||
changed = patchOptionalEnvGroup(container, kafka.enabled, {
|
||||
HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS: kafka.transactionalProjector?.heartbeatIntervalMs,
|
||||
}) || changed;
|
||||
changed = patchOptionalEnvGroup(container, kafka.enabled && kafka.features.projectionOutboxRelay, {
|
||||
changed = patchOptionalEnvGroup(container, kafka.enabled, {
|
||||
HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS: kafka.projectionOutboxRelay?.intervalMs,
|
||||
HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE: kafka.projectionOutboxRelay?.batchSize,
|
||||
HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS: kafka.projectionOutboxRelay?.leaseMs,
|
||||
HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS: kafka.projectionOutboxRelay?.sendTimeoutMs,
|
||||
HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS: kafka.projectionOutboxRelay?.retryBackoffMs,
|
||||
}) || changed;
|
||||
changed = patchOptionalEnvGroup(container, kafka.enabled && kafka.features.projectionRealtime, {
|
||||
changed = patchOptionalEnvGroup(container, kafka.enabled, {
|
||||
HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: kafka.projectionRealtime?.outboxTailBatchSize,
|
||||
HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: kafka.projectionRealtime?.sseHeartbeatMs,
|
||||
HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS: kafka.projectionRealtime?.sseDrainTimeoutMs,
|
||||
|
||||
@@ -11,7 +11,7 @@ afterEach(() => {
|
||||
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("patches YAML workloads across ordinary, multi-document, and Kubernetes List files", () => {
|
||||
test("patches fixed Kafka transport settings without architecture capability env", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "runtime-gitops-postprocess-"));
|
||||
roots.push(root);
|
||||
const runtimeDir = join(root, "runtime");
|
||||
@@ -32,23 +32,25 @@ test("patches YAML workloads across ordinary, multi-document, and Kubernetes Lis
|
||||
expect(result.stderr).toContain('"codeAgentRuntimeChanged":true');
|
||||
|
||||
const api = Bun.YAML.parse(readFileSync(join(runtimeDir, "cloud-api.yaml"), "utf8")) as any;
|
||||
expect(authorityValues(api)).toEqual(["false", "false", "false", "true", "true", "true"]);
|
||||
expect(envValues(api)).toMatchObject({
|
||||
HWLAB_KAFKA_ENABLED: "true",
|
||||
HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED: "true",
|
||||
HWLAB_KAFKA_PROJECTOR_GROUP_ID: "hwlab-projector",
|
||||
HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID: "hwlab-events",
|
||||
});
|
||||
expect(architectureEnvNames(api)).toEqual([]);
|
||||
|
||||
const multiDocs = readFileSync(join(runtimeDir, "cloud-web.yaml"), "utf8").split(/^---$/mu).map((document) => Bun.YAML.parse(document) as any);
|
||||
expect(multiDocs[0].kind).toBe("ConfigMap");
|
||||
expect(envValues(multiDocs[1])).toMatchObject({
|
||||
HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "false",
|
||||
HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "false",
|
||||
HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "true",
|
||||
});
|
||||
expect(envValues(multiDocs[1])).toEqual({ EXISTING: "preserved" });
|
||||
|
||||
const list = Bun.YAML.parse(readFileSync(join(runtimeDir, "list.yaml"), "utf8")) as any;
|
||||
expect(list.kind).toBe("List");
|
||||
expect(authorityValues(list.items[0])).toEqual(["false", "false", "false", "true", "true", "true"]);
|
||||
expect(architectureEnvNames(list.items[0])).toEqual([]);
|
||||
|
||||
const jsonText = readFileSync(join(runtimeDir, "json-workload.yaml"), "utf8");
|
||||
expect(jsonText.startsWith("{\n")).toBe(true);
|
||||
expect(authorityValues(JSON.parse(jsonText))).toEqual(["false", "false", "false", "true", "true", "true"]);
|
||||
expect(architectureEnvNames(JSON.parse(jsonText))).toEqual([]);
|
||||
|
||||
const afterFirstRun = snapshot(runtimeDir);
|
||||
const second = spawnSync(process.execPath, [script], {
|
||||
@@ -68,16 +70,11 @@ function overlay(runtimePath: string) {
|
||||
enabled: true,
|
||||
kafkaEventBridge: {
|
||||
enabled: true,
|
||||
directPublishConsumerGroupId: "hwlab-direct",
|
||||
transactionalProjectorConsumerGroupId: "hwlab-projector",
|
||||
features: {
|
||||
directPublish: false,
|
||||
liveKafkaSse: false,
|
||||
kafkaRefreshReplay: false,
|
||||
transactionalProjector: true,
|
||||
projectionOutboxRelay: true,
|
||||
projectionRealtime: true,
|
||||
},
|
||||
hwlabEventConsumerGroupId: "hwlab-events",
|
||||
transactionalProjector: { heartbeatIntervalMs: 2000 },
|
||||
projectionOutboxRelay: { intervalMs: 250, batchSize: 100, leaseMs: 30000, sendTimeoutMs: 10000, retryBackoffMs: 1000 },
|
||||
projectionRealtime: { outboxTailBatchSize: 100, sseHeartbeatMs: 1000, sseDrainTimeoutMs: 5000 },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -96,16 +93,16 @@ function envValues(workload: any) {
|
||||
return Object.fromEntries(workload.spec.template.spec.containers[0].env.map((item: any) => [item.name, item.value]));
|
||||
}
|
||||
|
||||
function authorityValues(workload: any) {
|
||||
const env = envValues(workload);
|
||||
return [
|
||||
env.HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED,
|
||||
env.HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED,
|
||||
env.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED,
|
||||
env.HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED,
|
||||
env.HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED,
|
||||
env.HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED,
|
||||
];
|
||||
function architectureEnvNames(workload: any) {
|
||||
const forbidden = new Set([
|
||||
"HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED",
|
||||
"HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED",
|
||||
"HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED",
|
||||
"HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED",
|
||||
"HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED",
|
||||
]);
|
||||
return workload.spec.template.spec.containers[0].env.map((item: any) => item.name).filter((name: string) => forbidden.has(name));
|
||||
}
|
||||
|
||||
function snapshot(runtimeDir: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function runtimeGitopsScriptsConfigMap({ overlay, scriptsDir, namespace, configMapName }) {
|
||||
export function runtimeGitopsScriptsConfigMap({ overlay, scriptsDir, featureConfigValidatorPath = null, ajvBundlePath = null, namespace, configMapName }) {
|
||||
const data = {
|
||||
"runtime-gitops-overlay.json": `${JSON.stringify({
|
||||
runtimePath: requiredString(overlay.runtimePath, "overlay.runtimePath"),
|
||||
@@ -9,9 +9,15 @@ export function runtimeGitopsScriptsConfigMap({ overlay, scriptsDir, namespace,
|
||||
codeAgentRuntime: objectOr(overlay.codeAgentRuntime),
|
||||
})}\n`,
|
||||
};
|
||||
for (const name of ["runtime-gitops-observability.mjs", "runtime-gitops-postprocess.mjs", "runtime-gitops-verify.mjs"]) {
|
||||
for (const name of [
|
||||
"runtime-gitops-observability.mjs",
|
||||
"runtime-gitops-postprocess.mjs",
|
||||
"runtime-gitops-verify.mjs",
|
||||
]) {
|
||||
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");
|
||||
return {
|
||||
apiVersion: "v1",
|
||||
kind: "ConfigMap",
|
||||
|
||||
@@ -13,21 +13,22 @@ afterEach(() => {
|
||||
test("preserves codeAgentRuntime in the owning runtime GitOps overlay", () => {
|
||||
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"]) {
|
||||
for (const name of [
|
||||
"runtime-gitops-observability.mjs",
|
||||
"runtime-gitops-postprocess.mjs",
|
||||
"runtime-gitops-verify.mjs",
|
||||
"feature-config-schema-warning.mjs",
|
||||
"ajv2020.min.js",
|
||||
]) {
|
||||
writeFileSync(join(scriptsDir, name), `${name}\n`);
|
||||
}
|
||||
const codeAgentRuntime = {
|
||||
enabled: true,
|
||||
kafkaEventBridge: {
|
||||
enabled: true,
|
||||
features: {
|
||||
directPublish: false,
|
||||
liveKafkaSse: false,
|
||||
kafkaRefreshReplay: false,
|
||||
transactionalProjector: true,
|
||||
projectionOutboxRelay: true,
|
||||
projectionRealtime: true,
|
||||
},
|
||||
transactionalProjector: { heartbeatIntervalMs: 2000 },
|
||||
projectionOutboxRelay: { intervalMs: 250 },
|
||||
projectionRealtime: { sseHeartbeatMs: 1000 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,4 +44,6 @@ test("preserves codeAgentRuntime in the owning runtime GitOps overlay", () => {
|
||||
observability: { prometheusOperator: false },
|
||||
codeAgentRuntime,
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -26,8 +26,6 @@ if (overlay?.observability?.prometheusOperator === false) {
|
||||
}
|
||||
if (overlay?.codeAgentRuntime?.enabled && overlay.codeAgentRuntime.kafkaEventBridge?.enabled) {
|
||||
checks.push("code-agent-runtime-kafka-event-bridge-enabled");
|
||||
verifyTransactionalProjectionAuthority(overlay.codeAgentRuntime.kafkaEventBridge);
|
||||
verifyCodeAgentRuntimeWorkloads();
|
||||
}
|
||||
|
||||
console.error(JSON.stringify({ event: "unidesk-runtime-gitops-verify", ok: true, runtimePath, checks }));
|
||||
@@ -43,63 +41,6 @@ function findPrometheusOperatorResources() {
|
||||
return refs;
|
||||
}
|
||||
|
||||
function verifyTransactionalProjectionAuthority(kafka) {
|
||||
const expected = {
|
||||
directPublish: false,
|
||||
liveKafkaSse: false,
|
||||
kafkaRefreshReplay: false,
|
||||
transactionalProjector: true,
|
||||
projectionOutboxRelay: true,
|
||||
projectionRealtime: true,
|
||||
};
|
||||
const actual = Object.fromEntries(Object.keys(expected).map((name) => [name, kafka.features?.[name]]));
|
||||
const invalid = Object.entries(expected).filter(([name, value]) => actual[name] !== value).map(([name]) => name);
|
||||
if (invalid.length > 0) fail("kafka-event-bridge-authority-invalid", { invalid, expected, actual });
|
||||
}
|
||||
|
||||
function verifyCodeAgentRuntimeWorkloads() {
|
||||
const expectedByWorkload = {
|
||||
"hwlab-cloud-api": {
|
||||
HWLAB_KAFKA_ENABLED: "true",
|
||||
HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED: "true",
|
||||
HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "false",
|
||||
HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "false",
|
||||
HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "false",
|
||||
HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "true",
|
||||
HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "true",
|
||||
HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "true",
|
||||
},
|
||||
"hwlab-cloud-web": {
|
||||
HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "false",
|
||||
HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "false",
|
||||
HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "true",
|
||||
},
|
||||
};
|
||||
const found = new Set();
|
||||
for (const file of listYamlFiles(runtimeDir)) {
|
||||
const rel = path.relative(repoDir, file);
|
||||
for (const document of parseStructuredDocuments(readFileSync(file, "utf8"), file)) {
|
||||
const items = isKubernetesList(document) ? document.items : [document];
|
||||
for (const item of items) {
|
||||
const workloadName = String(item?.metadata?.labels?.["app.kubernetes.io/name"] ?? item?.metadata?.name ?? "");
|
||||
const expected = expectedByWorkload[workloadName];
|
||||
if (!expected) continue;
|
||||
const container = item?.spec?.template?.spec?.containers?.find((candidate) => candidate?.name === workloadName);
|
||||
if (!container) fail("code-agent-runtime-container-missing", { file: rel, workload: workloadName, container: workloadName });
|
||||
if (!Array.isArray(container.env)) fail("code-agent-runtime-env-missing", { file: rel, workload: workloadName, container: workloadName });
|
||||
const actual = Object.fromEntries(container.env.filter((item) => typeof item?.name === "string").map((item) => [item.name, item.value]));
|
||||
const invalid = Object.entries(expected)
|
||||
.filter(([name, value]) => actual[name] !== value)
|
||||
.map(([name, value]) => ({ name, expected: value, actual: actual[name] ?? null }));
|
||||
if (invalid.length > 0) fail("code-agent-runtime-capability-env-invalid", { file: rel, workload: workloadName, container: workloadName, invalid });
|
||||
found.add(workloadName);
|
||||
}
|
||||
}
|
||||
}
|
||||
const missing = Object.keys(expectedByWorkload).filter((workloadName) => !found.has(workloadName));
|
||||
if (missing.length > 0) fail("code-agent-runtime-workload-missing", { runtimePath, missing });
|
||||
}
|
||||
|
||||
function splitYamlDocuments(text) {
|
||||
return text.split(/^---[ \t]*(?:#.*)?$/mu).map((doc) => doc.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -11,101 +11,28 @@ afterEach(() => {
|
||||
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("accepts the complete transactional projection capability env", () => {
|
||||
const fixture = createFixture();
|
||||
const result = runVerify(fixture.root);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('"code-agent-runtime-kafka-event-bridge-enabled"');
|
||||
});
|
||||
|
||||
test("fails closed when a required workload capability env is missing", () => {
|
||||
const fixture = createFixture({ omitWebEnv: "HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED" });
|
||||
const result = runVerify(fixture.root);
|
||||
expect(result.status).toBe(48);
|
||||
expect(result.stderr).toContain('"reason":"code-agent-runtime-capability-env-invalid"');
|
||||
expect(result.stderr).toContain('"name":"HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED"');
|
||||
});
|
||||
|
||||
test("fails closed when a required workload was not matched", () => {
|
||||
const fixture = createFixture({ omitWebWorkload: true });
|
||||
const result = runVerify(fixture.root);
|
||||
expect(result.status).toBe(48);
|
||||
expect(result.stderr).toContain('"reason":"code-agent-runtime-workload-missing"');
|
||||
expect(result.stderr).toContain('"hwlab-cloud-web"');
|
||||
});
|
||||
|
||||
test("fails closed when the transactional projection authority is incomplete", () => {
|
||||
const fixture = createFixture({ featureOverrides: { projectionOutboxRelay: false } });
|
||||
const result = runVerify(fixture.root);
|
||||
expect(result.status).toBe(48);
|
||||
expect(result.stderr).toContain('"reason":"kafka-event-bridge-authority-invalid"');
|
||||
expect(result.stderr).toContain('"projectionOutboxRelay"');
|
||||
});
|
||||
|
||||
function createFixture(options: { omitWebEnv?: string; omitWebWorkload?: boolean; featureOverrides?: Record<string, boolean> } = {}) {
|
||||
test("architecture capability env and workload matches are not runtime GitOps gates", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "runtime-gitops-verify-"));
|
||||
roots.push(root);
|
||||
const runtimeDir = join(root, "runtime");
|
||||
mkdirSync(runtimeDir);
|
||||
writeFileSync(join(root, "overlay.json"), JSON.stringify(overlay(options.featureOverrides)));
|
||||
writeFileSync(join(runtimeDir, "cloud-api.yaml"), deploymentYaml("hwlab-cloud-api", cloudApiEnv()));
|
||||
if (!options.omitWebWorkload) {
|
||||
writeFileSync(join(runtimeDir, "cloud-web.yaml"), deploymentYaml("hwlab-cloud-web", cloudWebEnv().filter(([name]) => name !== options.omitWebEnv)));
|
||||
}
|
||||
return { root };
|
||||
}
|
||||
mkdirSync(join(root, "runtime"));
|
||||
writeFileSync(join(root, "runtime", "unrelated.yaml"), "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: unrelated\n");
|
||||
writeFileSync(join(root, "overlay.json"), JSON.stringify({
|
||||
runtimePath: "runtime",
|
||||
codeAgentRuntime: {
|
||||
enabled: true,
|
||||
kafkaEventBridge: { enabled: true },
|
||||
},
|
||||
}));
|
||||
|
||||
function runVerify(root: string) {
|
||||
return spawnSync(process.execPath, [script], {
|
||||
const result = spawnSync(process.execPath, [script], {
|
||||
cwd: root,
|
||||
env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE: join(root, "overlay.json") },
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
function overlay(featureOverrides: Record<string, boolean> = {}) {
|
||||
return {
|
||||
runtimePath: "runtime",
|
||||
codeAgentRuntime: {
|
||||
enabled: true,
|
||||
kafkaEventBridge: {
|
||||
enabled: true,
|
||||
features: {
|
||||
directPublish: false,
|
||||
liveKafkaSse: false,
|
||||
kafkaRefreshReplay: false,
|
||||
transactionalProjector: true,
|
||||
projectionOutboxRelay: true,
|
||||
projectionRealtime: true,
|
||||
...featureOverrides,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cloudApiEnv(): Array<[string, string]> {
|
||||
return [
|
||||
["HWLAB_KAFKA_ENABLED", "true"],
|
||||
["HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", "true"],
|
||||
["HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED", "false"],
|
||||
["HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", "false"],
|
||||
["HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", "false"],
|
||||
["HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED", "true"],
|
||||
["HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED", "true"],
|
||||
["HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", "true"],
|
||||
];
|
||||
}
|
||||
|
||||
function cloudWebEnv(): Array<[string, string]> {
|
||||
return [
|
||||
["HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", "false"],
|
||||
["HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", "false"],
|
||||
["HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", "true"],
|
||||
];
|
||||
}
|
||||
|
||||
function deploymentYaml(name: string, env: Array<[string, string]>) {
|
||||
const envYaml = env.map(([envName, value]) => ` - name: ${envName}\n value: "${value}"`).join("\n");
|
||||
return `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: ${name}\n labels:\n app.kubernetes.io/name: ${name}\nspec:\n template:\n spec:\n containers:\n - name: ${name}\n env:\n${envYaml}\n`;
|
||||
}
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('"code-agent-runtime-kafka-event-bridge-enabled"');
|
||||
expect(result.stderr).not.toContain("capability-env-invalid");
|
||||
expect(result.stderr).not.toContain("workload-missing");
|
||||
expect(result.stderr).not.toContain("authority-invalid");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user