Files
pikasTech-unidesk/scripts/src/selfmedia.ts
T
2026-07-13 07:56:37 +02:00

254 lines
9.9 KiB
TypeScript

import { createHash } from "node:crypto";
import { createReadStream, existsSync, lstatSync, readdirSync } from "node:fs";
import { relative, resolve, sep } from "node:path";
import { resolveSelfMediaCutoverTarget, resolveSelfMediaDeliveryTarget, selfMediaConfigRef, type SelfMediaCutoverTarget } from "./selfmedia-config";
type CutoverAction = "plan" | "prepare" | "status" | "rollback";
interface CutoverOptions {
readonly targetId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
}
interface SourceDataSummary {
readonly available: boolean;
readonly files: number;
readonly bytes: number;
readonly digest: string | null;
readonly digestBasis: "relative-path,size,sha256-content";
readonly missingPaths: readonly string[];
readonly excludedPaths: readonly string[];
readonly valuesPrinted: false;
}
export async function runSelfMediaCommand(args: string[]): Promise<Record<string, unknown>> {
if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return help();
if (args[0] !== "cutover") return { ok: false, error: "unsupported-selfmedia-command", args, help: help() };
const action = args[1];
if (action !== "plan" && action !== "prepare" && action !== "status" && action !== "rollback") {
return { ok: false, error: "unsupported-selfmedia-cutover-command", args, help: help() };
}
const options = parseOptions(args.slice(2));
const target = resolveSelfMediaCutoverTarget(options.targetId);
const delivery = resolveSelfMediaDeliveryTarget(target.id);
if (action === "plan") return cutoverPlan(target, delivery.route);
if (action === "status") return await cutoverStatus(target, delivery.route);
if (action === "prepare") return await cutoverPrepare(target, delivery.route, options);
return cutoverRollback(target, delivery.route, options);
}
function help(): Record<string, unknown> {
return {
ok: true,
command: "selfmedia cutover plan|prepare|status|rollback",
configTruth: selfMediaConfigRef,
usage: [
"bun scripts/cli.ts selfmedia cutover plan --target NC01",
"bun scripts/cli.ts selfmedia cutover prepare --target NC01 --dry-run",
"bun scripts/cli.ts selfmedia cutover status --target NC01",
"bun scripts/cli.ts selfmedia cutover rollback --target NC01 --dry-run",
],
boundary: "当前入口只做配置校验、源数据摘要和切换计划;不停止服务、不复制数据、不访问 k8s、不切换端口。",
valuesPrinted: false,
};
}
function parseOptions(args: string[]): CutoverOptions {
let targetId: string | null = null;
let dryRun = false;
let confirm = false;
for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (token === "--target") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
targetId = value;
index += 1;
continue;
}
if (token === "--dry-run") {
dryRun = true;
continue;
}
if (token === "--confirm") {
confirm = true;
continue;
}
if (token === "--json") continue;
throw new Error(`unsupported selfmedia cutover option ${token}`);
}
if (dryRun && confirm) throw new Error("--dry-run and --confirm are mutually exclusive");
return { targetId, dryRun, confirm };
}
function cutoverPlan(target: SelfMediaCutoverTarget, deliveryRoute: string): Record<string, unknown> {
return {
ok: true,
action: "selfmedia-cutover-plan",
target: target.id,
mode: target.mode,
mutation: false,
configTruth: `${selfMediaConfigRef}#cutover.targets.${target.id}`,
routeAligned: target.destination.route === deliveryRoute,
source: {
workspace: target.source.workspace,
healthUrl: target.source.healthUrl,
dataPaths: target.source.dataPaths,
excludes: target.source.excludes,
},
destination: target.destination,
phases: target.prepare.phases,
rollback: {
phases: target.rollback.phases,
dataPolicy: target.rollback.dataPolicy,
},
safety: {
requiresFinalConfirmation: target.prepare.requiresFinalConfirmation,
oldLifecycleStateExcluded: target.source.excludes.includes(".state/server.json"),
runtimeExecutorAvailable: false,
},
next: `bun scripts/cli.ts selfmedia cutover prepare --target ${target.id} --dry-run`,
valuesPrinted: false,
};
}
async function cutoverStatus(target: SelfMediaCutoverTarget, deliveryRoute: string): Promise<Record<string, unknown>> {
const sourceData = await summarizeSourceData(target);
return {
ok: sourceData.available,
action: "selfmedia-cutover-status",
target: target.id,
mutation: false,
configReady: target.destination.route === deliveryRoute && target.source.excludes.includes(".state/server.json"),
sourceWorkspacePresent: existsSync(target.source.workspace),
sourceData,
runtimeObservation: {
status: "not-requested",
reason: "read-only CLI does not contact the host service, k8s, Argo, or public endpoint",
},
next: sourceData.available
? `bun scripts/cli.ts selfmedia cutover prepare --target ${target.id} --dry-run`
: "Restore or select the YAML-declared source workspace before preparing cutover.",
valuesPrinted: false,
};
}
async function cutoverPrepare(target: SelfMediaCutoverTarget, deliveryRoute: string, options: CutoverOptions): Promise<Record<string, unknown>> {
const sourceData = await summarizeSourceData(target);
const confirmedButUnsupported = options.confirm;
return {
ok: sourceData.available && !confirmedButUnsupported,
action: "selfmedia-cutover-prepare",
target: target.id,
mode: options.dryRun || !options.confirm ? "dry-run" : "blocked-confirmed",
mutation: false,
configReady: target.destination.route === deliveryRoute && target.source.excludes.includes(".state/server.json"),
sourceData,
proposedPhases: target.prepare.phases,
portHandoff: {
port: 4317,
oldServiceStopCommand: target.source.stopCommand,
destinationHealthUrl: target.destination.healthUrl,
executed: false,
},
blocked: confirmedButUnsupported ? {
code: "selfmedia-cutover-runtime-executor-not-implemented",
reason: "本次交付只建立 YAML 所有权和只读/干运行入口,禁止从此命令执行真实切换。",
} : null,
next: confirmedButUnsupported
? "由后续受控执行器实现 PVC seed、最终增量、端口释放、Argo 同步和原入口验收后再开放 --confirm。"
: `bun scripts/cli.ts selfmedia cutover status --target ${target.id}`,
valuesPrinted: false,
};
}
function cutoverRollback(target: SelfMediaCutoverTarget, deliveryRoute: string, options: CutoverOptions): Record<string, unknown> {
const confirmedButUnsupported = options.confirm;
return {
ok: !confirmedButUnsupported,
action: "selfmedia-cutover-rollback",
target: target.id,
mode: options.dryRun || !options.confirm ? "dry-run" : "blocked-confirmed",
mutation: false,
configReady: target.destination.route === deliveryRoute,
proposedPhases: target.rollback.phases,
dataPolicy: target.rollback.dataPolicy,
blocked: confirmedButUnsupported ? {
code: "selfmedia-cutover-runtime-executor-not-implemented",
reason: "回滚命令当前只输出计划,不操作 Argo、Deployment、宿主进程或数据。",
} : null,
valuesPrinted: false,
};
}
async function summarizeSourceData(target: SelfMediaCutoverTarget): Promise<SourceDataSummary> {
const root = resolve(target.source.workspace);
const missingPaths: string[] = [];
const files: { absolute: string; relative: string; size: number }[] = [];
for (const declared of target.source.dataPaths) {
const absolute = resolve(root, declared);
if (!inside(root, absolute) || !existsSync(absolute)) {
missingPaths.push(declared);
continue;
}
collectFiles(root, absolute, target.source.excludes, files);
}
files.sort((left, right) => left.relative.localeCompare(right.relative));
const digest = createHash("sha256");
let bytes = 0;
for (const file of files) {
const contentDigest = await hashFile(file.absolute);
bytes += file.size;
digest.update(file.relative);
digest.update("\0");
digest.update(String(file.size));
digest.update("\0");
digest.update(contentDigest);
digest.update("\n");
}
return {
available: missingPaths.length === 0,
files: files.length,
bytes,
digest: missingPaths.length === 0 ? `sha256:${digest.digest("hex")}` : null,
digestBasis: "relative-path,size,sha256-content",
missingPaths,
excludedPaths: target.source.excludes,
valuesPrinted: false,
};
}
function collectFiles(root: string, current: string, excludes: readonly string[], output: { absolute: string; relative: string; size: number }[]): void {
const currentRelative = relative(root, current).split(sep).join("/");
if (isExcluded(currentRelative, excludes)) return;
const stat = lstatSync(current);
if (stat.isSymbolicLink()) return;
if (stat.isFile()) {
output.push({ absolute: current, relative: currentRelative, size: stat.size });
return;
}
if (!stat.isDirectory()) return;
for (const entry of readdirSync(current).sort()) collectFiles(root, resolve(current, entry), excludes, output);
}
function isExcluded(path: string, excludes: readonly string[]): boolean {
return excludes.some((excluded) => path === excluded || path.startsWith(`${excluded}/`));
}
function inside(root: string, candidate: string): boolean {
return candidate === root || candidate.startsWith(`${root}${sep}`);
}
async function hashFile(path: string): Promise<string> {
const digest = createHash("sha256");
await new Promise<void>((resolvePromise, reject) => {
const stream = createReadStream(path);
stream.on("data", (chunk) => digest.update(chunk));
stream.on("error", reject);
stream.on("end", resolvePromise);
});
return digest.digest("hex");
}