feat: split backend-core artifact ci cd
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user