Files
pikasTech-unidesk/scripts/src/agentrun/git-mirror.ts
T
2026-06-29 08:13:34 +00:00

505 lines
20 KiB
TypeScript

// 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<Record<string, unknown>> {
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,
},
};
}