From f0dc754103b9be8a2bccd7ba6ae1dc0bc498dae2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 17 May 2026 16:50:19 +0000 Subject: [PATCH] Add deploy environment dry-run guardrails --- deploy.json | 1 + docs/reference/cli.md | 2 +- docs/reference/deploy.md | 12 ++ scripts/cli.ts | 10 +- scripts/src/deploy.ts | 233 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 247 insertions(+), 11 deletions(-) diff --git a/deploy.json b/deploy.json index 17cf7f07..76c5b149 100644 --- a/deploy.json +++ b/deploy.json @@ -1,5 +1,6 @@ { "schemaVersion": 1, + "environment": "prod", "services": [ { "id": "findjob", diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1c3e92a5..57005bb9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -21,7 +21,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `ssh skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 - `microservice list/status/health/diagnostics/tunnel-self-test/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 或 k3s 控制面中的用户服务(底层命令名仍为 microservice);`health`、`diagnostics`、`tunnel-self-test` 和 `proxy` 会走真实 backend-core -> provider-gateway 或 k3sctl-adapter -> 节点服务链路,`proxy` 支持受控 JSON 请求体并对超大响应 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 - `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;它不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。 -- `deploy check/plan/apply` 从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新直管服务与 k3s 代管服务;规则见 `docs/reference/deploy.md`。 +- `deploy check/plan/apply` 默认从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新直管服务与 k3s 代管服务;`deploy plan --env dev|prod` 在 Phase 0 只从固定 Git ref 读取 manifest 并输出 dry-run 环境计划,不使用本地 dirty worktree;规则见 `docs/reference/deploy.md`。 - `codex deploy ` 是 Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build、k3s import、rollout 和 live commit 验证路径;详细规则见 `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 只轮询并执行已入库任务。 - `codex task ` 通过 Code Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、执行 Provider、工作目录、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。 diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index 57f8947c..d2a29402 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -9,6 +9,7 @@ The root `deploy.json` is intentionally minimal: ```json { "schemaVersion": 1, + "environment": "prod", "services": [ { "id": "code-queue", @@ -19,8 +20,17 @@ The root `deploy.json` is intentionally minimal: } ``` +`environment` is optional only for the legacy local-file compatibility path. When present it must be exactly `dev` or `prod`. Any `--env ` command requires the manifest to declare the same `environment`; `--env dev` must reject `environment=prod`, and `--env prod` must reject `environment=dev`. + `deploy.json` must not contain provider IDs, ports, compose service names, Kubernetes namespace, health paths, environment variables, Dockerfile paths or build commands. The deploy reconciler joins each `id` with `config.json.microservices[]` and existing k3s manifests to resolve those details. A service listed in `deploy.json` but missing from `config.json` is an error. A service with no Dockerfile source artifact is reported as unsupported rather than silently skipped. `commitId` may be a unique pushed short SHA or a full SHA; every deploy command resolves it through the remote repository to a full 40-character commit before target-side build or rollout, and fails immediately if the SHA is missing or ambiguous. +Environment mode never reads the local working tree manifest. The mapping is fixed: + +- `dev -> origin/deploy/dev` +- `prod -> origin/deploy/prod` + +The current Phase 0 implementation enables only dry-run `check` and `plan` for `--env`. It fetches the fixed ref, reads `deploy.json` from that ref, validates the declared environment, and reports the manifest commit/blob, service commit IDs, target namespace, database fingerprint and Provider identity. `deploy apply --env ...` is intentionally rejected until the dev infrastructure executors exist. + `config.json.microservices[].repository.commitId` is retained for catalog compatibility, but `deploy.json` is the deployment version authority for the reconciler. ## CLI @@ -29,6 +39,8 @@ The root `deploy.json` is intentionally minimal: `bun scripts/cli.ts deploy plan [--file deploy.json] [--service ]` prints the same live state plus the intended action: `noop`, `deploy` or `unsupported`. +`bun scripts/cli.ts deploy plan --env dev [--service ]` reads `origin/deploy/dev:deploy.json` and prints a dry-run environment plan without checking or mutating live runtime resources. `deploy check --env dev` uses the same Phase 0 dry-run path. `--env prod` is available for parity but is also dry-run only in Phase 0; it reads `origin/deploy/prod:deploy.json` and must not use a dirty local `deploy.json`. + `bun scripts/cli.ts deploy apply [--file deploy.json] [--service ] [--dry-run] [--force]` starts an asynchronous job. Use `bun scripts/cli.ts job status --tail-bytes 30000` to observe progress. `--dry-run` resolves the same plan but does not build or replace runtime objects. `--force` rebuilds even when the live commit matches. All deploy commands output JSON. Long operations must use `.state/jobs/` and bounded log tails; no deploy path may succeed with missing progress output. diff --git a/scripts/cli.ts b/scripts/cli.ts index 8c9e698d..84685f9f 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -53,7 +53,7 @@ function help(): unknown { { command: "decision upload [--title text] [--type meeting|decision] [--level G0|G1|G2|G3|P0|P1|P2|P3|none] [--status active|blocked|parked|done] [--linked-goal-id id] [--evidence url]", description: "Upload a meeting note or decision record through backend-core -> decision-center user-service proxy." }, { command: "decision list [--type ...] [--status ...] [--level ...] [--linked-goal-id id] [--limit N]", description: "List Decision Center records through the user-service proxy." }, { command: "decision show ", description: "Show one Decision Center record." }, - { command: "deploy check|plan|apply [--file deploy.json] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest using target-side build and live commit verification." }, + { 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 uses fixed environment refs for dry-run planning in Phase 0." }, { 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." }, @@ -147,6 +147,14 @@ async function main(): Promise { return; } + if (top === "deploy" && (args.includes("--env") || args.includes("--environment"))) { + const result = await runDeployCommand(null, 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/deploy.ts b/scripts/src/deploy.ts index 2be24efa..0ccdad14 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -9,6 +9,7 @@ import { startJob } from "./jobs"; import { coreInternalFetch } from "./microservices"; type DeployAction = "check" | "plan" | "apply"; +type DeployEnvironment = "dev" | "prod"; interface DeployManifestService { id: string; @@ -18,11 +19,13 @@ interface DeployManifestService { interface DeployManifest { schemaVersion: 1; + environment: DeployEnvironment | null; services: DeployManifestService[]; } interface DeployOptions { file: string; + environment: DeployEnvironment | null; serviceId: string | null; runNow: boolean; dryRun: boolean; @@ -81,6 +84,37 @@ interface ServiceRuntimeState { raw?: unknown; } +interface DeployEnvironmentTarget { + environment: DeployEnvironment; + remote: string; + branch: string; + gitRef: string; + namespace: string; + runtimeScope: string; + database: { + serviceId: string; + host: string; + port: number; + name: string; + }; + provider: { + providerId: string; + nodeId: string; + credentialScope: string; + }; +} + +interface DeployEnvironmentManifestSource { + source: "git-ref"; + path: "deploy.json"; + ref: string; + remote: string; + branch: string; + commit: string; + blob: string; + fetchedAt: string; +} + const defaultDeployFile = "deploy.json"; const defaultTimeoutMs = 900_000; const resolveFetchTimeout = "120s"; @@ -97,6 +131,46 @@ const nativeK3sInstallVersion = "v1.34.1+k3s1"; const nativeK3sImage = "rancher/k3s:v1.34.1-k3s1"; const nativeK3sCtrAddress = "/run/k3s/containerd/containerd.sock"; const unideskRepoUrl = "https://github.com/pikasTech/unidesk"; +const deployEnvironmentTargets: Record = { + dev: { + environment: "dev", + remote: "origin", + branch: "deploy/dev", + gitRef: "origin/deploy/dev", + namespace: "unidesk-dev", + runtimeScope: "d601-k3s-dev", + database: { + serviceId: "postgres-dev", + host: "postgres-dev.unidesk-dev.svc.cluster.local", + port: 5432, + name: "unidesk_dev", + }, + provider: { + providerId: "D601-dev", + nodeId: "D601", + credentialScope: "dev-only", + }, + }, + prod: { + environment: "prod", + remote: "origin", + branch: "deploy/prod", + gitRef: "origin/deploy/prod", + namespace: "unidesk", + runtimeScope: "prod-main-server-compose-and-d601-k3s", + database: { + serviceId: "postgres-prod", + host: "database.unidesk-main-server", + port: 5432, + name: "unidesk", + }, + provider: { + providerId: "D601", + nodeId: "D601", + credentialScope: "prod", + }, + }, +}; function isHelpArg(value: string | undefined): boolean { return value === "help" || value === "--help" || value === "-h"; @@ -108,17 +182,18 @@ 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." }, + { name: "--env ", description: "Read deploy.json from the fixed environment ref: dev=origin/deploy/dev, prod=origin/deploy/prod. Phase 0 supports check/plan only." }, { name: "--service ", description: "Limit reconcile to one service from the manifest." }, { name: "--dry-run", description: "Prepare and validate without mutating the target service." }, { name: "--force", description: "Redeploy even when the live commit appears up to date." }, @@ -187,6 +262,10 @@ function isFullGitSha(value: string): boolean { return /^[0-9a-f]{40}$/iu.test(value); } +function isDeployEnvironment(value: string): value is DeployEnvironment { + return value === "dev" || value === "prod"; +} + function isUnideskRepo(repo: string): boolean { const desiredSlug = repoSlug(repo); const unideskSlug = repoSlug(unideskRepoUrl); @@ -315,6 +394,7 @@ function resolveDesiredCommit(desired: DeployManifestService): DeployManifestSer function resolveManifestCommits(manifest: DeployManifest, serviceId: string | null): DeployManifest { return { schemaVersion: manifest.schemaVersion, + environment: manifest.environment, services: manifest.services.map((service) => (serviceId === null || service.id === serviceId ? resolveDesiredCommit(service) : service)), }; } @@ -338,9 +418,19 @@ function positiveIntegerOption(args: string[], names: string[], defaultValue: nu return parsed; } +function environmentOption(args: string[]): DeployEnvironment | null { + const raw = optionValue(args, ["--env", "--environment"]); + if (raw === undefined) return null; + if (!isDeployEnvironment(raw)) throw new Error("--env must be dev or prod"); + return raw; +} + function parseOptions(args: string[]): DeployOptions { + const environment = environmentOption(args); + if (environment !== null && args.includes("--file")) throw new Error("deploy --env reads deploy.json from the fixed Git ref and cannot be combined with --file"); return { file: optionValue(args, ["--file"]) ?? defaultDeployFile, + environment, serviceId: optionValue(args, ["--service", "--service-id"]) ?? null, runNow: args.includes("--run-now"), dryRun: args.includes("--dry-run"), @@ -362,10 +452,24 @@ function positionalArgs(args: string[]): string[] { return result; } -function parseDeployManifest(parsed: unknown): DeployManifest { +function parseDeployManifest(parsed: unknown, source: string, expectedEnvironment: DeployEnvironment | null): DeployManifest { const record = asRecord(parsed); - if (record === null) throw new Error("deploy manifest must be an object"); + if (record === null) throw new Error(`deploy manifest ${source} must be an object`); if (record.schemaVersion !== 1) throw new Error("deploy manifest schemaVersion must be 1"); + const rawEnvironment = record.environment; + let environment: DeployEnvironment | null = null; + if (rawEnvironment !== undefined) { + if (typeof rawEnvironment !== "string" || !isDeployEnvironment(rawEnvironment)) { + throw new Error("deploy manifest environment must be dev or prod when present"); + } + environment = rawEnvironment; + } + if (expectedEnvironment !== null) { + if (environment === null) throw new Error(`deploy manifest ${source} must declare environment=${expectedEnvironment} when --env ${expectedEnvironment} is used`); + if (environment !== expectedEnvironment) { + throw new Error(`deploy manifest ${source} declares environment=${environment}, refusing --env ${expectedEnvironment}`); + } + } if (!Array.isArray(record.services)) throw new Error("deploy manifest services must be an array"); const seen = new Set(); const services = record.services.map((item, index) => { @@ -381,7 +485,34 @@ function parseDeployManifest(parsed: unknown): DeployManifest { seen.add(id); return { id, repo, commitId }; }); - return { schemaVersion: 1, services }; + return { schemaVersion: 1, environment, services }; +} + +function readEnvironmentDeployManifest(environment: DeployEnvironment): { manifest: DeployManifest; source: DeployEnvironmentManifestSource } { + const target = deployEnvironmentTargets[environment]; + const remoteRef = `refs/remotes/${target.remote}/${target.branch}`; + const fetch = runCommand(["git", "fetch", "--no-tags", target.remote, `+refs/heads/${target.branch}:${remoteRef}`], repoRoot, { timeoutMs: 60_000 }); + if (fetch.exitCode !== 0) { + throw new Error(`failed to fetch ${target.gitRef} for deploy --env ${environment}: ${commandFailure(fetch)}`); + } + const commit = parseFullCommit(runGitOrThrow(["rev-parse", "--verify", `${target.gitRef}^{commit}`], repoRoot, `failed to resolve ${target.gitRef}`).stdout); + if (commit.length !== 40) throw new Error(`failed to resolve a full commit for ${target.gitRef}`); + const blob = runGitOrThrow(["rev-parse", `${target.gitRef}:deploy.json`], repoRoot, `failed to resolve ${target.gitRef}:deploy.json`).stdout.trim().toLowerCase(); + if (!/^[0-9a-f]{40}$/iu.test(blob)) throw new Error(`failed to resolve a deploy.json blob for ${target.gitRef}`); + const raw = runGitOrThrow(["show", `${target.gitRef}:deploy.json`], repoRoot, `failed to read ${target.gitRef}:deploy.json`).stdout; + return { + manifest: parseDeployManifest(JSON.parse(raw) as unknown, `${target.gitRef}:deploy.json`, environment), + source: { + source: "git-ref", + path: "deploy.json", + ref: target.gitRef, + remote: target.remote, + branch: target.branch, + commit, + blob, + fetchedAt: nowIso(), + }, + }; } async function readDeployManifest(file: string): Promise { @@ -392,9 +523,9 @@ async function readDeployManifest(file: string): Promise { const moduleRecord = asRecord(moduleValue); const parsed = moduleRecord?.default ?? moduleRecord?.manifest; if (parsed === undefined) throw new Error(`deploy JS manifest must export default or named manifest: ${path}`); - return parseDeployManifest(parsed); + return parseDeployManifest(parsed, path, null); } - return parseDeployManifest(JSON.parse(readFileSync(path, "utf8")) as unknown); + return parseDeployManifest(JSON.parse(readFileSync(path, "utf8")) as unknown, path, null); } function frontendCoreDeployService(config: UniDeskConfig): UniDeskMicroserviceConfig { @@ -470,6 +601,31 @@ function unsupportedReason(service: UniDeskMicroserviceConfig): string | null { return null; } +function databaseFingerprint(target: DeployEnvironmentTarget): string { + const material = [ + target.environment, + target.namespace, + target.database.serviceId, + target.database.host, + target.database.port.toString(), + target.database.name, + target.provider.providerId, + ].join("|"); + return `sha256:${createHash("sha256").update(material).digest("hex").slice(0, 16)}`; +} + +function environmentTargetSummary(target: DeployEnvironmentTarget): Record { + return { + namespace: target.namespace, + runtimeScope: target.runtimeScope, + database: { + ...target.database, + fingerprint: databaseFingerprint(target), + }, + provider: target.provider, + }; +} + function targetIsMain(service: UniDeskMicroserviceConfig): boolean { return service.providerId === "main-server"; } @@ -1756,6 +1912,59 @@ async function checkOrPlan(config: UniDeskConfig, manifest: DeployManifest, opti }; } +function environmentDryRunPlan( + manifest: DeployManifest, + source: DeployEnvironmentManifestSource, + options: DeployOptions, + action: "check" | "plan", +): Record { + const environment = options.environment; + if (environment === null) throw new Error("environment dry-run requires --env"); + const target = deployEnvironmentTargets[environment]; + const services = options.serviceId === null + ? manifest.services + : manifest.services.filter((service) => service.id === options.serviceId); + if (options.serviceId !== null && services.length === 0) { + throw new Error(`deploy manifest ${source.ref}:deploy.json does not contain service: ${options.serviceId}`); + } + const fingerprint = databaseFingerprint(target); + return { + ok: true, + action, + mode: "environment-ref-dry-run", + dryRun: true, + mutatesRuntime: false, + environment, + gitRef: source.ref, + manifest: { + source: source.source, + path: source.path, + ref: source.ref, + remote: source.remote, + branch: source.branch, + commit: source.commit, + blob: source.blob, + environment: manifest.environment, + fetchedAt: source.fetchedAt, + }, + target: environmentTargetSummary(target), + localCompatibility: { + localFileMode: false, + deployJsonFromWorktree: false, + dirtyWorktreeUsed: false, + }, + services: services.map((service) => ({ + id: service.id, + repo: service.repo, + commitId: service.commitId, + environment, + targetNamespace: target.namespace, + providerId: target.provider.providerId, + databaseFingerprint: fingerprint, + })), + }; +} + async function runApplyNow(config: UniDeskConfig, manifest: DeployManifest, options: DeployOptions): Promise> { const selected = selectServices(config, manifest, options.serviceId); const startedAt = nowIso(); @@ -1792,12 +2001,18 @@ function applyJob(config: UniDeskConfig, args: string[], options: DeployOptions) }; } -export async function runDeployCommand(config: UniDeskConfig, args: string[]): Promise { +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"); const action = actionRaw as DeployAction; const options = parseOptions(args.slice(1)); + if (options.environment !== null) { + if (action === "apply") throw new Error("deploy apply --env is not enabled in Phase 0; use deploy plan --env dev|prod for dry-run only"); + const { manifest, source } = readEnvironmentDeployManifest(options.environment); + return environmentDryRunPlan(manifest, source, options, action); + } + if (config === null) throw new Error("deploy local manifest mode requires config.json"); const manifest = resolveManifestCommits(await readDeployManifest(options.file), options.serviceId); if (action === "check" || action === "plan") return await checkOrPlan(config, manifest, options, action); if (!options.runNow) return applyJob(config, args, options);