Files
pikasTech-unidesk/scripts/src/deploy/manifest.ts
T
2026-07-09 10:46:55 +02:00

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