feat: 接入 selfmedia 受控交付
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user