Merge remote-tracking branch 'origin/master' into feat/pikaoa-platform
# Conflicts: # config/secrets-distribution.yaml # docs/MDTODO/agentrun-runtime-reliability.md # scripts/src/platform-infra-pipelines-as-code.ts
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);
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
#!/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;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
featureConfigOtelPayload,
|
||||
runFeatureConfigSchemaValidation,
|
||||
validateFeatureConfigSchema,
|
||||
} 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 });
|
||||
});
|
||||
|
||||
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("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({
|
||||
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 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",
|
||||
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`;
|
||||
}
|
||||
@@ -336,10 +336,16 @@ function injectRuntimeGitopsGuard(pipeline) {
|
||||
const step = steps.find((item) => typeof recordOrNull(item)?.script === "string" && item.script.includes("scripts/gitops-render.mjs"));
|
||||
if (!step) throw new Error("runtime GitOps guard injection failed: gitops-promote render step missing");
|
||||
const originalScript = String(step.script);
|
||||
let script = patchNoBuildNoRolloutSkip(originalScript);
|
||||
const noBuildSkipPatched = script !== originalScript;
|
||||
let script = patchNoBuildNoRolloutCondition(originalScript);
|
||||
const noBuildConditionPatched = script !== originalScript;
|
||||
const beforeSkipPatch = script;
|
||||
script = patchNoBuildNoRolloutSkip(script);
|
||||
const noBuildSkipPatched = script !== beforeSkipPatch;
|
||||
const beforeInjection = script;
|
||||
script = injectRuntimeGitopsCommands(script);
|
||||
if (hasWillRunGitopsPromoteCoupling(script)) {
|
||||
throw new Error("runtime GitOps guard injection failed: no-build-no-rollout marker remains coupled to willRunGitopsPromote");
|
||||
}
|
||||
if (hasNoBuildNoRolloutEarlyExit(script)) {
|
||||
throw new Error("runtime GitOps guard injection failed: no-build-no-rollout-plan early exit remains after patch");
|
||||
}
|
||||
@@ -354,12 +360,20 @@ function injectRuntimeGitopsGuard(pipeline) {
|
||||
present: true,
|
||||
configMapName: runtimeGitopsConfigMapName,
|
||||
stepName: stringOrNull(step.name),
|
||||
noBuildConditionPatched,
|
||||
noBuildSkipPatched,
|
||||
postprocessInjected,
|
||||
verifyInjected,
|
||||
};
|
||||
}
|
||||
|
||||
function patchNoBuildNoRolloutCondition(script) {
|
||||
return String(script).replace(
|
||||
/const willRunGitopsPromote = plan\.ciCdPlan && plan\.ciCdPlan\.willRunGitopsPromote === true;\nprocess\.exit\(!willRunGitopsPromote && buildServices\.length === 0 && affectedServices\.length === 0 \? 0 : 1\);/u,
|
||||
"process.exit(buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);",
|
||||
);
|
||||
}
|
||||
|
||||
function patchNoBuildNoRolloutSkip(script) {
|
||||
return String(script).replace(
|
||||
/(^|\n)([ \t]*)echo '\{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"\}'(?:\s*>\s*&2)?\n[ \t]*exit 0(?=\n|$)/u,
|
||||
@@ -367,6 +381,10 @@ function patchNoBuildNoRolloutSkip(script) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasWillRunGitopsPromoteCoupling(script) {
|
||||
return /process\.exit\(!willRunGitopsPromote && buildServices\.length === 0 && affectedServices\.length === 0 \? 0 : 1\);/u.test(String(script));
|
||||
}
|
||||
|
||||
function hasNoBuildNoRolloutEarlyExit(script) {
|
||||
return /echo '\{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"\}'(?:\s*>\s*&2)?\n[ \t]*exit 0(?=\n|$)/u.test(String(script));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -25,6 +25,41 @@ function exactSkipMarker(records, event) {
|
||||
&& item.reason === "no-build-no-rollout-plan") || null;
|
||||
}
|
||||
|
||||
function exactRuntimeGitopsVerifyMarker(records) {
|
||||
return [...records].reverse().find((item) => item.event === "gitops-promote"
|
||||
&& item.status === "continuing"
|
||||
&& 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) {
|
||||
@@ -362,13 +397,15 @@ 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");
|
||||
const runtimeGitopsVerifyMarker = exactRuntimeGitopsVerifyMarker(records);
|
||||
const planTask = exactTaskTerminal(records, "plan-artifacts");
|
||||
const collectTask = exactTaskTerminal(records, "collect-artifacts");
|
||||
const promoteTask = exactTaskTerminal(records, "gitops-promote");
|
||||
if (plan === null && collectMarker === null && promoteMarker === null && planTask === null && collectTask === null && promoteTask === null) return null;
|
||||
if (plan === null && collectMarker === null && promoteMarker === null && runtimeGitopsVerifyMarker === null && planTask === null && collectTask === null && promoteTask === null) return null;
|
||||
|
||||
const affected = stringArray(plan?.affectedServices);
|
||||
const rollout = stringArray(plan?.rolloutServices);
|
||||
@@ -384,6 +421,8 @@ function extractPacSourceObservation(recordsInput) {
|
||||
const noRuntimeChangeMarkers = collectMarker !== null && promoteMarker !== null;
|
||||
const skipMarkerSeen = collectMarker !== null || promoteMarker !== null;
|
||||
const skipMarkerConflict = skipMarkerSeen && !noRuntimeChangePlan;
|
||||
const runtimeGitopsVerifyMarkerConflict = runtimeGitopsVerifyMarker !== null
|
||||
&& (!noRuntimeChangePlan || promoteMarker !== null);
|
||||
const deliveryTerminalReady = planTask?.status === "succeeded"
|
||||
&& collectTask?.status === "succeeded"
|
||||
&& promoteTask?.status === "succeeded";
|
||||
@@ -394,11 +433,15 @@ function extractPacSourceObservation(recordsInput) {
|
||||
let mode = "inconsistent";
|
||||
let valid = false;
|
||||
let reason = "structured-plan-or-terminal-marker-is-incomplete";
|
||||
if (planShapeValid && sourceCommitValid && noRuntimeChangePlan && noRuntimeChangeMarkers) {
|
||||
if (planShapeValid && sourceCommitValid && noRuntimeChangePlan && noRuntimeChangeMarkers && runtimeGitopsVerifyMarker === null) {
|
||||
mode = "no-runtime-change";
|
||||
valid = true;
|
||||
reason = "no-build-no-rollout-plan";
|
||||
} else if (planShapeValid && sourceCommitValid && !noRuntimeChangePlan && !skipMarkerConflict && deliveryTerminalReady) {
|
||||
} else if (planShapeValid && sourceCommitValid && noRuntimeChangePlan && runtimeGitopsVerifyMarker !== null && !runtimeGitopsVerifyMarkerConflict && deliveryTerminalReady) {
|
||||
mode = "runtime-gitops-verify";
|
||||
valid = true;
|
||||
reason = "no-build-no-rollout-plan-gitops-verify";
|
||||
} else if (planShapeValid && sourceCommitValid && !noRuntimeChangePlan && runtimeGitopsVerifyMarker === null && !skipMarkerConflict && deliveryTerminalReady) {
|
||||
mode = "delivery";
|
||||
valid = true;
|
||||
reason = "artifact-or-runtime-delivery-required";
|
||||
@@ -406,10 +449,14 @@ function extractPacSourceObservation(recordsInput) {
|
||||
reason = "source-commit-invalid-or-missing";
|
||||
} else if (!planShapeValid) {
|
||||
reason = "delivery-plan-shape-invalid-or-missing";
|
||||
} else if (runtimeGitopsVerifyMarkerConflict) {
|
||||
reason = "runtime-gitops-verify-marker-conflicts-with-plan-or-skip-marker";
|
||||
} else if (skipMarkerConflict) {
|
||||
reason = "no-runtime-change-marker-conflicts-with-delivery-plan";
|
||||
} else if (deliveryTerminalFailed) {
|
||||
reason = "delivery-terminal-failed";
|
||||
} else if (noRuntimeChangePlan && runtimeGitopsVerifyMarker !== null) {
|
||||
reason = "runtime-gitops-verify-terminal-evidence-incomplete";
|
||||
} else if (!noRuntimeChangePlan) {
|
||||
reason = "delivery-terminal-evidence-incomplete";
|
||||
}
|
||||
@@ -440,8 +487,11 @@ function extractPacSourceObservation(recordsInput) {
|
||||
terminalSources: {
|
||||
planArtifacts: terminalSource(planTask, plan, "observed", planMarkerSource),
|
||||
collectArtifacts: terminalSource(collectTask, collectMarker),
|
||||
gitopsPromote: terminalSource(promoteTask, promoteMarker),
|
||||
gitopsPromote: runtimeGitopsVerifyMarker === null
|
||||
? terminalSource(promoteTask, promoteMarker)
|
||||
: terminalSource(promoteTask, runtimeGitopsVerifyMarker, "continuing", "runtime-gitops-verify-structured-log"),
|
||||
},
|
||||
featureConfigSchema,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -602,14 +652,14 @@ function evaluatePacStatus(inputValue) {
|
||||
const deliveryDisabled = artifact.imageStatus === "disabled";
|
||||
const deliverySkipped = artifact.imageStatus === "skipped";
|
||||
const sourceObservationMode = stringOrNull(sourceObservation.mode);
|
||||
const hwlabCatalogDelivery = sourceObservationMode === "delivery"
|
||||
const hwlabCatalogDelivery = (sourceObservationMode === "delivery" || sourceObservationMode === "runtime-gitops-verify")
|
||||
&& sourceObservation.contract === "hwlab-g14-ci-plan";
|
||||
const noRuntimeChange = sourceObservationMode === "no-runtime-change";
|
||||
const catalog = record(artifact.catalog);
|
||||
const catalogPresent = catalog.present === true;
|
||||
const catalogSourceCommit = stringOrNull(catalog.sourceCommit);
|
||||
const catalogSourceMatches = catalogSourceCommit !== null && catalogSourceCommit === sourceCommit;
|
||||
const catalogDeliveryEvidence = sourceObservationMode === "delivery"
|
||||
const catalogDeliveryEvidence = (sourceObservationMode === "delivery" || sourceObservationMode === "runtime-gitops-verify")
|
||||
&& sourceObservation.valid === true
|
||||
&& catalogPresent
|
||||
&& catalog.status === "published"
|
||||
@@ -892,6 +942,25 @@ function deliveryObservation(sourceCommit = "c".repeat(40)) {
|
||||
]);
|
||||
}
|
||||
|
||||
function runtimeGitopsVerifyObservation(sourceCommit = "c".repeat(40)) {
|
||||
return extractPacSourceObservation([
|
||||
{
|
||||
event: "g14-ci-plan",
|
||||
sourceCommitId: sourceCommit,
|
||||
affectedServices: [],
|
||||
rolloutServices: [],
|
||||
buildServices: [],
|
||||
reusedServices: Array.from({ length: 8 }, (_item, index) => `service-${index}`),
|
||||
noImageBuildReason: "test-only-change",
|
||||
},
|
||||
{ event: "collect-artifacts", status: "skipped", reason: "no-build-no-rollout-plan" },
|
||||
{ event: "gitops-promote", status: "continuing", reason: "no-build-no-rollout-plan-gitops-verify" },
|
||||
{ event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
{ event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
{ event: "pac-task-terminal", pipelineTask: "gitops-promote", taskRun: "promote", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
]);
|
||||
}
|
||||
|
||||
function publishedCatalog(sourceCommit = "c".repeat(40)) {
|
||||
return { present: true, status: "published", sourceCommit, registryVerified: true };
|
||||
}
|
||||
@@ -902,6 +971,7 @@ function runPacStatusFixtureChecks() {
|
||||
const revision = "b".repeat(40);
|
||||
const retained = noRuntimeChangeObservation();
|
||||
const delivery = deliveryObservation();
|
||||
const runtimeGitopsVerify = runtimeGitopsVerifyObservation();
|
||||
const exactArgo = { sync: "Synced", health: "Healthy", revision, revisionRelation: { relation: "exact", expectedRevision: revision, observedRevision: revision } };
|
||||
const cases = [
|
||||
{
|
||||
@@ -910,6 +980,24 @@ function runPacStatusFixtureChecks() {
|
||||
expectedCode: "pac-ready-no-runtime-change",
|
||||
input: fixtureInput({ artifact: { imageStatus: "retained", sourceObservation: retained } }),
|
||||
},
|
||||
{
|
||||
id: "runtime-gitops-verify-ready",
|
||||
expectedOk: true,
|
||||
expectedCode: "pac-ready-gitops-exact",
|
||||
input: fixtureInput({ artifact: { imageStatus: "reused", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: runtimeGitopsVerify, catalog: publishedCatalog() }, argo: exactArgo }),
|
||||
},
|
||||
{
|
||||
id: "runtime-gitops-verify-catalog-missing",
|
||||
expectedOk: false,
|
||||
expectedCode: "pac-artifact-catalog-missing",
|
||||
input: fixtureInput({ artifact: { imageStatus: "reused", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: runtimeGitopsVerify }, argo: exactArgo }),
|
||||
},
|
||||
{
|
||||
id: "runtime-gitops-verify-gitops-missing",
|
||||
expectedOk: false,
|
||||
expectedCode: "pac-gitops-missing",
|
||||
input: fixtureInput({ artifact: { imageStatus: "reused", digest: digestA, digests: [digestA], sourceObservation: runtimeGitopsVerify, catalog: publishedCatalog() }, argo: exactArgo }),
|
||||
},
|
||||
{
|
||||
id: "retained-gitops-missing",
|
||||
expectedOk: false,
|
||||
@@ -1000,6 +1088,55 @@ function runPacStatusFixtureChecks() {
|
||||
expectedCode: "pac-source-observation-inconsistent",
|
||||
input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([{ event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: [], rolloutServices: [], buildServices: [], reusedServices: [] }]) } }),
|
||||
},
|
||||
{
|
||||
id: "runtime-gitops-verify-marker-missing",
|
||||
expectedOk: false,
|
||||
expectedCode: "pac-source-observation-inconsistent",
|
||||
input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([
|
||||
{ event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: [], rolloutServices: [], buildServices: [], reusedServices: ["service"] },
|
||||
{ event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
{ event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
{ event: "pac-task-terminal", pipelineTask: "gitops-promote", taskRun: "promote", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
]) } }),
|
||||
},
|
||||
{
|
||||
id: "runtime-gitops-verify-promote-marker-conflict",
|
||||
expectedOk: false,
|
||||
expectedCode: "pac-source-observation-inconsistent",
|
||||
input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([
|
||||
{ event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: [], rolloutServices: [], buildServices: [], reusedServices: ["service"] },
|
||||
{ event: "collect-artifacts", status: "skipped", reason: "no-build-no-rollout-plan" },
|
||||
{ event: "gitops-promote", status: "skipped", reason: "no-build-no-rollout-plan" },
|
||||
{ event: "gitops-promote", status: "continuing", reason: "no-build-no-rollout-plan-gitops-verify" },
|
||||
{ event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
{ event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
{ event: "pac-task-terminal", pipelineTask: "gitops-promote", taskRun: "promote", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
]) } }),
|
||||
},
|
||||
{
|
||||
id: "runtime-gitops-verify-nonzero-plan-conflict",
|
||||
expectedOk: false,
|
||||
expectedCode: "pac-source-observation-inconsistent",
|
||||
input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([
|
||||
{ event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: ["service"], rolloutServices: ["service"], buildServices: [], reusedServices: ["service"] },
|
||||
{ event: "gitops-promote", status: "continuing", reason: "no-build-no-rollout-plan-gitops-verify" },
|
||||
{ event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
{ event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
{ event: "pac-task-terminal", pipelineTask: "gitops-promote", taskRun: "promote", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
]) } }),
|
||||
},
|
||||
{
|
||||
id: "runtime-gitops-verify-terminal-evidence-missing",
|
||||
expectedOk: false,
|
||||
expectedCode: "pac-source-observation-inconsistent",
|
||||
input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([
|
||||
{ event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: [], rolloutServices: [], buildServices: [], reusedServices: ["service"] },
|
||||
{ event: "collect-artifacts", status: "skipped", reason: "no-build-no-rollout-plan" },
|
||||
{ event: "gitops-promote", status: "continuing", reason: "no-build-no-rollout-plan-gitops-verify" },
|
||||
{ event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
{ event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] },
|
||||
]) } }),
|
||||
},
|
||||
{
|
||||
id: "retained-source-mismatch",
|
||||
expectedOk: false,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { runtimeGitopsScriptsConfigMap } from "../hwlab/runtime-gitops-scripts-configmap.mjs";
|
||||
import { hwlabRuntimeLaneSpecForNode } from "../../src/hwlab-node-lanes.ts";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
@@ -43,6 +45,49 @@ function safeReleaseStatePath(value) {
|
||||
return path;
|
||||
}
|
||||
|
||||
function safeResourceManifestPath(value, pathLabel) {
|
||||
const path = required(value, pathLabel);
|
||||
if (path.startsWith("/") || path.split("/").includes("..") || !path.endsWith(".yaml")) throw new Error(`${pathLabel} must be a safe relative .yaml path`);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function renderGitOpsResources(gitops, sourceRoot) {
|
||||
if (gitops.resources === undefined) return [];
|
||||
if (!Array.isArray(gitops.resources)) throw new Error("delivery.gitops.resources must be an array");
|
||||
return gitops.resources.map((value, index) => {
|
||||
const pathLabel = `delivery.gitops.resources[${index}]`;
|
||||
const resource = record(value, pathLabel);
|
||||
const renderer = required(resource.renderer, `${pathLabel}.renderer`);
|
||||
if (renderer !== "hwlab-runtime-gitops-scripts") throw new Error(`${pathLabel}.renderer is unsupported: ${renderer}`);
|
||||
const configRef = required(resource.configRef, `${pathLabel}.configRef`);
|
||||
const match = /^config\/hwlab-node-lanes\.yaml#lanes\.([^.]+)\.targets\.([^.]+)$/u.exec(configRef);
|
||||
if (match === null) throw new Error(`${pathLabel}.configRef must select config/hwlab-node-lanes.yaml#lanes.<lane>.targets.<node>`);
|
||||
const lane = match[1];
|
||||
const node = match[2];
|
||||
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
|
||||
const overlay = {
|
||||
nodeId: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
runtimePath: spec.runtimePath,
|
||||
observability: spec.observability,
|
||||
};
|
||||
const configMapName = `${spec.pipeline}-runtime-gitops-scripts`;
|
||||
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, "scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js"),
|
||||
namespace: required(resource.namespace, `${pathLabel}.namespace`),
|
||||
configMapName,
|
||||
}));
|
||||
return {
|
||||
id: required(resource.id, `${pathLabel}.id`),
|
||||
path: safeResourceManifestPath(resource.manifestPath, `${pathLabel}.manifestPath`),
|
||||
content: `${manifest.trimEnd()}\n`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function releaseValue(releaseDir, name) {
|
||||
return required(readFileSync(resolve(releaseDir, name), "utf8").trim(), `${releaseDir}/${name}`);
|
||||
}
|
||||
@@ -100,6 +145,7 @@ const writeUrl = required(gitops.writeUrl, "delivery.gitops.writeUrl");
|
||||
const branch = required(gitops.branch, "delivery.gitops.branch");
|
||||
const manifestPath = safeManifestPath(gitops.manifestPath);
|
||||
const releaseStatePath = safeReleaseStatePath(gitops.releaseStatePath);
|
||||
const gitopsResources = action === "skip" ? [] : renderGitOpsResources(gitops, sourceRoot);
|
||||
let digest = null;
|
||||
let digestRef = null;
|
||||
let manifest = null;
|
||||
@@ -146,6 +192,26 @@ if (action === "skip") {
|
||||
digest = existingState.digest;
|
||||
digestRef = existingState.digestRef;
|
||||
runtimeSourceCommit = existingState.sourceCommit;
|
||||
const gitopsCommit = run("git", ["rev-parse", "HEAD"], worktree).stdout.trim();
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
phase: "gitops-publish",
|
||||
action,
|
||||
reason,
|
||||
status: "skipped",
|
||||
imageStatus: "skipped",
|
||||
sourceCommit,
|
||||
baselineSourceCommit,
|
||||
runtimeSourceCommit,
|
||||
digest,
|
||||
digestRef,
|
||||
gitopsCommit,
|
||||
resources: [],
|
||||
changed: false,
|
||||
pushAttempts: 0,
|
||||
valuesPrinted: false,
|
||||
})}\n`);
|
||||
return;
|
||||
} else if (action === "build" && manifest !== null) {
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
writeFileSync(targetPath, manifest, "utf8");
|
||||
@@ -163,12 +229,23 @@ if (action === "skip") {
|
||||
removeFile(statePath);
|
||||
}
|
||||
|
||||
for (const resource of gitopsResources) {
|
||||
const resourcePath = resolve(worktree, resource.path);
|
||||
if (!resourcePath.startsWith(`${worktree}/`)) throw new Error(`resolved resource path escaped the GitOps worktree: ${resource.id}`);
|
||||
if (enabled) {
|
||||
mkdirSync(dirname(resourcePath), { recursive: true });
|
||||
writeFileSync(resourcePath, resource.content, "utf8");
|
||||
} else {
|
||||
removeFile(resourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
run("git", ["config", "user.name", required(author.name, "delivery.gitops.author.name")], worktree);
|
||||
run("git", ["config", "user.email", required(author.email, "delivery.gitops.author.email")], worktree);
|
||||
run("git", ["add", "-A"], worktree);
|
||||
const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0;
|
||||
if (changed) {
|
||||
const commitAction = action === "build" ? "deploy" : "remove";
|
||||
const commitAction = action === "disabled" ? "remove" : "deploy";
|
||||
run("git", ["commit", "-m", `unidesk-host: ${commitAction} ${serviceRef} ${sourceCommit.slice(0, 12)}`], worktree);
|
||||
}
|
||||
|
||||
@@ -208,10 +285,11 @@ process.stdout.write(`${JSON.stringify({
|
||||
digest,
|
||||
digestRef,
|
||||
gitopsCommit,
|
||||
resources: gitopsResources.map((resource) => ({ id: resource.id, path: resource.path })),
|
||||
changed,
|
||||
pushAttempts,
|
||||
valuesPrinted: false,
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
if (!process.execArgv.includes("--check")) main();
|
||||
if (import.meta.main && !process.execArgv.includes("--check")) main();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { rootPath } from "../../src/config";
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("UniDesk Host GitOps publisher", () => {
|
||||
test("skip preserves the GitOps branch and every managed resource", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "unidesk-host-gitops-skip-"));
|
||||
temporaryRoots.push(root);
|
||||
const seed = join(root, "seed");
|
||||
const bare = join(root, "gitops.git");
|
||||
const releaseDir = join(root, "release");
|
||||
const publisherWorktree = join(root, "publisher");
|
||||
const configPath = join(root, "config.yaml");
|
||||
const statePath = "deploy/gitops-state/unidesk-host/todo-note.json";
|
||||
const resourcePath = "deploy/gitops/unidesk-host/hwlab-runtime-gitops-scripts.yaml";
|
||||
const runtimeSourceCommit = "a".repeat(40);
|
||||
const sourceCommit = "b".repeat(40);
|
||||
const digest = `sha256:${"c".repeat(64)}`;
|
||||
|
||||
mkdirSync(seed);
|
||||
git(["init", "-b", "unidesk-host-gitops"], seed);
|
||||
identity(seed);
|
||||
write(seed, "deploy/gitops/unidesk-host/todo-note.yaml", "existing service manifest\n");
|
||||
write(seed, resourcePath, "stale configmap must remain unchanged\n");
|
||||
write(seed, statePath, `${JSON.stringify({
|
||||
version: 1,
|
||||
kind: "UniDeskHostReleaseState",
|
||||
serviceRef: "services.todoNote",
|
||||
sourceCommit: runtimeSourceCommit,
|
||||
digest,
|
||||
digestRef: `registry.example/todo-note@${digest}`,
|
||||
}, null, 2)}\n`);
|
||||
git(["add", "."], seed);
|
||||
git(["commit", "-m", "seed gitops"], seed);
|
||||
const originalHead = git(["rev-parse", "HEAD"], seed).stdout.trim();
|
||||
git(["init", "--bare", bare], root);
|
||||
git(["remote", "add", "origin", `file://${bare}`], seed);
|
||||
git(["push", "origin", "unidesk-host-gitops"], seed);
|
||||
|
||||
mkdirSync(releaseDir);
|
||||
writeFileSync(join(releaseDir, "action"), "skip\n");
|
||||
writeFileSync(join(releaseDir, "reason"), "runtime-inputs-unchanged\n");
|
||||
writeFileSync(join(releaseDir, "baseline-source-commit"), `${runtimeSourceCommit}\n`);
|
||||
writeFileSync(configPath, Bun.YAML.stringify({
|
||||
delivery: {
|
||||
enabled: true,
|
||||
serviceRef: "services.todoNote",
|
||||
image: { repository: "registry.example/todo-note" },
|
||||
gitops: {
|
||||
readUrl: `file://${bare}`,
|
||||
writeUrl: `file://${bare}`,
|
||||
branch: "unidesk-host-gitops",
|
||||
manifestPath: "deploy/gitops/unidesk-host/todo-note.yaml",
|
||||
releaseStatePath: statePath,
|
||||
resources: [{
|
||||
id: "hwlab-runtime-gitops-scripts",
|
||||
renderer: "hwlab-runtime-gitops-scripts",
|
||||
configRef: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01",
|
||||
manifestPath: resourcePath,
|
||||
namespace: "hwlab-ci",
|
||||
}],
|
||||
author: { name: "UniDesk Test", email: "unidesk-test@example.invalid" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const result = spawnSync("bun", [
|
||||
"scripts/native/cicd/publish-unidesk-host-gitops.mjs",
|
||||
"--config", configPath,
|
||||
"--source-root", rootPath(),
|
||||
"--metadata", join(root, "unused-metadata.json"),
|
||||
"--release-dir", releaseDir,
|
||||
"--source-commit", sourceCommit,
|
||||
"--worktree", publisherWorktree,
|
||||
], { cwd: rootPath(), encoding: "utf8", timeout: 30_000 });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const output = JSON.parse(result.stdout) as Record<string, any>;
|
||||
expect(output).toMatchObject({ action: "skip", status: "skipped", changed: false, pushAttempts: 0, resources: [] });
|
||||
expect(output.gitopsCommit).toBe(originalHead);
|
||||
expect(git(["rev-parse", "refs/heads/unidesk-host-gitops"], bare).stdout.trim()).toBe(originalHead);
|
||||
expect(show(bare, resourcePath)).toBe("stale configmap must remain unchanged\n");
|
||||
expect(JSON.parse(show(bare, statePath)).sourceCommit).toBe(runtimeSourceCommit);
|
||||
});
|
||||
});
|
||||
|
||||
function write(root: string, relativePath: string, content: string) {
|
||||
const path = join(root, relativePath);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
|
||||
function identity(root: string) {
|
||||
git(["config", "user.name", "UniDesk Test"], root);
|
||||
git(["config", "user.email", "unidesk-test@example.invalid"], root);
|
||||
}
|
||||
|
||||
function show(bare: string, relativePath: string): string {
|
||||
return git(["show", `refs/heads/unidesk-host-gitops:${relativePath}`], bare).stdout;
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string) {
|
||||
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
||||
if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
||||
return result;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { runtimeGitopsScriptsConfigMap } from "./runtime-gitops-scripts-configmap.mjs";
|
||||
|
||||
const requireFromScript = createRequire(import.meta.url);
|
||||
const requireFromCwd = createRequire(path.join(process.cwd(), "package.json"));
|
||||
@@ -53,10 +54,16 @@ function injectRuntimeGitopsGuard(pipeline) {
|
||||
if (!step) throw new Error("runtime GitOps guard injection failed: gitops-promote render step missing");
|
||||
|
||||
const originalScript = String(step.script);
|
||||
let script = patchNoBuildNoRolloutSkip(originalScript);
|
||||
const noBuildSkipPatched = script !== originalScript;
|
||||
let script = patchNoBuildNoRolloutCondition(originalScript);
|
||||
const noBuildConditionPatched = script !== originalScript;
|
||||
const beforeSkipPatch = script;
|
||||
script = patchNoBuildNoRolloutSkip(script);
|
||||
const noBuildSkipPatched = script !== beforeSkipPatch;
|
||||
const beforeInjection = script;
|
||||
script = injectRuntimeGitopsCommands(script);
|
||||
if (hasWillRunGitopsPromoteCoupling(script)) {
|
||||
throw new Error("runtime GitOps guard injection failed: no-build-no-rollout marker remains coupled to willRunGitopsPromote");
|
||||
}
|
||||
if (hasNoBuildNoRolloutEarlyExit(script)) {
|
||||
throw new Error("runtime GitOps guard injection failed: no-build-no-rollout-plan early exit remains after patch");
|
||||
}
|
||||
@@ -69,12 +76,20 @@ function injectRuntimeGitopsGuard(pipeline) {
|
||||
present: true,
|
||||
configMapName,
|
||||
stepName: typeof step.name === "string" ? step.name : null,
|
||||
noBuildConditionPatched,
|
||||
noBuildSkipPatched,
|
||||
postprocessInjected: !hasPostprocess(beforeInjection) && postprocessPresent,
|
||||
verifyInjected: !hasVerify(beforeInjection) && verifyPresent,
|
||||
};
|
||||
}
|
||||
|
||||
function patchNoBuildNoRolloutCondition(script) {
|
||||
return String(script).replace(
|
||||
/const willRunGitopsPromote = plan\.ciCdPlan && plan\.ciCdPlan\.willRunGitopsPromote === true;\nprocess\.exit\(!willRunGitopsPromote && buildServices\.length === 0 && affectedServices\.length === 0 \? 0 : 1\);/u,
|
||||
"process.exit(buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);",
|
||||
);
|
||||
}
|
||||
|
||||
function patchNoBuildNoRolloutSkip(script) {
|
||||
return String(script).replace(
|
||||
/(^|\n)([ \t]*)echo '\{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"\}'(?:\s*>\s*&2)?\n[ \t]*exit 0(?=\n|$)/u,
|
||||
@@ -82,19 +97,21 @@ function patchNoBuildNoRolloutSkip(script) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasWillRunGitopsPromoteCoupling(script) {
|
||||
return /process\.exit\(!willRunGitopsPromote && buildServices\.length === 0 && affectedServices\.length === 0 \? 0 : 1\);/u.test(String(script));
|
||||
}
|
||||
|
||||
function hasNoBuildNoRolloutEarlyExit(script) {
|
||||
return /echo '\{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"\}'(?:\s*>\s*&2)?\n[ \t]*exit 0(?=\n|$)/u.test(String(script));
|
||||
}
|
||||
|
||||
function injectRuntimeGitopsCommands(script) {
|
||||
if (hasPostprocess(script) && hasVerify(script)) return script;
|
||||
const overlayEnv = `UNIDESK_RUNTIME_GITOPS_OVERLAY_B64=${shellSingle(Buffer.from(JSON.stringify({
|
||||
runtimePath: overlay.runtimePath,
|
||||
observability: overlay.observability,
|
||||
}), "utf8").toString("base64"))}`;
|
||||
const overlayEnv = "UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json";
|
||||
const migratedScript = String(script).replace(/UNIDESK_RUNTIME_GITOPS_OVERLAY_B64='[^'\n]*'/gu, overlayEnv);
|
||||
if (hasPostprocess(migratedScript) && hasVerify(migratedScript)) return migratedScript;
|
||||
const postprocess = `${overlayEnv} node /etc/unidesk-cicd-runtime-gitops/runtime-gitops-postprocess.mjs`;
|
||||
const verify = `${overlayEnv} node /etc/unidesk-cicd-runtime-gitops/runtime-gitops-verify.mjs`;
|
||||
return String(script).replace(
|
||||
return migratedScript.replace(
|
||||
/(node scripts\/run-bun\.mjs scripts\/gitops-render\.mjs[^\n]*--use-deploy-images[^\n]*)/g,
|
||||
(match) => {
|
||||
if (match.includes("--check")) return hasVerify(script) ? match : verify;
|
||||
@@ -127,26 +144,9 @@ function ensureRuntimeGitopsScriptsMount(taskSpec, step) {
|
||||
|
||||
function writeScriptsConfigMap(targetPath, namespace) {
|
||||
if (scriptsDir === null) throw new Error("--scripts-dir is required with --scripts-configmap");
|
||||
const data = {};
|
||||
for (const name of ["runtime-gitops-observability.mjs", "runtime-gitops-postprocess.mjs", "runtime-gitops-verify.mjs"]) {
|
||||
data[name] = readFileSync(path.join(scriptsDir, name), "utf8");
|
||||
}
|
||||
writeFileSync(targetPath, `${YAML.stringify({
|
||||
apiVersion: "v1",
|
||||
kind: "ConfigMap",
|
||||
metadata: {
|
||||
name: configMapName,
|
||||
namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/name": "hwlab-runtime-gitops-scripts",
|
||||
"app.kubernetes.io/part-of": "unidesk-hwlab-control-plane",
|
||||
"hwlab.pikastech.local/node": overlay.nodeId ?? null,
|
||||
"hwlab.pikastech.local/lane": overlay.lane ?? null,
|
||||
},
|
||||
},
|
||||
data,
|
||||
}).trimEnd()}\n`, "utf8");
|
||||
return { name: configMapName, namespace, keyCount: Object.keys(data).length, path: targetPath };
|
||||
const configMap = runtimeGitopsScriptsConfigMap({ overlay, scriptsDir, namespace, configMapName });
|
||||
writeFileSync(targetPath, `${YAML.stringify(configMap).trimEnd()}\n`, "utf8");
|
||||
return { name: configMapName, namespace, keyCount: Object.keys(configMap.data).length, path: targetPath };
|
||||
}
|
||||
|
||||
function pipelineNamespace(docs) {
|
||||
@@ -193,10 +193,6 @@ function objectOr(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function shellSingle(value) {
|
||||
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function requireYaml() {
|
||||
try {
|
||||
return requireFromScript("yaml");
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
// SPEC: PJ2026-01060703 CI/CD branch follower runtime GitOps postprocess.
|
||||
// Responsibility: mutate rendered runtime GitOps files after HWLAB source render, before publish.
|
||||
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
const requireFromScript = createRequire(import.meta.url);
|
||||
const requireFromCwd = createRequire(path.join(process.cwd(), "package.json"));
|
||||
const YAML = requireYaml();
|
||||
const repoDir = process.cwd();
|
||||
const overlay = readOverlay();
|
||||
const runtimePath = requiredOverlayString("runtimePath");
|
||||
@@ -20,26 +24,106 @@ emit({ ok: true, runtimePath, ...result });
|
||||
|
||||
function postprocessRuntimeGitops() {
|
||||
const prometheusOperatorDisabled = overlay?.observability?.prometheusOperator === false;
|
||||
if (!prometheusOperatorDisabled) {
|
||||
return { observabilityPrometheusOperator: overlay?.observability?.prometheusOperator ?? null, observabilityWorkloadsChanged: false, kustomizationChanged: false };
|
||||
}
|
||||
const changedFiles = [];
|
||||
const deletedFiles = [];
|
||||
for (const file of listYamlFiles(runtimeDir)) {
|
||||
const changed = stripPrometheusOperatorResourcesFromFile(file);
|
||||
const changed = prometheusOperatorDisabled ? stripPrometheusOperatorResourcesFromFile(file) : "unchanged";
|
||||
if (changed === "deleted") deletedFiles.push(path.relative(repoDir, file));
|
||||
if (changed === "changed") changedFiles.push(path.relative(repoDir, file));
|
||||
}
|
||||
const kustomizationChanged = pruneMissingKustomizationResources();
|
||||
const kustomizationChanged = prometheusOperatorDisabled ? pruneMissingKustomizationResources() : false;
|
||||
const codeAgentRuntimeChanged = patchCodeAgentRuntimeWorkloads();
|
||||
return {
|
||||
observabilityPrometheusOperator: false,
|
||||
observabilityPrometheusOperator: overlay?.observability?.prometheusOperator ?? null,
|
||||
observabilityWorkloadsChanged: changedFiles.length > 0 || deletedFiles.length > 0,
|
||||
kustomizationChanged,
|
||||
codeAgentRuntimeChanged,
|
||||
changedFiles,
|
||||
deletedFiles,
|
||||
};
|
||||
}
|
||||
|
||||
function patchCodeAgentRuntimeWorkloads() {
|
||||
const runtime = overlay?.codeAgentRuntime;
|
||||
if (!runtime?.enabled || !runtime.kafkaEventBridge) return false;
|
||||
const kafka = runtime.kafkaEventBridge;
|
||||
let changed = false;
|
||||
for (const file of listYamlFiles(runtimeDir)) {
|
||||
const documents = parseStructuredDocuments(readFileSync(file, "utf8"), file);
|
||||
let fileChanged = false;
|
||||
for (const document of documents.values) {
|
||||
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 containers = item?.spec?.template?.spec?.containers;
|
||||
if (!Array.isArray(containers)) continue;
|
||||
for (const container of containers) {
|
||||
if (!Array.isArray(container?.env)) continue;
|
||||
if (workloadName === "hwlab-cloud-api" && container.name === "hwlab-cloud-api") {
|
||||
fileChanged = patchCloudApiKafkaEnv(container, kafka) || fileChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fileChanged) {
|
||||
writeFileSync(file, serializeStructuredDocuments(documents), "utf8");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
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)) || 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, {
|
||||
HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS: kafka.transactionalProjector?.heartbeatIntervalMs,
|
||||
}) || changed;
|
||||
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, {
|
||||
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,
|
||||
}) || changed;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function patchOptionalEnvGroup(container, enabled, values) {
|
||||
let changed = false;
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
changed = enabled ? setEnvValue(container, name, String(value)) || changed : removeEnvValue(container, name) || changed;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function setEnvValue(container, name, value) {
|
||||
const existing = container.env.find((item) => item?.name === name);
|
||||
if (existing?.value === value && existing.valueFrom === undefined) return false;
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
delete existing.valueFrom;
|
||||
} else {
|
||||
container.env.push({ name, value });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeEnvValue(container, name) {
|
||||
const next = container.env.filter((item) => item?.name !== name);
|
||||
if (next.length === container.env.length) return false;
|
||||
container.env = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function stripPrometheusOperatorResourcesFromFile(file) {
|
||||
const original = readFileSync(file, "utf8");
|
||||
const jsonResult = stripPrometheusOperatorResourcesFromJsonFile(file, original);
|
||||
@@ -146,6 +230,20 @@ function parseJsonDocument(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseStructuredDocuments(text, file) {
|
||||
const json = parseJsonDocument(text);
|
||||
if (json !== null) return { format: "json", values: [json] };
|
||||
const documents = YAML.parseAllDocuments(text);
|
||||
const errors = documents.flatMap((document) => document.errors);
|
||||
if (errors.length > 0) throw new Error(`invalid YAML in ${file}: ${errors.map((error) => error.message).join("; ")}`);
|
||||
return { format: "yaml", values: documents.map((document) => document.toJS()).filter((document) => document !== null) };
|
||||
}
|
||||
|
||||
function serializeStructuredDocuments(documents) {
|
||||
if (documents.format === "json") return `${JSON.stringify(documents.values[0], null, 2)}\n`;
|
||||
return `${documents.values.map((document) => YAML.stringify(document).trimEnd()).join("\n---\n")}\n`;
|
||||
}
|
||||
|
||||
function leadingSpaces(value) {
|
||||
const match = value.match(/^ */u);
|
||||
return match ? match[0].length : 0;
|
||||
@@ -166,11 +264,21 @@ function listYamlFiles(root) {
|
||||
}
|
||||
|
||||
function readOverlay() {
|
||||
const file = process.env.UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE;
|
||||
if (file) return JSON.parse(readFileSync(file, "utf8"));
|
||||
const encoded = process.env.UNIDESK_RUNTIME_GITOPS_OVERLAY_B64;
|
||||
if (!encoded) throw new Error("UNIDESK_RUNTIME_GITOPS_OVERLAY_B64 is required");
|
||||
if (!encoded) throw new Error("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE or UNIDESK_RUNTIME_GITOPS_OVERLAY_B64 is required");
|
||||
return JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
|
||||
}
|
||||
|
||||
function requireYaml() {
|
||||
try {
|
||||
return requireFromScript("yaml");
|
||||
} catch {
|
||||
return requireFromCwd("yaml");
|
||||
}
|
||||
}
|
||||
|
||||
function requiredOverlayString(name) {
|
||||
const value = overlay[name];
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`overlay.${name} is required`);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const roots: string[] = [];
|
||||
const script = resolve(import.meta.dir, "runtime-gitops-postprocess.mjs");
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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");
|
||||
mkdirSync(runtimeDir);
|
||||
writeFileSync(join(root, "overlay.json"), JSON.stringify(overlay("runtime")));
|
||||
writeFileSync(join(runtimeDir, "cloud-api.yaml"), deploymentYaml("hwlab-cloud-api"));
|
||||
writeFileSync(join(runtimeDir, "cloud-web.yaml"), `apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: unchanged\n---\n${deploymentYaml("hwlab-cloud-web")}`);
|
||||
writeFileSync(join(runtimeDir, "list.yaml"), `apiVersion: v1\nkind: List\nitems:\n${listItem(deploymentYaml("hwlab-cloud-api"))}\n`);
|
||||
writeFileSync(join(runtimeDir, "json-workload.yaml"), `${JSON.stringify(Bun.YAML.parse(deploymentYaml("hwlab-cloud-api")), null, 2)}\n`);
|
||||
|
||||
const result = spawnSync(process.execPath, [script], {
|
||||
cwd: root,
|
||||
env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE: join(root, "overlay.json") },
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
if (result.status !== 0) throw new Error(result.stderr);
|
||||
expect(result.stderr).toContain('"codeAgentRuntimeChanged":true');
|
||||
|
||||
const api = Bun.YAML.parse(readFileSync(join(runtimeDir, "cloud-api.yaml"), "utf8")) as any;
|
||||
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])).toEqual({ EXISTING: "preserved" });
|
||||
|
||||
const list = Bun.YAML.parse(readFileSync(join(runtimeDir, "list.yaml"), "utf8")) as any;
|
||||
expect(list.kind).toBe("List");
|
||||
expect(architectureEnvNames(list.items[0])).toEqual([]);
|
||||
|
||||
const jsonText = readFileSync(join(runtimeDir, "json-workload.yaml"), "utf8");
|
||||
expect(jsonText.startsWith("{\n")).toBe(true);
|
||||
expect(architectureEnvNames(JSON.parse(jsonText))).toEqual([]);
|
||||
|
||||
const afterFirstRun = snapshot(runtimeDir);
|
||||
const second = spawnSync(process.execPath, [script], {
|
||||
cwd: root,
|
||||
env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE: join(root, "overlay.json") },
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (second.status !== 0) throw new Error(second.stderr);
|
||||
expect(second.stderr).toContain('"codeAgentRuntimeChanged":false');
|
||||
expect(snapshot(runtimeDir)).toEqual(afterFirstRun);
|
||||
});
|
||||
|
||||
function overlay(runtimePath: string) {
|
||||
return {
|
||||
runtimePath,
|
||||
codeAgentRuntime: {
|
||||
enabled: true,
|
||||
kafkaEventBridge: {
|
||||
enabled: true,
|
||||
transactionalProjectorConsumerGroupId: "hwlab-projector",
|
||||
hwlabEventConsumerGroupId: "hwlab-events",
|
||||
transactionalProjector: { heartbeatIntervalMs: 2000 },
|
||||
projectionOutboxRelay: { intervalMs: 250, batchSize: 100, leaseMs: 30000, sendTimeoutMs: 10000, retryBackoffMs: 1000 },
|
||||
projectionRealtime: { outboxTailBatchSize: 100, sseHeartbeatMs: 1000, sseDrainTimeoutMs: 5000 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deploymentYaml(name: string) {
|
||||
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 - name: EXISTING\n value: preserved\n`;
|
||||
}
|
||||
|
||||
function listItem(value: string) {
|
||||
const [first, ...rest] = value.trimEnd().split("\n");
|
||||
return [` - ${first}`, ...rest.map((line) => ` ${line}`)].join("\n");
|
||||
}
|
||||
|
||||
function envValues(workload: any) {
|
||||
return Object.fromEntries(workload.spec.template.spec.containers[0].env.map((item: any) => [item.name, item.value]));
|
||||
}
|
||||
|
||||
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) {
|
||||
return ["cloud-api.yaml", "cloud-web.yaml", "list.yaml", "json-workload.yaml"].map((name) => readFileSync(join(runtimeDir, name), "utf8"));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
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"),
|
||||
observability: objectOr(overlay.observability),
|
||||
codeAgentRuntime: objectOr(overlay.codeAgentRuntime),
|
||||
})}\n`,
|
||||
};
|
||||
for (const name of [
|
||||
"runtime-gitops-observability.mjs",
|
||||
"runtime-gitops-postprocess.mjs",
|
||||
"runtime-gitops-verify.mjs",
|
||||
]) {
|
||||
data[name] = readFileSync(path.join(scriptsDir, name), "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",
|
||||
metadata: {
|
||||
name: configMapName,
|
||||
namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/name": "hwlab-runtime-gitops-scripts",
|
||||
"app.kubernetes.io/part-of": "unidesk-hwlab-control-plane",
|
||||
"app.kubernetes.io/managed-by": "unidesk-host-gitops",
|
||||
"hwlab.pikastech.local/node": overlay.nodeId ?? null,
|
||||
"hwlab.pikastech.local/lane": overlay.lane ?? null,
|
||||
},
|
||||
},
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function requiredString(value, pathLabel) {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${pathLabel} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectOr(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function optionalFile(file) {
|
||||
try {
|
||||
return readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runtimeGitopsScriptsConfigMap } from "./runtime-gitops-scripts-configmap.mjs";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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",
|
||||
"feature-config-schema-warning.mjs",
|
||||
"ajv2020.min.js",
|
||||
]) {
|
||||
writeFileSync(join(scriptsDir, name), `${name}\n`);
|
||||
}
|
||||
const codeAgentRuntime = {
|
||||
enabled: true,
|
||||
kafkaEventBridge: {
|
||||
enabled: true,
|
||||
transactionalProjector: { heartbeatIntervalMs: 2000 },
|
||||
projectionOutboxRelay: { intervalMs: 250 },
|
||||
projectionRealtime: { sseHeartbeatMs: 1000 },
|
||||
},
|
||||
};
|
||||
|
||||
const configMap = runtimeGitopsScriptsConfigMap({
|
||||
overlay: { runtimePath: "runtime", observability: { prometheusOperator: false }, codeAgentRuntime },
|
||||
scriptsDir,
|
||||
namespace: "hwlab",
|
||||
configMapName: "runtime-gitops-scripts",
|
||||
});
|
||||
|
||||
expect(JSON.parse(configMap.data["runtime-gitops-overlay.json"])).toEqual({
|
||||
runtimePath: "runtime",
|
||||
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");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -2,8 +2,12 @@
|
||||
// SPEC: PJ2026-01060703 CI/CD branch follower runtime GitOps verify.
|
||||
// Responsibility: fail publish before Argo when rendered runtime GitOps violates UniDesk overlay gates.
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
const requireFromScript = createRequire(import.meta.url);
|
||||
const requireFromCwd = createRequire(path.join(process.cwd(), "package.json"));
|
||||
const YAML = requireYaml();
|
||||
const repoDir = process.cwd();
|
||||
const overlay = readOverlay();
|
||||
const runtimePath = requiredOverlayString("runtimePath");
|
||||
@@ -20,6 +24,9 @@ if (overlay?.observability?.prometheusOperator === false) {
|
||||
const refs = findPrometheusOperatorResources();
|
||||
if (refs.length > 0) fail("prometheus-operator-resource-present", { runtimePath, refs: refs.slice(0, 12), refCount: refs.length });
|
||||
}
|
||||
if (overlay?.codeAgentRuntime?.enabled && overlay.codeAgentRuntime.kafkaEventBridge?.enabled) {
|
||||
checks.push("code-agent-runtime-kafka-event-bridge-enabled");
|
||||
}
|
||||
|
||||
console.error(JSON.stringify({ event: "unidesk-runtime-gitops-verify", ok: true, runtimePath, checks }));
|
||||
|
||||
@@ -87,6 +94,15 @@ function parseJsonDocument(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseStructuredDocuments(text, file) {
|
||||
const json = parseJsonDocument(text);
|
||||
if (json !== null) return [json];
|
||||
const documents = YAML.parseAllDocuments(text);
|
||||
const errors = documents.flatMap((document) => document.errors);
|
||||
if (errors.length > 0) fail("runtime-yaml-invalid", { file: path.relative(repoDir, file), errors: errors.map((error) => error.message) });
|
||||
return documents.map((document) => document.toJS()).filter((document) => document !== null);
|
||||
}
|
||||
|
||||
function listYamlFiles(root) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(root)) {
|
||||
@@ -102,11 +118,21 @@ function listYamlFiles(root) {
|
||||
}
|
||||
|
||||
function readOverlay() {
|
||||
const file = process.env.UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE;
|
||||
if (file) return JSON.parse(readFileSync(file, "utf8"));
|
||||
const encoded = process.env.UNIDESK_RUNTIME_GITOPS_OVERLAY_B64;
|
||||
if (!encoded) throw new Error("UNIDESK_RUNTIME_GITOPS_OVERLAY_B64 is required");
|
||||
if (!encoded) throw new Error("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE or UNIDESK_RUNTIME_GITOPS_OVERLAY_B64 is required");
|
||||
return JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
|
||||
}
|
||||
|
||||
function requireYaml() {
|
||||
try {
|
||||
return requireFromScript("yaml");
|
||||
} catch {
|
||||
return requireFromCwd("yaml");
|
||||
}
|
||||
}
|
||||
|
||||
function requiredOverlayString(name) {
|
||||
const value = overlay[name];
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`overlay.${name} is required`);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const roots: string[] = [];
|
||||
const script = resolve(import.meta.dir, "runtime-gitops-verify.mjs");
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("architecture capability env and workload matches are not runtime GitOps gates", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "runtime-gitops-verify-"));
|
||||
roots.push(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 },
|
||||
},
|
||||
}));
|
||||
|
||||
const result = spawnSync(process.execPath, [script], {
|
||||
cwd: root,
|
||||
env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE: join(root, "overlay.json") },
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
// Renders AgentRun YAML lane policy into runtime manager environment.
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AgentRunLaneSpec } from "./agentrun-lanes";
|
||||
import { gitRepositoryIdentity, resolveCicdDeliveryAuthority } from "./cicd-delivery-authority";
|
||||
import { renderPacFeatureConfigSchemaStage } from "./pac-feature-config-schema-stage";
|
||||
import { stableJsonSha256 } from "./stable-json";
|
||||
|
||||
export interface AgentRunArtifactService {
|
||||
@@ -408,6 +410,19 @@ function agentRunTektonGitopsPublishScript(spec: AgentRunLaneSpec): string {
|
||||
const templateImage = agentRunImageArtifact(spec, { sourceCommit: sourcePlaceholder, envIdentity: envPlaceholder, digest: digestPlaceholder, status: statusPlaceholder });
|
||||
const templateFiles = renderAgentRunGitopsFiles(spec, { sourceCommit: sourcePlaceholder, image: templateImage });
|
||||
const templateB64 = Buffer.from(JSON.stringify(templateFiles), "utf8").toString("base64");
|
||||
const authority = resolveCicdDeliveryAuthority({
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
sourceRepository: gitRepositoryIdentity(spec.source.repository),
|
||||
sourceBranch: spec.source.branch,
|
||||
});
|
||||
if (authority.kind !== "pac-pr-merge") throw new Error(`AgentRun ${spec.nodeId}/${spec.lane} requires exact PaC consumer authority for feature config validation`);
|
||||
const featureConfigSchemaStage = renderPacFeatureConfigSchemaStage({
|
||||
repositoryId: authority.consumer.repositoryRef,
|
||||
consumer: authority.consumer.consumerId,
|
||||
repoDirExpression: '"$root"',
|
||||
renderedRootExpression: '"$PWD/$(params.gitops-root)"',
|
||||
});
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
"set -eu",
|
||||
@@ -446,6 +461,7 @@ function agentRunTektonGitopsPublishScript(spec: AgentRunLaneSpec): string {
|
||||
" writeFileSync(file.path, content);",
|
||||
"}",
|
||||
"NODE",
|
||||
featureConfigSchemaStage,
|
||||
"git add source.json \"$(params.artifact-catalog)\" \"$(params.gitops-root)\"",
|
||||
"if git diff --quiet --cached; then changed=false; else changed=true; git -c user.email=agentrun@unidesk.local -c user.name='UniDesk AgentRun PaC' commit -m \"deploy: render AgentRun $(params.gitops-branch) from PaC\"; fi",
|
||||
"git remote set-url origin \"$git_auth_url\"",
|
||||
|
||||
@@ -667,7 +667,6 @@ function targetTaskPreflightFromArgs(args: string[], aipod: string): AgentRunTar
|
||||
mutation: false,
|
||||
recoveryActions: [
|
||||
"修复错误后用同一条 create task 命令重新执行;失败预检不会创建 task。",
|
||||
"先保留 --dry-run 查看 Target、source、workspaceRef、resourceBundleRef、session 与 SecretRef 摘要。",
|
||||
],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
|
||||
@@ -84,6 +84,22 @@ describe("GitHub repository token overrides", () => {
|
||||
expect(selected.probe).toMatchObject({ source: "yaml-token-source", scope: "repository-override", yamlSourcePriority: "before-env" });
|
||||
});
|
||||
|
||||
test("matches repository overrides without case sensitivity", () => {
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
const authConfig = testAuthConfig();
|
||||
|
||||
for (const repo of ["PikaInc/selfmedia", "pikainc/SELFMEDIA", "PIKAINC/SELFMEDIA"]) {
|
||||
const selected = resolveToken(repo, false, authConfig);
|
||||
expect(selected.token).toBe("selfmedia-token");
|
||||
expect(selected.probe).toMatchObject({ scope: "repository-override", sourceRef: "selfmedia-token.txt", valuesPrinted: false });
|
||||
}
|
||||
|
||||
const global = resolveToken("PikasTech/UniDesk", false, authConfig);
|
||||
expect(global.token).toBe("global-token");
|
||||
expect(global.probe).toMatchObject({ scope: "global", sourceRef: "global-token.txt", valuesPrinted: false });
|
||||
});
|
||||
|
||||
test("uses the final positional repository for issue and PR token selection", () => {
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
|
||||
@@ -31,6 +31,10 @@ export interface GitHubYamlAuthConfig {
|
||||
repositoryOverrides: GitHubYamlTokenSourceConfig[];
|
||||
}
|
||||
|
||||
function repositoryLookupKey(repository: string): string {
|
||||
return repository.toLowerCase();
|
||||
}
|
||||
|
||||
export function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
|
||||
if (options.comment === undefined && options.commentFile === undefined) return null;
|
||||
if (options.comment !== undefined) {
|
||||
@@ -59,7 +63,10 @@ export function tokenFromEnvironment(): GitHubTokenProbe {
|
||||
}
|
||||
|
||||
export function tokenFromYamlSource(repo: string, authConfig: GitHubYamlAuthConfig = readGitHubYamlAuthConfig()): { token: string | null; probe: GitHubTokenProbe } {
|
||||
const override = authConfig.repositoryOverrides.find((candidate) => candidate.repository === repo) ?? null;
|
||||
const repoLookupKey = repositoryLookupKey(repo);
|
||||
const override = authConfig.repositoryOverrides.find((candidate) => (
|
||||
candidate.repository !== undefined && repositoryLookupKey(candidate.repository) === repoLookupKey
|
||||
)) ?? null;
|
||||
const config = override ?? authConfig.tokenSource;
|
||||
if (config === null || config.enabled === false) {
|
||||
return {
|
||||
@@ -162,8 +169,9 @@ function readGitHubYamlAuthConfig(): GitHubYamlAuthConfig {
|
||||
const configRef = `${GITHUB_REPOSITORY_OVERRIDES_CONFIG_REF}[${index}]`;
|
||||
const override = asRecord(value, configRef);
|
||||
const repository = repositoryField(override, "repository", configRef);
|
||||
if (seenRepositories.has(repository)) throw new Error(`${GITHUB_REPOSITORY_OVERRIDES_CONFIG_REF} contains duplicate repository ${repository}`);
|
||||
seenRepositories.add(repository);
|
||||
const repositoryKey = repositoryLookupKey(repository);
|
||||
if (seenRepositories.has(repositoryKey)) throw new Error(`${GITHUB_REPOSITORY_OVERRIDES_CONFIG_REF} contains duplicate repository ${repository}`);
|
||||
seenRepositories.add(repositoryKey);
|
||||
return {
|
||||
repository,
|
||||
priority: tokenSourcePriorityField(override, "priority", configRef),
|
||||
|
||||
@@ -100,14 +100,14 @@ export function ghHelp(): unknown {
|
||||
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
|
||||
"comment update/edit PATCHes /repos/{owner}/{repo}/issues/comments/{comment_id} and preserves the comment id/timeline; comment delete is supported because GitHub supports deleting issue comments, but routine wording fixes should use update/edit. issue/pr hard delete is unsupported and close is the lifecycle alternative.",
|
||||
"PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
|
||||
"PR review-plan is the review-first bounded patch index. It uses GitHub REST PR files, prints changed files with additions/deletions/hunk counts/patch-line counts/default truncation flags, and emits one per-file gh pr diff --file drill-down command without creating a local worktree.",
|
||||
"PR review-plan is the review-first bounded patch index. It uses GitHub REST PR files, prints changed files with additions/deletions/hunk counts/patch-line counts/default truncation flags, and emits per-file gh pr diff --file drill-down commands without creating a local worktree. Use only the drill-downs needed for review; do not mechanically expand every file.",
|
||||
"PR diff --file reads one changed file patch from GitHub REST and prints only a bounded excerpt by default. Add --hunk N to inspect one hunk, --limit N to change displayed patch lines, or --full/--raw for explicit structured full-patch disclosure. --stat remains the no-patch file/stat compatibility path.",
|
||||
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
|
||||
"PR view is the canonical bounded metadata summary; read remains a UniDesk compatibility alias. Human full-body reading uses `trans gh:/owner/repo/pr/<number> cat`, and targeted lookup uses the same route with `rg <pattern>`. `--json body`, `--full`, and `--raw` are explicit structured machine disclosure only. View/read retain positional numbers, GitHub URLs, owner/repo#number shorthand, compatible --number, REST closeout fields, and on-demand GraphQL closeout metadata.",
|
||||
"PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.",
|
||||
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
|
||||
"PR preflight is a low-noise read-only closeout helper for explicit diagnosis only. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and an explicit read-only policy. It is not a required step before gh pr merge. Use --full or --raw to include all fetched status contexts.",
|
||||
"PR merge is the one-command guarded write path: it reads closeout metadata itself, retries GitHub UNKNOWN/null mergeability with YAML-configured exponential backoff, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. It defaults to deleting the merged same-repo head branch, cleaning the matching local .worktree when clean, and fast-forwarding the local main worktree on the PR base branch; --sync-node NODE additionally runs mapped node source-workspace sync. Use --dry-run to see the exact merge and closeout plan without writing.",
|
||||
"PR merge is the one-command guarded write path: it reads closeout metadata itself, retries GitHub UNKNOWN/null mergeability with YAML-configured exponential backoff, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Its success summary includes mergeCommit and mergedAt, so a routine follow-up pr view is unnecessary. It defaults to deleting the merged same-repo head branch, cleaning the matching local .worktree when clean, and fast-forwarding the local main worktree on the PR base branch; --sync-node NODE additionally runs mapped node source-workspace sync. Use --dry-run to see the exact merge and closeout plan without writing.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,6 +169,12 @@ export function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails?
|
||||
: {};
|
||||
const mergeMethod = result.method ?? result.mergeMethod ?? details.method;
|
||||
const deleteBranch = result.deleteBranch ?? details.deleteBranch;
|
||||
const mergeResult = isRecord(result.mergeResult) ? result.mergeResult : {};
|
||||
const mergeCommitValue = pullRequest.mergeCommit;
|
||||
const mergeCommit = isRecord(mergeCommitValue)
|
||||
? mergeCommitValue.oid
|
||||
: mergeCommitValue ?? mergeResult.sha;
|
||||
const mergedAt = pullRequest.mergedAt;
|
||||
const status = result.ok === true
|
||||
? result.alreadyMerged === true
|
||||
? "already-merged"
|
||||
@@ -198,13 +204,14 @@ export function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails?
|
||||
` repo=${ghText(result.repo)} title=${ghShort(ghText(pullRequest.title), 96)}`,
|
||||
` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`,
|
||||
` retry=${retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)} exhausted=${ghText(retry.exhausted)}` : "-"}`,
|
||||
` mergeCommit=${ghText(mergeCommit)} mergedAt=${ghText(mergedAt)}`,
|
||||
` branchDeletion=${ghText(branchDeletion.ok ?? branchDeletion.skippedReason ?? branchDeletion.attempted)}`,
|
||||
` closeout localWorktree=${closeoutCell(localWorktree)} mainWorktree=${closeoutCell(mainWorktree)} nodeSyncs=${nodeSyncs.length === 0 ? "-" : nodeSyncs.map(closeoutCell).join(",")}`,
|
||||
"",
|
||||
"Next:",
|
||||
];
|
||||
if (result.ok === true && result.alreadyMerged !== true && result.dryRun !== true) {
|
||||
lines.push(` bun scripts/cli.ts gh pr view ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)}`);
|
||||
if (result.ok === true && result.dryRun !== true) {
|
||||
lines.push(" no follow-up required; merge facts are included in Summary.");
|
||||
} else {
|
||||
lines.push(` ${prMergeRetryCommand(ghText(result.repo), result.number ?? details.number, mergeMethod, deleteBranch)}`);
|
||||
lines.push(` bun scripts/cli.ts gh pr preflight ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)} --full`);
|
||||
|
||||
@@ -545,20 +545,27 @@ export interface HwlabRuntimeKafkaShadowProducerSpec {
|
||||
|
||||
export interface HwlabRuntimeKafkaEventBridgeSpec {
|
||||
readonly enabled: boolean;
|
||||
readonly features: {
|
||||
readonly directPublish: boolean;
|
||||
readonly liveKafkaSse: boolean;
|
||||
readonly kafkaRefreshReplay: boolean;
|
||||
readonly transactionalProjector: boolean;
|
||||
readonly projectionOutboxRelay: boolean;
|
||||
readonly projectionRealtime: boolean;
|
||||
readonly transactionalProjector?: {
|
||||
readonly heartbeatIntervalMs: number;
|
||||
};
|
||||
readonly refreshReplay?: {
|
||||
readonly groupIdPrefix: string;
|
||||
readonly timeoutMs: number;
|
||||
readonly scanLimit: number;
|
||||
readonly matchedEventLimit: number;
|
||||
readonly liveBufferLimit: number;
|
||||
readonly projectionOutboxRelay?: {
|
||||
readonly intervalMs: number;
|
||||
readonly batchSize: number;
|
||||
readonly leaseMs: number;
|
||||
readonly sendTimeoutMs: number;
|
||||
readonly retryBackoffMs: number;
|
||||
};
|
||||
readonly projectionRealtime?: {
|
||||
readonly outboxTailBatchSize: number;
|
||||
readonly sseHeartbeatMs: number;
|
||||
readonly sseDrainTimeoutMs: number;
|
||||
};
|
||||
readonly healthThresholds: {
|
||||
readonly consumerLagWarning: number;
|
||||
readonly outboxBacklogWarning: number;
|
||||
readonly retryCountWarning: number;
|
||||
readonly failedInboxWarning: number;
|
||||
readonly dlqWarning: number;
|
||||
};
|
||||
readonly configRef: string;
|
||||
readonly bootstrapServers: string;
|
||||
@@ -566,7 +573,6 @@ export interface HwlabRuntimeKafkaEventBridgeSpec {
|
||||
readonly agentRunEventTopic: string;
|
||||
readonly hwlabEventTopic: string;
|
||||
readonly clientId: string;
|
||||
readonly directPublishConsumerGroupId: string;
|
||||
readonly transactionalProjectorConsumerGroupId: string;
|
||||
readonly hwlabEventConsumerGroupId: string;
|
||||
}
|
||||
@@ -628,6 +634,7 @@ export interface HwlabRuntimePipelineProvenanceSpec {
|
||||
readonly configRef: string;
|
||||
readonly renderer: "hwlab-runtime-lane";
|
||||
readonly mode: "remote-pipeline-annotation";
|
||||
readonly maxInlineScriptBytes: number;
|
||||
}
|
||||
|
||||
export interface HwlabRuntimeLaneSpec {
|
||||
@@ -1060,6 +1067,7 @@ export function parseHwlabRuntimePipelineProvenance(
|
||||
configRef,
|
||||
renderer: enumStringField(raw, "renderer", path, ["hwlab-runtime-lane"]),
|
||||
mode: enumStringField(raw, "mode", path, ["remote-pipeline-annotation"]),
|
||||
maxInlineScriptBytes: boundedIntegerField(raw, "maxInlineScriptBytes", path, 1024, 131072),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1220,55 +1228,75 @@ function codeAgentKafkaShadowProducerConfig(value: unknown, path: string): Hwlab
|
||||
|
||||
function codeAgentKafkaEventBridgeConfig(value: unknown, path: string): HwlabRuntimeKafkaEventBridgeSpec {
|
||||
const raw = asRecord(value, path);
|
||||
const features = asRecord(raw.features, `${path}.features`);
|
||||
const liveKafkaSse = booleanField(features, "liveKafkaSse", `${path}.features`);
|
||||
const kafkaRefreshReplay = booleanField(features, "kafkaRefreshReplay", `${path}.features`);
|
||||
const refreshReplay = raw.refreshReplay === undefined
|
||||
if (raw.refreshReplay !== undefined) throw new Error(`${path}.refreshReplay was removed with the fixed projection realtime architecture`);
|
||||
const enabled = booleanField(raw, "enabled", path);
|
||||
const transactionalProjectorConfig = raw.transactionalProjector === undefined
|
||||
? undefined
|
||||
: codeAgentKafkaRefreshReplayConfig(raw.refreshReplay, `${path}.refreshReplay`);
|
||||
if (kafkaRefreshReplay && refreshReplay === undefined) {
|
||||
throw new Error(`${path}.refreshReplay is required when ${path}.features.kafkaRefreshReplay is true`);
|
||||
}
|
||||
if (kafkaRefreshReplay && !liveKafkaSse) {
|
||||
throw new Error(`${path}.features.liveKafkaSse must be true when ${path}.features.kafkaRefreshReplay is true`);
|
||||
}
|
||||
const directPublishConsumerGroupId = stringField(raw, "directPublishConsumerGroupId", path);
|
||||
: codeAgentKafkaTransactionalProjectorConfig(raw.transactionalProjector, `${path}.transactionalProjector`);
|
||||
const projectionOutboxRelayConfig = raw.projectionOutboxRelay === undefined
|
||||
? undefined
|
||||
: codeAgentKafkaProjectionOutboxRelayConfig(raw.projectionOutboxRelay, `${path}.projectionOutboxRelay`);
|
||||
const projectionRealtimeConfig = raw.projectionRealtime === undefined
|
||||
? undefined
|
||||
: codeAgentKafkaProjectionRealtimeConfig(raw.projectionRealtime, `${path}.projectionRealtime`);
|
||||
if (transactionalProjectorConfig === undefined) throw new Error(`${path}.transactionalProjector is required by the fixed transactional projection architecture`);
|
||||
if (projectionOutboxRelayConfig === undefined) throw new Error(`${path}.projectionOutboxRelay is required by the fixed transactional projection architecture`);
|
||||
if (projectionRealtimeConfig === undefined) throw new Error(`${path}.projectionRealtime is required by the fixed transactional projection architecture`);
|
||||
const transactionalProjectorConsumerGroupId = stringField(raw, "transactionalProjectorConsumerGroupId", path);
|
||||
const hwlabEventConsumerGroupId = stringField(raw, "hwlabEventConsumerGroupId", path);
|
||||
if (new Set([directPublishConsumerGroupId, transactionalProjectorConsumerGroupId, hwlabEventConsumerGroupId]).size !== 3) {
|
||||
throw new Error(`${path} consumer group ids must be distinct so enabled capabilities receive independent event streams`);
|
||||
if (transactionalProjectorConsumerGroupId === hwlabEventConsumerGroupId) {
|
||||
throw new Error(`${path} projector and realtime consumer group ids must be distinct`);
|
||||
}
|
||||
return {
|
||||
enabled: booleanField(raw, "enabled", path),
|
||||
features: {
|
||||
directPublish: booleanField(features, "directPublish", `${path}.features`),
|
||||
liveKafkaSse,
|
||||
kafkaRefreshReplay,
|
||||
transactionalProjector: booleanField(features, "transactionalProjector", `${path}.features`),
|
||||
projectionOutboxRelay: booleanField(features, "projectionOutboxRelay", `${path}.features`),
|
||||
projectionRealtime: booleanField(features, "projectionRealtime", `${path}.features`),
|
||||
},
|
||||
...(refreshReplay === undefined ? {} : { refreshReplay }),
|
||||
enabled,
|
||||
transactionalProjector: transactionalProjectorConfig,
|
||||
projectionOutboxRelay: projectionOutboxRelayConfig,
|
||||
projectionRealtime: projectionRealtimeConfig,
|
||||
healthThresholds: codeAgentKafkaHealthThresholdsConfig(raw.healthThresholds, `${path}.healthThresholds`),
|
||||
configRef: stringField(raw, "configRef", path),
|
||||
bootstrapServers: stringField(raw, "bootstrapServers", path),
|
||||
stdioTopic: stringField(raw, "stdioTopic", path),
|
||||
agentRunEventTopic: stringField(raw, "agentRunEventTopic", path),
|
||||
hwlabEventTopic: stringField(raw, "hwlabEventTopic", path),
|
||||
clientId: stringField(raw, "clientId", path),
|
||||
directPublishConsumerGroupId,
|
||||
transactionalProjectorConsumerGroupId,
|
||||
hwlabEventConsumerGroupId,
|
||||
};
|
||||
}
|
||||
|
||||
function codeAgentKafkaRefreshReplayConfig(value: unknown, path: string): NonNullable<HwlabRuntimeKafkaEventBridgeSpec["refreshReplay"]> {
|
||||
function codeAgentKafkaTransactionalProjectorConfig(value: unknown, path: string): NonNullable<HwlabRuntimeKafkaEventBridgeSpec["transactionalProjector"]> {
|
||||
const raw = asRecord(value, path);
|
||||
return { heartbeatIntervalMs: numberField(raw, "heartbeatIntervalMs", path) };
|
||||
}
|
||||
|
||||
function codeAgentKafkaProjectionOutboxRelayConfig(value: unknown, path: string): NonNullable<HwlabRuntimeKafkaEventBridgeSpec["projectionOutboxRelay"]> {
|
||||
const raw = asRecord(value, path);
|
||||
return {
|
||||
groupIdPrefix: stringField(raw, "groupIdPrefix", path),
|
||||
timeoutMs: numberField(raw, "timeoutMs", path),
|
||||
scanLimit: numberField(raw, "scanLimit", path),
|
||||
matchedEventLimit: numberField(raw, "matchedEventLimit", path),
|
||||
liveBufferLimit: numberField(raw, "liveBufferLimit", path),
|
||||
intervalMs: numberField(raw, "intervalMs", path),
|
||||
batchSize: numberField(raw, "batchSize", path),
|
||||
leaseMs: numberField(raw, "leaseMs", path),
|
||||
sendTimeoutMs: numberField(raw, "sendTimeoutMs", path),
|
||||
retryBackoffMs: numberField(raw, "retryBackoffMs", path),
|
||||
};
|
||||
}
|
||||
|
||||
function codeAgentKafkaProjectionRealtimeConfig(value: unknown, path: string): NonNullable<HwlabRuntimeKafkaEventBridgeSpec["projectionRealtime"]> {
|
||||
const raw = asRecord(value, path);
|
||||
return {
|
||||
outboxTailBatchSize: numberField(raw, "outboxTailBatchSize", path),
|
||||
sseHeartbeatMs: numberField(raw, "sseHeartbeatMs", path),
|
||||
sseDrainTimeoutMs: numberField(raw, "sseDrainTimeoutMs", path),
|
||||
};
|
||||
}
|
||||
|
||||
function codeAgentKafkaHealthThresholdsConfig(value: unknown, path: string): HwlabRuntimeKafkaEventBridgeSpec["healthThresholds"] {
|
||||
const raw = asRecord(value, path);
|
||||
return {
|
||||
consumerLagWarning: nonNegativeIntegerField(raw, "consumerLagWarning", path),
|
||||
outboxBacklogWarning: nonNegativeIntegerField(raw, "outboxBacklogWarning", path),
|
||||
retryCountWarning: nonNegativeIntegerField(raw, "retryCountWarning", path),
|
||||
failedInboxWarning: nonNegativeIntegerField(raw, "failedInboxWarning", path),
|
||||
dlqWarning: nonNegativeIntegerField(raw, "dlqWarning", path),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ test("realtime fanout keeps the YAML terminal timeout when durationMs is absent"
|
||||
conditionalEventTypePairs: { "tool-call": { agentrun: "tool_call", hwlab: "tool" } },
|
||||
expectedKafka: {
|
||||
topics: { stdio: "codex-stdio.raw.v1", agentrun: "agentrun.event.v1", hwlab: "hwlab.event.v1" },
|
||||
groups: { directPublish: "direct", transactionalProjector: "projector", liveSse: "live" },
|
||||
capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true },
|
||||
groups: { transactionalProjector: "projector", liveSse: "live" },
|
||||
capabilities: { transactionalProjector: true, projectionOutboxRelay: true, projectionRealtime: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -194,7 +194,7 @@ test("realtime fanout validates stdio against the scoped AgentRun session lineag
|
||||
assert.equal(expectedStreamSessionId("hwlab", hwlabSessionId), hwlabSessionId);
|
||||
});
|
||||
|
||||
test("realtime fanout validates YAML-owned refresh and legacy live-only connected contracts", () => {
|
||||
test("realtime fanout validates the fixed transactional replay and live connected contract", () => {
|
||||
const source = nodeWebObserveRunnerRealtimeSource();
|
||||
const build = new Function(`${source}\nreturn realtimeAssertConnectedContract;`) as () => (
|
||||
connected: Record<string, unknown>,
|
||||
@@ -207,7 +207,7 @@ test("realtime fanout validates YAML-owned refresh and legacy live-only connecte
|
||||
const sessionId = "ses_refresh_contract";
|
||||
const expectedKafka = {
|
||||
topics: { hwlab: "hwlab.event.v1" },
|
||||
capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true },
|
||||
capabilities: { transactionalProjector: true, projectionOutboxRelay: true, projectionRealtime: true },
|
||||
};
|
||||
const refreshProfile = {
|
||||
expectedKafka,
|
||||
@@ -249,28 +249,6 @@ test("realtime fanout validates YAML-owned refresh and legacy live-only connecte
|
||||
/replayed count exceeds matched count/u,
|
||||
);
|
||||
|
||||
const liveProfile = {
|
||||
expectedKafka: { topics: { hwlab: "hwlab.event.v1" }, capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: false } },
|
||||
expectedProductSse: {
|
||||
deliverySemantics: "live-only",
|
||||
liveOnly: true,
|
||||
replay: false,
|
||||
replaySupported: false,
|
||||
lossPossible: true,
|
||||
refreshHandoff: false,
|
||||
replayPriorTraceOnReconnect: false,
|
||||
},
|
||||
};
|
||||
assert.doesNotThrow(() => assertConnected({
|
||||
realtimeSource: "hwlab.event.v1",
|
||||
deliverySemantics: "live-only",
|
||||
liveOnly: true,
|
||||
replay: false,
|
||||
replaySupported: false,
|
||||
lossPossible: true,
|
||||
filters: { sessionId, traceId: null },
|
||||
capabilities: liveProfile.expectedKafka.capabilities,
|
||||
}, sessionId, "live", liveProfile));
|
||||
});
|
||||
|
||||
test("existing session refresh rejects duplicate or reordered Workbench identities", () => {
|
||||
@@ -326,7 +304,7 @@ test("existing session refresh preserves rejected connected evidence and rejects
|
||||
counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 },
|
||||
};
|
||||
const profile = {
|
||||
expectedKafka: { topics: { hwlab: "hwlab.event.v1" }, capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true } },
|
||||
expectedKafka: { topics: { hwlab: "hwlab.event.v1" }, capabilities: { transactionalProjector: true, projectionOutboxRelay: true, projectionRealtime: true } },
|
||||
expectedProductSse: { deliverySemantics: "kafka-retention-then-live", liveOnly: false, replay: true, replaySupported: true, lossPossible: false, refreshHandoff: true },
|
||||
};
|
||||
const connected = {
|
||||
|
||||
@@ -803,11 +803,18 @@ function realtimeFanoutEffectiveProfile(profile, durationMs) {
|
||||
if (!expectedProductSse || typeof expectedProductSse.deliverySemantics !== "string") throw new Error("realtime fanout profile lacks YAML-owned product SSE expectations");
|
||||
const expectedKafka = profile.expectedKafka && typeof profile.expectedKafka === "object" ? profile.expectedKafka : null;
|
||||
if (!expectedKafka?.topics || !expectedKafka?.groups || !expectedKafka?.capabilities) throw new Error("realtime fanout profile lacks YAML-derived Kafka expectations");
|
||||
if (expectedKafka.capabilities.directPublish !== true || expectedKafka.capabilities.liveKafkaSse !== true) {
|
||||
throw new Error("realtime fanout profile requires YAML-enabled directPublish and liveKafkaSse capabilities");
|
||||
if (expectedKafka.capabilities.transactionalProjector !== true
|
||||
|| expectedKafka.capabilities.projectionOutboxRelay !== true
|
||||
|| expectedKafka.capabilities.projectionRealtime !== true) {
|
||||
throw new Error("realtime fanout profile requires the fixed transactional projection capabilities");
|
||||
}
|
||||
if (expectedProductSse.refreshHandoff !== (expectedKafka.capabilities.kafkaRefreshReplay === true)) {
|
||||
throw new Error("realtime fanout product SSE refreshHandoff differs from the YAML Kafka capability");
|
||||
if (expectedProductSse.deliverySemantics !== "kafka-retention-then-live"
|
||||
|| expectedProductSse.liveOnly !== false
|
||||
|| expectedProductSse.replay !== true
|
||||
|| expectedProductSse.replaySupported !== true
|
||||
|| expectedProductSse.lossPossible !== false
|
||||
|| expectedProductSse.refreshHandoff !== true) {
|
||||
throw new Error("realtime fanout product SSE must use fixed retention replay and live handoff semantics");
|
||||
}
|
||||
if (expectedProductSse.replayPriorTraceOnReconnect === true && expectedProductSse.replay !== true) {
|
||||
throw new Error("realtime fanout replayPriorTraceOnReconnect requires replay=true");
|
||||
|
||||
@@ -791,7 +791,7 @@ export function sentinelPublishImageBuildShell(state: SentinelCicdState, jobName
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function sentinelPublishShell(state: SentinelCicdState, jobName: string, publishGitops: boolean): string {
|
||||
export function sentinelPublishShell(state: SentinelCicdState, jobName: string, publishGitops: boolean, featureConfigSchemaStage = ""): string {
|
||||
const gitopsFiles = publishGitops ? sentinelGitopsFiles(state) : [];
|
||||
const filesB64 = Buffer.from(JSON.stringify(gitopsFiles.map((file) => ({
|
||||
path: file.path,
|
||||
@@ -871,6 +871,7 @@ export function sentinelPublishShell(state: SentinelCicdState, jobName: string,
|
||||
"}",
|
||||
"console.error(JSON.stringify({event:'web-probe-sentinel-gitops-files', fileCount: files.length, valuesRedacted:true}));",
|
||||
"NODE",
|
||||
...(featureConfigSchemaStage.length === 0 ? [] : [featureConfigSchemaStage]),
|
||||
" git add .",
|
||||
" file_count=$(git diff --cached --name-only | wc -l | tr -d ' ')",
|
||||
" if git diff --quiet --cached; then changed=false; else changed=true; git -c user.email=web-probe-sentinel@unidesk.local -c user.name='UniDesk Web Probe Sentinel' commit -m \"deploy: render web-probe sentinel ${source_commit}\"; fi",
|
||||
|
||||
@@ -44,11 +44,15 @@ import { nodeRuntimeDeployYamlOverlayShellScript } from "./deploy-overlay";
|
||||
import { pipelineProvenanceAnnotationKeys } from "../pipeline-provenance";
|
||||
import { hwlabNodeDeliveryAuthority, hwlabNodeDeliveryNext } from "./delivery-authority";
|
||||
import type { CicdDeliveryAuthority } from "../cicd-delivery-authority";
|
||||
import { renderPacFeatureConfigSchemaStage } from "../pac-feature-config-schema-stage";
|
||||
|
||||
const runtimeGitopsObservabilityNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-observability.mjs"), "utf8").trimEnd();
|
||||
const runtimeGitopsPipelineGuardNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs"), "utf8").trimEnd();
|
||||
const runtimeGitopsScriptsConfigMapNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs"), "utf8").trimEnd();
|
||||
const runtimeGitopsPostprocessNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-postprocess.mjs"), "utf8").trimEnd();
|
||||
const runtimeGitopsVerifyNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-verify.mjs"), "utf8").trimEnd();
|
||||
const featureConfigSchemaWarningNativeScript = readFileSync(rootPath("scripts/native/cicd/feature-config-schema-warning.mjs"), "utf8").trimEnd();
|
||||
const ajv2020BundleScript = readFileSync(rootPath("scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js"), "utf8").trimEnd();
|
||||
const runtimePipelineProvenanceNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-pipeline-provenance.mjs"), "utf8").trimEnd();
|
||||
|
||||
export function nodeRuntimeGitMirrorJobName(mirror: NodeRuntimeGitMirrorTargetSpec, action: "sync" | "flush"): string {
|
||||
@@ -753,6 +757,8 @@ export function summarizeNodeRuntimeControlPlaneStatus(
|
||||
const pipelineRunDiagnostics = record(status.pipelineRunDiagnostics);
|
||||
const argo = record(status.argo);
|
||||
const runtime = record(status.runtime);
|
||||
const codeAgentRuntime = record(runtime.codeAgentRuntime);
|
||||
const kafkaAuthority = record(codeAgentRuntime.kafkaAuthority);
|
||||
const publicProbes = record(status.publicProbes);
|
||||
const gitMirror = record(status.gitMirror);
|
||||
const gitMirrorCompact = record(gitMirror.compact);
|
||||
@@ -822,8 +828,25 @@ export function summarizeNodeRuntimeControlPlaneStatus(
|
||||
externalPostgresReady: runtime.externalPostgresBridge === undefined && runtime.externalPostgresSecrets === undefined
|
||||
? null
|
||||
: record(runtime.externalPostgresBridge).ready === true && record(runtime.externalPostgresSecrets).ready === true,
|
||||
codeAgentRuntimeReady: record(runtime.codeAgentRuntime).required === true ? record(runtime.codeAgentRuntime).ready === true : null,
|
||||
codeAgentRuntimeReason: typeof record(runtime.codeAgentRuntime).degradedReason === "string" ? record(runtime.codeAgentRuntime).degradedReason : null,
|
||||
codeAgentRuntimeReady: codeAgentRuntime.required === true ? codeAgentRuntime.ready === true : null,
|
||||
codeAgentRuntimeReason: typeof codeAgentRuntime.degradedReason === "string" ? codeAgentRuntime.degradedReason : null,
|
||||
kafkaAuthority: Object.keys(kafkaAuthority).length === 0 ? null : {
|
||||
authority: kafkaAuthority.authority ?? null,
|
||||
fixedGroups: kafkaAuthority.fixedGroups ?? null,
|
||||
capabilities: kafkaAuthority.capabilities ?? null,
|
||||
liveReplay: kafkaAuthority.liveReplay ?? null,
|
||||
healthObserved: kafkaAuthority.healthObserved === true,
|
||||
healthStatus: kafkaAuthority.healthStatus ?? null,
|
||||
consumerLag: kafkaAuthority.consumerLag ?? null,
|
||||
backlogCount: kafkaAuthority.backlogCount ?? null,
|
||||
retryCount: kafkaAuthority.retryCount ?? null,
|
||||
failedInboxCount: kafkaAuthority.failedInboxCount ?? null,
|
||||
dlqCount: kafkaAuthority.dlqCount ?? null,
|
||||
thresholds: kafkaAuthority.thresholds ?? null,
|
||||
warning: kafkaAuthority.warning === true,
|
||||
warningReasons: kafkaAuthority.warningReasons ?? [],
|
||||
blocking: false,
|
||||
},
|
||||
},
|
||||
publicProbe: {
|
||||
ready: publicProbes.ready === true,
|
||||
@@ -1520,12 +1543,24 @@ export function renderNodeRuntimeControlPlaneOnNode(spec: HwlabRuntimeLaneSpec,
|
||||
`--web-endpoint ${shellQuote(spec.publicWebUrl)}`,
|
||||
`--out ${shellQuote(renderDir)}`,
|
||||
].join(" "),
|
||||
...nodeRuntimePipelinePostprocessScript(),
|
||||
...nodeRuntimePipelinePostprocessScript(nodeRuntimeFeatureConfigSchemaStage(spec)),
|
||||
].join("\n");
|
||||
return { result: runNodeHostScriptAsync(spec, script, timeoutSeconds, `${spec.nodeId.toLowerCase()}-${spec.lane}-render`), renderDir, worktreeDir, location: "node-host" };
|
||||
}
|
||||
|
||||
export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
export function nodeRuntimeFeatureConfigSchemaStage(spec: HwlabRuntimeLaneSpec): string {
|
||||
const authority = hwlabNodeDeliveryAuthority({ node: spec.nodeId, lane: spec.lane, spec });
|
||||
if (authority.kind !== "pac-pr-merge") throw new Error(`HWLAB ${spec.nodeId}/${spec.lane} requires exact PaC consumer authority for feature config validation`);
|
||||
return renderPacFeatureConfigSchemaStage({
|
||||
repositoryId: authority.consumer.repositoryRef,
|
||||
consumer: authority.consumer.consumerId,
|
||||
repoDirExpression: '"$PWD"',
|
||||
renderedRootExpression: JSON.stringify(spec.runtimePath),
|
||||
ajvBundleExpression: "'/etc/unidesk-cicd-runtime-gitops/ajv2020.min.js'",
|
||||
});
|
||||
}
|
||||
|
||||
export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: string): string[] {
|
||||
return [
|
||||
"node - \"$render_dir\" \"$overlay_b64\" <<'NODE'",
|
||||
"const fs = require('fs');",
|
||||
@@ -1534,6 +1569,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
"const renderDir = process.argv[2];",
|
||||
"const overlay = JSON.parse(Buffer.from(process.argv[3], 'base64').toString('utf8'));",
|
||||
`const runtimeGitopsObservabilityNativeScript = ${JSON.stringify(runtimeGitopsObservabilityNativeScript)};`,
|
||||
`const featureConfigSchemaStage = ${JSON.stringify(featureConfigSchemaStage)};`,
|
||||
"const pipelinePath = path.join(renderDir, overlay.tektonDir, 'pipeline.yaml');",
|
||||
"let text = fs.readFileSync(pipelinePath, 'utf8');",
|
||||
"let YAML = null;",
|
||||
@@ -1562,6 +1598,9 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
"echo '{\"event\":\"prepare-source-dependencies\",\"status\":\"not-required\",\"dependency\":\"yaml\",\"manager\":\"inline-service-id-parser\"}' >&2",
|
||||
"ci_timing_emit prepare-source-dependencies succeeded \"$prepare_source_dependencies_started_ms\"`;",
|
||||
"}",
|
||||
"function featureConfigSchemaValidationScript() {",
|
||||
" return featureConfigSchemaStage;",
|
||||
"}",
|
||||
"function validatePrepareSourceDependencyScript(script) {",
|
||||
" const marker = 'NODE_UNIDESK_YAML_DEPENDENCY';",
|
||||
" let offset = 0;",
|
||||
@@ -1818,12 +1857,6 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
"function cloudWebRuntimeEnvEntries() {",
|
||||
" const exposure = overlay.publicExposure;",
|
||||
" const entries = !exposure || !Array.isArray(exposure.extraProxies) ? [] : exposure.extraProxies.filter((proxy) => proxy && proxy.cloudWebEnvName && proxy.publicBaseUrl).map((proxy) => ({ name: String(proxy.cloudWebEnvName), value: String(proxy.publicBaseUrl) }));",
|
||||
" const kafka = overlay.codeAgentRuntime && overlay.codeAgentRuntime.kafkaEventBridge;",
|
||||
" if (kafka) {",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', value: String(kafka.enabled && kafka.features.liveKafkaSse) });",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', value: String(kafka.enabled && kafka.features.kafkaRefreshReplay) });",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', value: String(kafka.enabled && kafka.features.projectionRealtime) });",
|
||||
" }",
|
||||
" return entries;",
|
||||
"}",
|
||||
"function startupProbeFrom(probe) {",
|
||||
@@ -1921,31 +1954,35 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
" if (codeAgentRuntime.kafkaEventBridge) {",
|
||||
" const kafka = codeAgentRuntime.kafkaEventBridge;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_ENABLED', String(kafka.enabled)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector))) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_BOOTSTRAP_SERVERS', String(kafka.bootstrapServers)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_STDIO_TOPIC', String(kafka.stdioTopic)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC', String(kafka.agentRunEventTopic)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_EVENT_TOPIC', String(kafka.hwlabEventTopic)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_CLIENT_ID', String(kafka.clientId)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID', String(kafka.directPublishConsumerGroupId)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_PROJECTOR_GROUP_ID', String(kafka.transactionalProjectorConsumerGroupId)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID', String(kafka.hwlabEventConsumerGroupId)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED', String(kafka.enabled && kafka.features.directPublish)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', String(kafka.enabled && kafka.features.liveKafkaSse)) || codeAgentRuntimeChanged;",
|
||||
" const refreshReplayEnabled = kafka.enabled && kafka.features.kafkaRefreshReplay;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', String(refreshReplayEnabled)) || codeAgentRuntimeChanged;",
|
||||
" if (refreshReplayEnabled) {",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', String(kafka.refreshReplay.groupIdPrefix)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', String(kafka.refreshReplay.timeoutMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', String(kafka.refreshReplay.scanLimit)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', String(kafka.refreshReplay.matchedEventLimit)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT', String(kafka.refreshReplay.liveBufferLimit)) || codeAgentRuntimeChanged;",
|
||||
" if (kafka.enabled) {",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS', String(kafka.transactionalProjector.heartbeatIntervalMs)) || codeAgentRuntimeChanged;",
|
||||
" } else {",
|
||||
" codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT']) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS']) || codeAgentRuntimeChanged;",
|
||||
" }",
|
||||
" if (kafka.enabled) {",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS', String(kafka.projectionOutboxRelay.intervalMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE', String(kafka.projectionOutboxRelay.batchSize)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS', String(kafka.projectionOutboxRelay.leaseMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS', String(kafka.projectionOutboxRelay.sendTimeoutMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS', String(kafka.projectionOutboxRelay.retryBackoffMs)) || codeAgentRuntimeChanged;",
|
||||
" } else {",
|
||||
" codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS', 'HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE', 'HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS', 'HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS', 'HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS']) || codeAgentRuntimeChanged;",
|
||||
" }",
|
||||
" if (kafka.enabled) {",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE', String(kafka.projectionRealtime.outboxTailBatchSize)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_SSE_HEARTBEAT_MS', String(kafka.projectionRealtime.sseHeartbeatMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS', String(kafka.projectionRealtime.sseDrainTimeoutMs)) || codeAgentRuntimeChanged;",
|
||||
" } else {",
|
||||
" codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE', 'HWLAB_WORKBENCH_SSE_HEARTBEAT_MS', 'HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS']) || codeAgentRuntimeChanged;",
|
||||
" }",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED', String(kafka.enabled && kafka.features.transactionalProjector)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED', String(kafka.enabled && kafka.features.projectionOutboxRelay)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', String(kafka.enabled && kafka.features.projectionRealtime)) || codeAgentRuntimeChanged;",
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
@@ -2270,12 +2307,6 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
"function cloudWebRuntimeEnvEntries() {",
|
||||
" const exposure = overlay.publicExposure;",
|
||||
" const entries = !exposure || !Array.isArray(exposure.extraProxies) ? [] : exposure.extraProxies.filter((proxy) => proxy && proxy.cloudWebEnvName && proxy.publicBaseUrl).map((proxy) => ({ name: String(proxy.cloudWebEnvName), value: String(proxy.publicBaseUrl) }));",
|
||||
" const kafka = overlay.codeAgentRuntime && overlay.codeAgentRuntime.kafkaEventBridge;",
|
||||
" if (kafka) {",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', value: String(kafka.enabled && kafka.features.liveKafkaSse) });",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', value: String(kafka.enabled && kafka.features.kafkaRefreshReplay) });",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', value: String(kafka.enabled && kafka.features.projectionRealtime) });",
|
||||
" }",
|
||||
" return entries;",
|
||||
"}",
|
||||
"function workloadRef(item, file, container) { return { file, kind: item && item.kind, name: item && item.metadata && item.metadata.name, container: container && container.name }; }",
|
||||
@@ -2349,31 +2380,14 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
" if (codeAgentRuntime.kafkaEventBridge) {",
|
||||
" const kafka = codeAgentRuntime.kafkaEventBridge;",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_ENABLED', String(kafka.enabled));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector)));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_BOOTSTRAP_SERVERS', String(kafka.bootstrapServers));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_STDIO_TOPIC', String(kafka.stdioTopic));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC', String(kafka.agentRunEventTopic));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_EVENT_TOPIC', String(kafka.hwlabEventTopic));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_CLIENT_ID', String(kafka.clientId));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID', String(kafka.directPublishConsumerGroupId));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_PROJECTOR_GROUP_ID', String(kafka.transactionalProjectorConsumerGroupId));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID', String(kafka.hwlabEventConsumerGroupId));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED', String(kafka.enabled && kafka.features.directPublish));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', String(kafka.enabled && kafka.features.liveKafkaSse));",
|
||||
" const refreshReplayEnabled = kafka.enabled && kafka.features.kafkaRefreshReplay;",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', String(refreshReplayEnabled));",
|
||||
" if (refreshReplayEnabled) {",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', String(kafka.refreshReplay.groupIdPrefix));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', String(kafka.refreshReplay.timeoutMs));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', String(kafka.refreshReplay.scanLimit));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', String(kafka.refreshReplay.matchedEventLimit));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT', String(kafka.refreshReplay.liveBufferLimit));",
|
||||
" } else {",
|
||||
" for (const envName of ['HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT']) checkCodeAgentRuntimeAbsent(item, file, container, envName);",
|
||||
" }",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED', String(kafka.enabled && kafka.features.transactionalProjector));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED', String(kafka.enabled && kafka.features.projectionOutboxRelay));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', String(kafka.enabled && kafka.features.projectionRealtime));",
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
@@ -2407,7 +2421,6 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
"if (workloadCheck.wrongCodeAgentRuntimeEnvs.length > 0) fail('code-agent-runtime-env-mismatch', { refs: workloadCheck.wrongCodeAgentRuntimeEnvs.slice(0, 12), count: workloadCheck.wrongCodeAgentRuntimeEnvs.length });",
|
||||
"if (overlay.codeAgentRuntime && overlay.codeAgentRuntime.enabled) checks.push('code-agent-runtime-env');",
|
||||
"if (workloadCheck.wrongCloudWebRuntimeEnvs.length > 0) fail('cloud-web-runtime-env-mismatch', { refs: workloadCheck.wrongCloudWebRuntimeEnvs.slice(0, 12), count: workloadCheck.wrongCloudWebRuntimeEnvs.length });",
|
||||
"if (cloudWebRuntimeEnvEntries().length > 0) checks.push('cloud-web-runtime-env');",
|
||||
"const pg = overlay.externalPostgres;",
|
||||
"if (pg && pg.serviceName) {",
|
||||
" const access = pg.runtimeAccess || { endpointAddress: pg.endpointAddress, port: pg.port };",
|
||||
@@ -2528,8 +2541,8 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
" return next;",
|
||||
" });",
|
||||
" result = result.replace(/(node scripts\\/run-bun\\.mjs scripts\\/gitops-render\\.mjs[^\\n]*--use-deploy-images[^\\n]*)/g, (match) => {",
|
||||
" if (match.includes('--check')) return runtimeGitopsVerifyScript();",
|
||||
" return `${match}\\n${[runtimePathOverlayScript(), runtimeGitopsPostprocessScript()].filter(Boolean).join('\\n')}`;",
|
||||
" if (match.includes('--check')) return match;",
|
||||
" return `${match}\\n${featureConfigSchemaValidationScript()}\\n${runtimePathOverlayScript()}`;",
|
||||
" });",
|
||||
" result = result.replace(/node scripts\\/run-bun\\.mjs scripts\\/gitops-render\\.mjs([^\\n]*?)--out \"\\$render_check_dir\"/g, (match) => match.includes('--gitops-root ') ? match : `${match} --gitops-root ${JSON.stringify(overlay.gitopsRoot)} --node ${shellSingle(overlay.nodeId)} --git-read-url ${shellSingle(overlay.gitReadUrl)} --git-write-url ${shellSingle(overlay.gitWriteUrl)} --runtime-endpoint ${shellSingle(overlay.publicApiUrl)} --web-endpoint ${shellSingle(overlay.publicWebUrl)}`);",
|
||||
" validatePrepareSourceDependencyScript(result);",
|
||||
@@ -2887,9 +2900,12 @@ function runtimeGitopsPipelineGuardScript(): string[] {
|
||||
"runtime_gitops_guard_dir=\"$render_dir/.unidesk-runtime-gitops\"",
|
||||
"mkdir -p \"$runtime_gitops_guard_dir\"",
|
||||
...writeRuntimeGitopsNativeScript("runtime-gitops-pipeline-guard.mjs", runtimeGitopsPipelineGuardNativeScript, "UNIDESK_RUNTIME_GITOPS_PIPELINE_GUARD_MJS"),
|
||||
...writeRuntimeGitopsNativeScript("runtime-gitops-scripts-configmap.mjs", runtimeGitopsScriptsConfigMapNativeScript, "UNIDESK_RUNTIME_GITOPS_SCRIPTS_CONFIGMAP_MJS"),
|
||||
...writeRuntimeGitopsNativeScript("runtime-gitops-observability.mjs", runtimeGitopsObservabilityNativeScript, "UNIDESK_RUNTIME_GITOPS_OBSERVABILITY_MJS"),
|
||||
...writeRuntimeGitopsNativeScript("runtime-gitops-postprocess.mjs", runtimeGitopsPostprocessNativeScript, "UNIDESK_RUNTIME_GITOPS_POSTPROCESS_MJS"),
|
||||
...writeRuntimeGitopsNativeScript("runtime-gitops-verify.mjs", runtimeGitopsVerifyNativeScript, "UNIDESK_RUNTIME_GITOPS_VERIFY_MJS"),
|
||||
...writeRuntimeGitopsNativeScript("feature-config-schema-warning.mjs", featureConfigSchemaWarningNativeScript, "UNIDESK_FEATURE_CONFIG_SCHEMA_WARNING_MJS"),
|
||||
...writeRuntimeGitopsNativeScript("ajv2020.min.js", ajv2020BundleScript, "UNIDESK_AJV2020_MIN_JS"),
|
||||
[
|
||||
"UNIDESK_RUNTIME_GITOPS_OVERLAY_B64=\"$overlay_b64\"",
|
||||
"node \"$runtime_gitops_guard_dir/runtime-gitops-pipeline-guard.mjs\"",
|
||||
|
||||
@@ -3,11 +3,13 @@ import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes";
|
||||
import { rootPath } from "../config";
|
||||
import { buildWebObserveCommandVisibility, nodeWebProbeRealtimeFanoutProfiles, nodeWebProbeWorkbenchKafkaDebugReplay, nodeWebProbeWorkbenchTraceReadability, resolveWebObserveActionJson } from "./web-probe-observe-actions";
|
||||
|
||||
test("realtime fanout runner contract derives topics, groups, and independent capabilities from owning YAML", () => {
|
||||
test("realtime authority runner contract derives topics, groups, and transactional capabilities from owning YAML", () => {
|
||||
const profiles = nodeWebProbeRealtimeFanoutProfiles(hwlabRuntimeLaneSpecForNode("v03", "NC01"));
|
||||
const expectedKafka = profiles["pure-kafka-live"]?.expectedKafka as Record<string, any>;
|
||||
assert.deepEqual(expectedKafka.topics, {
|
||||
@@ -16,17 +18,34 @@ test("realtime fanout runner contract derives topics, groups, and independent ca
|
||||
hwlab: "hwlab.event.v1",
|
||||
});
|
||||
assert.deepEqual(expectedKafka.groups, {
|
||||
directPublish: "hwlab-v03-agentrun-event-direct-publish",
|
||||
transactionalProjector: "hwlab-v03-agentrun-event-projector",
|
||||
liveSse: "hwlab-v03-workbench-live-sse",
|
||||
});
|
||||
assert.deepEqual(expectedKafka.capabilities, {
|
||||
directPublish: true,
|
||||
liveKafkaSse: true,
|
||||
kafkaRefreshReplay: true,
|
||||
transactionalProjector: false,
|
||||
projectionOutboxRelay: false,
|
||||
projectionRealtime: false,
|
||||
transactionalProjector: true,
|
||||
projectionOutboxRelay: true,
|
||||
projectionRealtime: true,
|
||||
});
|
||||
const kafka = hwlabRuntimeLaneSpecForNode("v03", "NC01").codeAgentRuntime?.kafkaEventBridge;
|
||||
assert.deepEqual(kafka?.transactionalProjector, { heartbeatIntervalMs: 2_000 });
|
||||
assert.deepEqual(kafka?.projectionOutboxRelay, {
|
||||
intervalMs: 250,
|
||||
batchSize: 100,
|
||||
leaseMs: 30_000,
|
||||
sendTimeoutMs: 10_000,
|
||||
retryBackoffMs: 1_000,
|
||||
});
|
||||
assert.deepEqual(kafka?.projectionRealtime, {
|
||||
outboxTailBatchSize: 100,
|
||||
sseHeartbeatMs: 15_000,
|
||||
sseDrainTimeoutMs: 2_500,
|
||||
});
|
||||
assert.deepEqual(kafka?.healthThresholds, {
|
||||
consumerLagWarning: 0,
|
||||
outboxBacklogWarning: 0,
|
||||
retryCountWarning: 0,
|
||||
failedInboxWarning: 0,
|
||||
dlqWarning: 0,
|
||||
});
|
||||
assert.deepEqual(profiles["pure-kafka-live"]?.requiredEventTypes, {
|
||||
agentrun: ["user_message", "assistant_message", "terminal_status"],
|
||||
@@ -38,6 +57,16 @@ test("realtime fanout runner contract derives topics, groups, and independent ca
|
||||
});
|
||||
});
|
||||
|
||||
test("realtime authority parser and inline render expose no direct authority or duplicate native postprocess execution", () => {
|
||||
const laneSource = readFileSync(rootPath("scripts", "src", "hwlab-node-lanes.ts"), "utf8");
|
||||
const renderSource = readFileSync(rootPath("scripts", "src", "hwlab-node", "render.ts"), "utf8");
|
||||
assert.doesNotMatch(laneSource, /direct-live/u);
|
||||
assert.equal(renderSource.includes("UNIDESK_RUNTIME_GITOPS_RUNTIME_PATH"), false);
|
||||
assert.equal(renderSource.includes('UNIDESK_RUNTIME_GITOPS_OVERLAY_B64="$overlay_b64" UNIDESK_RUNTIME_GITOPS_RUNTIME_PATH'), false);
|
||||
assert.match(renderSource, /runtimeGitopsPostprocessScript\(\)/u);
|
||||
assert.match(renderSource, /writeRuntimeGitopsNativeScript\("runtime-gitops-postprocess\.mjs"/u);
|
||||
});
|
||||
|
||||
test("Workbench Kafka debug replay runner contract is derived from owning YAML", () => {
|
||||
assert.deepEqual(nodeWebProbeWorkbenchKafkaDebugReplay(hwlabRuntimeLaneSpecForNode("v03", "NC01")), {
|
||||
enabled: true,
|
||||
|
||||
@@ -23,7 +23,11 @@ export function nodeWebProbeRealtimeFanoutProfiles(spec: HwlabRuntimeLaneSpec):
|
||||
if (Object.keys(profiles).length === 0) return {};
|
||||
const kafka = spec.codeAgentRuntime?.kafkaEventBridge;
|
||||
if (kafka === undefined) throw new Error("realtimeFanoutProfiles requires codeAgentRuntime.kafkaEventBridge in the owning YAML");
|
||||
const capabilities = Object.fromEntries(Object.entries(kafka.features).map(([name, enabled]) => [name, kafka.enabled && enabled]));
|
||||
const capabilities = {
|
||||
transactionalProjector: kafka.enabled,
|
||||
projectionOutboxRelay: kafka.enabled,
|
||||
projectionRealtime: kafka.enabled,
|
||||
};
|
||||
return Object.fromEntries(Object.entries(profiles).map(([id, profile]) => {
|
||||
if (profile.businessEvent !== kafka.hwlabEventTopic) {
|
||||
throw new Error(`realtimeFanoutProfiles.${id}.businessEvent must match codeAgentRuntime.kafkaEventBridge.hwlabEventTopic`);
|
||||
@@ -38,7 +42,6 @@ export function nodeWebProbeRealtimeFanoutProfiles(spec: HwlabRuntimeLaneSpec):
|
||||
hwlab: kafka.hwlabEventTopic,
|
||||
},
|
||||
groups: {
|
||||
directPublish: kafka.directPublishConsumerGroupId,
|
||||
transactionalProjector: kafka.transactionalProjectorConsumerGroupId,
|
||||
liveSse: kafka.hwlabEventConsumerGroupId,
|
||||
},
|
||||
|
||||
@@ -1482,6 +1482,7 @@ export function nodeRuntimeCodeAgentRuntimeStatus(spec: HwlabRuntimeLaneSpec, na
|
||||
const apiKey = secretKeyStatus(spec, runtime.apiKeySecretName, runtime.apiKeySecretKey);
|
||||
const cloudApiEnv = nodeRuntimeCodeAgentCloudApiEnvStatus(spec, runtime);
|
||||
const cloudWebEnv = nodeRuntimeCodeAgentCloudWebEnvStatus(spec, runtime);
|
||||
const kafkaAuthority = nodeRuntimeKafkaAuthorityStatus(spec, runtime);
|
||||
const ready = runnerNamespace.exitCode === 0
|
||||
&& secretNamespace.exitCode === 0
|
||||
&& managerNamespace.exitCode === 0
|
||||
@@ -1538,12 +1539,73 @@ export function nodeRuntimeCodeAgentRuntimeStatus(spec: HwlabRuntimeLaneSpec, na
|
||||
apiKey,
|
||||
cloudApiEnv,
|
||||
cloudWebEnv,
|
||||
kafkaAuthority,
|
||||
repoUrlFrom: runtime.repoUrlFrom,
|
||||
providerIdFrom: runtime.providerIdFrom,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeRuntimeKafkaAuthorityStatus(spec: HwlabRuntimeLaneSpec, runtime: NonNullable<HwlabRuntimeLaneSpec["codeAgentRuntime"]>): Record<string, unknown> {
|
||||
const kafka = runtime.kafkaEventBridge;
|
||||
if (kafka === undefined || kafka.enabled === false) return { required: false, authority: kafka === undefined ? null : "transactional-projection", valuesPrinted: false };
|
||||
const healthUrl = `${spec.publicApiUrl.replace(/\/+$/u, "")}/health/ready`;
|
||||
const result = runCommand(["curl", "-k", "-sS", "--connect-timeout", "5", "--max-time", "12", healthUrl], repoRoot, { timeoutMs: 15_000 });
|
||||
let health: Record<string, unknown> = {};
|
||||
try {
|
||||
health = result.exitCode === 0 ? record(JSON.parse(result.stdout)) : {};
|
||||
} catch {
|
||||
health = {};
|
||||
}
|
||||
const projector = record(health.kafkaProjector);
|
||||
const components = Array.isArray(projector.components) ? projector.components.map(record) : [];
|
||||
const observations = [projector, ...components];
|
||||
const consumerLag = observations.map((item) => record(item.consumerLag).total).find((value) => typeof value === "number") ?? null;
|
||||
const backlogCount = observations.map((item) => item.backlogCount).find((value) => typeof value === "number") ?? null;
|
||||
const retryCount = observations.map((item) => item.retryCount).find((value) => typeof value === "number") ?? null;
|
||||
const failedInboxCount = observations.map((item) => item.failedInboxCount).find((value) => typeof value === "number") ?? null;
|
||||
const dlqCount = observations.map((item) => item.dlqCount).find((value) => typeof value === "number") ?? null;
|
||||
const thresholds = kafka.healthThresholds;
|
||||
const warningReasons = [
|
||||
typeof consumerLag === "number" && consumerLag > thresholds.consumerLagWarning ? "consumer-lag" : null,
|
||||
typeof backlogCount === "number" && backlogCount > thresholds.outboxBacklogWarning ? "outbox-backlog" : null,
|
||||
typeof retryCount === "number" && retryCount > thresholds.retryCountWarning ? "outbox-retry" : null,
|
||||
typeof failedInboxCount === "number" && failedInboxCount > thresholds.failedInboxWarning ? "failed-inbox" : null,
|
||||
typeof dlqCount === "number" && dlqCount > thresholds.dlqWarning ? "dlq" : null,
|
||||
].filter((value): value is string => value !== null);
|
||||
return {
|
||||
required: true,
|
||||
authority: "transactional-projection",
|
||||
fixedGroups: {
|
||||
transactionalProjector: kafka.transactionalProjectorConsumerGroupId,
|
||||
liveSse: kafka.hwlabEventConsumerGroupId,
|
||||
},
|
||||
capabilities: {
|
||||
transactionalProjector: true,
|
||||
projectionOutboxRelay: true,
|
||||
projectionRealtime: true,
|
||||
},
|
||||
liveReplay: {
|
||||
live: true,
|
||||
replay: true,
|
||||
cursor: "projection-outbox-seq",
|
||||
},
|
||||
healthObserved: Object.keys(projector).length > 0,
|
||||
healthStatus: projector.status ?? null,
|
||||
consumerLag,
|
||||
backlogCount,
|
||||
retryCount,
|
||||
failedInboxCount,
|
||||
dlqCount,
|
||||
thresholds,
|
||||
warning: warningReasons.length > 0,
|
||||
warningReasons,
|
||||
blocking: false,
|
||||
result: compactRuntimeCommand(result),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeRuntimeCodeAgentManagerTarget(managerUrl: string, fallbackNamespace: string): { namespace: string; serviceName: string } {
|
||||
let parsed: URL | null = null;
|
||||
try {
|
||||
@@ -1632,47 +1694,55 @@ function nodeRuntimeCodeAgentCloudApiEnvStatus(spec: HwlabRuntimeLaneSpec, runti
|
||||
const kafkaEventBridge = runtime.kafkaEventBridge;
|
||||
if (kafkaEventBridge !== undefined) {
|
||||
expectValue("HWLAB_KAFKA_ENABLED", String(kafkaEventBridge.enabled));
|
||||
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafkaEventBridge.enabled && (kafkaEventBridge.features.directPublish || kafkaEventBridge.features.transactionalProjector)));
|
||||
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafkaEventBridge.enabled));
|
||||
expectValue("HWLAB_KAFKA_BOOTSTRAP_SERVERS", kafkaEventBridge.bootstrapServers);
|
||||
expectValue("HWLAB_KAFKA_STDIO_TOPIC", kafkaEventBridge.stdioTopic);
|
||||
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC", kafkaEventBridge.agentRunEventTopic);
|
||||
expectValue("HWLAB_KAFKA_EVENT_TOPIC", kafkaEventBridge.hwlabEventTopic);
|
||||
expectValue("HWLAB_KAFKA_CLIENT_ID", kafkaEventBridge.clientId);
|
||||
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID", kafkaEventBridge.directPublishConsumerGroupId);
|
||||
expectValue("HWLAB_KAFKA_PROJECTOR_GROUP_ID", kafkaEventBridge.transactionalProjectorConsumerGroupId);
|
||||
expectValue("HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID", kafkaEventBridge.hwlabEventConsumerGroupId);
|
||||
expectValue("HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.directPublish));
|
||||
expectValue("HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.liveKafkaSse));
|
||||
const refreshReplayEnabled = kafkaEventBridge.enabled && kafkaEventBridge.features.kafkaRefreshReplay;
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(refreshReplayEnabled));
|
||||
const refreshReplayEnvNames = [
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT",
|
||||
];
|
||||
if (refreshReplayEnabled) {
|
||||
const refreshReplay = kafkaEventBridge.refreshReplay;
|
||||
if (refreshReplay === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.refreshReplay is required when Kafka refresh replay is enabled");
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX", refreshReplay.groupIdPrefix);
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS", String(refreshReplay.timeoutMs));
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT", String(refreshReplay.scanLimit));
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT", String(refreshReplay.matchedEventLimit));
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT", String(refreshReplay.liveBufferLimit));
|
||||
if (kafkaEventBridge.enabled) {
|
||||
if (kafkaEventBridge.transactionalProjector === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.transactionalProjector is required when transactional projector is enabled");
|
||||
expectValue("HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS", String(kafkaEventBridge.transactionalProjector.heartbeatIntervalMs));
|
||||
} else {
|
||||
for (const name of refreshReplayEnvNames) expectAbsent(name);
|
||||
expectAbsent("HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS");
|
||||
}
|
||||
const relayEnvNames = [
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS",
|
||||
];
|
||||
if (kafkaEventBridge.enabled) {
|
||||
const relay = kafkaEventBridge.projectionOutboxRelay;
|
||||
if (relay === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.projectionOutboxRelay is required when projection outbox relay is enabled");
|
||||
expectValue("HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS", String(relay.intervalMs));
|
||||
expectValue("HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE", String(relay.batchSize));
|
||||
expectValue("HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS", String(relay.leaseMs));
|
||||
expectValue("HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS", String(relay.sendTimeoutMs));
|
||||
expectValue("HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS", String(relay.retryBackoffMs));
|
||||
} else {
|
||||
for (const name of relayEnvNames) expectAbsent(name);
|
||||
}
|
||||
const projectionRealtimeEnvNames = ["HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE", "HWLAB_WORKBENCH_SSE_HEARTBEAT_MS", "HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS"];
|
||||
if (kafkaEventBridge.enabled) {
|
||||
const realtime = kafkaEventBridge.projectionRealtime;
|
||||
if (realtime === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.projectionRealtime is required when projection realtime is enabled");
|
||||
expectValue("HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE", String(realtime.outboxTailBatchSize));
|
||||
expectValue("HWLAB_WORKBENCH_SSE_HEARTBEAT_MS", String(realtime.sseHeartbeatMs));
|
||||
expectValue("HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS", String(realtime.sseDrainTimeoutMs));
|
||||
} else {
|
||||
for (const name of projectionRealtimeEnvNames) expectAbsent(name);
|
||||
}
|
||||
expectValue("HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.transactionalProjector));
|
||||
expectValue("HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionOutboxRelay));
|
||||
expectValue("HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionRealtime));
|
||||
}
|
||||
return {
|
||||
ready: mismatches.length === 0,
|
||||
deploymentExists: true,
|
||||
deploymentName: "hwlab-cloud-api",
|
||||
containerName: "hwlab-cloud-api",
|
||||
checkedEnvCount: 9 + (kafkaShadowProducer === undefined ? 0 : 5) + (kafkaEventBridge === undefined ? 0 : 21),
|
||||
checkedEnvCount: 9 + (kafkaShadowProducer === undefined ? 0 : 5) + (kafkaEventBridge === undefined ? 0 : 22),
|
||||
envCount: envByName.size,
|
||||
mismatches,
|
||||
result: compactCodeAgentDeploymentProbeResult(envProbe),
|
||||
@@ -1681,53 +1751,9 @@ function nodeRuntimeCodeAgentCloudApiEnvStatus(spec: HwlabRuntimeLaneSpec, runti
|
||||
}
|
||||
|
||||
function nodeRuntimeCodeAgentCloudWebEnvStatus(spec: HwlabRuntimeLaneSpec, runtime: NonNullable<HwlabRuntimeLaneSpec["codeAgentRuntime"]>): Record<string, unknown> {
|
||||
const kafkaEventBridge = runtime.kafkaEventBridge;
|
||||
if (kafkaEventBridge === undefined) return { required: false, ready: true, checkedEnvCount: 0, valuesPrinted: false };
|
||||
const envProbe = runNodeK3sArgs(spec, [
|
||||
"kubectl",
|
||||
"-n",
|
||||
spec.runtimeNamespace,
|
||||
"get",
|
||||
"deployment",
|
||||
"hwlab-cloud-web",
|
||||
"-o",
|
||||
'jsonpath={range .spec.template.spec.containers[?(@.name=="hwlab-cloud-web")].env[*]}{.name}{"\\t"}{.value}{"\\t"}{.valueFrom.secretKeyRef.name}{"\\t"}{.valueFrom.secretKeyRef.key}{"\\n"}{end}',
|
||||
], 60);
|
||||
if (envProbe.exitCode !== 0) {
|
||||
return {
|
||||
required: true,
|
||||
ready: false,
|
||||
deploymentExists: false,
|
||||
mismatches: [{ name: "hwlab-cloud-web", reason: "deployment-missing" }],
|
||||
result: compactCodeAgentDeploymentProbeResult(envProbe),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const envByName = parseCodeAgentEnvProbe(envProbe.stdout);
|
||||
const mismatches: Array<Record<string, unknown>> = [];
|
||||
const expectValue = (name: string, expected: string): void => {
|
||||
const item = envByName.get(name);
|
||||
if (item === undefined) {
|
||||
mismatches.push({ name, expected, present: false, kind: "value" });
|
||||
return;
|
||||
}
|
||||
if (item.value !== expected) mismatches.push({ name, expected, value: item.value, present: true, kind: "value" });
|
||||
};
|
||||
expectValue("HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.liveKafkaSse));
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.kafkaRefreshReplay));
|
||||
expectValue("HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionRealtime));
|
||||
return {
|
||||
required: true,
|
||||
ready: mismatches.length === 0,
|
||||
deploymentExists: true,
|
||||
deploymentName: "hwlab-cloud-web",
|
||||
containerName: "hwlab-cloud-web",
|
||||
checkedEnvCount: 3,
|
||||
envCount: envByName.size,
|
||||
mismatches,
|
||||
result: compactCodeAgentDeploymentProbeResult(envProbe),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
void spec;
|
||||
void runtime;
|
||||
return { required: false, ready: true, checkedEnvCount: 0, valuesPrinted: false };
|
||||
}
|
||||
|
||||
type CodeAgentEnvProbeRow = {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { resolveAgentRunLaneTarget } from "./agentrun-lanes";
|
||||
import { renderAgentRunPipelineManifest } from "./agentrun-manifests";
|
||||
import { renderPacFeatureConfigSchemaStage } from "./pac-feature-config-schema-stage";
|
||||
|
||||
test("shared stage injects owning YAML OTel values for all three PaC repositories", () => {
|
||||
for (const [repositoryId, consumer] of [
|
||||
["hwlab-nc01-v03", "hwlab-nc01-v03"],
|
||||
["agentrun-nc01-v02", "agentrun-nc01-v02"],
|
||||
["sentinel-nc01-v03", "sentinel-nc01-v03"],
|
||||
] as const) {
|
||||
const stage = renderPacFeatureConfigSchemaStage({
|
||||
repositoryId,
|
||||
consumer,
|
||||
repoDirExpression: "'/workspace/source'",
|
||||
renderedRootExpression: "'/workspace/rendered'",
|
||||
});
|
||||
expect(stage).toContain("feature-config-schema-validation");
|
||||
expect(stage).toContain("cicd.feature_config.schema");
|
||||
expect(stage).toContain("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces'");
|
||||
expect(stage).toContain("OTEL_TRACES_SAMPLER_ARG='1'");
|
||||
expect(stage).toContain("OTEL_EXPORTER_TIMEOUT_MS='300'");
|
||||
expect(stage).toContain("NODE_UNIDESK_AJV2020_BUNDLE");
|
||||
expect(stage).not.toContain("process.env.webProbeSentinel");
|
||||
}
|
||||
});
|
||||
|
||||
test("HWLAB, AgentRun, and Sentinel normal stages validate without source workspace node_modules", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "feature-config-consumers-"));
|
||||
try {
|
||||
const source = join(root, "source");
|
||||
const rendered = join(root, "rendered");
|
||||
const temp = join(root, "tmp");
|
||||
mkdirSync(join(source, "config"), { recursive: true });
|
||||
mkdirSync(rendered, { recursive: true });
|
||||
mkdirSync(temp);
|
||||
writeFileSync(join(source, "config", "feature-config.schema.json"), JSON.stringify({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
properties: { webProbeSentinel: { type: "boolean", "x-unidesk-feature": "web-probe-sentinel" } },
|
||||
additionalProperties: false,
|
||||
}));
|
||||
writeFileSync(join(rendered, "deployment.json"), JSON.stringify({
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
spec: { template: { spec: { containers: [{ name: "fixture", env: [{ name: "webProbeSentinel", value: "true" }] }] } } },
|
||||
}));
|
||||
const vendorBundle = resolve(import.meta.dir, "../vendor/ajv-dist/8.17.1/ajv2020.min.js");
|
||||
for (const consumer of [
|
||||
{ repositoryId: "hwlab-nc01-v03", consumer: "hwlab-nc01-v03", ajvBundleExpression: JSON.stringify(vendorBundle) },
|
||||
{ repositoryId: "agentrun-nc01-v02", consumer: "agentrun-nc01-v02" },
|
||||
{ repositoryId: "sentinel-nc01-v03", consumer: "sentinel-nc01-v03" },
|
||||
]) {
|
||||
const stage = renderPacFeatureConfigSchemaStage({
|
||||
...consumer,
|
||||
repoDirExpression: JSON.stringify(source),
|
||||
renderedRootExpression: JSON.stringify(rendered),
|
||||
});
|
||||
const result = spawnSync("sh", [], {
|
||||
input: `set -eu\n${stage}\n`,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, TMPDIR: temp },
|
||||
});
|
||||
expect(result.status, consumer.consumer).toBe(0);
|
||||
expect(result.stderr, consumer.consumer).toContain('"code":"feature-config-schema-valid"');
|
||||
expect(result.stderr, consumer.consumer).not.toContain("feature-config-validator-unavailable");
|
||||
expect(result.stderr, consumer.consumer).not.toContain("feature-config-validator-process-failure");
|
||||
expect(readdirSync(temp), consumer.consumer).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("embedded node process failure remains a typed warning with zero shell exit", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "feature-config-stage-"));
|
||||
try {
|
||||
const bin = join(root, "bin");
|
||||
const temp = join(root, "tmp");
|
||||
mkdirSync(bin);
|
||||
mkdirSync(temp);
|
||||
writeFileSync(join(bin, "node"), "#!/bin/sh\nexit 71\n", { mode: 0o755 });
|
||||
const stage = renderPacFeatureConfigSchemaStage({
|
||||
repositoryId: "sentinel-nc01-v03",
|
||||
consumer: "sentinel-nc01-v03",
|
||||
repoDirExpression: "'/workspace/source'",
|
||||
renderedRootExpression: "'/workspace/rendered'",
|
||||
});
|
||||
const result = spawnSync("sh", [], {
|
||||
input: `set -eu\n${stage}\n`,
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}`, TMPDIR: temp },
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("feature-config-validator-process-failure");
|
||||
expect(readdirSync(temp)).toEqual([]);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("AgentRun normal GitOps publish task contains the shared warning-only stage", () => {
|
||||
const { spec } = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" });
|
||||
const pipeline = renderAgentRunPipelineManifest(spec) as Record<string, any>;
|
||||
const scripts = pipeline.spec.tasks.flatMap((task: Record<string, any>) => task.taskSpec.steps.map((step: Record<string, any>) => String(step.script ?? ""))).join("\n");
|
||||
expect(scripts).toContain("UNIDESK_PAC_CONSUMER='agentrun-nc01-v02'");
|
||||
expect(scripts).toContain("feature-config-schema-validation");
|
||||
expect(scripts).toContain("feature-config-validator-process-failure");
|
||||
expect(scripts).toContain("OTEL_EXPORTER_TIMEOUT_MS='300'");
|
||||
expect(scripts).toContain("NODE_UNIDESK_AJV2020_BUNDLE");
|
||||
});
|
||||
|
||||
test("repo-owned schema is Draft 2020-12 with one canonical feature property", () => {
|
||||
const schema = JSON.parse(readFileSync("config/feature-config.schema.json", "utf8"));
|
||||
expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
|
||||
expect(schema.properties.webProbeSentinel["x-unidesk-feature"]).toBe("web-probe-sentinel");
|
||||
expect(Object.keys(schema.properties)).toEqual(["webProbeSentinel"]);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { rootPath } from "./config";
|
||||
import { loadCicdOtelRuntimeConfig } from "../native/cicd/otel-runtime-config";
|
||||
|
||||
const validatorSource = readFileSync(rootPath("scripts/native/cicd/feature-config-schema-warning.mjs"), "utf8").trimEnd();
|
||||
const ajv2020BundleSource = readFileSync(rootPath("scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js"), "utf8").trimEnd();
|
||||
|
||||
export interface PacFeatureConfigSchemaStageOptions {
|
||||
readonly repositoryId: string;
|
||||
readonly consumer: string;
|
||||
readonly repoDirExpression: string;
|
||||
readonly renderedRootExpression: string;
|
||||
readonly ajvBundleExpression?: string | null;
|
||||
}
|
||||
|
||||
export function renderPacFeatureConfigSchemaStage(options: PacFeatureConfigSchemaStageOptions): string {
|
||||
const otel = loadCicdOtelRuntimeConfig(options.repositoryId);
|
||||
const materializeBundle = options.ajvBundleExpression === undefined || options.ajvBundleExpression === null;
|
||||
return [
|
||||
"feature_config_schema_started_ms=\"$(date +%s%3N 2>/dev/null || date +%s000)\"",
|
||||
`export UNIDESK_FEATURE_CONFIG_REPO_DIR=${options.repoDirExpression}`,
|
||||
`export UNIDESK_FEATURE_CONFIG_RENDERED_ROOT=${options.renderedRootExpression}`,
|
||||
`export UNIDESK_PAC_CONSUMER=${shellSingle(options.consumer)}`,
|
||||
`export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${shellSingle(otel.endpoint)}`,
|
||||
`export OTEL_SERVICE_NAME=${shellSingle(otel.serviceName)}`,
|
||||
`export OTEL_TRACES_SAMPLER_ARG=${shellSingle(String(otel.samplingRatio))}`,
|
||||
`export OTEL_EXPORTER_TIMEOUT_MS=${shellSingle(String(otel.timeoutMs))}`,
|
||||
...(materializeBundle
|
||||
? [
|
||||
"feature_config_ajv_bundle=''",
|
||||
"if feature_config_ajv_bundle=\"$(mktemp \"${TMPDIR:-/tmp}/unidesk-ajv2020.XXXXXX\" 2>/dev/null)\"; then",
|
||||
" if ! cat >\"$feature_config_ajv_bundle\" <<'NODE_UNIDESK_AJV2020_BUNDLE'",
|
||||
ajv2020BundleSource,
|
||||
"NODE_UNIDESK_AJV2020_BUNDLE",
|
||||
" then",
|
||||
" rm -f \"$feature_config_ajv_bundle\" || true",
|
||||
" feature_config_ajv_bundle=''",
|
||||
" fi",
|
||||
"fi",
|
||||
"export UNIDESK_AJV2020_BUNDLE=\"${feature_config_ajv_bundle:-${TMPDIR:-/tmp}/unidesk-ajv2020-unavailable}\"",
|
||||
]
|
||||
: [`export UNIDESK_AJV2020_BUNDLE=${options.ajvBundleExpression}`]),
|
||||
"if ! node --input-type=module <<'NODE_UNIDESK_FEATURE_CONFIG_SCHEMA'",
|
||||
validatorSource,
|
||||
"await runFeatureConfigSchemaValidation({",
|
||||
" repoDir: process.env.UNIDESK_FEATURE_CONFIG_REPO_DIR,",
|
||||
" renderedRoot: process.env.UNIDESK_FEATURE_CONFIG_RENDERED_ROOT,",
|
||||
" consumer: process.env.UNIDESK_PAC_CONSUMER,",
|
||||
"});",
|
||||
"NODE_UNIDESK_FEATURE_CONFIG_SCHEMA",
|
||||
"then",
|
||||
" printf '{\"event\":\"feature-config-schema-validation\",\"warning\":true,\"blocking\":false,\"code\":\"feature-config-validator-process-failure\",\"mutation\":false,\"valuesPrinted\":false}\\n' >&2",
|
||||
"fi",
|
||||
...(materializeBundle ? ["if [ -n \"$feature_config_ajv_bundle\" ]; then rm -f \"$feature_config_ajv_bundle\" || true; fi"] : []),
|
||||
"if command -v ci_timing_emit >/dev/null 2>&1; then",
|
||||
" ci_timing_emit feature-config-schema-validation succeeded \"$feature_config_schema_started_ms\"",
|
||||
"else",
|
||||
" printf '{\"event\":\"ci.stage.timing\",\"stage\":\"feature-config-schema-validation\",\"status\":\"succeeded\",\"blocking\":false}\\n' >&2",
|
||||
"fi",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function shellSingle(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
@@ -11,6 +11,9 @@ const require = createRequire(import.meta.url);
|
||||
const { parsePacLogRecords } = require("../native/cicd/pac-status-evaluator.cjs") as {
|
||||
parsePacLogRecords(value: string): Array<Record<string, unknown>>;
|
||||
};
|
||||
const { extractPacSourceObservation } = require("../native/cicd/pac-status-evaluator.cjs") as {
|
||||
extractPacSourceObservation(records: Array<Record<string, unknown>>, sourceCommit: string): Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe("PaC 失败证据合同", () => {
|
||||
test("真实 sentinel TaskRun 日志保留 typed 首断点", () => {
|
||||
@@ -52,6 +55,39 @@ describe("PaC 失败证据合同", () => {
|
||||
expect(compactStatus).toMatchObject({ artifact: { traceId: row.traceId, firstBreak: row.firstBreak, error: row.error } });
|
||||
});
|
||||
|
||||
test("feature-config warning 在 status、history、debug-step 同构有界投影", () => {
|
||||
const observation = extractPacSourceObservation([
|
||||
{
|
||||
event: "pac-delivery-plan",
|
||||
sourceCommitId: "a".repeat(40),
|
||||
affectedServices: [],
|
||||
rolloutServices: [],
|
||||
buildServices: [],
|
||||
reusedServices: [],
|
||||
},
|
||||
{
|
||||
event: "feature-config-schema-validation",
|
||||
warning: true,
|
||||
blocking: false,
|
||||
code: "feature-config-input-missing",
|
||||
schemaPath: "config/feature-config.schema.json",
|
||||
errorCount: 1,
|
||||
firstError: "rendered workload candidate snapshot is missing",
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
]);
|
||||
const expected = "feature-config-schema: warning=true blocking=false code=feature-config-input-missing path=config/feature-config.schema.json errors=1 first=rendered workload candidate snapshot is missing";
|
||||
const history = renderHistory({ rows: [{ id: "run", sourceObservation: observation }], detailId: "run", config: {}, historyErrors: [], next: {} }).renderedText;
|
||||
const debug = renderDebugStep({ realRun: { found: true, pipelineRun: {}, artifact: { sourceObservation: observation } }, checks: [], target: {}, next: {} }).renderedText;
|
||||
const status = renderStatus({ summary: { latestPipelineRun: {}, artifact: {}, diagnostics: {}, sourceObservation: observation }, consumer: {}, deliveryAuthority: {}, config: {}, coverage: [], target: {}, next: {} }).renderedText;
|
||||
expect(status).toContain(expected);
|
||||
expect(debug).toContain(expected);
|
||||
expect(history).toContain("featureConfigCode");
|
||||
expect(history).toContain("feature-config-input-missing");
|
||||
expect(JSON.stringify(observation)).not.toContain("process.env");
|
||||
});
|
||||
|
||||
test("collector 遍历全部 matching TaskRun,terminal record 仍限定合同任务", () => {
|
||||
const remote = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-remote.sh"), "utf8");
|
||||
expect(remote).toContain("for (const item of matching)");
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { featureConfigSchemaHistoryFields, renderFeatureConfigSchemaLine } from "./platform-infra-pac-feature-config-projection";
|
||||
|
||||
test("status and debug share one bounded feature config line", () => {
|
||||
const observation = {
|
||||
warning: true,
|
||||
blocking: false,
|
||||
code: "feature-config-value-mismatch",
|
||||
schemaPath: "config/feature-config.schema.json",
|
||||
errorCount: 2,
|
||||
firstError: " /webProbeSentinel required must have required property ",
|
||||
};
|
||||
const line = renderFeatureConfigSchemaLine(observation);
|
||||
expect(line).toBe(" feature-config-schema: warning=true blocking=false code=feature-config-value-mismatch path=config/feature-config.schema.json errors=2 first=/webProbeSentinel required must have required property");
|
||||
expect(line.length).toBeLessThan(300);
|
||||
});
|
||||
|
||||
test("history uses the same bounded observation fields", () => {
|
||||
expect(featureConfigSchemaHistoryFields({
|
||||
warning: true,
|
||||
blocking: false,
|
||||
code: "feature-config-schema-missing",
|
||||
schemaPath: "config/feature-config.schema.json",
|
||||
errorCount: 1,
|
||||
firstError: "schema file is missing",
|
||||
})).toEqual([
|
||||
["featureConfigWarning", "true"],
|
||||
["featureConfigBlocking", "false"],
|
||||
["featureConfigCode", "feature-config-schema-missing"],
|
||||
["featureConfigSchemaPath", "config/feature-config.schema.json"],
|
||||
["featureConfigErrorCount", "1"],
|
||||
["featureConfigFirstError", "schema file is missing"],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export function renderFeatureConfigSchemaLine(value: unknown): string {
|
||||
const featureConfigSchema = record(value);
|
||||
return ` feature-config-schema: warning=${boolText(featureConfigSchema.warning)} blocking=${boolText(featureConfigSchema.blocking)} code=${stringValue(featureConfigSchema.code)} path=${stringValue(featureConfigSchema.schemaPath)} errors=${stringValue(featureConfigSchema.errorCount)} first=${compactLine(stringValue(featureConfigSchema.firstError))}`;
|
||||
}
|
||||
|
||||
export function featureConfigSchemaHistoryFields(value: unknown): string[][] {
|
||||
const featureConfigSchema = record(value);
|
||||
return [
|
||||
["featureConfigWarning", stringValue(featureConfigSchema.warning)],
|
||||
["featureConfigBlocking", stringValue(featureConfigSchema.blocking)],
|
||||
["featureConfigCode", stringValue(featureConfigSchema.code)],
|
||||
["featureConfigSchemaPath", stringValue(featureConfigSchema.schemaPath)],
|
||||
["featureConfigErrorCount", stringValue(featureConfigSchema.errorCount)],
|
||||
["featureConfigFirstError", compactLine(stringValue(featureConfigSchema.firstError))],
|
||||
];
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = "-"): string {
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function boolText(value: unknown): string {
|
||||
return value === true ? "true" : "false";
|
||||
}
|
||||
|
||||
function compactLine(value: string): string {
|
||||
const trimmed = value.replace(/\s+/gu, " ").trim();
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 120) : "-";
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { resolve } from "node:path";
|
||||
import { rootPath } from "./config";
|
||||
import { applyNodeRuntimeDeployYamlOverlay, nodeRuntimeDeployYamlOverlayShellScript } from "./hwlab-node/deploy-overlay";
|
||||
import { hwlabRuntimePipelineProvenance } from "./hwlab-node/pipeline-provenance";
|
||||
import { nodeRuntimePipelineProvenancePostprocessScript } from "./hwlab-node/render";
|
||||
import { nodeRuntimeFeatureConfigSchemaStage, nodeRuntimePipelinePostprocessScript, nodeRuntimePipelineProvenancePostprocessScript } from "./hwlab-node/render";
|
||||
import { nodeRuntimeRenderOverlay } from "./hwlab-node/web-probe";
|
||||
import { hwlabRuntimeLaneSpecForNode, parseHwlabRuntimePipelineProvenance } from "./hwlab-node-lanes";
|
||||
import { pipelineProvenanceAnnotationKeys, pipelineProvenanceAnnotations } from "./pipeline-provenance";
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
observePacSourceArtifactRuntime,
|
||||
selectPipelineRunBySourceCommit,
|
||||
} from "../native/cicd/pac-source-artifact-runtime.mjs";
|
||||
import { renderGitOpsResources } from "../native/cicd/publish-unidesk-host-gitops.mjs";
|
||||
|
||||
const sourceCommit = "a".repeat(40);
|
||||
const provenance = {
|
||||
@@ -238,6 +239,189 @@ describe("HWLAB YAML-owned Pipeline provenance", () => {
|
||||
}, ownerPath, configRef)).toThrow("mode must be one of remote-pipeline-annotation");
|
||||
});
|
||||
|
||||
test("runtime GitOps guard keeps Tekton scripts below the owning YAML budget", () => {
|
||||
const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-hwlab-runtime-gitops-guard-"));
|
||||
try {
|
||||
const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01");
|
||||
const budget = spec.pipelineProvenance?.maxInlineScriptBytes;
|
||||
expect(budget).toBeNumber();
|
||||
const overlay = nodeRuntimeRenderOverlay(spec);
|
||||
const pipelineDir = resolve(temporary, spec.tektonDir);
|
||||
const pipelinePath = resolve(pipelineDir, "pipeline.yaml");
|
||||
const configMapPath = resolve(pipelineDir, "runtime-gitops-scripts.yaml");
|
||||
const gitMirrorDir = resolve(temporary, "devops-infra");
|
||||
mkdirSync(pipelineDir, { recursive: true });
|
||||
mkdirSync(gitMirrorDir, { recursive: true });
|
||||
writeFileSync(resolve(gitMirrorDir, "git-mirror.yaml"), Bun.YAML.stringify({
|
||||
apiVersion: "v1",
|
||||
kind: "List",
|
||||
items: [
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "ConfigMap",
|
||||
metadata: { name: overlay.gitMirror.syncConfigMapName },
|
||||
data: {
|
||||
"sync.sh": "#!/bin/sh\nset -eu\nrepo_url='ssh://git@github.com/pikasTech/HWLAB.git'\n",
|
||||
"flush.sh": "#!/bin/sh\nset -eu\nremote='ssh://git@github.com/pikasTech/HWLAB.git'\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
metadata: { name: overlay.gitMirror.serviceReadName },
|
||||
spec: { template: { spec: { containers: [{ name: "git-mirror", env: [] }] } } },
|
||||
},
|
||||
],
|
||||
}));
|
||||
writeFileSync(pipelinePath, Bun.YAML.stringify({
|
||||
apiVersion: "tekton.dev/v1",
|
||||
kind: "Pipeline",
|
||||
metadata: { name: spec.pipeline, namespace: "hwlab-ci" },
|
||||
spec: { tasks: [{
|
||||
name: "gitops-promote",
|
||||
taskSpec: { steps: [{
|
||||
name: "render",
|
||||
script: [
|
||||
"if [ -s /workspace/source/affected-services.json ] && node - /workspace/source/affected-services.json <<'NODE'",
|
||||
"const fs = require(\"node:fs\");",
|
||||
"const plan = JSON.parse(fs.readFileSync(process.argv[2], \"utf8\"));",
|
||||
"const buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : [];",
|
||||
"const affectedServices = Array.isArray(plan.affectedServices) ? plan.affectedServices : [];",
|
||||
"const willRunGitopsPromote = plan.ciCdPlan && plan.ciCdPlan.willRunGitopsPromote === true;",
|
||||
"process.exit(!willRunGitopsPromote && buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);",
|
||||
"NODE",
|
||||
"then",
|
||||
" printf 'false' > /tekton/results/runtime-ready-required || true",
|
||||
" echo '{\"event\":\"gitops-promote\",\"status\":\"skipped\",\"reason\":\"no-build-no-rollout-plan\"}'",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"node scripts/run-bun.mjs scripts/gitops-render.mjs --use-deploy-images",
|
||||
"node scripts/run-bun.mjs scripts/gitops-render.mjs --use-deploy-images --check",
|
||||
"",
|
||||
].join("\n"),
|
||||
}] },
|
||||
}] },
|
||||
}));
|
||||
const overlayBase64 = Buffer.from(JSON.stringify(overlay), "utf8").toString("base64");
|
||||
const postprocessInput = [
|
||||
"set -eu",
|
||||
`render_dir=${JSON.stringify(temporary)}`,
|
||||
`overlay_b64=${JSON.stringify(overlayBase64)}`,
|
||||
...nodeRuntimePipelinePostprocessScript(nodeRuntimeFeatureConfigSchemaStage(spec)),
|
||||
].join("\n");
|
||||
const result = spawnSync("sh", [], {
|
||||
cwd: rootPath(),
|
||||
input: postprocessInput,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const guardedPipelineText = readFileSync(pipelinePath, "utf8");
|
||||
const repeated = spawnSync("node", [
|
||||
resolve(temporary, ".unidesk-runtime-gitops", "runtime-gitops-pipeline-guard.mjs"),
|
||||
"--pipeline", pipelinePath,
|
||||
], {
|
||||
cwd: rootPath(),
|
||||
env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_B64: overlayBase64 },
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(repeated.status, repeated.stderr).toBe(0);
|
||||
expect(readFileSync(pipelinePath, "utf8")).toBe(guardedPipelineText);
|
||||
const pipeline = Bun.YAML.parse(guardedPipelineText) as Record<string, any>;
|
||||
const scripts = pipeline.spec.tasks.flatMap((task: Record<string, any>) => task.taskSpec.steps.map((step: Record<string, any>) => String(step.script ?? "")));
|
||||
expect(Math.max(...scripts.map((script: string) => Buffer.byteLength(script)))).toBeLessThanOrEqual(budget as number);
|
||||
expect(scripts.join("\n")).toContain("process.exit(buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);");
|
||||
expect(scripts.join("\n")).not.toContain("!willRunGitopsPromote");
|
||||
expect(scripts.join("\n")).toContain("no-build-no-rollout-plan-gitops-verify");
|
||||
expect(scripts.join("\n")).not.toContain('"status":"skipped","reason":"no-build-no-rollout-plan"');
|
||||
expect(scripts.join("\n")).toContain("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json");
|
||||
expect(scripts.join("\n")).toContain("export UNIDESK_AJV2020_BUNDLE='/etc/unidesk-cicd-runtime-gitops/ajv2020.min.js'");
|
||||
expect(scripts.join("\n")).toContain("feature-config-schema-validation");
|
||||
expect(scripts.join("\n")).toContain("cicd.feature_config.schema");
|
||||
expect(scripts.join("\n")).toContain("export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces'");
|
||||
expect(scripts.join("\n")).toContain("export OTEL_SERVICE_NAME='unidesk-cicd'");
|
||||
expect(scripts.join("\n")).toContain("export OTEL_TRACES_SAMPLER_ARG='1'");
|
||||
expect(scripts.join("\n")).toContain("export OTEL_EXPORTER_TIMEOUT_MS='300'");
|
||||
expect(scripts.join("\n")).toContain("feature-config-validator-process-failure");
|
||||
expect(scripts.join("\n").indexOf("scripts/gitops-render.mjs --use-deploy-images"))
|
||||
.toBeLessThan(scripts.join("\n").indexOf("feature-config-schema-validation"));
|
||||
expect(scripts.join("\n")).not.toContain("NODE_UNIDESK_RUNTIME_GITOPS_POSTPROCESS");
|
||||
expect(scripts.join("\n")).not.toContain("NODE_UNIDESK_RUNTIME_GITOPS_VERIFY");
|
||||
expect(scripts.join("\n")).not.toContain("UNIDESK_RUNTIME_GITOPS_OVERLAY_B64=");
|
||||
expect(scripts.join("\n")).not.toContain(Buffer.from(JSON.stringify(overlay.observability), "utf8").toString("base64"));
|
||||
const configMap = Bun.YAML.parse(readFileSync(configMapPath, "utf8")) as Record<string, any>;
|
||||
expect(configMap.metadata.labels["app.kubernetes.io/managed-by"]).toBe("unidesk-host-gitops");
|
||||
expect(JSON.parse(configMap.data["runtime-gitops-overlay.json"])).toEqual({ runtimePath: overlay.runtimePath, observability: overlay.observability, codeAgentRuntime: overlay.codeAgentRuntime });
|
||||
expect(Object.keys(configMap.data).sort()).toEqual([
|
||||
"ajv2020.min.js",
|
||||
"feature-config-schema-warning.mjs",
|
||||
"runtime-gitops-observability.mjs",
|
||||
"runtime-gitops-overlay.json",
|
||||
"runtime-gitops-postprocess.mjs",
|
||||
"runtime-gitops-verify.mjs",
|
||||
]);
|
||||
const hostConfig = Bun.YAML.parse(readFileSync(rootPath("config", "unidesk-host-k8s.yaml"), "utf8")) as Record<string, any>;
|
||||
const runtimePaths = new Set(hostConfig.delivery.changeDetection.runtimePaths as string[]);
|
||||
for (const inputPath of [
|
||||
"bun.lock",
|
||||
"config/hwlab-node-lanes.yaml",
|
||||
"package.json",
|
||||
"scripts/native/cicd/feature-config-schema-warning.mjs",
|
||||
"scripts/native/hwlab/runtime-gitops-observability.mjs",
|
||||
"scripts/native/hwlab/runtime-gitops-postprocess.mjs",
|
||||
"scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs",
|
||||
"scripts/native/hwlab/runtime-gitops-verify.mjs",
|
||||
"scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js",
|
||||
"scripts/vendor/ajv-dist/8.17.1/manifest.json",
|
||||
"scripts/src/config.ts",
|
||||
"scripts/src/hwlab-node/render.ts",
|
||||
"scripts/src/hwlab-node-lanes.ts",
|
||||
"scripts/src/platform-infra-pipelines-as-code.ts",
|
||||
"scripts/src/yaml-composition.ts",
|
||||
]) expect(runtimePaths.has(inputPath), inputPath).toBe(true);
|
||||
expect(hostConfig.delivery.gitops.resources).toContainEqual({
|
||||
id: "hwlab-nc01-v03-runtime-gitops-scripts",
|
||||
renderer: "hwlab-runtime-gitops-scripts",
|
||||
configRef: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01",
|
||||
manifestPath: "deploy/gitops/unidesk-host/hwlab-nc01-v03-runtime-gitops-scripts.yaml",
|
||||
namespace: "hwlab-ci",
|
||||
});
|
||||
const gitopsResources = renderGitOpsResources(hostConfig.delivery.gitops, rootPath());
|
||||
expect(gitopsResources).toHaveLength(1);
|
||||
expect(gitopsResources[0]?.path).toBe("deploy/gitops/unidesk-host/hwlab-nc01-v03-runtime-gitops-scripts.yaml");
|
||||
const gitopsConfigMap = Bun.YAML.parse(gitopsResources[0]?.content ?? "") as Record<string, any>;
|
||||
expect(gitopsConfigMap.metadata.name).toBe("hwlab-nc01-v03-ci-image-publish-runtime-gitops-scripts");
|
||||
expect(gitopsConfigMap.metadata.namespace).toBe("hwlab-ci");
|
||||
expect(gitopsConfigMap.data["runtime-gitops-postprocess.mjs"]).toContain("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE");
|
||||
expect(gitopsConfigMap.data["runtime-gitops-postprocess.mjs"].indexOf("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE"))
|
||||
.toBeLessThan(gitopsConfigMap.data["runtime-gitops-postprocess.mjs"].indexOf("UNIDESK_RUNTIME_GITOPS_OVERLAY_B64"));
|
||||
const legacyPayload = Buffer.from(JSON.stringify({ runtimePath: overlay.runtimePath, observability: overlay.observability }), "utf8").toString("base64");
|
||||
const legacyPipeline = Bun.YAML.parse(readFileSync(pipelinePath, "utf8")) as Record<string, any>;
|
||||
const legacyStep = legacyPipeline.spec.tasks[0].taskSpec.steps[0];
|
||||
legacyStep.script = String(legacyStep.script).replaceAll(
|
||||
"UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json",
|
||||
`UNIDESK_RUNTIME_GITOPS_OVERLAY_B64='${legacyPayload}'`,
|
||||
);
|
||||
writeFileSync(pipelinePath, Bun.YAML.stringify(legacyPipeline));
|
||||
const migrated = spawnSync("node", [
|
||||
rootPath("scripts", "native", "hwlab", "runtime-gitops-pipeline-guard.mjs"),
|
||||
"--pipeline", pipelinePath,
|
||||
], {
|
||||
cwd: rootPath(),
|
||||
env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_B64: Buffer.from(JSON.stringify(overlay), "utf8").toString("base64") },
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(migrated.status).toBe(0);
|
||||
const migratedText = readFileSync(pipelinePath, "utf8");
|
||||
expect(migratedText).toContain("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json");
|
||||
expect(migratedText).not.toContain(legacyPayload);
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("normal control-plane postprocess writes exactly the shared four provenance fields", () => {
|
||||
const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-hwlab-pipeline-provenance-"));
|
||||
try {
|
||||
@@ -491,7 +675,15 @@ describe("runtime source-commit selection", () => {
|
||||
configRef: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01",
|
||||
renderer: "hwlab-runtime-lane",
|
||||
mode: "remote-pipeline-annotation",
|
||||
maxInlineScriptBytes: 32768,
|
||||
});
|
||||
expect(spec.codeAgentRuntime?.kafkaEventBridge).toMatchObject({
|
||||
enabled: true,
|
||||
transactionalProjectorConsumerGroupId: "hwlab-v03-agentrun-event-projector",
|
||||
hwlabEventConsumerGroupId: "hwlab-v03-workbench-live-sse",
|
||||
});
|
||||
expect(spec.codeAgentRuntime?.kafkaEventBridge).not.toHaveProperty("features");
|
||||
expect(spec.codeAgentRuntime?.kafkaEventBridge).not.toHaveProperty("refreshReplay");
|
||||
expect(remoteAnnotations).toEqual(pipelineProvenanceAnnotations(remoteProvenance));
|
||||
expect(Object.keys(remoteAnnotations).sort()).toEqual(Object.values(pipelineProvenanceAnnotationKeys).sort());
|
||||
expect(readFileSync(rootPath("scripts", "src", "hwlab-node", "render.ts"), "utf8")).not.toContain("overlay.sourceArtifactProvenance");
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AGENTRUN_CONFIG_PATH, resolveAgentRunLaneTarget } from "./agentrun-lane
|
||||
import { renderAgentRunPipelineManifest } from "./agentrun-manifests";
|
||||
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
|
||||
import { nodeRuntimeGitopsRoot } from "./hwlab-node/cleanup";
|
||||
import { nodeRuntimePipelinePostprocessScript } from "./hwlab-node/render";
|
||||
import { nodeRuntimeFeatureConfigSchemaStage, nodeRuntimePipelinePostprocessScript } from "./hwlab-node/render";
|
||||
import { nodeRuntimeRenderOverlay } from "./hwlab-node/web-probe";
|
||||
import { hwlabRuntimePipelineProvenance } from "./hwlab-node/pipeline-provenance";
|
||||
import { applyNodeRuntimeDeployYamlOverlay } from "./hwlab-node/deploy-overlay";
|
||||
@@ -617,7 +617,7 @@ function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWor
|
||||
"set -eu",
|
||||
`render_dir=${shellQuote(output)}`,
|
||||
`overlay_b64=${shellQuote(overlay)}`,
|
||||
...nodeRuntimePipelinePostprocessScript(),
|
||||
...nodeRuntimePipelinePostprocessScript(nodeRuntimeFeatureConfigSchemaStage(spec)),
|
||||
].join("\n");
|
||||
runChecked("sh", [], temporarySource, 120_000, "HWLAB UniDesk domain postprocess/verify renderer", postprocess);
|
||||
const path = resolve(output, spec.tektonDir, "pipeline.yaml");
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
renderPacBootstrap,
|
||||
resolvePacBootstrapMirrorRepository,
|
||||
} from "./platform-infra-pipelines-as-code-bootstrap";
|
||||
import { featureConfigSchemaHistoryFields, renderFeatureConfigSchemaLine } from "./platform-infra-pac-feature-config-projection";
|
||||
|
||||
const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml");
|
||||
const configLabel = "config/platform-infra/pipelines-as-code.yaml";
|
||||
@@ -2286,6 +2287,7 @@ export function renderStatus(result: Record<string, unknown>): RenderedCliResult
|
||||
stringValue(deliveryPlan.reusedServiceCount),
|
||||
stringValue(sourceObservation.reason),
|
||||
]]),
|
||||
renderFeatureConfigSchemaLine(sourceObservation.featureConfigSchema),
|
||||
"",
|
||||
"TASKRUN DURATIONS",
|
||||
...(taskRuns.length === 0 ? ["-"] : table(["TASKRUN", "STATUS", "REASON", "FAILED_STEP", "EXIT", "DURATION_S"], taskRuns.map((item) => {
|
||||
@@ -2503,6 +2505,7 @@ export function renderDebugStep(result: Record<string, unknown>): RenderedCliRes
|
||||
stringValue(catalog.digestCount),
|
||||
short(stringValue(artifact.gitopsCommit), 16),
|
||||
]]),
|
||||
renderFeatureConfigSchemaLine(record(artifact.sourceObservation).featureConfigSchema),
|
||||
]),
|
||||
"",
|
||||
"STATUS EVALUATOR FIXTURES",
|
||||
@@ -2925,6 +2928,7 @@ function renderHistoryDetail(row: Record<string, unknown>): string[] {
|
||||
["rolloutServices", stringValue(deliveryPlan.rolloutServiceCount)],
|
||||
["buildServices", stringValue(deliveryPlan.buildServiceCount)],
|
||||
["reusedServices", stringValue(deliveryPlan.reusedServiceCount)],
|
||||
...featureConfigSchemaHistoryFields(sourceObservation.featureConfigSchema),
|
||||
]),
|
||||
"",
|
||||
"TERMINAL EVIDENCE",
|
||||
|
||||
+203
-19
@@ -40,7 +40,7 @@ interface SecretDistributionConfig {
|
||||
version: number;
|
||||
kind: "unidesk-secret-distribution";
|
||||
metadata: { id: string; owner: string; relatedIssues: number[] };
|
||||
sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: RawSourceFileConfig[] };
|
||||
sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: SourceFileConfig[] };
|
||||
targets: DistributionTarget[];
|
||||
kubernetesSecrets: KubernetesSecretConfig[];
|
||||
}
|
||||
@@ -76,6 +76,11 @@ interface DistributionTarget {
|
||||
namespace: string;
|
||||
scope: string;
|
||||
enabled: boolean;
|
||||
consumerRollout: {
|
||||
deployments: string[];
|
||||
timeoutSeconds: number;
|
||||
pollIntervalSeconds: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface KubernetesSecretConfig {
|
||||
@@ -263,8 +268,9 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise<Rec
|
||||
mode: "confirmed",
|
||||
mutation: true,
|
||||
config: configSummary(distribution, options),
|
||||
localSources: sourceSummary(sources),
|
||||
desiredSecrets: desired.map(desiredSecretSummary),
|
||||
...(options.full
|
||||
? { localSources: sourceSummary(sources), desiredSecrets: desired.map(desiredSecretSummary) }
|
||||
: { localSources: compactSourceSummary(sources), desiredSecrets: desired.map(compactDesiredSecretSummary) }),
|
||||
targets: perTarget,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
@@ -311,7 +317,7 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig
|
||||
files: arrayOfRecords(sources.files, `${label}.sources.files`).map((item, index) => parseSourceFile(item, `${label}.sources.files[${index}]`)),
|
||||
externalFiles: sources.externalFiles === undefined
|
||||
? []
|
||||
: arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseRawSourceFile(item, `${label}.sources.externalFiles[${index}]`)),
|
||||
: arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseExternalSourceFile(item, `${label}.sources.externalFiles[${index}]`)),
|
||||
},
|
||||
targets: arrayOfRecords(root.targets, `${label}.targets`).map((item, index) => parseTarget(item, `${label}.targets[${index}]`)),
|
||||
kubernetesSecrets: arrayOfRecords(root.kubernetesSecrets, `${label}.kubernetesSecrets`).map((item, index) => parseKubernetesSecret(item, `${label}.kubernetesSecrets[${index}]`)),
|
||||
@@ -320,13 +326,13 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseSourceFile(record: Record<string, unknown>, path: string): EnvSourceFileConfig {
|
||||
function parseSourceFile(record: Record<string, unknown>, path: string, external = false): EnvSourceFileConfig {
|
||||
const type = stringField(record, "type", path);
|
||||
if (type !== "env") throw new Error(`${path}.type must be env`);
|
||||
const createRaw = record.createIfMissing === undefined ? {} : objectField(record, "createIfMissing", path);
|
||||
const randomBase64UrlRaw = createRaw.randomBase64Url === undefined ? {} : objectField(createRaw, "randomBase64Url", `${path}.createIfMissing`);
|
||||
return {
|
||||
sourceRef: sourceRefField(record, "sourceRef", path),
|
||||
sourceRef: external ? externalSourceRefField(record, "sourceRef", path) : sourceRefField(record, "sourceRef", path),
|
||||
type,
|
||||
requiredKeys: stringArrayField(record, "requiredKeys", path).map((key, index) => envKeyValue(key, `${path}.requiredKeys[${index}]`)),
|
||||
required: true,
|
||||
@@ -339,15 +345,16 @@ function parseSourceFile(record: Record<string, unknown>, path: string): EnvSour
|
||||
};
|
||||
}
|
||||
|
||||
function parseRawSourceFile(record: Record<string, unknown>, path: string): RawSourceFileConfig {
|
||||
function parseExternalSourceFile(record: Record<string, unknown>, path: string): SourceFileConfig {
|
||||
const type = stringField(record, "type", path);
|
||||
if (type === "env") return parseSourceFile(record, path, true);
|
||||
if (type !== "raw-file") throw new Error(`${path}.type must be raw-file`);
|
||||
const createRaw = objectField(record, "createIfMissing", path);
|
||||
const enabled = booleanField(createRaw, "enabled", `${path}.createIfMissing`);
|
||||
const randomHex = createRaw.randomHex === undefined
|
||||
const randomHex: Record<string, number> = createRaw.randomHex === undefined
|
||||
? {}
|
||||
: { contents: randomByteCount(createRaw.randomHex, `${path}.createIfMissing.randomHex`) };
|
||||
const randomBase64Url = createRaw.randomBase64Url === undefined
|
||||
const randomBase64Url: Record<string, { bytes: number; prefix: string }> = createRaw.randomBase64Url === undefined
|
||||
? {}
|
||||
: { contents: randomBase64UrlSpec(createRaw.randomBase64Url, `${path}.createIfMissing.randomBase64Url`) };
|
||||
if (createRaw.values !== undefined) throw new Error(`${path}.createIfMissing.values is not allowed for raw-file sources`);
|
||||
@@ -369,12 +376,22 @@ function parseRawSourceFile(record: Record<string, unknown>, path: string): RawS
|
||||
}
|
||||
|
||||
function parseTarget(record: Record<string, unknown>, path: string): DistributionTarget {
|
||||
const rollout = record.consumerRollout === undefined ? null : objectField(record, "consumerRollout", path);
|
||||
const deployments = rollout === null
|
||||
? []
|
||||
: stringArrayField(rollout, "deployments", `${path}.consumerRollout`).map((value, index) => kubernetesNameValue(value, `${path}.consumerRollout.deployments[${index}]`));
|
||||
const timeoutSeconds = rollout === null ? 0 : integerField(rollout, "timeoutSeconds", `${path}.consumerRollout`);
|
||||
const pollIntervalSeconds = rollout === null ? 0 : integerField(rollout, "pollIntervalSeconds", `${path}.consumerRollout`);
|
||||
if (rollout !== null && (deployments.length === 0 || timeoutSeconds <= 0 || pollIntervalSeconds <= 0)) {
|
||||
throw new Error(`${path}.consumerRollout requires deployments, a positive timeoutSeconds, and a positive pollIntervalSeconds`);
|
||||
}
|
||||
return {
|
||||
id: simpleId(stringField(record, "id", path), `${path}.id`),
|
||||
route: stringField(record, "route", path),
|
||||
namespace: kubernetesNameField(record, "namespace", path),
|
||||
scope: simpleId(stringField(record, "scope", path), `${path}.scope`),
|
||||
enabled: booleanField(record, "enabled", path),
|
||||
consumerRollout: rollout === null ? null : { deployments, timeoutSeconds, pollIntervalSeconds },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -414,7 +431,8 @@ function validateDistributionConfig(config: SecretDistributionConfig): void {
|
||||
for (const item of secret.data) {
|
||||
const source = sources.get(item.sourceRef);
|
||||
if (source === undefined) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} references undeclared sourceRef ${item.sourceRef}`);
|
||||
if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`);
|
||||
const requiredKeys: readonly string[] = source.requiredKeys;
|
||||
if (!requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`);
|
||||
if (targetKeys.has(item.targetKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps duplicate target key ${item.targetKey}`);
|
||||
targetKeys.add(item.targetKey);
|
||||
}
|
||||
@@ -429,7 +447,7 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean,
|
||||
const root = secretRoot(config);
|
||||
const materials = new Map<string, SourceMaterial>();
|
||||
const entries = allSourceFiles(config).filter((source) => selectedRefs.has(source.sourceRef)).map((source) => {
|
||||
const sourcePath = source.type === "env" ? join(root, source.sourceRef) : externalSourcePath(source.sourceRef);
|
||||
const sourcePath = sourcePathFor(root, source);
|
||||
const exists = existsSync(sourcePath);
|
||||
const existingText = exists ? readFileSync(sourcePath, "utf8") : "";
|
||||
const existing = exists ? source.type === "env" ? parseEnvFile(existingText) : { contents: existingText } : {};
|
||||
@@ -502,6 +520,8 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean,
|
||||
presence,
|
||||
bytes,
|
||||
fingerprint,
|
||||
ownerOnly: true,
|
||||
fileMode: "0600",
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -517,6 +537,8 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean,
|
||||
generatedKeys,
|
||||
unmaterializedGeneratedKeys,
|
||||
fingerprint: material.fingerprint,
|
||||
ownerOnly: true,
|
||||
fileMode: "0600",
|
||||
valuesPrinted: false,
|
||||
};
|
||||
});
|
||||
@@ -590,10 +612,17 @@ async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTar
|
||||
const yaml = renderSecretManifest(target, secrets);
|
||||
const result = await capture(config, target.route, ["sh"], applySecretScript(target, secrets, yaml));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const applied = result.exitCode === 0 && boolField(parsed, "ok", false);
|
||||
const consumerRolloutStatus = applied && target.consumerRollout !== null
|
||||
? await waitForConsumerRollout(config, target)
|
||||
: null;
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
ok: applied && (consumerRolloutStatus === null || consumerRolloutStatus.ok === true),
|
||||
target: targetSummary(target),
|
||||
summary: parsed,
|
||||
summary: {
|
||||
...parsed,
|
||||
...(consumerRolloutStatus === null ? {} : { consumerRolloutStatus }),
|
||||
},
|
||||
remote: secretCaptureSummary(result),
|
||||
...(options.raw ? { rawCaptureOmitted: true, rawPolicy: "Secret distribution never returns raw SSH capture because remote output can contain credential-bearing diagnostics." } : {}),
|
||||
};
|
||||
@@ -640,6 +669,9 @@ ${Object.entries(secret.data).sort(([a], [b]) => a.localeCompare(b)).map(([key,
|
||||
function applySecretScript(target: DistributionTarget, secrets: DesiredSecret[], yaml: string): string {
|
||||
const manifestB64 = Buffer.from(yaml, "utf8").toString("base64");
|
||||
const summaryB64 = Buffer.from(JSON.stringify(secrets.map(remoteSecretSummary)), "utf8").toString("base64");
|
||||
const rollout = target.consumerRollout;
|
||||
const rolloutDeployments = rollout?.deployments.join(" ") ?? "";
|
||||
const rolloutSummaryB64 = Buffer.from(JSON.stringify(rollout), "utf8").toString("base64");
|
||||
return `
|
||||
set -u
|
||||
tmp="$(mktemp -d)"
|
||||
@@ -656,22 +688,38 @@ else
|
||||
printf '%s\\n' 'skipped because namespace sync failed' >"$tmp/apply.err"
|
||||
apply_rc=1
|
||||
fi
|
||||
python3 - "$ns_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY'
|
||||
rollout_restart_rc=0
|
||||
: >"$tmp/rollout-restart.out"
|
||||
: >"$tmp/rollout-restart.err"
|
||||
if [ "$apply_rc" -eq 0 ] && [ -n '${rolloutDeployments}' ]; then
|
||||
for deployment in ${rolloutDeployments}; do
|
||||
kubectl -n ${target.namespace} rollout restart "deployment/$deployment" >>"$tmp/rollout-restart.out" 2>>"$tmp/rollout-restart.err" || {
|
||||
rollout_restart_rc=$?
|
||||
break
|
||||
}
|
||||
done
|
||||
elif [ "$apply_rc" -ne 0 ] && [ -n '${rolloutDeployments}' ]; then
|
||||
rollout_restart_rc=1
|
||||
printf '%s\\n' 'skipped because Secret apply failed' >"$tmp/rollout-restart.err"
|
||||
fi
|
||||
python3 - "$ns_rc" "$apply_rc" "$rollout_restart_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout-restart.out" "$tmp/rollout-restart.err" <<'PY'
|
||||
import base64, json, os, sys
|
||||
ns_rc, apply_rc = int(sys.argv[1]), int(sys.argv[2])
|
||||
ns_rc, apply_rc, rollout_restart_rc = [int(value) for value in sys.argv[1:4]]
|
||||
def byte_count(path):
|
||||
try:
|
||||
return os.path.getsize(path)
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
payload = {
|
||||
"ok": ns_rc == 0 and apply_rc == 0,
|
||||
"ok": ns_rc == 0 and apply_rc == 0 and rollout_restart_rc == 0,
|
||||
"namespace": "${target.namespace}",
|
||||
"secrets": json.loads(base64.b64decode("${summaryB64}").decode("utf-8")),
|
||||
"consumerRollout": json.loads(base64.b64decode("${rolloutSummaryB64}").decode("utf-8")),
|
||||
"valuesPrinted": False,
|
||||
"steps": {
|
||||
"namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[3]), "stderrBytes": byte_count(sys.argv[4]), "outputOmitted": True},
|
||||
"apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[5]), "stderrBytes": byte_count(sys.argv[6]), "outputOmitted": True},
|
||||
"namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[4]), "stderrBytes": byte_count(sys.argv[5]), "outputOmitted": True},
|
||||
"apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[6]), "stderrBytes": byte_count(sys.argv[7]), "outputOmitted": True},
|
||||
"consumerRolloutRestart": {"exitCode": rollout_restart_rc, "stdoutBytes": byte_count(sys.argv[8]), "stderrBytes": byte_count(sys.argv[9]), "outputOmitted": True},
|
||||
},
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
@@ -680,6 +728,105 @@ PY
|
||||
`;
|
||||
}
|
||||
|
||||
async function waitForConsumerRollout(config: UniDeskConfig, target: DistributionTarget): Promise<Record<string, unknown>> {
|
||||
const rollout = target.consumerRollout;
|
||||
if (rollout === null) return { ok: true, mode: "not-declared", mutation: false };
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + rollout.timeoutSeconds * 1000;
|
||||
let attempts = 0;
|
||||
let lastSummary: Record<string, unknown> | null = null;
|
||||
let lastRemote: Record<string, unknown> | null = null;
|
||||
while (true) {
|
||||
attempts += 1;
|
||||
const result = await capture(config, target.route, ["sh"], consumerRolloutStatusScript(target));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
lastSummary = parsed;
|
||||
lastRemote = secretCaptureSummary(result);
|
||||
if (result.exitCode === 0 && boolField(parsed, "ok", false)) {
|
||||
return {
|
||||
ok: true,
|
||||
mutation: false,
|
||||
attempts,
|
||||
elapsedSeconds: Math.ceil((Date.now() - startedAt) / 1000),
|
||||
summary: parsed,
|
||||
remote: lastRemote,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
mutation: false,
|
||||
attempts,
|
||||
elapsedSeconds: Math.ceil((Date.now() - startedAt) / 1000),
|
||||
reason: "consumer-rollout-timeout",
|
||||
summary: lastSummary,
|
||||
remote: lastRemote,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
await Bun.sleep(Math.min(rollout.pollIntervalSeconds * 1000, remainingMs));
|
||||
}
|
||||
}
|
||||
|
||||
function consumerRolloutStatusScript(target: DistributionTarget): string {
|
||||
const rollout = target.consumerRollout;
|
||||
if (rollout === null) throw new Error(`target ${target.id} does not declare consumerRollout`);
|
||||
const commands = rollout.deployments.map((deployment, index) => [
|
||||
`kubectl -n ${target.namespace} get deployment ${deployment} -o json >"$tmp/deployment.${index}.json" 2>"$tmp/deployment.${index}.err"`,
|
||||
`printf '%s' "$?" >"$tmp/deployment.${index}.rc"`,
|
||||
].join("\n")).join("\n");
|
||||
const expectedB64 = Buffer.from(JSON.stringify(rollout.deployments), "utf8").toString("base64");
|
||||
return `
|
||||
set -u
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
${commands}
|
||||
python3 - "$tmp" <<'PY'
|
||||
import base64, json, os, sys
|
||||
tmp = sys.argv[1]
|
||||
expected = json.loads(base64.b64decode("${expectedB64}").decode("utf-8"))
|
||||
items = []
|
||||
ok = True
|
||||
for index, name in enumerate(expected):
|
||||
try:
|
||||
rc = int(open(os.path.join(tmp, f"deployment.{index}.rc"), encoding="utf-8").read() or "1")
|
||||
except FileNotFoundError:
|
||||
rc = 1
|
||||
try:
|
||||
observed = json.load(open(os.path.join(tmp, f"deployment.{index}.json"), encoding="utf-8"))
|
||||
except Exception:
|
||||
observed = None
|
||||
metadata = (observed or {}).get("metadata") or {}
|
||||
spec = (observed or {}).get("spec") or {}
|
||||
status = (observed or {}).get("status") or {}
|
||||
generation = int(metadata.get("generation") or 0)
|
||||
observed_generation = int(status.get("observedGeneration") or 0)
|
||||
desired = int(spec.get("replicas") or 0)
|
||||
updated = int(status.get("updatedReplicas") or 0)
|
||||
ready = int(status.get("readyReplicas") or 0)
|
||||
available = int(status.get("availableReplicas") or 0)
|
||||
item_ok = rc == 0 and desired > 0 and observed_generation >= generation and updated >= desired and ready >= desired and available >= desired
|
||||
ok = ok and item_ok
|
||||
items.append({
|
||||
"name": name,
|
||||
"exists": rc == 0,
|
||||
"generation": generation,
|
||||
"observedGeneration": observed_generation,
|
||||
"desiredReplicas": desired,
|
||||
"updatedReplicas": updated,
|
||||
"readyReplicas": ready,
|
||||
"availableReplicas": available,
|
||||
"ready": item_ok,
|
||||
})
|
||||
payload = {"ok": ok, "namespace": "${target.namespace}", "deployments": items, "valuesPrinted": False}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
sys.exit(0 if ok else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function statusSecretScript(target: DistributionTarget, secrets: DesiredSecret[]): string {
|
||||
const summaryB64 = Buffer.from(JSON.stringify(secrets.map(remoteSecretSummary)), "utf8").toString("base64");
|
||||
const commands = secrets.map((secret, index) => [
|
||||
@@ -761,6 +908,22 @@ function sourceSummary(sources: SourceInspection): Record<string, unknown> {
|
||||
return { ok: sources.ok, root: sources.root, entries: sources.entries, valuesPrinted: false };
|
||||
}
|
||||
|
||||
function compactSourceSummary(sources: SourceInspection): Record<string, unknown> {
|
||||
return {
|
||||
ok: sources.ok,
|
||||
entries: sources.entries.map((entry) => ({
|
||||
sourceRef: entry.sourceRef,
|
||||
type: entry.type,
|
||||
presence: entry.presence ?? entry.exists,
|
||||
missingKeys: entry.missingKeys ?? [],
|
||||
action: entry.action ?? "none",
|
||||
fingerprint: entry.fingerprint ?? null,
|
||||
valuesPrinted: false,
|
||||
})),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function desiredSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
||||
return {
|
||||
name: secret.name,
|
||||
@@ -775,6 +938,17 @@ function desiredSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function compactDesiredSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
||||
return {
|
||||
name: secret.name,
|
||||
secretName: secret.secretName,
|
||||
keys: Object.keys(secret.data).sort(),
|
||||
missingKeys: secret.missingKeys,
|
||||
fingerprint: secret.fingerprint,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function remoteSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
||||
return {
|
||||
name: secret.name,
|
||||
@@ -786,7 +960,7 @@ function remoteSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function targetSummary(target: DistributionTarget): Record<string, unknown> {
|
||||
return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope };
|
||||
return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope, consumerRollout: target.consumerRollout };
|
||||
}
|
||||
|
||||
function readOptionValue(args: string[], index: number, option: string): string {
|
||||
@@ -967,6 +1141,12 @@ function externalSourcePath(sourceRef: string): string {
|
||||
throw new Error(`external sourceRef ${sourceRef} must be an absolute path or a ~/ home path`);
|
||||
}
|
||||
|
||||
function sourcePathFor(root: string, source: SourceFileConfig): string {
|
||||
return source.sourceRef.startsWith("~/") || isAbsolute(source.sourceRef)
|
||||
? externalSourcePath(source.sourceRef)
|
||||
: join(root, source.sourceRef);
|
||||
}
|
||||
|
||||
function sourceDataKeyField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(obj, key, path);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9._-]*$/u.test(value)) throw new Error(`${path}.${key} must be a source data key`);
|
||||
@@ -982,6 +1162,10 @@ function kubernetesNameField(obj: Record<string, unknown>, key: string, path: st
|
||||
return yamlKubernetesNameField(obj, key, path, "");
|
||||
}
|
||||
|
||||
function kubernetesNameValue(value: string, path: string): string {
|
||||
return yamlKubernetesNameField({ value }, "value", path, "");
|
||||
}
|
||||
|
||||
function kubernetesSecretKeyField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(obj, key, path);
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path}.${key} must be a Kubernetes Secret key`);
|
||||
|
||||
@@ -158,6 +158,7 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
|
||||
if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`);
|
||||
if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`);
|
||||
if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`);
|
||||
validateAccessSecret(deployment, `${path}.deployment`);
|
||||
const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`);
|
||||
if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`);
|
||||
return {
|
||||
@@ -206,6 +207,29 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
|
||||
};
|
||||
}
|
||||
|
||||
function validateAccessSecret(deployment: Record<string, unknown>, path: string): void {
|
||||
const secrets = record(deployment.secrets, `${path}.secrets`);
|
||||
const access = record(secrets.access, `${path}.secrets.access`);
|
||||
const target = record(access.target, `${path}.secrets.access.target`);
|
||||
absolutePath(access.sourceRef, `${path}.secrets.access.sourceRef`);
|
||||
kubernetesName(target.secretName, `${path}.secrets.access.target.secretName`);
|
||||
if (boolean(target.readOnly, `${path}.secrets.access.target.readOnly`) !== true) {
|
||||
throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.readOnly must be true`);
|
||||
}
|
||||
if (!Array.isArray(target.files) || target.files.length === 0) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files must be a non-empty array`);
|
||||
const targetKeys = new Set<string>();
|
||||
const mountPaths = new Set<string>();
|
||||
target.files.forEach((value, index) => {
|
||||
const file = record(value, `${path}.secrets.access.target.files[${index}]`);
|
||||
const targetKey = text(file.targetKey, `${path}.secrets.access.target.files[${index}].targetKey`);
|
||||
const mountPath = absolutePath(file.mountPath, `${path}.secrets.access.target.files[${index}].mountPath`);
|
||||
if (targetKeys.has(targetKey)) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files contains duplicate targetKey ${targetKey}`);
|
||||
if (mountPaths.has(mountPath)) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files contains duplicate mountPath ${mountPath}`);
|
||||
targetKeys.add(targetKey);
|
||||
mountPaths.add(mountPath);
|
||||
});
|
||||
}
|
||||
|
||||
function parseCutoverTarget(id: string, value: Record<string, unknown>): SelfMediaCutoverTarget {
|
||||
const path = `cutover.targets.${id}`;
|
||||
const source = record(value.source, `${path}.source`);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"package": "ajv-dist",
|
||||
"version": "8.17.1",
|
||||
"sourcePath": "node_modules/ajv-dist/dist/ajv2020.min.js",
|
||||
"vendorPath": "scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js",
|
||||
"sha256": "d2f97a24636c44135e1ffffeea29656c06a1c1f71b315e958c88d0e8126c2100",
|
||||
"bytes": 132117
|
||||
}
|
||||
Reference in New Issue
Block a user