Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,9 @@
|
||||
bindAddr = "0.0.0.0"
|
||||
bindPort = {{CONTROL_PORT}}
|
||||
|
||||
auth.method = "token"
|
||||
auth.token = "{{ .Envs.FRP_TOKEN }}"
|
||||
|
||||
allowPorts = [
|
||||
{ single = {{REMOTE_PORT}} }
|
||||
]
|
||||
Executable
+15
@@ -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 #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)。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user