345 lines
16 KiB
TypeScript
345 lines
16 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. remote module for scripts/src/ci.ts.
|
|
|
|
// Moved mechanically from scripts/src/ci.ts:888-1205 for #903.
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, 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 { jobWithTail, listJobs, readJob, startJob } from "../jobs";
|
|
import { coreInternalFetch } from "../microservices";
|
|
import {
|
|
artifactRegistryReadonlyResultFromCommand,
|
|
buildArtifactRegistryReadonlyProbe,
|
|
parseArtifactRegistryOptions,
|
|
type ArtifactRegistryReadonlyProbe,
|
|
} from "../artifact-registry";
|
|
import { d601K3sGuardShellLines, d601NativeKubeconfig } from "../d601-k3s-guard";
|
|
import { runSshCommandCapture } from "../ssh";
|
|
|
|
import type { DispatchResult, PublishPreflightTransport } from "./types";
|
|
import { status } from "./cleanup";
|
|
import { boundedHintLine, ciTargetGuardShellLines, extractCiLogFailureHints, shellQuote, tailTextLines } from "./options";
|
|
import { asRecord, asString, chunks, coreBody, emitCiInstallProgress, hostSshBase64UploadChunkChars } from "./preflight";
|
|
import { artifactRegistryProbeCommand, dispatchReadonlySsh } from "./runner-preflight";
|
|
import { ciRuntimeImages, ciTarget, providerDispatchCompletionLagMs, providerGatewayWsEgressProxyUrl } from "./types";
|
|
|
|
export const localPublishPreflightTransport: PublishPreflightTransport = {
|
|
kind: "local-docker",
|
|
coreFetch: (path, init) => coreInternalFetch(path, init),
|
|
dispatchHostSsh: dispatchReadonlySsh,
|
|
commandCwd: repoRoot,
|
|
artifactRegistryCommand: artifactRegistryProbeCommand,
|
|
};
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export async function dispatchSsh(command: string, waitMs: number, remoteTimeoutMs: number, pollCompletion = true, target = ciTarget(null)): Promise<DispatchResult> {
|
|
const dispatchResponse = coreInternalFetch("/api/dispatch", {
|
|
method: "POST",
|
|
timeoutMs: Math.min(Math.max(waitMs, 15_000), 45_000),
|
|
body: {
|
|
providerId: target.providerId,
|
|
command: "host.ssh",
|
|
payload: {
|
|
source: "ci-cli",
|
|
mode: "exec",
|
|
command,
|
|
timeoutMs: remoteTimeoutMs,
|
|
cwd: target.hostCwd,
|
|
},
|
|
},
|
|
});
|
|
const dispatchBody = coreBody(dispatchResponse);
|
|
const taskId = asString(dispatchBody?.taskId);
|
|
if (dispatchBody?.ok !== true || taskId.length === 0) {
|
|
const failureKind = asRecord(dispatchResponse)?.timedOut === true ? "backend-core-dispatch-timeout" : "backend-core-dispatch-submit-failed";
|
|
return {
|
|
ok: false,
|
|
taskId: taskId || null,
|
|
status: null,
|
|
stdout: "",
|
|
stderr: asString(dispatchBody?.error) || `${failureKind}: dispatch did not return a task id`,
|
|
exitCode: null,
|
|
raw: {
|
|
failureKind,
|
|
dispatchResponse,
|
|
timeoutMs: Math.min(Math.max(waitMs, 15_000), 45_000),
|
|
},
|
|
};
|
|
}
|
|
if (!pollCompletion) {
|
|
return {
|
|
ok: true,
|
|
taskId,
|
|
status: "submitted",
|
|
stdout: "",
|
|
stderr: "",
|
|
exitCode: null,
|
|
raw: dispatchBody,
|
|
};
|
|
}
|
|
const effectiveWaitMs = Math.max(waitMs, Math.min(remoteTimeoutMs + providerDispatchCompletionLagMs, 120_000));
|
|
const deadline = Date.now() + Math.max(effectiveWaitMs, 1_000);
|
|
let latest: unknown = null;
|
|
while (Date.now() < deadline) {
|
|
latest = coreInternalFetch(`/api/tasks/${encodeURIComponent(taskId)}`, { maxResponseBytes: 3_000_000, timeoutMs: 15_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(effectiveWaitMs, 1_000)}ms`,
|
|
exitCode: null,
|
|
raw: latest,
|
|
};
|
|
}
|
|
|
|
export async function runRemoteKubectl(script: string, waitMs = 60_000, remoteTimeoutMs = 45_000, target = ciTarget(null)): Promise<DispatchResult> {
|
|
const result = await runRemoteKubectlRaw(script, waitMs, remoteTimeoutMs, target);
|
|
if (!result.ok) {
|
|
throw new Error(`${target.providerId} kubectl command failed: ${result.stderr || result.stdout || JSON.stringify(result.raw)}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function runRemoteKubectlRaw(script: string, waitMs = 60_000, remoteTimeoutMs = 45_000, target = ciTarget(null)): Promise<DispatchResult> {
|
|
const command = [
|
|
"set -eu",
|
|
...ciTargetGuardShellLines(target, { passOutput: "stderr" }),
|
|
script,
|
|
].join("\n");
|
|
return dispatchSsh(command, waitMs, remoteTimeoutMs, true, target);
|
|
}
|
|
|
|
export async function uploadRemoteBase64(path: string, encoded: string, target = ciTarget(null)): 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, true, target);
|
|
if (!init.ok) return init;
|
|
// D601 provider-gateway rejects long host.ssh commands; keep each append
|
|
// envelope comfortably below the transport limit.
|
|
for (const chunk of chunks(encoded, hostSshBase64UploadChunkChars)) {
|
|
const append = await dispatchSsh([
|
|
"set -euo pipefail",
|
|
`target=${shellQuote(path)}`,
|
|
`printf %s ${shellQuote(chunk)} >> "$target"`,
|
|
].join("\n"), 20_000, 10_000, true, target);
|
|
if (!append.ok) return append;
|
|
}
|
|
return dispatchSsh([
|
|
"set -euo pipefail",
|
|
`target=${shellQuote(path)}`,
|
|
`expected=${shellQuote(String(Buffer.byteLength(encoded)))}`,
|
|
"actual=$(wc -c < \"$target\" | tr -d ' ')",
|
|
"printf 'uploaded_bytes=%s expected_bytes=%s path=%s\\n' \"$actual\" \"$expected\" \"$target\"",
|
|
"test \"$actual\" = \"$expected\"",
|
|
].join("\n"), 20_000, 10_000, true, target);
|
|
}
|
|
|
|
export async function runRemoteBackground(label: string, script: string, timeoutMs: number, target = ciTarget(null)): 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, target);
|
|
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, true, target);
|
|
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, true, target);
|
|
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`,
|
|
};
|
|
}
|
|
|
|
export async function remoteApplyManifest(config: UniDeskConfig, path: string, target = ciTarget(null)): Promise<void> {
|
|
const absolute = rootPath(path);
|
|
if (!existsSync(absolute)) throw new Error(`manifest not found: ${path}`);
|
|
const manifest = readFileSync(absolute, "utf8");
|
|
const encoded = Buffer.from(manifest, "utf8").toString("base64");
|
|
emitCiInstallProgress("upload-manifest", "started", { providerId: target.providerId, manifest: path, bytes: Buffer.byteLength(manifest) });
|
|
const script = [
|
|
"set -eu",
|
|
...ciTargetGuardShellLines(target),
|
|
"tmp=$(mktemp /tmp/unidesk-ci-apply.XXXXXX.yaml)",
|
|
"trap 'rm -f \"$tmp\"' EXIT",
|
|
"base64 -d > \"$tmp\" <<'UNIDESK_CI_MANIFEST_B64'",
|
|
encoded,
|
|
"UNIDESK_CI_MANIFEST_B64",
|
|
"test -s \"$tmp\"",
|
|
"printf 'manifest_bytes=%s\\n' \"$(wc -c < \"$tmp\" | tr -d ' ')\"",
|
|
"kubectl apply -f \"$tmp\"",
|
|
].join("\n");
|
|
emitCiInstallProgress("kubectl-apply", "started", { providerId: target.providerId, manifest: path });
|
|
const result = await runSshCommandCapture(config, `${target.providerId}:k3s`, ["sh"], script);
|
|
if (result.exitCode !== 0) throw new Error(`kubectl apply failed for ${path}: ${result.stderr || result.stdout}`);
|
|
emitCiInstallProgress("upload-manifest", "succeeded", { providerId: target.providerId, manifest: path, upload: result.stdout.split(/\r?\n/u).find((line) => line.startsWith("manifest_bytes=")) ?? "" });
|
|
emitCiInstallProgress("kubectl-apply", "succeeded", { providerId: target.providerId, manifest: path });
|
|
}
|
|
|
|
export async function prewarmCiRuntimeImages(target = ciTarget(null)): Promise<void> {
|
|
const images = ciRuntimeImages.map(shellQuote).join(" ");
|
|
const script = [
|
|
"set -euo pipefail",
|
|
...ciTargetGuardShellLines(target),
|
|
"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",
|
|
"root_exec() {",
|
|
" if [ \"$(id -u)\" = \"0\" ]; then \"$@\"; return $?; fi",
|
|
" if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo \"$@\"; return $?; fi",
|
|
" if [ -x /mnt/c/Windows/System32/wsl.exe ]; then /mnt/c/Windows/System32/wsl.exe -u root -- \"$@\"; return $?; fi",
|
|
" echo ci_runtime_image_containerd_root_required=true >&2",
|
|
" \"$@\"",
|
|
"}",
|
|
"containerd_images=$(root_exec 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",
|
|
"root_exec 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",
|
|
"root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F 'docker.io/rancher/mirrored-pause:3.6' >/dev/null",
|
|
"root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F 'docker.io/oven/bun:1-debian' >/dev/null",
|
|
"root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F 'docker.io/alpine/git:2.45.2' >/dev/null",
|
|
`root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F ${shellQuote(`docker.io/library/${target.codeQueueImage}`)} >/dev/null`,
|
|
].join("\n");
|
|
const result = await runRemoteBackground("prewarm-runtime-images", script, 900_000, target);
|
|
if (!result.ok) {
|
|
const combined = `${result.stdout}\n${result.stderr}`.trim();
|
|
const failureLines = extractCiLogFailureHints(combined).concat(
|
|
combined.split(/\r?\n/u).filter((line) => /ci_runtime_image_|containerd_|root_required|sudo/iu.test(line)).map(boundedHintLine).slice(-40),
|
|
);
|
|
throw new Error(`CI runtime image prewarm failed: ${JSON.stringify({
|
|
providerId: target.providerId,
|
|
exitCode: result.exitCode,
|
|
failureLines: Array.from(new Set(failureLines)).slice(-50),
|
|
stdoutTail: tailTextLines(result.stdout, 80),
|
|
stderrTail: tailTextLines(result.stderr, 80),
|
|
recovery: [
|
|
"If Tekton is already installed and only CI manifests need refreshing, rerun with: bun scripts/cli.ts ci install --skip-prewarm",
|
|
"If runtime helper images are missing from containerd, restore passwordless/root containerd import on the provider and rerun without --skip-prewarm.",
|
|
],
|
|
})}`);
|
|
}
|
|
}
|