// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. git-mirror module for scripts/src/agentrun.ts. // Moved mechanically from scripts/src/agentrun.ts:5384-5846 for #903. // SPEC: PJ2026-01020108 cancel lifecycle + PJ2026-01020205 AipodSpec binding + PJ2026-01020302 session policy + PJ2026-01020305 cancel control + PJ2026-01060305/06 YAML execution policy and bounded output draft-2026-06-25-p0. // Exposes AgentRun lane-scoped policy, AipodSpec SecretRef binding, cancel lifecycle, and bounded default output in the UniDesk CLI. import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { rootPath, type UniDeskConfig } from "../config"; import type { RenderedCliResult } from "../output"; import { applyLocalCaddyManagedSite } from "../pk01-caddy"; import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; import { runRemoteSshCommandCapture } from "../remote"; import { startJob } from "../jobs"; import { AGENTRUN_CONFIG_PATH, agentRunLaneSummary, agentRunPipelineRunName, agentRunProviderCredentialRefs, resolveAgentRunLaneTarget, type AgentRunCancelLifecycleSpec, type AgentRunLaneSpec, } from "../agentrun-lanes"; import { agentRunImageArtifact, placeholderAgentRunImage, renderedFilesDigest, renderedObjectsDigest, renderAgentRunControlPlaneManifests, renderAgentRunGitopsFiles, type AgentRunArtifactService, } from "../agentrun-manifests"; import { sha256Fingerprint } from "../platform-infra-ops-library"; import type { GitMirrorStatusOptions } from "./options"; import { readGitMirrorStatus } from "./rest-bridge"; import { compactCapture, shQuote } from "./utils"; export function cleanupRunnersFinalizeNodeScript(): string { return String.raw` const fs = require("node:fs"); const path = require("node:path"); const tmp = process.env.TMP_DIR; const deleteExit = Number(process.env.DELETE_EXIT || 0); const plan = JSON.parse(fs.readFileSync(path.join(tmp, "plan.json"), "utf8")); const jobsAfter = JSON.parse(fs.readFileSync(path.join(tmp, "jobs-after.json"), "utf8")); const podsAfter = JSON.parse(fs.readFileSync(path.join(tmp, "pods-after.json"), "utf8")); const matchLabels = plan.criteria?.selectors?.matchLabels || {}; const jobNamePrefixes = plan.criteria?.selectors?.jobNamePrefixes || []; function labelsOf(item) { return item?.metadata?.labels && typeof item.metadata.labels === "object" ? item.metadata.labels : {}; } function matchesLabels(labels) { return Object.entries(matchLabels).every(([key, value]) => labels?.[key] === value); } function matchesPrefix(name) { return jobNamePrefixes.length === 0 || jobNamePrefixes.some((prefix) => name === prefix || name.startsWith(prefix + "-")); } function isTerminalPod(pod) { const phase = pod?.status?.phase || ""; return phase === "Succeeded" || phase === "Failed"; } function jobNameForPod(pod) { const labels = labelsOf(pod); if (typeof labels["job-name"] === "string") return labels["job-name"]; const owner = (Array.isArray(pod?.metadata?.ownerReferences) ? pod.metadata.ownerReferences : []).find((entry) => entry?.kind === "Job" && typeof entry?.name === "string"); return owner?.name || null; } function tail(name) { try { const text = fs.readFileSync(path.join(tmp, name), "utf8"); return text.length > 3000 ? text.slice(-3000) : text; } catch { return ""; } } const selected = new Set(Array.isArray(plan.selectedRunnerJobs) ? plan.selectedRunnerJobs : []); const remainingJobs = (Array.isArray(jobsAfter.items) ? jobsAfter.items : []) .map((job) => ({ name: job?.metadata?.name || "", labels: labelsOf(job) })) .filter((item) => item.name && matchesLabels(item.labels) && matchesPrefix(item.name)); const remainingJobNames = new Set(remainingJobs.map((item) => item.name)); const matchedPods = (Array.isArray(podsAfter.items) ? podsAfter.items : []).filter((pod) => { const jobName = jobNameForPod(pod); return jobName !== null && remainingJobNames.has(jobName); }); const remainingSelectedRunnerJobs = Array.from(selected).filter((name) => remainingJobNames.has(name)); const deletedRunnerJobs = Array.from(selected).filter((name) => !remainingJobNames.has(name)); console.log(JSON.stringify({ ...plan, ok: deleteExit === 0, deletion: { exitCode: deleteExit, stdoutTail: tail("delete.out"), stderrTail: tail("delete.err") }, deletedRunnerJobs, deletedRunnerJobCount: deletedRunnerJobs.length, remainingSelectedRunnerJobs, remainingSelectedRunnerJobCount: remainingSelectedRunnerJobs.length, after: { runnerJobCount: remainingJobs.length, nonTerminalRunnerPodCount: matchedPods.filter((pod) => !isTerminalPod(pod)).length, overLimitCount: Math.max(0, remainingJobs.length - Number(plan.criteria?.maxRunners || 0)), }, valuesPrinted: false, })); `; } export function cleanupRunsPlanNodeScript(): string { return String.raw` const fs = require("node:fs"); const path = require("node:path"); const cp = require("node:child_process"); const tmp = process.env.TMP_DIR; const namespace = process.env.NAMESPACE; const pipelineRunPrefix = process.env.PIPELINE_RUN_PREFIX || ""; const minAgeMinutes = Number(process.env.MIN_AGE_MINUTES || 60); const limit = Number(process.env.LIMIT || 20); const now = Date.now(); function readJson(name) { return JSON.parse(fs.readFileSync(path.join(tmp, name), "utf8")); } function conditionOf(item) { const conditions = Array.isArray(item?.status?.conditions) ? item.status.conditions : []; return conditions.find((entry) => entry.type === "Succeeded") || conditions[0] || {}; } function ageMinutes(createdAt) { const createdMs = Date.parse(createdAt || ""); return Number.isFinite(createdMs) ? Math.floor((now - createdMs) / 60000) : null; } function localPathOf(pv) { return pv?.spec?.local?.path || pv?.spec?.hostPath?.path || null; } function duBytes(hostPath) { if (typeof hostPath !== "string" || !hostPath.startsWith("/var/lib/rancher/k3s/storage/")) return null; try { const out = cp.execFileSync("du", ["-sB1", hostPath], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); const bytes = Number(String(out).trim().split(/\s+/)[0]); return Number.isFinite(bytes) ? bytes : null; } catch { return null; } } function formatBytes(bytes) { if (!Number.isFinite(bytes)) return null; const units = ["B", "KiB", "MiB", "GiB", "TiB"]; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return value.toFixed(unit === 0 ? 0 : 1) + units[unit]; } const pipelineRuns = readJson("pipelineruns.json"); const pvcs = readJson("pvcs.json"); const pvs = readJson("pvs.json"); const pods = readJson("pods.json"); const pvByName = new Map((Array.isArray(pvs.items) ? pvs.items : []).map((item) => [item?.metadata?.name, item])); const activeClaimPods = new Map(); for (const pod of Array.isArray(pods.items) ? pods.items : []) { const phase = pod?.status?.phase || ""; if (phase === "Succeeded" || phase === "Failed") continue; for (const volume of Array.isArray(pod?.spec?.volumes) ? pod.spec.volumes : []) { const claimName = volume?.persistentVolumeClaim?.claimName; if (!claimName) continue; const entry = activeClaimPods.get(claimName) || []; entry.push(pod?.metadata?.name || null); activeClaimPods.set(claimName, entry.filter(Boolean)); } } const pvcsByOwner = new Map(); for (const pvc of Array.isArray(pvcs.items) ? pvcs.items : []) { const owner = (Array.isArray(pvc?.metadata?.ownerReferences) ? pvc.metadata.ownerReferences : []).find((entry) => entry.kind === "PipelineRun"); if (!owner?.name) continue; const entry = pvcsByOwner.get(owner.name) || []; entry.push(pvc); pvcsByOwner.set(owner.name, entry); } const allPipelineRuns = (Array.isArray(pipelineRuns.items) ? pipelineRuns.items : []) .map((item) => { const condition = conditionOf(item); return { name: item?.metadata?.name || "", createdAt: item?.metadata?.creationTimestamp || null, ageMinutes: ageMinutes(item?.metadata?.creationTimestamp), status: condition.status || null, reason: condition.reason || null, }; }) .filter((item) => pipelineRunPrefix.length === 0 || item.name.startsWith(pipelineRunPrefix + "-")); const protectedActivePipelineRuns = allPipelineRuns .filter((item) => item.status !== "True" && item.status !== "False") .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))); const protectedLatestPipelineRun = allPipelineRuns .filter((item) => item.status === "True" || item.status === "False") .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]?.name || null; const candidates = allPipelineRuns .filter((item) => item.status === "True" || item.status === "False") .filter((item) => typeof item.ageMinutes === "number" && item.ageMinutes >= minAgeMinutes) .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))) .slice(0, limit) .map((item) => { const owned = pvcsByOwner.get(item.name) || []; const activeMountPods = owned.flatMap((pvc) => activeClaimPods.get(pvc?.metadata?.name) || []); const protectedLatest = item.name === protectedLatestPipelineRun; return { ...item, selected: activeMountPods.length === 0 && !protectedLatest, selectedReason: protectedLatest ? "protected-latest-pipelinerun" : activeMountPods.length === 0 ? "terminal-and-unmounted" : "owned-pvc-active-mounted", ownedPvcCount: owned.length, activeMountPods, }; }); const selectedPipelineRuns = candidates.filter((item) => item.selected).map((item) => item.name); const selectedSet = new Set(selectedPipelineRuns); const ownedPvcs = []; const protectedOwnedPvcs = []; for (const [owner, items] of pvcsByOwner.entries()) { if (!selectedSet.has(owner) && !candidates.some((item) => item.name === owner)) continue; for (const pvc of items) { const volume = pvc?.spec?.volumeName || null; const pv = volume ? pvByName.get(volume) : null; const hostPath = localPathOf(pv); const activeMountPods = activeClaimPods.get(pvc?.metadata?.name) || []; const entry = { name: pvc?.metadata?.name || null, volume, phase: pvc?.status?.phase || null, ownerKind: "PipelineRun", owner, storageClass: pv?.spec?.storageClassName || null, reclaimPolicy: pv?.spec?.persistentVolumeReclaimPolicy || null, hostPath, estimatedBytes: duBytes(hostPath), activeMountPods, }; if (selectedSet.has(owner)) ownedPvcs.push(entry); else protectedOwnedPvcs.push(entry); } } const estimatedReclaimBytes = ownedPvcs.reduce((sum, item) => sum + (Number.isFinite(item.estimatedBytes) ? item.estimatedBytes : 0), 0); console.log(JSON.stringify({ ok: true, planKind: "agentrun-ci-completed-pipelinerun-workspace-retention", generatedAt: new Date().toISOString(), namespace, criteria: { prefix: pipelineRunPrefix + "-", terminalStatuses: ["True", "False"], minAgeMinutes, limit }, candidates, candidateCount: candidates.length, protectedActivePipelineRuns, protectedActivePipelineRunCount: protectedActivePipelineRuns.length, protectedLatestPipelineRun, selectedPipelineRuns, selectedPipelineRunCount: selectedPipelineRuns.length, ownedPvcs, ownedPvcCount: ownedPvcs.length, protectedOwnedPvcs, protectedOwnedPvcCount: protectedOwnedPvcs.length, estimatedReclaimBytes, estimatedReclaimHuman: formatBytes(estimatedReclaimBytes), })); `; } export function cleanupRunsFinalizeNodeScript(): string { return String.raw` const fs = require("node:fs"); const path = require("node:path"); const tmp = process.env.TMP_DIR; const deleteExit = Number(process.env.DELETE_EXIT || 0); const plan = JSON.parse(fs.readFileSync(path.join(tmp, "plan.json"), "utf8")); const pvcsAfter = JSON.parse(fs.readFileSync(path.join(tmp, "pvcs-after.json"), "utf8")); const selected = new Set(Array.isArray(plan.selectedPipelineRuns) ? plan.selectedPipelineRuns : []); const remainingOwnedPvcs = (Array.isArray(pvcsAfter.items) ? pvcsAfter.items : []) .map((pvc) => { const owner = (Array.isArray(pvc?.metadata?.ownerReferences) ? pvc.metadata.ownerReferences : []).find((entry) => entry.kind === "PipelineRun"); return { name: pvc?.metadata?.name || null, volume: pvc?.spec?.volumeName || null, phase: pvc?.status?.phase || null, ownerKind: owner?.kind || null, owner: owner?.name || null, }; }) .filter((item) => item.owner && selected.has(item.owner)); function tail(name) { try { const text = fs.readFileSync(path.join(tmp, name), "utf8"); return text.length > 3000 ? text.slice(-3000) : text; } catch { return ""; } } console.log(JSON.stringify({ ...plan, ok: deleteExit === 0, deletedPipelineRuns: Array.from(selected), deletedPipelineRunCount: selected.size, deletion: { exitCode: deleteExit, stdoutTail: tail("delete.out"), stderrTail: tail("delete.err") }, remainingOwnedPvcs, remainingOwnedPvcCount: remainingOwnedPvcs.length, })); `; } export function cleanupReleasedPvsPlanNodeScript(): string { return String.raw` const fs = require("node:fs"); const path = require("node:path"); const cp = require("node:child_process"); const tmp = process.env.TMP_DIR; const namespace = process.env.NAMESPACE; const limit = Number(process.env.LIMIT || 20); function readJson(name) { return JSON.parse(fs.readFileSync(path.join(tmp, name), "utf8")); } function localPathOf(pv) { return pv?.spec?.local?.path || pv?.spec?.hostPath?.path || null; } function duBytes(hostPath) { if (typeof hostPath !== "string" || !hostPath.startsWith("/var/lib/rancher/k3s/storage/")) return null; try { const out = cp.execFileSync("du", ["-sB1", hostPath], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); const bytes = Number(String(out).trim().split(/\s+/)[0]); return Number.isFinite(bytes) ? bytes : null; } catch { return null; } } function formatBytes(bytes) { if (!Number.isFinite(bytes)) return null; const units = ["B", "KiB", "MiB", "GiB", "TiB"]; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return value.toFixed(unit === 0 ? 0 : 1) + units[unit]; } const pvs = readJson("pvs.json"); const candidates = (Array.isArray(pvs.items) ? pvs.items : []) .map((pv) => { const hostPath = localPathOf(pv); return { name: pv?.metadata?.name || "", createdAt: pv?.metadata?.creationTimestamp || null, phase: pv?.status?.phase || null, storageClass: pv?.spec?.storageClassName || null, reclaimPolicy: pv?.spec?.persistentVolumeReclaimPolicy || null, claimNamespace: pv?.spec?.claimRef?.namespace || null, claimName: pv?.spec?.claimRef?.name || null, capacity: pv?.spec?.capacity?.storage || null, hostPath, estimatedBytes: duBytes(hostPath), }; }) .filter((item) => item.phase === "Released") .filter((item) => item.storageClass === "local-path" && item.reclaimPolicy === "Delete") .filter((item) => item.claimNamespace === namespace) .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))) .slice(0, limit); const selectedPersistentVolumes = candidates.map((item) => item.name); const estimatedReclaimBytes = candidates.reduce((sum, item) => sum + (Number.isFinite(item.estimatedBytes) ? item.estimatedBytes : 0), 0); console.log(JSON.stringify({ ok: true, planKind: "agentrun-ci-released-local-path-pv-retention", generatedAt: new Date().toISOString(), namespace, criteria: { phase: "Released", storageClass: "local-path", reclaimPolicy: "Delete", claimNamespace: namespace, limit }, candidates, candidateCount: candidates.length, selectedPersistentVolumes, selectedPersistentVolumeCount: selectedPersistentVolumes.length, estimatedReclaimBytes, estimatedReclaimHuman: formatBytes(estimatedReclaimBytes), })); `; } export function cleanupReleasedPvsFinalizeNodeScript(): string { return String.raw` const fs = require("node:fs"); const path = require("node:path"); const tmp = process.env.TMP_DIR; const deleteExit = Number(process.env.DELETE_EXIT || 0); const plan = JSON.parse(fs.readFileSync(path.join(tmp, "plan.json"), "utf8")); const pvsAfter = JSON.parse(fs.readFileSync(path.join(tmp, "pvs-after.json"), "utf8")); const selected = new Set(Array.isArray(plan.selectedPersistentVolumes) ? plan.selectedPersistentVolumes : []); const remainingPersistentVolumes = (Array.isArray(pvsAfter.items) ? pvsAfter.items : []) .filter((pv) => selected.has(pv?.metadata?.name)) .map((pv) => ({ name: pv?.metadata?.name || null, phase: pv?.status?.phase || null, claimNamespace: pv?.spec?.claimRef?.namespace || null, claimName: pv?.spec?.claimRef?.name || null })); function tail(name) { try { const text = fs.readFileSync(path.join(tmp, name), "utf8"); return text.length > 3000 ? text.slice(-3000) : text; } catch { return ""; } } console.log(JSON.stringify({ ...plan, ok: deleteExit === 0, deletedPersistentVolumes: Array.from(selected), deletedPersistentVolumeCount: selected.size, deletion: { exitCode: deleteExit, stdoutTail: tail("delete.out"), stderrTail: tail("delete.err") }, remainingPersistentVolumes, remainingPersistentVolumeCount: remainingPersistentVolumes.length, })); `; } export function refreshYamlLaneScript(spec: AgentRunLaneSpec): string { return [ "set -eu", `namespace=${shQuote(spec.gitops.argoNamespace)}`, `application=${shQuote(spec.gitops.argoApplication)}`, "kubectl -n \"$namespace\" annotate application \"$application\" argocd.argoproj.io/refresh=hard --overwrite >/tmp/agentrun-refresh-output.txt", "kubectl -n \"$namespace\" get application \"$application\" -o json >/tmp/agentrun-refresh-app.json", "NAMESPACE=\"$namespace\" APPLICATION=\"$application\" python3 - <<'PY'", "import datetime, json, os", "try:", " with open('/tmp/agentrun-refresh-app.json', 'r', encoding='utf-8') as fh:", " app = json.load(fh)", "except Exception:", " app = {}", "status = app.get('status') or {}", "sync = status.get('sync') or {}", "health = status.get('health') or {}", "print(json.dumps({", " 'ok': True,", " 'namespace': os.environ.get('NAMESPACE'),", " 'application': os.environ.get('APPLICATION'),", " 'revision': sync.get('revision'),", " 'syncStatus': sync.get('status'),", " 'healthStatus': health.get('status'),", " 'observedAt': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z'),", " 'valuesPrinted': False,", "}, ensure_ascii=False))", "PY", ].join("\n"); } export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorStatusOptions = { full: false, raw: false, node: null, lane: null }): Promise> { const target = resolveAgentRunLaneTarget(options); const spec = target.spec; const observation = await readGitMirrorStatus(config, target); const summary = observation.summary; return { ok: observation.ok, command: "agentrun git-mirror status", mode: "yaml-declared-node-lane", configPath: target.configPath, target: agentRunLaneSummary(spec), namespace: spec.gitMirror.namespace, readUrl: spec.gitMirror.readUrl, writeUrl: spec.gitMirror.writeUrl, summary, ...(options.raw ? { raw: observation.raw } : {}), probe: compactCapture(observation.result, { full: options.full || options.raw, stdoutTailChars: 6000, stderrTailChars: 3000 }), disclosure: { defaultView: "compact-low-noise", full: options.full, raw: options.raw, rawOmitted: !options.raw, probeTailOmitted: !(options.full || options.raw), expandWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --full`, rawWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --raw`, }, next: { sync: `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`, flush: summary.pendingFlush === true ? `bun scripts/cli.ts agentrun git-mirror flush --node ${spec.nodeId} --lane ${spec.lane} --confirm` : null, }, }; }