216 lines
9.1 KiB
JavaScript
216 lines
9.1 KiB
JavaScript
#!/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;
|
|
}
|