|
|
|
@@ -0,0 +1,478 @@
|
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
|
|
import { dirname, isAbsolute } from "node:path";
|
|
|
|
|
import type { UniDeskConfig } from "./config";
|
|
|
|
|
import { rootPath } from "./config";
|
|
|
|
|
import { startJob } from "./jobs";
|
|
|
|
|
import type { RenderedCliResult } from "./output";
|
|
|
|
|
import { capture, compactCapture, fingerprintValues, parseEnvFile, readYamlRecord, redactText, resolveRepoPath, shQuote } from "./platform-infra-ops-library";
|
|
|
|
|
|
|
|
|
|
const configPath = rootPath("config", "platform-infra", "selfmedia-voice.yaml");
|
|
|
|
|
const configLabel = "config/platform-infra/selfmedia-voice.yaml";
|
|
|
|
|
|
|
|
|
|
interface VoiceTarget {
|
|
|
|
|
id: string;
|
|
|
|
|
role: "gpu-service" | "frps";
|
|
|
|
|
route: string;
|
|
|
|
|
workDir: string;
|
|
|
|
|
composeProject: string;
|
|
|
|
|
cacheDir?: string;
|
|
|
|
|
dataDir?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface VoiceConfig {
|
|
|
|
|
version: number;
|
|
|
|
|
kind: "platform-infra-selfmedia-voice";
|
|
|
|
|
metadata: { id: string; owner: string; relatedIssues: number[]; mdtodo: string };
|
|
|
|
|
rollout: { prerequisite: string; approved: boolean };
|
|
|
|
|
artifacts: Record<"root" | "containerfile" | "api" | "modelInit" | "d518Compose" | "nc01Compose" | "frpcTemplate" | "frpsTemplate", string>;
|
|
|
|
|
image: {
|
|
|
|
|
name: string;
|
|
|
|
|
base: { repository: string; tag: string; digest: string; imageId: string };
|
|
|
|
|
source: { repository: string; ref: string; submodules: "recursive" };
|
|
|
|
|
modelSource: { provider: "modelscope-git-lfs"; id: string; repository: string; branch: string; ref: string; requireBranchHeadMatch: boolean };
|
|
|
|
|
referenceVoice: { id: string; path: string; sha256: string; promptText: string };
|
|
|
|
|
};
|
|
|
|
|
dependencies: {
|
|
|
|
|
pip: { version: string; indexUrl: string };
|
|
|
|
|
setuptools: { installSpec: string };
|
|
|
|
|
torch: { versionIdentity: string; libraryPath: string };
|
|
|
|
|
torchaudio: { package: string; installSpec: string; versionIdentity: string; abiPolicy: "torch-major-minor-match" };
|
|
|
|
|
openaiWhisper: { installSpec: string; noBuildIsolation: boolean };
|
|
|
|
|
onnxRuntimeGpu: { installSpec: string; indexUrl: string; independentLayer: boolean };
|
|
|
|
|
};
|
|
|
|
|
frp: {
|
|
|
|
|
images: { frps: string; frpc: string };
|
|
|
|
|
secret: { sourceRef: string; sourceKey: string };
|
|
|
|
|
server: { targetId: string; address: string; controlPort: number; workDir: string; composeProject: string };
|
|
|
|
|
tunnel: { targetId: string; proxyName: string; remotePort: number; localAddress: string; localPort: number };
|
|
|
|
|
};
|
|
|
|
|
targets: VoiceTarget[];
|
|
|
|
|
runtime: {
|
|
|
|
|
serviceName: string;
|
|
|
|
|
frpcServiceName: string;
|
|
|
|
|
frpsServiceName: string;
|
|
|
|
|
bindAddress: string;
|
|
|
|
|
port: number;
|
|
|
|
|
publicBaseUrl: string;
|
|
|
|
|
inference: {
|
|
|
|
|
device: string;
|
|
|
|
|
fp16: boolean;
|
|
|
|
|
loadTrt: boolean;
|
|
|
|
|
loadVllm: boolean;
|
|
|
|
|
replicas: number;
|
|
|
|
|
workers: number;
|
|
|
|
|
concurrency: "serial";
|
|
|
|
|
referenceVoice: string;
|
|
|
|
|
speed: { min: number; max: number };
|
|
|
|
|
cudaAllocator: string;
|
|
|
|
|
cudaModuleLoading: string;
|
|
|
|
|
};
|
|
|
|
|
operations: { applyTimeoutSeconds: number };
|
|
|
|
|
smoke: { healthPath: string; speechPath: string; text: string; voice: string; timeoutSeconds: number };
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CommonOptions { full: boolean; raw: boolean }
|
|
|
|
|
interface ApplyOptions extends CommonOptions { confirm: boolean; wait: boolean }
|
|
|
|
|
interface LogsOptions extends CommonOptions { component: "all" | "voice" | "frpc" | "frps"; lines: number }
|
|
|
|
|
|
|
|
|
|
export async function runSelfMediaVoiceCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
|
|
|
const [action, subaction] = args;
|
|
|
|
|
if (action === undefined || action === "plan") return renderResult("plan", plan(parseCommon(args.slice(action === undefined ? 0 : 1))));
|
|
|
|
|
if (action === "secret" && subaction === "init") return renderResult("secret init", secretInit(parseApply(args.slice(2))));
|
|
|
|
|
if (action === "apply") return renderResult("apply", await apply(config, parseApply(args.slice(1))));
|
|
|
|
|
if (action === "status") return renderResult("status", await status(config, parseCommon(args.slice(1))));
|
|
|
|
|
if (action === "logs") return renderResult("logs", await logs(config, parseLogs(args.slice(1))));
|
|
|
|
|
if (action === "validate") return renderResult("validate", await validate(config, parseCommon(args.slice(1))));
|
|
|
|
|
if (action === "help" || action === "--help" || action === "-h") return help();
|
|
|
|
|
return { ok: false, action: "platform-infra-selfmedia-voice", error: `unsupported command: ${args.join(" ")}`, next: help().usage };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function help(): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
action: "platform-infra-selfmedia-voice-help",
|
|
|
|
|
usage: [
|
|
|
|
|
"bun scripts/cli.ts platform-infra selfmedia-voice plan [--json|--full|--raw]",
|
|
|
|
|
"bun scripts/cli.ts platform-infra selfmedia-voice secret init --confirm",
|
|
|
|
|
"bun scripts/cli.ts platform-infra selfmedia-voice apply --confirm",
|
|
|
|
|
"bun scripts/cli.ts platform-infra selfmedia-voice status [--json|--full|--raw]",
|
|
|
|
|
"bun scripts/cli.ts platform-infra selfmedia-voice logs [--component voice|frpc|frps|all] [--lines 120]",
|
|
|
|
|
"bun scripts/cli.ts platform-infra selfmedia-voice validate [--json|--full|--raw]",
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseCommon(args: string[]): CommonOptions {
|
|
|
|
|
let full = false;
|
|
|
|
|
let raw = false;
|
|
|
|
|
for (const arg of args) {
|
|
|
|
|
if (arg === "--full") full = true;
|
|
|
|
|
else if (arg === "--raw" || arg === "--json") raw = true;
|
|
|
|
|
else throw new Error(`unsupported option: ${arg}`);
|
|
|
|
|
}
|
|
|
|
|
return { full, raw };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseApply(args: string[]): ApplyOptions {
|
|
|
|
|
let confirm = false;
|
|
|
|
|
let wait = false;
|
|
|
|
|
const common: string[] = [];
|
|
|
|
|
for (const arg of args) {
|
|
|
|
|
if (arg === "--confirm") confirm = true;
|
|
|
|
|
else if (arg === "--wait") wait = true;
|
|
|
|
|
else common.push(arg);
|
|
|
|
|
}
|
|
|
|
|
return { ...parseCommon(common), confirm, wait };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseLogs(args: string[]): LogsOptions {
|
|
|
|
|
let component: LogsOptions["component"] = "all";
|
|
|
|
|
let lines = 120;
|
|
|
|
|
const common: string[] = [];
|
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
|
|
|
const arg = args[index];
|
|
|
|
|
if (arg === "--component") {
|
|
|
|
|
const value = args[index + 1];
|
|
|
|
|
if (value !== "voice" && value !== "frpc" && value !== "frps" && value !== "all") throw new Error("--component must be voice, frpc, frps, or all");
|
|
|
|
|
component = value;
|
|
|
|
|
index += 1;
|
|
|
|
|
} else if (arg === "--lines") {
|
|
|
|
|
lines = Number(args[index + 1]);
|
|
|
|
|
if (!Number.isInteger(lines) || lines < 1 || lines > 500) throw new Error("--lines must be an integer in 1..500");
|
|
|
|
|
index += 1;
|
|
|
|
|
} else common.push(arg);
|
|
|
|
|
}
|
|
|
|
|
return { ...parseCommon(common), component, lines };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readConfig(): VoiceConfig {
|
|
|
|
|
const config = readYamlRecord<VoiceConfig>(configPath, "platform-infra-selfmedia-voice");
|
|
|
|
|
requireInteger(config.version, "version");
|
|
|
|
|
if (config.metadata.mdtodo !== "R9.4") throw new Error(`${configLabel}.metadata.mdtodo must be R9.4`);
|
|
|
|
|
for (const path of Object.values(config.artifacts)) resolveRepoPath(requireString(path, "artifacts path"));
|
|
|
|
|
if (typeof config.rollout.approved !== "boolean") throw new Error(`${configLabel}.rollout.approved must be a boolean`);
|
|
|
|
|
requireString(config.rollout.prerequisite, "rollout.prerequisite");
|
|
|
|
|
requireString(config.image.base.repository, "image.base.repository");
|
|
|
|
|
requireString(config.image.base.tag, "image.base.tag");
|
|
|
|
|
requireSha256(config.image.base.digest, "image.base.digest");
|
|
|
|
|
requireSha256(config.image.base.imageId, "image.base.imageId");
|
|
|
|
|
requireCommit(config.image.source.ref, "image.source.ref");
|
|
|
|
|
if (config.image.source.submodules !== "recursive") throw new Error(`${configLabel}.image.source.submodules must be recursive`);
|
|
|
|
|
if (config.image.modelSource.provider !== "modelscope-git-lfs") throw new Error(`${configLabel}.image.modelSource.provider must be modelscope-git-lfs`);
|
|
|
|
|
requireCommit(config.image.modelSource.ref, "image.modelSource.ref");
|
|
|
|
|
requireString(config.image.modelSource.branch, "image.modelSource.branch");
|
|
|
|
|
if (config.image.modelSource.requireBranchHeadMatch !== true) throw new Error(`${configLabel}.image.modelSource.requireBranchHeadMatch must be true`);
|
|
|
|
|
requireSha256(config.image.referenceVoice.sha256, "image.referenceVoice.sha256", false);
|
|
|
|
|
requireString(config.dependencies.pip.version, "dependencies.pip.version");
|
|
|
|
|
if (new URL(config.dependencies.pip.indexUrl).protocol !== "https:") throw new Error(`${configLabel}.dependencies.pip.indexUrl must use https`);
|
|
|
|
|
requireString(config.dependencies.setuptools.installSpec, "dependencies.setuptools.installSpec");
|
|
|
|
|
requireString(config.dependencies.torch.versionIdentity, "dependencies.torch.versionIdentity");
|
|
|
|
|
requireAbsolute(config.dependencies.torch.libraryPath, "dependencies.torch.libraryPath");
|
|
|
|
|
requireString(config.dependencies.torchaudio.package, "dependencies.torchaudio.package");
|
|
|
|
|
requireString(config.dependencies.torchaudio.installSpec, "dependencies.torchaudio.installSpec");
|
|
|
|
|
requireString(config.dependencies.torchaudio.versionIdentity, "dependencies.torchaudio.versionIdentity");
|
|
|
|
|
if (config.dependencies.torchaudio.abiPolicy !== "torch-major-minor-match") throw new Error(`${configLabel}.dependencies.torchaudio.abiPolicy must be torch-major-minor-match`);
|
|
|
|
|
requireString(config.dependencies.openaiWhisper.installSpec, "dependencies.openaiWhisper.installSpec");
|
|
|
|
|
if (config.dependencies.openaiWhisper.noBuildIsolation !== true) throw new Error(`${configLabel}.dependencies.openaiWhisper.noBuildIsolation must be true`);
|
|
|
|
|
requireString(config.dependencies.onnxRuntimeGpu.installSpec, "dependencies.onnxRuntimeGpu.installSpec");
|
|
|
|
|
if (new URL(config.dependencies.onnxRuntimeGpu.indexUrl).protocol !== "https:") throw new Error(`${configLabel}.dependencies.onnxRuntimeGpu.indexUrl must use https`);
|
|
|
|
|
if (config.dependencies.onnxRuntimeGpu.independentLayer !== true) throw new Error(`${configLabel}.dependencies.onnxRuntimeGpu.independentLayer must be true`);
|
|
|
|
|
requireAbsolute(config.frp.secret.sourceRef, "frp.secret.sourceRef");
|
|
|
|
|
requirePort(config.frp.server.controlPort, "frp.server.controlPort");
|
|
|
|
|
requirePort(config.frp.tunnel.remotePort, "frp.tunnel.remotePort");
|
|
|
|
|
requirePort(config.frp.tunnel.localPort, "frp.tunnel.localPort");
|
|
|
|
|
requirePort(config.runtime.port, "runtime.port");
|
|
|
|
|
requireInteger(config.runtime.operations.applyTimeoutSeconds, "runtime.operations.applyTimeoutSeconds");
|
|
|
|
|
if (config.runtime.operations.applyTimeoutSeconds < 60 || config.runtime.operations.applyTimeoutSeconds > 3600) throw new Error(`${configLabel}.runtime.operations.applyTimeoutSeconds must be in 60..3600`);
|
|
|
|
|
if (config.runtime.port !== config.frp.tunnel.localPort) throw new Error(`${configLabel}.runtime.port must match frp.tunnel.localPort`);
|
|
|
|
|
if (new URL(config.runtime.publicBaseUrl).protocol !== "http:") throw new Error(`${configLabel}.runtime.publicBaseUrl must use http`);
|
|
|
|
|
if (config.targets.length !== 2) throw new Error(`${configLabel}.targets must contain exactly D518 gpu-service and NC01 frps targets`);
|
|
|
|
|
const gpu = targetByRole(config, "gpu-service");
|
|
|
|
|
const frps = targetByRole(config, "frps");
|
|
|
|
|
if (gpu.id !== config.frp.tunnel.targetId || frps.id !== config.frp.server.targetId) throw new Error(`${configLabel} FRP target ids must match target roles`);
|
|
|
|
|
for (const target of config.targets) {
|
|
|
|
|
requireString(target.route, `targets.${target.id}.route`);
|
|
|
|
|
requireAbsolute(target.workDir, `targets.${target.id}.workDir`);
|
|
|
|
|
}
|
|
|
|
|
requireAbsolute(gpu.cacheDir, `targets.${gpu.id}.cacheDir`);
|
|
|
|
|
requireAbsolute(gpu.dataDir, `targets.${gpu.id}.dataDir`);
|
|
|
|
|
requireInteger(config.runtime.inference.replicas, "runtime.inference.replicas");
|
|
|
|
|
requireInteger(config.runtime.inference.workers, "runtime.inference.workers");
|
|
|
|
|
if (config.runtime.inference.concurrency !== "serial") throw new Error(`${configLabel}.runtime.inference.concurrency must be serial`);
|
|
|
|
|
if (config.runtime.inference.referenceVoice !== config.image.referenceVoice.id) throw new Error(`${configLabel}.runtime.inference.referenceVoice must select image.referenceVoice.id`);
|
|
|
|
|
if (typeof config.runtime.inference.speed.min !== "number" || typeof config.runtime.inference.speed.max !== "number" || config.runtime.inference.speed.min >= config.runtime.inference.speed.max) throw new Error(`${configLabel}.runtime.inference.speed must declare an increasing numeric range`);
|
|
|
|
|
return config;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function plan(options: CommonOptions): Record<string, unknown> {
|
|
|
|
|
const config = readConfig();
|
|
|
|
|
const bundle = renderBundle(config);
|
|
|
|
|
const secret = secretSummary(config);
|
|
|
|
|
const policy = policyChecks(config, bundle);
|
|
|
|
|
return {
|
|
|
|
|
ok: policy.every((item) => item.ok),
|
|
|
|
|
action: "platform-infra-selfmedia-voice-plan",
|
|
|
|
|
mutation: false,
|
|
|
|
|
metadata: config.metadata,
|
|
|
|
|
targets: config.targets.map(targetSummary),
|
|
|
|
|
image: config.image,
|
|
|
|
|
dependencies: config.dependencies,
|
|
|
|
|
ports: { frpsControl: config.frp.server.controlPort, remote: config.frp.tunnel.remotePort, d518Loopback: config.runtime.port },
|
|
|
|
|
publicBaseUrl: config.runtime.publicBaseUrl,
|
|
|
|
|
lowMemory: config.runtime.inference,
|
|
|
|
|
secret,
|
|
|
|
|
artifacts: bundle.summary,
|
|
|
|
|
policy,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
...(options.full || options.raw ? { rendered: bundle.disclosure } : {}),
|
|
|
|
|
next: {
|
|
|
|
|
secretInit: "bun scripts/cli.ts platform-infra selfmedia-voice secret init --confirm",
|
|
|
|
|
apply: "bun scripts/cli.ts platform-infra selfmedia-voice apply --confirm",
|
|
|
|
|
status: "bun scripts/cli.ts platform-infra selfmedia-voice status",
|
|
|
|
|
validate: "bun scripts/cli.ts platform-infra selfmedia-voice validate",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function secretInit(options: ApplyOptions): Record<string, unknown> {
|
|
|
|
|
const config = readConfig();
|
|
|
|
|
if (!options.confirm) return { ok: false, action: "platform-infra-selfmedia-voice-secret-init", mutation: false, mode: "missing-confirm", error: "secret init requires --confirm", secret: secretSummary(config) };
|
|
|
|
|
const sourceRef = config.frp.secret.sourceRef;
|
|
|
|
|
if (!existsSync(sourceRef)) {
|
|
|
|
|
mkdirSync(dirname(sourceRef), { recursive: true, mode: 0o700 });
|
|
|
|
|
writeFileSync(sourceRef, `${config.frp.secret.sourceKey}=${randomBytes(32).toString("base64url")}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
|
|
|
}
|
|
|
|
|
chmodSync(sourceRef, 0o600);
|
|
|
|
|
return { ok: true, action: "platform-infra-selfmedia-voice-secret-init", mutation: true, mode: "initialized", secret: secretSummary(config), valuesPrinted: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apply(configRoot: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const config = readConfig();
|
|
|
|
|
if (!options.confirm) return { ok: false, action: "platform-infra-selfmedia-voice-apply", mutation: false, mode: "missing-confirm", error: "apply requires --confirm" };
|
|
|
|
|
const policy = policyChecks(config, renderBundle(config));
|
|
|
|
|
if (!policy.every((item) => item.ok)) return { ok: false, action: "platform-infra-selfmedia-voice-apply", mutation: false, mode: "policy-blocked", policy };
|
|
|
|
|
const secret = readSecret(config);
|
|
|
|
|
if (!options.wait) {
|
|
|
|
|
const job = startJob(
|
|
|
|
|
"platform_infra_selfmedia_voice_apply",
|
|
|
|
|
["bun", "scripts/cli.ts", "platform-infra", "selfmedia-voice", "apply", "--confirm", "--wait"],
|
|
|
|
|
"Build and converge the YAML-declared D518 voice service/FRPC and NC01 FRPS host-Docker runtimes",
|
|
|
|
|
);
|
|
|
|
|
return { ok: true, action: "platform-infra-selfmedia-voice-apply", mutation: true, mode: "async-job", job, statePath: job.stateFile, statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, valuesPrinted: false };
|
|
|
|
|
}
|
|
|
|
|
const bundle = renderBundle(config);
|
|
|
|
|
const frpsTarget = targetByRole(config, "frps");
|
|
|
|
|
const gpuTarget = targetByRole(config, "gpu-service");
|
|
|
|
|
const frps = await convergeTarget(configRoot, config, frpsTarget, runtimeFiles(config, bundle, secret, "frps"));
|
|
|
|
|
if (frps.exitCode !== 0) return { ok: false, action: "platform-infra-selfmedia-voice-apply", mutation: true, mode: "frps-failed", target: targetSummary(frpsTarget), result: compactCapture(frps, { full: true }), valuesPrinted: false };
|
|
|
|
|
const gpu = await convergeTarget(configRoot, config, gpuTarget, runtimeFiles(config, bundle, secret, "gpu-service"));
|
|
|
|
|
return { ok: gpu.exitCode === 0, action: "platform-infra-selfmedia-voice-apply", mutation: true, mode: "completed", targets: { frps: compactCapture(frps), gpu: compactCapture(gpu, { full: gpu.exitCode !== 0 }) }, secret: secret.summary, valuesPrinted: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function status(configRoot: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const config = readConfig();
|
|
|
|
|
const results: Record<string, unknown> = {};
|
|
|
|
|
for (const target of config.targets) {
|
|
|
|
|
const compose = target.role === "gpu-service" ? config.artifacts.d518Compose : config.artifacts.nc01Compose;
|
|
|
|
|
const probe = target.role === "gpu-service"
|
|
|
|
|
? `curl --silent --show-error --max-time 10 http://${config.runtime.bindAddress}:${config.runtime.port}${config.runtime.smoke.healthPath}`
|
|
|
|
|
: `printf '{"frpsControlPort":${config.frp.server.controlPort},"remotePort":${config.frp.tunnel.remotePort}}\\n'`;
|
|
|
|
|
const script = `set +e\ncd ${shQuote(target.workDir)}\ndocker compose --project-name ${shQuote(target.composeProject)} --env-file runtime.env -f compose.yaml ps --format json\nrc=$?\n${probe}\nexit $rc\n`;
|
|
|
|
|
const result = await capture(configRoot, target.route, ["sh"], script);
|
|
|
|
|
results[target.id] = { target: targetSummary(target), composeArtifact: compose, result: compactCapture(result, { full: options.full || options.raw }) };
|
|
|
|
|
}
|
|
|
|
|
return { ok: Object.values(results).every((value) => (value as { result: { exitCode: number } }).result.exitCode === 0), action: "platform-infra-selfmedia-voice-status", mutation: false, image: config.image, dependencies: config.dependencies, ports: { control: config.frp.server.controlPort, remote: config.frp.tunnel.remotePort, local: config.runtime.port }, publicBaseUrl: config.runtime.publicBaseUrl, secret: secretSummary(config), targets: results, valuesPrinted: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function logs(configRoot: UniDeskConfig, options: LogsOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const config = readConfig();
|
|
|
|
|
const selected = config.targets.filter((target) => options.component === "all" || (target.role === "frps" ? options.component === "frps" : options.component === "voice" || options.component === "frpc"));
|
|
|
|
|
const results: Record<string, unknown> = {};
|
|
|
|
|
for (const target of selected) {
|
|
|
|
|
const services = target.role === "frps" ? [config.runtime.frpsServiceName] : options.component === "all" ? [config.runtime.serviceName, config.runtime.frpcServiceName] : [options.component];
|
|
|
|
|
const script = `cd ${shQuote(target.workDir)} && docker compose --project-name ${shQuote(target.composeProject)} --env-file runtime.env -f compose.yaml logs --no-color --tail ${options.lines} ${services.map(shQuote).join(" ")}`;
|
|
|
|
|
const result = await capture(configRoot, target.route, ["sh"], script);
|
|
|
|
|
results[target.id] = { exitCode: result.exitCode, stdout: redactText(result.stdout).slice(-12000), stderr: redactText(result.stderr).slice(-4000) };
|
|
|
|
|
}
|
|
|
|
|
return { ok: Object.values(results).every((value) => (value as { exitCode: number }).exitCode === 0), action: "platform-infra-selfmedia-voice-logs", mutation: false, component: options.component, lines: options.lines, targets: results, valuesPrinted: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function validate(configRoot: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const config = readConfig();
|
|
|
|
|
const current = await status(configRoot, options);
|
|
|
|
|
const frpsTarget = targetByRole(config, "frps");
|
|
|
|
|
const request = JSON.stringify({ model: config.image.modelSource.id, input: config.runtime.smoke.text, voice: config.runtime.smoke.voice, response_format: "wav" });
|
|
|
|
|
const script = `set -eu\ntmp=$(mktemp -d)\ntrap 'rm -rf "$tmp"' EXIT\ncurl --fail --silent --show-error --max-time 20 ${shQuote(`${config.runtime.publicBaseUrl}${config.runtime.smoke.healthPath}`)} >"$tmp/health.json"\ncurl --fail --silent --show-error --max-time ${config.runtime.smoke.timeoutSeconds} -H 'content-type: application/json' -d ${shQuote(request)} ${shQuote(`${config.runtime.publicBaseUrl}${config.runtime.smoke.speechPath}`)} >"$tmp/speech.wav"\nffprobe -v error -show_entries format=format_name,duration -of json "$tmp/speech.wav"\n`;
|
|
|
|
|
const smoke = await capture(configRoot, frpsTarget.route, ["sh"], script, { runtimeTimeoutMs: (config.runtime.smoke.timeoutSeconds + 30) * 1000 });
|
|
|
|
|
return { ok: current.ok === true && smoke.exitCode === 0, action: "platform-infra-selfmedia-voice-validate", mutation: false, status: current, smoke: compactCapture(smoke, { full: options.full || options.raw || smoke.exitCode !== 0 }), secret: secretSummary(config), valuesPrinted: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderBundle(config: VoiceConfig): { files: Record<string, string>; summary: Record<string, unknown>[]; disclosure: Record<string, unknown> } {
|
|
|
|
|
const artifact = (path: string) => readFileSync(resolveRepoPath(path), "utf8");
|
|
|
|
|
const replace = (text: string, values: Record<string, string | number>) => text.replace(/\{\{([A-Z_]+)\}\}/gu, (match, key: string) => key in values ? String(values[key]) : match);
|
|
|
|
|
const files = {
|
|
|
|
|
containerfile: artifact(config.artifacts.containerfile),
|
|
|
|
|
api: artifact(config.artifacts.api),
|
|
|
|
|
modelInit: artifact(config.artifacts.modelInit),
|
|
|
|
|
d518Compose: artifact(config.artifacts.d518Compose),
|
|
|
|
|
nc01Compose: artifact(config.artifacts.nc01Compose),
|
|
|
|
|
frpc: replace(artifact(config.artifacts.frpcTemplate), { SERVER_ADDRESS: config.frp.server.address, CONTROL_PORT: config.frp.server.controlPort, PROXY_NAME: config.frp.tunnel.proxyName, LOCAL_ADDRESS: config.frp.tunnel.localAddress, LOCAL_PORT: config.frp.tunnel.localPort, REMOTE_PORT: config.frp.tunnel.remotePort }),
|
|
|
|
|
frps: replace(artifact(config.artifacts.frpsTemplate), { CONTROL_PORT: config.frp.server.controlPort, REMOTE_PORT: config.frp.tunnel.remotePort }),
|
|
|
|
|
};
|
|
|
|
|
const summary = Object.entries(files).map(([name, value]) => ({ name, bytes: Buffer.byteLength(value), fingerprint: fingerprintValues({ value }, ["value"]) }));
|
|
|
|
|
return { files, summary, disclosure: { frpc: redactText(files.frpc), frps: redactText(files.frps), dependencyIdentity: config.dependencies, runtimeEnvKeys: runtimeEnv(config, targetByRole(config, "gpu-service")).split("\n").filter(Boolean).map((line) => line.split("=", 1)[0]) } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runtimeFiles(config: VoiceConfig, bundle: ReturnType<typeof renderBundle>, secret: ReturnType<typeof readSecret>, role: VoiceTarget["role"]): Record<string, string> {
|
|
|
|
|
const target = targetByRole(config, role);
|
|
|
|
|
const common = { "runtime.env": runtimeEnv(config, target), "secret.env": `${config.frp.secret.sourceKey}=${secret.value}\n` };
|
|
|
|
|
return role === "frps" ? { ...common, "compose.yaml": bundle.files.nc01Compose, "frps.toml": bundle.files.frps } : { ...common, "compose.yaml": bundle.files.d518Compose, "Containerfile": bundle.files.containerfile, "api.py": bundle.files.api, "model-init.sh": bundle.files.modelInit, "frpc.toml": bundle.files.frpc };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runtimeEnv(config: VoiceConfig, target: VoiceTarget): string {
|
|
|
|
|
const values: Record<string, string | number> = target.role === "frps" ? {
|
|
|
|
|
FRPS_IMAGE: config.frp.images.frps,
|
|
|
|
|
FRPS_CONTROL_PORT: config.frp.server.controlPort,
|
|
|
|
|
FRP_REMOTE_PORT: config.frp.tunnel.remotePort,
|
|
|
|
|
} : {
|
|
|
|
|
VOICE_IMAGE: config.image.name,
|
|
|
|
|
BASE_IMAGE: `${config.image.base.repository}@${config.image.base.digest}`,
|
|
|
|
|
SOURCE_REPOSITORY: config.image.source.repository,
|
|
|
|
|
SOURCE_REF: config.image.source.ref,
|
|
|
|
|
SOURCE_SUBMODULES: config.image.source.submodules,
|
|
|
|
|
MODEL_ID: config.image.modelSource.id,
|
|
|
|
|
MODEL_REPOSITORY: config.image.modelSource.repository,
|
|
|
|
|
MODEL_BRANCH: config.image.modelSource.branch,
|
|
|
|
|
MODEL_REF: config.image.modelSource.ref,
|
|
|
|
|
MODEL_REQUIRE_BRANCH_HEAD_MATCH: String(config.image.modelSource.requireBranchHeadMatch),
|
|
|
|
|
REFERENCE_VOICE_ID: config.image.referenceVoice.id,
|
|
|
|
|
REFERENCE_WAV_PATH: config.image.referenceVoice.path,
|
|
|
|
|
REFERENCE_WAV_SHA256: config.image.referenceVoice.sha256,
|
|
|
|
|
REFERENCE_PROMPT_TEXT: config.image.referenceVoice.promptText,
|
|
|
|
|
PIP_VERSION: config.dependencies.pip.version,
|
|
|
|
|
PIP_INDEX_URL: config.dependencies.pip.indexUrl,
|
|
|
|
|
SETUPTOOLS_INSTALL_SPEC: config.dependencies.setuptools.installSpec,
|
|
|
|
|
TORCH_VERSION_IDENTITY: config.dependencies.torch.versionIdentity,
|
|
|
|
|
TORCH_LIBRARY_PATH: config.dependencies.torch.libraryPath,
|
|
|
|
|
TORCHAUDIO_VERSION_IDENTITY: config.dependencies.torchaudio.versionIdentity,
|
|
|
|
|
OPENAI_WHISPER_INSTALL_SPEC: config.dependencies.openaiWhisper.installSpec,
|
|
|
|
|
ONNXRUNTIME_GPU_INSTALL_SPEC: config.dependencies.onnxRuntimeGpu.installSpec,
|
|
|
|
|
ONNXRUNTIME_GPU_INDEX_URL: config.dependencies.onnxRuntimeGpu.indexUrl,
|
|
|
|
|
VOICE_BIND_ADDRESS: config.runtime.bindAddress,
|
|
|
|
|
VOICE_PORT: config.runtime.port,
|
|
|
|
|
VOICE_CACHE_DIR: requireAbsolute(target.cacheDir, `targets.${target.id}.cacheDir`),
|
|
|
|
|
VOICE_DATA_DIR: requireAbsolute(target.dataDir, `targets.${target.id}.dataDir`),
|
|
|
|
|
VOICE_DEVICE: config.runtime.inference.device,
|
|
|
|
|
VOICE_FP16: String(config.runtime.inference.fp16),
|
|
|
|
|
VOICE_LOAD_TRT: String(config.runtime.inference.loadTrt),
|
|
|
|
|
VOICE_LOAD_VLLM: String(config.runtime.inference.loadVllm),
|
|
|
|
|
VOICE_REPLICAS: config.runtime.inference.replicas,
|
|
|
|
|
VOICE_WORKERS: config.runtime.inference.workers,
|
|
|
|
|
VOICE_CONCURRENCY: config.runtime.inference.concurrency,
|
|
|
|
|
VOICE_SPEED_MIN: config.runtime.inference.speed.min,
|
|
|
|
|
VOICE_SPEED_MAX: config.runtime.inference.speed.max,
|
|
|
|
|
PYTORCH_CUDA_ALLOC_CONF: config.runtime.inference.cudaAllocator,
|
|
|
|
|
CUDA_MODULE_LOADING: config.runtime.inference.cudaModuleLoading,
|
|
|
|
|
FRPC_IMAGE: config.frp.images.frpc,
|
|
|
|
|
};
|
|
|
|
|
return Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n") + "\n";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function convergeTarget(configRoot: UniDeskConfig, config: VoiceConfig, target: VoiceTarget, files: Record<string, string>) {
|
|
|
|
|
const encoded = Object.entries(files).map(([name, value]) => `printf %s ${shQuote(Buffer.from(value).toString("base64"))} | base64 -d > ${shQuote(name)}`).join("\n");
|
|
|
|
|
const script = `set -eu\numask 077\nmkdir -p ${shQuote(target.workDir)}\ncd ${shQuote(target.workDir)}\n${encoded}\nchmod 600 secret.env\ndocker compose --project-name ${shQuote(target.composeProject)} --env-file runtime.env -f compose.yaml config >/dev/null\ndocker compose --project-name ${shQuote(target.composeProject)} --env-file runtime.env -f compose.yaml up -d --build\n`;
|
|
|
|
|
return await capture(configRoot, target.route, ["sh"], script, { runtimeTimeoutMs: config.runtime.operations.applyTimeoutSeconds * 1000 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readSecret(config: VoiceConfig): { value: string; summary: Record<string, unknown> } {
|
|
|
|
|
const sourceRef = config.frp.secret.sourceRef;
|
|
|
|
|
if (!existsSync(sourceRef)) throw new Error(`${sourceRef} is missing; run platform-infra selfmedia-voice secret init --confirm`);
|
|
|
|
|
const values = parseEnvFile(readFileSync(sourceRef, "utf8"));
|
|
|
|
|
const value = values[config.frp.secret.sourceKey];
|
|
|
|
|
if (value === undefined || value.length < 32) throw new Error(`${sourceRef}.${config.frp.secret.sourceKey} must contain at least 32 characters`);
|
|
|
|
|
return { value, summary: { sourceRef, sourceKey: config.frp.secret.sourceKey, presence: true, fingerprint: fingerprintValues({ value }, ["value"]), valuesPrinted: false } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function secretSummary(config: VoiceConfig): Record<string, unknown> {
|
|
|
|
|
if (!existsSync(config.frp.secret.sourceRef)) return { sourceRef: config.frp.secret.sourceRef, sourceKey: config.frp.secret.sourceKey, presence: false, fingerprint: null, valuesPrinted: false };
|
|
|
|
|
try { return readSecret(config).summary; } catch (error) { return { sourceRef: config.frp.secret.sourceRef, sourceKey: config.frp.secret.sourceKey, presence: true, fingerprint: null, error: error instanceof Error ? error.message : String(error), valuesPrinted: false }; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function policyChecks(config: VoiceConfig, bundle: ReturnType<typeof renderBundle>): Array<{ id: string; ok: boolean; detail: string }> {
|
|
|
|
|
const rendered = Object.values(bundle.files).join("\n").toLowerCase();
|
|
|
|
|
return [
|
|
|
|
|
{ id: "no-caddy-domain-k8s-exposure", ok: !/(caddy|ingress|nodeport)/u.test(rendered), detail: "Caddy, domain routing, Ingress and NodePort are forbidden" },
|
|
|
|
|
{ id: "locked-source-refs", ok: /^[0-9a-f]{40}$/u.test(config.image.source.ref) && /^[0-9a-f]{40}$/u.test(config.image.modelSource.ref), detail: "CosyVoice and ModelScope refs are immutable commits" },
|
|
|
|
|
{ id: "locked-base-image", ok: /^sha256:[0-9a-f]{64}$/u.test(config.image.base.digest) && /^sha256:[0-9a-f]{64}$/u.test(config.image.base.imageId), detail: "base image digest and observed D518 image identity are declared" },
|
|
|
|
|
{ id: "modelscope-git-lfs", ok: config.image.modelSource.provider === "modelscope-git-lfs" && config.image.modelSource.requireBranchHeadMatch, detail: "model fetch uses Git/LFS and asserts branch head before locked checkout" },
|
|
|
|
|
{ id: "dependency-identity", ok: config.dependencies.torch.versionIdentity === config.dependencies.torchaudio.versionIdentity, detail: `torch/torchaudio ${config.dependencies.torch.versionIdentity}; ABI policy ${config.dependencies.torchaudio.abiPolicy}` },
|
|
|
|
|
{ id: "single-gpu-runtime", ok: config.runtime.inference.replicas === 1 && config.runtime.inference.workers === 1 && config.runtime.inference.concurrency === "serial", detail: "one replica, one worker and serialized inference prevent concurrent model copies" },
|
|
|
|
|
{ id: "rollout-approved", ok: config.rollout.approved, detail: `${config.rollout.prerequisite}: ${config.rollout.approved ? "approved" : "not approved"}` },
|
|
|
|
|
{ id: "host-docker-only", ok: config.targets.every((target) => !target.route.includes("k3s")), detail: "Both targets use host-Docker routes" },
|
|
|
|
|
{ id: "loopback-service", ok: config.runtime.bindAddress === "127.0.0.1" && config.frp.tunnel.localAddress === "127.0.0.1", detail: "D518 service is loopback-only behind FRPC" },
|
|
|
|
|
{ id: "dedicated-frps", ok: targetByRole(config, "frps").composeProject.includes("selfmedia-voice") && config.frp.server.controlPort === 22110, detail: "SelfMedia Voice owns a dedicated FRPS project and control port" },
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderResult(command: string, data: Record<string, unknown>): RenderedCliResult | Record<string, unknown> {
|
|
|
|
|
if (process.argv.includes("--json") || process.argv.includes("--full") || process.argv.includes("--raw")) return data;
|
|
|
|
|
const lines = [
|
|
|
|
|
`PLATFORM-INFRA SELFMEDIA-VOICE ${command.toUpperCase()}`,
|
|
|
|
|
`ok=${String(data.ok)} action=${String(data.action ?? "-")} mutation=${String(data.mutation ?? false)} mode=${String(data.mode ?? "-")}`,
|
|
|
|
|
`public=${String(data.publicBaseUrl ?? "-")} valuesPrinted=false`,
|
|
|
|
|
];
|
|
|
|
|
const secret = data.secret as Record<string, unknown> | undefined;
|
|
|
|
|
if (secret !== undefined) lines.push(`secret sourceRef=${String(secret.sourceRef)} presence=${String(secret.presence)} fingerprint=${String(secret.fingerprint ?? "-")}`);
|
|
|
|
|
const job = data.job as Record<string, unknown> | undefined;
|
|
|
|
|
if (job !== undefined) lines.push(`job id=${String(job.id)} status=${String(job.status)} statePath=${String(data.statePath ?? job.stateFile ?? "-")}`);
|
|
|
|
|
const next = data.next as Record<string, unknown> | undefined;
|
|
|
|
|
if (next !== undefined) lines.push(...Object.entries(next).map(([key, value]) => `next.${key}=${String(value)}`));
|
|
|
|
|
return { ok: data.ok === true, command: `platform-infra selfmedia-voice ${command}`, renderedText: `${lines.join("\n")}\n`, contentType: "text/plain", projection: data };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function targetByRole(config: VoiceConfig, role: VoiceTarget["role"]): VoiceTarget {
|
|
|
|
|
const matches = config.targets.filter((target) => target.role === role);
|
|
|
|
|
if (matches.length !== 1) throw new Error(`${configLabel}.targets must contain exactly one ${role} target`);
|
|
|
|
|
return matches[0]!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function targetSummary(target: VoiceTarget): Record<string, unknown> {
|
|
|
|
|
return { id: target.id, role: target.role, route: target.route, workDir: target.workDir, composeProject: target.composeProject, cacheDir: target.cacheDir ?? null, dataDir: target.dataDir ?? null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requireString(value: unknown, label: string): string {
|
|
|
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${configLabel}.${label} must be a non-empty string`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requireInteger(value: unknown, label: string): number {
|
|
|
|
|
if (!Number.isInteger(value)) throw new Error(`${configLabel}.${label} must be an integer`);
|
|
|
|
|
return value as number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requirePort(value: unknown, label: string): number {
|
|
|
|
|
const port = requireInteger(value, label);
|
|
|
|
|
if (port < 1 || port > 65535) throw new Error(`${configLabel}.${label} must be in 1..65535`);
|
|
|
|
|
return port;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requireAbsolute(value: unknown, label: string): string {
|
|
|
|
|
const path = requireString(value, label);
|
|
|
|
|
if (!isAbsolute(path)) throw new Error(`${configLabel}.${label} must be an absolute path`);
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requireCommit(value: unknown, label: string): string {
|
|
|
|
|
const ref = requireString(value, label);
|
|
|
|
|
if (!/^[0-9a-f]{40}$/u.test(ref)) throw new Error(`${configLabel}.${label} must be a 40-character commit`);
|
|
|
|
|
return ref;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requireSha256(value: unknown, label: string, prefix = true): string {
|
|
|
|
|
const hash = requireString(value, label);
|
|
|
|
|
const pattern = prefix ? /^sha256:[0-9a-f]{64}$/u : /^[0-9a-f]{64}$/u;
|
|
|
|
|
if (!pattern.test(hash)) throw new Error(`${configLabel}.${label} must be a SHA-256 identity`);
|
|
|
|
|
return hash;
|
|
|
|
|
}
|