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
+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);