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"; import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig } from "./otel-runtime-config"; 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 otelConfig = loadCicdOtelRuntimeConfig(options.node, options.lane); const otelSetup = [ `export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${shellQuote(otelConfig.endpoint)}`, `export OTEL_EXPORTER_TIMEOUT_MS=${shellQuote(String(otelConfig.timeoutMs))}`, `export OTEL_SERVICE_NAME=${shellQuote(otelConfig.serviceName)}`, `export OTEL_TRACES_SAMPLER_ARG=${shellQuote(String(otelConfig.samplingRatio))}`, ].join("\n"); 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", otelSetup, `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", otelSetup, traceSetup, proxySetup, sentinelPublishImageBuildShell(state, options.pipelineRun)].join("\n")); writeExecutable("publish.sh", ["#!/bin/sh", "set -eu", otelSetup, traceSetup, proxySetup, sentinelPublishShell(state, options.pipelineRun, true)].join("\n")); emitConfigFallbackWarning(); 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 context = process.env.TRACEPARENT?.match(/^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/iu); if (context === undefined || context === null || !shouldSample(context[1] ?? "", otelConfig.samplingRatio)) return; const startedAtMs = Number(process.env.CICD_TRACE_STARTED_AT_MS ?? Date.now()); const payload = { resourceSpans: [{ resource: { attributes: attributes({ "service.name": otelConfig.serviceName, "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 warning = await exportCicdOtelPayload(otelConfig, process.env.TRACEPARENT ?? "", context[1]?.toLowerCase() ?? "", payload); if (warning !== null) process.stderr.write(`${JSON.stringify(warning)}\n`); } function emitConfigFallbackWarning(): void { if (otelConfig.fallbackCodes.length === 0) return; process.stderr.write(`${JSON.stringify({ event: "cicd.otel.config", warning: true, blocking: false, causeCodes: otelConfig.fallbackCodes, endpointHostname: otelConfig.endpointHostname, valuesRedacted: true })}\n`); } function shouldSample(traceIdValue: string, ratio: number): boolean { if (ratio <= 0) return false; if (ratio >= 1) return true; const bucket = Number.parseInt(traceIdValue.slice(0, 8), 16) / 0xffffffff; return bucket < ratio; } 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); }