diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 82949502..673bcb80 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -40,6 +40,7 @@ bun scripts/cli.ts cicd branch-follower status - 任一 CI/CD 阶段或总耗时超过 2 分钟时,不要继续死等或把超长等待视为正常;先输出阶段耗时分解,并优先从 env reuse、git mirror、BuildKit/cache、GitOps/Argo watch 和 runtime readiness 探测方向优化后再继续交付。 - node-scoped `trigger-current --wait` 必须把 source sync、pre/post flush、PipelineRun、GitOps/Argo、runtime readiness 和 `/health` closeout 放进同一 120s 端到端预算;超预算时由 CLI 输出阶段分解、Argo target revision、runtime/public 状态和 TaskRun/Pod drill-down,不继续死等,也不要求操作者手动串联多个状态/flush 命令才能完成一次交付。 - 触发或验收 rollout 时必须绑定 lane、source commit、PipelineRun/GitOps revision、runtime ready 和 `/health` 端点验证结果;web-probe/Playwright 结果只能作为单独的 post-deploy 证据。 +- CI/CD 状态、日志和事件查询必须减少 trans/SSH 传输:能在目标 NODE/k8s 内解析、聚合、裁剪的内容,必须在目标侧计算成短 JSON/table 摘要后再回传;禁止为了本地解析而把完整 ConfigMap、大对象、长日志或原始 API payload 透传回来。 - Secret 只通过 YAML sourceRef/targetKey 和受控 CLI 下发;输出只披露 presence/fingerprint。 - 长命令用异步 job 或短轮询;不要长时间挂住 trans/ssh。 diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md index 00d0c6b4..dac942ff 100644 --- a/.agents/skills/unidesk-cicd/references/branch-follower.md +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -89,4 +89,6 @@ The controller automatic loop submits trigger work without a blocking wait; late State ConfigMaps must stay bounded and human-queryable. Store compact summaries, stage refs, conditions, short messages, and drill-down object names; do not store full API payloads or long log dumps. Cleanup is an explicit operator operation for stale/broken state and must not be required for normal convergence. +Status readers must compute near the data. When the operator CLI reaches a target node or k8s route through `trans`, the target NODE/k8s side must parse ConfigMap values, Kubernetes objects and log/event lists locally, then return only the bounded follower summary, timing rows, object names, counts and short tails needed by the CLI. Do not transmit complete ConfigMap entries, full API objects or long logs back to the host just so host-side TypeScript can parse and trim them. + `run-once --dry-run` is read-only for deployment: it may refresh the state ConfigMap with current native observations, but it must not trigger adapters. diff --git a/scripts/native/cicd/read-state-summary.mjs b/scripts/native/cicd/read-state-summary.mjs new file mode 100644 index 00000000..555469b1 --- /dev/null +++ b/scripts/native/cicd/read-state-summary.mjs @@ -0,0 +1,168 @@ +import { execFileSync } from "node:child_process"; + +const namespace = process.env.NAMESPACE || ""; +const configMap = process.env.CONFIGMAP || ""; +const followerIds = parseFollowerIds(process.env.FOLLOWERS_JSON || "[]"); +const maxTimingStages = Number(process.env.MAX_TIMING_STAGES || "24"); + +function parseFollowerIds(text) { + try { + const parsed = JSON.parse(text); + return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.length > 0) : []; + } catch { + return []; + } +} + +function kubectlConfigMap() { + try { + const stdout = execFileSync("kubectl", ["-n", namespace, "get", "configmap", configMap, "-o", "json"], { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + return { ok: true, present: true, object: JSON.parse(stdout), error: "" }; + } catch (error) { + const stderr = String(error?.stderr || error?.message || ""); + if (/not found/i.test(stderr)) return { ok: true, present: false, object: null, error: stderr }; + return { ok: false, present: false, object: null, error: stderr || "kubectl configmap read failed" }; + } +} + +function compactStateText(text) { + if (typeof text !== "string" || text.length === 0) return null; + let state; + try { + state = JSON.parse(text); + } catch { + return null; + } + return { + id: stringOrNull(state.id), + adapter: stringOrNull(state.adapter), + enabled: state.enabled === true, + phase: stringOrNull(state.phase), + source: compactSource(state.source), + target: compactTarget(state.target), + lastTriggeredSha: stringOrNull(state.lastTriggeredSha), + lastSucceededSha: stringOrNull(state.lastSucceededSha), + pipelineRun: stringOrNull(state.pipelineRun), + inFlightJob: stringOrNull(state.inFlightJob), + controller: compactController(state.controller), + decision: stringOrNull(state.decision), + dryRun: state.dryRun === true, + updatedAt: stringOrNull(state.updatedAt), + timings: compactTimings(state.timings), + warnings: arrayStrings(state.warnings).slice(0, 6), + stateFormat: stringOrNull(state.stateFormat), + }; +} + +function compactSource(source) { + const value = recordOrNull(source); + if (value === null) return null; + return { + repository: stringOrNull(value.repository), + branch: stringOrNull(value.branch), + branchRef: stringOrNull(value.branchRef), + snapshotPrefix: stringOrNull(value.snapshotPrefix), + observedSha: stringOrNull(value.observedSha), + }; +} + +function compactTarget(target) { + const value = recordOrNull(target); + if (value === null) return null; + return { + node: stringOrNull(value.node), + lane: stringOrNull(value.lane), + namespace: stringOrNull(value.namespace), + sentinel: stringOrNull(value.sentinel), + targetSha: stringOrNull(value.targetSha), + }; +} + +function compactController(controller) { + const value = recordOrNull(controller); + if (value === null) return null; + return { + mode: stringOrNull(value.mode), + stateConfigMap: stringOrNull(value.stateConfigMap), + leaseName: stringOrNull(value.leaseName), + }; +} + +function compactTimings(timings) { + const value = recordOrNull(timings); + if (value === null) return null; + return { + budgetSeconds: numberOrNull(value.budgetSeconds), + totalSeconds: numberOrNull(value.totalSeconds), + totalStatus: stringOrNull(value.totalStatus), + totalSource: stringOrNull(value.totalSource), + startedAt: stringOrNull(value.startedAt), + finishedAt: stringOrNull(value.finishedAt), + overBudget: typeof value.overBudget === "boolean" ? value.overBudget : null, + stages: arrayRecords(value.stages).slice(0, maxTimingStages).map(compactStageTiming), + }; +} + +function compactStageTiming(stage) { + return { + stage: stringOrNull(stage.stage), + status: stringOrNull(stage.status), + seconds: numberOrNull(stage.seconds), + budgetSeconds: numberOrNull(stage.budgetSeconds), + source: stringOrNull(stage.source), + object: stringOrNull(stage.object), + }; +} + +function recordOrNull(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null; +} + +function arrayRecords(value) { + return Array.isArray(value) ? value.filter((item) => recordOrNull(item) !== null) : []; +} + +function arrayStrings(value) { + return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; +} + +function stringOrNull(value) { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberOrNull(value) { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +const result = kubectlConfigMap(); +const errors = []; +const stateByFollower = {}; +const valueBytes = {}; + +if (result.ok && result.present) { + const data = recordOrNull(result.object?.data) || {}; + for (const id of followerIds) { + const text = typeof data[id] === "string" ? data[id] : ""; + if (text.length === 0) continue; + valueBytes[id] = Buffer.byteLength(text, "utf8"); + const compact = compactStateText(text); + if (compact === null) errors.push(`${id}: invalid state json`); + else stateByFollower[id] = compact; + } +} + +if (!result.ok) errors.push(result.error); + +process.stdout.write(JSON.stringify({ + ok: result.ok && errors.length === 0, + present: result.present, + stateByFollower, + valueBytes, + errors, + statusAuthority: "target-node-summary", + parsedDownstreamCliOutput: false, +})); diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts index e54c4fb1..8b869bfc 100644 --- a/scripts/src/cicd.ts +++ b/scripts/src/cicd.ts @@ -3,7 +3,6 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { isAbsolute } from "node:path"; -import { gunzipSync } from "node:zlib"; import { repoRoot, rootPath, type UniDeskConfig } from "./config"; import { runCommand, type CommandResult } from "./command"; import { startJob } from "./jobs"; @@ -1546,7 +1545,11 @@ const NATIVE_BUNDLE_SCRIPT_NAMES = [ ] as const; function nativeBundleScriptLoadShell(): string { - return NATIVE_BUNDLE_SCRIPT_NAMES.map((name) => { + return nativeScriptLoadShell(NATIVE_BUNDLE_SCRIPT_NAMES); +} + +function nativeScriptLoadShell(names: readonly string[]): string { + return names.map((name) => { const encoded = Buffer.from(readFileSync(rootPath("scripts/native/cicd", name), "utf8"), "utf8").toString("base64"); return [ `printf '%s' ${shQuote(encoded)} | base64 -d > "$tmpdir/${name}"`, @@ -1850,95 +1853,37 @@ function readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions): } function kubeConfigMapFollowerState(registry: BranchFollowerRegistry, options: ParsedOptions): K8sFollowerStateRead { - const stateByFollower: Record> = {}; - const errors: string[] = []; - let present = false; - for (const follower of registry.followers) { - const result = kubeConfigMapDataValue(registry, options, follower.id); - if (!result.ok) { - errors.push(result.error); - continue; - } - if (!result.present) { - return { ok: true, stateByFollower: {}, present: false, error: "" }; - } - present = true; - if (result.omitted) continue; - if (result.value === null || result.value.length === 0) continue; - const parsed = parseJsonObject(result.value); - if (parsed === null) { - errors.push(`${follower.id}: invalid state json`); - continue; - } - stateByFollower[follower.id] = parsed; - } - return { - ok: errors.length === 0, - stateByFollower, - present, - error: errors.join("; "), - }; -} - -function kubeConfigMapDataValue(registry: BranchFollowerRegistry, options: ParsedOptions, key: string): { ok: boolean; present: boolean; value: string | null; omitted: boolean; error: string } { - const template = `{{ with index .data ${JSON.stringify(key)} }}{{ . }}{{ end }}`; - const maxValueBytes = 32_768; const script = [ "set -eu", "tmpdir=$(mktemp -d)", "cleanup() { rm -rf \"$tmpdir\"; }", "trap cleanup EXIT INT TERM", + nativeScriptLoadShell(["read-state-summary.mjs"]), `NAMESPACE=${shQuote(registry.controller.namespace)}`, `CONFIGMAP=${shQuote(registry.controller.stateConfigMapName)}`, - `MAX_VALUE_BYTES=${maxValueBytes}`, - "export NAMESPACE CONFIGMAP MAX_VALUE_BYTES", - `if ! value=$(kubectl -n "$NAMESPACE" get configmap "$CONFIGMAP" -o go-template=${shQuote(template)} 2>"$tmpdir/error"); then`, - " error_b64=$(tail -c 800 \"$tmpdir/error\" | base64 | tr -d '\\n')", - " if grep -qi 'not found' \"$tmpdir/error\"; then", - " printf '{\"ok\":true,\"present\":false,\"valueB64\":null,\"omitted\":false,\"errorB64\":\"%s\"}' \"$error_b64\"", - " exit 0", - " fi", - " printf '{\"ok\":false,\"present\":false,\"valueB64\":null,\"omitted\":false,\"errorB64\":\"%s\"}' \"$error_b64\"", - " exit 0", - "fi", - "value_bytes=$(printf '%s' \"$value\" | wc -c | tr -d ' ')", - "if [ \"${value_bytes:-0}\" -gt \"$MAX_VALUE_BYTES\" ]; then", - " printf '{\"ok\":true,\"present\":true,\"valueB64\":null,\"omitted\":true,\"valueBytes\":%s,\"errorB64\":\"\"}' \"$value_bytes\"", - " exit 0", - "fi", - "if command -v gzip >/dev/null 2>&1; then", - " value_b64=$(printf '%s' \"$value\" | gzip -c | base64 | tr -d '\\n')", - " printf '{\"ok\":true,\"present\":true,\"valueB64\":\"%s\",\"encoding\":\"gzip-base64\",\"omitted\":false,\"valueBytes\":%s,\"errorB64\":\"\"}' \"$value_b64\" \"$value_bytes\"", - "else", - " value_b64=$(printf '%s' \"$value\" | base64 | tr -d '\\n')", - " printf '{\"ok\":true,\"present\":true,\"valueB64\":\"%s\",\"encoding\":\"base64\",\"omitted\":false,\"valueBytes\":%s,\"errorB64\":\"\"}' \"$value_b64\" \"$value_bytes\"", - "fi", + `FOLLOWERS_JSON=${shQuote(JSON.stringify(registry.followers.map((follower) => follower.id)))}`, + "MAX_TIMING_STAGES=24", + "export NAMESPACE CONFIGMAP FOLLOWERS_JSON MAX_TIMING_STAGES", + "node \"$tmpdir/read-state-summary.mjs\"", ].join("\n"); const result = runKubeScript(registry, options, script, "", 10_000); const parsed = result.exitCode === 0 ? parseJsonObject(result.stdout) : null; if (parsed === null) { - return { - ok: false, - present: false, - value: null, - omitted: false, - error: redactText(tailText(result.stderr || result.stdout, 800)), - }; + const error = redactText(tailText(result.stderr || result.stdout, 800)); + return { ok: false, stateByFollower: {}, present: false, error }; } - const errorB64 = typeof parsed.errorB64 === "string" ? parsed.errorB64 : ""; - const error = errorB64.length === 0 ? "" : Buffer.from(errorB64, "base64").toString("utf8"); - const ok = parsed.ok === true; - const present = parsed.present === true; - const omitted = parsed.omitted === true; - const valueB64 = typeof parsed.valueB64 === "string" ? parsed.valueB64 : null; - const encoding = stringOrNull(parsed.encoding) ?? "base64"; - const valueBuffer = valueB64 === null ? null : Buffer.from(valueB64, "base64"); + const parsedStates = asOptionalRecord(parsed.stateByFollower) ?? {}; + const stateByFollower: Record> = {}; + for (const follower of registry.followers) { + const state = asOptionalRecord(parsedStates[follower.id]); + if (state !== null) stateByFollower[follower.id] = state; + } + const errors = Array.isArray(parsed.errors) ? parsed.errors.map(String).filter((item) => item.length > 0) : []; return { - ok, - present, - value: valueBuffer === null ? null : encoding === "gzip-base64" ? gunzipSync(valueBuffer).toString("utf8") : valueBuffer.toString("utf8"), - omitted, - error: redactText(error), + ok: parsed.ok === true && errors.length === 0, + stateByFollower, + present: parsed.present === true, + error: errors.join("; "), }; }