import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import { runCommand, type CommandResult } from "./command"; import { readConfig, repoRoot, rootPath } from "./config"; import { resolveComposeCommand, writeComposeEnv } from "./docker"; import { startJob } from "./jobs"; type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core"; interface ArtifactRegistryOptions { providerId: string; host: string; port: number; image: string; baseDir: string; storageDir: string; unitName: string; composeProject: string; serviceName: string; containerName: string; timeoutMs: number; dryRun: boolean; runNow: boolean; commit: string | null; targetImage: string; } interface RenderedFile { path: string; mode: string; sha256: string; content: string; } interface RenderedBundle { files: RenderedFile[]; paths: { unit: string; compose: string; config: string; storage: string; baseDir: string; }; } const defaultOptions: ArtifactRegistryOptions = { providerId: "D601", host: "127.0.0.1", port: 5000, image: "registry:2.8.3", baseDir: "/home/ubuntu/.unidesk/artifact-registry", storageDir: "/home/ubuntu/.unidesk/registry-storage", unitName: "unidesk-artifact-registry.service", composeProject: "unidesk-artifact-registry", serviceName: "registry", containerName: "unidesk-artifact-registry", timeoutMs: 30_000, dryRun: false, runNow: false, commit: null, targetImage: "unidesk-backend-core", }; function isHelpArg(value: string | undefined): boolean { return value === undefined || value === "help" || value === "--help" || value === "-h"; } function requireValue(args: string[], index: number, option: string): string { const value = args[index + 1]; if (value === undefined || value.length === 0) throw new Error(`${option} requires a non-empty value`); return value; } function positiveInt(value: string, option: string): number { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`); 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; } else if (arg === "--host") { options.host = requireValue(args, index, arg); index += 1; } else if (arg === "--port") { options.port = positiveInt(requireValue(args, index, arg), arg); index += 1; } else if (arg === "--image") { options.image = requireValue(args, index, arg); index += 1; } else if (arg === "--base-dir") { options.baseDir = absolutePath(requireValue(args, index, arg), arg); index += 1; } else if (arg === "--storage-dir") { options.storageDir = absolutePath(requireValue(args, index, arg), arg); index += 1; } 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 === "--target-image") { options.targetImage = requireValue(args, index, arg); index += 1; } else { throw new Error(`unknown artifact-registry option: ${arg}`); } } if (options.host !== "127.0.0.1") throw new Error("--host is first-stage restricted to 127.0.0.1"); if (options.port !== 5000) throw new Error("--port is first-stage restricted to 5000"); return options; } function sha256(text: string): string { return createHash("sha256").update(text).digest("hex"); } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } function rootExecPrelude(): string { return [ "root_exec() {", " if [ \"$(id -u)\" = \"0\" ]; then \"$@\"; return; fi", " if sudo -n true >/dev/null 2>&1; then sudo -n \"$@\"; return; fi", " if [ -x /mnt/c/Windows/System32/wsl.exe ]; then /mnt/c/Windows/System32/wsl.exe -u root -- \"$@\"; return; fi", " echo 'artifact_registry_root_access=missing' >&2", " return 1", "}", ].join("\n"); } function file(path: string, content: string, mode = "0644"): RenderedFile { return { path, mode, sha256: sha256(content), content }; } function registryConfig(): string { return `version: 0.1 log: fields: service: unidesk-artifact-registry storage: filesystem: rootdirectory: /var/lib/registry delete: enabled: false http: addr: :5000 headers: X-Content-Type-Options: [nosniff] health: storagedriver: enabled: true interval: 10s threshold: 3 `; } function composeFile(options: ArtifactRegistryOptions): string { return `name: ${options.composeProject} services: ${options.serviceName}: image: ${options.image} container_name: ${options.containerName} restart: unless-stopped ports: - "${options.host}:${options.port}:5000" volumes: - "${options.baseDir}/config.yml:/etc/docker/registry/config.yml:ro" - "${options.storageDir}:/var/lib/registry" labels: unidesk.ai/service-id: artifact-registry unidesk.ai/managed-by: host-systemd-docker-compose unidesk.ai/provider-id: ${options.providerId} `; } function systemdUnit(options: ArtifactRegistryOptions): string { return `[Unit] Description=UniDesk D601 Artifact Registry (CNCF Distribution) Documentation=https://github.com/pikasTech/unidesk/blob/master/docs/reference/artifact-registry.md After=network-online.target Wants=network-online.target ConditionPathExists=/var/run/docker.sock [Service] Type=oneshot RemainAfterExit=yes WorkingDirectory=${options.baseDir} ExecStartPre=/bin/mkdir -p ${options.baseDir} ${options.storageDir} ExecStartPre=/bin/sh -lc 'docker info >/dev/null' ExecStart=/bin/sh -lc 'docker compose -p ${options.composeProject} -f ${options.baseDir}/compose.yml up -d --remove-orphans' ExecStop=/bin/sh -lc 'docker compose -p ${options.composeProject} -f ${options.baseDir}/compose.yml down' TimeoutStartSec=120 TimeoutStopSec=120 [Install] WantedBy=multi-user.target `; } function renderBundle(options: ArtifactRegistryOptions): RenderedBundle { const paths = { unit: `/etc/systemd/system/${options.unitName}`, compose: `${options.baseDir}/compose.yml`, config: `${options.baseDir}/config.yml`, storage: options.storageDir, baseDir: options.baseDir, }; return { paths, files: [ file(paths.config, registryConfig()), file(paths.compose, composeFile(options)), file(paths.unit, systemdUnit(options)), ], }; } function plan(options: ArtifactRegistryOptions): Record { const bundle = renderBundle(options); return { ok: true, providerId: options.providerId, mode: "d601-host-managed", firstStage: true, runtime: { implementation: "CNCF Distribution Docker registry", image: options.image, endpoint: `http://${options.host}:${options.port}`, publicExposure: "none", orchestration: "systemd + Docker Compose on D601 host/WSL OS", k3sManaged: false, }, dependencies: [ { name: "Docker Engine", scope: "D601 host", requiredFor: "running the registry container" }, { name: "Docker Compose plugin", scope: "D601 host", requiredFor: "systemd ExecStart/ExecStop" }, { name: "systemd", scope: "D601 host", requiredFor: "host-managed service lifecycle" }, { name: "registry:2.8.3", scope: "D601 Docker cache / upstream pull", requiredFor: "CNCF Distribution runtime image" }, { name: "provider-gateway Host SSH", scope: "UniDesk control bridge", requiredFor: "readonly status and health checks" }, { name: "local filesystem storage", scope: bundle.paths.storage, requiredFor: "artifact persistence outside k3s" }, ], boundaries: [ "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", "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, backendCoreArtifactFlow: [ "D601 CI builds unidesk/backend-core:", "D601 CI pushes 127.0.0.1:5000/unidesk/backend-core:", "main server pulls via a controlled short-lived localhost relay", "prod CD retags, recreates backend-core, and verifies live commit metadata", ], }; } function statusScript(options: ArtifactRegistryOptions, bundle: RenderedBundle): string { const hashes = Object.fromEntries(bundle.files.map((item) => [item.path, item.sha256])); return `set -u kv() { printf '%s=%s\\n' "$1" "$2"; } bool_file() { [ -f "$1" ] && printf true || printf false; } bool_dir() { [ -d "$1" ] && printf true || printf false; } hash_file() { sha256sum "$1" 2>/dev/null | awk '{print $1}' || true; } unit=${shellQuote(bundle.paths.unit)} compose=${shellQuote(bundle.paths.compose)} config=${shellQuote(bundle.paths.config)} storage=${shellQuote(bundle.paths.storage)} container=${shellQuote(options.containerName)} expected_image=${shellQuote(options.image)} port=${options.port} kv readonly true kv unit_path "$unit" kv compose_path "$compose" kv config_path "$config" kv storage_path "$storage" kv unit_exists "$(bool_file "$unit")" kv compose_exists "$(bool_file "$compose")" kv config_exists "$(bool_file "$config")" kv storage_exists "$(bool_dir "$storage")" if command -v systemctl >/dev/null 2>&1; then kv systemctl_available true kv unit_active "$(systemctl is-active "${options.unitName}" 2>/dev/null || true)" kv unit_enabled "$(systemctl is-enabled "${options.unitName}" 2>/dev/null || true)" else kv systemctl_available false kv unit_active unknown kv unit_enabled unknown fi if command -v docker >/dev/null 2>&1; then kv docker_available true container_running="$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true)" container_status="$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || true)" container_image="$(docker inspect -f '{{.Config.Image}}' "$container" 2>/dev/null || true)" container_restart_policy="$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$container" 2>/dev/null || true)" kv container_running "$([ -n "$container_running" ] && printf '%s' "$container_running" || printf false)" kv container_status "$container_status" kv container_image "$container_image" kv container_restart_policy "$container_restart_policy" else kv docker_available false kv container_running false kv container_status unknown kv container_image "" kv container_restart_policy unknown fi if command -v ss >/dev/null 2>&1; then listeners="$(ss -ltnH 2>/dev/null | awk -v p=":$port" '$4 ~ p "$" {print $4}' || true)" else listeners="" fi listener_count="$(printf '%s\\n' "$listeners" | sed '/^$/d' | wc -l | tr -d ' ')" bad_listener_count="$(printf '%s\\n' "$listeners" | sed '/^$/d' | grep -Ev "^(127[.]0[.]0[.]1|localhost):${options.port}$|^\\[::1\\]:${options.port}$|^::1:${options.port}$" | wc -l | tr -d ' ')" loopback_only=false if [ "\${listener_count:-0}" -gt 0 ] && [ "\${bad_listener_count:-0}" -eq 0 ]; then loopback_only=true; fi kv listener_count "\${listener_count:-0}" kv bad_listener_count "\${bad_listener_count:-0}" kv loopback_only "$loopback_only" if command -v curl >/dev/null 2>&1; then kv curl_available true kv v2_http_code "$(timeout 3 curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:${options.port}/v2/ 2>/dev/null || true)" else kv curl_available false kv v2_http_code "" fi config_hash="$(hash_file "$config")" compose_hash="$(hash_file "$compose")" unit_hash="$(hash_file "$unit")" kv config_hash "$config_hash" kv compose_hash "$compose_hash" kv unit_hash "$unit_hash" kv expected_config_hash ${shellQuote(hashes[bundle.paths.config] ?? "")} kv expected_compose_hash ${shellQuote(hashes[bundle.paths.compose] ?? "")} kv expected_unit_hash ${shellQuote(hashes[bundle.paths.unit] ?? "")} kv config_hash_matches "$([ -n "$config_hash" ] && [ "$config_hash" = ${shellQuote(hashes[bundle.paths.config] ?? "")} ] && printf true || printf false)" kv compose_hash_matches "$([ -n "$compose_hash" ] && [ "$compose_hash" = ${shellQuote(hashes[bundle.paths.compose] ?? "")} ] && printf true || printf false)" kv unit_hash_matches "$([ -n "$unit_hash" ] && [ "$unit_hash" = ${shellQuote(hashes[bundle.paths.unit] ?? "")} ] && printf true || printf false)" kv image_matches "$([ "\${container_image:-}" = "$expected_image" ] && printf true || printf false)" `; } function parseKeyValueOutput(stdout: string): Record { const values: Record = {}; for (const line of stdout.split(/\r?\n/u)) { const match = /^([A-Za-z0-9_]+)=(.*)$/u.exec(line); if (match !== null) values[match[1]] = match[2]; } return values; } function asBool(value: string | undefined): boolean { return value === "true"; } function commandTail(result: CommandResult): Record { return { command: result.command.length > 7 ? [...result.command.slice(0, 7), ""] : result.command, exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, stdoutTail: result.stdout.slice(-4000), stderrTail: result.stderr.slice(-4000), }; } 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, command: CommandResult, healthMode: boolean): Record { const commandOk = command.exitCode === 0 && !command.timedOut; const checks = { systemctlAvailable: asBool(values.systemctl_available), dockerAvailable: asBool(values.docker_available), curlAvailable: asBool(values.curl_available), unitExists: asBool(values.unit_exists), unitActive: values.unit_active === "active", unitEnabled: values.unit_enabled === "enabled", composeExists: asBool(values.compose_exists), configExists: asBool(values.config_exists), storageExists: asBool(values.storage_exists), containerRunning: asBool(values.container_running), loopbackOnly: asBool(values.loopback_only), v2Ok: values.v2_http_code === "200", imageMatches: asBool(values.image_matches), configHashMatches: asBool(values.config_hash_matches), composeHashMatches: asBool(values.compose_hash_matches), unitHashMatches: asBool(values.unit_hash_matches), }; const installed = checks.unitExists || checks.composeExists || checks.configExists || checks.storageExists || checks.containerRunning; const healthy = commandOk && checks.systemctlAvailable && checks.dockerAvailable && checks.unitExists && checks.unitActive && checks.composeExists && checks.configExists && checks.storageExists && checks.containerRunning && checks.loopbackOnly && checks.v2Ok && checks.imageMatches && checks.configHashMatches && checks.composeHashMatches && checks.unitHashMatches; return { ok: healthMode ? healthy : commandOk, readonly: true, installed, healthy, checks, observed: { unit: { path: values.unit_path, active: values.unit_active, enabled: values.unit_enabled }, compose: { path: values.compose_path, sha256: values.compose_hash }, config: { path: values.config_path, sha256: values.config_hash }, storage: { path: values.storage_path }, container: { name: options.containerName, running: values.container_running, status: values.container_status, image: values.container_image, restartPolicy: values.container_restart_policy, }, listener: { count: Number(values.listener_count ?? 0), badCount: Number(values.bad_listener_count ?? 0), loopbackOnly: checks.loopbackOnly, }, registryApi: { v2HttpCode: values.v2_http_code }, }, expected: { unitHash: values.expected_unit_hash, composeHash: values.expected_compose_hash, configHash: values.expected_config_hash, image: options.image, endpoint: `http://${options.host}:${options.port}`, }, command: commandTail(command), }; } function runReadonlyStatus(options: ArtifactRegistryOptions, healthMode: boolean): Record { const bundle = renderBundle(options); const script = statusScript(options, bundle); const result = runRemoteScript(options, script); if (result.exitCode !== 0 || result.timedOut) { return { ok: false, readonly: true, installed: false, healthy: false, checks: {}, expected: { endpoint: `http://${options.host}:${options.port}`, image: options.image, paths: bundle.paths, }, command: commandTail(result), }; } return statusFromValues(options, parseKeyValueOutput(result.stdout), result, healthMode); } function remoteWriteFileCommand(item: RenderedFile): string { const encoded = Buffer.from(item.content, "utf8").toString("base64"); const rootOwned = item.path.startsWith("/etc/"); 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 [ ${rootOwned ? "1" : "0"} = 1 ]; then`, " root_exec mkdir -p \"$(dirname \"$target\")\"", ` root_exec install -m ${shellQuote(item.mode)} "$tmp" "$target"`, "else", " mkdir -p \"$(dirname \"$target\")\"", ` 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 { const bundle = renderBundle(options); const script = [ "set -euo pipefail", "command -v docker >/dev/null", "docker compose version >/dev/null", "command -v systemctl >/dev/null", rootExecPrelude(), `mkdir -p ${shellQuote(bundle.paths.baseDir)} ${shellQuote(bundle.paths.storage)}`, ...bundle.files.map(remoteWriteFileCommand), "root_exec systemctl daemon-reload", `root_exec systemctl enable --now ${shellQuote(options.unitName)}`, "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 { const bundle = renderBundle(options); return { ok: true, dryRun: true, readonly: true, mutation: false, providerId: options.providerId, plan: plan(options), render: bundle, intendedRemoteActions: [ `mkdir -p ${options.baseDir} ${options.storageDir}`, `write ${bundle.paths.config}`, `write ${bundle.paths.compose}`, `write ${bundle.paths.unit}`, "systemctl daemon-reload", `systemctl enable --now ${options.unitName}`, `curl -fsS http://${options.host}:${options.port}/v2/`, ], note: "Dry run only; no D601 runtime files or services were changed.", }; } 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): void { const existing = readFileSync(path, "utf8"); const seen = new Set(); 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"); } function pullBackendCoreArtifactFromD601(options: ArtifactRegistryOptions, sourceImage: string): CommandResult { const remoteScript = [ "set -euo pipefail", `image=${shellQuote(sourceImage)}`, "docker pull -q \"$image\" >/dev/null", "docker image inspect \"$image\" --format 'remote_source={{ index .Config.Labels \"unidesk.ai/source-commit\" }} remote_service={{ index .Config.Labels \"unidesk.ai/service-id\" }}' >&2", "docker save \"$image\" | gzip -1", ].join("\n"); const sshCommand = [ process.execPath, rootPath("scripts", "cli.ts"), "ssh", options.providerId, "argv", "bash", "-lc", remoteScript, ].map(shellQuote).join(" "); const pipeline = [ "set -euo pipefail", `${sshCommand} | gzip -dc | docker load`, ].join("\n"); return runCommand(["bash", "-lc", pipeline], repoRoot, { timeoutMs: Math.max(options.timeoutMs, 900_000) }); } async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise> { const commit = options.commit; if (commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit "); 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", `registry_image=${shellQuote(sourceImage)}`, `manifest_url=${shellQuote(`http://127.0.0.1:${options.port}/v2/unidesk/backend-core/manifests/${commit}`)}`, "headers=$(mktemp /tmp/unidesk-backend-core-manifest.XXXXXX.headers)", "trap 'rm -f \"$headers\"' EXIT", "curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -D \"$headers\" -o /dev/null \"$manifest_url\"", "manifest_digest=$(awk 'BEGIN{IGNORECASE=1} /^Docker-Content-Digest:/ {gsub(/\\r/, \"\", $2); print $2; exit}' \"$headers\")", "printf 'registry_image=%s\\nmanifest_url=%s\\nmanifest_digest=%s\\n' \"$registry_image\" \"$manifest_url\" \"$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 pull = pullBackendCoreArtifactFromD601(options, sourceImage); if (pull.exitCode !== 0 || pull.timedOut) { return { ok: false, step: "docker-load", sourceImage, registryProbe: commandTail(registryProbe), pull: commandTail(pull), }; } const localLoadedImage = sourceImage; const inspectPulled = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{json .Config.Labels}}"], repoRoot); const labelCommit = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot); const labelService = runCommand(["docker", "image", "inspect", localLoadedImage, "--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", localLoadedImage, 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", localLoadedImage, 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)}`, "docker exec \"$cid\" bun -e \"fetch('http://127.0.0.1:8080/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\" >/tmp/unidesk-backend-core-health.json", "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), localLoadedImage, targetImage: composeImage, targetCommitImage: commitImage, 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`, }, }; } function deployBackendCoreJob(args: string[], options: ArtifactRegistryOptions): Record { if (options.commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit "); 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, provider-gateway SSH image stream, docker load, retag, no-build recreate, live commit verification.", }; } function localHelp(): Record { return { ok: true, 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 [--provider-id D601]", "bun scripts/cli.ts artifact-registry deploy-backend-core --commit [--run-now] [--provider-id D601]", ], firstStage: "install now writes the rendered systemd/Compose/config files and starts the registry", defaults: defaultOptions, }; } export async function runArtifactRegistryCommand(args: string[]): Promise { const action = args[0]; if (isHelpArg(action)) return localHelp(); 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); if (action === "render") return { ok: true, providerId: options.providerId, render: renderBundle(options) }; if (action === "status") return runReadonlyStatus(options, false); if (action === "health") return runReadonlyStatus(options, true); if (action === "install") { 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 "); return options.runNow ? await deployBackendCoreNow(options) : deployBackendCoreJob(args, options); } throw new Error("unreachable artifact-registry action"); }