diff --git a/scripts/src/cicd-gates.ts b/scripts/src/cicd-gates.ts index 39258921..c619c022 100644 --- a/scripts/src/cicd-gates.ts +++ b/scripts/src/cicd-gates.ts @@ -2,7 +2,7 @@ // Responsibility: submit bounded target-side gate Jobs and return compact evidence. import type { CommandResult } from "./command"; import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; -import { runNativeHwlabControlPlaneRefresh } from "./cicd-hwlab-refresh"; +import { nativeHwlabControlPlaneRefreshJobManifest, runNativeHwlabControlPlaneRefresh } from "./cicd-hwlab-refresh"; import { nativeCicdScriptLoadShell } from "./cicd-native-bundle"; import { waitForJobShell } from "./cicd-controller-render"; import type { BranchFollowerRegistry, FollowerSpec, ParsedOptions } from "./cicd-types"; @@ -60,9 +60,10 @@ function runTargetControlPlaneRefreshGateJob(registry: BranchFollowerRegistry, f if (follower.adapter !== "hwlab-node-runtime" || options.sourceCommit === null || !options.confirm) { return runControlPlaneRefreshGate(registry, follower, options); } + const spec = hwlabRuntimeLaneSpecForNode(follower.target.lane, follower.target.node); const timeoutSeconds = options.timeoutSeconds ?? follower.budgets.controlPlaneRefreshSeconds; - const jobName = `bf-gate-${safeName(follower.id)}-control-refresh-${Date.now().toString(36)}`.slice(0, 63); - const manifest = controllerGateJobManifest(registry, follower, options, jobName, timeoutSeconds); + const jobName = nativeCapabilityJobName(follower.id, "control-plane-refresh", options.sourceCommit); + const manifest = nativeHwlabControlPlaneRefreshJobManifest(registry, follower, spec, options.sourceCommit, jobName, timeoutSeconds); const manifestYaml = `${Bun.YAML.stringify(manifest).trim()}\n`; const script = [ "set -eu", @@ -76,14 +77,14 @@ function runTargetControlPlaneRefreshGateJob(registry: BranchFollowerRegistry, f ].join("\n"); const startedAt = Date.now(); const command = runKubeScript(registry, options, script, "", (timeoutSeconds + registry.controller.budgets.reconcileTransportGraceSeconds) * 1000); - const parsed = command.exitCode === 0 ? parseFirstJsonObject(command.stdout) : null; - const ok = command.exitCode === 0 && parsed !== null && parsed.ok !== false; + const parsed = command.exitCode === 0 ? parseLastJsonObject(command.stdout, isControlPlaneRefreshResult) : null; + const ok = command.exitCode === 0 && parsed !== null && parsed.ok === true; return { ok, action: "gate", gate: options.gate, follower: follower.id, - target: { name: jobName, namespace: registry.controller.namespace, execution: "k8s-native-gate-job" }, + target: { name: jobName, namespace: registry.controller.namespace, execution: "k8s-native-control-plane-refresh" }, result: parsed, command: { exitCode: command.exitCode, @@ -159,74 +160,6 @@ function runControlPlaneRefreshGate(registry: BranchFollowerRegistry, follower: }; } -function controllerGateJobManifest(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions, jobName: string, timeoutSeconds: number): Record { - const labels = { ...registry.controller.labels, "app.kubernetes.io/component": "cicd-gate-job" }; - const commandArgs = [ - "bun", - "scripts/cli.ts", - "cicd", - "branch-follower", - "gate", - "--follower", - follower.id, - "--gate", - "control-plane-refresh", - "--source-commit", - options.sourceCommit ?? "", - "--confirm", - "--in-cluster", - "--config", - "config/cicd-branch-followers.yaml", - "--timeout-seconds", - String(timeoutSeconds), - "--json", - ]; - return { - apiVersion: "batch/v1", - kind: "Job", - metadata: { name: jobName, namespace: registry.controller.namespace, labels }, - spec: { - backoffLimit: registry.controller.budgets.reconcileJobBackoffLimit, - ttlSecondsAfterFinished: registry.controller.budgets.reconcileJobTtlSeconds, - activeDeadlineSeconds: timeoutSeconds + registry.controller.budgets.reconcileJobDeadlineGraceSeconds, - template: { - metadata: { labels }, - spec: { - restartPolicy: "Never", - serviceAccountName: registry.controller.serviceAccountName, - volumes: [ - { name: "registry", configMap: { name: registry.controller.configMapName, defaultMode: 0o755 } }, - { name: "git-mirror-cache", persistentVolumeClaim: { claimName: registry.controller.source.gitMirrorCachePvcName } }, - { name: "git-ssh", secret: { secretName: registry.controller.source.githubSsh.secretName, defaultMode: 0o400 } }, - { name: "work", emptyDir: {} }, - ], - containers: [{ - name: "gate", - image: registry.controller.image, - imagePullPolicy: "IfNotPresent", - command: ["/bin/sh", "/etc/unidesk-cicd-branch-follower/controller-one-shot.sh"], - args: commandArgs, - env: [ - { name: "UNIDESK_CONTROLLER_SOURCE_BRANCH", value: registry.controller.source.branch }, - { name: "UNIDESK_CONTROLLER_SOURCE_REPOSITORY", value: registry.controller.source.repository }, - { name: "UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX", value: registry.controller.source.sourceSnapshot.stageRefPrefix.replaceAll("{branch}", registry.controller.source.branch) }, - { 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) }, - ], - volumeMounts: [ - { name: "registry", mountPath: "/etc/unidesk-cicd-branch-follower", readOnly: true }, - { name: "git-mirror-cache", mountPath: "/cache" }, - { name: "git-ssh", mountPath: "/git-ssh", readOnly: true }, - { name: "work", mountPath: "/work" }, - ], - }], - }, - }, - }, - }; -} - function gateJobManifest(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions, jobName: string, timeoutSeconds: number): Record { const labels = { ...registry.controller.labels, "app.kubernetes.io/component": "cicd-gate-job" }; const agentrun = follower.adapter === "agentrun-yaml-lane" ? resolveAgentRunLaneTarget({ node: follower.target.node, lane: follower.target.lane }).spec : null; @@ -353,7 +286,7 @@ function parseFirstJsonObject(text: string): Record | null { return null; } -function parseLastJsonObject(text: string): Record | null { +function parseLastJsonObject(text: string, predicate: (value: Record) => boolean = () => true): Record | null { let last: Record | null = null; let offset = 0; while (offset < text.length) { @@ -374,7 +307,7 @@ function parseLastJsonObject(text: string): Record | null { else if (char === "}" && --depth === 0) { try { const parsed = JSON.parse(text.slice(start, index + 1)) as unknown; - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) last = parsed as Record; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && predicate(parsed as Record)) last = parsed as Record; } catch { // Keep scanning because controller bootstrap may print non-contract JSON-like fragments. } @@ -388,6 +321,10 @@ function parseLastJsonObject(text: string): Record | null { return last; } +function isControlPlaneRefreshResult(value: Record): boolean { + return value.ok === true && value.status === "applied" && value.sourceAuthority === "k8s-git-mirror-snapshot"; +} + function safeName(value: string): string { return value.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/-+/gu, "-").replace(/^-|-$/gu, "").slice(0, 32); } diff --git a/scripts/src/cicd-hwlab-refresh.ts b/scripts/src/cicd-hwlab-refresh.ts index 97114a9e..5d1d678f 100644 --- a/scripts/src/cicd-hwlab-refresh.ts +++ b/scripts/src/cicd-hwlab-refresh.ts @@ -18,7 +18,7 @@ export function runNativeHwlabControlPlaneRefresh( return { jobName, namespace, result }; } -function nativeHwlabControlPlaneRefreshJobManifest( +export function nativeHwlabControlPlaneRefreshJobManifest( registry: BranchFollowerRegistry, follower: FollowerSpec, spec: HwlabRuntimeLaneSpec,