Add deploy environment dry-run guardrails
This commit is contained in:
+9
-1
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user