237 lines
11 KiB
TypeScript
237 lines
11 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. dev-e2e module for scripts/src/ci.ts.
|
|
|
|
// Moved mechanically from scripts/src/ci.ts:2960-3167 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 { CiDevE2EOptions, DeployDevManifestSummary, DispatchResult } from "./types";
|
|
import { status } from "./cleanup";
|
|
import { shellQuote } from "./options";
|
|
import { asRecord, asString } from "./preflight";
|
|
import { dispatchSsh, positiveManifestNumber, requireCiScriptPath, requireManifestString } from "./remote";
|
|
import { providerGatewayWsEgressProxyUrl } from "./types";
|
|
|
|
export 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);
|
|
}
|
|
|
|
export 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,
|
|
};
|
|
}
|
|
|
|
export 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,
|
|
};
|
|
}
|
|
|
|
export 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);
|
|
}
|
|
|
|
export 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",
|
|
],
|
|
};
|
|
}
|