Merge remote-tracking branch 'origin/master'

This commit is contained in:
Codex
2026-07-15 11:18:14 +02:00
13 changed files with 993 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
version: 1
kind: platform-infra-selfmedia-voice
metadata:
id: selfmedia-voice
owner: unidesk
relatedIssues:
- 2139
mdtodo: R9.4
rollout:
prerequisite: R9.1.1 CosyVoice3 GPU HTTP/WAV smoke
approved: true
artifacts:
root: deploy/selfmedia-voice
containerfile: deploy/selfmedia-voice/Containerfile
api: deploy/selfmedia-voice/api.py
modelInit: deploy/selfmedia-voice/model-init.sh
d518Compose: deploy/selfmedia-voice/compose.d518.yaml
nc01Compose: deploy/selfmedia-voice/compose.nc01.yaml
frpcTemplate: deploy/selfmedia-voice/frpc.toml.tmpl
frpsTemplate: deploy/selfmedia-voice/frps.toml.tmpl
image:
name: unidesk-selfmedia-voice:cosyvoice3-0.5b-2512
base:
repository: docker.m.daocloud.io/pytorch/pytorch
tag: 2.3.1-cuda12.1-cudnn8-runtime
digest: sha256:fc47f8018254e6df30f48c48f2db1c758d44de21a8c553de1a1c451a65baa70a
imageId: sha256:2d1dee23cc95bf01aad2a292c10f051b01ddc6e7fa7535620cc8a7457720794c
source:
repository: https://github.com/FunAudioLLM/CosyVoice.git
ref: 074ca6dc9e80a2f424f1f74b48bdd7d3fea531cc
submodules: recursive
modelSource:
provider: modelscope-git-lfs
id: FunAudioLLM/Fun-CosyVoice3-0.5B-2512
repository: https://www.modelscope.cn/FunAudioLLM/Fun-CosyVoice3-0.5B-2512.git
branch: master
ref: 9f9c56f2514700ef79d64fd0afb693e0d672373b
requireBranchHeadMatch: true
referenceVoice:
id: official-zero-shot-prompt
path: asset/zero_shot_prompt.wav
sha256: c7b31d6dbe7cc6a716dded00550db5b50940bf209e424e4ad207b12e657c8ff6
promptText: You are a helpful assistant.<|endofprompt|>希望你以后能够做的比我还好呦。
dependencies:
pip:
version: "24.0"
indexUrl: https://mirrors.ustc.edu.cn/pypi/simple
setuptools:
installSpec: setuptools<81
torch:
versionIdentity: 2.3.1
libraryPath: /opt/conda/lib/python3.10/site-packages/torch/lib
torchaudio:
package: torchaudio
installSpec: torchaudio==2.3.1
versionIdentity: 2.3.1
abiPolicy: torch-major-minor-match
openaiWhisper:
installSpec: openai-whisper==20231117
noBuildIsolation: true
onnxRuntimeGpu:
installSpec: onnxruntime-gpu==1.18.0
indexUrl: https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/
independentLayer: true
frp:
images:
frps: fatedier/frps:v0.68.1
frpc: fatedier/frpc:v0.68.1
secret:
sourceRef: /root/.unidesk/.env/selfmedia-voice-frp.env
sourceKey: FRP_TOKEN
server:
targetId: NC01
address: 152.53.229.148
controlPort: 22110
workDir: /root/.unidesk/platform-infra/selfmedia-voice/frps
composeProject: unidesk-selfmedia-voice-frps
tunnel:
targetId: D518
proxyName: selfmedia-voice-d518
remotePort: 24317
localAddress: 127.0.0.1
localPort: 18327
targets:
- id: D518
role: gpu-service
route: D518
workDir: /root/.unidesk/platform-infra/selfmedia-voice/runtime
composeProject: unidesk-selfmedia-voice
cacheDir: /root/.cache/unidesk/selfmedia-voice
dataDir: /root/.unidesk/.state/selfmedia-voice
- id: NC01
role: frps
route: NC01
workDir: /root/.unidesk/platform-infra/selfmedia-voice/frps
composeProject: unidesk-selfmedia-voice-frps
runtime:
serviceName: voice
frpcServiceName: frpc
frpsServiceName: frps
bindAddress: 127.0.0.1
port: 18327
publicBaseUrl: http://152.53.229.148:24317
inference:
device: cuda:0
fp16: true
loadTrt: false
loadVllm: false
replicas: 1
workers: 1
concurrency: serial
referenceVoice: official-zero-shot-prompt
speed:
min: 0.5
max: 2.0
cudaAllocator: expandable_segments:True,max_split_size_mb:128
cudaModuleLoading: LAZY
operations:
applyTimeoutSeconds: 3600
smoke:
healthPath: /health
speechPath: /v1/audio/speech
text: 你好,这是 SelfMedia 语音平台的固定验收请求。
voice: official-zero-shot-prompt
timeoutSeconds: 600
+58
View File
@@ -0,0 +1,58 @@
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
ARG SOURCE_REPOSITORY
ARG SOURCE_REF
ARG SOURCE_SUBMODULES
ARG REFERENCE_WAV_PATH
ARG REFERENCE_WAV_SHA256
ARG PIP_VERSION
ARG PIP_INDEX_URL
ARG SETUPTOOLS_INSTALL_SPEC
ARG TORCH_VERSION_IDENTITY
ARG TORCH_LIBRARY_PATH
ARG TORCHAUDIO_VERSION_IDENTITY
ARG OPENAI_WHISPER_INSTALL_SPEC
ARG ONNXRUNTIME_GPU_INSTALL_SPEC
ARG ONNXRUNTIME_GPU_INDEX_URL
ENV DEBIAN_FRONTEND=noninteractive \
PIP_INDEX_URL=${PIP_INDEX_URL} \
PYTHONPATH=/opt/CosyVoice:/opt/CosyVoice/third_party/Matcha-TTS \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl ffmpeg git git-lfs libsox-dev sox \
&& rm -rf /var/lib/apt/lists/* \
&& python -m pip install "pip==${PIP_VERSION}" "${SETUPTOOLS_INSTALL_SPEC}" wheel
RUN git clone --no-checkout "${SOURCE_REPOSITORY}" /opt/CosyVoice \
&& git -C /opt/CosyVoice fetch --depth 1 origin "${SOURCE_REF}" \
&& git -C /opt/CosyVoice checkout --detach "${SOURCE_REF}" \
&& test "$(git -C /opt/CosyVoice rev-parse HEAD)" = "${SOURCE_REF}" \
&& if [ "${SOURCE_SUBMODULES}" = recursive ]; then git -C /opt/CosyVoice submodule update --init --recursive --depth 1; fi \
&& printf '%s %s\n' "${REFERENCE_WAV_SHA256}" "/opt/CosyVoice/${REFERENCE_WAV_PATH}" | sha256sum -c -
RUN sed -e '/^torch==/d' \
-e '/^deepspeed==/d' \
-e '/^tensorrt-cu12/d' \
-e '/^openai-whisper==/d' \
-e '/^onnxruntime-gpu==/d' \
/opt/CosyVoice/requirements.txt > /tmp/runtime-requirements.txt \
&& python -m pip install "${OPENAI_WHISPER_INSTALL_SPEC}" --no-build-isolation
RUN python -m pip install --index-url "${ONNXRUNTIME_GPU_INDEX_URL}" "${ONNXRUNTIME_GPU_INSTALL_SPEC}"
RUN python -m pip install -r /tmp/runtime-requirements.txt \
&& test -d "${TORCH_LIBRARY_PATH}" \
&& python -c 'import sys, torch, torchaudio; expected_torch=sys.argv[1]; expected_audio=sys.argv[2]; assert torch.__version__.split("+")[0] == expected_torch, f"torch identity mismatch: expected={expected_torch} actual={torch.__version__}"; assert torchaudio.__version__.split("+")[0] == expected_audio, f"torchaudio identity mismatch: expected={expected_audio} actual={torchaudio.__version__}"; from cosyvoice.cli.cosyvoice import AutoModel' "${TORCH_VERSION_IDENTITY}" "${TORCHAUDIO_VERSION_IDENTITY}"
LABEL ai.unidesk.source.ref="${SOURCE_REF}"
LABEL ai.unidesk.torchaudio.version-identity="${TORCHAUDIO_VERSION_IDENTITY}"
WORKDIR /app
COPY api.py /app/api.py
COPY model-init.sh /app/model-init.sh
EXPOSE 18327
CMD ["python", "/app/api.py"]
+154
View File
@@ -0,0 +1,154 @@
import io
import os
import threading
import time
from contextlib import asynccontextmanager
import torch
import torchaudio
import uvicorn
from cosyvoice.cli.cosyvoice import AutoModel
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
class SpeechRequest(BaseModel):
model: str | None = None
input: str = Field(min_length=1)
voice: str | None = None
response_format: str = "wav"
speed: float = 1.0
instructions: str | None = None
instruct: str | None = None
class Runtime:
def __init__(self) -> None:
self.model = None
self.loaded_at = None
self.onnx_providers = None
self.error = None
self.lock = threading.Lock()
def load(self) -> None:
try:
if not torch.cuda.is_available():
raise RuntimeError("CUDA is unavailable")
device = os.environ["VOICE_DEVICE"]
if not device.startswith("cuda:"):
raise RuntimeError("VOICE_DEVICE must select a CUDA device")
torch.cuda.set_device(int(device.split(":", 1)[1]))
self.model = AutoModel(
model_dir=os.environ["MODEL_DIR"],
load_trt=env_bool("VOICE_LOAD_TRT"),
load_vllm=env_bool("VOICE_LOAD_VLLM"),
fp16=env_bool("VOICE_FP16"),
)
self.onnx_providers = self.model.frontend.speech_tokenizer_session.get_providers()
if "CUDAExecutionProvider" not in self.onnx_providers:
raise RuntimeError(f"CUDAExecutionProvider was not instantiated: {self.onnx_providers}")
self.loaded_at = time.time()
except Exception as error:
self.error = f"{type(error).__name__}: {error}"
raise
def env_bool(name: str) -> bool:
value = os.environ[name].lower()
if value not in {"true", "false"}:
raise RuntimeError(f"{name} must be true or false")
return value == "true"
runtime = Runtime()
@asynccontextmanager
async def lifespan(_: FastAPI):
runtime.load()
yield
app = FastAPI(title="SelfMedia Voice", version="1", lifespan=lifespan)
@app.get("/health")
def health() -> dict:
return {
"ok": runtime.model is not None and runtime.error is None,
"model": os.environ["MODEL_ID"],
"modelRef": os.environ["MODEL_REF"],
"sourceRef": os.environ["SOURCE_REF"],
"torch": torch.__version__,
"torchaudio": torchaudio.__version__,
"cuda": torch.version.cuda,
"gpu": torch.cuda.get_device_name(torch.cuda.current_device()) if torch.cuda.is_available() else None,
"device": os.environ["VOICE_DEVICE"],
"fp16": env_bool("VOICE_FP16"),
"loadTrt": env_bool("VOICE_LOAD_TRT"),
"loadVllm": env_bool("VOICE_LOAD_VLLM"),
"workers": int(os.environ["VOICE_WORKERS"]),
"concurrency": os.environ["VOICE_CONCURRENCY"],
"onnxProviders": runtime.onnx_providers,
"loadedAt": runtime.loaded_at,
"error": runtime.error,
}
@app.post("/v1/audio/speech")
def speech(request: SpeechRequest) -> Response:
if request.response_format.lower() != "wav":
raise HTTPException(status_code=400, detail="response_format must be wav")
if not float(os.environ["VOICE_SPEED_MIN"]) <= request.speed <= float(os.environ["VOICE_SPEED_MAX"]):
raise HTTPException(status_code=400, detail="speed is outside the configured range")
if runtime.model is None:
raise HTTPException(status_code=503, detail=runtime.error or "model is not ready")
if request.voice not in {None, os.environ["REFERENCE_VOICE_ID"]}:
raise HTTPException(status_code=400, detail="voice must select the configured reference voice")
started = time.perf_counter()
instruction = request.instructions or request.instruct
with runtime.lock:
inference = (
runtime.model.inference_instruct2(
request.input,
instruction,
os.environ["PROMPT_WAV"],
stream=False,
speed=request.speed,
)
if instruction
else runtime.model.inference_zero_shot(
request.input,
os.environ["PROMPT_TEXT"],
os.environ["PROMPT_WAV"],
stream=False,
speed=request.speed,
)
)
chunks = [
output["tts_speech"].cpu()
for output in inference
]
if not chunks:
raise HTTPException(status_code=500, detail="empty inference result")
waveform = torch.cat(chunks, dim=1)
output = io.BytesIO()
torchaudio.save(output, waveform, runtime.model.sample_rate, format="wav")
return Response(
content=output.getvalue(),
media_type="audio/wav",
headers={
"X-Inference-Seconds": f"{time.perf_counter() - started:.6f}",
"X-Sample-Rate": str(runtime.model.sample_rate),
"X-Inference-Mode": "instruct2" if instruction else "zero-shot",
},
)
if __name__ == "__main__":
workers = int(os.environ["VOICE_WORKERS"])
if workers != 1:
raise RuntimeError("VOICE_WORKERS must be 1 for the single-GPU model runtime")
uvicorn.run(app, host="0.0.0.0", port=int(os.environ["VOICE_PORT"]), workers=workers)
+94
View File
@@ -0,0 +1,94 @@
services:
model-init:
image: ${VOICE_IMAGE}
build: &voice-build
context: .
dockerfile: Containerfile
args:
BASE_IMAGE: ${BASE_IMAGE}
SOURCE_REPOSITORY: ${SOURCE_REPOSITORY}
SOURCE_REF: ${SOURCE_REF}
SOURCE_SUBMODULES: ${SOURCE_SUBMODULES}
REFERENCE_WAV_PATH: ${REFERENCE_WAV_PATH}
REFERENCE_WAV_SHA256: ${REFERENCE_WAV_SHA256}
PIP_VERSION: ${PIP_VERSION}
PIP_INDEX_URL: ${PIP_INDEX_URL}
SETUPTOOLS_INSTALL_SPEC: ${SETUPTOOLS_INSTALL_SPEC}
TORCH_VERSION_IDENTITY: ${TORCH_VERSION_IDENTITY}
TORCH_LIBRARY_PATH: ${TORCH_LIBRARY_PATH}
TORCHAUDIO_VERSION_IDENTITY: ${TORCHAUDIO_VERSION_IDENTITY}
OPENAI_WHISPER_INSTALL_SPEC: ${OPENAI_WHISPER_INSTALL_SPEC}
ONNXRUNTIME_GPU_INSTALL_SPEC: ${ONNXRUNTIME_GPU_INSTALL_SPEC}
ONNXRUNTIME_GPU_INDEX_URL: ${ONNXRUNTIME_GPU_INDEX_URL}
restart: "no"
environment:
MODEL_REPOSITORY: ${MODEL_REPOSITORY}
MODEL_BRANCH: ${MODEL_BRANCH}
MODEL_REF: ${MODEL_REF}
MODEL_REQUIRE_BRANCH_HEAD_MATCH: ${MODEL_REQUIRE_BRANCH_HEAD_MATCH}
command: [/app/model-init.sh]
volumes:
- ${VOICE_CACHE_DIR}/model:/model
logging: &voice-logs
driver: json-file
options:
max-size: 20m
max-file: "3"
voice:
image: ${VOICE_IMAGE}
build: *voice-build
restart: unless-stopped
gpus: all
shm_size: 4gb
deploy:
replicas: ${VOICE_REPLICAS}
depends_on:
model-init:
condition: service_completed_successfully
ports:
- ${VOICE_BIND_ADDRESS}:${VOICE_PORT}:${VOICE_PORT}
environment:
MODEL_ID: ${MODEL_ID}
MODEL_DIR: /model
MODEL_REF: ${MODEL_REF}
SOURCE_REF: ${SOURCE_REF}
PROMPT_WAV: /opt/CosyVoice/${REFERENCE_WAV_PATH}
PROMPT_TEXT: ${REFERENCE_PROMPT_TEXT}
REFERENCE_VOICE_ID: ${REFERENCE_VOICE_ID}
VOICE_DEVICE: ${VOICE_DEVICE}
VOICE_FP16: ${VOICE_FP16}
VOICE_LOAD_TRT: ${VOICE_LOAD_TRT}
VOICE_LOAD_VLLM: ${VOICE_LOAD_VLLM}
VOICE_WORKERS: ${VOICE_WORKERS}
VOICE_CONCURRENCY: ${VOICE_CONCURRENCY}
VOICE_SPEED_MIN: ${VOICE_SPEED_MIN}
VOICE_SPEED_MAX: ${VOICE_SPEED_MAX}
VOICE_PORT: ${VOICE_PORT}
PYTORCH_CUDA_ALLOC_CONF: ${PYTORCH_CUDA_ALLOC_CONF}
CUDA_MODULE_LOADING: ${CUDA_MODULE_LOADING}
LD_LIBRARY_PATH: ${TORCH_LIBRARY_PATH}
volumes:
- ${VOICE_CACHE_DIR}/model:/model:ro
- ${VOICE_DATA_DIR}:/data
healthcheck:
test: [CMD, curl, --fail, --silent, http://127.0.0.1:${VOICE_PORT}/health]
interval: 30s
timeout: 10s
retries: 20
start_period: 10m
logging: *voice-logs
frpc:
image: ${FRPC_IMAGE}
restart: unless-stopped
depends_on:
voice:
condition: service_healthy
env_file:
- secret.env
command: [-c, /etc/frp/frpc.toml]
volumes:
- ./frpc.toml:/etc/frp/frpc.toml:ro
network_mode: host
logging: *voice-logs
+17
View File
@@ -0,0 +1,17 @@
services:
frps:
image: ${FRPS_IMAGE}
restart: unless-stopped
env_file:
- secret.env
command: [-c, /etc/frp/frps.toml]
ports:
- ${FRPS_CONTROL_PORT}:${FRPS_CONTROL_PORT}/tcp
- ${FRP_REMOTE_PORT}:${FRP_REMOTE_PORT}/tcp
volumes:
- ./frps.toml:/etc/frp/frps.toml:ro
logging:
driver: json-file
options:
max-size: 20m
max-file: "3"
+12
View File
@@ -0,0 +1,12 @@
serverAddr = "{{SERVER_ADDRESS}}"
serverPort = {{CONTROL_PORT}}
auth.method = "token"
auth.token = "{{ .Envs.FRP_TOKEN }}"
[[proxies]]
name = "{{PROXY_NAME}}"
type = "tcp"
localIP = "{{LOCAL_ADDRESS}}"
localPort = {{LOCAL_PORT}}
remotePort = {{REMOTE_PORT}}
+9
View File
@@ -0,0 +1,9 @@
bindAddr = "0.0.0.0"
bindPort = {{CONTROL_PORT}}
auth.method = "token"
auth.token = "{{ .Envs.FRP_TOKEN }}"
allowPorts = [
{ single = {{REMOTE_PORT}} }
]
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
if [ ! -d /model/.git ]; then
find /model -mindepth 1 -maxdepth 1 -exec rm -rf {} +
GIT_LFS_SKIP_SMUDGE=1 git clone --no-checkout "$MODEL_REPOSITORY" /model
fi
git -C /model fetch --depth 1 origin "$MODEL_BRANCH"
if [ "$MODEL_REQUIRE_BRANCH_HEAD_MATCH" = true ]; then
test "$(git -C /model rev-parse FETCH_HEAD)" = "$MODEL_REF"
fi
GIT_LFS_SKIP_SMUDGE=1 git -C /model checkout --detach "$MODEL_REF"
git -C /model lfs pull
test "$(git -C /model rev-parse HEAD)" = "$MODEL_REF"
@@ -22,3 +22,11 @@
## R2 [completed]
解决 UniDesk #1913https://github.com/pikasTech/unidesk/issues/1913):复用 agent_skills #7 与 PR #8 的 MDTODO 安全 stdin 实现,让主代理、Artificer resource bundle 和受控 host skill 投影默认使用安全标题输入,并有界披露 source/target freshness、warning 与精确修复入口;不得复制第二套 MDTODO 工具、增加版本门禁或覆盖宿主并行脏改,由 Artificer 在独立 worktree 和分支实现、测试并提交 PR,不自行合并,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R2_Task_Report.md)。
## R3 [in_progress]
解决 [UniDesk #2170](https://github.com/pikasTech/unidesk/issues/2170):为 unidesk-ymalops 增加配置引用问题的 skill + owning YAML 快速收敛与停止条件,证明同类问题减少不必要工具调用,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R3_Task_Report.md)。
## R4 [in_progress]
解决 [agent_skills #15](https://github.com/pikasTech/agent_skills/issues/15):为 Playwright wrapper 增加不泄露凭据的 fill-file 表单填充,并证明 Secret 不进入 argv、输出或 JSONL 日志,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R4_Task_Report.md)。
+4
View File
@@ -901,6 +901,10 @@ function platformInfraHelpSummary(): unknown {
"bun scripts/cli.ts platform-infra wechat-archive pull --remote-path /UniDesk/WeChatArchive/...",
"bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full",
"bun scripts/cli.ts platform-infra wechat-archive collector-status --full",
"bun scripts/cli.ts platform-infra selfmedia-voice plan",
"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|logs|validate",
"bun scripts/cli.ts platform-infra gitea status --target NC01",
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
@@ -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;
}
+7
View File
@@ -399,6 +399,7 @@ export function platformInfraHelp(): unknown {
"nginx",
"langbot",
"n8n",
"selfmedia-voice",
"webterm",
"wechat-archive",
"observability",
@@ -449,6 +450,12 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra n8n status [--target G14] [--full|--raw]",
"bun scripts/cli.ts platform-infra n8n logs [--target G14] [--component app|frpc|all]",
"bun scripts/cli.ts platform-infra n8n validate [--target G14] [--full|--raw]",
"bun scripts/cli.ts platform-infra selfmedia-voice plan",
"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",
"bun scripts/cli.ts platform-infra selfmedia-voice logs [--component voice|frpc|frps|all]",
"bun scripts/cli.ts platform-infra selfmedia-voice validate",
"bun scripts/cli.ts platform-infra webterm plan",
"bun scripts/cli.ts platform-infra webterm apply --confirm",
"bun scripts/cli.ts platform-infra webterm status",
+4
View File
@@ -58,6 +58,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin
const { runN8nCommand } = await import("../platform-infra-n8n");
return await runN8nCommand(config, args.slice(1));
}
if (target === "selfmedia-voice") {
const { runSelfMediaVoiceCommand } = await import("../platform-infra-selfmedia-voice");
return await runSelfMediaVoiceCommand(config, args.slice(1));
}
if (target === "webterm") {
const { runWebtermCommand } = await import("../platform-infra-webterm");
return await runWebtermCommand(config, args.slice(1));