132 lines
7.6 KiB
TypeScript
132 lines
7.6 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. manifest module for scripts/src/deploy.ts.
|
|
|
|
// Moved mechanically from scripts/src/deploy.ts:536-633 for #903.
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { runCommand } from "../command";
|
|
import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "../config";
|
|
import { ensureGithubSshIdentityForProvider } from "../deploy-ssh-identity";
|
|
import { baiduNetdiskRuntimeSecretRequirements, runtimeSecretContractFromEnvText, type RuntimeSecretContract, runArtifactRegistryCommand } from "../artifact-registry";
|
|
import { startJob } from "../jobs";
|
|
import { coreInternalFetch } from "../microservices";
|
|
import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "../code-queue-source-guard";
|
|
import { k3sGuardShellLines, defaultNativeKubeconfig } from "../k3s-target-guard";
|
|
import { composeRuntimeEnvValue } from "../runtime-env";
|
|
import {
|
|
compareDeployJsonExecutorMirrors,
|
|
deployJsonCommitImage,
|
|
deployJsonDriftResult,
|
|
deployJsonSourceOfTruth,
|
|
encodeDeployJsonServiceContract,
|
|
hasDeployJsonExecutorContract,
|
|
k3sManifestExecutorMirror,
|
|
parseDeployJsonServiceContract,
|
|
type DeployJsonExecutorMirror,
|
|
type DeployJsonServiceContract,
|
|
} from "../deploy-json-contract";
|
|
|
|
import type { DeployEnvironment, DeployEnvironmentManifestSource, DeployManifest, DeployManifestService } from "./types";
|
|
import { asRecord, commandFailure, isDeployEnvironment, nowIso, parseFullCommit, runGitOrThrow } from "./options";
|
|
import { deployEnvironmentTargets } from "./types";
|
|
|
|
export function parseDeployManifestService(item: unknown, index: number, seen: Set<string>, path: string): DeployManifestService {
|
|
const service = parseDeployJsonServiceContract(item, `deploy manifest ${path}[${index}]`);
|
|
const id = service.id;
|
|
const repo = service.repo;
|
|
const commitId = service.commitId;
|
|
if (id.length === 0) throw new Error(`deploy manifest ${path}[${index}].id must be a non-empty string`);
|
|
if (repo.length === 0) throw new Error(`deploy manifest ${path}[${index}].repo must be a non-empty string`);
|
|
if (!/^[0-9a-f]{7,40}$/iu.test(commitId)) throw new Error(`deploy manifest ${path}[${index}].commitId must be a 7-40 char git SHA`);
|
|
if (seen.has(id)) throw new Error(`duplicate deploy manifest service id: ${id}`);
|
|
seen.add(id);
|
|
return service;
|
|
}
|
|
|
|
export function parseDeployManifestServices(value: unknown, path: string): DeployManifestService[] {
|
|
if (!Array.isArray(value)) throw new Error(`deploy manifest ${path} must be an array`);
|
|
const seen = new Set<string>();
|
|
return value.map((item, index) => parseDeployManifestService(item, index, seen, path));
|
|
}
|
|
|
|
export function parseDeployManifest(parsed: unknown, source: string, expectedEnvironment: DeployEnvironment | null): DeployManifest {
|
|
const record = asRecord(parsed);
|
|
if (record === null) throw new Error(`deploy manifest ${source} must be an object`);
|
|
if (record.schemaVersion !== 1 && record.schemaVersion !== 2) throw new Error("deploy manifest schemaVersion must be 1 or 2");
|
|
if (record.schemaVersion === 2) {
|
|
const environments = asRecord(record.environments);
|
|
if (environments === null) throw new Error("deploy manifest schemaVersion=2 requires environments");
|
|
if (expectedEnvironment === null) {
|
|
const prod = asRecord(environments.prod);
|
|
if (prod === null) throw new Error("deploy manifest schemaVersion=2 requires environments.prod for local compatibility mode");
|
|
return { schemaVersion: 2, environment: "prod", services: parseDeployManifestServices(prod.services, "environments.prod.services") };
|
|
}
|
|
const envRecord = asRecord(environments[expectedEnvironment]);
|
|
if (envRecord === null) throw new Error(`deploy manifest ${source} does not contain environments.${expectedEnvironment}`);
|
|
return { schemaVersion: 2, environment: expectedEnvironment, services: parseDeployManifestServices(envRecord.services, `environments.${expectedEnvironment}.services`) };
|
|
}
|
|
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}`);
|
|
}
|
|
}
|
|
return { schemaVersion: 1, environment, services: parseDeployManifestServices(record.services, "services") };
|
|
}
|
|
|
|
export 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;
|
|
const manifest = parseDeployManifest(JSON.parse(raw) as unknown, `${target.gitRef}:deploy.json#environments.${environment}`, environment);
|
|
return {
|
|
manifest,
|
|
source: {
|
|
source: "git-ref",
|
|
path: "deploy.json",
|
|
ref: target.gitRef,
|
|
remote: target.remote,
|
|
branch: target.branch,
|
|
commit,
|
|
blob,
|
|
fetchedAt: nowIso(),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function readProdDeployManifestSnapshot(): DeployManifest {
|
|
const raw = runGitOrThrow(["show", `${deployEnvironmentTargets.prod.gitRef}:deploy.json`], repoRoot, `failed to read ${deployEnvironmentTargets.prod.gitRef}:deploy.json`).stdout;
|
|
return parseDeployManifest(JSON.parse(raw) as unknown, `${deployEnvironmentTargets.prod.gitRef}:deploy.json#environments.prod`, "prod");
|
|
}
|
|
|
|
export async function readDeployManifest(file: string): Promise<DeployManifest> {
|
|
const path = resolve(repoRoot, file);
|
|
if (!existsSync(path)) throw new Error(`deploy manifest not found: ${path}`);
|
|
if (/\.(?:mjs|js|cjs)$/iu.test(path)) {
|
|
const moduleValue = await import(`${pathToFileURL(path).href}?unideskDeployManifest=${Date.now()}`);
|
|
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, path, null);
|
|
}
|
|
return parseDeployManifest(JSON.parse(readFileSync(path, "utf8")) as unknown, path, null);
|
|
}
|