254 lines
9.9 KiB
TypeScript
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");
|
|
}
|