#!/usr/bin/env node // Wait for Argo/runtime convergence with a single wall-clock budget. import { readFileSync } from "node:fs"; import https from "node:https"; const host = process.env.KUBERNETES_SERVICE_HOST; const port = process.env.KUBERNETES_SERVICE_PORT || "443"; const startedAt = Date.now(); const timeoutMs = positiveInteger(process.env.HWLAB_RUNTIME_READY_TIMEOUT_MS, 60_000); const pollMs = positiveInteger(process.env.HWLAB_RUNTIME_READY_POLL_MS, 1_000); const progressEveryMs = positiveInteger(process.env.HWLAB_RUNTIME_READY_PROGRESS_MS, 10_000); const deadline = startedAt + timeoutMs; function requiredEnv(name) { const value = process.env[name]; if (!value) throw new Error(name + " is required"); return value; } const revision = requiredEnv("HWLAB_SOURCE_REVISION"); const runtimeNamespace = requiredEnv("HWLAB_RUNTIME_NAMESPACE"); const gitopsTarget = requiredEnv("HWLAB_GITOPS_TARGET"); const pipelineRun = process.env.HWLAB_TEKTON_PIPELINERUN || null; const taskRun = process.env.HWLAB_TEKTON_TASKRUN || null; const task = process.env.HWLAB_TEKTON_TASK || null; function positiveInteger(value, fallback) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function emit(payload) { console.log(JSON.stringify({ event: "node-cicd-timing", schemaVersion: "v1", pipelineRun, taskRun, task, revision, source: "scripts/ci/runtime-ready.mjs", at: new Date().toISOString(), elapsedMs: Date.now() - startedAt, remainingMs: Math.max(0, deadline - Date.now()), ...payload })); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function safeJson(text) { try { return JSON.parse(text); } catch { return null; } } if (!host) { emit({ stage: "argo-refresh", status: "failed", reason: "kubernetes-service-host-missing", durationMs: 0 }); process.exit(1); } const token = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8"); const ca = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"); function request(method, path, body, contentType = "application/json") { const payload = body ? JSON.stringify(body) : ""; const headers = { Authorization: "Bearer " + token }; if (payload) { headers["Content-Type"] = contentType; headers["Content-Length"] = Buffer.byteLength(payload); } return new Promise((resolve, reject) => { const req = https.request({ host, port, method, path, ca, headers }, (res) => { let data = ""; res.setEncoding("utf8"); res.on("data", (chunk) => { data += chunk; }); res.on("end", () => resolve({ statusCode: res.statusCode || 0, body: data, json: safeJson(data) })); }); req.on("error", reject); if (payload) req.write(payload); req.end(); }); } function listPath(group, version, namespace, resource) { if (group) return "/apis/" + group + "/" + version + "/namespaces/" + encodeURIComponent(namespace) + "/" + resource; return "/api/" + version + "/namespaces/" + encodeURIComponent(namespace) + "/" + resource; } async function runtimeItems() { const [deployments, statefulsets] = await Promise.all([ request("GET", listPath("apps", "v1", runtimeNamespace, "deployments")), request("GET", listPath("apps", "v1", runtimeNamespace, "statefulsets")) ]); if (deployments.statusCode === 403 || statefulsets.statusCode === 403) return { status: "rbac-denied", items: [] }; const items = [...(deployments.json?.items || []), ...(statefulsets.json?.items || [])] .filter((item) => item.metadata?.labels?.["hwlab.pikastech.local/gitops-target"] === gitopsTarget); return { status: "ok", items }; } function runtimeSourceCommit(item) { return item.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/gitops-render-source-commit"] || item.spec?.template?.metadata?.annotations?.["hwlab.pikastech.local/gitops-render-source-commit"] || item.metadata?.labels?.["hwlab.pikastech.local/gitops-render-source-commit"] || item.metadata?.annotations?.["hwlab.pikastech.local/gitops-render-source-commit"] || item.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/source-commit"] || item.spec?.template?.metadata?.annotations?.["hwlab.pikastech.local/source-commit"] || item.metadata?.labels?.["hwlab.pikastech.local/source-commit"] || item.metadata?.annotations?.["hwlab.pikastech.local/source-commit"] || null; } function serviceIdForRuntimeItem(item) { return item.metadata?.labels?.["hwlab.pikastech.local/service-id"] || item.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || item.metadata?.labels?.["app.kubernetes.io/name"] || item.metadata?.name || null; } function readObservedServiceIds() { try { const plan = JSON.parse(readFileSync("/workspace/source/affected-services.json", "utf8")); const rolloutServices = Array.isArray(plan.rolloutServices) ? plan.rolloutServices.filter(Boolean) : []; if (rolloutServices.length === 0) return null; return new Set(rolloutServices); } catch { return null; } } const observedServiceIds = readObservedServiceIds(); const readinessMode = observedServiceIds ? "service-rollout" : "infra-runtime-change"; function observedRuntimeItems(items) { if (!observedServiceIds) return items; return items.filter((item) => observedServiceIds.has(serviceIdForRuntimeItem(item))); } function readyWorkload(item) { const specReplicas = item.spec?.replicas ?? 1; if (specReplicas === 0) return { ready: true, skipped: true }; const status = item.status || {}; const desired = specReplicas; const ready = status.readyReplicas ?? 0; const updated = status.updatedReplicas ?? 0; const observedGeneration = status.observedGeneration ?? 0; const generation = item.metadata?.generation ?? 0; return { ready: observedGeneration >= generation && ready >= desired && updated >= desired, desired, readyReplicas: ready, updatedReplicas: updated }; } function maybeEmitProgress(state, stage, stageStartedAt, payload) { const now = Date.now(); if (now - state.lastProgressAt < progressEveryMs) return; state.lastProgressAt = now; emit({ stage, status: "progress", durationMs: now - stageStartedAt, readinessMode, ...payload }); } async function waitForArgoRefresh() { const stageStartedAt = Date.now(); const progressState = { lastProgressAt: 0 }; while (Date.now() < deadline) { const runtime = await runtimeItems(); if (runtime.status === "rbac-denied") return fail("argo-refresh", "runtime-observer-rbac-denied", stageStartedAt, { readinessMode }); const observed = observedRuntimeItems(runtime.items); const pending = observed .map((item) => ({ kind: item.kind, name: item.metadata?.name, sourceCommit: runtimeSourceCommit(item) })) .filter((item) => item.sourceCommit !== revision); if (observed.length > 0 && pending.length === 0) { emit({ stage: "argo-refresh", status: "succeeded", durationMs: Date.now() - stageStartedAt, readinessMode, workloadCount: runtime.items.length, observedCount: observed.length }); return true; } maybeEmitProgress(progressState, "argo-refresh", stageStartedAt, { workloadCount: runtime.items.length, observedCount: observed.length, pending: pending.slice(0, 8) }); await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now()))); } return fail("argo-refresh", "source-commit-refresh-timeout", stageStartedAt, { readinessMode }, "timeout"); } async function waitForWorkloads() { const stageStartedAt = Date.now(); const progressState = { lastProgressAt: 0 }; while (Date.now() < deadline) { const runtime = await runtimeItems(); if (runtime.status === "rbac-denied") return fail("workload-ready", "runtime-observer-rbac-denied", stageStartedAt, { readinessMode }); const observed = observedRuntimeItems(runtime.items); const blocked = observed.map((item) => ({ kind: item.kind, name: item.metadata?.name, ...readyWorkload(item) })).filter((item) => !item.ready); if (observed.length > 0 && blocked.length === 0) { emit({ stage: "workload-ready", status: "succeeded", durationMs: Date.now() - stageStartedAt, readinessMode, workloadCount: runtime.items.length, observedCount: observed.length }); return true; } maybeEmitProgress(progressState, "workload-ready", stageStartedAt, { workloadCount: runtime.items.length, observedCount: observed.length, blocked: blocked.slice(0, 8) }); await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now()))); } return fail("workload-ready", "runtime-ready-wall-clock-timeout", stageStartedAt, { readinessMode }, "timeout"); } function fail(stage, reason, stageStartedAt, extra = {}, status = "failed") { emit({ stage, status, reason, durationMs: Date.now() - stageStartedAt, ...extra }); process.exitCode = 1; return false; } emit({ stage: "runtime-ready", status: "started", readinessMode, observedServiceCount: observedServiceIds ? observedServiceIds.size : 0, timeoutMs }); try { if (await waitForArgoRefresh()) { await waitForWorkloads(); } } catch (error) { emit({ stage: "runtime-ready", status: "failed", reason: error instanceof Error ? error.message : String(error), durationMs: Date.now() - startedAt }); process.exitCode = 1; }