From 917c0b6b524d1b09de4dc84d2f85d047ce03300b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 15:01:22 +0200 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20=E8=B7=9F=E8=B8=AA=20Gitea=20mirror?= =?UTF-8?q?=20plan=20=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/github-gitea-durable-source-authority.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/MDTODO/github-gitea-durable-source-authority.md b/docs/MDTODO/github-gitea-durable-source-authority.md index 1e63233d..a2236b36 100644 --- a/docs/MDTODO/github-gitea-durable-source-authority.md +++ b/docs/MDTODO/github-gitea-durable-source-authority.md @@ -49,3 +49,7 @@ ### R3.3 依赖 R3.2,覆盖仓库缺失、ref 缺失、重复 delivery、controller 重启、retry exhaustion 与恢复测试;只用修复 PR merge 产生的新自动事件验收 resource bundle 获取成功,禁止人工 mirror、sync、flush、trigger 或 direct GitHub 补洞,完成任务后将详细报告写入[任务报告](./details/github-gitea-durable-source-authority/R3.3_Task_Report.md)。 + +## R4 [in_progress] + +修复 [UniDesk #1933](https://github.com/pikasTech/unidesk/issues/1933) 暴露的 Gitea mirror 默认只读 plan 回归:恢复 repository-scoped targetRepos 计算,保持 mutation authority 不变,补充最小回归验证并由独立 Artificer PR 交付,完成任务后将详细报告写入[任务报告](./details/github-gitea-durable-source-authority/R4_Task_Report.md)。 From 0b73068c3d33a5b099873dace8b8b1a21bb91731 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 15:12:45 +0200 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E8=B7=9F=E8=B8=AA=20CI/CD=20OTel?= =?UTF-8?q?=20=E8=BF=90=E8=A1=8C=E9=9D=A2=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/observability-trace-reliability.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/MDTODO/observability-trace-reliability.md b/docs/MDTODO/observability-trace-reliability.md index 06d50b1e..d236c6c0 100644 --- a/docs/MDTODO/observability-trace-reliability.md +++ b/docs/MDTODO/observability-trace-reliability.md @@ -26,3 +26,7 @@ ## R3 [in_progress] 解决 [UniDesk #1923](https://github.com/pikasTech/unidesk/issues/1923):提高 CI/CD 自动链错误暴露能力并接入 OTel,覆盖 GitHub webhook→Gitea snapshot→PaC→Tekton PipelineRun/TaskRun/step→artifact/GitOps→Argo/runtime/health 的 trace context、阶段 span、首断点与脱敏错误投影;让 cicd status、PaC status/history/debug-step 和 sentinel control-plane status 默认有界显示 traceId/error code/phase/exit/下钻。exporter、版本或 commit 漂移只能非阻塞 warning;禁止新增人工交付路径、第二 authority、锁/租约/证据链/状态机,以真实失败 hwlab-web-probe-sentinel-nc01-6gjzl 和新 PR merge 自动事件验收,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R3_Task_Report.md)。 + +### R3.1 [in_progress] + +基于 PR #1925 合并后的真实自动事件继续修复 OTel 运行面:禁止 PaC Repository 参数未就绪时把花括号占位符作为 endpoint;从 owning YAML 在 source snapshot 内解析 OTel 配置并保留 300ms 非阻塞策略,增加安全的原始 fetch cause 投影;恢复 Tempo endpoint 后,用新的普通 PR merge 自动事件证明 collector 接收且 Tempo 可按 traceId 查询,禁止人工触发 PipelineRun,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R3.1_Task_Report.md)。 From e3214917de130c5a8f897b286abae4323bae662d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 15:15:38 +0200 Subject: [PATCH 3/4] fix: restore repository-scoped Gitea mirror plan --- scripts/src/cicd-delivery-authority.test.ts | 10 ++++++++-- scripts/src/platform-infra-gitea.ts | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/src/cicd-delivery-authority.test.ts b/scripts/src/cicd-delivery-authority.test.ts index fc9a06c8..1a2cc73d 100644 --- a/scripts/src/cicd-delivery-authority.test.ts +++ b/scripts/src/cicd-delivery-authority.test.ts @@ -498,9 +498,13 @@ describe("migrated CLI 执行 guard 与提示清理", () => { }); test("Gitea 与 mirror 默认 plan 只返回只读 Next", async () => { + const giteaPlan = await runPlatformInfraGiteaCommand({} as never, ["plan", "--target", "NC01", "--raw"]) as Record; + const nc01MirrorPlan = await runPlatformInfraGiteaCommand({} as never, ["mirror", "plan", "--target", "NC01", "--raw"]) as Record; + const jd01MirrorPlan = await runPlatformInfraGiteaCommand({} as never, ["mirror", "plan", "--target", "JD01", "--raw"]) as Record; const plans = [ - await runPlatformInfraGiteaCommand({} as never, ["plan", "--target", "NC01", "--raw"]), - await runPlatformInfraGiteaCommand({} as never, ["mirror", "plan", "--target", "NC01", "--raw"]), + giteaPlan, + nc01MirrorPlan, + jd01MirrorPlan, ] as Record[]; for (const plan of plans) { expect(plan.mutation).toBe(false); @@ -508,6 +512,8 @@ describe("migrated CLI 执行 guard 与提示清理", () => { expect(JSON.stringify(plan.next)).toContain("status"); expect(JSON.stringify(plan.next)).toContain(PAC_AUTOMATIC_DELIVERY_REFERENCE); } + expect(JSON.stringify(nc01MirrorPlan.credentials)).toContain("github-upstream:selfmedia-nc01"); + expect(JSON.stringify(jd01MirrorPlan.credentials)).not.toContain("github-upstream:selfmedia-nc01"); }); test("PaC history detail 只按唯一 consumer 生成 Next,四类前缀无默认 consumer 回退", () => { diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 0f01c99b..5cfb4e25 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -314,6 +314,7 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom function mirrorPlan(options: CommonOptions): Record { const gitea = readGiteaConfig(); const target = resolveCommandTarget(gitea, options.targetId); + const targetRepos = repositoriesForTarget(gitea, target); return { ok: true, action: "platform-infra-gitea-mirror-plan", @@ -321,7 +322,7 @@ function mirrorPlan(options: CommonOptions): Record { target: targetSummary(target), sourceAuthority: sourceAuthoritySummary(gitea, target), responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary), - repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)), + repositories: targetRepos.map((repo) => repositorySummary(gitea, repo)), credentials: credentialSummaries(gitea, targetRepos), next: mirrorReadOnlyNextCommands(target.id), }; From b740d5d31e97fd7679ccb4f68ea75b0aca4d3557 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 15:41:37 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20PaC=20OTel=20re?= =?UTF-8?q?nderer=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/native/cicd/otel-runtime-config.ts | 234 ++++++++++++++++++ .../cicd/render-sentinel-publish-task.test.ts | 133 ++++++++++ .../cicd/render-sentinel-publish-task.ts | 45 ++-- 3 files changed, 393 insertions(+), 19 deletions(-) create mode 100644 scripts/native/cicd/otel-runtime-config.ts create mode 100644 scripts/native/cicd/render-sentinel-publish-task.test.ts diff --git a/scripts/native/cicd/otel-runtime-config.ts b/scripts/native/cicd/otel-runtime-config.ts new file mode 100644 index 00000000..1b18c542 --- /dev/null +++ b/scripts/native/cicd/otel-runtime-config.ts @@ -0,0 +1,234 @@ +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 = process.env, +): CicdOtelRuntimeConfig { + const root = materializeYamlComposition( + readYamlRecord>(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 { + 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 { + 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, + 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, + 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 { + 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 : 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 { + const result = recordOrNull(value); + if (result === null) throw new Error(`${path} must be an object`); + return result; +} + +function recordOrNull(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} diff --git a/scripts/native/cicd/render-sentinel-publish-task.test.ts b/scripts/native/cicd/render-sentinel-publish-task.test.ts new file mode 100644 index 00000000..cd562c4c --- /dev/null +++ b/scripts/native/cicd/render-sentinel-publish-task.test.ts @@ -0,0 +1,133 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rootPath } from "../../src/config"; +import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig } from "./otel-runtime-config"; + +const roots: string[] = []; + +afterEach(() => { + while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +test("unexpanded Repository inputs fall back to materialized owning YAML", () => { + const config = loadCicdOtelRuntimeConfig("NC01", "v03", { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "{{otel_traces_endpoint}}", + OTEL_SERVICE_NAME: "{{otel_service_name}}", + OTEL_TRACES_SAMPLER_ARG: "{{otel_sampling_ratio}}", + OTEL_EXPORTER_TIMEOUT_MS: "{{otel_exporter_timeout_ms}}", + }); + expect(config).toMatchObject({ + endpoint: "http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces", + endpointHostname: "otel-collector.platform-infra.svc.cluster.local", + serviceName: "unidesk-cicd-sentinel-nc01", + samplingRatio: 1, + timeoutMs: 300, + }); + expect(config.fallbackCodes).toEqual([ + "INVALID_ENDPOINT_INPUT", + "INVALID_SERVICE_INPUT", + "INVALID_SAMPLING_INPUT", + "INVALID_TIMEOUT_INPUT", + ]); +}); + +test("valid but mismatched runtime inputs cannot override owning YAML", () => { + const config = loadCicdOtelRuntimeConfig("NC01", "v03", { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://collector.example.invalid:4318/v1/traces", + OTEL_SERVICE_NAME: "different-service", + OTEL_TRACES_SAMPLER_ARG: "0.5", + OTEL_EXPORTER_TIMEOUT_MS: "600", + }); + expect(config.endpointHostname).toBe("otel-collector.platform-infra.svc.cluster.local"); + expect(config.serviceName).toBe("unidesk-cicd-sentinel-nc01"); + expect(config.samplingRatio).toBe(1); + expect(config.timeoutMs).toBe(300); + expect(config.fallbackCodes).toEqual([ + "MISMATCH_ENDPOINT_INPUT", + "MISMATCH_SERVICE_INPUT", + "MISMATCH_SAMPLING_INPUT", + "MISMATCH_TIMEOUT_INPUT", + ]); +}); + +test("legal owning endpoint and non-blocking exporter failure share the bounded helper", async () => { + const config = loadCicdOtelRuntimeConfig("NC01", "v03", { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces", + OTEL_SERVICE_NAME: "unidesk-cicd-sentinel-nc01", + OTEL_TRACES_SAMPLER_ARG: "1", + OTEL_EXPORTER_TIMEOUT_MS: "300", + }); + expect(config.fallbackCodes).toEqual([]); + const warning = await exportCicdOtelPayload( + config, + "00-11111111111111111111111111111111-2222222222222222-01", + "11111111111111111111111111111111", + { resourceSpans: [] }, + async () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ENOTFOUND" } }); }, + ); + expect(warning).toEqual({ + event: "cicd.otel.export", + warning: true, + blocking: false, + causeCode: "ENOTFOUND", + endpointHostname: "otel-collector.platform-infra.svc.cluster.local", + timedOut: false, + errorType: "TypeError", + timeoutMs: 300, + traceId: "11111111111111111111111111111111", + valuesRedacted: true, + }); +}); + +test("actual renderer replaces unexpanded inputs without changing business exit", async () => { + const result = await render({ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "{{otel_traces_endpoint}}" }, false); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('"event":"cicd.otel.config"'); + expect(result.stderr).toContain('"blocking":false'); + expect(result.stderr).toContain('"endpointHostname":"otel-collector.platform-infra.svc.cluster.local"'); + expect(result.stderr).not.toContain("{{otel_traces_endpoint}}"); + expect(readFileSync(join(result.outputDir, "source.sh"), "utf8")).toContain("http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces"); +}); + +async function render(env: Record, traced = true) { + const outputDir = mkdtempSync(join(tmpdir(), "unidesk-render-sentinel-publish-")); + roots.push(outputDir); + const child = Bun.spawn({ + cmd: [ + process.execPath, + "scripts/native/cicd/render-sentinel-publish-task.ts", + "--node", "NC01", + "--lane", "v03", + "--sentinel", "nc01-web-probe-sentinel", + "--pipeline-run", "hwlab-web-probe-sentinel-nc01-test", + "--source-commit", "a".repeat(40), + "--source-stage-ref", `refs/unidesk/snapshots/test/${"a".repeat(40)}`, + "--source-authority", "gitea-snapshot", + "--output-dir", outputDir, + ], + cwd: rootPath(), + env: { + ...process.env, + TRACEPARENT: traced ? "00-11111111111111111111111111111111-2222222222222222-01" : "", + OTEL_SERVICE_NAME: "unidesk-cicd-test", + OTEL_TRACES_SAMPLER_ARG: "1", + OTEL_EXPORTER_TIMEOUT_MS: "300", + ...env, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { + exitCode, + stdout, + stderr, + outputDir, + }; +} diff --git a/scripts/native/cicd/render-sentinel-publish-task.ts b/scripts/native/cicd/render-sentinel-publish-task.ts index 8fd52029..284f6bb0 100644 --- a/scripts/native/cicd/render-sentinel-publish-task.ts +++ b/scripts/native/cicd/render-sentinel-publish-task.ts @@ -8,6 +8,7 @@ import { 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"); @@ -25,6 +26,13 @@ const state = loadSentinelCicdState( ); 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", @@ -33,14 +41,16 @@ const proxySetup = sentinelImageBuildProxyEnv(state).map(({ name, value }) => `e 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", traceSetup, proxySetup, sentinelPublishImageBuildShell(state, options.pipelineRun)].join("\n")); -writeExecutable("publish.sh", ["#!/bin/sh", "set -eu", traceSetup, proxySetup, sentinelPublishShell(state, options.pipelineRun, true)].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`); @@ -80,13 +90,12 @@ function traceId(traceparent: string | undefined): string | 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; + 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": process.env.OTEL_SERVICE_NAME ?? "unidesk-cicd", "unidesk.node": options.node, "hwlab.lane": options.lane, "unidesk.values_redacted": true }) }, + 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: [{ @@ -115,22 +124,20 @@ async function emitOuterSpan(): Promise { }], }], }; - 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); - } + const warning = await exportCicdOtelPayload(otelConfig, process.env.TRACEPARENT ?? "", context[1]?.toLowerCase() ?? "", payload); + if (warning !== null) process.stderr.write(`${JSON.stringify(warning)}\n`); } -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 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> {