794 lines
32 KiB
TypeScript
794 lines
32 KiB
TypeScript
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<string, unknown> {
|
|
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:<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",
|
|
],
|
|
};
|
|
}
|
|
|
|
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<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;
|
|
}
|
|
|
|
function asBool(value: string | undefined): boolean {
|
|
return value === "true";
|
|
}
|
|
|
|
function commandTail(result: CommandResult): Record<string, unknown> {
|
|
return {
|
|
command: result.command.length > 7 ? [...result.command.slice(0, 7), "<readonly-script>"] : 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<string, string>, command: CommandResult, healthMode: boolean): Record<string, unknown> {
|
|
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<string, unknown> {
|
|
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<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",
|
|
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<string, unknown> {
|
|
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<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");
|
|
}
|
|
|
|
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<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",
|
|
`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<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, provider-gateway SSH image stream, docker load, retag, no-build recreate, live commit verification.",
|
|
};
|
|
}
|
|
|
|
function localHelp(): Record<string, unknown> {
|
|
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 <full-sha> [--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<unknown> {
|
|
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 <full-sha>");
|
|
return options.runNow ? await deployBackendCoreNow(options) : deployBackendCoreJob(args, options);
|
|
}
|
|
throw new Error("unreachable artifact-registry action");
|
|
}
|