fix: tighten branch follower gate runtime inputs
This commit is contained in:
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import https from "node:https";
|
import https from "node:https";
|
||||||
|
import { parseRuntimeReuseConfig, summarizeRuntimeReuseConfig } from "./reuse-config-summary.mjs";
|
||||||
|
|
||||||
const gate = requiredEnv("GATE");
|
const gate = requiredEnv("GATE");
|
||||||
const follower = requiredEnv("FOLLOWER_ID");
|
const follower = requiredEnv("FOLLOWER_ID");
|
||||||
@@ -19,6 +20,8 @@ const argoApplication = process.env.ARGO_APPLICATION || "";
|
|||||||
const runtimeNamespace = process.env.RUNTIME_NAMESPACE || "";
|
const runtimeNamespace = process.env.RUNTIME_NAMESPACE || "";
|
||||||
const workloads = parseWorkloads(process.env.WORKLOADS_B64 || "");
|
const workloads = parseWorkloads(process.env.WORKLOADS_B64 || "");
|
||||||
const healthUrl = process.env.HEALTH_URL || "";
|
const healthUrl = process.env.HEALTH_URL || "";
|
||||||
|
const slowTaskSeconds = requiredPositiveIntEnv("SLOW_TASK_SECONDS");
|
||||||
|
const healthTimeoutMs = requiredPositiveIntEnv("HEALTH_TIMEOUT_MS");
|
||||||
|
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const branchCommit = rev(`refs/heads/${sourceBranch}`);
|
const branchCommit = rev(`refs/heads/${sourceBranch}`);
|
||||||
@@ -49,6 +52,10 @@ console.log(JSON.stringify({
|
|||||||
follower,
|
follower,
|
||||||
source,
|
source,
|
||||||
evidence,
|
evidence,
|
||||||
|
policy: {
|
||||||
|
slowTaskSeconds,
|
||||||
|
healthTimeoutMs,
|
||||||
|
},
|
||||||
errors: errors.slice(0, 6),
|
errors: errors.slice(0, 6),
|
||||||
statusAuthority: "kubernetes-api-serviceaccount",
|
statusAuthority: "kubernetes-api-serviceaccount",
|
||||||
parsedDownstreamCliOutput: false,
|
parsedDownstreamCliOutput: false,
|
||||||
@@ -110,47 +117,18 @@ function readReuseConfig(commit) {
|
|||||||
if (!commit || !sourceStageRef) return { present: false, reason: "source-commit-missing" };
|
if (!commit || !sourceStageRef) return { present: false, reason: "source-commit-missing" };
|
||||||
try {
|
try {
|
||||||
const text = execFileSync("git", [`--git-dir=${repoPath}`, "show", `${sourceStageRef}:gitops/reuse.ymal`], { encoding: "utf8", maxBuffer: 256 * 1024 });
|
const text = execFileSync("git", [`--git-dir=${repoPath}`, "show", `${sourceStageRef}:gitops/reuse.ymal`], { encoding: "utf8", maxBuffer: 256 * 1024 });
|
||||||
const services = reuseServiceIds(text);
|
const parsed = parseRuntimeReuseConfig(text, { sourceCommit: commit, stageRef: sourceStageRef });
|
||||||
|
const summary = summarizeRuntimeReuseConfig(parsed);
|
||||||
return {
|
return {
|
||||||
present: true,
|
...summary,
|
||||||
path: "gitops/reuse.ymal",
|
|
||||||
bytes: Buffer.byteLength(text, "utf8"),
|
bytes: Buffer.byteLength(text, "utf8"),
|
||||||
sha256: createHash("sha256").update(text).digest("hex"),
|
sha256: summary.sha256 ?? createHash("sha256").update(text).digest("hex"),
|
||||||
serviceCount: services.length,
|
|
||||||
serviceIds: services.slice(0, 12),
|
|
||||||
runtimeReuseMentioned: /\bruntimeReuse\b/u.test(text),
|
|
||||||
envReuseMentioned: /\benvReuse\b/u.test(text),
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { present: false, path: "gitops/reuse.ymal", reason: shortText(error?.message || String(error)) };
|
return { present: false, path: "gitops/reuse.ymal", reason: shortText(error?.message || String(error)) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reuseServiceIds(text) {
|
|
||||||
const ids = new Set([...text.matchAll(/(?:^|\n)\s*-\s*id:\s*([A-Za-z0-9_.-]+)/gu)].map((match) => match[1]).filter(Boolean));
|
|
||||||
const lines = text.split(/\r?\n/u);
|
|
||||||
let inServices = false;
|
|
||||||
let servicesIndent = 0;
|
|
||||||
for (const line of lines) {
|
|
||||||
const services = /^(\s*)services:\s*$/u.exec(line);
|
|
||||||
if (services) {
|
|
||||||
inServices = true;
|
|
||||||
servicesIndent = services[1].length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!inServices) continue;
|
|
||||||
const match = /^(\s*)([A-Za-z0-9_.-]+):\s*$/u.exec(line);
|
|
||||||
if (!match) continue;
|
|
||||||
const indent = match[1].length;
|
|
||||||
if (indent <= servicesIndent) {
|
|
||||||
inServices = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (indent === servicesIndent + 2) ids.add(match[2]);
|
|
||||||
}
|
|
||||||
return Array.from(ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
function gitMirrorSummary(commit) {
|
function gitMirrorSummary(commit) {
|
||||||
const localSource = rev(`refs/heads/${sourceBranch}`);
|
const localSource = rev(`refs/heads/${sourceBranch}`);
|
||||||
const githubSource = rev(`refs/mirror-stage/heads/${sourceBranch}`);
|
const githubSource = rev(`refs/mirror-stage/heads/${sourceBranch}`);
|
||||||
@@ -277,9 +255,10 @@ function taskRunsSummary(list) {
|
|||||||
});
|
});
|
||||||
const failed = rows.filter((item) => item.status === "False");
|
const failed = rows.filter((item) => item.status === "False");
|
||||||
const active = rows.filter((item) => item.status !== "True" && item.status !== "False");
|
const active = rows.filter((item) => item.status !== "True" && item.status !== "False");
|
||||||
const slow = rows.filter((item) => typeof item.durationSeconds === "number" && item.durationSeconds >= 60);
|
const slow = rows.filter((item) => typeof item.durationSeconds === "number" && item.durationSeconds >= slowTaskSeconds);
|
||||||
return {
|
return {
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
|
slowThresholdSeconds: slowTaskSeconds,
|
||||||
failedCount: failed.length,
|
failedCount: failed.length,
|
||||||
activeCount: active.length,
|
activeCount: active.length,
|
||||||
slowCount: slow.length,
|
slowCount: slow.length,
|
||||||
@@ -321,7 +300,7 @@ async function httpProbe(url) {
|
|||||||
const client = url.startsWith("https:") ? https : http;
|
const client = url.startsWith("https:") ? https : http;
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const req = client.get(url, { timeout: 5000 }, (res) => {
|
const req = client.get(url, { timeout: healthTimeoutMs }, (res) => {
|
||||||
res.resume();
|
res.resume();
|
||||||
res.on("end", () => resolve({ url, ok: (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300, statusCode: res.statusCode || null, elapsedMs: Date.now() - started }));
|
res.on("end", () => resolve({ url, ok: (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300, statusCode: res.statusCode || null, elapsedMs: Date.now() - started }));
|
||||||
});
|
});
|
||||||
@@ -382,6 +361,13 @@ function requiredEnv(name) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requiredPositiveIntEnv(name) {
|
||||||
|
const value = requiredEnv(name);
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) fail(`${name} must be a positive integer`);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function fail(message) {
|
function fail(message) {
|
||||||
console.error(message);
|
console.error(message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
|
||||||
|
/etc/unidesk-cicd-branch-follower/sync-source.sh "$REPOSITORY" "$SOURCE_BRANCH" "$SNAPSHOT_PREFIX" "$REPO_PATH" >/tmp/bf-gate-source-sync.json 2>/tmp/bf-gate-source-sync.err || true
|
||||||
|
|
||||||
|
cd "$script_dir"
|
||||||
|
exec bun ./branch-follower-gate.mjs
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// SPEC: PJ2026-01060703 CI/CD branch follower reuse config.
|
||||||
|
// Responsibility: native target-side parser for runtime/env reuse declarations.
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
export const RUNTIME_REUSE_CONFIG_PATH = "gitops/reuse.ymal";
|
||||||
|
|
||||||
|
export function parseRuntimeReuseConfig(text, source) {
|
||||||
|
const errors = [];
|
||||||
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = Bun.YAML.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`${RUNTIME_REUSE_CONFIG_PATH} YAML parse failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
const root = asOptionalRecord(parsed);
|
||||||
|
if (root === null) errors.push(`${RUNTIME_REUSE_CONFIG_PATH} must be a YAML object`);
|
||||||
|
const spec = asOptionalRecord(root?.spec) ?? root ?? {};
|
||||||
|
const services = parseServices(spec, errors);
|
||||||
|
if (services.length === 0) errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.services must declare at least one service`);
|
||||||
|
const kind = stringOrNull(root?.kind);
|
||||||
|
if (kind !== null && kind !== "RuntimeReuseConfig") errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.kind must be RuntimeReuseConfig`);
|
||||||
|
return {
|
||||||
|
ok: errors.length === 0,
|
||||||
|
present: true,
|
||||||
|
path: RUNTIME_REUSE_CONFIG_PATH,
|
||||||
|
sourceCommit: source.sourceCommit,
|
||||||
|
stageRef: source.stageRef,
|
||||||
|
sha256: createHash("sha256").update(text).digest("hex"),
|
||||||
|
apiVersion: stringOrNull(root?.apiVersion),
|
||||||
|
kind,
|
||||||
|
serviceCount: services.length,
|
||||||
|
services,
|
||||||
|
errors,
|
||||||
|
valuesRedacted: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRuntimeReuseConfig(config) {
|
||||||
|
return {
|
||||||
|
ok: config.ok,
|
||||||
|
present: config.present,
|
||||||
|
path: config.path,
|
||||||
|
sourceCommit: config.sourceCommit,
|
||||||
|
stageRef: config.stageRef,
|
||||||
|
sha256: config.sha256,
|
||||||
|
serviceCount: config.serviceCount,
|
||||||
|
serviceIds: config.services.map((service) => service.id).sort(),
|
||||||
|
errors: config.errors.slice(0, 5),
|
||||||
|
valuesRedacted: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServices(spec, errors) {
|
||||||
|
const raw = spec.services;
|
||||||
|
const items = Array.isArray(raw)
|
||||||
|
? raw.map((value, index) => ({ id: stringOrNull(asOptionalRecord(value)?.id) ?? String(index), value, path: `services[${index}]` }))
|
||||||
|
: Object.entries(asOptionalRecord(raw) ?? {}).map(([id, value]) => ({ id, value, path: `services.${id}` }));
|
||||||
|
return items.flatMap(({ id, value, path }) => {
|
||||||
|
const record = asOptionalRecord(value);
|
||||||
|
if (record === null) {
|
||||||
|
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path} must be an object`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const serviceId = stringOrNull(record.id) ?? id;
|
||||||
|
if (serviceId.length === 0) errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}.id is required`);
|
||||||
|
return [{
|
||||||
|
id: serviceId,
|
||||||
|
runtimeReuse: parseRuntimeReuse(asOptionalRecord(record.runtimeReuse), `${path}.runtimeReuse`, errors),
|
||||||
|
envReuse: parseEnvReuse(asOptionalRecord(record.envReuse), `${path}.envReuse`, errors),
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRuntimeReuse(record, path, errors) {
|
||||||
|
if (record === null) return null;
|
||||||
|
return {
|
||||||
|
enabled: booleanOrNull(record.enabled),
|
||||||
|
codeIdentityPaths: safePathArray(asOptionalRecord(record.codeIdentity)?.paths ?? record.codeIdentityPaths, `${path}.codeIdentity.paths`, errors),
|
||||||
|
envIdentityPaths: safePathArray(asOptionalRecord(record.envIdentity)?.paths ?? record.envIdentityPaths, `${path}.envIdentity.paths`, errors),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvReuse(record, path, errors) {
|
||||||
|
if (record === null) return null;
|
||||||
|
return {
|
||||||
|
enabled: booleanOrNull(record.enabled),
|
||||||
|
mode: stringOrNull(record.mode),
|
||||||
|
nodeDepsPath: stringOrNull(record.nodeDepsPath),
|
||||||
|
envIdentityFiles: safePathArray(record.envIdentityFiles ?? asOptionalRecord(record.envIdentity)?.paths, `${path}.envIdentityFiles`, errors),
|
||||||
|
buildArgs: stringRecord(asOptionalRecord(record.buildArgs), `${path}.buildArgs`, errors),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePathArray(value, path, errors) {
|
||||||
|
if (value === undefined || value === null) return [];
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path} must be a string array`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.flatMap((item, index) => {
|
||||||
|
if (typeof item !== "string" || item.length === 0) {
|
||||||
|
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}[${index}] must be a non-empty string`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (item.startsWith("/") || item.includes("..")) {
|
||||||
|
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}[${index}] must be a relative path without ..`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [item];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringRecord(record, path, errors) {
|
||||||
|
if (record === null) return {};
|
||||||
|
const out = {};
|
||||||
|
for (const [key, value] of Object.entries(record)) {
|
||||||
|
if (typeof value !== "string") errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}.${key} must be a string`);
|
||||||
|
else out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asOptionalRecord(value) {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOrNull(value) {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanOrNull(value) {
|
||||||
|
return typeof value === "boolean" ? value : null;
|
||||||
|
}
|
||||||
@@ -56,14 +56,14 @@ function gateJobManifest(registry: BranchFollowerRegistry, follower: FollowerSpe
|
|||||||
const gitopsBranch = agentrun?.gitops.branch ?? "";
|
const gitopsBranch = agentrun?.gitops.branch ?? "";
|
||||||
const healthUrl = agentrun?.runtime.internalBaseUrl ?? "";
|
const healthUrl = agentrun?.runtime.internalBaseUrl ?? "";
|
||||||
const workloads = (follower.nativeStatus.runtime?.workloads ?? []).map((item) => ({ kind: item.kind, name: item.name, sourceCommit: item.sourceCommit }));
|
const workloads = (follower.nativeStatus.runtime?.workloads ?? []).map((item) => ({ kind: item.kind, name: item.name, sourceCommit: item.sourceCommit }));
|
||||||
|
const gatePolicy = gatePolicyEnv(follower);
|
||||||
const gateScript = [
|
const gateScript = [
|
||||||
"set -eu",
|
"set -eu",
|
||||||
"tmpdir=$(mktemp -d)",
|
"tmpdir=$(mktemp -d)",
|
||||||
"cleanup() { rm -rf \"$tmpdir\"; }",
|
"cleanup() { rm -rf \"$tmpdir\"; }",
|
||||||
"trap cleanup EXIT INT TERM",
|
"trap cleanup EXIT INT TERM",
|
||||||
nativeCicdScriptLoadShell(["branch-follower-gate.mjs"]),
|
nativeCicdScriptLoadShell(["branch-follower-gate.sh", "branch-follower-gate.mjs", "reuse-config-summary.mjs"]),
|
||||||
"/etc/unidesk-cicd-branch-follower/sync-source.sh \"$REPOSITORY\" \"$SOURCE_BRANCH\" \"$SNAPSHOT_PREFIX\" \"$REPO_PATH\" >/tmp/bf-gate-source-sync.json 2>/tmp/bf-gate-source-sync.err || true",
|
"\"$tmpdir/branch-follower-gate.sh\"",
|
||||||
"node \"$tmpdir/branch-follower-gate.mjs\"",
|
|
||||||
].join("\n");
|
].join("\n");
|
||||||
return {
|
return {
|
||||||
apiVersion: "batch/v1",
|
apiVersion: "batch/v1",
|
||||||
@@ -104,6 +104,8 @@ function gateJobManifest(registry: BranchFollowerRegistry, follower: FollowerSpe
|
|||||||
{ name: "RUNTIME_NAMESPACE", value: follower.nativeStatus.runtime?.namespace ?? "" },
|
{ name: "RUNTIME_NAMESPACE", value: follower.nativeStatus.runtime?.namespace ?? "" },
|
||||||
{ name: "WORKLOADS_B64", value: Buffer.from(JSON.stringify(workloads), "utf8").toString("base64") },
|
{ name: "WORKLOADS_B64", value: Buffer.from(JSON.stringify(workloads), "utf8").toString("base64") },
|
||||||
{ name: "HEALTH_URL", value: healthUrl },
|
{ name: "HEALTH_URL", value: healthUrl },
|
||||||
|
{ name: "SLOW_TASK_SECONDS", value: String(gatePolicy.slowTaskSeconds) },
|
||||||
|
{ name: "HEALTH_TIMEOUT_MS", value: String(gatePolicy.healthTimeoutMs) },
|
||||||
{ name: "UNIDESK_CONTROLLER_GITHUB_SSH_PRIVATE_KEY", value: `/git-ssh/${registry.controller.source.githubSsh.privateKeySecretKey}` },
|
{ name: "UNIDESK_CONTROLLER_GITHUB_SSH_PRIVATE_KEY", value: `/git-ssh/${registry.controller.source.githubSsh.privateKeySecretKey}` },
|
||||||
{ name: "UNIDESK_CONTROLLER_GITHUB_PROXY_HOST", value: registry.controller.source.githubSsh.proxyHost },
|
{ name: "UNIDESK_CONTROLLER_GITHUB_PROXY_HOST", value: registry.controller.source.githubSsh.proxyHost },
|
||||||
{ name: "UNIDESK_CONTROLLER_GITHUB_PROXY_PORT", value: String(registry.controller.source.githubSsh.proxyPort) },
|
{ name: "UNIDESK_CONTROLLER_GITHUB_PROXY_PORT", value: String(registry.controller.source.githubSsh.proxyPort) },
|
||||||
@@ -120,6 +122,16 @@ function gateJobManifest(registry: BranchFollowerRegistry, follower: FollowerSpe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gatePolicyEnv(follower: FollowerSpec): { slowTaskSeconds: number; healthTimeoutMs: number } {
|
||||||
|
if (follower.drillDown === null) {
|
||||||
|
throw new Error(`follower ${follower.id} registry is missing drillDown policy`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
slowTaskSeconds: follower.drillDown.taskRunTimeoutSeconds,
|
||||||
|
healthTimeoutMs: follower.drillDown.taskRunTimeoutSeconds * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseFirstJsonObject(text: string): Record<string, unknown> | null {
|
function parseFirstJsonObject(text: string): Record<string, unknown> | null {
|
||||||
const start = text.indexOf("{");
|
const start = text.indexOf("{");
|
||||||
if (start < 0) return null;
|
if (start < 0) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user