From dd92e42918ac564759a65860b3fe96669c217518 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 10:37:44 +0000 Subject: [PATCH] fix: guard code queue hostpath source rollout --- docs/reference/codex-deploy.md | 18 ++- docs/reference/deploy.md | 24 ++++ scripts/code-queue-source-guard-test.ts | 40 ++++++ scripts/code-queue-source-guard.ts | 3 + scripts/src/code-queue-source-guard.ts | 161 ++++++++++++++++++++++++ scripts/src/deploy.ts | 109 +++++++++++++++- 6 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 scripts/code-queue-source-guard-test.ts create mode 100644 scripts/code-queue-source-guard.ts create mode 100644 scripts/src/code-queue-source-guard.ts diff --git a/docs/reference/codex-deploy.md b/docs/reference/codex-deploy.md index badf6ec9..d25b1ddd 100644 --- a/docs/reference/codex-deploy.md +++ b/docs/reference/codex-deploy.md @@ -18,7 +18,7 @@ bun scripts/cli.ts codex deploy 1. 对 Code Queue 部署先确保 PostgreSQL 中存在 `unidesk_deploy_ssh_identities(id='github.com')`,该记录保存 GitHub deploy SSH identity 的 private key、public key fingerprint 和 github.com `known_hosts` 行。未来受控 CD 不得把 secret 写入 task payload、deploy 日志、Docker image 或 Kubernetes Secret。 2. 在 D601 的 deploy cache 中通过本机 provider-gateway WS egress proxy 执行 `git fetch` remote,并用 `git archive ` 导出 tracked files 到一次性 export 目录;不得让 D601 直连 GitHub,也不得临时创建 SSH SOCKS、公网 master proxy 或 backend-core/provider-ingress fallback。 -3. 用 `rsync --delete` 同步导出的 repo 到 `/home/ubuntu/cq-deploy`,保留 `.state/`、`logs/`、`.git/`、`node_modules/` 和 `dist/`。 +3. 用 `rsync --delete` 同步导出的 repo 到 `/home/ubuntu/cq-deploy`,保留 `.state/`、`logs/`、`.git/`、`node_modules/` 和 `dist/`。同步完成后、任何 scheduler/read/write rollout 前,必须运行 Code Queue hostPath source guard:`bun scripts/code-queue-source-guard.ts --root /home/ubuntu/cq-deploy` 或等价 `bun scripts/cli.ts deploy guard code-queue-source --root /home/ubuntu/cq-deploy`。该 guard 至少扫描 `src/components/microservices/code-queue/src/**/*.ts` 的相对 `import` / `export ... from` / `import(...)`,确认目标文件存在;失败必须返回结构化 `degradedReason=missing-relative-import-target` 或 `source-root-missing`,并阻止 rollout。 4. 在 D601 用目标 Docker daemon 的本地 BuildKit builder 构建 `unidesk-code-queue:d601`,复用 D601 上已有基础镜像、inline cache 和 Code Queue build-base;provider-gateway WS egress 是唯一允许的构建代理通道,只作为本次 build 的环境变量与 build-arg 注入,并配合本次 build 的 `--network host` 让 RUN 阶段访问 D601 宿主 loopback proxy,不能污染 D601 宿主 Docker/HTTP proxy 配置,不能新建 SSH SOCKS、公网 master proxy 或直连 fallback。 5. `docker save` 镜像并导入原生 k3s containerd:`docker save unidesk-code-queue:d601 | sudo ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images import -`。导入后必须用同一个 containerd socket 验证 `unidesk-code-queue:d601` tag 存在;D601 Docker daemon 的本地 tag 不是 k3s containerd 的 source of truth。 6. `kubectl apply -f src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml`,其中包含 Code Queue、`d601-provider-egress-proxy` 和 `d601-tcp-egress-gateway`。apply 后必须验证 `code-queue`、`code-queue-read`、`code-queue-write`、`d601-provider-egress-proxy`、`d601-tcp-egress-gateway` 这些 Deployment 的 container image 都是 `unidesk-code-queue:d601`,不能让 kubelet 回退到 Docker Hub 或其他外部 registry。 @@ -26,6 +26,22 @@ bun scripts/cli.ts codex deploy 8. `kubectl -n unidesk rollout restart deployment/d601-tcp-egress-gateway deployment/code-queue` 并等待 rollout 完成。 9. 通过 backend-core 的真实微服务代理读取 Code Queue `/health`,强制校验 `deploy.commit` 等于本次解析出的 remote commit;如果健康的是旧服务或旧 Pod,job 必须失败。 +## HostPath Source Guard + +生产 `unidesk` namespace 的 Code Queue scheduler/read/write 仍处在 hostPath source 过渡模式:Pod 内 `/app` 和 `/root/unidesk` 都挂载 D601 host 的 `/home/ubuntu/cq-deploy`。这意味着镜像内已经包含的源码会被 hostPath 覆盖,镜像构建成功不能证明运行时源码完整。已知失效模式是 hostPath repo 部分同步:`src/components/microservices/code-queue/src/index.ts` 已包含 `./runtime-preflight` 导入,但 `/home/ubuntu/cq-deploy/src/components/microservices/code-queue/src/runtime-preflight.ts` 缺失,scheduler 启动即因 Bun 模块解析失败进入 CrashLoopBackOff。 + +`/home/ubuntu/cq-deploy` 是生产 Code Queue/k3s 共享 hostPath 部署仓库,也是当前 `code-queue.k8s.yaml` 中 repo、state 和 logs hostPath 的根。`/home/ubuntu/unidesk-code-queue-deploy` 是历史/开发工作区口径,不是生产 Pod 的 `/app` source mount;生产 rollout 判断必须以 `/home/ubuntu/cq-deploy` 为准。若两者需要保留软链接或迁移关系,必须先在 manifest、config 和文档中显式统一,不能让部署脚本从一个路径同步而 Pod 从另一个路径启动。 + +人工排查 D601 生产 k3s 状态时必须显式使用原生 k3s kubeconfig: + +```bash +KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n unidesk get deploy,svc,pod,endpoints +``` + +D601 默认 `kubectl` context 可能指向 Docker Desktop、kind 或其他本地集群,不能作为 UniDesk 原生 k3s scheduler 是否 ready、Service 是否有 endpoint、Pod 是否 CrashLoop 的证据。 + +hostPath source 模式只允许作为过渡态。长期生产 Code Queue 应收敛到 commit-pinned artifact/image CD:以 pushed commit 为版本真相,CI 产出带 source commit label 的镜像,CD 只消费 commit-pinned image、更新 manifest/env/annotation 并验证 live health commit;生产 Pod 不应再用可部分同步的 hostPath source 覆盖镜像内应用源码。 + ## Observability 未来受控 Code Queue CD 实现后,部署触发本身不应阻塞等待完成。返回 JSON 中必须包含 run id、status command 或等价查询入口;后台日志必须有界可查,失败时能显示最后日志尾部。 diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index 64976a54..6a386505 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -199,6 +199,30 @@ MDTODO and ClaudeQQ are standard `k3sctl-managed` artifact consumers in the same Code Queue is explicitly narrower. Only `--env dev --service code-queue` is a supported artifact consumer target, and it may mutate only `unidesk-dev` Code Queue execution objects. Production Code Queue artifact deploy, production rollout and production manifest mutation are unsupported and must fail visibly. +## Code Queue Production HostPath Guard + +生产 Code Queue 仍处在 hostPath source 过渡边界。生产 scheduler/read/write Pod 会把 D601 `/home/ubuntu/cq-deploy` 同时挂载为 `/app` 和 `/root/unidesk`,因此 Bun 进程启动时解析的是 hostPath repo,而不是镜像内已 COPY 的源码。即使 Docker build 或 `unidesk-code-queue:d601` 导入成功,只要 `/home/ubuntu/cq-deploy` 部分同步,运行态仍会失败。必须防住的具体故障类是:`index.ts` 已导入 `./runtime-preflight`,但 `/home/ubuntu/cq-deploy/src/components/microservices/code-queue/src/runtime-preflight.ts` 缺失;该状态必须视为 deploy-degraded,并阻止任何 scheduler restart 或 rollout。 + +任何仍会修改生产 `/home/ubuntu/cq-deploy` 的部署或恢复路径,都必须在 source sync 之后、Kubernetes rollout 之前运行 Code Queue source import guard: + +```bash +bun scripts/code-queue-source-guard.ts --root /home/ubuntu/cq-deploy +# or from a local controller worktree: +bun scripts/cli.ts deploy guard code-queue-source --root /home/ubuntu/cq-deploy +``` + +guard 必须返回 JSON,并在失败时以非零退出码给出 `degradedReason=source-root-missing` 或 `degradedReason=missing-relative-import-target`;部署编排必须透出该 reason,并在 `kubectl rollout restart` 或任何会迫使 scheduler 重新导入脏 hostPath source 的 Pod 删除之前停止。当前 guard 覆盖 `src/components/microservices/code-queue/src/**/*.ts` 下的相对 `import`、`export ... from` 和 `import(...)` 目标,包括 `runtime-preflight.ts` 这类缺文件故障。 + +路径所有权必须保持显式。`/home/ubuntu/cq-deploy` 是 `src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml` 使用的生产 k3s hostPath repo。`/home/ubuntu/unidesk-code-queue-deploy` 是历史/开发 worktree 名称;除非 manifest、部署代码和文档一起修改,否则不得假设它是生产 scheduler source。迁移期如果两者通过软链接关联,guard 仍必须对实际挂载进 `/app` 的路径运行。 + +D601 k3s 验证必须始终设置原生 kubeconfig: + +```bash +KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n unidesk get deploy,svc,pod,endpoints +``` + +D601 默认 `kubectl` context 可能指向 Docker Desktop、kind 或其他本地集群,因此不能作为 UniDesk production Code Queue ready 的证据。长期目标是完全移除生产 hostPath source 覆盖,让 Code Queue production 收敛到 commit-pinned artifact/image CD,并像其他已审查 artifact consumer 一样验证 live commit。 + ## CI Separation Continuous integration is intentionally separate from this deploy reconciler. D601 k3s hosts Tekton CI resources described in `docs/reference/ci.md`; PipelineRuns may clone, check, run read-only performance gates, create temporary CI-owned namespaces for dev manifest smoke e2e, or publish commit-pinned backend-core/user-service image artifacts to the D601 artifact registry. They must not call `deploy apply`, `codex deploy`, `kubectl rollout restart` for production services, mutate `deploy.json`, or write production namespaces. diff --git a/scripts/code-queue-source-guard-test.ts b/scripts/code-queue-source-guard-test.ts new file mode 100644 index 00000000..62035320 --- /dev/null +++ b/scripts/code-queue-source-guard-test.ts @@ -0,0 +1,40 @@ +import { cpSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "./src/code-queue-source-guard"; + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +const repoRoot = process.cwd(); +const current = codeQueueSourceImportPreflight(repoRoot); +assert(current.ok, `current source should pass guard: ${JSON.stringify(current.missing.slice(0, 3))}`); + +const tmpRoot = join(tmpdir(), `unidesk-code-queue-source-guard-${process.pid}`); +rmSync(tmpRoot, { recursive: true, force: true }); +mkdirSync(join(tmpRoot, codeQueueSourceSubdir), { recursive: true }); +cpSync(join(repoRoot, codeQueueSourceSubdir), join(tmpRoot, codeQueueSourceSubdir), { recursive: true }); +mkdirSync(join(tmpRoot, "src/components/shared/src"), { recursive: true }); +cpSync(join(repoRoot, "src/components/shared/src"), join(tmpRoot, "src/components/shared/src"), { recursive: true }); +rmSync(join(tmpRoot, codeQueueSourceSubdir, "runtime-preflight.ts")); + +const missing = codeQueueSourceImportPreflight(tmpRoot); +assert(!missing.ok, "guard should fail when runtime-preflight.ts is missing"); +assert(missing.degradedReason === "missing-relative-import-target", `unexpected degradedReason: ${missing.degradedReason}`); +assert(missing.missing.some((item) => item.importer.endsWith("index.ts") && item.specifier === "./runtime-preflight"), "missing runtime-preflight import was not reported"); + +rmSync(tmpRoot, { recursive: true, force: true }); + +process.stdout.write(`${JSON.stringify({ + ok: true, + current: { + checkedFiles: current.checkedFiles, + checkedImports: current.checkedImports, + }, + missingRuntimePreflight: { + ok: missing.ok, + degradedReason: missing.degradedReason, + missing: missing.missing.map((item) => ({ importer: item.importer, specifier: item.specifier })).slice(0, 5), + }, +}, null, 2)}\n`); diff --git a/scripts/code-queue-source-guard.ts b/scripts/code-queue-source-guard.ts new file mode 100644 index 00000000..a2a8d2bb --- /dev/null +++ b/scripts/code-queue-source-guard.ts @@ -0,0 +1,3 @@ +import { emitCodeQueueSourceGuardCli } from "./src/code-queue-source-guard"; + +emitCodeQueueSourceGuardCli(process.argv.slice(2)); diff --git a/scripts/src/code-queue-source-guard.ts b/scripts/src/code-queue-source-guard.ts new file mode 100644 index 00000000..b7492d49 --- /dev/null +++ b/scripts/src/code-queue-source-guard.ts @@ -0,0 +1,161 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +export const codeQueueSourceSubdir = "src/components/microservices/code-queue/src"; +const tsExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"] as const; + +export interface MissingRelativeImport { + importer: string; + specifier: string; + expected: string[]; +} + +export interface CodeQueueSourceImportPreflightResult { + ok: boolean; + guard: "code-queue-hostpath-source-imports"; + root: string; + sourceRoot: string; + checkedFiles: number; + checkedImports: number; + missing: MissingRelativeImport[]; + degradedReason: "none" | "source-root-missing" | "missing-relative-import-target"; + message: string; +} + +function normalizedRelative(from: string, to: string): string { + return relative(from, to).split("\\").join("/"); +} + +function walkTsFiles(dir: string): string[] { + const result: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git" || entry.name === ".state") continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) { + result.push(...walkTsFiles(path)); + continue; + } + if (entry.isFile() && /\.tsx?$/u.test(entry.name)) result.push(path); + } + return result.sort(); +} + +function stripComments(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//gu, "") + .replace(/(^|[^:])\/\/.*$/gmu, "$1"); +} + +function relativeImportSpecifiers(text: string): string[] { + const cleaned = stripComments(text); + const specifiers = new Set(); + const patterns = [ + /\b(?:import|export)\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'](\.{1,2}\/[^"']+)["']/gu, + /\bimport\s*\(\s*["'](\.{1,2}\/[^"']+)["']\s*\)/gu, + ]; + for (const pattern of patterns) { + for (const match of cleaned.matchAll(pattern)) { + const specifier = match[1]; + if (specifier !== undefined) specifiers.add(specifier); + } + } + return [...specifiers].sort(); +} + +function importCandidates(importer: string, specifier: string): string[] { + const base = resolve(dirname(importer), specifier); + if (tsExtensions.some((extension) => base.endsWith(extension))) return [base]; + return [ + ...tsExtensions.map((extension) => `${base}${extension}`), + ...tsExtensions.map((extension) => join(base, `index${extension}`)), + ]; +} + +function importTargetExists(candidates: string[]): boolean { + return candidates.some((candidate) => { + if (!existsSync(candidate)) return false; + const stats = statSync(candidate); + return stats.isFile(); + }); +} + +export function codeQueueSourceImportPreflight(rootDir: string): CodeQueueSourceImportPreflightResult { + const root = resolve(rootDir); + const sourceRoot = resolve(root, codeQueueSourceSubdir); + if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) { + return { + ok: false, + guard: "code-queue-hostpath-source-imports", + root, + sourceRoot, + checkedFiles: 0, + checkedImports: 0, + missing: [], + degradedReason: "source-root-missing", + message: `Code Queue source root is missing: ${codeQueueSourceSubdir}`, + }; + } + + const files = walkTsFiles(sourceRoot); + const missing: MissingRelativeImport[] = []; + let checkedImports = 0; + for (const file of files) { + const text = readFileSync(file, "utf8"); + for (const specifier of relativeImportSpecifiers(text)) { + checkedImports += 1; + const candidates = importCandidates(file, specifier); + if (importTargetExists(candidates)) continue; + missing.push({ + importer: normalizedRelative(root, file), + specifier, + expected: candidates.map((candidate) => normalizedRelative(root, candidate)), + }); + } + } + + return { + ok: missing.length === 0, + guard: "code-queue-hostpath-source-imports", + root, + sourceRoot, + checkedFiles: files.length, + checkedImports, + missing, + degradedReason: missing.length === 0 ? "none" : "missing-relative-import-target", + message: missing.length === 0 + ? `Code Queue hostPath source import preflight passed (${files.length} files, ${checkedImports} relative imports).` + : `Code Queue hostPath source import preflight failed: ${missing.length} relative import target(s) are missing.`, + }; +} + +function optionValue(args: string[], name: string): string | null { + const index = args.indexOf(name); + if (index === -1) return null; + const value = args[index + 1]; + if (value === undefined || value.length === 0) throw new Error(`${name} requires a value`); + return value; +} + +export function runCodeQueueSourceGuardCli(args: string[]): CodeQueueSourceImportPreflightResult { + if (args.includes("--help") || args.includes("-h")) { + return { + ok: true, + guard: "code-queue-hostpath-source-imports", + root: process.cwd(), + sourceRoot: resolve(process.cwd(), codeQueueSourceSubdir), + checkedFiles: 0, + checkedImports: 0, + missing: [], + degradedReason: "none", + message: "usage: bun scripts/code-queue-source-guard.ts --root ", + }; + } + const root = optionValue(args, "--root") ?? process.cwd(); + return codeQueueSourceImportPreflight(root); +} + +export function emitCodeQueueSourceGuardCli(args: string[]): void { + const result = runCodeQueueSourceGuardCli(args); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.ok) process.exitCode = 1; +} diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index 48d8b269..a1d9007c 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -8,6 +8,7 @@ import { ensureGithubSshIdentityForProvider } from "./deploy-ssh-identity"; import { runArtifactRegistryCommand } from "./artifact-registry"; import { startJob } from "./jobs"; import { coreInternalFetch } from "./microservices"; +import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "./code-queue-source-guard"; type DeployAction = "check" | "plan" | "apply"; type DeployEnvironment = "dev" | "prod"; @@ -128,7 +129,9 @@ const pollIntervalMs = 5_000; const remoteDeployRoot = "/home/ubuntu/.unidesk/deploy"; const k8sNamespace = "unidesk"; const k8sKubeconfig = "/etc/rancher/k3s/k3s.yaml"; -const k3sDeployDir = "/home/ubuntu/cq-deploy"; +// Production k3s hostPath repo. Code Queue production Pods mount this path as /app and /root/unidesk, +// so deploy guards must validate this tree rather than config.json development.worktreePath. +const k3sProductionHostPathRepoDir = "/home/ubuntu/cq-deploy"; const providerGatewayWsEgressProxyUrl = "http://127.0.0.1:18789"; const nativeK3sInstallVersion = "v1.34.1+k3s1"; const nativeK3sImage = "rancher/k3s:v1.34.1-k3s1"; @@ -191,7 +194,7 @@ function isHelpArg(value: string | undefined): boolean { } export function deployHelp(action: string | undefined = undefined): Record { - const command = action === undefined || isHelpArg(action) ? "deploy check|plan|apply" : `deploy ${action}`; + const command = action === undefined || isHelpArg(action) ? "deploy check|plan|apply|guard" : `deploy ${action}`; return { ok: true, command, @@ -199,11 +202,13 @@ export function deployHelp(action: string | undefined = undefined): Record", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js. Local D601 maintenance apply is limited to approved direct exceptions; Code Queue direct rollout is disabled." }, @@ -214,6 +219,7 @@ export function deployHelp(action: string | undefined = undefined): Record", default: defaultTimeoutMs, description: "Per-step timeout budget where supported." }, { name: "--run-now", description: "Run apply in the foreground worker process; omit it for fire-and-forget async job mode." }, + { name: "guard code-queue-source --root ", description: "Validate Code Queue hostPath source relative imports before any scheduler rollout; failures report degradedReason and missing import targets." }, ], }; } @@ -887,7 +893,7 @@ function targetExportDir(service: UniDeskMicroserviceConfig, runId: string): str function targetWorkDir(service: UniDeskMicroserviceConfig): string { if (isDevK3sDeployService(service)) return service.development.worktreePath; - if (service.deployment.mode === "k3sctl-managed") return k3sDeployDir; + if (service.deployment.mode === "k3sctl-managed") return k3sProductionHostPathRepoDir; if (targetIsMain(service) && isUnideskRepo(service.repository.url)) { return rootPath(".state", "deploy", "work", safeId(service.id)); } @@ -1223,6 +1229,79 @@ function syncSourceScript(service: UniDeskMicroserviceConfig, exportDir: string) ].join("\n"); } +function codeQueueSourcePreflightScript(service: UniDeskMicroserviceConfig): string { + if (service.id !== "code-queue") return ""; + const workDir = sourceWorkDir(service); + return [ + "set -euo pipefail", + `guard_root=${shellQuote(workDir)}`, + "if [ -f \"$guard_root/scripts/code-queue-source-guard.ts\" ] && command -v bun >/dev/null 2>&1; then", + " bun \"$guard_root/scripts/code-queue-source-guard.ts\" --root \"$guard_root\"", + "else", + " python3 - \"$guard_root\" <<'PY'", + "import json", + "import os", + "import re", + "import sys", + "", + `SOURCE_SUBDIR = ${JSON.stringify(codeQueueSourceSubdir)}`, + "EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']", + "", + "def rel(root, path):", + " return os.path.relpath(path, root).replace(os.sep, '/')", + "", + "def strip_comments(text):", + " text = re.sub(r'/\\*[\\s\\S]*?\\*/', '', text)", + " return re.sub(r'(^|[^:])//.*$', r'\\1', text, flags=re.MULTILINE)", + "", + "def specifiers(text):", + " clean = strip_comments(text)", + " values = set()", + " patterns = [", + " re.compile(r'\\b(?:import|export)\\s+(?:type\\s+)?(?:[\\s\\S]*?\\s+from\\s+)?[\"\\'](\\.{1,2}/[^\"\\']+)[\"\\']'),", + " re.compile(r'\\bimport\\s*\\(\\s*[\"\\'](\\.{1,2}/[^\"\\']+)[\"\\']\\s*\\)'),", + " ]", + " for pattern in patterns:", + " values.update(match.group(1) for match in pattern.finditer(clean))", + " return sorted(values)", + "", + "def candidates(importer, specifier):", + " base = os.path.abspath(os.path.join(os.path.dirname(importer), specifier))", + " if any(base.endswith(extension) for extension in EXTENSIONS):", + " return [base]", + " return [base + extension for extension in EXTENSIONS] + [os.path.join(base, 'index' + extension) for extension in EXTENSIONS]", + "", + "root = os.path.abspath(sys.argv[1])", + "source_root = os.path.join(root, SOURCE_SUBDIR)", + "if not os.path.isdir(source_root):", + " print(json.dumps({'ok': False, 'guard': 'code-queue-hostpath-source-imports', 'root': root, 'sourceRoot': source_root, 'checkedFiles': 0, 'checkedImports': 0, 'missing': [], 'degradedReason': 'source-root-missing', 'message': 'Code Queue source root is missing: ' + SOURCE_SUBDIR}, ensure_ascii=False))", + " raise SystemExit(1)", + "files = []", + "for current_root, dirs, names in os.walk(source_root):", + " dirs[:] = [name for name in dirs if name not in ('node_modules', 'dist', '.git', '.state')]", + " for name in names:", + " if name.endswith('.ts') or name.endswith('.tsx'):", + " files.append(os.path.join(current_root, name))", + "files.sort()", + "missing = []", + "checked_imports = 0", + "for path in files:", + " with open(path, encoding='utf-8') as handle:", + " text = handle.read()", + " for specifier in specifiers(text):", + " checked_imports += 1", + " expected = candidates(path, specifier)", + " if any(os.path.isfile(candidate) for candidate in expected):", + " continue", + " missing.append({'importer': rel(root, path), 'specifier': specifier, 'expected': [rel(root, candidate) for candidate in expected]})", + "result = {'ok': not missing, 'guard': 'code-queue-hostpath-source-imports', 'root': root, 'sourceRoot': source_root, 'checkedFiles': len(files), 'checkedImports': checked_imports, 'missing': missing, 'degradedReason': 'none' if not missing else 'missing-relative-import-target', 'message': 'Code Queue hostPath source import preflight passed (%d files, %d relative imports).' % (len(files), checked_imports) if not missing else 'Code Queue hostPath source import preflight failed: %d relative import target(s) are missing.' % len(missing)}", + "print(json.dumps(result, ensure_ascii=False))", + "raise SystemExit(0 if result['ok'] else 1)", + "PY", + "fi", + ].join("\n"); +} + function claudeqqDeployAssetOverlayCommands(): string[] { const assets = [ { @@ -2480,6 +2559,12 @@ async function applyOneService(config: UniDeskConfig, service: UniDeskMicroservi const sync = await step(config, service, "sync-source", syncSourceScript(service, exportDir), targetIsMain(service) ? repoRoot : "/home/ubuntu", 90_000, !targetIsMain(service)); if (!pushStep(steps, sync)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; + const codeQueueSourcePreflight = codeQueueSourcePreflightScript(service); + if (codeQueueSourcePreflight.length > 0) { + const sourcePreflight = await step(config, service, "code-queue-hostpath-source-preflight", codeQueueSourcePreflight, targetWorkDir(service), 60_000, !targetIsMain(service)); + if (!pushStep(steps, sourcePreflight)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; + } + const controlManifestSyncScript = syncK8sControlManifestsScript(service); if (controlManifestSyncScript.length > 0) { const controlManifestStep = isDevK3sDeployService(service) ? "verify-target-k3s-manifest" : "sync-k3s-control-manifests"; @@ -2901,10 +2986,26 @@ function applyJob(config: UniDeskConfig, args: string[], options: DeployOptions) }; } +function runDeployGuardCommand(args: string[]): unknown { + const [guardName] = args; + if (guardName !== "code-queue-source") { + return { + ok: false, + supported: false, + error: "unsupported-deploy-guard", + guard: guardName ?? null, + supportedGuards: ["code-queue-source"], + }; + } + const root = optionValue(args.slice(1), ["--root"]) ?? repoRoot; + return codeQueueSourceImportPreflight(root); +} + export async function runDeployCommand(config: UniDeskConfig | null, args: string[]): Promise { const [actionRaw = "check"] = args; if (isHelpArg(actionRaw) || args.slice(1).some(isHelpArg)) return deployHelp(isHelpArg(actionRaw) ? undefined : actionRaw); - if (!["check", "plan", "apply"].includes(actionRaw)) throw new Error("deploy command must be one of: check, plan, apply"); + if (actionRaw === "guard") return runDeployGuardCommand(args.slice(1)); + if (!["check", "plan", "apply"].includes(actionRaw)) throw new Error("deploy command must be one of: check, plan, apply, guard"); const action = actionRaw as DeployAction; const options = parseOptions(args.slice(1)); if (options.environment !== null) {