import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { posix as posixPath } from "node:path"; import { blockedCatalogArtifactIds, catalogSummary, findCiCatalogArtifact, loadCiCatalog, supportedSourceBuildArtifactIds, type CiCatalogArtifact, type CiSourceBuildCatalogArtifact, type CiUpstreamImageCatalogArtifact } from "./ci-catalog"; import { runCommand } from "./command"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "./deploy-ssh-identity"; 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 CiPublishBackendCoreOptions { repoUrl: string; commit: string; waitMs: number; sourceHostPath: string; dockerfile: string; imageRepository: string; dryRun: boolean; } interface CiPublishUserServiceArtifactOptions { repoUrl: string; commit: string; waitMs: number; sourceHostPath: string; serviceId: string; dockerfile: string; imageRepository: string; dryRun: boolean; } 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 PipelineRunCondition { ok: boolean | null; status: string; reason: string; message: string; query: { ok: boolean; status: string | null; exitCode: number | null; stdoutTail: string; stderrTail: string; }; } interface DeployDevManifestSummary { deployCommit: string; desiredRef: string; environment: "dev"; ci: { repo: string; scriptPath: string; timeoutMs: number; }; services: Array<{ id: string; commitId: string; repo: string }>; } interface ArtifactSummary { serviceId: string; sourceCommit: string; sourceRepo: string; dockerfile: string; registry: string; repository: string; tag: string; imageRef: string; digest: string | null; digestRef: string | null; } interface ArtifactSummaryContext { serviceId: string; commit: string; repoUrl: string; dockerfile: string; imageRepository: 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 "); if (!/^[A-Za-z0-9._/@:-]{1,160}$/u.test(value) || value.startsWith("-") || value.includes("..")) throw new Error("ci --revision contains unsupported characters"); return value; } function requireFullCommit(value: string | null, option = "--commit"): string { const commit = value?.toLowerCase() ?? ""; if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error(`${option} requires a full 40-character pushed Git commit SHA`); return commit; } function requireServiceId(value: string | null): string { const serviceId = value ?? ""; if (!/^[a-z0-9]([-a-z0-9]{0,62}[a-z0-9])?$/u.test(serviceId)) { throw new Error("ci publish-user-service requires --service "); } return serviceId; } 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 safePathToken(value: string): string { return value.replace(/[^a-z0-9._-]/giu, "-").toLowerCase().replace(/^-+|-+$/gu, "").slice(0, 80) || "artifact"; } function repoSshUrl(repoUrl: string): string { if (repoUrl.startsWith("git@")) return repoUrl; if (repoUrl.startsWith("https://github.com/")) { const repoPath = repoUrl.slice("https://github.com/".length).replace(/\.git$/u, ""); return `git@github.com:${repoPath}.git`; } return repoUrl; } function repoNeedsGithubSshIdentity(repoFetchUrl: string): boolean { return repoFetchUrl.startsWith("git@github.com:"); } function repoConnectivityProbeUrl(repoFetchUrl: string): string { const sshMatch = /^git@([^:]+):/u.exec(repoFetchUrl); if (sshMatch !== null) return `https://${sshMatch[1]}`; try { const parsed = new URL(repoFetchUrl); return `${parsed.protocol}//${parsed.host}`; } catch { return repoFetchUrl.startsWith("https://gitee.com/") ? "https://gitee.com" : "https://github.com"; } } function requireRepoRelativePath(path: string, label: string): string { if (path.length === 0 || path.startsWith("/") || path.includes("\0") || path.split("/").includes("..")) { throw new Error(`${label} must be a repo-relative path`); } const normalized = posixPath.normalize(path); if (normalized === "." || normalized.startsWith("../")) throw new Error(`${label} must be a repo-relative path`); return normalized; } function resolveCatalogArtifact(serviceId: string): CiCatalogArtifact { const artifact = findCiCatalogArtifact(serviceId); if (artifact === null) { const known = loadCiCatalog().artifacts.map((item) => item.serviceId).sort().join(", "); throw new Error(`unknown CI artifact service: ${serviceId}. Known services: ${known}`); } return artifact; } function blockedArtifactResult(artifact: CiUpstreamImageCatalogArtifact | CiSourceBuildCatalogArtifact, commit: string, note: string): Record { const base = { ok: false, status: "blocked", error: "blocked", serviceId: artifact.serviceId, commit, reason: note, catalogArtifact: artifact, boundary: "CI catalog marks this service as blocked; it must not be treated as a source-build artifact producer", }; return artifact.kind === "upstream-image" ? { ...base, upstream: artifact.upstream, next: [ `document the upstream image contract in CI.json for ${artifact.serviceId}`, ], } : { ...base, next: [ `unblock ${artifact.serviceId} in CI.json before attempting source-build publication`, ], }; } function blockedReason(artifact: CiSourceBuildCatalogArtifact): string { if (artifact.blockedReason === undefined) throw new Error(`${artifact.serviceId} is blocked in CI.json but has no blockedReason`); return artifact.blockedReason; } 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 | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function asString(value: unknown): string { return typeof value === "string" ? value : ""; } function coreBody(response: unknown): Record | 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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> { 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> { 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 `; } function backendCoreArtifactPipelineRunManifest(options: CiPublishBackendCoreOptions): string { const safeSuffix = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14).toLowerCase(); return `apiVersion: tekton.dev/v1 kind: PipelineRun metadata: generateName: backend-core-artifact-${safeSuffix}- namespace: unidesk-ci labels: app.kubernetes.io/name: unidesk-backend-core-artifact-publish app.kubernetes.io/part-of: unidesk unidesk.ai/service-id: backend-core unidesk.ai/revision: ${JSON.stringify(options.commit)} spec: pipelineRef: name: unidesk-backend-core-artifact-publish taskRunTemplate: serviceAccountName: unidesk-ci-runner params: - name: repo-url value: ${JSON.stringify(options.repoUrl)} - name: revision value: ${JSON.stringify(options.commit)} - name: dockerfile value: ${JSON.stringify(options.dockerfile)} - name: image-repository value: ${JSON.stringify(options.imageRepository)} - name: source-host-path value: ${JSON.stringify(options.sourceHostPath)} workspaces: - name: shared-workspace persistentVolumeClaim: claimName: unidesk-ci-cache `; } function userServiceArtifactPipelineRunManifest(options: CiPublishUserServiceArtifactOptions): string { const safeSuffix = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14).toLowerCase(); return `apiVersion: tekton.dev/v1 kind: PipelineRun metadata: generateName: user-service-artifact-${options.serviceId}-${safeSuffix}- namespace: unidesk-ci labels: app.kubernetes.io/name: unidesk-user-service-artifact-publish app.kubernetes.io/part-of: unidesk unidesk.ai/service-id: ${JSON.stringify(options.serviceId)} unidesk.ai/revision: ${JSON.stringify(options.commit)} spec: pipelineRef: name: unidesk-user-service-artifact-publish taskRunTemplate: serviceAccountName: unidesk-ci-runner params: - name: repo-url value: ${JSON.stringify(options.repoUrl)} - name: revision value: ${JSON.stringify(options.commit)} - name: service-id value: ${JSON.stringify(options.serviceId)} - name: dockerfile value: ${JSON.stringify(options.dockerfile)} - name: image-repository value: ${JSON.stringify(options.imageRepository)} - name: source-host-path value: ${JSON.stringify(options.sourceHostPath)} workspaces: - name: shared-workspace persistentVolumeClaim: claimName: unidesk-ci-cache `; } function backendCoreArtifactSourceHostPath(commit: string): string { return `/home/ubuntu/.unidesk/ci/backend-core-artifacts/${commit}`; } function userServiceArtifactSourceHostPath(serviceId: string, commit: string): string { return `/home/ubuntu/.unidesk/ci/user-service-artifacts/${serviceId}/${commit}`; } async function prepareBackendCoreArtifactSource(config: UniDeskConfig, options: CiPublishBackendCoreOptions): Promise> { const sourceRoot = "/home/ubuntu/.unidesk/ci/backend-core-artifacts"; const sourceHostPath = options.sourceHostPath; const repoCache = "/home/ubuntu/.unidesk/ci/git/unidesk.git"; const repoFetchUrl = repoSshUrl(options.repoUrl); const sshIdentity = repoNeedsGithubSshIdentity(repoFetchUrl) ? await ensureGithubSshIdentityForProvider(config, d601ProviderId) : null; if (sshIdentity !== null && !sshIdentity.ok) throw new Error(sshIdentity.detail); const proxyPython = gitSshHttpConnectProxySource(); const dockerfile = requireRepoRelativePath(options.dockerfile, "CI.json.artifacts.backend-core.source.dockerfile"); const script = [ "set -euo pipefail", `commit=${shellQuote(options.commit)}`, `repo_url=${shellQuote(options.repoUrl)}`, `repo_fetch_url=${shellQuote(repoFetchUrl)}`, `dockerfile=${shellQuote(dockerfile)}`, `source_root=${shellQuote(sourceRoot)}`, `source_dir=${shellQuote(sourceHostPath)}`, `repo_cache=${shellQuote(repoCache)}`, `proxy_url=${shellQuote(providerGatewayWsEgressProxyUrl)}`, "mkdir -p \"$(dirname \"$repo_cache\")\" \"$source_root\"", "export HTTP_PROXY=\"$proxy_url\" HTTPS_PROXY=\"$proxy_url\" ALL_PROXY=\"$proxy_url\"", "export NO_PROXY=\"localhost,127.0.0.1,::1,host.docker.internal,.svc,.cluster.local,kubernetes.default.svc\"", "curl -fsSI --max-time 20 -x \"$proxy_url\" https://github.com >/dev/null", "git_ssh_proxy=/tmp/unidesk-git-ssh-http-connect.py", "cat > \"$git_ssh_proxy\" <<'UNIDESK_GIT_SSH_PROXY'", proxyPython, "UNIDESK_GIT_SSH_PROXY", "chmod 700 \"$git_ssh_proxy\"", "export UNIDESK_GIT_SSH_HTTP_PROXY=\"$proxy_url\"", "export GIT_SSH_COMMAND=\"ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o 'ProxyCommand=$git_ssh_proxy %h %p'\"", "echo backend_core_artifact_source_proxy=provider-gateway-ws-egress:$proxy_url", "echo backend_core_artifact_repo_fetch_url=$repo_fetch_url", "if [ ! -d \"$repo_cache\" ]; then git clone --mirror \"$repo_fetch_url\" \"$repo_cache\"; fi", "git -C \"$repo_cache\" remote set-url origin \"$repo_fetch_url\"", "git -C \"$repo_cache\" fetch --no-tags origin \"$commit\" || git -C \"$repo_cache\" fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'", "resolved=$(git -C \"$repo_cache\" rev-parse --verify \"$commit^{commit}\")", "test \"$resolved\" = \"$commit\" || { echo \"backend_core_artifact_resolved_commit_mismatch=$resolved expected=$commit\" >&2; exit 1; }", "git -C \"$repo_cache\" cat-file -e \"$commit:$dockerfile\"", "git -C \"$repo_cache\" cat-file -e \"$commit:src/components/backend-core/src\"", "tmp_dir=\"$source_root/.tmp-$commit-$$\"", "rm -rf \"$tmp_dir\"", "mkdir -p \"$tmp_dir\"", "git -C \"$repo_cache\" archive \"$commit\" | tar -x -C \"$tmp_dir\"", "printf '%s\\n' \"$commit\" > \"$tmp_dir/.unidesk-source-commit\"", "printf '%s\\n' \"$repo_url\" > \"$tmp_dir/.unidesk-source-repo\"", "rm -rf \"$source_dir\"", "mv \"$tmp_dir\" \"$source_dir\"", "test -f \"$source_dir/$dockerfile\"", "test -d \"$source_dir/src/components/backend-core/src\"", "echo backend_core_artifact_source_host_path=$source_dir", ].join("\n"); const result = await runRemoteBackground("prepare-backend-core-source", script, 300_000); if (!result.ok) throw new Error(`failed to prepare backend-core source on D601: ${result.stderr || result.stdout || JSON.stringify(result.raw)}`); return { ok: true, mode: repoNeedsGithubSshIdentity(repoFetchUrl) ? "d601-host-git-ssh-export" : "d601-host-git-https-export", providerId: d601ProviderId, repoUrl: options.repoUrl, repoFetchUrl, commit: options.commit, sourceHostPath, dockerfile, identity: sshIdentity === null ? null : { fingerprint: sshIdentity.fingerprint, seededFromLocal: sshIdentity.seededFromLocal, }, stdoutTail: result.stdout.slice(-4000), }; } async function prepareUserServiceArtifactSource(config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions): Promise> { const sourceRoot = `/home/ubuntu/.unidesk/ci/user-service-artifacts/${options.serviceId}`; const sourceHostPath = options.sourceHostPath; const repoCache = `/home/ubuntu/.unidesk/ci/git/${safePathToken(options.serviceId)}.git`; const repoFetchUrl = repoSshUrl(options.repoUrl); const repoProbeUrl = repoConnectivityProbeUrl(repoFetchUrl); const sshIdentity = repoNeedsGithubSshIdentity(repoFetchUrl) ? await ensureGithubSshIdentityForProvider(config, d601ProviderId) : null; if (sshIdentity !== null && !sshIdentity.ok) throw new Error(sshIdentity.detail); const proxyPython = gitSshHttpConnectProxySource(); const script = [ "set -euo pipefail", `service_id=${shellQuote(options.serviceId)}`, `commit=${shellQuote(options.commit)}`, `repo_url=${shellQuote(options.repoUrl)}`, `repo_fetch_url=${shellQuote(repoFetchUrl)}`, `repo_probe_url=${shellQuote(repoProbeUrl)}`, `dockerfile=${shellQuote(options.dockerfile)}`, `source_root=${shellQuote(sourceRoot)}`, `source_dir=${shellQuote(sourceHostPath)}`, `repo_cache=${shellQuote(repoCache)}`, `proxy_url=${shellQuote(providerGatewayWsEgressProxyUrl)}`, "mkdir -p \"$(dirname \"$repo_cache\")\" \"$source_root\"", "export HTTP_PROXY=\"$proxy_url\" HTTPS_PROXY=\"$proxy_url\" ALL_PROXY=\"$proxy_url\"", "export NO_PROXY=\"localhost,127.0.0.1,::1,host.docker.internal,.svc,.cluster.local,kubernetes.default.svc\"", "curl -fsSI --max-time 20 -x \"$proxy_url\" \"$repo_probe_url\" >/dev/null", "git_ssh_proxy=/tmp/unidesk-git-ssh-http-connect.py", "cat > \"$git_ssh_proxy\" <<'UNIDESK_GIT_SSH_PROXY'", proxyPython, "UNIDESK_GIT_SSH_PROXY", "chmod 700 \"$git_ssh_proxy\"", "export UNIDESK_GIT_SSH_HTTP_PROXY=\"$proxy_url\"", "export GIT_SSH_COMMAND=\"ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o 'ProxyCommand=$git_ssh_proxy %h %p'\"", "echo user_service_artifact_source_proxy=provider-gateway-ws-egress:$proxy_url", "echo user_service_artifact_repo_fetch_url=$repo_fetch_url", "echo user_service_artifact_repo_probe_url=$repo_probe_url", "echo user_service_artifact_service_id=$service_id", "if [ ! -d \"$repo_cache\" ]; then git clone --mirror \"$repo_fetch_url\" \"$repo_cache\"; fi", "git -C \"$repo_cache\" remote set-url origin \"$repo_fetch_url\"", "git -C \"$repo_cache\" fetch --no-tags origin \"$commit\" || git -C \"$repo_cache\" fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'", "resolved=$(git -C \"$repo_cache\" rev-parse --verify \"$commit^{commit}\")", "test \"$resolved\" = \"$commit\" || { echo \"user_service_artifact_resolved_commit_mismatch=$resolved expected=$commit\" >&2; exit 1; }", "git -C \"$repo_cache\" cat-file -e \"$commit:$dockerfile\"", "tmp_dir=\"$source_root/.tmp-$commit-$$\"", "rm -rf \"$tmp_dir\"", "mkdir -p \"$tmp_dir\"", "git -C \"$repo_cache\" archive \"$commit\" | tar -x -C \"$tmp_dir\"", "printf '%s\\n' \"$commit\" > \"$tmp_dir/.unidesk-source-commit\"", "printf '%s\\n' \"$repo_url\" > \"$tmp_dir/.unidesk-source-repo\"", "printf '%s\\n' \"$service_id\" > \"$tmp_dir/.unidesk-service-id\"", "printf '%s\\n' \"$dockerfile\" > \"$tmp_dir/.unidesk-dockerfile\"", "rm -rf \"$source_dir\"", "mv \"$tmp_dir\" \"$source_dir\"", "test -f \"$source_dir/$dockerfile\"", "echo user_service_artifact_source_host_path=$source_dir", ].join("\n"); const result = await runRemoteBackground(`prepare-${options.serviceId}-source`, script, 300_000); if (!result.ok) throw new Error(`failed to prepare ${options.serviceId} source on D601: ${result.stderr || result.stdout || JSON.stringify(result.raw)}`); return { ok: true, mode: repoNeedsGithubSshIdentity(repoFetchUrl) ? "d601-host-git-ssh-export" : "d601-host-git-https-export", providerId: d601ProviderId, repoUrl: options.repoUrl, repoFetchUrl, repoProbeUrl, commit: options.commit, serviceId: options.serviceId, dockerfile: options.dockerfile, sourceHostPath, identity: sshIdentity === null ? null : { fingerprint: sshIdentity.fingerprint, seededFromLocal: sshIdentity.seededFromLocal, }, stdoutTail: result.stdout.slice(-4000), }; } async function prepareClaudeqqArtifactSource(config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions): Promise> { const sourceRoot = `/home/ubuntu/.unidesk/ci/user-service-artifacts/${options.serviceId}`; const sourceHostPath = options.sourceHostPath; const repoCache = "/home/ubuntu/.unidesk/ci/git/claudeqq-agent-skills.git"; const repoFetchUrl = options.repoUrl; const assets = [ { relativePath: "claudeqq/Dockerfile", sourcePath: rootPath("src/components/microservices/claudeqq/Dockerfile"), label: "Dockerfile", }, { relativePath: "claudeqq/unidesk-adapter.cjs", sourcePath: rootPath("src/components/microservices/claudeqq/adapter.js"), label: "unidesk-adapter.cjs", }, ]; for (const asset of assets) { if (!existsSync(asset.sourcePath)) throw new Error(`claudeqq artifact asset missing: ${asset.sourcePath}`); } const overlayCommands = assets.flatMap((asset) => { const encoded = Buffer.from(readFileSync(asset.sourcePath, "utf8"), "utf8").toString("base64"); return [ `mkdir -p "$tmp_dir/$(dirname ${shellQuote(asset.relativePath)})"`, `printf %s ${shellQuote(encoded)} | base64 -d > "$tmp_dir/${asset.relativePath}"`, `printf 'user_service_artifact_overlay=%s\\n' ${shellQuote(asset.label)}`, ]; }); const script = [ "set -euo pipefail", `service_id=${shellQuote(options.serviceId)}`, `commit=${shellQuote(options.commit)}`, `repo_url=${shellQuote(options.repoUrl)}`, `repo_fetch_url=${shellQuote(repoFetchUrl)}`, `dockerfile=${shellQuote(options.dockerfile)}`, `source_root=${shellQuote(sourceRoot)}`, `source_dir=${shellQuote(sourceHostPath)}`, `repo_cache=${shellQuote(repoCache)}`, `proxy_url=${shellQuote(providerGatewayWsEgressProxyUrl)}`, "mkdir -p \"$(dirname \"$repo_cache\")\" \"$source_root\"", "export HTTP_PROXY=\"$proxy_url\" HTTPS_PROXY=\"$proxy_url\" ALL_PROXY=\"$proxy_url\"", "export NO_PROXY=\"localhost,127.0.0.1,::1,host.docker.internal,.svc,.cluster.local,kubernetes.default.svc\"", "curl -fsSI --max-time 20 -x \"$proxy_url\" https://gitee.com >/dev/null", "echo user_service_artifact_source_proxy=provider-gateway-ws-egress:$proxy_url", "echo user_service_artifact_repo_fetch_url=$repo_fetch_url", "echo user_service_artifact_service_id=$service_id", "if [ ! -d \"$repo_cache\" ]; then git clone --mirror \"$repo_fetch_url\" \"$repo_cache\"; fi", "git -C \"$repo_cache\" remote set-url origin \"$repo_fetch_url\"", "git -C \"$repo_cache\" fetch --no-tags origin \"$commit\" || git -C \"$repo_cache\" fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'", "resolved=$(git -C \"$repo_cache\" rev-parse --verify \"$commit^{commit}\")", "test \"$resolved\" = \"$commit\" || { echo \"user_service_artifact_resolved_commit_mismatch=$resolved expected=$commit\" >&2; exit 1; }", "git -C \"$repo_cache\" cat-file -e \"$commit:claudeqq/scripts/src/server_ts/package.json\"", "git -C \"$repo_cache\" cat-file -e \"$commit:claudeqq/scripts/src/server_ts/src\"", "tmp_dir=\"$source_root/.tmp-$commit-$$\"", "rm -rf \"$tmp_dir\"", "mkdir -p \"$tmp_dir\"", "git -C \"$repo_cache\" archive \"$commit\" claudeqq | tar -x -C \"$tmp_dir\"", ...overlayCommands, "printf '%s\\n' \"$commit\" > \"$tmp_dir/.unidesk-source-commit\"", "printf '%s\\n' \"$repo_url\" > \"$tmp_dir/.unidesk-source-repo\"", "printf '%s\\n' \"$service_id\" > \"$tmp_dir/.unidesk-service-id\"", "printf '%s\\n' \"$dockerfile\" > \"$tmp_dir/.unidesk-dockerfile\"", "rm -rf \"$source_dir\"", "mv \"$tmp_dir\" \"$source_dir\"", "test -f \"$source_dir/$dockerfile\"", "test -f \"$source_dir/claudeqq/unidesk-adapter.cjs\"", "test -d \"$source_dir/claudeqq/scripts/src/server_ts/src\"", "echo user_service_artifact_source_host_path=$source_dir", ].join("\n"); const result = await runRemoteBackground(`prepare-${options.serviceId}-source`, script, 300_000); if (!result.ok) throw new Error(`failed to prepare ${options.serviceId} source on D601: ${result.stderr || result.stdout || JSON.stringify(result.raw)}`); return { ok: true, mode: "d601-host-gitee-https-export-with-unidesk-overlay", providerId: d601ProviderId, repoUrl: options.repoUrl, repoFetchUrl, commit: options.commit, serviceId: options.serviceId, dockerfile: options.dockerfile, sourceHostPath, stdoutTail: result.stdout.slice(-4000), }; } async function remoteCreatePipelineRun(manifest: string): Promise { 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 { if (waitMs <= 0) return null; const command = [ "set -euo pipefail", `export KUBECONFIG=${shellQuote(d601Kubeconfig)}`, `printf 'waiting_pipelinerun=%s\\n' ${shellQuote(name)}`, `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*)", " printf 'condition=%s\\n' \"$condition\"", ` kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} --no-headers 2>/dev/null || true`, " exit 0", " ;;", " False*)", " printf 'condition=%s\\n' \"$condition\"", ` kubectl get taskrun,pod -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} 2>/dev/null || true`, " 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 readPipelineRunCondition(name: string): Promise { const result = await runRemoteKubectlRaw([ "set -euo pipefail", `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)"`, "printf '%s\\n' \"$condition\"", ].join("\n"), 30_000, 15_000); const [status = "", reason = "", ...messageParts] = result.stdout.trim().split("\t"); const message = messageParts.join("\t"); return { ok: status === "True" ? true : status === "False" ? false : null, status, reason, message, query: { ok: result.ok, status: result.status, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200), }, }; } function pipelineRunWaitSucceeded(wait: DispatchResult | null, condition: PipelineRunCondition | null): boolean { if (wait === null) return true; if (condition?.ok === true) return true; if (condition?.ok === false) return false; return wait.ok || wait.exitCode === 0 || wait.stdout.includes("condition=True\tSucceeded\t"); } function artifactSummaryDefaults(context: ArtifactSummaryContext): ArtifactSummary { const registry = "127.0.0.1:5000"; const repository = `${registry}/${context.imageRepository}`; return { serviceId: context.serviceId, sourceCommit: context.commit, sourceRepo: context.repoUrl, dockerfile: context.dockerfile, registry, repository, tag: context.commit, imageRef: `${repository}:${context.commit}`, digest: null, digestRef: null, }; } function artifactSummaryField(fields: Map, suffix: string): string | null { const value = fields.get(`user_service_artifact_${suffix}`) ?? fields.get(`backend_core_artifact_${suffix}`) ?? null; return value === null || value.length === 0 ? null : value; } function parseArtifactSummaryFromFields(fields: Map, context: ArtifactSummaryContext): ArtifactSummary { const planned = artifactSummaryDefaults(context); const registry = artifactSummaryField(fields, "registry") ?? planned.registry; const repository = artifactSummaryField(fields, "repository") ?? planned.repository; const tag = artifactSummaryField(fields, "tag") ?? planned.tag; const imageRef = artifactSummaryField(fields, "image") ?? (repository.length > 0 && tag.length > 0 ? `${repository}:${tag}` : planned.imageRef); const digest = artifactSummaryField(fields, "digest"); const digestRef = artifactSummaryField(fields, "digest_ref") ?? (digest === null || digest.length === 0 || repository.length === 0 ? null : `${repository}@${digest}`); return { serviceId: artifactSummaryField(fields, "service_id") ?? planned.serviceId, sourceCommit: artifactSummaryField(fields, "source_commit") ?? planned.sourceCommit, sourceRepo: artifactSummaryField(fields, "source_repo") ?? planned.sourceRepo, dockerfile: artifactSummaryField(fields, "dockerfile") ?? planned.dockerfile, registry, repository, tag, imageRef, digest, digestRef, }; } function parseArtifactSummaryFromOutput(output: string, context: ArtifactSummaryContext): ArtifactSummary { const fields = new Map(); for (const line of output.split(/\r?\n/u)) { const match = /^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)=(.*)$/u.exec(line.trim()); if (match !== null) fields.set(match[1], match[2]); } return parseArtifactSummaryFromFields(fields, context); } async function completeArtifactSummaryFromRegistry(artifact: ArtifactSummary, context: ArtifactSummaryContext): Promise { if (artifact.digest !== null && artifact.digest.length > 0 && artifact.digestRef !== null && artifact.digestRef.length > 0) return artifact; const planned = artifactSummaryDefaults(context); const registry = artifact.registry.length > 0 ? artifact.registry : planned.registry; const repository = artifact.repository.length > 0 ? artifact.repository : planned.repository; const tag = artifact.tag.length > 0 ? artifact.tag : planned.tag; const imageRef = artifact.imageRef.length > 0 ? artifact.imageRef : `${repository}:${tag}`; if (registry !== "127.0.0.1:5000" || !repository.startsWith(`${registry}/`)) return artifact; const repositoryPath = repository.slice(`${registry}/`.length); if (repositoryPath.length === 0 || repositoryPath.includes("..") || tag.length === 0) return artifact; const result = await dispatchSsh([ "set -euo pipefail", `manifest_url=${shellQuote(`http://127.0.0.1:5000/v2/${repositoryPath}/manifests/${tag}`)}`, "headers=$(mktemp /tmp/unidesk-artifact-summary.XXXXXX.headers)", "trap 'rm -f \"$headers\"' EXIT", "curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -D \"$headers\" -o /dev/null \"$manifest_url\"", "manifest_digest=$(awk 'BEGIN{IGNORECASE=1} /^Docker-Content-Digest:/ {gsub(/\\r/, \"\", $2); print $2; exit}' \"$headers\")", "test -n \"$manifest_digest\"", "printf 'artifact_registry_manifest_digest=%s\\n' \"$manifest_digest\"", ].join("\n"), 60_000, 45_000); const digest = /^artifact_registry_manifest_digest=(sha256:[0-9a-f]{64})$/mu.exec(result.stdout)?.[1] ?? null; if (!result.ok || digest === null) return { ...artifact, registry, repository, tag, imageRef }; return { ...artifact, registry, repository, tag, imageRef, digest, digestRef: `${repository}@${digest}`, }; } function missingArtifactSummaryFields(artifact: ArtifactSummary): string[] { const missing: string[] = []; if (artifact.serviceId.length === 0) missing.push("serviceId"); if (!/^[0-9a-f]{40}$/u.test(artifact.sourceCommit)) missing.push("sourceCommit"); if (artifact.sourceRepo.length === 0) missing.push("sourceRepo"); if (artifact.dockerfile.length === 0) missing.push("dockerfile"); if (artifact.imageRef.length === 0) missing.push("imageRef"); if (artifact.tag.length === 0) missing.push("tag"); if (artifact.digest === null || artifact.digest.length === 0) missing.push("digest"); if (artifact.digestRef === null || artifact.digestRef.length === 0) missing.push("digestRef"); return missing; } function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void { const missing = missingArtifactSummaryFields(artifact); if (missing.length > 0) { throw new Error(`artifact summary for ${pipelineRun} is missing required field(s): ${missing.join(", ")}`); } } async function readArtifactSummaryFromPipelineRun(name: string, context: ArtifactSummaryContext): Promise { const result = await runRemoteKubectlRaw([ "set -euo pipefail", `kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o json`, ].join("\n"), 60_000, 45_000); if (result.ok && result.stdout.trim().length > 0) { try { const parsed = JSON.parse(result.stdout) as unknown; const fields = new Map(); const list = asRecord(parsed); const items = Array.isArray(list?.items) ? list.items : []; for (const item of items) { const taskRun = asRecord(item); const status = asRecord(taskRun?.status); const results = Array.isArray(status?.results) ? status.results : []; for (const rawResult of results) { const taskResult = asRecord(rawResult); const nameValue = asString(taskResult?.name); const value = asString(taskResult?.value); if (/^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)$/u.test(nameValue) && value.length > 0) { fields.set(nameValue, value); } } } if (fields.size > 0) { const fromResults = parseArtifactSummaryFromFields(fields, context); if (missingArtifactSummaryFields(fromResults).length === 0) return fromResults; const completedFromResults = await completeArtifactSummaryFromRegistry(fromResults, context); if (missingArtifactSummaryFields(completedFromResults).length === 0) return completedFromResults; } } catch { // Fall back to pod logs below; a malformed diagnostic line must not mask a succeeded PipelineRun. } } return completeArtifactSummaryFromRegistry(parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), context), context); } async function readPipelineRunLogText(name: string): Promise { const result = await runRemoteKubectlRaw([ "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=240 || true; done`, ].join("\n"), 60_000, 45_000); return `${result.stdout}\n${result.stderr}`.trim(); } async function run(options: CiOptions): Promise> { const name = await remoteCreatePipelineRun(pipelineRunManifest(options)); const wait = await waitForPipelineRun(name, options.waitMs); const condition = wait === null ? null : await readPipelineRunCondition(name); const waitSucceeded = pipelineRunWaitSucceeded(wait, condition); return { ok: waitSucceeded, pipelineRun: name, namespace: "unidesk-ci", repoUrl: options.repoUrl, revision: options.revision, wait: wait === null ? null : { ok: waitSucceeded, dispatchOk: wait.ok, dispatchStatus: wait.status, dispatchExitCode: wait.exitCode, stdoutTail: wait.stdout.slice(-6000), stderrTail: wait.stderr.slice(-6000), }, condition, next: [ `bun scripts/cli.ts ci logs ${name}`, "bun scripts/cli.ts ci status", ], }; } async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPublishBackendCoreOptions): Promise> { const summaryContext: ArtifactSummaryContext = { serviceId: "backend-core", commit: options.commit, repoUrl: options.repoUrl, dockerfile: options.dockerfile, imageRepository: options.imageRepository, }; const plannedArtifact = artifactSummaryDefaults(summaryContext); if (options.dryRun) { return { ok: true, mode: "dry-run", pipeline: "unidesk-backend-core-artifact-publish", namespace: "unidesk-ci", repoUrl: options.repoUrl, commit: options.commit, sourceHostPath: options.sourceHostPath, source: { ok: true, mode: "planned-only", providerId: d601ProviderId, repoUrl: options.repoUrl, repoFetchUrl: repoSshUrl(options.repoUrl), commit: options.commit, dockerfile: options.dockerfile, imageRepository: options.imageRepository, sourceHostPath: options.sourceHostPath, }, artifact: plannedArtifact.imageRef, artifactSummary: plannedArtifact, boundary: "dry-run only; no D601 source export, no Tekton submission, no production mutation", next: [ `bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`, ], }; } const source = await prepareBackendCoreArtifactSource(config, options); const name = await remoteCreatePipelineRun(backendCoreArtifactPipelineRunManifest(options)); const wait = await waitForPipelineRun(name, options.waitMs); const condition = wait === null ? null : await readPipelineRunCondition(name); const waitSucceeded = pipelineRunWaitSucceeded(wait, condition); const artifact = waitSucceeded && wait !== null ? await readArtifactSummaryFromPipelineRun(name, summaryContext) : plannedArtifact; if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name); return { ok: waitSucceeded, pipelineRun: name, namespace: "unidesk-ci", repoUrl: options.repoUrl, commit: options.commit, source, artifact: artifact.imageRef, artifactSummary: artifact, boundary: "CI publishes the image to D601 registry; CD must pull it and must not build backend-core", wait: wait === null ? null : { ok: waitSucceeded, dispatchOk: wait.ok, dispatchStatus: wait.status, dispatchExitCode: wait.exitCode, stdoutTail: wait.stdout.slice(-6000), stderrTail: wait.stderr.slice(-6000), }, condition, next: [ `bun scripts/cli.ts ci logs ${name}`, `bun scripts/cli.ts deploy apply --env prod --service backend-core --commit ${options.commit}`, ], }; } async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions): Promise> { const summaryContext: ArtifactSummaryContext = { serviceId: options.serviceId, dockerfile: options.dockerfile, commit: options.commit, repoUrl: options.repoUrl, imageRepository: options.imageRepository, }; const plannedArtifact = artifactSummaryDefaults(summaryContext); const plannedRepoFetchUrl = repoSshUrl(options.repoUrl); if (options.dryRun) { return { ok: true, mode: "dry-run", pipeline: "unidesk-user-service-artifact-publish", namespace: "unidesk-ci", repoUrl: options.repoUrl, commit: options.commit, serviceId: options.serviceId, sourceHostPath: options.sourceHostPath, source: { ok: true, mode: "planned-only", providerId: d601ProviderId, repoUrl: options.repoUrl, repoFetchUrl: plannedRepoFetchUrl, repoProbeUrl: repoConnectivityProbeUrl(plannedRepoFetchUrl), commit: options.commit, serviceId: options.serviceId, dockerfile: options.dockerfile, imageRepository: options.imageRepository, sourceHostPath: options.sourceHostPath, ...(options.serviceId === "claudeqq" ? { overlay: "UniDesk claudeqq Dockerfile and unidesk-adapter.cjs are injected before Tekton build" } : {}), }, artifact: plannedArtifact.imageRef, artifactSummary: plannedArtifact, boundary: "dry-run only; no D601 source export, no Tekton submission, no production mutation", next: [ `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`, ], }; } const source = options.serviceId === "claudeqq" ? await prepareClaudeqqArtifactSource(config, options) : await prepareUserServiceArtifactSource(config, options); const name = await remoteCreatePipelineRun(userServiceArtifactPipelineRunManifest(options)); const wait = await waitForPipelineRun(name, options.waitMs); const condition = wait === null ? null : await readPipelineRunCondition(name); const waitSucceeded = pipelineRunWaitSucceeded(wait, condition); const artifact = waitSucceeded && wait !== null ? await readArtifactSummaryFromPipelineRun(name, summaryContext) : plannedArtifact; if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name); return { ok: waitSucceeded, pipelineRun: name, namespace: "unidesk-ci", repoUrl: options.repoUrl, commit: options.commit, serviceId: options.serviceId, source, artifact: artifact.imageRef, artifactSummary: artifact, boundary: "CI publishes the user-service image to the D601 registry only; it must not deploy production or mutate the production namespace", wait: wait === null ? null : { ok: waitSucceeded, dispatchOk: wait.ok, dispatchStatus: wait.status, dispatchExitCode: wait.exitCode, stdoutTail: wait.stdout.slice(-6000), stderrTail: wait.stderr.slice(-6000), }, condition, next: [ `bun scripts/cli.ts ci logs ${name}`, "use artifactSummary.imageRef or artifactSummary.digestRef as later dev/prod deployment input", ], }; } async function runRemoteDevE2ELauncher(options: CiDevE2EOptions): Promise { 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 { 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`); const codeQueueService = services.find((service) => service.id === "code-queue"); if (codeQueueService === undefined) { throw new Error(`origin/${desiredRef}:deploy.json environments.dev.services must include code-queue for ci run-dev-e2e`); } if (!/^[0-9a-f]{40}$/u.test(codeQueueService.commitId)) { throw new Error(`origin/${desiredRef}:deploy.json environments.dev.services code-queue commitId must be a full 40-character SHA`); } 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> { 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> { 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 catalogArtifactDescriptor(artifact: CiCatalogArtifact): Record { if (artifact.kind === "source-build") { return { serviceId: artifact.serviceId, kind: artifact.kind, status: artifact.status, producer: artifact.producer, source: artifact.source, image: artifact.image, ...(artifact.notes === undefined ? {} : { notes: artifact.notes }), ...(artifact.blockedReason === undefined ? {} : { blockedReason: artifact.blockedReason }), }; } return { serviceId: artifact.serviceId, kind: artifact.kind, status: artifact.status, producer: artifact.producer, upstream: artifact.upstream, blockedReason: artifact.blockedReason, ...(artifact.notes === undefined ? {} : { notes: artifact.notes }), }; } export function ciHelp(): Record { const catalog = loadCiCatalog(); return { command: "ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs", description: "Manage the D601 k3s Tekton CI gate. CI may publish commit-pinned image artifacts, but it intentionally does not deploy CD.", examples: [ "bun scripts/cli.ts ci install", "bun scripts/cli.ts ci run --revision ", "bun scripts/cli.ts ci publish-backend-core --commit ", "bun scripts/cli.ts ci publish-user-service --service baidu-netdisk --commit ", "bun scripts/cli.ts ci publish-user-service --service mdtodo --commit ", "bun scripts/cli.ts ci publish-user-service --service claudeqq --commit ", "bun scripts/cli.ts ci publish-user-service --service code-queue --commit ", "bun scripts/cli.ts ci publish-user-service --service decision-center --commit ", "bun scripts/cli.ts ci publish-user-service --service frontend --commit ", "bun scripts/cli.ts ci run-dev-e2e --wait-ms 600000", "bun scripts/cli.ts ci logs ", ], tekton: { pipelineVersion: tektonPipelineVersion, triggersVersion: tektonTriggersVersion, sources: { pipeline: tektonPipelineReleaseUrl, triggers: tektonTriggersReleaseUrl, interceptors: tektonTriggersInterceptorsUrl, }, }, backendCoreArtifact: { producer: "D601 CI", registry: "127.0.0.1:5000/unidesk/backend-core:", cdCommand: "bun scripts/cli.ts deploy apply --env prod --service backend-core --commit ", }, userServiceArtifact: { producer: "D601 CI", command: "bun scripts/cli.ts ci publish-user-service --service --commit ", supportedServices: supportedSourceBuildArtifactIds().filter((serviceId) => serviceId !== "backend-core"), blockedServices: blockedCatalogArtifactIds(), registry: "127.0.0.1:5000/unidesk/:", outputFields: ["serviceId", "sourceCommit", "sourceRepo", "dockerfile", "imageRef", "tag", "digest", "digestRef"], summaryContract: catalog.summaryContract, catalogSummary: catalogSummary(), catalog: catalog.artifacts.map(catalogArtifactDescriptor), boundary: "artifact producer only; no prod deploy and no production namespace mutation", frontendNext: [ "bun scripts/cli.ts deploy apply --env dev --service frontend", "bun scripts/cli.ts deploy apply --env prod --service frontend", ], }, 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> { const [action = "status", nameArg] = args; if (isHelpArg(action) || args.slice(1).some(isHelpArg)) return ciHelp(); 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 === "publish-backend-core") { if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) { throw new Error("ci publish-backend-core reads source repo from CI.json; edit CI.json instead of using --repo"); } const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision")); const waitMs = numberOption(args, "--wait-ms", 0); const dryRun = boolFlag(args, "--dry-run"); const artifact = resolveCatalogArtifact("backend-core"); if (artifact.kind !== "source-build") throw new Error("backend-core must be modeled as a source-build artifact in CI.json"); if (artifact.status === "blocked") return blockedArtifactResult(artifact, commit, blockedReason(artifact)); return publishBackendCoreArtifact(config, { repoUrl: artifact.source.repo, commit, waitMs, sourceHostPath: backendCoreArtifactSourceHostPath(commit), dockerfile: artifact.source.dockerfile, imageRepository: artifact.image.repository, dryRun, }); } if (action === "publish-user-service") { const serviceId = requireServiceId(stringOption(args, "--service") ?? stringOption(args, "--service-id")); const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision")); const waitMs = numberOption(args, "--wait-ms", 0); const dryRun = boolFlag(args, "--dry-run"); if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) { throw new Error("ci publish-user-service reads source repo from CI.json; edit CI.json instead of using --repo"); } const artifact = resolveCatalogArtifact(serviceId); if (artifact.kind === "source-build" && artifact.serviceId === "backend-core") { throw new Error("backend-core uses ci publish-backend-core; publish-user-service is for registered user services"); } if (artifact.kind === "upstream-image") { return blockedArtifactResult(artifact, commit, artifact.blockedReason); } if (artifact.status === "blocked") { return blockedArtifactResult(artifact, commit, blockedReason(artifact)); } const repoUrl = artifact.source.repo; const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`); const configService = config.microservices.find((item) => item.id === serviceId); if (configService !== undefined) { const isD601K3sService = configService.providerId === d601ProviderId && configService.development.providerId === d601ProviderId && configService.deployment.mode === "k3sctl-managed"; const isD601DirectService = configService.providerId === d601ProviderId && configService.development.providerId === d601ProviderId && configService.deployment.mode === "unidesk-direct"; const isMainServerDirectService = configService.providerId === "main-server" && configService.development.providerId === "main-server" && configService.deployment.mode === "unidesk-direct"; const isMainServerInternalSidecar = configService.providerId === "main-server" && configService.development.providerId === "main-server" && configService.deployment.mode === "internal-sidecar"; if (!isD601K3sService && !isD601DirectService && !isMainServerDirectService && !isMainServerInternalSidecar) { return { ok: false, status: "blocked", error: "blocked", serviceId, commit, reason: `config.json marks ${serviceId} as ${configService.providerId}/${configService.deployment.mode}, which is outside the reviewed CI artifact producer boundary`, catalogArtifact: artifact, configService: { providerId: configService.providerId, deploymentMode: configService.deployment.mode, }, }; } } return publishUserServiceArtifact(config, { repoUrl, commit, waitMs, serviceId, dockerfile, imageRepository: artifact.image.repository, sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit), dryRun, }); } 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, publish-backend-core, publish-user-service, run-dev-e2e, logs"); } export function startCiInstallJob(): Record { const job = startJob("ci_install", ["bun", "scripts/cli.ts", "ci", "install"], "Install/refresh Tekton CI on D601 k3s"); return { ok: true, job }; }