cicd branch follower native closeout

This commit is contained in:
Codex
2026-07-03 11:00:11 +00:00
parent a70bc3e196
commit 9a3d665303
27 changed files with 1884 additions and 1066 deletions
@@ -0,0 +1,47 @@
import { execFileSync } from "node:child_process";
const repoPath = process.env.REPO_PATH || "";
const repository = process.env.REPOSITORY || "";
const sourceBranch = process.env.SOURCE_BRANCH || "";
const snapshotPrefix = (process.env.SNAPSHOT_PREFIX || "").replace(/\/+$/u, "");
const gitopsBranch = process.env.GITOPS_BRANCH || "";
function rev(ref) {
if (!ref) return null;
try {
const out = execFileSync("git", [`--git-dir=${repoPath}`, "rev-parse", "--verify", `${ref}^{commit}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
return /^[0-9a-f]{40}$/iu.test(out) ? out : null;
} catch {
return null;
}
}
const localSource = rev(`refs/heads/${sourceBranch}`);
const githubSource = rev(`refs/mirror-stage/heads/${sourceBranch}`);
const snapshotSource = githubSource || localSource;
const sourceStageRef = snapshotSource ? `${snapshotPrefix}/${snapshotSource}` : null;
const sourceSnapshot = sourceStageRef === null ? null : rev(sourceStageRef);
const localGitops = gitopsBranch ? rev(`refs/heads/${gitopsBranch}`) : null;
const githubGitops = gitopsBranch ? rev(`refs/mirror-stage/heads/${gitopsBranch}`) : null;
const pendingFlush = gitopsBranch ? Boolean(localGitops && localGitops !== githubGitops) : null;
const githubInSync = gitopsBranch ? Boolean(!localGitops || localGitops === githubGitops) : null;
const sourceSnapshotReady = snapshotSource ? sourceSnapshot === snapshotSource : false;
process.stdout.write(JSON.stringify({
ok: Boolean(localSource) && sourceSnapshotReady && pendingFlush !== true,
repository,
repoPath,
sourceBranch,
gitopsBranch: gitopsBranch || null,
localSource,
githubSource,
sourceStageRef,
sourceSnapshot,
sourceSnapshotReady,
localGitops,
githubGitops,
pendingFlush,
githubInSync,
statusAuthority: "k8s-git-mirror-cache",
valuesRedacted: true,
}));
@@ -0,0 +1,129 @@
import { readFileSync } from "node:fs";
const key = process.argv[2] || "";
const input = JSON.parse(readFileSync(0, "utf8"));
function cleanMap(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out = {};
for (const [k, v] of Object.entries(value)) {
if (k === "kubectl.kubernetes.io/last-applied-configuration") continue;
out[k] = v;
}
return out;
}
function metadata(obj) {
return {
name: obj?.metadata?.name || null,
namespace: obj?.metadata?.namespace || null,
labels: cleanMap(obj?.metadata?.labels),
annotations: cleanMap(obj?.metadata?.annotations),
};
}
function compactContainer(container) {
return {
name: container?.name || null,
image: container?.image || null,
env: Array.isArray(container?.env)
? container.env.filter((item) => item && typeof item.name === "string" && typeof item.value === "string").map((item) => ({ name: item.name, value: item.value }))
: [],
};
}
function condition(obj, type) {
const conditions = Array.isArray(obj?.status?.conditions) ? obj.status.conditions : [];
return conditions.find((item) => item?.type === type) || conditions[0] || null;
}
function timestampMs(value) {
const parsed = Date.parse(String(value || ""));
return Number.isFinite(parsed) ? parsed : null;
}
function durationSeconds(start, end) {
const s = timestampMs(start);
const e = timestampMs(end);
return s === null || e === null || e < s ? null : Math.round((e - s) / 1000);
}
let output = input;
if (key === "pipelineRun") {
const succeeded = condition(input, "Succeeded");
output = {
apiVersion: input.apiVersion,
kind: input.kind,
metadata: metadata(input),
spec: { params: Array.isArray(input?.spec?.params) ? input.spec.params : [] },
status: {
conditions: Array.isArray(input?.status?.conditions) ? input.status.conditions : [],
startTime: input?.status?.startTime || null,
completionTime: input?.status?.completionTime || null,
durationSeconds: durationSeconds(input?.status?.startTime, input?.status?.completionTime),
succeeded: succeeded?.status || null,
reason: succeeded?.reason || null,
},
};
} else if (key === "taskRuns") {
const items = (Array.isArray(input?.items) ? input.items : []).map((item) => {
const succeeded = condition(item, "Succeeded");
return {
name: item?.metadata?.name || null,
namespace: item?.metadata?.namespace || null,
pipelineTask: item?.metadata?.labels?.["tekton.dev/pipelineTask"] || item?.metadata?.labels?.["tekton.dev/task"] || null,
status: succeeded?.status || null,
reason: succeeded?.reason || null,
startTime: item?.status?.startTime || null,
completionTime: item?.status?.completionTime || null,
durationSeconds: durationSeconds(item?.status?.startTime, item?.status?.completionTime),
};
}).sort((left, right) => String(left.startTime || "").localeCompare(String(right.startTime || "")));
const slow = items.filter((item) => typeof item.durationSeconds === "number" && item.durationSeconds > 60);
output = {
ok: true,
count: items.length,
succeededCount: items.filter((item) => item.status === "True").length,
failedCount: items.filter((item) => item.status === "False").length,
activeCount: items.filter((item) => item.status !== "True" && item.status !== "False").length,
items,
performance: { slowCount: slow.length, slowTaskRuns: slow.slice(0, 8), warning: slow.length > 0 ? "taskrun-over-60s" : null },
statusAuthority: "kubernetes-api-serviceaccount",
};
} else if (key === "argoApplication") {
output = {
apiVersion: input.apiVersion,
kind: input.kind,
metadata: metadata(input),
status: {
sync: input?.status?.sync || null,
health: input?.status?.health || null,
operationState: input?.status?.operationState
? { phase: input.status.operationState.phase || null, message: input.status.operationState.message || null, finishedAt: input.status.operationState.finishedAt || null }
: null,
},
};
} else if (/^workload\d+$/.test(key)) {
const template = input?.spec?.template || {};
output = {
apiVersion: input.apiVersion,
kind: input.kind,
metadata: metadata(input),
spec: {
replicas: input?.spec?.replicas ?? null,
template: {
metadata: { labels: cleanMap(template?.metadata?.labels), annotations: cleanMap(template?.metadata?.annotations) },
spec: { containers: Array.isArray(template?.spec?.containers) ? template.spec.containers.map(compactContainer) : [] },
},
},
status: {
replicas: input?.status?.replicas ?? null,
readyReplicas: input?.status?.readyReplicas ?? null,
availableReplicas: input?.status?.availableReplicas ?? null,
updatedReplicas: input?.status?.updatedReplicas ?? null,
conditions: Array.isArray(input?.status?.conditions) ? input.status.conditions.map((item) => ({ type: item.type || null, status: item.status || null, reason: item.reason || null })) : [],
},
};
}
console.log(JSON.stringify(output));
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
interval="${UNIDESK_CICD_BRANCH_FOLLOWER_INTERVAL_SECONDS}"
timeout="${UNIDESK_CICD_BRANCH_FOLLOWER_TIMEOUT_SECONDS}"
while true; do
started_at=$(date -Iseconds)
echo "branch-follower loop started ${started_at}"
cd /work
rm -rf /work/unidesk
/etc/unidesk-cicd-branch-follower/sync-source.sh \
"${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}" \
"${UNIDESK_CONTROLLER_SOURCE_BRANCH}" \
"${UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX}" \
"/cache/${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}.git"
git clone --branch "${UNIDESK_CONTROLLER_SOURCE_BRANCH}" "/cache/${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}.git" /work/unidesk
cp /etc/unidesk-cicd-branch-follower/cicd-branch-followers.yaml /work/unidesk/config/cicd-branch-followers.yaml
cd /work/unidesk
bun scripts/cli.ts cicd branch-follower run-once --all --confirm --controller --config config/cicd-branch-followers.yaml --timeout-seconds "${timeout}" || true
echo "branch-follower loop finished $(date -Iseconds)"
cd /work
sleep "${interval}"
done
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
cd /work
rm -rf /work/unidesk
started_at=$(date -Iseconds)
echo "branch-follower one-shot started ${started_at}"
/etc/unidesk-cicd-branch-follower/sync-source.sh \
"${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}" \
"${UNIDESK_CONTROLLER_SOURCE_BRANCH}" \
"${UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX}" \
"/cache/${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}.git"
git clone --branch "${UNIDESK_CONTROLLER_SOURCE_BRANCH}" "/cache/${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}.git" /work/unidesk
cp /etc/unidesk-cicd-branch-follower/cicd-branch-followers.yaml /work/unidesk/config/cicd-branch-followers.yaml
cd /work/unidesk
"$@"
echo "branch-follower one-shot finished $(date -Iseconds)"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
exec ssh \
-i /root/.ssh/id_rsa \
-o IdentitiesOnly=yes \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile=/root/.ssh/known_hosts \
-o ConnectTimeout=15 \
-o ServerAliveInterval=5 \
-o ServerAliveCountMax=1 \
-o "ProxyCommand=node /etc/unidesk-cicd-branch-follower/github-proxy-connect.mjs ${UNIDESK_CONTROLLER_GITHUB_PROXY_HOST} ${UNIDESK_CONTROLLER_GITHUB_PROXY_PORT} %h %p" \
"$@"
@@ -0,0 +1,51 @@
#!/usr/bin/env node
import net from "node:net";
const [proxyHost, proxyPortRaw, targetHost, targetPortRaw] = process.argv.slice(2);
const proxyPort = Number.parseInt(proxyPortRaw || "", 10);
const targetPort = Number.parseInt(targetPortRaw || "", 10);
if (!proxyHost || !Number.isInteger(proxyPort) || !targetHost || !Number.isInteger(targetPort)) process.exit(64);
let settled = false;
let tunnel = false;
function finish(code) {
if (settled) return;
settled = true;
process.exit(code);
}
const socket = net.createConnection({ host: proxyHost, port: proxyPort });
let buffer = Buffer.alloc(0);
socket.setTimeout(15000, () => {
socket.destroy();
finish(65);
});
socket.on("connect", () => socket.write(`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\nProxy-Connection: Keep-Alive\r\n\r\n`));
socket.on("error", () => finish(tunnel ? 69 : 66));
socket.on("close", () => finish(tunnel ? 0 : 68));
socket.on("data", function onData(chunk) {
buffer = Buffer.concat([buffer, chunk]);
const headerEnd = buffer.indexOf("\r\n\r\n");
if (headerEnd === -1 && buffer.length < 8192) return;
if (headerEnd === -1) {
socket.destroy();
finish(68);
return;
}
const statusLine = buffer.slice(0, headerEnd).toString("latin1").split("\r\n", 1)[0] || "";
const statusCode = Number.parseInt(statusLine.split(" ")[1] || "", 10);
if (!statusLine.startsWith("HTTP/1.") || !Number.isInteger(statusCode) || statusCode < 200 || statusCode > 299) {
socket.destroy();
finish(67);
return;
}
socket.off("data", onData);
socket.setTimeout(0);
tunnel = true;
const rest = buffer.slice(headerEnd + 4);
if (rest.length) process.stdout.write(rest);
process.stdin.on("error", () => {});
process.stdout.on("error", () => {});
process.stdin.pipe(socket);
socket.pipe(process.stdout);
});
+27
View File
@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import https from "node:https";
const path = process.argv[2];
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 req = https.request({ host, port, path, method: "GET", ca, headers: { authorization: `Bearer ${token}` } }, (res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => { body += chunk; });
res.on("end", () => {
if ((res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300) {
process.stdout.write(body);
process.exit(0);
}
process.stderr.write(body || `kube api status ${res.statusCode}`);
process.exit(1);
});
});
req.on("error", (error) => {
process.stderr.write(error?.message || String(error));
process.exit(1);
});
req.end();
+127
View File
@@ -0,0 +1,127 @@
import { readFileSync } from "node:fs";
import https from "node:https";
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(0, "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;
const 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);
+96
View File
@@ -0,0 +1,96 @@
import { readFileSync } from "node:fs";
import https from "node:https";
const namespace = process.argv[2] || "";
const pipelineRun = process.argv[3] || "";
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");
function request(path) {
return new Promise((resolve) => {
const req = https.request({ host, port, path, method: "GET", ca, headers: { authorization: `Bearer ${token}` } }, (res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => { body += chunk; });
res.on("end", () => resolve({ status: res.statusCode || 0, body }));
});
req.on("error", (error) => resolve({ status: 599, body: error?.message || String(error) }));
req.end();
});
}
function parse(text) {
try {
return text ? JSON.parse(text) : null;
} catch {
return null;
}
}
function strings(value) {
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
}
const out = {
ok: false,
pipelineRun,
pods: [],
eventFound: false,
degradedReason: "plan-artifacts-log-query-skipped",
statusAuthority: "kubernetes-api-serviceaccount",
};
if (!namespace || !pipelineRun) {
process.stdout.write(JSON.stringify(out));
process.exit(0);
}
const selector = encodeURIComponent(`tekton.dev/pipelineRun=${pipelineRun}`);
const podsResult = await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?labelSelector=${selector}`);
const pods = parse(podsResult.body);
const items = Array.isArray(pods?.items) ? pods.items : [];
const planPods = items.filter((pod) => {
const labels = pod?.metadata?.labels || {};
const name = pod?.metadata?.name || "";
return labels["tekton.dev/pipelineTask"] === "plan-artifacts" || labels["tekton.dev/task"] === "plan-artifacts" || /plan-artifacts/u.test(name);
});
out.pods = planPods.map((pod) => pod?.metadata?.name).filter(Boolean);
const events = [];
for (const podName of out.pods.slice(-4)) {
const log = await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/pods/${encodeURIComponent(podName)}/log?tailLines=240`);
if (log.status < 200 || log.status >= 300) continue;
for (const raw of log.body.split(/\r?\n/u)) {
const line = raw.trim();
if (!line.startsWith("{")) continue;
const event = parse(line);
if (event?.event === "g14-ci-plan") events.push(event);
}
}
const latest = events.at(-1) || null;
if (latest) {
const audit = latest.artifactProvenanceAudit && typeof latest.artifactProvenanceAudit === "object" ? latest.artifactProvenanceAudit : null;
const unsafeReuseServices = strings(audit?.unsafeReuseServices);
const provenanceRebuildServices = strings(audit?.provenanceRebuildServices);
Object.assign(out, {
ok: true,
eventFound: true,
degradedReason: null,
sourceCommitId: typeof latest.sourceCommitId === "string" ? latest.sourceCommitId : null,
affectedServices: strings(latest.affectedServices),
rolloutServices: strings(latest.rolloutServices),
buildServices: strings(latest.buildServices),
reusedServices: strings(latest.reusedServices),
buildSkippedCount: typeof latest.buildSkippedCount === "number" ? latest.buildSkippedCount : null,
artifactProvenanceAudit: audit,
summary: `build=${strings(latest.buildServices).length} reuse=${strings(latest.reusedServices).length} unsafeReuse=${unsafeReuseServices.length} provenanceRebuild=${provenanceRebuildServices.length}`,
disclosure: "parsed from plan-artifacts g14-ci-plan log event via Kubernetes pod logs",
});
} else {
out.degradedReason = podsResult.status >= 200 && podsResult.status < 300 ? "g14-ci-plan-event-not-found" : "plan-artifacts-pod-list-failed";
}
process.stdout.write(JSON.stringify(out));
+114
View File
@@ -0,0 +1,114 @@
#!/bin/sh
set +e
tmpdir=$(mktemp -d)
cleanup() { rm -rf "${tmpdir}"; }
trap cleanup EXIT INT TERM
script_dir="${NATIVE_CICD_SCRIPT_DIR}"
repo_path="${REPO_PATH}"
branch="${SOURCE_BRANCH}"
repository="${REPOSITORY}"
snapshot_prefix="${SNAPSHOT_PREFIX}"
gitops_branch="${GITOPS_BRANCH:-}"
tekton_namespace="${TEKTON_NAMESPACE:-}"
pipeline_run_prefix="${PIPELINE_RUN_PREFIX:-}"
argo_namespace="${ARGO_NAMESPACE:-}"
argo_application="${ARGO_APPLICATION:-}"
emit_file_b64() {
key="$1"
path="$2"
printf 'UNIDESK_NATIVE_JSON\t%s\t' "${key}"
base64 "${path}" | tr -d '\n'
printf '\n'
}
emit_error_b64() {
key="$1"
path="$2"
printf 'UNIDESK_NATIVE_ERROR\t%s\t' "${key}"
base64 "${path}" | tr -d '\n'
printf '\n'
}
emit_kube_json() {
key="$1"
path="$2"
raw="${tmpdir}/${key}.raw"
out="${tmpdir}/${key}.out"
err="${tmpdir}/${key}.err"
if node "${script_dir}/kube-get.mjs" "${path}" >"${raw}" 2>"${err}" && node "${script_dir}/compact-native-object.mjs" "${key}" <"${raw}" >"${out}" 2>>"${err}"; then
emit_file_b64 "${key}" "${out}"
else
emit_error_b64 "${key}" "${err}"
fi
}
emit_plan_artifacts() {
namespace="$1"
pipeline_run="$2"
out="${tmpdir}/planArtifacts.out"
err="${tmpdir}/planArtifacts.err"
if node "${script_dir}/plan-artifacts.mjs" "${namespace}" "${pipeline_run}" >"${out}" 2>"${err}"; then
emit_file_b64 planArtifacts "${out}"
else
emit_error_b64 planArtifacts "${err}"
fi
}
source_commit=
source_err="${tmpdir}/source.err"
if [ -x /etc/unidesk-cicd-branch-follower/sync-source.sh ]; then
/etc/unidesk-cicd-branch-follower/sync-source.sh "${repository}" "${branch}" "${snapshot_prefix}" "${repo_path}" >/dev/null 2>"${source_err}" || true
fi
if [ -d "${repo_path}/objects" ]; then
source_commit=$(git --git-dir="${repo_path}" rev-parse --verify "refs/heads/${branch}^{commit}" 2>>"${source_err}" | head -n 1 | tr -d '\r' || true)
else
printf 'formal controller/job must mount k8s git-mirror cache at %s; fallback exec is disabled\n' "${repo_path}" >>"${source_err}"
fi
case "${source_commit}" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
stage_ref="${snapshot_prefix%/}/${source_commit}"
source_out="${tmpdir}/source.out"
printf '{"commit":"%s","branch":"%s","stageRef":"%s","sourceAuthority":"k8s-git-mirror-snapshot","mode":"k8s-git-mirror-cache","repoPath":"%s"}' "${source_commit}" "${branch}" "${stage_ref}" "${repo_path}" >"${source_out}"
emit_file_b64 source "${source_out}"
;;
*)
emit_error_b64 source "${source_err}"
;;
esac
if [ -d "${repo_path}/objects" ]; then
git_mirror_out="${tmpdir}/gitMirror.out"
git_mirror_err="${tmpdir}/gitMirror.err"
if REPO_PATH="${repo_path}" REPOSITORY="${repository}" SOURCE_BRANCH="${branch}" SNAPSHOT_PREFIX="${snapshot_prefix}" GITOPS_BRANCH="${gitops_branch}" node "${script_dir}/compact-git-mirror.mjs" >"${git_mirror_out}" 2>"${git_mirror_err}"; then
emit_file_b64 gitMirror "${git_mirror_out}"
else
emit_error_b64 gitMirror "${git_mirror_err}"
fi
fi
if [ -n "${source_commit}" ] && [ -n "${tekton_namespace}" ] && [ -n "${pipeline_run_prefix}" ]; then
sha12=$(printf '%s' "${source_commit}" | cut -c1-12)
pipeline_run="${pipeline_run_prefix}-${sha12}"
emit_kube_json pipelineRun "/apis/tekton.dev/v1/namespaces/${tekton_namespace}/pipelineruns/${pipeline_run}"
emit_kube_json taskRuns "/apis/tekton.dev/v1/namespaces/${tekton_namespace}/taskruns?labelSelector=tekton.dev%2FpipelineRun%3D${pipeline_run}"
emit_plan_artifacts "${tekton_namespace}" "${pipeline_run}"
fi
if [ -n "${argo_namespace}" ] && [ -n "${argo_application}" ]; then
emit_kube_json argoApplication "/apis/argoproj.io/v1alpha1/namespaces/${argo_namespace}/applications/${argo_application}"
fi
if [ -n "${WORKLOAD_REFS_B64:-}" ]; then
printf '%s' "${WORKLOAD_REFS_B64}" | base64 -d | while IFS="$(printf '\t')" read -r key path; do
[ -n "${key}" ] || continue
[ -n "${path}" ] || continue
emit_kube_json "${key}" "${path}"
done
fi
exit 0
+125
View File
@@ -0,0 +1,125 @@
import { readFileSync } from "node:fs";
import https from "node:https";
const namespace = process.env.NAMESPACE || "";
const pipelineRun = process.env.PIPELINERUN || "";
const shouldWait = process.env.WAIT === "true";
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(0, "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 parseBody(result) {
if (!result.text) return null;
try {
return JSON.parse(result.text);
} catch {
return null;
}
}
async function getPipelineRun() {
const result = await request("GET", `/apis/tekton.dev/v1/namespaces/${encodeURIComponent(namespace)}/pipelineruns/${encodeURIComponent(pipelineRun)}`);
if (result.status === 404) return { found: false, object: null, status: result.status, text: result.text };
if (result.status < 200 || result.status >= 300) throw new Error(result.text || `kube api GET pipelinerun status ${result.status}`);
return { found: true, object: parseBody(result), status: result.status, text: result.text };
}
function succeededCondition(object) {
const conditions = Array.isArray(object?.status?.conditions) ? object.status.conditions : [];
return conditions.find((item) => item && item.type === "Succeeded") || null;
}
function compact(object) {
const condition = succeededCondition(object);
return {
name: object?.metadata?.name || pipelineRun,
namespace: object?.metadata?.namespace || namespace,
generation: object?.metadata?.generation ?? null,
startTime: object?.status?.startTime || null,
completionTime: object?.status?.completionTime || null,
conditionStatus: condition?.status || null,
conditionReason: condition?.reason || null,
conditionMessage: condition?.message || null,
};
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
let created = false;
let reused = false;
let latest = await getPipelineRun();
if (latest.found) {
reused = true;
} else {
const result = await request("POST", `/apis/tekton.dev/v1/namespaces/${encodeURIComponent(namespace)}/pipelineruns`, 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 pipelinerun status ${result.status}`);
process.exit(1);
}
latest = await getPipelineRun();
}
const deadline = Date.now() + Math.max(1, timeoutSeconds) * 1000;
let polls = 0;
while (shouldWait) {
const condition = succeededCondition(latest.object);
if (condition?.status === "True" || condition?.status === "False") break;
if (Date.now() >= deadline) break;
polls += 1;
process.stderr.write(JSON.stringify({ event: "cicd.branch-follower.native-tekton.wait", pipelineRun, namespace, polls, conditionStatus: condition?.status || null, valuesRedacted: true }) + "\n");
await delay(2000);
latest = await getPipelineRun();
}
const condition = succeededCondition(latest.object);
const completed = condition?.status === "True";
const failed = condition?.status === "False";
const terminal = completed || failed;
const output = {
ok: !failed,
submitted: true,
created,
reused,
wait: shouldWait,
polls,
completed,
failed,
terminal,
stillRunning: !terminal,
timedOutWait: shouldWait && !terminal,
pipelineRun: compact(latest.object),
statusAuthority: "kubernetes-api-serviceaccount",
parsedDownstreamCliOutput: false,
valuesRedacted: true,
};
process.stdout.write(JSON.stringify(output));
if (failed) process.exit(1);
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
set -eu
repository="$1"
branch="$2"
snapshot_prefix="$3"
repo_path="$4"
private_key="${UNIDESK_CONTROLLER_GITHUB_SSH_PRIVATE_KEY}"
proxy_host="${UNIDESK_CONTROLLER_GITHUB_PROXY_HOST}"
proxy_port="${UNIDESK_CONTROLLER_GITHUB_PROXY_PORT}"
mkdir -p "$(dirname "${repo_path}")" /root/.ssh
cp "${private_key}" /root/.ssh/id_rsa
chmod 0400 /root/.ssh/id_rsa
touch /root/.ssh/known_hosts
test -n "${proxy_host}"
test -n "${proxy_port}"
export GIT_SSH=/etc/unidesk-cicd-branch-follower/git-ssh-proxy.sh
unset GIT_SSH_COMMAND
remote="ssh://git@ssh.github.com:443/${repository}.git"
if [ -d "${repo_path}/objects" ] && [ -f "${repo_path}/HEAD" ]; then
git --git-dir="${repo_path}" remote set-url origin "${remote}" || git --git-dir="${repo_path}" remote add origin "${remote}"
else
rm -rf "${repo_path}"
git init --bare "${repo_path}" >/dev/null
git --git-dir="${repo_path}" remote add origin "${remote}"
fi
git --git-dir="${repo_path}" config uploadpack.allowReachableSHA1InWant true
git --git-dir="${repo_path}" config uploadpack.allowAnySHA1InWant true
timeout 30 git --git-dir="${repo_path}" fetch --quiet --prune origin "+refs/heads/${branch}:refs/mirror-stage/heads/${branch}"
source_sha=$(git --git-dir="${repo_path}" rev-parse --verify "refs/mirror-stage/heads/${branch}^{commit}")
git --git-dir="${repo_path}" update-ref "refs/heads/${branch}" "${source_sha}"
if [ -n "${snapshot_prefix}" ]; then
git --git-dir="${repo_path}" update-ref "${snapshot_prefix%/}/${source_sha}" "${source_sha}"
fi
git --git-dir="${repo_path}" update-server-info
printf '{"event":"unidesk-cicd-git-mirror-sync","repository":"%s","branch":"%s","commit":"%s","sourceAuthority":"k8s-git-mirror-cache"}\n' "${repository}" "${branch}" "${source_sha}"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
deadline=$(( $(date +%s) + ${TIMEOUT_SECONDS} ))
while true; do
job_json=$(kubectl -n "${NAMESPACE}" get job "${JOB_NAME}" -o json)
phase=$(printf '%s' "${job_json}" | node -e "let s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{const j=JSON.parse(s);const c=j.status?.conditions||[];const done=c.find(x=>x.type==='Complete'&&x.status==='True');const failed=c.find(x=>x.type==='Failed'&&x.status==='True');process.stdout.write(done?'complete':failed?'failed':'running');})")
if [ "${phase}" = complete ]; then exit 0; fi
if [ "${phase}" = failed ]; then exit 1; fi
if [ "$(date +%s)" -ge "${deadline}" ]; then exit 124; fi
sleep 2
done