855 lines
35 KiB
TypeScript
855 lines
35 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { runCommand } from "./command";
|
|
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
|
import { startJob } from "./jobs";
|
|
import { coreInternalFetch } from "./microservices";
|
|
|
|
const d601ProviderId = "D601";
|
|
const d601Kubeconfig = "/etc/rancher/k3s/k3s.yaml";
|
|
const tektonPipelineVersion = "v1.12.0";
|
|
const tektonTriggersVersion = "v0.34.0";
|
|
const tektonPipelineReleaseUrl = `https://infra.tekton.dev/tekton-releases/pipeline/previous/${tektonPipelineVersion}/release.yaml`;
|
|
const tektonTriggersReleaseUrl = `https://infra.tekton.dev/tekton-releases/triggers/previous/${tektonTriggersVersion}/release.yaml`;
|
|
const tektonTriggersInterceptorsUrl = `https://infra.tekton.dev/tekton-releases/triggers/previous/${tektonTriggersVersion}/interceptors.yaml`;
|
|
const providerGatewayWsEgressProxyUrl = "http://127.0.0.1:18789";
|
|
const ciCodeQueueImage = "unidesk-code-queue:dev";
|
|
const ciRuntimeImages = [
|
|
"rancher/mirrored-pause:3.6",
|
|
"rancher/mirrored-library-busybox:1.36.1",
|
|
"cgr.dev/chainguard/busybox@sha256:19f02276bf8dbdd62f069b922f10c65262cc34b710eea26ff928129a736be791",
|
|
"ghcr.io/tektoncd/pipeline/entrypoint-bff0a22da108bc2f16c818c97641a296:v1.12.0",
|
|
"ghcr.io/tektoncd/pipeline/workingdirinit-0c558922ec6a1b739e550e349f2d5fc1:v1.12.0",
|
|
"ghcr.io/tektoncd/pipeline/nop-8eac7c133edad5df719dc37b36b62482:v1.12.0",
|
|
"ghcr.io/tektoncd/pipeline/events-a9042f7efb0cbade2a868a1ee5ddd52c:v1.12.0",
|
|
"ghcr.io/tektoncd/triggers/eventlistenersink-7ad1faa98cddbcb0c24990303b220bb8:v0.34.0",
|
|
"oven/bun:1-debian",
|
|
"alpine/git:2.45.2",
|
|
ciCodeQueueImage,
|
|
];
|
|
|
|
interface CiOptions {
|
|
repoUrl: string;
|
|
revision: string;
|
|
waitMs: number;
|
|
}
|
|
|
|
interface CiDevE2EOptions {
|
|
repoUrl: string;
|
|
desiredRef: string;
|
|
deployCommit: string;
|
|
environment: "dev";
|
|
scriptRepo: string;
|
|
scriptPath: string;
|
|
scriptTimeoutMs: number;
|
|
services: Array<{ id: string; commitId: string; repo: string }>;
|
|
runId: string;
|
|
keepNamespace: boolean;
|
|
waitMs: number;
|
|
}
|
|
|
|
interface DispatchResult {
|
|
ok: boolean;
|
|
taskId: string | null;
|
|
status: string | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
exitCode: number | null;
|
|
raw: unknown;
|
|
}
|
|
|
|
interface DeployDevManifestSummary {
|
|
deployCommit: string;
|
|
desiredRef: string;
|
|
environment: "dev";
|
|
ci: {
|
|
repo: string;
|
|
scriptPath: string;
|
|
timeoutMs: number;
|
|
};
|
|
services: Array<{ id: string; commitId: string; repo: string }>;
|
|
}
|
|
|
|
function stringOption(args: string[], name: string): string | null {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) return null;
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function numberOption(args: string[], name: string, fallback: number): number {
|
|
const raw = stringOption(args, name);
|
|
if (raw === null) return fallback;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
|
|
return value;
|
|
}
|
|
|
|
function requireRevision(value: string | null): string {
|
|
if (value === null || value.length === 0) throw new Error("ci run requires --revision <commit-or-ref>");
|
|
if (!/^[A-Za-z0-9._/@:-]{1,160}$/u.test(value)) throw new Error("ci --revision contains unsupported characters");
|
|
return value;
|
|
}
|
|
|
|
function requireDesiredRef(value: string | null): string {
|
|
const ref = value ?? "master";
|
|
if (!/^[A-Za-z0-9._/-]{1,160}$/u.test(ref) || ref.startsWith("-") || ref.includes("..")) {
|
|
throw new Error("ci run-dev-e2e --desired-ref contains unsupported characters");
|
|
}
|
|
return ref;
|
|
}
|
|
|
|
function boolFlag(args: string[], name: string): boolean {
|
|
return args.includes(name);
|
|
}
|
|
|
|
function isHelpArg(value: string | undefined): boolean {
|
|
return value === "help" || value === "--help" || value === "-h";
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/gu, "'\\''")}'`;
|
|
}
|
|
|
|
function chunks(value: string, size: number): string[] {
|
|
const result: string[] = [];
|
|
for (let index = 0; index < value.length; index += size) {
|
|
result.push(value.slice(index, index + size));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
}
|
|
|
|
function asString(value: unknown): string {
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function coreBody(response: unknown): Record<string, unknown> | null {
|
|
return asRecord(asRecord(response)?.body);
|
|
}
|
|
|
|
function positiveManifestNumber(value: unknown, fallback: number, path: string): number {
|
|
if (value === undefined || value === null) return fallback;
|
|
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path} must be a positive integer`);
|
|
return value;
|
|
}
|
|
|
|
function requireManifestString(value: unknown, path: string): string {
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
function requireCiScriptPath(value: unknown): string {
|
|
const scriptPath = requireManifestString(value, "environments.dev.ci.scriptPath");
|
|
if (!scriptPath.startsWith("scripts/ci/") || scriptPath.includes("..") || scriptPath.startsWith("/") || !scriptPath.endsWith(".sh")) {
|
|
throw new Error("environments.dev.ci.scriptPath must be a repo-relative scripts/ci/*.sh path");
|
|
}
|
|
return scriptPath;
|
|
}
|
|
|
|
async function dispatchSsh(command: string, waitMs: number, remoteTimeoutMs: number, pollCompletion = true): Promise<DispatchResult> {
|
|
const dispatchResponse = coreInternalFetch("/api/dispatch", {
|
|
method: "POST",
|
|
body: {
|
|
providerId: d601ProviderId,
|
|
command: "host.ssh",
|
|
payload: {
|
|
source: "ci-cli",
|
|
mode: "exec",
|
|
command,
|
|
timeoutMs: remoteTimeoutMs,
|
|
cwd: "/home/ubuntu",
|
|
},
|
|
},
|
|
});
|
|
const dispatchBody = coreBody(dispatchResponse);
|
|
const taskId = asString(dispatchBody?.taskId);
|
|
if (dispatchBody?.ok !== true || taskId.length === 0) {
|
|
return {
|
|
ok: false,
|
|
taskId: taskId || null,
|
|
status: null,
|
|
stdout: "",
|
|
stderr: asString(dispatchBody?.error) || "dispatch did not return a task id",
|
|
exitCode: null,
|
|
raw: dispatchResponse,
|
|
};
|
|
}
|
|
if (!pollCompletion) {
|
|
return {
|
|
ok: true,
|
|
taskId,
|
|
status: "submitted",
|
|
stdout: "",
|
|
stderr: "",
|
|
exitCode: null,
|
|
raw: dispatchBody,
|
|
};
|
|
}
|
|
const deadline = Date.now() + Math.max(waitMs, 1_000);
|
|
let latest: unknown = null;
|
|
while (Date.now() < deadline) {
|
|
latest = coreInternalFetch(`/api/tasks/${encodeURIComponent(taskId)}`, { maxResponseBytes: 3_000_000 });
|
|
const task = asRecord(coreBody(latest)?.task);
|
|
const status = asString(task?.status);
|
|
if (status === "succeeded" || status === "failed") {
|
|
const result = asRecord(task?.result);
|
|
const exitCode = typeof result?.exitCode === "number" ? result.exitCode : null;
|
|
const stdout = asString(result?.stdout);
|
|
const stderr = asString(result?.stderr);
|
|
return {
|
|
ok: status === "succeeded" && (exitCode === null || exitCode === 0),
|
|
taskId,
|
|
status,
|
|
stdout,
|
|
stderr,
|
|
exitCode,
|
|
raw: task,
|
|
};
|
|
}
|
|
await Bun.sleep(500);
|
|
}
|
|
return {
|
|
ok: false,
|
|
taskId,
|
|
status: "timeout",
|
|
stdout: "",
|
|
stderr: `host.ssh task ${taskId} did not finish within ${Math.max(waitMs, 1_000)}ms`,
|
|
exitCode: null,
|
|
raw: latest,
|
|
};
|
|
}
|
|
|
|
async function runRemoteKubectl(script: string, waitMs = 60_000, remoteTimeoutMs = 45_000): Promise<DispatchResult> {
|
|
const result = await runRemoteKubectlRaw(script, waitMs, remoteTimeoutMs);
|
|
if (!result.ok) {
|
|
throw new Error(`D601 kubectl command failed: ${result.stderr || result.stdout || JSON.stringify(result.raw)}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function runRemoteKubectlRaw(script: string, waitMs = 60_000, remoteTimeoutMs = 45_000): Promise<DispatchResult> {
|
|
const command = [
|
|
"set -euo pipefail",
|
|
`export KUBECONFIG=${shellQuote(d601Kubeconfig)}`,
|
|
script,
|
|
].join("\n");
|
|
return dispatchSsh(command, waitMs, remoteTimeoutMs);
|
|
}
|
|
|
|
async function uploadRemoteBase64(path: string, encoded: string): Promise<DispatchResult> {
|
|
const init = await dispatchSsh([
|
|
"set -euo pipefail",
|
|
`target=${shellQuote(path)}`,
|
|
"rm -f \"$target\"",
|
|
": > \"$target\"",
|
|
"chmod 600 \"$target\"",
|
|
].join("\n"), 20_000, 10_000);
|
|
if (!init.ok) return init;
|
|
for (const chunk of chunks(encoded, 950)) {
|
|
const append = await dispatchSsh([
|
|
"set -euo pipefail",
|
|
`target=${shellQuote(path)}`,
|
|
`printf %s ${shellQuote(chunk)} >> "$target"`,
|
|
].join("\n"), 20_000, 10_000);
|
|
if (!append.ok) return append;
|
|
}
|
|
return dispatchSsh([
|
|
"set -euo pipefail",
|
|
`target=${shellQuote(path)}`,
|
|
"wc -c \"$target\"",
|
|
].join("\n"), 20_000, 10_000);
|
|
}
|
|
|
|
async function runRemoteBackground(label: string, script: string, timeoutMs: number): Promise<DispatchResult> {
|
|
const token = randomUUID().replace(/-/gu, "").slice(0, 12);
|
|
const safeLabel = label.replace(/[^a-z0-9-]/giu, "-").toLowerCase().slice(0, 48);
|
|
const base = `/tmp/unidesk-ci-${safeLabel}-${token}`;
|
|
const scriptPath = `${base}.sh`;
|
|
const logPath = `${base}.log`;
|
|
const donePath = `${base}.done`;
|
|
const encoded = Buffer.from(script, "utf8").toString("base64");
|
|
const upload = await uploadRemoteBase64(`${scriptPath}.b64`, encoded);
|
|
if (!upload.ok) return upload;
|
|
const start = await dispatchSsh([
|
|
"set -euo pipefail",
|
|
`script_path=${shellQuote(scriptPath)}`,
|
|
`log_path=${shellQuote(logPath)}`,
|
|
`done_path=${shellQuote(donePath)}`,
|
|
"rm -f \"$script_path\" \"$log_path\" \"$done_path\"",
|
|
"base64 -d \"$script_path.b64\" > \"$script_path\"",
|
|
"rm -f \"$script_path.b64\"",
|
|
"chmod 700 \"$script_path\"",
|
|
"nohup bash -lc \"bash '$script_path' >'$log_path' 2>&1; code=\\$?; printf '%s\\n' \\\"\\$code\\\" >'$done_path'\" >/tmp/unidesk-ci-nohup.log 2>&1 &",
|
|
"printf 'remote_job_pid=%s\\nlog=%s\\ndone=%s\\n' \"$!\" \"$log_path\" \"$done_path\"",
|
|
].join("\n"), 20_000, 10_000);
|
|
if (!start.ok) return start;
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
let latest: DispatchResult = start;
|
|
while (Date.now() < deadline) {
|
|
await Bun.sleep(8_000);
|
|
latest = await dispatchSsh([
|
|
"set -euo pipefail",
|
|
`log_path=${shellQuote(logPath)}`,
|
|
`done_path=${shellQuote(donePath)}`,
|
|
"if [ -f \"$done_path\" ]; then",
|
|
" code=\"$(cat \"$done_path\" 2>/dev/null || printf 1)\"",
|
|
" printf 'REMOTE_DONE:%s\\n' \"$code\"",
|
|
"else",
|
|
" printf 'REMOTE_RUNNING\\n'",
|
|
"fi",
|
|
"tail -n 160 \"$log_path\" 2>/dev/null || true",
|
|
].join("\n"), 75_000, 12_000);
|
|
if (!latest.ok) {
|
|
if (latest.status === "timeout" || latest.stderr.includes("did not finish within")) {
|
|
continue;
|
|
}
|
|
return latest;
|
|
}
|
|
const firstLine = latest.stdout.split(/\r?\n/u)[0] ?? "";
|
|
if (firstLine.startsWith("REMOTE_DONE:")) {
|
|
const code = Number(firstLine.slice("REMOTE_DONE:".length).trim());
|
|
return {
|
|
...latest,
|
|
ok: code === 0,
|
|
exitCode: Number.isInteger(code) ? code : 1,
|
|
status: code === 0 ? "succeeded" : "failed",
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
...latest,
|
|
ok: false,
|
|
status: "timeout",
|
|
exitCode: 124,
|
|
stderr: `remote background job ${label} did not finish within ${timeoutMs}ms`,
|
|
};
|
|
}
|
|
|
|
async function remoteApplyManifest(path: string): Promise<void> {
|
|
const absolute = rootPath(path);
|
|
if (!existsSync(absolute)) throw new Error(`manifest not found: ${path}`);
|
|
const encoded = Buffer.from(readFileSync(absolute, "utf8"), "utf8").toString("base64");
|
|
const token = randomUUID().replace(/-/gu, "").slice(0, 12);
|
|
const b64Path = `/tmp/unidesk-ci-apply-${token}.b64`;
|
|
const upload = await uploadRemoteBase64(b64Path, encoded);
|
|
if (!upload.ok) throw new Error(`failed to upload manifest ${path}: ${upload.stderr || upload.stdout}`);
|
|
const script = [
|
|
"set -euo pipefail",
|
|
`export KUBECONFIG=${shellQuote(d601Kubeconfig)}`,
|
|
"tmp=$(mktemp /tmp/unidesk-ci-apply.XXXXXX.yaml)",
|
|
`b64_path=${shellQuote(b64Path)}`,
|
|
"trap 'rm -f \"$tmp\" \"$b64_path\"' EXIT",
|
|
"base64 -d \"$b64_path\" > \"$tmp\"",
|
|
"kubectl apply -f \"$tmp\"",
|
|
].join("\n");
|
|
const result = await runRemoteBackground(`apply-${path.split("/").pop() ?? "manifest"}`, script, 180_000);
|
|
if (!result.ok) throw new Error(`kubectl apply failed for ${path}: ${result.stderr || result.stdout}`);
|
|
}
|
|
|
|
async function prewarmCiRuntimeImages(): Promise<void> {
|
|
const images = ciRuntimeImages.map(shellQuote).join(" ");
|
|
const script = [
|
|
"set -euo pipefail",
|
|
`export KUBECONFIG=${shellQuote(d601Kubeconfig)}`,
|
|
"export DOCKER_CONFIG=/tmp/unidesk-ci-docker-config",
|
|
"mkdir -p \"$DOCKER_CONFIG\"",
|
|
"printf '{}\\n' > \"$DOCKER_CONFIG/config.json\"",
|
|
`images=(${images})`,
|
|
"for image in \"${images[@]}\"; do",
|
|
" if ! docker image inspect \"$image\" >/dev/null 2>&1; then",
|
|
" echo ci_runtime_image_pull=$image",
|
|
` HTTP_PROXY=${shellQuote(providerGatewayWsEgressProxyUrl)} HTTPS_PROXY=${shellQuote(providerGatewayWsEgressProxyUrl)} ALL_PROXY=${shellQuote(providerGatewayWsEgressProxyUrl)} NO_PROXY=localhost,127.0.0.1,::1,host.docker.internal docker pull --platform linux/amd64 "$image"`,
|
|
" else",
|
|
" echo ci_runtime_image_cached=$image",
|
|
" fi",
|
|
"done",
|
|
"pause_entrypoint=$(docker image inspect rancher/mirrored-pause:3.6 --format '{{json .Config.Entrypoint}}' 2>/dev/null || true)",
|
|
"if ! printf '%s' \"$pause_entrypoint\" | grep -q '\"/pause\"'; then echo native_k3s_pause_image_invalid_entrypoint=$pause_entrypoint >&2; exit 1; fi",
|
|
"containerd_images=$(/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls 2>/tmp/unidesk-ci-containerd-images.err || true)",
|
|
"containerd_ready=1",
|
|
"for image in \"${images[@]}\"; do",
|
|
" case \"$image\" in",
|
|
" rancher/*|oven/*|alpine/*) ref=\"docker.io/$image\" ;;",
|
|
" unidesk-*) ref=\"docker.io/library/$image\" ;;",
|
|
" *) ref=\"$image\" ;;",
|
|
" esac",
|
|
" if ! printf '%s\\n' \"$containerd_images\" | grep -F \"$ref\" >/dev/null; then",
|
|
" containerd_ready=0",
|
|
" echo ci_runtime_image_containerd_missing=$ref",
|
|
" fi",
|
|
"done",
|
|
"if [ \"$containerd_ready\" = \"1\" ]; then",
|
|
" echo ci_runtime_images_containerd_cached=all",
|
|
" exit 0",
|
|
"fi",
|
|
"rm -f /tmp/unidesk-ci-runtime-images.tar",
|
|
"docker save \"${images[@]}\" -o /tmp/unidesk-ci-runtime-images.tar",
|
|
"/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images import --digests --all-platforms /tmp/unidesk-ci-runtime-images.tar >/tmp/unidesk-ci-runtime-images-import.log",
|
|
"/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F 'docker.io/rancher/mirrored-pause:3.6' >/dev/null",
|
|
"/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F 'docker.io/oven/bun:1-debian' >/dev/null",
|
|
"/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F 'docker.io/alpine/git:2.45.2' >/dev/null",
|
|
`/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F ${shellQuote(`docker.io/library/${ciCodeQueueImage}`)} >/dev/null`,
|
|
].join("\n");
|
|
const result = await runRemoteBackground("prewarm-runtime-images", script, 900_000);
|
|
if (!result.ok) throw new Error(`CI runtime image prewarm failed: ${result.stderr || result.stdout}`);
|
|
}
|
|
|
|
async function status(): Promise<Record<string, unknown>> {
|
|
const summary = await runRemoteKubectl([
|
|
"set -euo pipefail",
|
|
"printf 'tekton_pipelines='",
|
|
"kubectl get deploy -n tekton-pipelines -o name 2>/dev/null | tr '\\n' ' ' || true",
|
|
"printf '\\ntekton_triggers='",
|
|
"kubectl get deploy -n tekton-pipelines-resolvers -o name 2>/dev/null | tr '\\n' ' ' || true",
|
|
"printf '\\nunidesk_ci='",
|
|
"kubectl get pipeline,task,pipelinerun,eventlistener,svc -n unidesk-ci -o name 2>/dev/null | tr '\\n' ' ' || true",
|
|
"printf '\\n'",
|
|
].join("\n"));
|
|
return {
|
|
ok: true,
|
|
providerId: d601ProviderId,
|
|
orchestrator: "native-k3s",
|
|
tekton: {
|
|
pipelineVersion: tektonPipelineVersion,
|
|
triggersVersion: tektonTriggersVersion,
|
|
},
|
|
summary: summary.stdout.trim(),
|
|
};
|
|
}
|
|
|
|
async function install(): Promise<Record<string, unknown>> {
|
|
if (!existsSync(rootPath("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml"))) {
|
|
throw new Error("CI manifests are missing");
|
|
}
|
|
await prewarmCiRuntimeImages();
|
|
const installTektonScript = [
|
|
"set -euo pipefail",
|
|
`export KUBECONFIG=${shellQuote(d601Kubeconfig)}`,
|
|
`kubectl apply -f ${shellQuote(tektonPipelineReleaseUrl)}`,
|
|
"kubectl wait --for=condition=Available deployment --all -n tekton-pipelines --timeout=900s",
|
|
`kubectl apply -f ${shellQuote(tektonTriggersReleaseUrl)}`,
|
|
`kubectl apply -f ${shellQuote(tektonTriggersInterceptorsUrl)}`,
|
|
"kubectl wait --for=condition=Available deployment --all -n tekton-pipelines --timeout=900s",
|
|
"kubectl wait --for=condition=Available deployment --all -n tekton-pipelines-resolvers --timeout=900s",
|
|
].join("\n");
|
|
const installTekton = await runRemoteBackground("install-tekton", installTektonScript, 1_200_000);
|
|
if (!installTekton.ok) throw new Error(`Tekton install failed: ${installTekton.stderr || installTekton.stdout}`);
|
|
await remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/tekton-install.yaml");
|
|
await remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml");
|
|
await remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.triggers.yaml");
|
|
return status();
|
|
}
|
|
|
|
function pipelineRunManifest(options: CiOptions): string {
|
|
const safeSuffix = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14).toLowerCase();
|
|
return `apiVersion: tekton.dev/v1
|
|
kind: PipelineRun
|
|
metadata:
|
|
generateName: unidesk-ci-${safeSuffix}-
|
|
namespace: unidesk-ci
|
|
labels:
|
|
app.kubernetes.io/name: unidesk-ci
|
|
app.kubernetes.io/part-of: unidesk
|
|
unidesk.ai/revision: ${JSON.stringify(options.revision)}
|
|
spec:
|
|
pipelineRef:
|
|
name: unidesk-ci
|
|
taskRunTemplate:
|
|
serviceAccountName: unidesk-ci-runner
|
|
params:
|
|
- name: repo-url
|
|
value: ${JSON.stringify(options.repoUrl)}
|
|
- name: revision
|
|
value: ${JSON.stringify(options.revision)}
|
|
workspaces:
|
|
- name: shared-workspace
|
|
persistentVolumeClaim:
|
|
claimName: unidesk-ci-cache
|
|
`;
|
|
}
|
|
|
|
async function remoteCreatePipelineRun(manifest: string): Promise<string> {
|
|
const encoded = Buffer.from(manifest, "utf8").toString("base64");
|
|
const token = randomUUID().replace(/-/gu, "").slice(0, 12);
|
|
const b64Path = `/tmp/unidesk-ci-pipelinerun-${token}.b64`;
|
|
const upload = await uploadRemoteBase64(b64Path, encoded);
|
|
if (!upload.ok) throw new Error(`failed to upload PipelineRun manifest: ${upload.stderr || upload.stdout}`);
|
|
const result = await runRemoteKubectl([
|
|
"tmp=$(mktemp /tmp/unidesk-ci-run.XXXXXX.yaml)",
|
|
`b64_path=${shellQuote(b64Path)}`,
|
|
"trap 'rm -f \"$tmp\" \"$b64_path\"' EXIT",
|
|
"base64 -d \"$b64_path\" > \"$tmp\"",
|
|
"kubectl create -f \"$tmp\" -o jsonpath='{.metadata.name}'",
|
|
].join("\n"), 60_000, 45_000);
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
async function waitForPipelineRun(name: string, waitMs: number): Promise<DispatchResult | null> {
|
|
if (waitMs <= 0) return null;
|
|
const command = [
|
|
"set -euo pipefail",
|
|
`export KUBECONFIG=${shellQuote(d601Kubeconfig)}`,
|
|
`deadline=$((SECONDS + ${Math.ceil(waitMs / 1000)}))`,
|
|
"while [ \"$SECONDS\" -lt \"$deadline\" ]; do",
|
|
` condition="$(kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o jsonpath='{range .status.conditions[?(@.type==\"Succeeded\")]}{.status}{\"\\t\"}{.reason}{\"\\t\"}{.message}{end}' 2>/dev/null || true)"`,
|
|
" case \"$condition\" in",
|
|
" True*)",
|
|
" echo \"$condition\"",
|
|
` kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o json`,
|
|
" exit 0",
|
|
" ;;",
|
|
" False*)",
|
|
" echo \"$condition\"",
|
|
` kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o json`,
|
|
" exit 1",
|
|
" ;;",
|
|
" esac",
|
|
" sleep 2",
|
|
"done",
|
|
`echo "Timed out waiting for pipelinerun/${name}" >&2`,
|
|
`kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o json`,
|
|
"exit 124",
|
|
].join("\n");
|
|
return dispatchSsh(command, waitMs + 30_000, waitMs + 20_000);
|
|
}
|
|
|
|
async function run(options: CiOptions): Promise<Record<string, unknown>> {
|
|
const name = await remoteCreatePipelineRun(pipelineRunManifest(options));
|
|
const wait = await waitForPipelineRun(name, options.waitMs);
|
|
const waitSucceeded = wait === null || wait.exitCode === 0 || wait.stdout.trimStart().startsWith("True\tSucceeded\t");
|
|
return {
|
|
ok: waitSucceeded,
|
|
pipelineRun: name,
|
|
namespace: "unidesk-ci",
|
|
repoUrl: options.repoUrl,
|
|
revision: options.revision,
|
|
wait: wait === null ? null : {
|
|
stdoutTail: wait.stdout.slice(-6000),
|
|
stderrTail: wait.stderr.slice(-6000),
|
|
},
|
|
next: [
|
|
`bun scripts/cli.ts ci logs ${name}`,
|
|
"bun scripts/cli.ts ci status",
|
|
],
|
|
};
|
|
}
|
|
|
|
async function runRemoteDevE2ELauncher(options: CiDevE2EOptions): Promise<DispatchResult> {
|
|
const scriptTimeoutMs = Math.max(options.scriptTimeoutMs, options.waitMs, 60_000);
|
|
const remoteTimeoutMs = 45_000;
|
|
const command = [
|
|
"set -euo pipefail",
|
|
`run_id=${shellQuote(options.runId)}`,
|
|
`repo_url=${shellQuote(options.scriptRepo)}`,
|
|
`commit=${shellQuote(options.deployCommit)}`,
|
|
`script_path=${shellQuote(options.scriptPath)}`,
|
|
`desired_ref=${shellQuote(options.desiredRef)}`,
|
|
`environment=${shellQuote(options.environment)}`,
|
|
`keep_namespace=${shellQuote(options.keepNamespace ? "true" : "false")}`,
|
|
`timeout_ms=${shellQuote(String(scriptTimeoutMs))}`,
|
|
"work_dir=\"/tmp/unidesk-ci/$run_id\"",
|
|
"result_dir=\"/home/ubuntu/.unidesk/runs/$run_id\"",
|
|
"mkdir -p \"$work_dir\" \"$result_dir\"",
|
|
"launcher_log=\"$result_dir/launcher.log\"",
|
|
"case \"$script_path\" in scripts/ci/*.sh) ;; *) echo \"invalid_script_path=$script_path\" >&2; exit 2 ;; esac",
|
|
"(",
|
|
"set -euo pipefail",
|
|
"trap '' HUP",
|
|
"exec >> \"$launcher_log\" 2>&1",
|
|
"echo \"launcher_run_id=$run_id\"",
|
|
"echo \"launcher_repo=$repo_url\"",
|
|
"echo \"launcher_commit=$commit\"",
|
|
"echo \"launcher_script_path=$script_path\"",
|
|
"export DOCKER_CONFIG=/tmp/unidesk-ci-docker-config",
|
|
"mkdir -p \"$DOCKER_CONFIG\"",
|
|
"printf '{}\\n' > \"$DOCKER_CONFIG/config.json\"",
|
|
`build_proxy=${shellQuote(providerGatewayWsEgressProxyUrl)}`,
|
|
"export HTTP_PROXY=\"$build_proxy\" HTTPS_PROXY=\"$build_proxy\" ALL_PROXY=\"$build_proxy\"",
|
|
"export NO_PROXY=\"localhost,127.0.0.1,::1,host.docker.internal,.svc,.cluster.local,kubernetes.default.svc\"",
|
|
"if ! curl -fsSI --max-time 20 -x \"$build_proxy\" https://github.com >/dev/null; then",
|
|
" echo \"ci_provider_egress_proxy_unavailable=$build_proxy\" >&2",
|
|
" exit 1",
|
|
"fi",
|
|
"echo \"ci_provider_egress_proxy=provider-gateway-ws-egress:$build_proxy\"",
|
|
"repo_fetch_url=\"$repo_url\"",
|
|
"case \"$repo_fetch_url\" in",
|
|
" https://github.com/*)",
|
|
" repo_path=\"${repo_fetch_url#https://github.com/}\"",
|
|
" repo_path=\"${repo_path%.git}\"",
|
|
" repo_fetch_url=\"git@github.com:$repo_path.git\"",
|
|
" ;;",
|
|
"esac",
|
|
"export GIT_SSH_COMMAND=\"ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o 'ProxyCommand=nc -X connect -x 127.0.0.1:18789 %h %p'\"",
|
|
"echo \"launcher_repo_fetch_url=$repo_fetch_url\"",
|
|
"repo_dir=\"$work_dir/repo\"",
|
|
"if [ ! -d \"$repo_dir/.git\" ]; then",
|
|
" git clone --no-checkout \"$repo_fetch_url\" \"$repo_dir\"",
|
|
"fi",
|
|
"git -C \"$repo_dir\" remote set-url origin \"$repo_fetch_url\"",
|
|
"git -C \"$repo_dir\" fetch --no-tags origin \"$commit\" || git -C \"$repo_dir\" fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'",
|
|
"resolved=$(git -C \"$repo_dir\" rev-parse --verify \"$commit^{commit}\")",
|
|
"test \"$resolved\" = \"$commit\" || { echo \"resolved_commit_mismatch=$resolved expected=$commit\" >&2; exit 1; }",
|
|
"git -C \"$repo_dir\" cat-file -e \"$resolved:$script_path\"",
|
|
"git -C \"$repo_dir\" show \"$resolved:$script_path\" > \"$work_dir/runner.sh\"",
|
|
"git -C \"$repo_dir\" show \"$resolved:deploy.json\" > \"$work_dir/deploy.json\"",
|
|
"chmod 700 \"$work_dir/runner.sh\"",
|
|
"echo \"runner_script_ready=$work_dir/runner.sh\"",
|
|
"runner_args=(",
|
|
" --run-id \"$run_id\"",
|
|
" --repo-url \"$repo_url\"",
|
|
" --desired-ref \"$desired_ref\"",
|
|
" --manifest-commit \"$commit\"",
|
|
" --manifest-file \"$work_dir/deploy.json\"",
|
|
" --environment \"$environment\"",
|
|
" --result-dir \"$result_dir\"",
|
|
" --timeout-ms \"$timeout_ms\"",
|
|
")",
|
|
"if [ \"$keep_namespace\" = \"true\" ]; then runner_args+=(--keep-namespace); fi",
|
|
"bash \"$work_dir/runner.sh\" \"${runner_args[@]}\"",
|
|
") &",
|
|
"launcher_pid=$!",
|
|
"disown \"$launcher_pid\" 2>/dev/null || true",
|
|
"printf 'launcher_background_pid=%s\\nresult_dir=%s\\n' \"$launcher_pid\" \"$result_dir\"",
|
|
].join("\n");
|
|
return dispatchSsh(command, 30_000, remoteTimeoutMs);
|
|
}
|
|
|
|
async function waitForDevE2EResult(runId: string, waitMs: number): Promise<DispatchResult | null> {
|
|
if (waitMs <= 0) return null;
|
|
const deadline = Date.now() + waitMs;
|
|
let latest: DispatchResult | null = null;
|
|
while (Date.now() < deadline) {
|
|
const result = await dispatchSsh([
|
|
"set -euo pipefail",
|
|
`run_id=${shellQuote(runId)}`,
|
|
"result_dir=\"/home/ubuntu/.unidesk/runs/$run_id\"",
|
|
"if [ -f \"$result_dir/result.json\" ]; then cat \"$result_dir/result.json\"; exit 0; fi",
|
|
"printf 'RUNNING result_dir=%s\\n' \"$result_dir\"",
|
|
"tail -n 40 \"$result_dir/launcher.log\" 2>/dev/null || true",
|
|
"tail -n 80 \"$result_dir/runner.log\" 2>/dev/null || true",
|
|
].join("\n"), 30_000, 20_000);
|
|
latest = result;
|
|
const stdout = result.stdout.trimStart();
|
|
if (stdout.startsWith("{")) {
|
|
const parsed = JSON.parse(stdout) as { ok?: boolean; status?: string };
|
|
return {
|
|
...result,
|
|
ok: parsed.ok === true,
|
|
status: parsed.status ?? (parsed.ok === true ? "succeeded" : "failed"),
|
|
exitCode: parsed.ok === true ? 0 : 1,
|
|
};
|
|
}
|
|
await Bun.sleep(5_000);
|
|
}
|
|
return {
|
|
ok: false,
|
|
taskId: latest?.taskId ?? null,
|
|
status: "timeout",
|
|
stdout: latest?.stdout ?? "",
|
|
stderr: `dev e2e result did not finish within ${waitMs}ms`,
|
|
exitCode: 124,
|
|
raw: latest?.raw ?? null,
|
|
};
|
|
}
|
|
|
|
function resolveDeployDevManifest(desiredRef: string): DeployDevManifestSummary {
|
|
const remoteRef = `refs/remotes/origin/${desiredRef}`;
|
|
const fetch = runCommand(["git", "fetch", "--quiet", "origin", `+refs/heads/${desiredRef}:${remoteRef}`], repoRoot);
|
|
if (fetch.exitCode !== 0) throw new Error(`failed to fetch origin/${desiredRef}: ${fetch.stderr || fetch.stdout}`);
|
|
const deployCommitResult = runCommand(["git", "rev-parse", `origin/${desiredRef}`], repoRoot);
|
|
if (deployCommitResult.exitCode !== 0) throw new Error(`failed to resolve origin/${desiredRef}: ${deployCommitResult.stderr || deployCommitResult.stdout}`);
|
|
const show = runCommand(["git", "show", `origin/${desiredRef}:deploy.json`], repoRoot);
|
|
if (show.exitCode !== 0) throw new Error(`failed to read deploy.json from origin/${desiredRef}: ${show.stderr || show.stdout}`);
|
|
const parsed = JSON.parse(show.stdout) as unknown;
|
|
const record = asRecord(parsed);
|
|
if (record?.schemaVersion !== 2) throw new Error(`origin/${desiredRef}:deploy.json must use schemaVersion=2`);
|
|
const environments = asRecord(record.environments);
|
|
const dev = asRecord(environments?.dev);
|
|
const ci = asRecord(dev?.ci);
|
|
if (ci === null) throw new Error(`origin/${desiredRef}:deploy.json must contain environments.dev.ci`);
|
|
const rawServices = Array.isArray(dev?.services) ? dev.services : [];
|
|
const services = rawServices.map((item) => {
|
|
const service = asRecord(item);
|
|
return {
|
|
id: asString(service?.id),
|
|
commitId: asString(service?.commitId).toLowerCase(),
|
|
repo: asString(service?.repo),
|
|
};
|
|
}).filter((service) => service.id.length > 0 && service.commitId.length > 0);
|
|
if (services.length === 0) throw new Error(`origin/${desiredRef}:deploy.json has no environments.dev services with commitId`);
|
|
return {
|
|
deployCommit: deployCommitResult.stdout.trim(),
|
|
desiredRef,
|
|
environment: "dev",
|
|
ci: {
|
|
repo: requireManifestString(ci.repo, "environments.dev.ci.repo"),
|
|
scriptPath: requireCiScriptPath(ci.scriptPath),
|
|
timeoutMs: positiveManifestNumber(ci.timeoutMs, 1_800_000, "environments.dev.ci.timeoutMs"),
|
|
},
|
|
services,
|
|
};
|
|
}
|
|
|
|
function makeRunId(deployCommit: string): string {
|
|
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14).toLowerCase();
|
|
return `${stamp}-${deployCommit.slice(0, 8).toLowerCase()}`.replace(/[^a-z0-9-]/gu, "-").slice(0, 48);
|
|
}
|
|
|
|
async function runDevE2E(options: CiDevE2EOptions): Promise<Record<string, unknown>> {
|
|
const result = await runRemoteDevE2ELauncher(options);
|
|
const wait = result.ok ? await waitForDevE2EResult(options.runId, options.waitMs) : null;
|
|
const ok = result.ok && (result.exitCode === null || result.exitCode === 0) && (wait === null || wait.ok);
|
|
return {
|
|
ok,
|
|
runId: options.runId,
|
|
namespace: "unidesk-ci",
|
|
temporaryNamespace: `unidesk-ci-e2e-${options.runId}`,
|
|
repoUrl: options.repoUrl,
|
|
desiredRef: options.desiredRef,
|
|
deployCommit: options.deployCommit,
|
|
scriptRepo: options.scriptRepo,
|
|
scriptPath: options.scriptPath,
|
|
environment: options.environment,
|
|
services: options.services,
|
|
keepNamespace: options.keepNamespace,
|
|
triggerMode: "commit-pinned-ssh-launcher",
|
|
launcher: {
|
|
taskId: result.taskId,
|
|
status: result.status,
|
|
exitCode: result.exitCode,
|
|
stdoutTail: result.stdout.slice(-6000),
|
|
stderrTail: result.stderr.slice(-6000),
|
|
},
|
|
wait: wait === null ? null : {
|
|
status: wait.status,
|
|
exitCode: wait.exitCode,
|
|
stdoutTail: wait.stdout.slice(-6000),
|
|
stderrTail: wait.stderr.slice(-6000),
|
|
},
|
|
resultDir: `/home/ubuntu/.unidesk/runs/${options.runId}`,
|
|
next: [
|
|
`bun scripts/cli.ts ci logs ${options.runId}`,
|
|
"bun scripts/cli.ts ci status",
|
|
],
|
|
};
|
|
}
|
|
|
|
async function logs(name: string): Promise<Record<string, unknown>> {
|
|
if (name.length === 0) throw new Error("ci logs requires run id or PipelineRun name");
|
|
if (/^[a-z0-9]([-a-z0-9]{0,46}[a-z0-9])?$/u.test(name)) {
|
|
const result = await dispatchSsh([
|
|
"set -euo pipefail",
|
|
`run_id=${shellQuote(name)}`,
|
|
"result_dir=\"/home/ubuntu/.unidesk/runs/$run_id\"",
|
|
"printf 'result_dir=%s\\n' \"$result_dir\"",
|
|
"found=0",
|
|
"if [ -f \"$result_dir/result.json\" ]; then found=1; echo '===== result.json'; cat \"$result_dir/result.json\"; fi",
|
|
"if [ -f \"$result_dir/launcher.log\" ]; then found=1; echo '===== launcher.log'; tail -n 160 \"$result_dir/launcher.log\"; fi",
|
|
"if [ -f \"$result_dir/runner.log\" ]; then found=1; echo '===== runner.log'; tail -n 240 \"$result_dir/runner.log\"; fi",
|
|
"if [ -f \"$result_dir/pods.log\" ]; then found=1; echo '===== pods.log'; tail -n 240 \"$result_dir/pods.log\"; fi",
|
|
"if [ \"$found\" = \"0\" ]; then echo \"no_run_files=$result_dir\" >&2; exit 42; fi",
|
|
].join("\n"), 60_000, 45_000);
|
|
if (result.ok || (result.exitCode !== 42 && !result.stderr.includes("no_run_files="))) {
|
|
return {
|
|
ok: result.ok,
|
|
runId: name,
|
|
output: result.stdout,
|
|
stderr: result.stderr,
|
|
exitCode: result.exitCode,
|
|
};
|
|
}
|
|
}
|
|
const result = await runRemoteKubectl([
|
|
"set -euo pipefail",
|
|
`kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o wide`,
|
|
`kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o wide`,
|
|
`for pod in $(kubectl get pods -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o name); do echo "===== $pod"; kubectl logs -n unidesk-ci "$pod" --all-containers=true --tail=160 || true; done`,
|
|
].join("\n"), 60_000, 45_000);
|
|
return {
|
|
ok: true,
|
|
pipelineRun: name,
|
|
output: result.stdout,
|
|
stderr: result.stderr,
|
|
};
|
|
}
|
|
|
|
function help(): Record<string, unknown> {
|
|
return {
|
|
command: "ci install|status|run|run-dev-e2e|logs",
|
|
description: "Manage the D601 k3s Tekton CI gate. This intentionally does not deploy CD.",
|
|
examples: [
|
|
"bun scripts/cli.ts ci install",
|
|
"bun scripts/cli.ts ci run --revision <commit>",
|
|
"bun scripts/cli.ts ci run-dev-e2e --wait-ms 600000",
|
|
"bun scripts/cli.ts ci logs <runId>",
|
|
],
|
|
tekton: {
|
|
pipelineVersion: tektonPipelineVersion,
|
|
triggersVersion: tektonTriggersVersion,
|
|
sources: {
|
|
pipeline: tektonPipelineReleaseUrl,
|
|
triggers: tektonTriggersReleaseUrl,
|
|
interceptors: tektonTriggersInterceptorsUrl,
|
|
},
|
|
},
|
|
runDevE2E: {
|
|
defaultTriggerMode: "commit-pinned-ssh-launcher",
|
|
desiredState: "origin/master:deploy.json#environments.dev",
|
|
scriptSource: "origin/master:deploy.json#environments.dev.ci",
|
|
},
|
|
};
|
|
}
|
|
|
|
function requireRunId(value: string): string {
|
|
if (!/^[a-z0-9]([-a-z0-9]{0,46}[a-z0-9])?$/u.test(value)) {
|
|
throw new Error("ci run-dev-e2e run id must be DNS-safe lowercase alnum/dash, max 48 chars");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export async function runCiCommand(_config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
|
const [action = "status", nameArg] = args;
|
|
if (isHelpArg(action) || args.slice(1).some(isHelpArg)) return help();
|
|
if (action === "install") return install();
|
|
if (action === "status") return status();
|
|
if (action === "run") {
|
|
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk";
|
|
const revision = requireRevision(stringOption(args, "--revision") ?? stringOption(args, "--commit"));
|
|
const waitMs = numberOption(args, "--wait-ms", 0);
|
|
return run({ repoUrl, revision, waitMs });
|
|
}
|
|
if (action === "run-dev-e2e") {
|
|
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk";
|
|
const desiredRef = requireDesiredRef(stringOption(args, "--desired-ref") ?? stringOption(args, "--deploy-branch"));
|
|
const manifest = resolveDeployDevManifest(desiredRef);
|
|
const waitMs = numberOption(args, "--wait-ms", 0);
|
|
const runId = requireRunId(stringOption(args, "--run-id") ?? makeRunId(manifest.deployCommit));
|
|
return runDevE2E({
|
|
repoUrl,
|
|
desiredRef,
|
|
deployCommit: manifest.deployCommit,
|
|
environment: manifest.environment,
|
|
scriptRepo: manifest.ci.repo,
|
|
scriptPath: manifest.ci.scriptPath,
|
|
scriptTimeoutMs: manifest.ci.timeoutMs,
|
|
services: manifest.services,
|
|
runId,
|
|
keepNamespace: boolFlag(args, "--keep-namespace"),
|
|
waitMs,
|
|
});
|
|
}
|
|
if (action === "logs") return logs(nameArg ?? "");
|
|
throw new Error("ci command must be one of: install, status, run, run-dev-e2e, logs");
|
|
}
|
|
|
|
export function startCiInstallJob(): Record<string, unknown> {
|
|
const job = startJob("ci_install", ["bun", "scripts/cli.ts", "ci", "install"], "Install/refresh Tekton CI on D601 k3s");
|
|
return { ok: true, job };
|
|
}
|