fix: trigger branch followers natively
This commit is contained in:
@@ -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<string, unknown> {
|
||||
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, unknown>): string {
|
||||
|
||||
+387
-4
@@ -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<TriggerResult> {
|
||||
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<TriggerResult> {
|
||||
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<TriggerResult> {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>;
|
||||
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<TriggerResult> {
|
||||
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<NativeCloseoutWaitResult> {
|
||||
return await waitNativeSentinelCloseout(registry, follower, observedSha, options, timeoutSeconds);
|
||||
}
|
||||
|
||||
function nativeCloseoutSummary(live: AdapterSummary): Record<string, unknown> {
|
||||
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<string, unknown>, 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<string,
|
||||
decision: state.decision,
|
||||
dryRun: state.dryRun,
|
||||
updatedAt: state.updatedAt,
|
||||
warnings: state.warnings.slice(0, 6),
|
||||
warnings: compactStateWarnings(state.warnings),
|
||||
stateFormat: "compact-v1",
|
||||
command: compactStateCommand(state.command),
|
||||
};
|
||||
@@ -2157,6 +2535,10 @@ function compactStateCommand(command: Record<string, unknown> | undefined): Reco
|
||||
};
|
||||
}
|
||||
|
||||
function compactStateWarnings(warnings: string[]): string[] {
|
||||
return warnings.slice(0, 4).map((item) => redactText(tailText(item, 400)));
|
||||
}
|
||||
|
||||
function compactNativePayload(payload: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (payload === null) return null;
|
||||
return {
|
||||
@@ -2391,7 +2773,7 @@ function renderControllerManifests(registry: BranchFollowerRegistry): Record<str
|
||||
rules: [
|
||||
{ apiGroups: [""], resources: ["configmaps", "pods", "events"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
||||
{ apiGroups: ["apps"], resources: ["deployments"], verbs: ["get", "list", "watch"] },
|
||||
{ apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
||||
{ apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] },
|
||||
{ apiGroups: ["coordination.k8s.io"], resources: ["leases"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
||||
],
|
||||
},
|
||||
@@ -2409,8 +2791,9 @@ function renderControllerManifests(registry: BranchFollowerRegistry): Record<str
|
||||
rules: [
|
||||
{ apiGroups: [""], resources: ["pods", "pods/log", "configmaps", "events"], verbs: ["get", "list", "watch"] },
|
||||
{ apiGroups: [""], resources: ["pods/exec"], verbs: ["create"] },
|
||||
{ apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch", "create", "delete"] },
|
||||
{ apiGroups: ["apps"], resources: ["deployments", "statefulsets"], verbs: ["get", "list", "watch"] },
|
||||
{ apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "watch", "create"] },
|
||||
{ apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "watch", "create", "patch", "delete"] },
|
||||
{ apiGroups: ["tekton.dev"], resources: ["taskruns"], verbs: ["get", "list", "watch"] },
|
||||
{ apiGroups: ["argoproj.io"], resources: ["applications"], verbs: ["get", "list", "watch", "patch"] },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user