// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. status module for scripts/src/artifact-registry.ts. // SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets. // Moved mechanically from scripts/src/artifact-registry.ts:1081-1318 for #903. import { createHash } from "node:crypto"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { runCommand, type CommandResult } from "../command"; import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config"; import { startJob } from "../jobs"; import { compareDeployJsonExecutorMirrors, deployJsonCommitImage, deployJsonDriftResult, deployJsonSourceOfTruth, hasDeployJsonExecutorContract, k3sManifestExecutorMirror, parseDeployJsonServiceContractBase64, readDeployJsonServiceContractFromFile, type DeployJsonExecutorMirror, type DeployJsonServiceContract, } from "../deploy-json-contract"; import { d601K3sGuardShellLines } from "../d601-k3s-guard"; import { composeRuntimeEnvValue } from "../runtime-env"; import { artifactRegistryOptionsTargetSummary, type ArtifactRegistryFailureClassification, type ArtifactRegistryOptions, type RenderedBundle } from "./types"; import { renderBundle, shellQuote } from "./bundle"; export function plan(options: ArtifactRegistryOptions): Record { const bundle = renderBundle(options); return { ok: true, target: artifactRegistryOptionsTargetSummary(options), 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, frontend, and reviewed user-service artifacts on D601", "CD only pulls, retags, recreates/imports artifacts, and verifies live commit", "master server CD must not compile Rust or run docker compose build for artifact consumers", "code-queue is dev-only for artifact consumer validation and has no prod artifact deploy target", ], renderedPaths: bundle.paths, artifactConsumerFlow: [ "D601 CI builds unidesk/:", "D601 CI pushes 127.0.0.1:5000/unidesk/:", "Compose consumers pull via a controlled provider-gateway SSH image stream", "k3s consumers pull on D601 and import into native k3s containerd", "CD retags/recreates or updates Deployment images and verifies live commit metadata", ], frontendArtifactFlow: [ "D601 CI builds and pushes 127.0.0.1:5000/unidesk/frontend:", "dev CD imports the image into D601 native k3s and verifies frontend-dev /health deploy.commit", "prod CD pulls via provider-gateway image stream, recreates Compose frontend with no build, and verifies /health deploy.commit", ], }; } export 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_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_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)" kv config_hash "$config_hash" kv compose_hash "$compose_hash" kv unit_hash "$unit_hash" `; } export 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; } export function asBool(value: string | undefined): boolean { return value === "true"; } export function registryRecommendedAction(classification: ArtifactRegistryFailureClassification | null): string { if (classification === null) return "none"; if (classification === "control-plane-missing") return "restore the artifact registry readonly control plane, or rerun through --main-server-ip once frontend/backend-core dispatch is reachable"; if (classification === "local-docker-required") return "run the read-only check through --main-server-ip or from a main-server CLI with backend-core available"; if (classification === "provider-ssh-command-missing") return "restore D601 provider-gateway host.ssh dispatch/capability before retrying artifact registry health"; if (classification === "ssh-helper-command-shape-incompatible") return "upgrade the CLI/control-plane host.ssh helper shape so it can run bash -lc readonly probes"; if (classification === "remote-command-timeout") return "rerun artifact-registry health and inspect D601 host.ssh latency if the timeout repeats"; if (classification === "registry-not-installed") return "install the D601 artifact registry through the controlled artifact-registry install path, then rerun health"; return "restore the D601 artifact registry service/container/API until registry health passes"; } export function readonlyRemoteCommandShape(action: "status" | "health", options: ArtifactRegistryOptions): string { if (isLocalProvider(options.providerId)) return `local bash -lc timeoutMs=${options.timeoutMs}`; return `host.ssh provider=${options.providerId} mode=exec argv=bash -lc timeoutMs=${options.timeoutMs}`; } export function isLocalProvider(providerId: string): boolean { return providerId === "local" || providerId === "D601-local"; } export function classifyProviderSshCommandFailure(command: CommandResult): ArtifactRegistryFailureClassification { const output = `${command.stderr}\n${command.stdout}`.toLowerCase(); if (command.timedOut || output.includes("timed out") || output.includes("timeout")) return "remote-command-timeout"; if (output.includes("no such container: unidesk-backend-core") || output.includes("no such container: unidesk-database") || output.includes("backend-core bridge unavailable")) { return "local-docker-required"; } if (output.includes("provider does not declare host.ssh") || output.includes("provider is offline") || output.includes("host.ssh capability")) { return "provider-ssh-command-missing"; } if (output.includes("remote frontend transport does not support") || output.includes("ssh helper") || output.includes("unsupported command") || output.includes("bad substitution") || output.includes("command is too long")) { return "ssh-helper-command-shape-incompatible"; } if (command.exitCode === 126 || command.exitCode === 127 || output.includes("command not found") || output.includes("exec format error")) { return "ssh-helper-command-shape-incompatible"; } return "provider-ssh-command-missing"; } export function providerSshCommandFailureScopes(classification: ArtifactRegistryFailureClassification): string[] { const scopes = ["provider-ssh-command"]; if (classification === "control-plane-missing") return ["control-plane-missing", ...scopes]; if (classification === "remote-command-timeout") scopes.push("remote-command-timeout"); if (classification === "local-docker-required") scopes.push("local-docker-control-plane"); if (classification === "ssh-helper-command-shape-incompatible") scopes.push("ssh-helper-command-shape"); return scopes; } export function registryHealthFailureClassification(checks: Record, failedScopes: string[]): ArtifactRegistryFailureClassification | null { if (failedScopes.length === 0) return null; const installed = checks.unitExists === true || checks.composeExists === true || checks.configExists === true || checks.storageExists === true || checks.containerRunning === true; return installed ? "registry-unhealthy" : "registry-not-installed"; } export function registryHealthDecision(checks: Record, commandOk: boolean): Record { const scopeChecks: Array<[string, boolean]> = [ ["systemd", checks.systemctlAvailable === true && checks.unitExists === true && checks.unitActive === true], ["docker", checks.dockerAvailable === true], ["registry-container", checks.containerRunning === true], ["loopback-listener", checks.loopbackOnly === true], ["registry-api", checks.v2Ok === true], ["storage", checks.storageExists === true], ["rendered-config", checks.configHashMatches === true && checks.composeHashMatches === true && checks.unitHashMatches === true], ["registry-image", checks.imageMatches === true], ]; const healthyScopes = scopeChecks.filter(([, ok]) => ok).map(([scope]) => scope); const failedScopes = scopeChecks.filter(([, ok]) => !ok).map(([scope]) => scope); const runtimeApiHealthy = checks.containerRunning === true && checks.loopbackOnly === true && checks.v2Ok === true; const failureClassification = commandOk ? registryHealthFailureClassification(checks, failedScopes) : "provider-ssh-command-missing"; return { decision: commandOk && failedScopes.length === 0 ? "healthy" : commandOk || runtimeApiHealthy ? "service-degraded" : "retryable-transient", retryable: true, failureClassification, healthyScopes, failedScopes, recommendedAction: registryRecommendedAction(failureClassification), runtimeApiHealthy, }; }