feat(cli): add D601 artifact registry planning
Add a first-stage artifact-registry CLI for D601 host-managed CNCF Distribution planning, rendering, dry-run install, and readonly status checks. Document the loopback-only boundary and future backend-core artifact CD flow.\n\nCloses #1.
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { runCommand, type CommandResult } from "./command";
|
||||
import { repoRoot } from "./config";
|
||||
|
||||
type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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 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 === "--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 {
|
||||
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 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
|
||||
Requires=docker.service
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=${options.baseDir}
|
||||
ExecStartPre=/bin/mkdir -p ${options.baseDir} ${options.storageDir}
|
||||
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",
|
||||
"first-stage CLI does not mutate D601 runtime; install is dry-run only",
|
||||
"backend-core production deploy behavior is unchanged in this issue",
|
||||
],
|
||||
renderedPaths: bundle.paths,
|
||||
futureFlow: [
|
||||
"D601 CI/dev builds unidesk/backend-core:<commit>",
|
||||
"D601 pushes the image into the loopback registry",
|
||||
"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 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 command = [process.execPath, "scripts/cli.ts", "ssh", options.providerId, "argv", "bash", "-lc", script];
|
||||
const result = runCommand(command, repoRoot, { timeoutMs: options.timeoutMs });
|
||||
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 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: "First-stage artifact-registry install is dry-run only; no D601 runtime files or services were changed.",
|
||||
};
|
||||
}
|
||||
|
||||
function localHelp(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
command: "artifact-registry plan|render|status|health|install",
|
||||
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]",
|
||||
],
|
||||
firstStage: "install without --dry-run is intentionally disabled",
|
||||
defaults: defaultOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export function runArtifactRegistryCommand(args: string[]): 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");
|
||||
}
|
||||
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") {
|
||||
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);
|
||||
}
|
||||
throw new Error("unreachable artifact-registry action");
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface CheckItem {
|
||||
const syntaxFiles = [
|
||||
"scripts/cli.ts",
|
||||
"scripts/src/check.ts",
|
||||
"scripts/src/artifact-registry.ts",
|
||||
"scripts/src/code-queue.ts",
|
||||
"scripts/src/command.ts",
|
||||
"scripts/src/decision-center.ts",
|
||||
@@ -220,6 +221,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
fileItem("scripts/cli.ts"),
|
||||
fileItem("AGENTS.md"),
|
||||
fileItem("TEST.md"),
|
||||
fileItem("docs/reference/artifact-registry.md"),
|
||||
fileItem("docker-compose.yml"),
|
||||
fileItem("src/components/backend-core/Cargo.toml"),
|
||||
fileItem("src/components/backend-core/Cargo.lock"),
|
||||
@@ -234,6 +236,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
fileItem("src/components/microservices/code-queue-mgr/src/index.ts"),
|
||||
fileItem("scripts/src/deploy.ts"),
|
||||
fileItem("scripts/src/e2e.ts"),
|
||||
fileItem("scripts/src/artifact-registry.ts"),
|
||||
);
|
||||
} else {
|
||||
items.push(skippedItem("files:required-entrypoints", "required file presence scan is opt-in", "--files or --full"));
|
||||
|
||||
@@ -36,6 +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: "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." },
|
||||
@@ -244,6 +245,27 @@ function devEnvHelp(): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
function artifactRegistryHelp(): unknown {
|
||||
return {
|
||||
command: "artifact-registry plan|render|status|health|install",
|
||||
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]",
|
||||
],
|
||||
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",
|
||||
"status and health use provider-gateway Host SSH readonly checks",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function staticNamespaceHelp(args: string[]): unknown | null {
|
||||
const [top] = args;
|
||||
if (!args.slice(1).some(isHelpToken)) return null;
|
||||
@@ -258,5 +280,6 @@ export function staticNamespaceHelp(args: string[]): unknown | null {
|
||||
if (top === "network") return networkHelp();
|
||||
if (top === "e2e") return e2eHelp();
|
||||
if (top === "dev-env") return devEnvHelp();
|
||||
if (top === "artifact-registry") return artifactRegistryHelp();
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user