diff --git a/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs b/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs index 5aa64d33..f60ac557 100644 --- a/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs +++ b/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs @@ -3,6 +3,7 @@ // Responsibility: render and apply the HWLAB node Tekton Pipeline from a k8s git-mirror snapshot. import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import https from "node:https"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -13,6 +14,7 @@ const sourceStageRef = requiredEnv("SOURCE_STAGE_REF"); const gitReadUrl = requiredEnv("GIT_READ_URL"); const fieldManager = requiredEnv("FIELD_MANAGER"); const tektonNamespace = requiredEnv("TEKTON_NAMESPACE"); +const kubeRequestTimeoutSeconds = requiredPositiveNumber("KUBE_REQUEST_TIMEOUT_SECONDS"); const overlay = JSON.parse(Buffer.from(requiredEnv("HWLAB_RENDER_OVERLAY_B64"), "base64").toString("utf8")); const workDir = mkdtempSync(path.join(tmpdir(), `hwlab-control-plane-${sourceCommit.slice(0, 12)}-`)); const repoDir = path.join(workDir, "repo"); @@ -24,7 +26,7 @@ try { prepareYamlDependency(); applyDeployOverlay(); renderControlPlane(); - applyPipeline(); + await applyPipeline(); emit({ ok: true, status: "applied" }); } finally { rmSync(workDir, { recursive: true, force: true }); @@ -74,8 +76,7 @@ function canResolveYaml() { } function applyDeployOverlay() { - const requireFromDeps = createRequire(path.join(depsDir, "package.json")); - const YAML = requireFromDeps("yaml"); + const YAML = yamlModule(); const deployPath = path.join(repoDir, "deploy/deploy.yaml"); const doc = YAML.parse(readFileSync(deployPath, "utf8")); doc.nodes = doc.nodes || {}; @@ -132,10 +133,61 @@ function renderControlPlane() { ], { cwd: repoDir, env: renderEnv() }); } -function applyPipeline() { +async function applyPipeline() { const pipelinePath = path.join(renderDir, overlay.tektonDir, "pipeline.yaml"); if (!existsSync(pipelinePath)) throw new Error(`rendered Pipeline missing: ${pipelinePath}`); - run("kubectl", ["apply", "--server-side", "--force-conflicts", `--field-manager=${fieldManager}`, "-f", pipelinePath], { cwd: repoDir, env: renderEnv() }); + const pipelineText = readFileSync(pipelinePath, "utf8"); + const pipeline = yamlModule().parse(pipelineText); + const pipelineName = pipeline?.metadata?.name; + if (typeof pipelineName !== "string" || pipelineName.length === 0) throw new Error(`rendered Pipeline metadata.name missing: ${pipelinePath}`); + await kubeRequest( + "PATCH", + `/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelines/${encodeURIComponent(pipelineName)}?fieldManager=${encodeURIComponent(fieldManager)}&force=true`, + pipelineText, + "application/apply-patch+yaml", + ); +} + +function yamlModule() { + const requireFromDeps = createRequire(path.join(depsDir, "package.json")); + return requireFromDeps("yaml"); +} + +function kubeRequest(method, requestPath, body, contentType = "application/json") { + const host = requiredEnv("KUBERNETES_SERVICE_HOST"); + const port = requiredPositiveNumber("KUBERNETES_SERVICE_PORT"); + const token = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8").trim(); + const ca = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"); + return new Promise((resolve, reject) => { + const payload = typeof body === "string" ? body : JSON.stringify(body); + const req = https.request({ + host, + port, + path: requestPath, + method, + ca, + headers: { + authorization: `Bearer ${token}`, + "content-type": contentType, + "content-length": Buffer.byteLength(payload), + }, + }, (res) => { + let text = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { text += chunk; }); + res.on("end", () => { + if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) { + reject(new Error(`kube api ${method} ${requestPath} status ${res.statusCode}: ${tail(text, 1200)}`)); + return; + } + resolve(text); + }); + }); + req.setTimeout(kubeRequestTimeoutSeconds * 1000, () => req.destroy(new Error(`kube api ${method} ${requestPath} timed out after ${kubeRequestTimeoutSeconds}s`))); + req.on("error", reject); + req.write(payload); + req.end(); + }); } function renderEnv(extra = {}) { diff --git a/scripts/src/cicd-hwlab-refresh.ts b/scripts/src/cicd-hwlab-refresh.ts index 0c5707d1..62fd02eb 100644 --- a/scripts/src/cicd-hwlab-refresh.ts +++ b/scripts/src/cicd-hwlab-refresh.ts @@ -14,7 +14,7 @@ export function runNativeHwlabControlPlaneRefresh( jobName: string, ): { jobName: string; namespace: string; result: NativeK8sJobResult } { const namespace = registry.controller.namespace; - const result = runNativeK8sJob(namespace, jobName, nativeHwlabControlPlaneRefreshJobManifest(registry, follower, spec, observedSha, jobName), timeoutSeconds, "control-plane-refresh", registry.controller.budgets); + const result = runNativeK8sJob(namespace, jobName, nativeHwlabControlPlaneRefreshJobManifest(registry, follower, spec, observedSha, jobName, timeoutSeconds), timeoutSeconds, "control-plane-refresh", registry.controller.budgets); return { jobName, namespace, result }; } @@ -24,6 +24,7 @@ function nativeHwlabControlPlaneRefreshJobManifest( spec: HwlabRuntimeLaneSpec, observedSha: string, jobName: string, + timeoutSeconds: number, ): Record { const mirror = nodeRuntimeGitMirrorTarget(spec); const overlay = Buffer.from(JSON.stringify(nodeRuntimeRenderOverlay(spec)), "utf8").toString("base64"); @@ -81,6 +82,7 @@ function nativeHwlabControlPlaneRefreshJobManifest( { name: "GIT_READ_URL", value: spec.gitReadUrl }, { name: "FIELD_MANAGER", value: spec.controlPlaneFieldManager }, { name: "TEKTON_NAMESPACE", value: tektonNamespace }, + { name: "KUBE_REQUEST_TIMEOUT_SECONDS", value: String(timeoutSeconds) }, { name: "HWLAB_RENDER_OVERLAY_B64", value: overlay }, ], }],