feat: split backend-core artifact ci cd

This commit is contained in:
Codex
2026-05-18 15:54:01 +00:00
parent d21453cf46
commit 803a695d0a
15 changed files with 713 additions and 56 deletions
+375 -25
View File
@@ -1,8 +1,13 @@
import { createHash } from "node:crypto";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createServer, type AddressInfo, type Server, type Socket } from "node:net";
import { readFileSync, writeFileSync } from "node:fs";
import { runCommand, type CommandResult } from "./command";
import { repoRoot } from "./config";
import { readConfig, repoRoot, rootPath } from "./config";
import { resolveComposeCommand, writeComposeEnv } from "./docker";
import { startJob } from "./jobs";
type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install";
type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core";
interface ArtifactRegistryOptions {
providerId: string;
@@ -17,6 +22,10 @@ interface ArtifactRegistryOptions {
containerName: string;
timeoutMs: number;
dryRun: boolean;
runNow: boolean;
commit: string | null;
localPullPort: number | null;
targetImage: string;
}
interface RenderedFile {
@@ -50,6 +59,10 @@ const defaultOptions: ArtifactRegistryOptions = {
containerName: "unidesk-artifact-registry",
timeoutMs: 30_000,
dryRun: false,
runNow: false,
commit: null,
localPullPort: null,
targetImage: "unidesk-backend-core",
};
function isHelpArg(value: string | undefined): boolean {
@@ -68,18 +81,32 @@ function positiveInt(value: string, option: string): number {
return parsed;
}
function optionalPort(value: string, option: string): number {
const parsed = positiveInt(value, option);
if (parsed > 65535) throw new Error(`${option} must be <= 65535`);
return parsed;
}
function absolutePath(value: string, option: string): string {
if (!value.startsWith("/")) throw new Error(`${option} must be an absolute path`);
if (value.includes("\n") || value.includes("\0")) throw new Error(`${option} must not contain control characters`);
return value.replace(/\/+$/u, "");
}
function commitValue(value: string, option: string): string {
const normalized = value.toLowerCase();
if (!/^[0-9a-f]{40}$/u.test(normalized)) throw new Error(`${option} must be a full 40-character commit SHA`);
return normalized;
}
function parseOptions(args: string[]): ArtifactRegistryOptions {
const options = { ...defaultOptions };
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--dry-run") {
options.dryRun = true;
} else if (arg === "--run-now") {
options.runNow = true;
} else if (arg === "--provider-id") {
options.providerId = requireValue(args, index, arg);
index += 1;
@@ -101,6 +128,15 @@ function parseOptions(args: string[]): ArtifactRegistryOptions {
} else if (arg === "--timeout-ms") {
options.timeoutMs = positiveInt(requireValue(args, index, arg), arg);
index += 1;
} else if (arg === "--commit") {
options.commit = commitValue(requireValue(args, index, arg), arg);
index += 1;
} else if (arg === "--local-pull-port") {
options.localPullPort = optionalPort(requireValue(args, index, arg), arg);
index += 1;
} else if (arg === "--target-image") {
options.targetImage = requireValue(args, index, arg);
index += 1;
} else {
throw new Error(`unknown artifact-registry option: ${arg}`);
}
@@ -231,13 +267,14 @@ function plan(options: ArtifactRegistryOptions): Record<string, unknown> {
"listen only on D601 host loopback 127.0.0.1:5000",
"do not expose a public port, NodePort, hostPort, or third-party registry",
"do not run inside k3s; keep the registry outside the native k3s failure domain",
"first-stage CLI does not mutate D601 runtime; install is dry-run only",
"backend-core production deploy behavior is unchanged in this issue",
"CI builds and publishes backend-core artifacts on D601",
"CD only pulls, retags, recreates backend-core, and verifies live commit",
"master server CD must not compile Rust or run docker compose build backend-core",
],
renderedPaths: bundle.paths,
futureFlow: [
"D601 CI/dev builds unidesk/backend-core:<commit>",
"D601 pushes the image into the loopback registry",
backendCoreArtifactFlow: [
"D601 CI builds unidesk/backend-core:<commit>",
"D601 CI pushes 127.0.0.1:5000/unidesk/backend-core:<commit>",
"main server pulls via a controlled short-lived localhost relay",
"prod CD retags, recreates backend-core, and verifies live commit metadata",
],
@@ -352,6 +389,11 @@ function commandTail(result: CommandResult): Record<string, unknown> {
};
}
function runRemoteScript(options: ArtifactRegistryOptions, script: string, timeoutMs = options.timeoutMs): CommandResult {
const command = [process.execPath, "scripts/cli.ts", "ssh", options.providerId, "argv", "bash", "-lc", script];
return runCommand(command, repoRoot, { timeoutMs });
}
function statusFromValues(options: ArtifactRegistryOptions, values: Record<string, string>, command: CommandResult, healthMode: boolean): Record<string, unknown> {
const commandOk = command.exitCode === 0 && !command.timedOut;
const checks = {
@@ -427,8 +469,7 @@ function statusFromValues(options: ArtifactRegistryOptions, values: Record<strin
function runReadonlyStatus(options: ArtifactRegistryOptions, healthMode: boolean): Record<string, unknown> {
const bundle = renderBundle(options);
const script = statusScript(options, bundle);
const command = [process.execPath, "scripts/cli.ts", "ssh", options.providerId, "argv", "bash", "-lc", script];
const result = runCommand(command, repoRoot, { timeoutMs: options.timeoutMs });
const result = runRemoteScript(options, script);
if (result.exitCode !== 0 || result.timedOut) {
return {
ok: false,
@@ -447,6 +488,54 @@ function runReadonlyStatus(options: ArtifactRegistryOptions, healthMode: boolean
return statusFromValues(options, parseKeyValueOutput(result.stdout), result, healthMode);
}
function remoteWriteFileCommand(item: RenderedFile): string {
const encoded = Buffer.from(item.content, "utf8").toString("base64");
return [
`target=${shellQuote(item.path)}`,
"tmp=$(mktemp /tmp/unidesk-artifact-registry.XXXXXX)",
"trap 'rm -f \"$tmp\"' EXIT",
`printf %s ${shellQuote(encoded)} | base64 -d > "$tmp"`,
"if [ \"$(id -u)\" = \"0\" ]; then",
" mkdir -p \"$(dirname \"$target\")\"",
` install -m ${shellQuote(item.mode)} "$tmp" "$target"`,
"else",
" sudo mkdir -p \"$(dirname \"$target\")\"",
` sudo install -m ${shellQuote(item.mode)} "$tmp" "$target"`,
"fi",
`echo ${shellQuote(`artifact_registry_file_written path=${item.path} sha256=${item.sha256}`)}`,
].join("\n");
}
function install(options: ArtifactRegistryOptions): Record<string, unknown> {
const bundle = renderBundle(options);
const script = [
"set -euo pipefail",
"command -v docker >/dev/null",
"docker compose version >/dev/null",
"command -v systemctl >/dev/null",
`mkdir -p ${shellQuote(bundle.paths.baseDir)} ${shellQuote(bundle.paths.storage)}`,
...bundle.files.map(remoteWriteFileCommand),
"if [ \"$(id -u)\" = \"0\" ]; then systemctl daemon-reload; else sudo systemctl daemon-reload; fi",
`if [ "$(id -u)" = "0" ]; then systemctl enable --now ${shellQuote(options.unitName)}; else sudo systemctl enable --now ${shellQuote(options.unitName)}; fi`,
"sleep 2",
`curl -fsS http://${options.host}:${options.port}/v2/ >/dev/null`,
].join("\n");
const command = runRemoteScript(options, script, Math.max(options.timeoutMs, 120_000));
const status = runReadonlyStatus(options, true);
return {
ok: command.exitCode === 0 && !command.timedOut && status.ok === true,
dryRun: false,
mutation: true,
providerId: options.providerId,
render: {
paths: bundle.paths,
files: bundle.files.map((item) => ({ path: item.path, mode: item.mode, sha256: item.sha256 })),
},
installCommand: commandTail(command),
health: status,
};
}
function installDryRun(options: ArtifactRegistryOptions): Record<string, unknown> {
const bundle = renderBundle(options);
return {
@@ -466,32 +555,297 @@ function installDryRun(options: ArtifactRegistryOptions): Record<string, unknown
`systemctl enable --now ${options.unitName}`,
`curl -fsS http://${options.host}:${options.port}/v2/`,
],
note: "First-stage artifact-registry install is dry-run only; no D601 runtime files or services were changed.",
note: "Dry run only; no D601 runtime files or services were changed.",
};
}
async function startRegistryRelay(options: ArtifactRegistryOptions): Promise<{
server: Server;
port: number;
close: () => Promise<void>;
}> {
const children = new Set<ChildProcessWithoutNullStreams>();
const sockets = new Set<Socket>();
const remoteProxyScript = String.raw`
import select
import socket
import sys
target = socket.create_connection(("127.0.0.1", 5000), timeout=10)
target.setblocking(False)
sys.stdin.buffer.raw.fileno()
sys.stdout.buffer.raw.fileno()
stdin_fd = sys.stdin.fileno()
stdout_fd = sys.stdout.fileno()
target_fd = target.fileno()
while True:
readable, _, _ = select.select([stdin_fd, target_fd], [], [])
if stdin_fd in readable:
data = sys.stdin.buffer.read1(65536)
if not data:
try:
target.shutdown(socket.SHUT_WR)
except OSError:
pass
else:
target.sendall(data)
if target_fd in readable:
try:
data = target.recv(65536)
except BlockingIOError:
data = b""
if not data:
break
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
`;
const server = createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
const child = spawn(process.execPath, [
"scripts/cli.ts",
"ssh",
options.providerId,
"argv",
"python3",
"-u",
"-c",
remoteProxyScript,
], {
cwd: repoRoot,
stdio: ["pipe", "pipe", "pipe"],
});
children.add(child);
child.on("close", () => children.delete(child));
child.on("error", () => socket.destroy());
socket.pipe(child.stdin);
child.stdout.pipe(socket);
child.stderr.on("data", (chunk) => process.stderr.write(`[artifact-registry-relay] ${chunk.toString("utf8")}`));
socket.on("close", () => child.kill());
});
const listen = new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen(options.localPullPort ?? 0, "127.0.0.1", () => {
server.off("error", reject);
const address = server.address() as AddressInfo;
resolve(address.port);
});
});
const port = await listen;
return {
server,
port,
close: async () => {
for (const socket of sockets) socket.destroy();
for (const child of children) child.kill();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
function composeLockScript(innerScript: string): string {
const lockDir = rootPath(".state", "locks");
const lockPath = rootPath(".state", "locks", "server-compose.lock");
return [
"set -euo pipefail",
`mkdir -p ${shellQuote(lockDir)}`,
`echo ${shellQuote(`compose_lock_wait ${lockPath}`)}`,
`flock ${shellQuote(lockPath)} bash -lc ${shellQuote(innerScript)}`,
].join("; ");
}
function upsertEnvFileValues(path: string, values: Record<string, string>): void {
const existing = readFileSync(path, "utf8");
const seen = new Set<string>();
const lines = existing.split(/\n/u).filter((line, index, array) => index < array.length - 1 || line.length > 0).map((line) => {
const match = /^([A-Za-z0-9_]+)=/u.exec(line);
if (match === null || values[match[1]] === undefined) return line;
seen.add(match[1]);
return `${match[1]}=${values[match[1]]}`;
});
for (const [key, value] of Object.entries(values)) {
if (!seen.has(key)) lines.push(`${key}=${value}`);
}
writeFileSync(path, `${lines.join("\n")}\n`, "utf8");
}
async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<Record<string, unknown>> {
const commit = options.commit;
if (commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
const health = runReadonlyStatus(options, true);
if (health.ok !== true) {
return { ok: false, error: "D601 artifact registry is not healthy", health };
}
const sourceImage = `127.0.0.1:${options.port}/unidesk/backend-core:${commit}`;
const composeImage = options.targetImage;
const commitImage = `${options.targetImage}:${commit}`;
const registryProbeScript = [
"set -euo pipefail",
`image=${shellQuote(`unidesk/backend-core:${commit}`)}`,
`registry_image=${shellQuote(sourceImage)}`,
"docker manifest inspect \"$registry_image\" >/tmp/unidesk-backend-core-manifest.json",
"manifest_digest=$(docker manifest inspect --verbose \"$registry_image\" 2>/dev/null | awk -F'\"' '/Digest/ {print $4; exit}' || true)",
"printf 'registry_image=%s\\nmanifest_digest=%s\\n' \"$registry_image\" \"$manifest_digest\"",
].join("\n");
const registryProbe = runRemoteScript(options, registryProbeScript, Math.max(options.timeoutMs, 120_000));
if (registryProbe.exitCode !== 0 || registryProbe.timedOut) {
return {
ok: false,
step: "registry-artifact-check",
error: "backend-core image artifact is missing from D601 registry; run CI artifact publication first",
sourceImage,
registryProbe: commandTail(registryProbe),
};
}
const relay = await startRegistryRelay(options);
const localSourceImage = `127.0.0.1:${relay.port}/unidesk/backend-core:${commit}`;
try {
const pull = runCommand(["docker", "pull", localSourceImage], repoRoot, { timeoutMs: Math.max(options.timeoutMs, 900_000) });
if (pull.exitCode !== 0 || pull.timedOut) {
return {
ok: false,
step: "docker-pull",
sourceImage,
localRelayImage: localSourceImage,
registryProbe: commandTail(registryProbe),
pull: commandTail(pull),
};
}
const inspectPulled = runCommand(["docker", "image", "inspect", localSourceImage, "--format", "{{json .Config.Labels}}"], repoRoot);
const labelCommit = runCommand(["docker", "image", "inspect", localSourceImage, "--format", "{{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot);
const labelService = runCommand(["docker", "image", "inspect", localSourceImage, "--format", "{{ index .Config.Labels \"unidesk.ai/service-id\" }}"], repoRoot);
if (labelCommit.stdout.trim() !== commit || labelService.stdout.trim() !== "backend-core") {
return {
ok: false,
step: "image-label-verify",
expected: { commit, serviceId: "backend-core" },
observed: { commit: labelCommit.stdout.trim(), serviceId: labelService.stdout.trim(), labels: inspectPulled.stdout.trim() },
registryProbe: commandTail(registryProbe),
};
}
const tag = runCommand(["docker", "tag", localSourceImage, composeImage], repoRoot);
if (tag.exitCode !== 0 || tag.timedOut) {
return { ok: false, step: "docker-tag", targetImage: composeImage, tag: commandTail(tag), registryProbe: commandTail(registryProbe) };
}
const tagCommit = runCommand(["docker", "tag", localSourceImage, commitImage], repoRoot);
if (tagCommit.exitCode !== 0 || tagCommit.timedOut) {
return { ok: false, step: "docker-tag-commit", targetImage: commitImage, tag: commandTail(tagCommit), registryProbe: commandTail(registryProbe) };
}
const config = readConfig();
const runtimeEnv = writeComposeEnv(config, false);
upsertEnvFileValues(runtimeEnv.envFile, {
UNIDESK_DEPLOY_REF: "deploy.json#environments.prod.services.backend-core",
UNIDESK_DEPLOY_SERVICE_ID: "backend-core",
UNIDESK_DEPLOY_REPO: "https://github.com/pikasTech/unidesk",
UNIDESK_DEPLOY_COMMIT: commit,
UNIDESK_DEPLOY_REQUESTED_COMMIT: commit,
});
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
const upScript = [
"set -euo pipefail",
`echo ${shellQuote(`backend_core_artifact_cd source=${sourceImage} target=${composeImage}`)}`,
`${compose.map(shellQuote).join(" ")} up -d --no-build --no-deps --force-recreate backend-core`,
"ready=0",
"for attempt in $(seq 1 60); do",
" cid=$(docker ps -q --filter label=com.docker.compose.project=unidesk --filter label=com.docker.compose.service=backend-core --filter label=com.docker.compose.oneoff=False | head -1 || true)",
" if [ -n \"$cid\" ]; then",
" health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \"$cid\" 2>/dev/null || true)",
" echo \"backend_core_container_probe attempt=$attempt cid=$cid health=$health\"",
" if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi",
" else",
" echo \"backend_core_container_probe attempt=$attempt cid=missing\"",
" fi",
" sleep 1",
"done",
"test \"$ready\" = \"1\"",
"cid=$(docker ps -q --filter label=com.docker.compose.project=unidesk --filter label=com.docker.compose.service=backend-core --filter label=com.docker.compose.oneoff=False | head -1)",
"image_id=$(docker inspect -f '{{.Image}}' \"$cid\")",
"actual_commit=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}' \"$image_id\")",
`test "$actual_commit" = ${shellQuote(commit)}`,
"if docker exec \"$cid\" sh -lc 'command -v backend-core >/dev/null 2>&1'; then",
" docker exec \"$cid\" backend-core --fetch-json http://127.0.0.1:8080/health --require-ok >/tmp/unidesk-backend-core-health.json",
"else",
" docker exec \"$cid\" bun -e \"fetch('http://127.0.0.1:8080/health').then(async r=>{console.log(await r.text()); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\" >/tmp/unidesk-backend-core-health.json",
"fi",
"cat /tmp/unidesk-backend-core-health.json",
].join("\n");
const deploy = runCommand(["bash", "-lc", composeLockScript(upScript)], repoRoot, { timeoutMs: Math.max(options.timeoutMs, 300_000) });
if (deploy.exitCode !== 0 || deploy.timedOut) {
return {
ok: false,
step: "compose-recreate",
sourceImage,
targetImage: composeImage,
registryProbe: commandTail(registryProbe),
deploy: commandTail(deploy),
};
}
const running = runCommand(["docker", "inspect", "unidesk-backend-core", "--format", "image={{.Config.Image}} imageId={{.Image}} status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}"], repoRoot);
return {
ok: true,
commit,
providerId: options.providerId,
sourceImage,
registryProbe: commandTail(registryProbe),
localRelayImage: localSourceImage,
targetImage: composeImage,
targetCommitImage: commitImage,
relay: {
bind: `127.0.0.1:${relay.port}`,
persistent: false,
},
pull: commandTail(pull),
deploy: commandTail(deploy),
running: running.stdout.trim(),
rollback: {
previousImageHint: "Use docker image ls and docker inspect logs for the previous backend-core image id; Compose named volumes were not changed.",
commandShape: `${compose.map(shellQuote).join(" ")} up -d --no-build --no-deps --force-recreate backend-core`,
},
};
} finally {
await relay.close();
}
}
function deployBackendCoreJob(args: string[], options: ArtifactRegistryOptions): Record<string, unknown> {
if (options.commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
const runArgs = args.includes("--run-now") ? args : [...args, "--run-now"];
const command = [process.execPath, rootPath("scripts", "cli.ts"), "artifact-registry", ...runArgs];
const job = startJob("artifact_registry_backend_core_cd", command, `Pull and deploy backend-core artifact ${options.commit} from D601 registry`);
return {
ok: true,
mode: "async-job",
job,
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
tailCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`,
note: "Backend-core CD continues in the background: D601 registry health, short-lived local relay, docker pull, retag, no-build recreate, live commit verification.",
};
}
function localHelp(): Record<string, unknown> {
return {
ok: true,
command: "artifact-registry plan|render|status|health|install",
command: "artifact-registry plan|render|status|health|install|deploy-backend-core",
output: "json",
usage: [
"bun scripts/cli.ts artifact-registry plan [--provider-id D601]",
"bun scripts/cli.ts artifact-registry render [--provider-id D601]",
"bun scripts/cli.ts artifact-registry status [--provider-id D601]",
"bun scripts/cli.ts artifact-registry health [--provider-id D601]",
"bun scripts/cli.ts artifact-registry install --dry-run [--provider-id D601]",
"bun scripts/cli.ts artifact-registry install [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-backend-core --commit <full-sha> [--run-now] [--provider-id D601]",
],
firstStage: "install without --dry-run is intentionally disabled",
firstStage: "install now writes the rendered systemd/Compose/config files and starts the registry",
defaults: defaultOptions,
};
}
export function runArtifactRegistryCommand(args: string[]): unknown {
export async function runArtifactRegistryCommand(args: string[]): Promise<unknown> {
const action = args[0];
if (isHelpArg(action)) return localHelp();
if (action !== "plan" && action !== "render" && action !== "status" && action !== "health" && action !== "install") {
throw new Error("artifact-registry usage: plan|render|status|health|install");
if (action !== "plan" && action !== "render" && action !== "status" && action !== "health" && action !== "install" && action !== "deploy-backend-core") {
throw new Error("artifact-registry usage: plan|render|status|health|install|deploy-backend-core");
}
const options = parseOptions(args.slice(1));
if (action === "plan") return plan(options);
@@ -499,15 +853,11 @@ export function runArtifactRegistryCommand(args: string[]): unknown {
if (action === "status") return runReadonlyStatus(options, false);
if (action === "health") return runReadonlyStatus(options, true);
if (action === "install") {
if (!options.dryRun) {
return {
ok: false,
error: "artifact-registry install is first-stage dry-run only; pass --dry-run",
firstStageOnly: true,
mutation: false,
};
}
return installDryRun(options);
return options.dryRun ? installDryRun(options) : install(options);
}
if (action === "deploy-backend-core") {
if (options.commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
return options.runNow ? await deployBackendCoreNow(options) : deployBackendCoreJob(args, options);
}
throw new Error("unreachable artifact-registry action");
}
+2
View File
@@ -236,9 +236,11 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
fileItem("src/components/microservices/code-queue-mgr/src/index.ts"),
fileItem("src/components/microservices/code-queue-mgr/src/prompt-observation.ts"),
fileItem("scripts/src/deploy.ts"),
fileItem("scripts/src/ci.ts"),
fileItem("scripts/src/e2e.ts"),
fileItem("scripts/code-queue-prompt-observation-test.ts"),
fileItem("scripts/src/artifact-registry.ts"),
fileItem("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml"),
);
} else {
items.push(skippedItem("files:required-entrypoints", "required file presence scan is opt-in", "--files or --full"));
+79 -3
View File
@@ -34,6 +34,12 @@ interface CiOptions {
waitMs: number;
}
interface CiPublishBackendCoreOptions {
repoUrl: string;
commit: string;
waitMs: number;
}
interface CiDevE2EOptions {
repoUrl: string;
desiredRef: string;
@@ -92,6 +98,12 @@ function requireRevision(value: string | null): string {
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 requireDesiredRef(value: string | null): string {
const ref = value ?? "master";
if (!/^[A-Za-z0-9._/-]{1,160}$/u.test(ref) || ref.startsWith("-") || ref.includes("..")) {
@@ -474,6 +486,35 @@ spec:
`;
}
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)}
workspaces:
- name: shared-workspace
persistentVolumeClaim:
claimName: unidesk-ci-cache
`;
}
async function remoteCreatePipelineRun(manifest: string): Promise<string> {
const encoded = Buffer.from(manifest, "utf8").toString("base64");
const token = randomUUID().replace(/-/gu, "").slice(0, 12);
@@ -540,6 +581,29 @@ async function run(options: CiOptions): Promise<Record<string, unknown>> {
};
}
async function publishBackendCoreArtifact(options: CiPublishBackendCoreOptions): Promise<Record<string, unknown>> {
const name = await remoteCreatePipelineRun(backendCoreArtifactPipelineRunManifest(options));
const wait = await waitForPipelineRun(name, options.waitMs);
const waitSucceeded = wait === null || wait.exitCode === 0 || wait.stdout.trimStart().startsWith("True\tSucceeded\t");
return {
ok: waitSucceeded,
pipelineRun: name,
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
artifact: `127.0.0.1:5000/unidesk/backend-core:${options.commit}`,
boundary: "CI publishes the image to D601 registry; CD must pull it and must not build backend-core",
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 artifact-registry deploy-backend-core --commit ${options.commit}`,
],
};
}
async function runRemoteDevE2ELauncher(options: CiDevE2EOptions): Promise<DispatchResult> {
const scriptTimeoutMs = Math.max(options.scriptTimeoutMs, options.waitMs, 60_000);
const remoteTimeoutMs = 45_000;
@@ -788,11 +852,12 @@ async function logs(name: string): Promise<Record<string, unknown>> {
export function ciHelp(): Record<string, unknown> {
return {
command: "ci install|status|run|run-dev-e2e|logs",
description: "Manage the D601 k3s Tekton CI gate. This intentionally does not deploy CD.",
command: "ci install|status|run|publish-backend-core|run-dev-e2e|logs",
description: "Manage the D601 k3s Tekton CI gate. CI may publish backend-core image artifacts, but it 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 publish-backend-core --commit <full-sha>",
"bun scripts/cli.ts ci run-dev-e2e --wait-ms 600000",
"bun scripts/cli.ts ci logs <runId>",
],
@@ -805,6 +870,11 @@ export function ciHelp(): Record<string, unknown> {
interceptors: tektonTriggersInterceptorsUrl,
},
},
backendCoreArtifact: {
producer: "D601 CI",
registry: "127.0.0.1:5000/unidesk/backend-core:<commit>",
cdCommand: "bun scripts/cli.ts artifact-registry deploy-backend-core --commit <full-sha>",
},
runDevE2E: {
defaultTriggerMode: "commit-pinned-ssh-launcher",
desiredState: "origin/master:deploy.json#environments.dev",
@@ -831,6 +901,12 @@ export async function runCiCommand(_config: UniDeskConfig, args: string[]): Prom
const waitMs = numberOption(args, "--wait-ms", 0);
return run({ repoUrl, revision, waitMs });
}
if (action === "publish-backend-core") {
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk";
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
const waitMs = numberOption(args, "--wait-ms", 0);
return publishBackendCoreArtifact({ repoUrl, commit, waitMs });
}
if (action === "run-dev-e2e") {
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk";
const desiredRef = requireDesiredRef(stringOption(args, "--desired-ref") ?? stringOption(args, "--deploy-branch"));
@@ -852,7 +928,7 @@ export async function runCiCommand(_config: UniDeskConfig, args: string[]): Prom
});
}
if (action === "logs") return logs(nameArg ?? "");
throw new Error("ci command must be one of: install, status, run, run-dev-e2e, logs");
throw new Error("ci command must be one of: install, status, run, publish-backend-core, run-dev-e2e, logs");
}
export function startCiInstallJob(): Record<string, unknown> {
+5
View File
@@ -106,6 +106,11 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
UNIDESK_PROVIDER_NAME: config.providerGateway.name,
UNIDESK_PROVIDER_LABELS_JSON: labels,
UNIDESK_MICROSERVICES_JSON: microservices,
UNIDESK_DEPLOY_REF: runtimeSecret("UNIDESK_DEPLOY_REF"),
UNIDESK_DEPLOY_SERVICE_ID: runtimeSecret("UNIDESK_DEPLOY_SERVICE_ID") || "backend-core",
UNIDESK_DEPLOY_REPO: runtimeSecret("UNIDESK_DEPLOY_REPO"),
UNIDESK_DEPLOY_COMMIT: runtimeSecret("UNIDESK_DEPLOY_COMMIT"),
UNIDESK_DEPLOY_REQUESTED_COMMIT: runtimeSecret("UNIDESK_DEPLOY_REQUESTED_COMMIT"),
UNIDESK_AUTH_USERNAME: config.auth.username,
UNIDESK_AUTH_PASSWORD: config.auth.password,
UNIDESK_SESSION_SECRET: config.auth.sessionSecret,
+7 -5
View File
@@ -36,7 +36,7 @@ export function rootHelp(): unknown {
{ command: "decision show <id>", description: "Show one Decision Center record." },
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads origin/master:deploy.json environments and can apply supported dev services." },
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
{ command: "artifact-registry plan|render|status|health|install --dry-run", description: "Plan and inspect the D601 host-managed CNCF Distribution registry for future backend-core artifact CD; first-stage install is dry-run only." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only production backend-core artifact CD." },
{ command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N." },
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
{ command: "codex deploy <commitId> [--provider-id D601] [--timeout-ms N]", description: "Compatibility wrapper for deploy apply --service code-queue with a temporary repo+commit manifest." },
@@ -52,7 +52,7 @@ export function rootHelp(): 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|run-dev-e2e|logs", description: "Manage D601 k3s Tekton CI only; run-dev-e2e manually validates master deploy.json dev state in an isolated temporary namespace." },
{ command: "ci install|status|run|publish-backend-core|run-dev-e2e|logs", description: "Manage D601 k3s Tekton CI; publish-backend-core builds commit-pinned images in CI without deploying CD." },
{ 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." },
],
};
@@ -247,20 +247,22 @@ function devEnvHelp(): unknown {
function artifactRegistryHelp(): unknown {
return {
command: "artifact-registry plan|render|status|health|install",
command: "artifact-registry plan|render|status|health|install|deploy-backend-core",
output: "json",
usage: [
"bun scripts/cli.ts artifact-registry plan [--provider-id D601]",
"bun scripts/cli.ts artifact-registry render [--provider-id D601]",
"bun scripts/cli.ts artifact-registry status [--provider-id D601]",
"bun scripts/cli.ts artifact-registry health [--provider-id D601]",
"bun scripts/cli.ts artifact-registry install --dry-run [--provider-id D601]",
"bun scripts/cli.ts artifact-registry install [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-backend-core --commit <full-sha> [--run-now] [--provider-id D601]",
],
description: "Manage the declaration, rendered files and readonly checks for the D601 host-managed CNCF Distribution artifact registry.",
boundary: [
"registry endpoint is D601 loopback 127.0.0.1:5000 only",
"service is host-managed by systemd + Docker Compose, not k3s-managed",
"first-stage install without --dry-run is rejected",
"install writes the rendered host unit/config and starts the registry",
"deploy-backend-core only pulls commit-pinned backend-core artifacts and does not build backend-core on the master server",
"status and health use provider-gateway Host SSH readonly checks",
],
};