From 4c4e78d61e43af6f6d088da63c4474f9ef22ab36 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 18 May 2026 12:57:41 +0000 Subject: [PATCH] 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. --- AGENTS.md | 2 + docs/reference/artifact-registry.md | 107 ++++++ docs/reference/cli.md | 1 + scripts/cli.ts | 9 + scripts/src/artifact-registry.ts | 513 ++++++++++++++++++++++++++++ scripts/src/check.ts | 3 + scripts/src/help.ts | 23 ++ 7 files changed, 658 insertions(+) create mode 100644 docs/reference/artifact-registry.md create mode 100644 scripts/src/artifact-registry.ts diff --git a/AGENTS.md b/AGENTS.md index 56cadf6b..c2c43231 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts decision diary import/list/months/show`:把带日期标题的工作日志 Markdown 拆成 `YYYY-MM/YYYY-MM-DD.md` 日记条目并写入 PostgreSQL,规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service ]`:按根目录 `deploy.json` 或 `origin/master:deploy.json#environments.` 的服务 repo 和 commit 期望状态校验或更新用户服务;`--env dev` 当前只开放 D601 `backend-core`/`frontend` persistent dev rollout,规则见 `docs/reference/deploy.md` 与 `docs/reference/dev-environment.md`。 - `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。 +- `bun scripts/cli.ts artifact-registry plan|render|status|health|install --dry-run`:声明和只读检查 D601 host-managed CNCF Distribution registry;第一阶段不写 runtime、不改变 backend-core 生产部署路径,规则见 `docs/reference/artifact-registry.md`。 - `bun scripts/cli.ts ci install/status/run/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2e;`run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`,Tekton 规则见 `docs/reference/ci.md`。 - `bun scripts/cli.ts codex deploy `:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`。 - `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]`:通过 backend-core 私有代理提交 Code Queue 任务;控制面默认走主 server `code-queue-mgr` 写入 PostgreSQL,`--dry-run` 可只检查请求体不入队,规则见 `docs/reference/cli.md`。 @@ -74,6 +75,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `docs/reference/pipeline-model-proxy.md`:Pipeline v2 model proxy 链路架构、D601 宿主 proxy 服务部署、harness token 注入规则和 smoke test 验证流程。 - `docs/reference/deploy.md`:`deploy.json` desired-state、target-side build、一次性构建 proxy、直管/代管服务部署 executor 和 live commit 验证规则。 - `docs/reference/devops-hygiene.md`:Git-backed deployment truth、dirty worktree/manual repair 边界、受限手动操作和 CI 私有仓库 source-auth 规则。 +- `docs/reference/artifact-registry.md`:D601 host-managed CNCF Distribution registry、loopback-only 边界和 backend-core artifact CD 目标流程。 - `docs/reference/dev-environment.md`:D601 `unidesk-dev` persistent dev 环境、18083 dev frontend proxy、`deploy apply --env dev` 服务范围和 Rust backend-core 只在 D601 编译的边界。 - `docs/reference/ci.md`:D601 k3s Tekton CI、只读主数据库性能门禁和 CLI 入口规则。 - `docs/reference/dev-ci-runner.md`:`ci run-dev-e2e` 的 Git 控制 runner、短 launcher、结果目录和 no-CD 边界。 diff --git a/docs/reference/artifact-registry.md b/docs/reference/artifact-registry.md new file mode 100644 index 00000000..33675e18 --- /dev/null +++ b/docs/reference/artifact-registry.md @@ -0,0 +1,107 @@ +# D601 Artifact Registry + +D601 artifact registry 是为 backend-core 轻量 CD 准备的本地镜像制品入口。它使用开源、成熟的 CNCF Distribution Docker registry,不自定义镜像协议,也不把镜像托管到第三方服务。 + +第一阶段只提供声明、渲染、dry-run install 和只读状态检查;不会安装、启动或替换任何生产服务。backend-core 现有生产部署路径保持不变。 + +## Architecture + +registry 运行在 D601 host/WSL OS 上,由 systemd 管理 Docker Compose 项目: + +- systemd unit:`unidesk-artifact-registry.service` +- Compose project:`unidesk-artifact-registry` +- container:`unidesk-artifact-registry` +- image:`registry:2.8.3` +- config:`/home/ubuntu/.unidesk/artifact-registry/config.yml` +- compose:`/home/ubuntu/.unidesk/artifact-registry/compose.yml` +- storage:`/home/ubuntu/.unidesk/registry-storage` +- endpoint:`http://127.0.0.1:5000` + +它不是 k3s workload,不使用 NodePort、hostPort、LoadBalancer 或公开端口。Docker 端口映射必须绑定到 D601 loopback:`127.0.0.1:5000:5000`。容器内部 registry 可监听 `:5000`,安全边界由 host loopback 绑定保证。 + +这个服务和 `k3sctl-adapter` 一样位于 k3s 故障域外,但职责不同: + +- `k3sctl-adapter` 是 UniDesk 到 native k3s 的控制桥,属于 UniDesk 直管服务。 +- artifact registry 是 D601 host-managed 制品缓存基础设施,后续服务 CD 会使用它拉取或推送 commit-pinned 镜像。 + +## Dependency Boundary + +registry 运行期依赖应保持低且可手动维护: + +- D601 host Docker Engine。 +- Docker Compose plugin。 +- systemd。 +- `registry:2.8.3` 镜像,后续可升级为 digest pin。 +- D601 本地持久化目录。 +- provider-gateway Host SSH 只用于 UniDesk CLI 的只读 `status` / `health` 检查。 + +它不得依赖: + +- k3s 控制面或任意 k3s namespace。 +- 第三方镜像托管服务作为 backend-core artifact source of truth。 +- main server backend-core 本地 Rust 编译。 +- 公开 registry 端口或公网反向代理。 + +## CLI + +入口是: + +```bash +bun scripts/cli.ts artifact-registry plan +bun scripts/cli.ts artifact-registry render +bun scripts/cli.ts artifact-registry install --dry-run +bun scripts/cli.ts artifact-registry status +bun scripts/cli.ts artifact-registry health +``` + +`plan` 输出架构边界、依赖项、默认路径和未来 backend-core artifact CD 流程。`render` 输出 systemd unit、Compose 文件和 registry config 的完整内容与 SHA-256。`install --dry-run` 只列出将来要执行的远端动作,不写 D601 文件、不启动容器、不 reload systemd。 + +第一阶段 `install` 不带 `--dry-run` 必须失败。启用真实安装前,需要单独评审并补齐幂等写入、配置 hash 比对、回滚/停止策略和验收门禁。 + +`status` 和 `health` 通过: + +```bash +bun scripts/cli.ts ssh D601 argv bash -lc '' +``` + +只读检查 D601 状态。检查项包括: + +- systemd unit 是否存在、active、enabled。 +- Docker 是否可用,registry container 是否 running。 +- host 端口是否只监听 `127.0.0.1:5000` 或 `[::1]:5000`。 +- `curl http://127.0.0.1:5000/v2/` 是否返回 200。 +- storage 目录是否存在。 +- 远端 config、compose、unit 的 hash 是否匹配 CLI 渲染结果。 +- 运行镜像是否匹配期望版本。 + +`status` 表示只读查询是否成功;未安装时仍可 `ok=true` 并报告 `installed=false`。`health` 表示 registry 是否已按期望运行;未安装或不健康时返回 `ok=false`。 + +## Manual Maintenance + +服务采用 systemd + Docker Compose 管理,目的是让 D601 host 管理员可以用标准工具维护: + +```bash +systemctl status unidesk-artifact-registry.service +systemctl restart unidesk-artifact-registry.service +docker compose -p unidesk-artifact-registry -f /home/ubuntu/.unidesk/artifact-registry/compose.yml ps +``` + +手动维护必须遵守 Git-backed deployment truth:运行态可以临时修复,但长期配置应回到 `artifact-registry render` 的声明文件和本文档。若 D601 runtime 文件 hash 与 CLI 渲染结果不一致,`status` / `health` 会显示 mismatch,操作者需要确认这是受控升级还是漂移。 + +## Future Backend-Core Artifact CD + +目标流程是: + +1. D601 CI/dev execution 在 D601 构建 `unidesk/backend-core:`。 +2. 构建产物 push 到 D601 loopback registry。 +3. main server 通过受控、短生命周期的 localhost relay 从 D601 registry 拉取 commit-pinned 镜像。 +4. main server retag 后替换 backend-core Compose 服务。 +5. 部署后通过 image label、runtime env、health payload 验证 live commit。 + +这个未来 CD 路径仍必须满足: + +- source commit 来自 pushed Git,不来自 dirty worktree。 +- 镜像 tag 必须 commit-pinned,不能用 mutable latest 作为部署真相。 +- relay 是临时控制动作,不开放长期公网 registry。 +- CI 可以有较多依赖;CD 只做拉取、retag、recreate 和 live commit 验证。 +- `server rebuild backend-core` 仍不得作为 master server Rust 编译路径。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 68e24d9f..772170da 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -26,6 +26,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `deploy check/plan/apply` 默认从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新已支持目标;`deploy plan --env dev|prod` 只从 `origin/master:deploy.json#environments.` 读取 manifest 并输出 dry-run 环境计划,不使用本地 dirty worktree;当前 `deploy apply --env dev` 只支持 D601 `backend-core` 与 `frontend` persistent dev rollout,dev desired-state smoke 使用 `ci run-dev-e2e`;规则见 `docs/reference/deploy.md`、`docs/reference/dev-environment.md` 和 `docs/reference/dev-ci-runner.md`。 - `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace、dev PostgreSQL 底座和 dev workload manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`;也可显式校验 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` 或 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml`。所有 namespaced 对象必须只落到 `unidesk-dev`,foundation manifest 必须包含 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guard,core manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/Service,Code Queue dev manifest 必须包含 `code-queue-scheduler-dev`、`code-queue-read-dev`、`code-queue-write-dev` 和 dev provider egress proxy。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f `,仍不 apply 资源。 - `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine` 和 `rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。 +- `artifact-registry plan|render|status|health|install --dry-run` 管理 D601 host-managed CNCF Distribution registry 的声明和只读检查。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;第一阶段只允许渲染和 dry-run install,不写 D601 runtime,不改变 backend-core 生产部署路径。长期规则见 `docs/reference/artifact-registry.md`。 - `ci install|status|run|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`。 - `codex deploy ` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`。 - `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。 diff --git a/scripts/cli.ts b/scripts/cli.ts index a45a84dd..8f0ed3f0 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -17,6 +17,7 @@ import { parseNetworkPerfOptions, runNetworkPerf } from "./src/network-perf"; import { ciHelp, runCiCommand } from "./src/ci"; import { runSwapCommand } from "./src/swap"; import { runDevEnvCommand } from "./src/dev-env"; +import { runArtifactRegistryCommand } from "./src/artifact-registry"; import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from "./src/help"; const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); @@ -134,6 +135,14 @@ async function main(): Promise { return; } + if (top === "artifact-registry") { + const result = runArtifactRegistryCommand(args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } + const config = readConfig(); if (top === "ssh") { diff --git a/scripts/src/artifact-registry.ts b/scripts/src/artifact-registry.ts new file mode 100644 index 00000000..b1102c04 --- /dev/null +++ b/scripts/src/artifact-registry.ts @@ -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 { + 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:", + "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 { + 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 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 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 { + 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 { + 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"); +} diff --git a/scripts/src/check.ts b/scripts/src/check.ts index 3644495b..301064b3 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -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")); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index dd036be3..9f269495 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -36,6 +36,7 @@ export function rootHelp(): unknown { { command: "decision show ", 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 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 [--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; }