Files
pikasTech-unidesk/scripts/native/cicd/render-sentinel-publish-task.ts
T
2026-07-13 15:41:37 +02:00

160 lines
7.9 KiB
TypeScript

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<string, string>();
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<void> {
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<string, unknown>): Array<Record<string, unknown>> {
return Object.entries(values).filter(([, value]) => value !== null && value !== undefined).map(([key, value]) => ({ key, value: attributeValue(value) }));
}
function attributeValue(value: unknown): Record<string, unknown> {
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);
}