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> { 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 { 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 { 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> { 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> { 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 { 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 { 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 { const digest = createHash("sha256"); await new Promise((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"); }