fix: guard code queue hostpath source rollout
This commit is contained in:
@@ -18,7 +18,7 @@ bun scripts/cli.ts codex deploy <commitId>
|
||||
|
||||
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 <commitId>` 导出 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 <commitId>
|
||||
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 或等价查询入口;后台日志必须有界可查,失败时能显示最后日志尾部。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { emitCodeQueueSourceGuardCli } from "./src/code-queue-source-guard";
|
||||
|
||||
emitCodeQueueSourceGuardCli(process.argv.slice(2));
|
||||
@@ -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<string>();
|
||||
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 <repo-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;
|
||||
}
|
||||
+105
-4
@@ -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<string, unknown> {
|
||||
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<strin
|
||||
check: "bun scripts/cli.ts deploy check [--file deploy.json | --env dev|prod] [--service id]",
|
||||
plan: "bun scripts/cli.ts deploy plan [--file deploy.json | --env dev|prod] [--service id]",
|
||||
apply: "bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force] [--timeout-ms N] [--run-now]",
|
||||
guard: "bun scripts/cli.ts deploy guard code-queue-source [--root /home/ubuntu/cq-deploy]",
|
||||
},
|
||||
actions: {
|
||||
check: "Validate desired repo+commit state against live service health and commit markers.",
|
||||
plan: "Show desired/live drift, or with --env show the environment-ref dry-run plan without touching runtime resources.",
|
||||
apply: "Start an async target-side reconcile job unless --run-now is explicitly present.",
|
||||
guard: "Run local deployment guards without mutating runtime resources.",
|
||||
},
|
||||
options: [
|
||||
{ name: "--file <path>", 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<strin
|
||||
{ name: "--force", description: "Redeploy even when the live commit appears up to date." },
|
||||
{ name: "--timeout-ms <n>", 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 <path>", 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<unknown> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user