diff --git a/config/platform-infra/selfmedia-voice.yaml b/config/platform-infra/selfmedia-voice.yaml new file mode 100644 index 00000000..c30744f1 --- /dev/null +++ b/config/platform-infra/selfmedia-voice.yaml @@ -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 diff --git a/deploy/selfmedia-voice/Containerfile b/deploy/selfmedia-voice/Containerfile new file mode 100644 index 00000000..239d75f3 --- /dev/null +++ b/deploy/selfmedia-voice/Containerfile @@ -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"] diff --git a/deploy/selfmedia-voice/api.py b/deploy/selfmedia-voice/api.py new file mode 100644 index 00000000..66a3f4f0 --- /dev/null +++ b/deploy/selfmedia-voice/api.py @@ -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) diff --git a/deploy/selfmedia-voice/compose.d518.yaml b/deploy/selfmedia-voice/compose.d518.yaml new file mode 100644 index 00000000..c335e738 --- /dev/null +++ b/deploy/selfmedia-voice/compose.d518.yaml @@ -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 diff --git a/deploy/selfmedia-voice/compose.nc01.yaml b/deploy/selfmedia-voice/compose.nc01.yaml new file mode 100644 index 00000000..92cbbe7e --- /dev/null +++ b/deploy/selfmedia-voice/compose.nc01.yaml @@ -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" diff --git a/deploy/selfmedia-voice/frpc.toml.tmpl b/deploy/selfmedia-voice/frpc.toml.tmpl new file mode 100644 index 00000000..be4069f6 --- /dev/null +++ b/deploy/selfmedia-voice/frpc.toml.tmpl @@ -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}} diff --git a/deploy/selfmedia-voice/frps.toml.tmpl b/deploy/selfmedia-voice/frps.toml.tmpl new file mode 100644 index 00000000..39828cf8 --- /dev/null +++ b/deploy/selfmedia-voice/frps.toml.tmpl @@ -0,0 +1,9 @@ +bindAddr = "0.0.0.0" +bindPort = {{CONTROL_PORT}} + +auth.method = "token" +auth.token = "{{ .Envs.FRP_TOKEN }}" + +allowPorts = [ + { single = {{REMOTE_PORT}} } +] diff --git a/deploy/selfmedia-voice/model-init.sh b/deploy/selfmedia-voice/model-init.sh new file mode 100755 index 00000000..ca602dea --- /dev/null +++ b/deploy/selfmedia-voice/model-init.sh @@ -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" diff --git a/docs/MDTODO/mdtodo-tooling-reliability.md b/docs/MDTODO/mdtodo-tooling-reliability.md index f94e9f4b..0c9d8a61 100644 --- a/docs/MDTODO/mdtodo-tooling-reliability.md +++ b/docs/MDTODO/mdtodo-tooling-reliability.md @@ -22,3 +22,11 @@ ## R2 [completed] 解决 UniDesk #1913(https://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)。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index f1795a03..6de46906 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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", diff --git a/scripts/src/platform-infra-selfmedia-voice.ts b/scripts/src/platform-infra-selfmedia-voice.ts new file mode 100644 index 00000000..6d287b8f --- /dev/null +++ b/scripts/src/platform-infra-selfmedia-voice.ts @@ -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 | 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 { + 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(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 { + 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 { + 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> { + 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> { + const config = readConfig(); + const results: Record = {}; + 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> { + 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 = {}; + 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> { + 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; summary: Record[]; disclosure: Record } { + const artifact = (path: string) => readFileSync(resolveRepoPath(path), "utf8"); + const replace = (text: string, values: Record) => 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, secret: ReturnType, role: VoiceTarget["role"]): Record { + 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 = 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) { + 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 } { + 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 { + 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): 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): RenderedCliResult | Record { + 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 | 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 | 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 | 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 { + 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; +} diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index fdaac348..23426058 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -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", diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index c4c049ce..fbe7cc5e 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -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));