merge: code queue claim move race fix
# Conflicts: # AGENTS.md # TEST.md # config.json # deploy.json # docs/reference/cli.md # docs/reference/deploy.md # docs/reference/microservices.md # scripts/cli.ts # scripts/src/check.ts # scripts/src/e2e.ts # scripts/src/remote.ts # src/components/microservices/code-queue/src/index.ts # src/components/microservices/code-queue/src/queue-api.ts # src/components/microservices/decision-center/Dockerfile # src/components/microservices/decision-center/src/index.ts
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
interface TimingSample {
|
||||
label: string;
|
||||
method: string;
|
||||
url: string;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
durationMs: number;
|
||||
bytes: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
interface CandidateTask {
|
||||
id: string;
|
||||
status: string;
|
||||
stepCount: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TraceCandidate {
|
||||
seq: number | null;
|
||||
total: number | null;
|
||||
durationMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface PerfCheck {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
valueMs: number;
|
||||
budgetMs: number;
|
||||
hard: boolean;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
function envNumber(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.length === 0) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
return (process.env.CI_CODE_QUEUE_URL ?? "http://code-queue-ci-read.unidesk-ci.svc.cluster.local:4222").replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
function terminalStatus(status: string): boolean {
|
||||
return status === "succeeded" || status === "failed" || status === "canceled";
|
||||
}
|
||||
|
||||
async function fetchSample(label: string, url: string, timeoutMs = 30_000): Promise<TimingSample> {
|
||||
const started = performance.now();
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
const text = await response.text();
|
||||
return {
|
||||
label,
|
||||
method: "GET",
|
||||
url,
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
durationMs: Math.round((performance.now() - started) * 10) / 10,
|
||||
bytes: text.length,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
label,
|
||||
method: "GET",
|
||||
url,
|
||||
ok: false,
|
||||
status: 0,
|
||||
durationMs: Math.round((performance.now() - started) * 10) / 10,
|
||||
bytes: 0,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function percentile(values: number[], percentileValue: number): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = values.slice().sort((left, right) => left - right);
|
||||
if (percentileValue <= 0) return sorted[0] ?? 0;
|
||||
if (percentileValue >= 100) return sorted[sorted.length - 1] ?? 0;
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((percentileValue / 100) * sorted.length) - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
async function candidateTasks(url: string): Promise<CandidateTask[]> {
|
||||
const response = await fetch(`${url}/api/tasks/overview?limit=48&transcriptLimit=0&compact=1&selected=0&includeActive=0&stats=0&skipTrace=1`, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = await response.json() as { tasks?: Array<{ id?: string; status?: string; stepCount?: number; llmStepCount?: number; updatedAt?: string }> };
|
||||
const tasks = (body.tasks ?? [])
|
||||
.map((task): CandidateTask | null => {
|
||||
if (typeof task.id !== "string" || task.id.length === 0) return null;
|
||||
const stepCount = Number(task.stepCount ?? task.llmStepCount);
|
||||
return {
|
||||
id: task.id,
|
||||
status: typeof task.status === "string" ? task.status : "",
|
||||
stepCount: Number.isFinite(stepCount) && stepCount >= 0 ? Math.floor(stepCount) : null,
|
||||
updatedAt: typeof task.updatedAt === "string" ? task.updatedAt : "",
|
||||
};
|
||||
})
|
||||
.filter((task): task is CandidateTask => task !== null);
|
||||
const ordered = [
|
||||
...tasks.filter((task) => terminalStatus(task.status) && (task.stepCount ?? 0) > 0 && (task.stepCount ?? 0) <= 300),
|
||||
...tasks.filter((task) => terminalStatus(task.status) && ((task.stepCount ?? 0) === 0 || task.stepCount === null)),
|
||||
...tasks.filter((task) => terminalStatus(task.status)),
|
||||
...tasks.filter((task) => !terminalStatus(task.status) && task.status !== "queued" && task.status !== "running" && task.status !== "judging"),
|
||||
...tasks,
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return ordered.filter((task) => {
|
||||
if (seen.has(task.id)) return false;
|
||||
seen.add(task.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function traceSeq(url: string, taskId: string, timeoutMs: number): Promise<TraceCandidate> {
|
||||
const started = performance.now();
|
||||
try {
|
||||
const response = await fetch(`${url}/api/tasks/${encodeURIComponent(taskId)}/trace-steps?tail=1&limit=1`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const body = await response.json() as { total?: number; steps?: Array<{ seq?: number }> };
|
||||
const durationMs = Math.round((performance.now() - started) * 10) / 10;
|
||||
if (!response.ok) return { seq: null, total: null, durationMs, error: `status=${response.status}` };
|
||||
const seq = body.steps?.find((step) => Number.isFinite(Number(step.seq)))?.seq;
|
||||
return {
|
||||
seq: Number.isFinite(Number(seq)) ? Number(seq) : null,
|
||||
total: Number.isFinite(Number(body.total)) ? Number(body.total) : null,
|
||||
durationMs,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
seq: null,
|
||||
total: null,
|
||||
durationMs: Math.round((performance.now() - started) * 10) / 10,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function traceTarget(url: string): Promise<{ taskId: string; skippedTaskIds: string[]; selection: JsonValue }> {
|
||||
const tasks = await candidateTasks(url);
|
||||
if (tasks.length === 0) throw new Error("Code Queue CI perf could not find a task id in the production PostgreSQL task table");
|
||||
const target = tasks[0];
|
||||
if (target === undefined) throw new Error("Code Queue CI perf could not select a task from the production PostgreSQL task table");
|
||||
return { taskId: target.id, skippedTaskIds: tasks.slice(1).map((task) => task.id), selection: target as unknown as JsonValue };
|
||||
}
|
||||
|
||||
async function measureFirstPaint(url: string): Promise<Record<string, unknown>> {
|
||||
const sample = await fetchSample("code-queue-read-first-paint-proxy", `${url}/api/tasks/overview?limit=12&transcriptLimit=1&compact=1&selected=0&includeActive=0&stats=0&skipTrace=1`, 60_000);
|
||||
return {
|
||||
ok: sample.ok,
|
||||
url: sample.url,
|
||||
firstPaintMs: sample.durationMs,
|
||||
apiTimings: [sample],
|
||||
consoleErrors: [],
|
||||
note: "Code Queue service is API-only in k3s; this measures the first overview payload used by the frontend Code Queue page.",
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const url = baseUrl();
|
||||
const budgets = {
|
||||
firstPaintMs: envNumber("FIRST_PAINT_BUDGET_MS", 2000),
|
||||
traceSummaryMs: envNumber("TRACE_SUMMARY_BUDGET_MS", 10_000),
|
||||
traceStepsMs: envNumber("TRACE_STEPS_BUDGET_MS", 900),
|
||||
traceStepDetailMs: envNumber("TRACE_STEP_DETAIL_BUDGET_MS", 700),
|
||||
overviewP95Ms: envNumber("OVERVIEW_P95_BUDGET_MS", 900),
|
||||
};
|
||||
const health = await fetchSample("health", `${url}/health`);
|
||||
if (!health.ok) throw new Error(`Code Queue CI read health failed: ${JSON.stringify(health)}`);
|
||||
const target = await traceTarget(url);
|
||||
const { taskId } = target;
|
||||
const firstPaint = await measureFirstPaint(url);
|
||||
const traceSummary = await fetchSample("trace-summary", `${url}/api/tasks/${encodeURIComponent(taskId)}/trace-summary`);
|
||||
const overviewSamples: TimingSample[] = [];
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
overviewSamples.push(await fetchSample("overview", `${url}/api/tasks/overview?limit=12&transcriptLimit=1&compact=1&selected=0&includeActive=0&stats=0&skipTrace=1&__ci=${Date.now()}-${index}`));
|
||||
}
|
||||
const traceProbe = await traceSeq(url, taskId, Math.max(10_000, Math.min(30_000, budgets.traceStepsMs)));
|
||||
const seq = traceProbe.seq ?? 0;
|
||||
const traceSteps = await fetchSample("trace-steps", `${url}/api/tasks/${encodeURIComponent(taskId)}/trace-steps?tail=1&limit=1`, Math.max(10_000, Math.min(30_000, budgets.traceStepsMs)));
|
||||
const traceStepDetail = seq > 0
|
||||
? await fetchSample("trace-step-detail", `${url}/api/tasks/${encodeURIComponent(taskId)}/trace-step?seq=${encodeURIComponent(String(seq))}`, Math.max(10_000, Math.min(30_000, budgets.traceStepDetailMs)))
|
||||
: {
|
||||
label: "trace-step-detail",
|
||||
method: "GET",
|
||||
url: `${url}/api/tasks/${encodeURIComponent(taskId)}/trace-step?seq=0`,
|
||||
ok: false,
|
||||
status: 0,
|
||||
durationMs: 0,
|
||||
bytes: 0,
|
||||
error: traceProbe.error ?? "trace step seq unavailable",
|
||||
};
|
||||
const overviewSuccessful = overviewSamples.filter((sample) => sample.ok).map((sample) => sample.durationMs);
|
||||
const overviewP95Ms = Math.round(percentile(overviewSuccessful, 95) * 10) / 10;
|
||||
const firstPaintMs = Number((firstPaint as { firstPaintMs?: number }).firstPaintMs ?? 0);
|
||||
const checks: PerfCheck[] = [
|
||||
{ name: "first-paint", ok: firstPaintMs <= budgets.firstPaintMs, valueMs: firstPaintMs, budgetMs: budgets.firstPaintMs, hard: true },
|
||||
{ name: "trace-summary", ok: traceSummary.ok && traceSummary.durationMs <= budgets.traceSummaryMs, valueMs: traceSummary.durationMs, budgetMs: budgets.traceSummaryMs, hard: true },
|
||||
{ name: "overview-p95", ok: overviewSamples.every((sample) => sample.ok) && overviewP95Ms <= budgets.overviewP95Ms, valueMs: overviewP95Ms, budgetMs: budgets.overviewP95Ms, hard: true },
|
||||
{ name: "trace-steps", ok: traceSteps.ok && traceSteps.durationMs <= budgets.traceStepsMs, valueMs: traceSteps.durationMs, budgetMs: budgets.traceStepsMs, hard: false },
|
||||
{ name: "trace-step-detail", ok: traceStepDetail.ok && traceStepDetail.durationMs <= budgets.traceStepDetailMs, valueMs: traceStepDetail.durationMs, budgetMs: budgets.traceStepDetailMs, hard: false },
|
||||
];
|
||||
const hardChecks = checks.filter((check) => check.hard);
|
||||
const result = {
|
||||
ok: hardChecks.every((check) => check.ok),
|
||||
measuredAt: new Date().toISOString(),
|
||||
url,
|
||||
taskId,
|
||||
seq,
|
||||
skippedTaskIds: target.skippedTaskIds,
|
||||
selection: target.selection,
|
||||
budgets,
|
||||
checks,
|
||||
diagnostics: {
|
||||
nonBlockingChecks: checks.filter((check) => !check.hard).map((check) => check.name),
|
||||
traceProbe,
|
||||
},
|
||||
health,
|
||||
firstPaint,
|
||||
traceSummary,
|
||||
traceSteps,
|
||||
traceStepDetail,
|
||||
overview: {
|
||||
p50Ms: Math.round(percentile(overviewSuccessful, 50) * 10) / 10,
|
||||
p95Ms: overviewP95Ms,
|
||||
samples: overviewSamples,
|
||||
},
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -14,6 +14,7 @@ import { runCodeQueueDeployCompatCommand, runDeployCommand } from "./src/deploy"
|
||||
import { runProviderCommand } from "./src/provider-attach";
|
||||
import { runScheduleCommand } from "./src/schedules";
|
||||
import { parseNetworkPerfOptions, runNetworkPerf } from "./src/network-perf";
|
||||
import { runCiCommand } from "./src/ci";
|
||||
|
||||
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
|
||||
const args = remoteOptions.args;
|
||||
@@ -45,6 +46,8 @@ function help(): unknown {
|
||||
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
|
||||
{ command: "microservice health <id>", description: "Probe one user service through backend-core -> provider-gateway HTTP proxy." },
|
||||
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; JSON request bodies are supported for controlled write/debug endpoints." },
|
||||
{ command: "microservice diagnostics <id>", description: "Split k3sctl-managed proxy health into provider-gateway, HTTP tunnel, adapter, Kubernetes API service proxy, and target Service checks." },
|
||||
{ command: "microservice tunnel-self-test <id>", description: "Trigger an expected provider HTTP tunnel failure and verify requestId/stage diagnostics are returned." },
|
||||
{ command: "decision upload <markdown-file> [--title text] [--type meeting|decision] [--level G0|G1|G2|G3|P0|P1|P2|P3|none] [--status active|blocked|parked|done] [--linked-goal-id id] [--evidence url]", description: "Upload a meeting note or decision record through backend-core -> decision-center user-service proxy." },
|
||||
{ command: "decision list [--type ...] [--status ...] [--level ...] [--linked-goal-id id] [--limit N]", description: "List Decision Center records through the user-service proxy." },
|
||||
{ command: "decision show <id>", description: "Show one Decision Center record." },
|
||||
@@ -64,6 +67,7 @@ function help(): unknown {
|
||||
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|microservice.http|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." },
|
||||
{ command: "debug task <taskId|latest>", description: "Read a dispatched task record from internal core for CLI debugging." },
|
||||
{ command: "network perf [--service code-queue --path /api/tasks/overview?limit=30 --count N --concurrency N --label before|after]", description: "Benchmark frontend -> backend-core -> provider/adapter user-service networking and report latency/proxy-mode distributions." },
|
||||
{ command: "ci install|status|run|logs", description: "Manage D601 k3s Tekton CI only; does not deploy CD. CI reads the production PostgreSQL through a temporary read-only Code Queue service." },
|
||||
{ command: "e2e run [--only pattern[,pattern...]] [--skip pattern[,pattern...]]", description: "Run selected public/internal/Playwright E2E checks; use --only for focused iteration and rerun without filters for final regression." },
|
||||
],
|
||||
};
|
||||
@@ -258,6 +262,11 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "ci") {
|
||||
emitJson(commandName, runCiCommand(config, args.slice(1)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "e2e" && sub === "run") {
|
||||
const result = await runE2E(config, parseE2ERunOptions(args.slice(2)));
|
||||
const ok = (result as { ok?: unknown }).ok === true;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
import { startJob } from "./jobs";
|
||||
|
||||
const k3sctlContainerName = "k3sctl-adapter";
|
||||
const k3sctlSshKey = "/run/host-ssh/id_ed25519";
|
||||
const d601SshTarget = "ubuntu@host.docker.internal";
|
||||
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 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",
|
||||
"unidesk-code-queue:d601",
|
||||
];
|
||||
|
||||
interface CiOptions {
|
||||
repoUrl: string;
|
||||
revision: string;
|
||||
waitMs: number;
|
||||
}
|
||||
|
||||
function stringOption(args: string[], name: string): string | null {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberOption(args: string[], name: string, fallback: number): number {
|
||||
const raw = stringOption(args, name);
|
||||
if (raw === null) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireRevision(value: string | null): string {
|
||||
if (value === null || value.length === 0) throw new Error("ci run requires --revision <commit-or-ref>");
|
||||
if (!/^[A-Za-z0-9._/@:-]{1,160}$/u.test(value)) throw new Error("ci --revision contains unsupported characters");
|
||||
return value;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/gu, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function dockerExecK3sctl(args: string[]) {
|
||||
return runCommand(["docker", "exec", k3sctlContainerName, ...args], repoRoot);
|
||||
}
|
||||
|
||||
function dockerExecK3sctlWithInput(args: string[], input: string) {
|
||||
const command = ["docker", "exec", "-i", k3sctlContainerName, ...args];
|
||||
const result = spawnSync(command[0], command.slice(1), {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
input,
|
||||
maxBuffer: 1024 * 1024 * 8,
|
||||
});
|
||||
return {
|
||||
command,
|
||||
cwd: repoRoot,
|
||||
exitCode: result.status,
|
||||
stdout: result.stdout ?? "",
|
||||
stderr: result.stderr ?? result.error?.message ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function remoteKubectlCommand(script: string): string[] {
|
||||
return [
|
||||
"sh",
|
||||
"-lc",
|
||||
[
|
||||
"ssh",
|
||||
"-i",
|
||||
shellQuote(k3sctlSshKey),
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/tmp/unidesk-ci-known-hosts",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
shellQuote(d601SshTarget),
|
||||
shellQuote(`KUBECONFIG=${d601Kubeconfig} bash -lc ${shellQuote(script)}`),
|
||||
].join(" "),
|
||||
];
|
||||
}
|
||||
|
||||
function runRemoteKubectl(script: string) {
|
||||
const result = dockerExecK3sctl(remoteKubectlCommand(script));
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`D601 kubectl command failed: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function remoteApplyManifest(path: string): void {
|
||||
const absolute = rootPath(path);
|
||||
if (!existsSync(absolute)) throw new Error(`manifest not found: ${path}`);
|
||||
const result = dockerExecK3sctlWithInput([
|
||||
"sh",
|
||||
"-lc",
|
||||
[
|
||||
"ssh",
|
||||
"-i",
|
||||
shellQuote(k3sctlSshKey),
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/tmp/unidesk-ci-known-hosts",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
shellQuote(d601SshTarget),
|
||||
shellQuote(`KUBECONFIG=${d601Kubeconfig} kubectl apply -f -`),
|
||||
].join(" "),
|
||||
], readFileSync(absolute, "utf8"));
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`kubectl apply failed for ${path}: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
function prewarmCiRuntimeImages(): void {
|
||||
const images = ciRuntimeImages.map(shellQuote).join(" ");
|
||||
runRemoteKubectl([
|
||||
"set -euo pipefail",
|
||||
"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",
|
||||
"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 'docker.io/library/unidesk-code-queue:d601' >/dev/null",
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
function status(): Record<string, unknown> {
|
||||
const summary = 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: "D601",
|
||||
orchestrator: "native-k3s",
|
||||
tekton: {
|
||||
pipelineVersion: tektonPipelineVersion,
|
||||
triggersVersion: tektonTriggersVersion,
|
||||
},
|
||||
summary: summary.stdout.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function install(): Record<string, unknown> {
|
||||
if (!existsSync(rootPath("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml"))) {
|
||||
throw new Error("CI manifests are missing");
|
||||
}
|
||||
prewarmCiRuntimeImages();
|
||||
runRemoteKubectl([
|
||||
"set -euo pipefail",
|
||||
`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"));
|
||||
remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/tekton-install.yaml");
|
||||
remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml");
|
||||
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 remoteCreatePipelineRun(manifest: string): string {
|
||||
const result = dockerExecK3sctlWithInput([
|
||||
"sh",
|
||||
"-lc",
|
||||
[
|
||||
"ssh",
|
||||
"-i",
|
||||
shellQuote(k3sctlSshKey),
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/tmp/unidesk-ci-known-hosts",
|
||||
shellQuote(d601SshTarget),
|
||||
shellQuote(`KUBECONFIG=${d601Kubeconfig} kubectl create -f - -o jsonpath='{.metadata.name}'`),
|
||||
].join(" "),
|
||||
], manifest);
|
||||
if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function run(options: CiOptions): Record<string, unknown> {
|
||||
const name = remoteCreatePipelineRun(pipelineRunManifest(options));
|
||||
const wait = options.waitMs > 0 ? dockerExecK3sctl(remoteKubectlCommand([
|
||||
"set -euo pipefail",
|
||||
`deadline=$((SECONDS + ${Math.ceil(options.waitMs / 1000)}))`,
|
||||
"while [ \"$SECONDS\" -lt \"$deadline\" ]; do",
|
||||
` condition="$(kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o jsonpath='{range .status.conditions[?(@.type==\"Succeeded\")]}{.status}{\"\\t\"}{.reason}{\"\\t\"}{.message}{end}' 2>/dev/null || true)"`,
|
||||
" case \"$condition\" in",
|
||||
" True*)",
|
||||
" echo \"$condition\"",
|
||||
` kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o json`,
|
||||
" exit 0",
|
||||
" ;;",
|
||||
" False*)",
|
||||
" echo \"$condition\"",
|
||||
` kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o json`,
|
||||
" exit 1",
|
||||
" ;;",
|
||||
" esac",
|
||||
" sleep 2",
|
||||
"done",
|
||||
`echo "Timed out waiting for pipelinerun/${name}" >&2`,
|
||||
`kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o json`,
|
||||
"exit 124",
|
||||
].join("\n"))) : null;
|
||||
const waitSucceeded = wait === null || wait.exitCode === 0 || wait.stdout.trimStart().startsWith("True\tSucceeded\t");
|
||||
return {
|
||||
ok: waitSucceeded,
|
||||
pipelineRun: name,
|
||||
namespace: "unidesk-ci",
|
||||
repoUrl: options.repoUrl,
|
||||
revision: options.revision,
|
||||
wait: wait === null ? null : {
|
||||
stdoutTail: wait.stdout.slice(-6000),
|
||||
stderrTail: wait.stderr.slice(-6000),
|
||||
},
|
||||
next: [
|
||||
`bun scripts/cli.ts ci logs ${name}`,
|
||||
"bun scripts/cli.ts ci status",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function logs(name: string): Record<string, unknown> {
|
||||
if (name.length === 0) throw new Error("ci logs requires PipelineRun name");
|
||||
const result = 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; done`,
|
||||
].join("\n"));
|
||||
return {
|
||||
ok: true,
|
||||
pipelineRun: name,
|
||||
output: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function help(): Record<string, unknown> {
|
||||
return {
|
||||
command: "ci install|status|run|logs",
|
||||
description: "Manage the D601 k3s Tekton CI gate. This intentionally does not deploy CD.",
|
||||
examples: [
|
||||
"bun scripts/cli.ts ci install",
|
||||
"bun scripts/cli.ts ci run --revision <commit>",
|
||||
"bun scripts/cli.ts ci logs <pipelineRun>",
|
||||
],
|
||||
tekton: {
|
||||
pipelineVersion: tektonPipelineVersion,
|
||||
triggersVersion: tektonTriggersVersion,
|
||||
sources: {
|
||||
pipeline: tektonPipelineReleaseUrl,
|
||||
triggers: tektonTriggersReleaseUrl,
|
||||
interceptors: tektonTriggersInterceptorsUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function runCiCommand(_config: UniDeskConfig, args: string[]): Record<string, unknown> {
|
||||
const [action = "status", nameArg] = args;
|
||||
if (action === "help" || action === "--help" || action === "-h") return help();
|
||||
if (action === "install") return install();
|
||||
if (action === "status") return status();
|
||||
if (action === "run") {
|
||||
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk";
|
||||
const revision = requireRevision(stringOption(args, "--revision") ?? stringOption(args, "--commit"));
|
||||
const waitMs = numberOption(args, "--wait-ms", 0);
|
||||
return run({ repoUrl, revision, waitMs });
|
||||
}
|
||||
if (action === "logs") return logs(nameArg ?? "");
|
||||
throw new Error("ci command must be one of: install, status, run, logs");
|
||||
}
|
||||
|
||||
export function startCiInstallJob(): Record<string, unknown> {
|
||||
const job = startJob("ci_install", ["bun", "scripts/cli.ts", "ci", "install"], "Install/refresh Tekton CI on D601 k3s");
|
||||
return { ok: true, job };
|
||||
}
|
||||
@@ -125,11 +125,19 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin
|
||||
const id = requireId(idArg, "microservice health");
|
||||
return coreInternalFetch(`/api/microservices/${encodeId(id)}/health`);
|
||||
}
|
||||
if (action === "diagnostics") {
|
||||
const id = requireId(idArg, "microservice diagnostics");
|
||||
return coreInternalFetch(`/api/microservices/${encodeId(id)}/diagnostics`);
|
||||
}
|
||||
if (action === "tunnel-self-test") {
|
||||
const id = requireId(idArg, "microservice tunnel-self-test");
|
||||
return coreInternalFetch(`/api/microservices/${encodeId(id)}/tunnel-self-test`);
|
||||
}
|
||||
if (action === "proxy") {
|
||||
const id = requireId(idArg, "microservice proxy");
|
||||
const path = requireProxyPath(pathArg);
|
||||
const body = requestBodyOption(args);
|
||||
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: methodOption(args, body !== undefined), body }), args);
|
||||
}
|
||||
throw new Error("microservice command must be one of: list, status, health, proxy");
|
||||
throw new Error("microservice command must be one of: list, status, health, diagnostics, tunnel-self-test, proxy");
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro
|
||||
if (action === "list") {
|
||||
return { transport: "frontend", response: await frontendJson(session, "/api/microservices", undefined, 12_000) };
|
||||
}
|
||||
if ((action === "status" || action === "health") && id !== undefined) {
|
||||
if ((action === "status" || action === "health" || action === "diagnostics" || action === "tunnel-self-test") && id !== undefined) {
|
||||
return {
|
||||
transport: "frontend",
|
||||
response: await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/${action}`, undefined, 18_000),
|
||||
@@ -468,7 +468,7 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro
|
||||
response: summarizeMicroserviceProxyResponse(response, args),
|
||||
};
|
||||
}
|
||||
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | proxy <id> <path>");
|
||||
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | diagnostics <id> | tunnel-self-test <id> | proxy <id> <path>");
|
||||
}
|
||||
|
||||
async function remoteCodeQueue(session: FrontendSession, args: string[]): Promise<unknown> {
|
||||
@@ -559,7 +559,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, {
|
||||
transport: "frontend",
|
||||
baseUrl: session.baseUrl,
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex judge <taskId> --attempt N", "network perf"],
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "microservice list", "microservice status <id>", "microservice health <id>", "microservice diagnostics <id>", "microservice tunnel-self-test <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex judge <taskId> --attempt N", "network perf"],
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["cli.ts", "src/**/*.ts"]
|
||||
"include": ["cli.ts", "src/**/*.ts", "../scripts/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user