diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md index 23a4c568..5a2264b3 100644 --- a/.agents/skills/unidesk-cicd/references/branch-follower.md +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -39,7 +39,7 @@ Use configRef summaries in plan/status; do not create a `full.md` or super Markd ## First Followers - `hwlab-jd01-v03`: follows `pikasTech/HWLAB@v0.3`, adapter `hwlab-node-runtime`, trigger `hwlab nodes control-plane trigger-current --node JD01 --lane v03 --confirm --wait`. -- `agentrun-d601-v02`: follows `pikasTech/agentrun@v0.2`, adapter `agentrun-yaml-lane`, trigger `agentrun control-plane trigger-current --node D601 --lane v02 --confirm --wait`. +- `agentrun-jd01-v02`: follows `pikasTech/agentrun@v0.2`, adapter `agentrun-yaml-lane`, trigger `agentrun control-plane trigger-current --node JD01 --lane jd01-v02 --confirm --wait`. - `web-probe-sentinel-master`: follows `pikasTech/unidesk@master`, adapter `web-probe-sentinel-cicd`, trigger `web-probe sentinel publish-current --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel --confirm --wait`. ## Status Contract diff --git a/config/cicd-branch-followers.yaml b/config/cicd-branch-followers.yaml index 725bf3fa..16704839 100644 --- a/config/cicd-branch-followers.yaml +++ b/config/cicd-branch-followers.yaml @@ -9,7 +9,7 @@ metadata: controller: namespace: devops-infra - kubeRoute: D601:k3s + kubeRoute: JD01:k3s fieldManager: unidesk-cicd-branch-follower serviceAccountName: unidesk-cicd-branch-follower deploymentName: unidesk-cicd-branch-follower @@ -89,28 +89,28 @@ followers: closeout: checks: ["sourceSnapshot", "pipelineRun", "gitMirrorPostFlush", "gitops", "argo", "runtime", "publicHealth"] - - id: agentrun-d601-v02 + - id: agentrun-jd01-v02 enabled: true adapter: agentrun-yaml-lane - description: Follow AgentRun v0.2 into the D601 YAML-only runtime lane. + description: Follow AgentRun v0.2 into the JD01 YAML-only runtime lane. source: repository: pikasTech/agentrun branch: v0.2 - branchRef: config/agentrun.yaml#controlPlane.lanes.v02.source.branch - authorityRef: config/agentrun.yaml#controlPlane.lanes.v02.source.sourceAuthority + branchRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.source.branch + authorityRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.source.sourceAuthority snapshotPrefix: refs/unidesk/snapshots/agentrun-yaml-lane/v0.2 - snapshotRef: config/agentrun.yaml#controlPlane.lanes.v02.source.sourceSnapshot.stageRefPrefix + snapshotRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.source.sourceSnapshot.stageRefPrefix target: - node: D601 - lane: v02 + node: JD01 + lane: jd01-v02 namespace: agentrun-v02 configRefs: - lane: config/agentrun.yaml#controlPlane.lanes.v02 - source: config/agentrun.yaml#controlPlane.lanes.v02.source - runtime: config/agentrun.yaml#controlPlane.lanes.v02.runtime - pipeline: config/agentrun.yaml#controlPlane.lanes.v02.ci.pipeline - pipelineRunPrefix: config/agentrun.yaml#controlPlane.lanes.v02.ci.pipelineRunPrefix - argoApplication: config/agentrun.yaml#controlPlane.lanes.v02.gitops.argoApplication + lane: config/agentrun.yaml#controlPlane.lanes.jd01-v02 + source: config/agentrun.yaml#controlPlane.lanes.jd01-v02.source + runtime: config/agentrun.yaml#controlPlane.lanes.jd01-v02.runtime + pipeline: config/agentrun.yaml#controlPlane.lanes.jd01-v02.ci.pipeline + pipelineRunPrefix: config/agentrun.yaml#controlPlane.lanes.jd01-v02.ci.pipelineRunPrefix + argoApplication: config/agentrun.yaml#controlPlane.lanes.jd01-v02.gitops.argoApplication budgets: endToEndSeconds: 120 statusSeconds: 35 @@ -118,19 +118,19 @@ followers: sourceSyncSeconds: 20 commands: plan: - argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "trigger-current", "--node", "D601", "--lane", "v02", "--dry-run", "--raw"] + argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "trigger-current", "--node", "JD01", "--lane", "jd01-v02", "--dry-run", "--raw"] timeoutSeconds: 35 status: - argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "status", "--node", "D601", "--lane", "v02", "--raw"] + argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "status", "--node", "JD01", "--lane", "jd01-v02", "--raw"] timeoutSeconds: 35 trigger: - argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "trigger-current", "--node", "D601", "--lane", "v02", "--confirm", "--wait", "--raw"] + argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "trigger-current", "--node", "JD01", "--lane", "jd01-v02", "--confirm", "--wait", "--raw"] timeoutSeconds: 120 events: - argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "status", "--node", "D601", "--lane", "v02", "--full"] + argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "status", "--node", "JD01", "--lane", "jd01-v02", "--full"] timeoutSeconds: 35 logs: - argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "status", "--node", "D601", "--lane", "v02", "--full"] + argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "status", "--node", "JD01", "--lane", "jd01-v02", "--full"] timeoutSeconds: 35 closeout: checks: ["sourceSnapshot", "pipelineRun", "gitops", "argo", "manager", "runtimeHealth"] diff --git a/project-management/PJ2026-01/specs/PJ2026-01060703-cicd-branch-follower.md b/project-management/PJ2026-01/specs/PJ2026-01060703-cicd-branch-follower.md index aa2004a4..92fbc57f 100644 --- a/project-management/PJ2026-01/specs/PJ2026-01060703-cicd-branch-follower.md +++ b/project-management/PJ2026-01/specs/PJ2026-01060703-cicd-branch-follower.md @@ -9,7 +9,7 @@ SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower 首批 follower: - `hwlab-jd01-v03`: 跟随 `pikasTech/HWLAB@v0.3`,触发 `hwlab-node-runtime` 控制面。 -- `agentrun-d601-v02`: 跟随 `pikasTech/agentrun@v0.2`,触发 `agentrun-yaml-lane` 控制面。 +- `agentrun-jd01-v02`: 跟随 `pikasTech/agentrun@v0.2`,触发 JD01 `agentrun-yaml-lane` 控制面。 - `web-probe-sentinel-master`: 跟随 `pikasTech/unidesk@master`,触发 YAML 选中的 web-probe sentinel CI/CD。 ## 2. 强约束 @@ -80,7 +80,7 @@ bun scripts/cli.ts cicd branch-follower run-once --all --confirm --wait --contro 每个 adapter 复用既有受控 CLI: - `hwlab-node-runtime`: `hwlab nodes control-plane status|trigger-current --node JD01 --lane v03` -- `agentrun-yaml-lane`: `agentrun control-plane status|trigger-current --node D601 --lane v02` +- `agentrun-yaml-lane`: `agentrun control-plane status|trigger-current --node JD01 --lane jd01-v02` - `web-probe-sentinel-cicd`: `web-probe sentinel publish-current|control-plane status --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel` branch follower 不直接操作 Tekton、Argo、kubectl 或 GitHub。它只通过 adapter 命令读取 compact status 或触发已存在的控制面。 diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts index 8c1cb151..3637c747 100644 --- a/scripts/src/cicd.ts +++ b/scripts/src/cicd.ts @@ -1,7 +1,7 @@ // SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower. // Responsibility: YAML-first K8s branch-follower controller, status, and adapter orchestration. import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { isAbsolute } from "node:path"; import { repoRoot, rootPath, type UniDeskConfig } from "./config"; import { runCommand, type CommandResult } from "./command"; @@ -579,11 +579,14 @@ function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Re async function applyController(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { const manifests = renderControllerManifests(registry); const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; + const manifestBase64 = Buffer.from(manifestYaml, "utf8").toString("base64"); const waitSeconds = options.timeoutSeconds ?? registry.controller.budgets.applyWaitSeconds; const script = [ "set -eu", "tmp=$(mktemp)", - "cat >\"$tmp\"", + "base64 -d >\"$tmp\" <<'UNIDESK_CICD_BRANCH_FOLLOWER_MANIFEST_B64'", + manifestBase64, + "UNIDESK_CICD_BRANCH_FOLLOWER_MANIFEST_B64", options.dryRun ? `kubectl apply --dry-run=server --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp"` : `kubectl apply --server-side --force-conflicts --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp"`, @@ -592,7 +595,7 @@ async function applyController(registry: BranchFollowerRegistry, options: Parsed : "true", `kubectl -n ${shQuote(registry.controller.namespace)} get deploy/${shQuote(registry.controller.deploymentName)} cm/${shQuote(registry.controller.configMapName)} cm/${shQuote(registry.controller.stateConfigMapName)} lease/${shQuote(registry.controller.leaseName)} -o wide 2>/dev/null || true`, ].join("\n"); - const result = runKubeScript(registry, options, script, manifestYaml, (waitSeconds + 15) * 1000); + const result = runKubeScript(registry, options, script, "", (waitSeconds + 15) * 1000); return { ok: result.exitCode === 0, action: "apply", @@ -849,7 +852,8 @@ async function readAdapterStatus(follower: FollowerSpec, options: ParsedOptions) const spec = follower.commands.status; const timeoutSeconds = options.timeoutSeconds ?? spec.timeoutSeconds; const result = runCommand(spec.argv, repoRoot, { timeoutMs: timeoutSeconds * 1000 }); - const payload = parseJsonObject(result.stdout) ?? parseJsonObject(result.stderr); + const rawPayload = parseJsonObject(result.stdout) ?? parseJsonObject(result.stderr); + const payload = recoverDumpPayload(rawPayload) ?? rawPayload; const body = payload === null ? null : unwrapEnvelope(payload); const observedSha = firstStringPath(body, [ "summary.sourceCommit", @@ -864,6 +868,7 @@ async function readAdapterStatus(follower: FollowerSpec, options: ParsedOptions) "selectedCommit", "sourceCommit", "observedSha", + "alignment.sourceCommit", ], ["sourceCommit", "observedSha", "observedCommit", "selectedCommit", "selectedSourceCommit"]); const targetSha = firstStringPath(body, [ "summary.targetCommit", @@ -878,6 +883,10 @@ async function readAdapterStatus(follower: FollowerSpec, options: ParsedOptions) "deployedCommit", "currentCommit", "targetSha", + "summary.runtimeAlignment.managerSourceCommit", + "alignment.runtimeAlignment.managerSourceCommit", + "runtime.manager.sourceCommit", + "manager.sourceCommit", ], ["targetCommit", "targetSha", "runtimeCommit", "gitopsCommit", "deployedCommit", "currentCommit"]); const lastTriggeredSha = firstStringPath(body, [ "summary.lastTriggeredSha", @@ -894,10 +903,12 @@ async function readAdapterStatus(follower: FollowerSpec, options: ParsedOptions) ], ["lastSucceededSha", "lastSucceededCommit", "succeededCommit"]); const pipelineRun = firstStringPath(body, [ "summary.pipelineRun", + "summary.expectedPipelineRun", + "alignment.expectedPipelineRun", "pipelineRun.name", "pipelineRun", "latestPipelineRun.name", - ], ["pipelineRun", "pipelineRunName"]); + ], ["pipelineRun", "pipelineRunName", "expectedPipelineRun"]); const inFlightJob = firstStringPath(body, [ "summary.inFlightJob", "job.id", @@ -1001,7 +1012,7 @@ function readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions): const deploymentResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get deploy ${shQuote(registry.controller.deploymentName)} -o json`, 10_000); const leaseResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get lease ${shQuote(registry.controller.leaseName)} -o json`, 10_000); const podSelector = labelSelector(registry.controller.labels); - const podsResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get pods -l ${shQuote(podSelector)} -o json`, 10_000); + const podsResult = kubePodList(registry, options, podSelector); if (!stateResult.ok && !isNotFoundText(stateResult.error)) errors.push(`state configmap: ${stateResult.error}`); if (!deploymentResult.ok && !isNotFoundText(deploymentResult.error)) errors.push(`deployment: ${deploymentResult.error}`); if (!leaseResult.ok && !isNotFoundText(leaseResult.error)) errors.push(`lease: ${leaseResult.error}`); @@ -1037,6 +1048,21 @@ function kubeJson(registry: BranchFollowerRegistry, options: ParsedOptions, comm }; } +function kubePodList(registry: BranchFollowerRegistry, options: ParsedOptions, selector: string): { ok: boolean; value: Record | null; error: string } { + const command = `kubectl -n ${shQuote(registry.controller.namespace)} get pods -l ${shQuote(selector)} -o name`; + const result = runKubeScript(registry, options, `set -eu\n${command}`, "", 10_000); + const names = result.stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.replace(/^pod\//u, "")); + return { + ok: result.exitCode === 0, + value: result.exitCode === 0 ? { items: names.map((name) => ({ metadata: { name } })) } : null, + error: redactText(tailText(result.stderr || result.stdout, 800)), + }; +} + function runKubeScript(registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number): CommandResult { if (options.controller) { return runCommand(["sh", "-lc", script], repoRoot, { input, timeoutMs }); @@ -1166,12 +1192,14 @@ function controllerLoopScript(): string { "while true; do", " started_at=$(date -Iseconds)", " echo \"branch-follower loop started ${started_at}\"", + " cd /work", " rm -rf /work/unidesk", " git clone --depth=1 --branch \"${UNIDESK_CONTROLLER_SOURCE_BRANCH}\" \"${UNIDESK_CONTROLLER_GIT_MIRROR_READ_URL}\" /work/unidesk", " cp /etc/unidesk-cicd-branch-follower/cicd-branch-followers.yaml /work/unidesk/config/cicd-branch-followers.yaml", " cd /work/unidesk", " bun scripts/cli.ts cicd branch-follower run-once --all --confirm --wait --controller --config config/cicd-branch-followers.yaml --timeout-seconds \"${timeout}\" --json || true", " echo \"branch-follower loop finished $(date -Iseconds)\"", + " cd /work", " sleep \"${interval}\"", "done", ].join("\n"); @@ -1278,6 +1306,19 @@ function unwrapEnvelope(payload: Record): Record | null): Record | null { + if (payload === null) return null; + const data = asOptionalRecord(payload.data); + const dump = asOptionalRecord(data?.dump) ?? asOptionalRecord(payload.dump); + const path = stringOrNull(dump?.path); + if (path === null || !existsSync(path)) return payload; + try { + return parseJsonObject(readFileSync(path, "utf8")) ?? payload; + } catch { + return payload; + } +} + function firstStringPath(root: Record | null, paths: string[], fallbackKeys: string[] = []): string | null { if (root === null) return null; for (const path of paths) {