diff --git a/scripts/src/agentrun/secrets.ts b/scripts/src/agentrun/secrets.ts index 2c83386e..de76ee51 100644 --- a/scripts/src/agentrun/secrets.ts +++ b/scripts/src/agentrun/secrets.ts @@ -296,8 +296,30 @@ export async function runYamlLaneGitMirrorJob(config: UniDeskConfig, spec: Agent } export function yamlLanePipelineRunCreateScript(spec: AgentRunLaneSpec, sourceCommit: string, pipelineRun: string): string { + const manifest = yamlLanePipelineRunManifest(spec, sourceCommit, pipelineRun); + const manifestB64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64"); + return [ + "set -eu", + `namespace=${shQuote(spec.ci.namespace)}`, + `pipeline_run=${shQuote(pipelineRun)}`, + `manifest_b64=${shQuote(manifestB64)}`, + "tmp=$(mktemp)", + "trap 'rm -f \"$tmp\"' EXIT", + "printf '%s' \"$manifest_b64\" | base64 -d > \"$tmp\"", + "existing_status=$(kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" -o 'jsonpath={.status.conditions[0].status}' 2>/dev/null || true)", + "if [ \"$existing_status\" = False ]; then kubectl -n \"$namespace\" delete pipelinerun \"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true; fi", + "if kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" >/dev/null 2>&1; then created=false; else kubectl create -f \"$tmp\"; created=true; fi", + "status=$(kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" -o 'jsonpath={.status.conditions[0].status}' 2>/dev/null || true)", + "CREATED=\"$created\" STATUS=\"$status\" PIPELINE_RUN=\"$pipeline_run\" python3 - <<'PY'", + "import json, os", + "print(json.dumps({'ok': True, 'pipelineRun': os.environ.get('PIPELINE_RUN'), 'created': os.environ.get('CREATED') == 'true', 'status': os.environ.get('STATUS') or None, 'valuesPrinted': False}, ensure_ascii=False))", + "PY", + ].join("\n"); +} + +export function yamlLanePipelineRunManifest(spec: AgentRunLaneSpec, sourceCommit: string, pipelineRun: string): Record { const sourceStageRef = spec.source.statusMode === "k3s-git-mirror" ? agentRunSourceSnapshotRef(spec, sourceCommit) : null; - const manifest = { + return { apiVersion: "tekton.dev/v1", kind: "PipelineRun", metadata: { @@ -337,24 +359,6 @@ export function yamlLanePipelineRunCreateScript(spec: AgentRunLaneSpec, sourceCo ], }, }; - const manifestB64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64"); - return [ - "set -eu", - `namespace=${shQuote(spec.ci.namespace)}`, - `pipeline_run=${shQuote(pipelineRun)}`, - `manifest_b64=${shQuote(manifestB64)}`, - "tmp=$(mktemp)", - "trap 'rm -f \"$tmp\"' EXIT", - "printf '%s' \"$manifest_b64\" | base64 -d > \"$tmp\"", - "existing_status=$(kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" -o 'jsonpath={.status.conditions[0].status}' 2>/dev/null || true)", - "if [ \"$existing_status\" = False ]; then kubectl -n \"$namespace\" delete pipelinerun \"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true; fi", - "if kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" >/dev/null 2>&1; then created=false; else kubectl create -f \"$tmp\"; created=true; fi", - "status=$(kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" -o 'jsonpath={.status.conditions[0].status}' 2>/dev/null || true)", - "CREATED=\"$created\" STATUS=\"$status\" PIPELINE_RUN=\"$pipeline_run\" python3 - <<'PY'", - "import json, os", - "print(json.dumps({'ok': True, 'pipelineRun': os.environ.get('PIPELINE_RUN'), 'created': os.environ.get('CREATED') == 'true', 'status': os.environ.get('STATUS') or None, 'valuesPrinted': False}, ensure_ascii=False))", - "PY", - ].join("\n"); } export function createYamlLaneJobScript(namespace: string, jobName: string, manifest: Record): string { diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts index 40022428..bb01e232 100644 --- a/scripts/src/cicd.ts +++ b/scripts/src/cicd.ts @@ -8,6 +8,12 @@ import { runCommand, type CommandResult } from "./command"; import { startJob } from "./jobs"; import type { RenderedCliResult } from "./output"; import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes"; +import { agentRunImageArtifact, renderAgentRunGitopsFiles } from "./agentrun-manifests"; +import { agentRunPipelineRunName, resolveAgentRunLaneTarget } from "./agentrun-lanes"; +import { yamlLaneGitMirrorJobManifest, yamlLaneGitopsPublishJobManifest, yamlLaneGitopsPublishPayloadFromProbe, yamlLanePipelineRunManifest } from "./agentrun/secrets"; +import { yamlLaneK3sBuildImageJobManifest } from "./agentrun/yaml-lane"; +import { nodeRuntimePipelineRunName } from "./hwlab-node/cleanup"; +import { nodeRuntimePipelineRunManifest } from "./hwlab-node/web-probe"; import { loadSentinelCicdState } from "./hwlab-node-web-sentinel-cicd"; import { sentinelPublishPipelineRunManifest } from "./hwlab-node-web-sentinel-cicd-jobs"; import { sentinelPipelineRunName } from "./hwlab-node-web-sentinel-cicd-shared"; @@ -252,6 +258,24 @@ interface NativeCloseoutWaitResult { parsedDownstreamCliOutput: false; } +interface NativeK8sJobResult { + ok: boolean; + completed: boolean; + failed: boolean; + timedOut: boolean; + created: boolean; + reused: boolean; + jobName: string; + namespace: string; + polls: number; + elapsedMs: number; + logsTail: string | null; + conditionReason: string | null; + conditionMessage: string | null; + statusAuthority: "kubernetes-api-serviceaccount"; + parsedDownstreamCliOutput: false; +} + interface FollowerState { id: string; adapter: string; @@ -1104,6 +1128,12 @@ async function decideAndMaybeTrigger( async function executeTrigger(registry: BranchFollowerRegistry, follower: FollowerSpec, observedSha: string | null, options: ParsedOptions): Promise { const spec = follower.commands.trigger; const timeoutSeconds = options.timeoutSeconds ?? spec.timeoutSeconds; + if (follower.adapter === "hwlab-node-runtime" && options.controller) { + return await executeNativeHwlabNodeTrigger(registry, follower, observedSha, options, timeoutSeconds); + } + if (follower.adapter === "agentrun-yaml-lane" && options.controller) { + return await executeNativeAgentRunTrigger(registry, follower, observedSha, options, timeoutSeconds); + } if (follower.adapter === "web-probe-sentinel-cicd" && options.controller) { return await executeNativeSentinelTrigger(registry, follower, observedSha, options, timeoutSeconds); } @@ -1132,6 +1162,197 @@ async function executeTrigger(registry: BranchFollowerRegistry, follower: Follow }; } +async function executeNativeHwlabNodeTrigger(registry: BranchFollowerRegistry, follower: FollowerSpec, observedSha: string | null, options: ParsedOptions, timeoutSeconds: number): Promise { + if (observedSha === null) { + return nativeTriggerError(follower, "native HWLAB trigger requires observed source sha", "observed-sha-missing"); + } + if (follower.nativeStatus.tekton === null) { + return nativeTriggerError(follower, "native HWLAB trigger requires Tekton nativeStatus", "tekton-native-status-missing"); + } + const spec = hwlabRuntimeLaneSpecForNode(follower.target.lane, follower.target.node); + const pipelineRun = nodeRuntimePipelineRunName(spec, observedSha); + const namespace = follower.nativeStatus.tekton.namespace; + const manifest = nodeRuntimePipelineRunManifest(spec, observedSha, pipelineRun); + const startedAt = Date.now(); + const result = runNativeTektonPipelineRun(namespace, pipelineRun, manifest, options.wait, timeoutSeconds); + const payload = parseJsonObject(result.stdout) ?? {}; + const pipelineRunCompleted = payload.completed === true; + const failed = payload.failed === true || result.exitCode !== 0; + const closeout = !failed && options.wait && pipelineRunCompleted + ? await waitNativeFollowerCloseout(registry, follower, observedSha, options, remainingSeconds(startedAt, timeoutSeconds)) + : null; + return nativeTektonTriggerResult({ + follower, + observedSha, + namespace, + pipelineRun, + stageRef: `${follower.source.snapshotPrefix.replace(/\/+$/u, "")}/${observedSha}`, + wait: options.wait, + result, + payload, + closeout, + successMessage: `native HWLAB PipelineRun ${pipelineRun} succeeded and runtime reached ${shortSha(observedSha)}`, + }); +} + +async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, follower: FollowerSpec, observedSha: string | null, options: ParsedOptions, timeoutSeconds: number): Promise { + if (observedSha === null) { + return nativeTriggerError(follower, "native AgentRun trigger requires observed source sha", "observed-sha-missing"); + } + if (follower.nativeStatus.tekton === null) { + return nativeTriggerError(follower, "native AgentRun trigger requires Tekton nativeStatus", "tekton-native-status-missing"); + } + const { configPath, spec } = resolveAgentRunLaneTarget({ node: follower.target.node, lane: follower.target.lane }); + const startedAt = Date.now(); + const stageRef = `${follower.source.snapshotPrefix.replace(/\/+$/u, "")}/${observedSha}`; + const buildJob = `agentrun-bf-build-${spec.nodeId.toLowerCase()}-${observedSha.slice(0, 12)}-${Date.now().toString(36)}`.slice(0, 63); + const build = runNativeK8sJob(spec.ci.namespace, buildJob, yamlLaneK3sBuildImageJobManifest(spec, observedSha, buildJob), Math.min(remainingSeconds(startedAt, timeoutSeconds), Math.max(60, spec.deployment.manager.imageBuild.timeoutSeconds)), "buildkit"); + const buildPayload = yamlLaneGitopsPublishPayloadFromProbe({ logsTail: stringOrNull(build.logsTail) ?? "" }); + const digest = stringOrNull(buildPayload.digest); + const envIdentity = stringOrNull(buildPayload.envIdentity); + if (!build.ok || digest === null || envIdentity === null) { + return nativeAgentRunStageFailure(follower, observedSha, "image-build", buildJob, build, buildPayload, "native AgentRun image build failed"); + } + const image = agentRunImageArtifact(spec, { sourceCommit: observedSha, envIdentity, digest, status: stringOrNull(buildPayload.status) ?? "built" }); + const renderedFiles = renderAgentRunGitopsFiles(spec, { sourceCommit: observedSha, image }); + const publishJob = `agentrun-bf-gitops-${spec.nodeId.toLowerCase()}-${observedSha.slice(0, 12)}-${Date.now().toString(36)}`.slice(0, 63); + const publish = runNativeK8sJob(spec.gitMirror.namespace, publishJob, yamlLaneGitopsPublishJobManifest(spec, renderedFiles, publishJob), remainingSeconds(startedAt, timeoutSeconds), "publish"); + const publishPayload = yamlLaneGitopsPublishPayloadFromProbe({ logsTail: stringOrNull(publish.logsTail) ?? "" }); + if (!publish.ok || publishPayload.ok === false || stringOrNull(publishPayload.gitopsCommit) === null) { + return nativeAgentRunStageFailure(follower, observedSha, "gitops-publish", publishJob, publish, publishPayload, "native AgentRun GitOps publish failed"); + } + const flushJob = `${spec.gitMirror.flushJobPrefix}-${Date.now().toString(36)}-bf`.slice(0, 63); + const flush = runNativeK8sJob(spec.gitMirror.namespace, flushJob, yamlLaneGitMirrorJobManifest(spec, "flush", flushJob), remainingSeconds(startedAt, timeoutSeconds), "flush"); + if (!flush.ok) { + return nativeAgentRunStageFailure(follower, observedSha, "git-mirror-flush", flushJob, flush, {}, "native AgentRun git-mirror flush failed"); + } + const pipelineRun = agentRunPipelineRunName(spec, observedSha); + const tektonResult = runNativeTektonPipelineRun(follower.nativeStatus.tekton.namespace, pipelineRun, yamlLanePipelineRunManifest(spec, observedSha, pipelineRun), options.wait, remainingSeconds(startedAt, timeoutSeconds)); + const tektonPayload = parseJsonObject(tektonResult.stdout) ?? {}; + const pipelineRunCompleted = tektonPayload.completed === true; + const failed = tektonPayload.failed === true || tektonResult.exitCode !== 0; + const closeout = !failed && options.wait && pipelineRunCompleted + ? await waitNativeFollowerCloseout(registry, follower, observedSha, options, remainingSeconds(startedAt, timeoutSeconds)) + : null; + const trigger = nativeTektonTriggerResult({ + follower, + observedSha, + namespace: follower.nativeStatus.tekton.namespace, + pipelineRun, + stageRef, + wait: options.wait, + result: tektonResult, + payload: { + ...tektonPayload, + agentrun: { + configPath, + imageBuild: { jobName: buildJob, payload: buildPayload }, + gitopsPublish: { jobName: publishJob, payload: publishPayload }, + gitMirrorFlush: { jobName: flushJob, payload: flush }, + }, + }, + closeout, + successMessage: `native AgentRun PipelineRun ${pipelineRun} succeeded and runtime reached ${shortSha(observedSha)}`, + }); + return trigger; +} + +function nativeTriggerError(follower: FollowerSpec, message: string, reason: string): TriggerResult { + return { + ok: false, + completed: false, + message, + jobId: null, + command: { mode: "k8s-native-tekton", adapter: follower.adapter, ok: false, reason, parsedDownstreamCliOutput: false }, + }; +} + +function nativeAgentRunStageFailure( + follower: FollowerSpec, + observedSha: string, + phase: string, + jobName: string, + job: NativeK8sJobResult, + payload: Record, + message: string, +): TriggerResult { + return { + ok: false, + completed: false, + message, + jobId: jobName, + command: { + mode: "k8s-native-job", + adapter: follower.adapter, + phase, + jobName, + sourceCommit: observedSha, + ok: false, + payload, + job, + statusAuthority: "kubernetes-api-serviceaccount", + parsedDownstreamCliOutput: false, + }, + }; +} + +function nativeTektonTriggerResult(input: { + follower: FollowerSpec; + observedSha: string; + namespace: string; + pipelineRun: string; + stageRef: string; + wait: boolean; + result: CommandResult; + payload: Record; + closeout: NativeCloseoutWaitResult | null; + successMessage: string; +}): TriggerResult { + const pipelineRunCompleted = input.payload.completed === true; + const failed = input.payload.failed === true || input.result.exitCode !== 0; + const stillRunning = input.payload.stillRunning === true || input.payload.timedOutWait === true; + const message = failed + ? tailText(input.result.stderr || stringOrNull(input.payload.message) || input.result.stdout, 500) + : input.closeout?.completed === true + ? input.successMessage + : input.closeout?.timedOut === true + ? `native PipelineRun ${input.pipelineRun} succeeded but runtime closeout did not converge within budget` + : pipelineRunCompleted + ? `native PipelineRun ${input.pipelineRun} succeeded; runtime closeout remains k8s-native` + : stillRunning + ? `native PipelineRun ${input.pipelineRun} is still running; query status/events/logs for closeout` + : `native PipelineRun ${input.pipelineRun} submitted`; + const ok = !failed && (input.closeout === null || input.closeout.completed === true); + return { + ok, + completed: input.closeout?.completed === true, + message, + jobId: input.pipelineRun, + command: { + mode: "k8s-native-tekton", + adapter: input.follower.adapter, + namespace: input.namespace, + pipelineRun: input.pipelineRun, + sourceCommit: input.observedSha, + sourceStageRef: input.stageRef, + wait: input.wait, + pipelineRunCompleted, + stillRunning, + closeout: input.closeout, + statusAuthority: "kubernetes-api-serviceaccount", + parsedDownstreamCliOutput: false, + payload: input.payload, + exitCode: input.result.exitCode, + timedOut: input.result.timedOut, + stderrTail: failed ? redactText(tailText(input.result.stderr, 1000)) : "", + }, + }; +} + +function remainingSeconds(startedAt: number, timeoutSeconds: number): number { + return Math.max(1, timeoutSeconds - Math.ceil((Date.now() - startedAt) / 1000)); +} + async function executeNativeSentinelTrigger(registry: BranchFollowerRegistry, follower: FollowerSpec, observedSha: string | null, options: ParsedOptions, timeoutSeconds: number): Promise { if (observedSha === null) { return { @@ -1255,6 +1476,16 @@ async function waitNativeSentinelCloseout( }; } +async function waitNativeFollowerCloseout( + registry: BranchFollowerRegistry, + follower: FollowerSpec, + observedSha: string, + options: ParsedOptions, + timeoutSeconds: number, +): Promise { + return await waitNativeSentinelCloseout(registry, follower, observedSha, options, timeoutSeconds); +} + function nativeCloseoutSummary(live: AdapterSummary): Record { const payload = asOptionalRecord(live.payload); return { @@ -1340,6 +1571,153 @@ function runNativeTektonPipelineRun(namespace: string, pipelineRun: string, mani return runCommand(["sh", "-lc", script], repoRoot, { timeoutMs: Math.max(5, timeoutSeconds + 10) * 1000 }); } +function runNativeK8sJob(namespace: string, jobName: string, manifest: Record, timeoutSeconds: number, logContainer: string): NativeK8sJobResult { + const manifestBase64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64"); + const script = [ + "set -eu", + "tmpdir=$(mktemp -d)", + "cleanup() { rm -rf \"$tmpdir\"; }", + "trap cleanup EXIT INT TERM", + "cat >\"$tmpdir/manifest.b64\" <<'UNIDESK_NATIVE_JOB_B64'", + manifestBase64, + "UNIDESK_NATIVE_JOB_B64", + `NAMESPACE=${shQuote(namespace)}`, + `JOB_NAME=${shQuote(jobName)}`, + `LOG_CONTAINER=${shQuote(logContainer)}`, + `TIMEOUT_SECONDS=${shQuote(String(timeoutSeconds))}`, + "export NAMESPACE JOB_NAME LOG_CONTAINER TIMEOUT_SECONDS", + "cat >\"$tmpdir/native-job.mjs\" <<'NODE_NATIVE_JOB'", + nativeK8sJobNodeScript(), + "NODE_NATIVE_JOB", + "node \"$tmpdir/native-job.mjs\" \"$tmpdir/manifest.b64\"", + ].join("\n"); + const result = runCommand(["sh", "-lc", script], repoRoot, { timeoutMs: Math.max(5, timeoutSeconds + 10) * 1000 }); + const parsed = result.exitCode === 0 ? parseJsonObject(result.stdout) : null; + return { + ok: result.exitCode === 0 && parsed?.ok === true, + completed: parsed?.completed === true, + failed: parsed?.failed === true || result.exitCode !== 0, + timedOut: parsed?.timedOut === true || result.timedOut, + created: parsed?.created === true, + reused: parsed?.reused === true, + jobName, + namespace, + polls: numberOrNull(parsed?.polls) ?? 0, + elapsedMs: numberOrNull(parsed?.elapsedMs) ?? 0, + logsTail: stringOrNull(parsed?.logsTail), + conditionReason: stringOrNull(parsed?.conditionReason), + conditionMessage: stringOrNull(parsed?.conditionMessage) ?? (result.exitCode === 0 ? null : tailText(result.stderr || result.stdout, 500)), + statusAuthority: "kubernetes-api-serviceaccount", + parsedDownstreamCliOutput: false, + }; +} + +function nativeK8sJobNodeScript(): string { + return [ + "import { readFileSync } from 'node:fs';", + "import https from 'node:https';", + "const manifestPath = process.argv[2];", + "const namespace = process.env.NAMESPACE || '';", + "const jobName = process.env.JOB_NAME || '';", + "const logContainer = process.env.LOG_CONTAINER || '';", + "const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS || '60');", + "const host = process.env.KUBERNETES_SERVICE_HOST;", + "const port = Number(process.env.KUBERNETES_SERVICE_PORT || '443');", + "const token = readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/token', 'utf8').trim();", + "const ca = readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt');", + "const manifest = JSON.parse(Buffer.from(readFileSync(manifestPath, 'utf8').replace(/\\s+/g, ''), 'base64').toString('utf8'));", + "function request(method, path, body, contentType = 'application/json') {", + " return new Promise((resolve, reject) => {", + " const headers = { authorization: `Bearer ${token}` };", + " const payload = body === undefined ? null : typeof body === 'string' ? body : JSON.stringify(body);", + " if (payload !== null) { headers['content-type'] = contentType; headers['content-length'] = Buffer.byteLength(payload); }", + " const req = https.request({ host, port, path, method, ca, headers }, (res) => {", + " let text = '';", + " res.setEncoding('utf8');", + " res.on('data', (chunk) => { text += chunk; });", + " res.on('end', () => resolve({ status: res.statusCode || 0, text }));", + " });", + " req.on('error', reject);", + " if (payload !== null) req.write(payload);", + " req.end();", + " });", + "}", + "function parse(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }", + "function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }", + "function condition(job, type) { return (Array.isArray(job?.status?.conditions) ? job.status.conditions : []).find((item) => item?.type === type && item?.status === 'True') || null; }", + "async function getJob() {", + " const result = await request('GET', `/apis/batch/v1/namespaces/${encodeURIComponent(namespace)}/jobs/${encodeURIComponent(jobName)}`);", + " if (result.status === 404) return null;", + " if (result.status < 200 || result.status >= 300) throw new Error(result.text || `kube api GET job status ${result.status}`);", + " return parse(result.text);", + "}", + "async function podNames() {", + " const selector = encodeURIComponent(`job-name=${jobName}`);", + " const result = await request('GET', `/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?labelSelector=${selector}`);", + " if (result.status < 200 || result.status >= 300) return [];", + " const list = parse(result.text);", + " return (Array.isArray(list?.items) ? list.items : []).map((pod) => pod?.metadata?.name).filter(Boolean);", + "}", + "async function logsTail() {", + " const names = await podNames();", + " let combined = '';", + " for (const pod of names.slice(-2)) {", + " const container = logContainer ? `&container=${encodeURIComponent(logContainer)}` : '';", + " const result = await request('GET', `/api/v1/namespaces/${encodeURIComponent(namespace)}/pods/${encodeURIComponent(pod)}/log?tailLines=120${container}`);", + " if (result.status >= 200 && result.status < 300) combined += `${result.text}\\n`;", + " }", + " return combined.length > 6000 ? combined.slice(-6000) : combined;", + "}", + "let created = false;", + "let reused = false;", + "let existing = await getJob();", + "if (existing) {", + " reused = true;", + "} else {", + " const result = await request('POST', `/apis/batch/v1/namespaces/${encodeURIComponent(namespace)}/jobs`, manifest);", + " if (result.status === 409) reused = true;", + " else if (result.status >= 200 && result.status < 300) created = true;", + " else { process.stderr.write(result.text || `kube api POST job status ${result.status}`); process.exit(1); }", + "}", + "const startedAt = Date.now();", + "const deadline = startedAt + Math.max(1, timeoutSeconds) * 1000;", + "let polls = 0;", + "let latest = await getJob();", + "while (Date.now() <= deadline) {", + " const complete = condition(latest, 'Complete');", + " const failed = condition(latest, 'Failed');", + " if (complete || failed) break;", + " polls += 1;", + " await delay(2000);", + " latest = await getJob();", + "}", + "const complete = condition(latest, 'Complete');", + "const failed = condition(latest, 'Failed');", + "const logs = await logsTail();", + "const timedOut = !complete && !failed;", + "const output = {", + " ok: Boolean(complete) && !timedOut,", + " completed: Boolean(complete),", + " failed: Boolean(failed),", + " timedOut,", + " created,", + " reused,", + " jobName,", + " namespace,", + " polls,", + " elapsedMs: Date.now() - startedAt,", + " conditionReason: complete?.reason || failed?.reason || null,", + " conditionMessage: complete?.message || failed?.message || null,", + " logsTail: logs || null,", + " statusAuthority: 'kubernetes-api-serviceaccount',", + " parsedDownstreamCliOutput: false,", + " valuesRedacted: true,", + "};", + "process.stdout.write(JSON.stringify(output));", + "if (!output.ok) process.exit(1);", + ].join("\n"); +} + function nativeTektonPipelineRunNodeScript(): string { return [ "import { readFileSync } from 'node:fs';", @@ -1990,7 +2368,7 @@ function kubeConfigMapFollowerState(registry: BranchFollowerRegistry, options: P function kubeConfigMapDataValue(registry: BranchFollowerRegistry, options: ParsedOptions, key: string): { ok: boolean; present: boolean; value: string | null; omitted: boolean; error: string } { const template = `{{ with index .data ${JSON.stringify(key)} }}{{ . }}{{ end }}`; - const maxValueBytes = 4096; + const maxValueBytes = 6144; const script = [ "set -eu", "tmpdir=$(mktemp -d)", @@ -2117,7 +2495,7 @@ function compactFollowerStateForConfigMap(state: FollowerState): Record | undefined): Reco }; } +function compactStateWarnings(warnings: string[]): string[] { + return warnings.slice(0, 4).map((item) => redactText(tailText(item, 400))); +} + function compactNativePayload(payload: Record | null): Record | null { if (payload === null) return null; return { @@ -2391,7 +2773,7 @@ function renderControllerManifests(registry: BranchFollowerRegistry): Record