235 lines
8.4 KiB
TypeScript
235 lines
8.4 KiB
TypeScript
import { rootPath } from "../../src/config";
|
|
import { readYamlRecord } from "../../src/platform-infra-ops-library";
|
|
import { materializeYamlComposition } from "../../src/yaml-composition";
|
|
|
|
const pacConfigPath = "config/platform-infra/pipelines-as-code.yaml";
|
|
|
|
export interface CicdOtelRuntimeConfig {
|
|
readonly endpoint: string;
|
|
readonly endpointHostname: string;
|
|
readonly serviceName: string;
|
|
readonly samplingRatio: number;
|
|
readonly timeoutMs: number;
|
|
readonly fallbackCodes: readonly string[];
|
|
}
|
|
|
|
export interface CicdOtelExportWarning {
|
|
readonly event: "cicd.otel.export";
|
|
readonly warning: true;
|
|
readonly blocking: false;
|
|
readonly causeCode: string;
|
|
readonly endpointHostname: string;
|
|
readonly httpStatus?: number;
|
|
readonly timedOut?: boolean;
|
|
readonly errorType?: string;
|
|
readonly timeoutMs: number;
|
|
readonly traceId: string;
|
|
readonly valuesRedacted: true;
|
|
}
|
|
|
|
export function loadCicdOtelRuntimeConfig(
|
|
node: string,
|
|
lane: string,
|
|
env: Record<string, string | undefined> = process.env,
|
|
): CicdOtelRuntimeConfig {
|
|
const root = materializeYamlComposition(
|
|
readYamlRecord<Record<string, unknown>>(rootPath(pacConfigPath), "platform-infra-pipelines-as-code"),
|
|
{ label: "platform-infra-pipelines-as-code", stripTemplateKeys: true },
|
|
).value;
|
|
const observability = record(root.observability, `${pacConfigPath}#observability`);
|
|
const repository = repositoryFor(root.repositories, node, lane);
|
|
const params = record(repository.params, `${pacConfigPath}#repositories.${repository.id}.params`);
|
|
const fallbackCodes: string[] = [];
|
|
const endpoint = authoritativeValue(
|
|
[
|
|
[params.otel_traces_endpoint, "INVALID_ENDPOINT_PARAM"],
|
|
[observability.tracesEndpoint, "INVALID_ENDPOINT_AUTHORITY"],
|
|
],
|
|
validEndpoint,
|
|
fallbackCodes,
|
|
);
|
|
validateInput(env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, endpoint, validEndpoint, "ENDPOINT", fallbackCodes);
|
|
const endpointUrl = new URL(endpoint);
|
|
const serviceName = authoritativeValue(
|
|
[
|
|
[params.otel_service_name, "INVALID_SERVICE_PARAM"],
|
|
[observability.serviceName, "INVALID_SERVICE_AUTHORITY"],
|
|
],
|
|
validServiceName,
|
|
fallbackCodes,
|
|
);
|
|
validateInput(env.OTEL_SERVICE_NAME, serviceName, validServiceName, "SERVICE", fallbackCodes);
|
|
const sampling = record(observability.sampling, `${pacConfigPath}#observability.sampling`);
|
|
const samplingRatio = authoritativeNumber(
|
|
[
|
|
[params.otel_sampling_ratio, "INVALID_SAMPLING_PARAM"],
|
|
[sampling.ratio, "INVALID_SAMPLING_AUTHORITY"],
|
|
],
|
|
(value) => value >= 0 && value <= 1,
|
|
fallbackCodes,
|
|
);
|
|
validateNumberInput(env.OTEL_TRACES_SAMPLER_ARG, samplingRatio, "SAMPLING", fallbackCodes);
|
|
const exporterFailure = record(observability.exporterFailure, `${pacConfigPath}#observability.exporterFailure`);
|
|
const timeoutMs = authoritativeNumber(
|
|
[
|
|
[params.otel_exporter_timeout_ms, "INVALID_TIMEOUT_PARAM"],
|
|
[exporterFailure.timeoutMs, "INVALID_TIMEOUT_AUTHORITY"],
|
|
],
|
|
(value) => Number.isInteger(value) && value >= 50 && value <= 2_000,
|
|
fallbackCodes,
|
|
);
|
|
validateNumberInput(env.OTEL_EXPORTER_TIMEOUT_MS, timeoutMs, "TIMEOUT", fallbackCodes);
|
|
return {
|
|
endpoint: endpointUrl.toString(),
|
|
endpointHostname: endpointUrl.hostname,
|
|
serviceName,
|
|
samplingRatio,
|
|
timeoutMs,
|
|
fallbackCodes,
|
|
};
|
|
}
|
|
|
|
export async function exportCicdOtelPayload(
|
|
config: CicdOtelRuntimeConfig,
|
|
traceparent: string,
|
|
traceId: string,
|
|
payload: unknown,
|
|
fetcher: typeof fetch = fetch,
|
|
): Promise<CicdOtelExportWarning | null> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
try {
|
|
const response = await fetcher(config.endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", traceparent },
|
|
body: JSON.stringify(payload),
|
|
signal: controller.signal,
|
|
});
|
|
if (response.ok) return null;
|
|
return warning(config, traceId, `HTTP_${response.status}`, { httpStatus: response.status });
|
|
} catch (error) {
|
|
return warning(config, traceId, errorCauseCode(error), {
|
|
timedOut: error instanceof Error && error.name === "AbortError",
|
|
errorType: error instanceof Error ? error.name : "Error",
|
|
});
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function repositoryFor(value: unknown, node: string, lane: string): Record<string, unknown> {
|
|
if (!Array.isArray(value)) throw new Error(`${pacConfigPath}#repositories must be an array`);
|
|
const expectedId = `sentinel-${node.toLowerCase()}-${lane.toLowerCase()}`;
|
|
const repository = value.find((item) => recordOrNull(item)?.id === expectedId);
|
|
if (repository === undefined) throw new Error(`${pacConfigPath} has no repository ${expectedId}`);
|
|
return record(repository, `${pacConfigPath}#repositories.${expectedId}`);
|
|
}
|
|
|
|
function authoritativeValue(
|
|
candidates: ReadonlyArray<readonly [unknown, string]>,
|
|
validate: (value: string) => boolean,
|
|
fallbackCodes: string[],
|
|
): string {
|
|
for (const [candidate, code] of candidates) {
|
|
if (candidate === undefined || candidate === null || candidate === "") {
|
|
fallbackCodes.push(code);
|
|
continue;
|
|
}
|
|
if (typeof candidate === "string" && validate(candidate)) return candidate;
|
|
fallbackCodes.push(code);
|
|
}
|
|
throw new Error(`invalid owning OTel configuration: ${fallbackCodes.at(-1) ?? "UNKNOWN"}`);
|
|
}
|
|
|
|
function authoritativeNumber(
|
|
candidates: ReadonlyArray<readonly [unknown, string]>,
|
|
validate: (value: number) => boolean,
|
|
fallbackCodes: string[],
|
|
): number {
|
|
for (const [candidate, code] of candidates) {
|
|
if (candidate === undefined || candidate === null || candidate === "") {
|
|
fallbackCodes.push(code);
|
|
continue;
|
|
}
|
|
const value = typeof candidate === "number" ? candidate : Number(candidate);
|
|
if (Number.isFinite(value) && validate(value)) return value;
|
|
fallbackCodes.push(code);
|
|
}
|
|
throw new Error(`invalid owning OTel configuration: ${fallbackCodes.at(-1) ?? "UNKNOWN"}`);
|
|
}
|
|
|
|
function validateInput(
|
|
input: string | undefined,
|
|
desired: string,
|
|
validate: (value: string) => boolean,
|
|
label: string,
|
|
fallbackCodes: string[],
|
|
): void {
|
|
if (input === undefined || input === "") return;
|
|
if (!validate(input)) fallbackCodes.push(`INVALID_${label}_INPUT`);
|
|
else if (input !== desired) fallbackCodes.push(`MISMATCH_${label}_INPUT`);
|
|
}
|
|
|
|
function validateNumberInput(input: string | undefined, desired: number, label: string, fallbackCodes: string[]): void {
|
|
if (input === undefined || input === "") return;
|
|
const value = Number(input);
|
|
if (!Number.isFinite(value)) fallbackCodes.push(`INVALID_${label}_INPUT`);
|
|
else if (input !== String(desired)) fallbackCodes.push(`MISMATCH_${label}_INPUT`);
|
|
}
|
|
|
|
function validEndpoint(value: string): boolean {
|
|
if (hasTemplatePlaceholder(value)) return false;
|
|
try {
|
|
const url = new URL(value);
|
|
return (url.protocol === "http:" || url.protocol === "https:")
|
|
&& url.hostname.length > 0
|
|
&& url.username.length === 0
|
|
&& url.password.length === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function validServiceName(value: string): boolean {
|
|
return !hasTemplatePlaceholder(value) && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);
|
|
}
|
|
|
|
function hasTemplatePlaceholder(value: string): boolean {
|
|
return /\{\{[^}]+\}\}|\$\{[^}]+\}/u.test(value);
|
|
}
|
|
|
|
function warning(
|
|
config: CicdOtelRuntimeConfig,
|
|
traceId: string,
|
|
causeCode: string,
|
|
details: Pick<CicdOtelExportWarning, "httpStatus" | "timedOut" | "errorType">,
|
|
): CicdOtelExportWarning {
|
|
return {
|
|
event: "cicd.otel.export",
|
|
warning: true,
|
|
blocking: false,
|
|
causeCode,
|
|
endpointHostname: config.endpointHostname,
|
|
...details,
|
|
timeoutMs: config.timeoutMs,
|
|
traceId,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function errorCauseCode(error: unknown): string {
|
|
if (error instanceof Error && error.name === "AbortError") return "EXPORT_TIMEOUT";
|
|
const cause = error instanceof Error && typeof error.cause === "object" && error.cause !== null ? error.cause as Record<string, unknown> : null;
|
|
return typeof cause?.code === "string" && /^[A-Za-z0-9_.-]{1,80}$/u.test(cause.code) ? cause.code : "EXPORT_FAILED";
|
|
}
|
|
|
|
function record(value: unknown, path: string): Record<string, unknown> {
|
|
const result = recordOrNull(value);
|
|
if (result === null) throw new Error(`${path} must be an object`);
|
|
return result;
|
|
}
|
|
|
|
function recordOrNull(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
}
|