160 lines
7.9 KiB
TypeScript
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);
|
|
}
|