Add deploy environment dry-run guardrails

This commit is contained in:
Codex
2026-05-17 16:50:19 +00:00
parent 3a2f86df9e
commit f0dc754103
5 changed files with 247 additions and 11 deletions
+1
View File
@@ -1,5 +1,6 @@
{
"schemaVersion": 1,
"environment": "prod",
"services": [
{
"id": "findjob",
+1 -1
View File
@@ -21,7 +21,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
- `ssh <providerId> 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 <commitId>` 是 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`,由它写入主 PostgreSQLD601 scheduler 只轮询并执行已入库任务。
- `codex task <taskId>` 通过 Code Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、执行 Provider、工作目录、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。
+12
View File
@@ -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 <name>` 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 <id>]` prints the same live state plus the intended action: `noop`, `deploy` or `unsupported`.
`bun scripts/cli.ts deploy plan --env dev [--service <id>]` 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 <id>] [--dry-run] [--force]` starts an asynchronous job. Use `bun scripts/cli.ts job status <jobId> --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.
+9 -1
View File
@@ -53,7 +53,7 @@ function help(): unknown {
{ command: "decision upload <markdown-file> [--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 <id>", 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 <id> supports --wait-ms N." },
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
{ command: "codex deploy <commitId> [--provider-id D601] [--timeout-ms N]", description: "Compatibility wrapper for deploy apply --service code-queue with a temporary repo+commit manifest." },
@@ -147,6 +147,14 @@ async function main(): Promise<void> {
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") {
+224 -9
View File
@@ -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<DeployEnvironment, DeployEnvironmentTarget> = {
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<string, unkn
ok: true,
command,
usage: {
check: "bun scripts/cli.ts deploy check [--file deploy.json] [--service id]",
plan: "bun scripts/cli.ts deploy plan [--file deploy.json] [--service id]",
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] [--service id] [--dry-run] [--force] [--timeout-ms N] [--run-now]",
},
actions: {
check: "Validate desired repo+commit state against live service health and commit markers.",
plan: "Show desired/live drift without requiring live health to be healthy.",
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.",
},
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." },
{ name: "--env <dev|prod>", 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 <id>", 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<string>();
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<DeployManifest> {
@@ -392,9 +523,9 @@ async function readDeployManifest(file: string): Promise<DeployManifest> {
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<string, unknown> {
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<string, unknown> {
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<Record<string, unknown>> {
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<unknown> {
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");
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);