cicd branch follower native closeout
This commit is contained in:
@@ -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));
|
||||
@@ -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)"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user