Files
pikasTech-unidesk/scripts/src/artifact-registry/status.ts
T
Lyon edfddd2445 fix: YAML-first 治理 CI/CD target (#919)
* docs: specify cicd yaml target governance

* fix: resolve cicd targets from yaml

---------

Co-authored-by: Codex <codex@noreply.local>
2026-06-26 01:14:38 +08:00

251 lines
14 KiB
TypeScript

// 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<string, unknown> {
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/<service-id>:<commit>",
"D601 CI pushes 127.0.0.1:5000/unidesk/<service-id>:<commit>",
"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:<commit>",
"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<string, string> {
const values: Record<string, string> = {};
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 <host> once frontend/backend-core dispatch is reachable";
if (classification === "local-docker-required") return "run the read-only check through --main-server-ip <host> 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 <artifact-registry-${action}-readonly-script> timeoutMs=${options.timeoutMs}`;
return `host.ssh provider=${options.providerId} mode=exec argv=bash -lc <artifact-registry-${action}-readonly-script> 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<string, boolean>, 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<string, boolean>, commandOk: boolean): Record<string, unknown> {
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,
};
}