import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { hwlabRuntimeLaneSpecForNode } from "../../src/hwlab-node-lanes"; import { loadSentinelCicdState } from "../../src/hwlab-node-web-sentinel-cicd"; import { sentinelImageBuildProxyEnv, sentinelPublishImageBuildShell, sentinelPublishShell, sentinelPublishSourceShell, } from "../../src/hwlab-node-web-sentinel-cicd-jobs"; const options = parseOptions(process.argv.slice(2)); const sourceAuthority = options.sourceAuthority === "gitea-snapshot" ? "gitea-snapshot" : fail("--source-authority must be gitea-snapshot"); const state = loadSentinelCicdState( hwlabRuntimeLaneSpecForNode(options.lane, options.node), options.sentinel, 30, "cached", { commit: options.sourceCommit, stageRef: options.sourceStageRef, mirrorCommit: options.sourceCommit, sourceAuthority, }, ); const outputDir = resolve(options.outputDir); mkdirSync(outputDir, { recursive: true }); const traceSetup = [ "if [ -f /workspace/meta/traceparent ]; then TRACEPARENT=$(cat /workspace/meta/traceparent); export TRACEPARENT; fi", "if [ -f /workspace/meta/tracestate ]; then TRACESTATE=$(cat /workspace/meta/tracestate); export TRACESTATE; fi", ].join("\n"); const proxySetup = sentinelImageBuildProxyEnv(state).map(({ name, value }) => `export ${name}=${shellQuote(value)}`).join("\n"); writeExecutable("source.sh", [ "#!/bin/sh", "set -eu", `incoming_traceparent=${shellQuote(process.env.TRACEPARENT ?? "")}`, `incoming_tracestate=${shellQuote(process.env.TRACESTATE ?? "")}`, sentinelPublishSourceShell(state, options.pipelineRun), "if [ -n \"$incoming_traceparent\" ]; then printf '%s' \"$incoming_traceparent\" > /workspace/meta/traceparent; fi", "if [ -n \"$incoming_tracestate\" ]; then printf '%s' \"$incoming_tracestate\" > /workspace/meta/tracestate; fi", ].join("\n")); writeExecutable("image-build.sh", ["#!/bin/sh", "set -eu", traceSetup, proxySetup, sentinelPublishImageBuildShell(state, options.pipelineRun)].join("\n")); writeExecutable("publish.sh", ["#!/bin/sh", "set -eu", traceSetup, proxySetup, sentinelPublishShell(state, options.pipelineRun, true)].join("\n")); await emitOuterSpan(); process.stdout.write(`${JSON.stringify({ ok: true, action: "render-sentinel-publish-task", node: options.node, lane: options.lane, sentinel: options.sentinel, pipelineRun: options.pipelineRun, sourceCommit: options.sourceCommit, outputDir, traceId: traceId(process.env.TRACEPARENT), valuesRedacted: true })}\n`); function writeExecutable(name: string, content: string): void { const path = resolve(outputDir, name); writeFileSync(path, `${content}\n`, "utf8"); chmodSync(path, 0o755); } function parseOptions(args: string[]): Record<"node" | "lane" | "sentinel" | "pipelineRun" | "sourceCommit" | "sourceStageRef" | "sourceAuthority" | "outputDir", string> { const values = new Map(); for (let index = 0; index < args.length; index += 2) { const key = args[index]; const value = args[index + 1]; if (key === undefined || value === undefined || !key.startsWith("--")) fail("render-sentinel-publish-task requires key/value options"); values.set(key.slice(2), value); } const required = (key: string): string => values.get(key) || fail(`--${key} is required`); const result = { node: required("node"), lane: required("lane"), sentinel: required("sentinel"), pipelineRun: required("pipeline-run"), sourceCommit: required("source-commit"), sourceStageRef: required("source-stage-ref"), sourceAuthority: required("source-authority"), outputDir: required("output-dir"), }; if (!/^[0-9a-f]{40}$/u.test(result.sourceCommit)) fail("--source-commit must be a 40-character lowercase Git commit"); for (const key of ["node", "lane", "sentinel", "pipelineRun"] as const) if (!/^[A-Za-z0-9._-]+$/u.test(result[key])) fail(`--${key} must be a simple id`); if (!result.sourceStageRef.startsWith("refs/unidesk/snapshots/")) fail("--source-stage-ref must be an immutable UniDesk snapshot ref"); return result; } function traceId(traceparent: string | undefined): string | null { return traceparent?.match(/^00-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/iu)?.[1]?.toLowerCase() ?? null; } async function emitOuterSpan(): Promise { const endpoint = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT; const context = process.env.TRACEPARENT?.match(/^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/iu); if (endpoint === undefined || context === undefined || context === null) return; const startedAtMs = Number(process.env.CICD_TRACE_STARTED_AT_MS ?? Date.now()); const payload = { resourceSpans: [{ resource: { attributes: attributes({ "service.name": process.env.OTEL_SERVICE_NAME ?? "unidesk-cicd", "unidesk.node": options.node, "hwlab.lane": options.lane, "unidesk.values_redacted": true }) }, scopeSpans: [{ scope: { name: "unidesk.cicd.pac_outer", version: "1" }, spans: [{ traceId: context[1]?.toLowerCase(), spanId: context[2]?.toLowerCase(), name: "cicd.pac.outer_event", kind: 2, startTimeUnixNano: (BigInt(startedAtMs) * 1_000_000n).toString(), endTimeUnixNano: (BigInt(Date.now()) * 1_000_000n).toString(), attributes: attributes({ "cicd.target": options.node, "cicd.lane": options.lane, "cicd.consumer": `sentinel-${options.node.toLowerCase()}-v03`, "cicd.repository": "pikasTech/unidesk", "cicd.branch": "master", "cicd.source_commit": options.sourceCommit, "cicd.pipeline_run": options.pipelineRun, "cicd.delivery_class": "outer-pac-event", "cicd.phase": "task-rendered", "warning": false, "blocking": false, "valuesRedacted": true, }), status: { code: 1 }, }], }], }], }; const controller = new AbortController(); const timeoutMs = exporterTimeoutMs(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json", traceparent: process.env.TRACEPARENT ?? "" }, body: JSON.stringify(payload), signal: controller.signal }); if (!response.ok) process.stderr.write(`${JSON.stringify({ event: "cicd.otel.export", warning: true, blocking: false, httpStatus: response.status, timeoutMs, traceId: context[1]?.toLowerCase(), valuesRedacted: true })}\n`); } catch (error) { process.stderr.write(`${JSON.stringify({ event: "cicd.otel.export", warning: true, blocking: false, timedOut: error instanceof Error && error.name === "AbortError", errorType: error instanceof Error ? error.name : "Error", timeoutMs, traceId: context[1]?.toLowerCase(), valuesRedacted: true })}\n`); } finally { clearTimeout(timer); } } function exporterTimeoutMs(): number { const value = Number(process.env.OTEL_EXPORTER_TIMEOUT_MS ?? "300"); return Number.isInteger(value) && value >= 50 && value <= 2_000 ? value : 300; } function attributes(values: Record): Array> { return Object.entries(values).filter(([, value]) => value !== null && value !== undefined).map(([key, value]) => ({ key, value: attributeValue(value) })); } function attributeValue(value: unknown): Record { if (typeof value === "boolean") return { boolValue: value }; if (typeof value === "number" && Number.isFinite(value)) return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value }; return { stringValue: typeof value === "string" ? value : JSON.stringify(value) }; } function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } function fail(message: string): never { throw new Error(message); }