From 5c035a85ee8110b64e9539741a09bf8a33e661fc Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 4 Jul 2026 04:49:41 +0000 Subject: [PATCH] fix: tighten branch follower gate runtime inputs --- scripts/native/cicd/branch-follower-gate.mjs | 56 +++----- scripts/native/cicd/branch-follower-gate.sh | 9 ++ scripts/native/cicd/reuse-config-summary.mjs | 134 +++++++++++++++++++ scripts/src/cicd-gates.ts | 18 ++- 4 files changed, 179 insertions(+), 38 deletions(-) create mode 100644 scripts/native/cicd/branch-follower-gate.sh create mode 100644 scripts/native/cicd/reuse-config-summary.mjs diff --git a/scripts/native/cicd/branch-follower-gate.mjs b/scripts/native/cicd/branch-follower-gate.mjs index 6604ab9f..2c76c240 100644 --- a/scripts/native/cicd/branch-follower-gate.mjs +++ b/scripts/native/cicd/branch-follower-gate.mjs @@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import http from "node:http"; import https from "node:https"; +import { parseRuntimeReuseConfig, summarizeRuntimeReuseConfig } from "./reuse-config-summary.mjs"; const gate = requiredEnv("GATE"); const follower = requiredEnv("FOLLOWER_ID"); @@ -19,6 +20,8 @@ const argoApplication = process.env.ARGO_APPLICATION || ""; const runtimeNamespace = process.env.RUNTIME_NAMESPACE || ""; const workloads = parseWorkloads(process.env.WORKLOADS_B64 || ""); const healthUrl = process.env.HEALTH_URL || ""; +const slowTaskSeconds = requiredPositiveIntEnv("SLOW_TASK_SECONDS"); +const healthTimeoutMs = requiredPositiveIntEnv("HEALTH_TIMEOUT_MS"); const errors = []; const branchCommit = rev(`refs/heads/${sourceBranch}`); @@ -49,6 +52,10 @@ console.log(JSON.stringify({ follower, source, evidence, + policy: { + slowTaskSeconds, + healthTimeoutMs, + }, errors: errors.slice(0, 6), statusAuthority: "kubernetes-api-serviceaccount", parsedDownstreamCliOutput: false, @@ -110,47 +117,18 @@ function readReuseConfig(commit) { if (!commit || !sourceStageRef) return { present: false, reason: "source-commit-missing" }; try { 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 { - present: true, - path: "gitops/reuse.ymal", + ...summary, bytes: Buffer.byteLength(text, "utf8"), - 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), + sha256: summary.sha256 ?? createHash("sha256").update(text).digest("hex"), }; } catch (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) { const localSource = rev(`refs/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 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 { count: rows.length, + slowThresholdSeconds: slowTaskSeconds, failedCount: failed.length, activeCount: active.length, slowCount: slow.length, @@ -321,7 +300,7 @@ async function httpProbe(url) { const client = url.startsWith("https:") ? https : http; const started = Date.now(); return await new Promise((resolve) => { - const req = client.get(url, { timeout: 5000 }, (res) => { + const req = client.get(url, { timeout: healthTimeoutMs }, (res) => { res.resume(); 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; } +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) { console.error(message); process.exit(1); diff --git a/scripts/native/cicd/branch-follower-gate.sh b/scripts/native/cicd/branch-follower-gate.sh new file mode 100644 index 00000000..c9b2c4df --- /dev/null +++ b/scripts/native/cicd/branch-follower-gate.sh @@ -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 diff --git a/scripts/native/cicd/reuse-config-summary.mjs b/scripts/native/cicd/reuse-config-summary.mjs new file mode 100644 index 00000000..59fb9397 --- /dev/null +++ b/scripts/native/cicd/reuse-config-summary.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; +} diff --git a/scripts/src/cicd-gates.ts b/scripts/src/cicd-gates.ts index 7cecbc19..7cb4accb 100644 --- a/scripts/src/cicd-gates.ts +++ b/scripts/src/cicd-gates.ts @@ -56,14 +56,14 @@ function gateJobManifest(registry: BranchFollowerRegistry, follower: FollowerSpe const gitopsBranch = agentrun?.gitops.branch ?? ""; const healthUrl = agentrun?.runtime.internalBaseUrl ?? ""; const workloads = (follower.nativeStatus.runtime?.workloads ?? []).map((item) => ({ kind: item.kind, name: item.name, sourceCommit: item.sourceCommit })); + const gatePolicy = gatePolicyEnv(follower); const gateScript = [ "set -eu", "tmpdir=$(mktemp -d)", "cleanup() { rm -rf \"$tmpdir\"; }", "trap cleanup EXIT INT TERM", - nativeCicdScriptLoadShell(["branch-follower-gate.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", - "node \"$tmpdir/branch-follower-gate.mjs\"", + nativeCicdScriptLoadShell(["branch-follower-gate.sh", "branch-follower-gate.mjs", "reuse-config-summary.mjs"]), + "\"$tmpdir/branch-follower-gate.sh\"", ].join("\n"); return { apiVersion: "batch/v1", @@ -104,6 +104,8 @@ function gateJobManifest(registry: BranchFollowerRegistry, follower: FollowerSpe { name: "RUNTIME_NAMESPACE", value: follower.nativeStatus.runtime?.namespace ?? "" }, { name: "WORKLOADS_B64", value: Buffer.from(JSON.stringify(workloads), "utf8").toString("base64") }, { 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_PROXY_HOST", value: registry.controller.source.githubSsh.proxyHost }, { 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 | null { const start = text.indexOf("{"); if (start < 0) return null;