Merge remote-tracking branch 'origin/master' into feat/2010-hwlab-release-production-lane

This commit is contained in:
Codex
2026-07-15 11:36:58 +02:00
19 changed files with 1171 additions and 5 deletions
+24
View File
@@ -5,6 +5,30 @@ description: UniDesk YAML-first 运维正规化技能。用户提到 ymal-first/
# UniDesk YAML Ops
## 配置引用问题快速路径
用户只问“YAML 引用的密钥在哪里、如何修改”或等价的配置定位问题时,固定执行以下最短路径:
1. 读取本技能。
2. 定位并读取该服务的 owning YAML,只查看相关 target/service 与 `sourceRef``sourceKey``targetKey`、transform、rollout/sync 字段。
3. 直接回答:
- owning YAML 的文件和对象路径;
- `sourceRef` 指向的本地 Secret 来源;
- `sourceKey` 存在时对应来源文件内的键;
- `targetKey` 对应运行时接收键,不是用户名或 Secret 值;
- 修改来源文件后应使用的领域受控 `plan/status/sync/apply` 入口;
- 回答只披露路径、键名和入口,不读取或打印值。
4. 信息齐全后立即停止。
停止条件与例外:
- owning YAML 已给出来源、目标和受控入口所需的 selector 时,不再搜索应用源码、数据库、Pod Secret、环境变量、日志或运行面。
- 不得根据 DSN、URL 或包含 `@` 的字符串形态猜测用户名、密码或字段边界;字段语义只由 YAML 键和对应外部格式定义。
- `sourceRef` 为绝对路径或 `~/` 路径时按声明回答;相对路径的解析基准只有在 owning YAML 或领域 parser 未明确时才允许定点读取对应 parser,不扩大为运行面调查。
- owning YAML 同时声明 `configRef`/`declarationId` 时,Secret 分发默认使用 `bun scripts/cli.ts secrets sync --config <configRef 文件> --scope <target scope> --confirm`;领域 skill 明确声明了更具体入口时,以领域入口为准。
- 用户进一步要求确认是否已生效、登录复测或执行下发时,才进入对应领域 skill 的受控 CLI 与运行面验证流程。
- 详细判定与回答模板以 `docs/reference/yaml-first-ops.md#配置引用问题的最短查找路径` 为唯一权威出处。
## Scope
本技能用于推进 UniDesk 的 `ymal-first 正规化`:把运维参数、节点/lane/service 归属、公开暴露、Secret 绑定、容量/版本/endpoint 等可调事实收敛到 YAML;代码只负责读取、校验形状、渲染计划、执行薄的 domain CLI。
+133
View File
@@ -0,0 +1,133 @@
version: 1
kind: platform-infra-selfmedia-voice
metadata:
id: selfmedia-voice
owner: unidesk
relatedIssues:
- 2139
mdtodo: R9.4
rollout:
prerequisite: R9.1.1 CosyVoice3 GPU HTTP/WAV smoke
approved: true
artifacts:
root: deploy/selfmedia-voice
containerfile: deploy/selfmedia-voice/Containerfile
api: deploy/selfmedia-voice/api.py
modelInit: deploy/selfmedia-voice/model-init.sh
d518Compose: deploy/selfmedia-voice/compose.d518.yaml
nc01Compose: deploy/selfmedia-voice/compose.nc01.yaml
frpcTemplate: deploy/selfmedia-voice/frpc.toml.tmpl
frpsTemplate: deploy/selfmedia-voice/frps.toml.tmpl
image:
name: unidesk-selfmedia-voice:cosyvoice3-0.5b-2512
base:
repository: docker.m.daocloud.io/pytorch/pytorch
tag: 2.3.1-cuda12.1-cudnn8-runtime
digest: sha256:fc47f8018254e6df30f48c48f2db1c758d44de21a8c553de1a1c451a65baa70a
imageId: sha256:2d1dee23cc95bf01aad2a292c10f051b01ddc6e7fa7535620cc8a7457720794c
source:
repository: https://github.com/FunAudioLLM/CosyVoice.git
ref: 074ca6dc9e80a2f424f1f74b48bdd7d3fea531cc
submodules: recursive
modelSource:
provider: modelscope-git-lfs
id: FunAudioLLM/Fun-CosyVoice3-0.5B-2512
repository: https://www.modelscope.cn/FunAudioLLM/Fun-CosyVoice3-0.5B-2512.git
branch: master
ref: 9f9c56f2514700ef79d64fd0afb693e0d672373b
requireBranchHeadMatch: true
referenceVoice:
id: official-zero-shot-prompt
path: asset/zero_shot_prompt.wav
sha256: c7b31d6dbe7cc6a716dded00550db5b50940bf209e424e4ad207b12e657c8ff6
promptText: You are a helpful assistant.<|endofprompt|>希望你以后能够做的比我还好呦。
dependencies:
pip:
version: "24.0"
indexUrl: https://mirrors.ustc.edu.cn/pypi/simple
setuptools:
installSpec: setuptools<81
torch:
versionIdentity: 2.3.1
libraryPath: /opt/conda/lib/python3.10/site-packages/torch/lib
torchaudio:
package: torchaudio
installSpec: torchaudio==2.3.1
versionIdentity: 2.3.1
abiPolicy: torch-major-minor-match
openaiWhisper:
installSpec: openai-whisper==20231117
noBuildIsolation: true
onnxRuntimeGpu:
installSpec: onnxruntime-gpu==1.18.0
indexUrl: https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/
independentLayer: true
frp:
images:
frps: fatedier/frps:v0.68.1
frpc: fatedier/frpc:v0.68.1
secret:
sourceRef: /root/.unidesk/.env/selfmedia-voice-frp.env
sourceKey: FRP_TOKEN
server:
targetId: NC01
address: 152.53.229.148
controlPort: 22110
workDir: /root/.unidesk/platform-infra/selfmedia-voice/frps
composeProject: unidesk-selfmedia-voice-frps
tunnel:
targetId: D518
proxyName: selfmedia-voice-d518
remotePort: 24317
localAddress: 127.0.0.1
localPort: 18327
targets:
- id: D518
role: gpu-service
route: D518
workDir: /root/.unidesk/platform-infra/selfmedia-voice/runtime
composeProject: unidesk-selfmedia-voice
cacheDir: /root/.cache/unidesk/selfmedia-voice
dataDir: /root/.unidesk/.state/selfmedia-voice
- id: NC01
role: frps
route: NC01
workDir: /root/.unidesk/platform-infra/selfmedia-voice/frps
composeProject: unidesk-selfmedia-voice-frps
runtime:
serviceName: voice
frpcServiceName: frpc
frpsServiceName: frps
bindAddress: 127.0.0.1
port: 18327
publicBaseUrl: http://152.53.229.148:24317
inference:
device: cuda:0
fp16: true
loadTrt: false
loadVllm: false
replicas: 1
workers: 1
concurrency: serial
referenceVoice: official-zero-shot-prompt
speed:
min: 0.5
max: 2.0
cudaAllocator: expandable_segments:True,max_split_size_mb:128
cudaModuleLoading: LAZY
operations:
applyTimeoutSeconds: 3600
smoke:
healthPath: /health
speechPath: /v1/audio/speech
text: 你好,这是 SelfMedia 语音平台的固定验收请求。
voice: official-zero-shot-prompt
timeoutSeconds: 600
+58
View File
@@ -0,0 +1,58 @@
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
ARG SOURCE_REPOSITORY
ARG SOURCE_REF
ARG SOURCE_SUBMODULES
ARG REFERENCE_WAV_PATH
ARG REFERENCE_WAV_SHA256
ARG PIP_VERSION
ARG PIP_INDEX_URL
ARG SETUPTOOLS_INSTALL_SPEC
ARG TORCH_VERSION_IDENTITY
ARG TORCH_LIBRARY_PATH
ARG TORCHAUDIO_VERSION_IDENTITY
ARG OPENAI_WHISPER_INSTALL_SPEC
ARG ONNXRUNTIME_GPU_INSTALL_SPEC
ARG ONNXRUNTIME_GPU_INDEX_URL
ENV DEBIAN_FRONTEND=noninteractive \
PIP_INDEX_URL=${PIP_INDEX_URL} \
PYTHONPATH=/opt/CosyVoice:/opt/CosyVoice/third_party/Matcha-TTS \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl ffmpeg git git-lfs libsox-dev sox \
&& rm -rf /var/lib/apt/lists/* \
&& python -m pip install "pip==${PIP_VERSION}" "${SETUPTOOLS_INSTALL_SPEC}" wheel
RUN git clone --no-checkout "${SOURCE_REPOSITORY}" /opt/CosyVoice \
&& git -C /opt/CosyVoice fetch --depth 1 origin "${SOURCE_REF}" \
&& git -C /opt/CosyVoice checkout --detach "${SOURCE_REF}" \
&& test "$(git -C /opt/CosyVoice rev-parse HEAD)" = "${SOURCE_REF}" \
&& if [ "${SOURCE_SUBMODULES}" = recursive ]; then git -C /opt/CosyVoice submodule update --init --recursive --depth 1; fi \
&& printf '%s %s\n' "${REFERENCE_WAV_SHA256}" "/opt/CosyVoice/${REFERENCE_WAV_PATH}" | sha256sum -c -
RUN sed -e '/^torch==/d' \
-e '/^deepspeed==/d' \
-e '/^tensorrt-cu12/d' \
-e '/^openai-whisper==/d' \
-e '/^onnxruntime-gpu==/d' \
/opt/CosyVoice/requirements.txt > /tmp/runtime-requirements.txt \
&& python -m pip install "${OPENAI_WHISPER_INSTALL_SPEC}" --no-build-isolation
RUN python -m pip install --index-url "${ONNXRUNTIME_GPU_INDEX_URL}" "${ONNXRUNTIME_GPU_INSTALL_SPEC}"
RUN python -m pip install -r /tmp/runtime-requirements.txt \
&& test -d "${TORCH_LIBRARY_PATH}" \
&& python -c 'import sys, torch, torchaudio; expected_torch=sys.argv[1]; expected_audio=sys.argv[2]; assert torch.__version__.split("+")[0] == expected_torch, f"torch identity mismatch: expected={expected_torch} actual={torch.__version__}"; assert torchaudio.__version__.split("+")[0] == expected_audio, f"torchaudio identity mismatch: expected={expected_audio} actual={torchaudio.__version__}"; from cosyvoice.cli.cosyvoice import AutoModel' "${TORCH_VERSION_IDENTITY}" "${TORCHAUDIO_VERSION_IDENTITY}"
LABEL ai.unidesk.source.ref="${SOURCE_REF}"
LABEL ai.unidesk.torchaudio.version-identity="${TORCHAUDIO_VERSION_IDENTITY}"
WORKDIR /app
COPY api.py /app/api.py
COPY model-init.sh /app/model-init.sh
EXPOSE 18327
CMD ["python", "/app/api.py"]
+154
View File
@@ -0,0 +1,154 @@
import io
import os
import threading
import time
from contextlib import asynccontextmanager
import torch
import torchaudio
import uvicorn
from cosyvoice.cli.cosyvoice import AutoModel
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
class SpeechRequest(BaseModel):
model: str | None = None
input: str = Field(min_length=1)
voice: str | None = None
response_format: str = "wav"
speed: float = 1.0
instructions: str | None = None
instruct: str | None = None
class Runtime:
def __init__(self) -> None:
self.model = None
self.loaded_at = None
self.onnx_providers = None
self.error = None
self.lock = threading.Lock()
def load(self) -> None:
try:
if not torch.cuda.is_available():
raise RuntimeError("CUDA is unavailable")
device = os.environ["VOICE_DEVICE"]
if not device.startswith("cuda:"):
raise RuntimeError("VOICE_DEVICE must select a CUDA device")
torch.cuda.set_device(int(device.split(":", 1)[1]))
self.model = AutoModel(
model_dir=os.environ["MODEL_DIR"],
load_trt=env_bool("VOICE_LOAD_TRT"),
load_vllm=env_bool("VOICE_LOAD_VLLM"),
fp16=env_bool("VOICE_FP16"),
)
self.onnx_providers = self.model.frontend.speech_tokenizer_session.get_providers()
if "CUDAExecutionProvider" not in self.onnx_providers:
raise RuntimeError(f"CUDAExecutionProvider was not instantiated: {self.onnx_providers}")
self.loaded_at = time.time()
except Exception as error:
self.error = f"{type(error).__name__}: {error}"
raise
def env_bool(name: str) -> bool:
value = os.environ[name].lower()
if value not in {"true", "false"}:
raise RuntimeError(f"{name} must be true or false")
return value == "true"
runtime = Runtime()
@asynccontextmanager
async def lifespan(_: FastAPI):
runtime.load()
yield
app = FastAPI(title="SelfMedia Voice", version="1", lifespan=lifespan)
@app.get("/health")
def health() -> dict:
return {
"ok": runtime.model is not None and runtime.error is None,
"model": os.environ["MODEL_ID"],
"modelRef": os.environ["MODEL_REF"],
"sourceRef": os.environ["SOURCE_REF"],
"torch": torch.__version__,
"torchaudio": torchaudio.__version__,
"cuda": torch.version.cuda,
"gpu": torch.cuda.get_device_name(torch.cuda.current_device()) if torch.cuda.is_available() else None,
"device": os.environ["VOICE_DEVICE"],
"fp16": env_bool("VOICE_FP16"),
"loadTrt": env_bool("VOICE_LOAD_TRT"),
"loadVllm": env_bool("VOICE_LOAD_VLLM"),
"workers": int(os.environ["VOICE_WORKERS"]),
"concurrency": os.environ["VOICE_CONCURRENCY"],
"onnxProviders": runtime.onnx_providers,
"loadedAt": runtime.loaded_at,
"error": runtime.error,
}
@app.post("/v1/audio/speech")
def speech(request: SpeechRequest) -> Response:
if request.response_format.lower() != "wav":
raise HTTPException(status_code=400, detail="response_format must be wav")
if not float(os.environ["VOICE_SPEED_MIN"]) <= request.speed <= float(os.environ["VOICE_SPEED_MAX"]):
raise HTTPException(status_code=400, detail="speed is outside the configured range")
if runtime.model is None:
raise HTTPException(status_code=503, detail=runtime.error or "model is not ready")
if request.voice not in {None, os.environ["REFERENCE_VOICE_ID"]}:
raise HTTPException(status_code=400, detail="voice must select the configured reference voice")
started = time.perf_counter()
instruction = request.instructions or request.instruct
with runtime.lock:
inference = (
runtime.model.inference_instruct2(
request.input,
instruction,
os.environ["PROMPT_WAV"],
stream=False,
speed=request.speed,
)
if instruction
else runtime.model.inference_zero_shot(
request.input,
os.environ["PROMPT_TEXT"],
os.environ["PROMPT_WAV"],
stream=False,
speed=request.speed,
)
)
chunks = [
output["tts_speech"].cpu()
for output in inference
]
if not chunks:
raise HTTPException(status_code=500, detail="empty inference result")
waveform = torch.cat(chunks, dim=1)
output = io.BytesIO()
torchaudio.save(output, waveform, runtime.model.sample_rate, format="wav")
return Response(
content=output.getvalue(),
media_type="audio/wav",
headers={
"X-Inference-Seconds": f"{time.perf_counter() - started:.6f}",
"X-Sample-Rate": str(runtime.model.sample_rate),
"X-Inference-Mode": "instruct2" if instruction else "zero-shot",
},
)
if __name__ == "__main__":
workers = int(os.environ["VOICE_WORKERS"])
if workers != 1:
raise RuntimeError("VOICE_WORKERS must be 1 for the single-GPU model runtime")
uvicorn.run(app, host="0.0.0.0", port=int(os.environ["VOICE_PORT"]), workers=workers)
+94
View File
@@ -0,0 +1,94 @@
services:
model-init:
image: ${VOICE_IMAGE}
build: &voice-build
context: .
dockerfile: Containerfile
args:
BASE_IMAGE: ${BASE_IMAGE}
SOURCE_REPOSITORY: ${SOURCE_REPOSITORY}
SOURCE_REF: ${SOURCE_REF}
SOURCE_SUBMODULES: ${SOURCE_SUBMODULES}
REFERENCE_WAV_PATH: ${REFERENCE_WAV_PATH}
REFERENCE_WAV_SHA256: ${REFERENCE_WAV_SHA256}
PIP_VERSION: ${PIP_VERSION}
PIP_INDEX_URL: ${PIP_INDEX_URL}
SETUPTOOLS_INSTALL_SPEC: ${SETUPTOOLS_INSTALL_SPEC}
TORCH_VERSION_IDENTITY: ${TORCH_VERSION_IDENTITY}
TORCH_LIBRARY_PATH: ${TORCH_LIBRARY_PATH}
TORCHAUDIO_VERSION_IDENTITY: ${TORCHAUDIO_VERSION_IDENTITY}
OPENAI_WHISPER_INSTALL_SPEC: ${OPENAI_WHISPER_INSTALL_SPEC}
ONNXRUNTIME_GPU_INSTALL_SPEC: ${ONNXRUNTIME_GPU_INSTALL_SPEC}
ONNXRUNTIME_GPU_INDEX_URL: ${ONNXRUNTIME_GPU_INDEX_URL}
restart: "no"
environment:
MODEL_REPOSITORY: ${MODEL_REPOSITORY}
MODEL_BRANCH: ${MODEL_BRANCH}
MODEL_REF: ${MODEL_REF}
MODEL_REQUIRE_BRANCH_HEAD_MATCH: ${MODEL_REQUIRE_BRANCH_HEAD_MATCH}
command: [/app/model-init.sh]
volumes:
- ${VOICE_CACHE_DIR}/model:/model
logging: &voice-logs
driver: json-file
options:
max-size: 20m
max-file: "3"
voice:
image: ${VOICE_IMAGE}
build: *voice-build
restart: unless-stopped
gpus: all
shm_size: 4gb
deploy:
replicas: ${VOICE_REPLICAS}
depends_on:
model-init:
condition: service_completed_successfully
ports:
- ${VOICE_BIND_ADDRESS}:${VOICE_PORT}:${VOICE_PORT}
environment:
MODEL_ID: ${MODEL_ID}
MODEL_DIR: /model
MODEL_REF: ${MODEL_REF}
SOURCE_REF: ${SOURCE_REF}
PROMPT_WAV: /opt/CosyVoice/${REFERENCE_WAV_PATH}
PROMPT_TEXT: ${REFERENCE_PROMPT_TEXT}
REFERENCE_VOICE_ID: ${REFERENCE_VOICE_ID}
VOICE_DEVICE: ${VOICE_DEVICE}
VOICE_FP16: ${VOICE_FP16}
VOICE_LOAD_TRT: ${VOICE_LOAD_TRT}
VOICE_LOAD_VLLM: ${VOICE_LOAD_VLLM}
VOICE_WORKERS: ${VOICE_WORKERS}
VOICE_CONCURRENCY: ${VOICE_CONCURRENCY}
VOICE_SPEED_MIN: ${VOICE_SPEED_MIN}
VOICE_SPEED_MAX: ${VOICE_SPEED_MAX}
VOICE_PORT: ${VOICE_PORT}
PYTORCH_CUDA_ALLOC_CONF: ${PYTORCH_CUDA_ALLOC_CONF}
CUDA_MODULE_LOADING: ${CUDA_MODULE_LOADING}
LD_LIBRARY_PATH: ${TORCH_LIBRARY_PATH}
volumes:
- ${VOICE_CACHE_DIR}/model:/model:ro
- ${VOICE_DATA_DIR}:/data
healthcheck:
test: [CMD, curl, --fail, --silent, "http://127.0.0.1:${VOICE_PORT}/health"]
interval: 30s
timeout: 10s
retries: 20
start_period: 10m
logging: *voice-logs
frpc:
image: ${FRPC_IMAGE}
restart: unless-stopped
depends_on:
voice:
condition: service_healthy
env_file:
- secret.env
command: [-c, /etc/frp/frpc.toml]
volumes:
- ./frpc.toml:/etc/frp/frpc.toml:ro
network_mode: host
logging: *voice-logs
+17
View File
@@ -0,0 +1,17 @@
services:
frps:
image: ${FRPS_IMAGE}
restart: unless-stopped
env_file:
- secret.env
command: [-c, /etc/frp/frps.toml]
ports:
- ${FRPS_CONTROL_PORT}:${FRPS_CONTROL_PORT}/tcp
- ${FRP_REMOTE_PORT}:${FRP_REMOTE_PORT}/tcp
volumes:
- ./frps.toml:/etc/frp/frps.toml:ro
logging:
driver: json-file
options:
max-size: 20m
max-file: "3"
+12
View File
@@ -0,0 +1,12 @@
serverAddr = "{{SERVER_ADDRESS}}"
serverPort = {{CONTROL_PORT}}
auth.method = "token"
auth.token = "{{ .Envs.FRP_TOKEN }}"
[[proxies]]
name = "{{PROXY_NAME}}"
type = "tcp"
localIP = "{{LOCAL_ADDRESS}}"
localPort = {{LOCAL_PORT}}
remotePort = {{REMOTE_PORT}}
+9
View File
@@ -0,0 +1,9 @@
bindAddr = "0.0.0.0"
bindPort = {{CONTROL_PORT}}
auth.method = "token"
auth.token = "{{ .Envs.FRP_TOKEN }}"
allowPorts = [
{ single = {{REMOTE_PORT}} }
]
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
if [ ! -d /model/.git ]; then
find /model -mindepth 1 -maxdepth 1 -exec rm -rf {} +
GIT_LFS_SKIP_SMUDGE=1 git clone --no-checkout "$MODEL_REPOSITORY" /model
fi
git -C /model fetch --depth 1 origin "$MODEL_BRANCH"
if [ "$MODEL_REQUIRE_BRANCH_HEAD_MATCH" = true ]; then
test "$(git -C /model rev-parse FETCH_HEAD)" = "$MODEL_REF"
fi
GIT_LFS_SKIP_SMUDGE=1 git -C /model checkout --detach "$MODEL_REF"
git -C /model lfs pull
test "$(git -C /model rev-parse HEAD)" = "$MODEL_REF"
@@ -0,0 +1,36 @@
# R3 任务报告
## 目标
解决 UniDesk #2170,使“YAML 引用的密钥在哪里、如何修改”类问题只读取 `unidesk-ymalops` 与 owning YAML 即可回答并停止。
## 变更
-`.agents/skills/unidesk-ymalops/SKILL.md` 前置配置引用快速路径、停止条件和 Secret 分发受控入口。
-`docs/reference/yaml-first-ops.md` 建立唯一详细合同,固定 `sourceRef``sourceKey``targetKey``configRef``transform` 的含义与回答模板。
- 明确禁止从 `@``:` 等字符串形态猜测用户名或密码,也禁止为定位问题反查源码、数据库、Pod Secret、环境变量、日志或运行面。
## 最小 smoke
只读取以下两个输入:
1. `.agents/skills/unidesk-ymalops/SKILL.md` 的“配置引用问题快速路径”;
2. `config/pikaoa.yaml#releaseRuntime.targets.NC01.runtime.administrator.password`
得到的脱敏答案为:owning YAML 是 `config/pikaoa.yaml`;管理员用户名由同级 `username` 声明;密码修改源由 `sourceRef` 声明;运行时接收键由 `targetKey` 声明;目标还通过 `secret.configRef`/`declarationId` 选择 Secret 分发,修改后走 `secrets sync` 受控入口。无需读取 PikaOA 源码、数据库或 Kubernetes Secret。
验证命令:
```bash
git diff --check
sed -n '/配置引用问题快速路径/,/## Scope/p' .agents/skills/unidesk-ymalops/SKILL.md
sed -n '293,302p' config/pikaoa.yaml
```
结果:通过;输出只包含路径、用户名和键名,没有读取或打印 Secret 值。
## 边界与风险
- 本任务不修改产品源码、YAML 数值、Secret 来源内容或运行面。
- 相对 `sourceRef` 的解析基准若未由 owning YAML 明确,仍允许定点读取领域 parser;这是唯一需要额外读取的例外。
- 领域存在专用 Secret CLI 时,以领域 skill 声明的入口为准。
@@ -0,0 +1,20 @@
# R4 任务报告
## 结论
核查 `pikasTech/agent_skills` 远端 `master` 后确认,Playwright `fill-file` 已由 PR #14 实现并合入,对应实现提交为 `f4389fa`。因此 agent_skills #15 属于重复需求,已附证据关闭,不再复制实现。
## 安全边界
本机 `/root/.agents/skills` 固定 worktree 落后远端且包含用户并行修改。本次未执行 stash、reset、checkout 或覆盖同步,也未读取或打印任何 Secret 值。`fill-file` 的设计目标仍是由工具进程读取文件,避免敏感值进入命令 argv 和工具输出。
## 后续
实际缺口是固定 Skill 安装态无法安全识别和刷新,已登记 UniDesk #2174。该支线应提供 installed/upstream commit、ahead/behind 和 dirty presence 的受控状态入口;dirty 时只给出非破坏性建议,不作为 PikaOA 主线门禁。
## 验证
- 远端 `master` 包含 PR #14`fill-file` 实现;
- agent_skills #15 已关闭并引用既有实现;
- 本地固定 Skill worktree 的并行修改保持原状;
- 未进行产品或运行面 mutation。
@@ -0,0 +1,56 @@
# R9.1.1 任务报告:D518 CosyVoice3 先进中文语音实验
## 结论
D518 本地 Docker GPU smoke 已通过。`FunAudioLLM/Fun-CosyVoice3-0.5B-2512` 可在 RTX 2060 6 GiB 上以 FP16、单 worker、串行推理运行,能够提供 OpenAI Speech 风格的 `/health``/v1/audio/speech` 接口。实验未发布 FRP、未修改集群,正式发布继续由 R9.4 YAML-first 控制面负责。
## 固定身份
- 源码:`FunAudioLLM/CosyVoice@074ca6dc9e80a2f424f1f74b48bdd7d3fea531cc`
- 模型:`FunAudioLLM/Fun-CosyVoice3-0.5B-2512@9f9c56f2514700ef79d64fd0afb693e0d672373b`
- 基础镜像:`docker.m.daocloud.io/pytorch/pytorch:2.3.1-cuda12.1-cudnn8-runtime@sha256:fc47f8018254e6df30f48c48f2db1c758d44de21a8c553de1a1c451a65baa70a`
- 基础镜像 ID`sha256:2d1dee23cc95bf01aad2a292c10f051b01ddc6e7fa7535620cc8a7457720794c`
- 实验镜像 ID`sha256:5e941831593d2d98c618fb7584f41af260f68c6899cf7faa88c9f72064ed7bda`
- 运行组合:Torch `2.3.1`、torchaudio `2.3.1`、CUDA `12.1`
- 低显存参数:`fp16=true``load_trt=false``load_vllm=false`
- Python 构建:pip `24.0``setuptools<81``openai-whisper==20231117 --no-build-isolation``onnxruntime-gpu==1.18.0`
- 参考音:官方 Apache-2.0 源码中的 `asset/zero_shot_prompt.wav`
模型通过 ModelScope Git/LFS 获取:先验证远端 `master` 等于锁定 commit,再检出该 commit 并拉取 LFS;没有把 SDK 的可漂移 revision 当成不可变身份。
## smoke 结果
固定文本:`我国新一代人工智能平台今日完成升级,将为科研与产业协同提供更高效的算力支持。`
- 模型加载:38 秒
- cold:请求 14.147833 秒,推理 14.141894 秒,WAV 8.24 秒,RTF 1.716970395598 字节
- hot:请求 12.421401 秒,推理 12.416900 秒,WAV 10.52 秒,RTF 1.180742505038 字节
- GPURTX 2060 6144 MiB
- 显存:基线 412 MiB,峰值 5831 MiB
- OOM:无
显存余量约 313 MiB,因此正式服务必须单副本、单 worker、串行推理,禁止并发模型副本。
## 完整性证据
- 核心权重 SHA-256`/tmp/selfmedia-voice-r9-1-1/model-files.sha256`
- 结构化 smoke`/tmp/selfmedia-voice-r9-1-1/smoke-result.json`
- Torch/GPU import`/tmp/selfmedia-voice-r9-1-1/import-smoke.txt`
- 冷/热 WAV、headers 与日志:`/tmp/selfmedia-voice-r9-1-1/`
- GitHub 证据:[UniDesk #2148 comment](https://github.com/pikasTech/unidesk/issues/2148#issuecomment-4978683332)
实验容器已经清理;保留模型缓存、哈希、WAV 和日志供 R9.4 正式部署复用。
## 收敛过程
1. Qwen3-TTS 路径因 Torch/torchaudio ABI 不一致停止后,按子任务边界切换到 CosyVoice3。
2. Whisper 的 PEP517 隔离构建缺少 `pkg_resources`,改为独立非隔离安装。
3. 默认 PyPI 下载过慢;临时实验使用公开 USTC 镜像,正式实现将下载源放入 owning YAML。
4. 大依赖拆分为独立成功缓存层,避免后续失败时重复下载 ONNX Runtime。
5. ModelScope SDK 不接受 Git SHA revision,改用 Git/LFS 固定 commit。
6. 当前 CosyVoice3 构造器不接受旧 `load_jit` 参数,按锁定源码真实签名使用 `load_trt/load_vllm/fp16`
7. D518 宿主无 `ffprobe`WAV 校验改用运行镜像内工具。
## 后续
R9.4 将使用以上精确身份更新 YAML、容器、API、FRP 与受控 CLI;只有 plan/validate 和配置审核通过后才执行正式 apply,并继续在 SELFMEDIA 开发环境 4317 完成 voice 单步 smoke。
+2 -2
View File
@@ -23,10 +23,10 @@
解决 UniDesk #1913https://github.com/pikasTech/unidesk/issues/1913):复用 agent_skills #7 与 PR #8 的 MDTODO 安全 stdin 实现,让主代理、Artificer resource bundle 和受控 host skill 投影默认使用安全标题输入,并有界披露 source/target freshness、warning 与精确修复入口;不得复制第二套 MDTODO 工具、增加版本门禁或覆盖宿主并行脏改,由 Artificer 在独立 worktree 和分支实现、测试并提交 PR,不自行合并,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R2_Task_Report.md)。
## R3 [in_progress]
## R3 [completed]
解决 [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]
## R4 [completed]
解决 [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)。
+1 -1
View File
@@ -104,7 +104,7 @@
依据 [UniDesk #2133](https://github.com/pikasTech/unidesk/issues/2133) 由 Artificer 经 trans 在 D518 /tmp 完成 Qwen3-TTS 0.6B CustomVoice Docker GPU smoke:锁定官方上游 ref、镜像/构建、低显存参数和 health/speech API,以固定中文技术资讯文本验证首次与热调用、WAV 解码、实时率和峰值显存;成功后清理临时容器并留下可接力证据,失败则在一次官方低显存参数尝试后停止且不无界更换模型,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.1_Task_Report.md)。
#### R9.1.1 [in_progress]
#### R9.1.1 [completed]
依据 [UniDesk #2148](https://github.com/pikasTech/unidesk/issues/2148) 在 Qwen3-TTS 因 Torch/TorchAudio ABI 不兼容停止后,由 Artificer 经 trans 在 D518 /tmp 验证下一候选 CosyVoice3 0.5B:锁定官方 source/model ref 和 Apache-2.0 许可,使用稳定匹配的 Torch/TorchAudio Docker GPU 组合与本地自有或开源生成的中文参考 WAV,提供 health/speech API并以固定技术资讯文本完成 WAV、实时率和峰值显存 smoke;成功后为 R9.4 提供正式模型身份,失败则记录首个确定阻塞并停止无界试错,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.1.1_Task_Report.md)。
### R9.2 [in_progress]
+47 -2
View File
@@ -1,6 +1,51 @@
# YAML-First Heterogeneous Distributed Ops
# YAML-first 异构分布式运维
This document defines the UniDesk architecture for YAML-first heterogeneous distributed operations. It is the long-term reference for turning node, lane, service, Secret, exposure, database, rollout and probe decisions into declared configuration plus reusable CLI execution. Concrete values belong in YAML under `config/`; this document defines ownership and architecture only.
## 配置引用问题的最短查找路径
- 适用问题:
- YAML 引用的密钥、密码、token 或连接配置在哪里;
- 应修改哪个来源文件;
- 来源内容会写入哪个运行时键;
- 修改后应走哪个受控入口。
- 必要输入只有两个:
- 领域 `skill`
- 服务的 owning YAML。
- 字段解释:
- `sourceRef` 是可修改的本地 Secret 来源或外部 Secret 引用;
- `sourceKey` 是结构化来源内被读取的键,缺省时按领域 parser 声明的整文件语义处理;
- `targetKey` 是 Secret、环境变量或渲染配置中的接收键,不代表用户名,也不包含 Secret 值;
- `transform` 描述下发前的具名转换,不能据此反向恢复原值;
- `configRef` 指向另一个 owning YAML 对象时,只沿该引用读取一次目标对象,不扫描整个仓库。
- Secret 分发对象同时提供 `configRef``declarationId` 和 target scope 时,标准受控入口是 `bun scripts/cli.ts secrets sync --config <configRef 文件> --scope <target scope> --confirm`
- 标准回答必须包含:
- owning YAML 的文件路径和对象路径;
- `sourceRef`、可选 `sourceKey``targetKey`
- 修改动作是编辑 `sourceRef` 所指来源,而不是修改 `targetKey` 或运行面对象;
- 修改后使用领域 skill 已声明的 `plan/status/sync/apply` 受控入口;
- 不披露 Secret 值、完整 DSN、token 或可复制凭据。
- 停止条件:
- 上述字段齐全时立即回答并停止;
- 不继续读取应用源码、数据库实现、Pod Secret、环境变量、日志或运行面;
- 不通过字符串中 `@``:``/` 等分隔符猜测用户名或密码;
- 只有相对 `sourceRef` 的解析基准未声明时,才定点读取领域 parser;
- 只有用户要求验证生效或执行下发时,才进入运行面操作。
- 回答模板:
```text
owning YAML<file>#<object.path>
修改来源:<sourceRef>sourceKey<key 或“整文件”>
注入目标:<targetKey>
受控入口:<domain CLI plan/status/sync/apply>
说明:targetKey 不是用户名或密码;未读取或打印 Secret 值。
```
- 最小 smoke 示例只用于证明通用规则,不构成第二配置真相:
- 读取 `config/pikaoa.yaml#releaseRuntime.targets.NC01.runtime.administrator.password`
- 可直接得到管理员用户名、密码 `sourceRef` 与 `targetKey`
- 修改密码应编辑该 `sourceRef`,再走 PikaOA/Secret 分发的领域受控入口;
- 到此停止,无需读取 PikaOA 源码、数据库或 Kubernetes Secret。
本文定义 UniDesk 的 YAML-first 异构分布式运维架构,是将节点、lane、服务、Secret、公开暴露、数据库、rollout 和探针决策收敛为声明式配置与可复用 CLI 执行的长期参考。具体数值归属于 `config/` 下的 YAML;本文只定义归属关系和架构。
## Scope
+4
View File
@@ -901,6 +901,10 @@ function platformInfraHelpSummary(): unknown {
"bun scripts/cli.ts platform-infra wechat-archive pull --remote-path /UniDesk/WeChatArchive/...",
"bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full",
"bun scripts/cli.ts platform-infra wechat-archive collector-status --full",
"bun scripts/cli.ts platform-infra selfmedia-voice plan",
"bun scripts/cli.ts platform-infra selfmedia-voice secret init --confirm",
"bun scripts/cli.ts platform-infra selfmedia-voice apply --confirm",
"bun scripts/cli.ts platform-infra selfmedia-voice status|logs|validate",
"bun scripts/cli.ts platform-infra gitea status --target NC01",
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
@@ -0,0 +1,478 @@
import { randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute } from "node:path";
import type { UniDeskConfig } from "./config";
import { rootPath } from "./config";
import { startJob } from "./jobs";
import type { RenderedCliResult } from "./output";
import { capture, compactCapture, fingerprintValues, parseEnvFile, readYamlRecord, redactText, resolveRepoPath, shQuote } from "./platform-infra-ops-library";
const configPath = rootPath("config", "platform-infra", "selfmedia-voice.yaml");
const configLabel = "config/platform-infra/selfmedia-voice.yaml";
interface VoiceTarget {
id: string;
role: "gpu-service" | "frps";
route: string;
workDir: string;
composeProject: string;
cacheDir?: string;
dataDir?: string;
}
interface VoiceConfig {
version: number;
kind: "platform-infra-selfmedia-voice";
metadata: { id: string; owner: string; relatedIssues: number[]; mdtodo: string };
rollout: { prerequisite: string; approved: boolean };
artifacts: Record<"root" | "containerfile" | "api" | "modelInit" | "d518Compose" | "nc01Compose" | "frpcTemplate" | "frpsTemplate", string>;
image: {
name: string;
base: { repository: string; tag: string; digest: string; imageId: string };
source: { repository: string; ref: string; submodules: "recursive" };
modelSource: { provider: "modelscope-git-lfs"; id: string; repository: string; branch: string; ref: string; requireBranchHeadMatch: boolean };
referenceVoice: { id: string; path: string; sha256: string; promptText: string };
};
dependencies: {
pip: { version: string; indexUrl: string };
setuptools: { installSpec: string };
torch: { versionIdentity: string; libraryPath: string };
torchaudio: { package: string; installSpec: string; versionIdentity: string; abiPolicy: "torch-major-minor-match" };
openaiWhisper: { installSpec: string; noBuildIsolation: boolean };
onnxRuntimeGpu: { installSpec: string; indexUrl: string; independentLayer: boolean };
};
frp: {
images: { frps: string; frpc: string };
secret: { sourceRef: string; sourceKey: string };
server: { targetId: string; address: string; controlPort: number; workDir: string; composeProject: string };
tunnel: { targetId: string; proxyName: string; remotePort: number; localAddress: string; localPort: number };
};
targets: VoiceTarget[];
runtime: {
serviceName: string;
frpcServiceName: string;
frpsServiceName: string;
bindAddress: string;
port: number;
publicBaseUrl: string;
inference: {
device: string;
fp16: boolean;
loadTrt: boolean;
loadVllm: boolean;
replicas: number;
workers: number;
concurrency: "serial";
referenceVoice: string;
speed: { min: number; max: number };
cudaAllocator: string;
cudaModuleLoading: string;
};
operations: { applyTimeoutSeconds: number };
smoke: { healthPath: string; speechPath: string; text: string; voice: string; timeoutSeconds: number };
};
}
interface CommonOptions { full: boolean; raw: boolean }
interface ApplyOptions extends CommonOptions { confirm: boolean; wait: boolean }
interface LogsOptions extends CommonOptions { component: "all" | "voice" | "frpc" | "frps"; lines: number }
export async function runSelfMediaVoiceCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action, subaction] = args;
if (action === undefined || action === "plan") return renderResult("plan", plan(parseCommon(args.slice(action === undefined ? 0 : 1))));
if (action === "secret" && subaction === "init") return renderResult("secret init", secretInit(parseApply(args.slice(2))));
if (action === "apply") return renderResult("apply", await apply(config, parseApply(args.slice(1))));
if (action === "status") return renderResult("status", await status(config, parseCommon(args.slice(1))));
if (action === "logs") return renderResult("logs", await logs(config, parseLogs(args.slice(1))));
if (action === "validate") return renderResult("validate", await validate(config, parseCommon(args.slice(1))));
if (action === "help" || action === "--help" || action === "-h") return help();
return { ok: false, action: "platform-infra-selfmedia-voice", error: `unsupported command: ${args.join(" ")}`, next: help().usage };
}
function help(): Record<string, unknown> {
return {
ok: true,
action: "platform-infra-selfmedia-voice-help",
usage: [
"bun scripts/cli.ts platform-infra selfmedia-voice plan [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra selfmedia-voice secret init --confirm",
"bun scripts/cli.ts platform-infra selfmedia-voice apply --confirm",
"bun scripts/cli.ts platform-infra selfmedia-voice status [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra selfmedia-voice logs [--component voice|frpc|frps|all] [--lines 120]",
"bun scripts/cli.ts platform-infra selfmedia-voice validate [--json|--full|--raw]",
],
};
}
function parseCommon(args: string[]): CommonOptions {
let full = false;
let raw = false;
for (const arg of args) {
if (arg === "--full") full = true;
else if (arg === "--raw" || arg === "--json") raw = true;
else throw new Error(`unsupported option: ${arg}`);
}
return { full, raw };
}
function parseApply(args: string[]): ApplyOptions {
let confirm = false;
let wait = false;
const common: string[] = [];
for (const arg of args) {
if (arg === "--confirm") confirm = true;
else if (arg === "--wait") wait = true;
else common.push(arg);
}
return { ...parseCommon(common), confirm, wait };
}
function parseLogs(args: string[]): LogsOptions {
let component: LogsOptions["component"] = "all";
let lines = 120;
const common: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--component") {
const value = args[index + 1];
if (value !== "voice" && value !== "frpc" && value !== "frps" && value !== "all") throw new Error("--component must be voice, frpc, frps, or all");
component = value;
index += 1;
} else if (arg === "--lines") {
lines = Number(args[index + 1]);
if (!Number.isInteger(lines) || lines < 1 || lines > 500) throw new Error("--lines must be an integer in 1..500");
index += 1;
} else common.push(arg);
}
return { ...parseCommon(common), component, lines };
}
function readConfig(): VoiceConfig {
const config = readYamlRecord<VoiceConfig>(configPath, "platform-infra-selfmedia-voice");
requireInteger(config.version, "version");
if (config.metadata.mdtodo !== "R9.4") throw new Error(`${configLabel}.metadata.mdtodo must be R9.4`);
for (const path of Object.values(config.artifacts)) resolveRepoPath(requireString(path, "artifacts path"));
if (typeof config.rollout.approved !== "boolean") throw new Error(`${configLabel}.rollout.approved must be a boolean`);
requireString(config.rollout.prerequisite, "rollout.prerequisite");
requireString(config.image.base.repository, "image.base.repository");
requireString(config.image.base.tag, "image.base.tag");
requireSha256(config.image.base.digest, "image.base.digest");
requireSha256(config.image.base.imageId, "image.base.imageId");
requireCommit(config.image.source.ref, "image.source.ref");
if (config.image.source.submodules !== "recursive") throw new Error(`${configLabel}.image.source.submodules must be recursive`);
if (config.image.modelSource.provider !== "modelscope-git-lfs") throw new Error(`${configLabel}.image.modelSource.provider must be modelscope-git-lfs`);
requireCommit(config.image.modelSource.ref, "image.modelSource.ref");
requireString(config.image.modelSource.branch, "image.modelSource.branch");
if (config.image.modelSource.requireBranchHeadMatch !== true) throw new Error(`${configLabel}.image.modelSource.requireBranchHeadMatch must be true`);
requireSha256(config.image.referenceVoice.sha256, "image.referenceVoice.sha256", false);
requireString(config.dependencies.pip.version, "dependencies.pip.version");
if (new URL(config.dependencies.pip.indexUrl).protocol !== "https:") throw new Error(`${configLabel}.dependencies.pip.indexUrl must use https`);
requireString(config.dependencies.setuptools.installSpec, "dependencies.setuptools.installSpec");
requireString(config.dependencies.torch.versionIdentity, "dependencies.torch.versionIdentity");
requireAbsolute(config.dependencies.torch.libraryPath, "dependencies.torch.libraryPath");
requireString(config.dependencies.torchaudio.package, "dependencies.torchaudio.package");
requireString(config.dependencies.torchaudio.installSpec, "dependencies.torchaudio.installSpec");
requireString(config.dependencies.torchaudio.versionIdentity, "dependencies.torchaudio.versionIdentity");
if (config.dependencies.torchaudio.abiPolicy !== "torch-major-minor-match") throw new Error(`${configLabel}.dependencies.torchaudio.abiPolicy must be torch-major-minor-match`);
requireString(config.dependencies.openaiWhisper.installSpec, "dependencies.openaiWhisper.installSpec");
if (config.dependencies.openaiWhisper.noBuildIsolation !== true) throw new Error(`${configLabel}.dependencies.openaiWhisper.noBuildIsolation must be true`);
requireString(config.dependencies.onnxRuntimeGpu.installSpec, "dependencies.onnxRuntimeGpu.installSpec");
if (new URL(config.dependencies.onnxRuntimeGpu.indexUrl).protocol !== "https:") throw new Error(`${configLabel}.dependencies.onnxRuntimeGpu.indexUrl must use https`);
if (config.dependencies.onnxRuntimeGpu.independentLayer !== true) throw new Error(`${configLabel}.dependencies.onnxRuntimeGpu.independentLayer must be true`);
requireAbsolute(config.frp.secret.sourceRef, "frp.secret.sourceRef");
requirePort(config.frp.server.controlPort, "frp.server.controlPort");
requirePort(config.frp.tunnel.remotePort, "frp.tunnel.remotePort");
requirePort(config.frp.tunnel.localPort, "frp.tunnel.localPort");
requirePort(config.runtime.port, "runtime.port");
requireInteger(config.runtime.operations.applyTimeoutSeconds, "runtime.operations.applyTimeoutSeconds");
if (config.runtime.operations.applyTimeoutSeconds < 60 || config.runtime.operations.applyTimeoutSeconds > 3600) throw new Error(`${configLabel}.runtime.operations.applyTimeoutSeconds must be in 60..3600`);
if (config.runtime.port !== config.frp.tunnel.localPort) throw new Error(`${configLabel}.runtime.port must match frp.tunnel.localPort`);
if (new URL(config.runtime.publicBaseUrl).protocol !== "http:") throw new Error(`${configLabel}.runtime.publicBaseUrl must use http`);
if (config.targets.length !== 2) throw new Error(`${configLabel}.targets must contain exactly D518 gpu-service and NC01 frps targets`);
const gpu = targetByRole(config, "gpu-service");
const frps = targetByRole(config, "frps");
if (gpu.id !== config.frp.tunnel.targetId || frps.id !== config.frp.server.targetId) throw new Error(`${configLabel} FRP target ids must match target roles`);
for (const target of config.targets) {
requireString(target.route, `targets.${target.id}.route`);
requireAbsolute(target.workDir, `targets.${target.id}.workDir`);
}
requireAbsolute(gpu.cacheDir, `targets.${gpu.id}.cacheDir`);
requireAbsolute(gpu.dataDir, `targets.${gpu.id}.dataDir`);
requireInteger(config.runtime.inference.replicas, "runtime.inference.replicas");
requireInteger(config.runtime.inference.workers, "runtime.inference.workers");
if (config.runtime.inference.concurrency !== "serial") throw new Error(`${configLabel}.runtime.inference.concurrency must be serial`);
if (config.runtime.inference.referenceVoice !== config.image.referenceVoice.id) throw new Error(`${configLabel}.runtime.inference.referenceVoice must select image.referenceVoice.id`);
if (typeof config.runtime.inference.speed.min !== "number" || typeof config.runtime.inference.speed.max !== "number" || config.runtime.inference.speed.min >= config.runtime.inference.speed.max) throw new Error(`${configLabel}.runtime.inference.speed must declare an increasing numeric range`);
return config;
}
function plan(options: CommonOptions): Record<string, unknown> {
const config = readConfig();
const bundle = renderBundle(config);
const secret = secretSummary(config);
const policy = policyChecks(config, bundle);
return {
ok: policy.every((item) => item.ok),
action: "platform-infra-selfmedia-voice-plan",
mutation: false,
metadata: config.metadata,
targets: config.targets.map(targetSummary),
image: config.image,
dependencies: config.dependencies,
ports: { frpsControl: config.frp.server.controlPort, remote: config.frp.tunnel.remotePort, d518Loopback: config.runtime.port },
publicBaseUrl: config.runtime.publicBaseUrl,
lowMemory: config.runtime.inference,
secret,
artifacts: bundle.summary,
policy,
valuesPrinted: false,
...(options.full || options.raw ? { rendered: bundle.disclosure } : {}),
next: {
secretInit: "bun scripts/cli.ts platform-infra selfmedia-voice secret init --confirm",
apply: "bun scripts/cli.ts platform-infra selfmedia-voice apply --confirm",
status: "bun scripts/cli.ts platform-infra selfmedia-voice status",
validate: "bun scripts/cli.ts platform-infra selfmedia-voice validate",
},
};
}
function secretInit(options: ApplyOptions): Record<string, unknown> {
const config = readConfig();
if (!options.confirm) return { ok: false, action: "platform-infra-selfmedia-voice-secret-init", mutation: false, mode: "missing-confirm", error: "secret init requires --confirm", secret: secretSummary(config) };
const sourceRef = config.frp.secret.sourceRef;
if (!existsSync(sourceRef)) {
mkdirSync(dirname(sourceRef), { recursive: true, mode: 0o700 });
writeFileSync(sourceRef, `${config.frp.secret.sourceKey}=${randomBytes(32).toString("base64url")}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
}
chmodSync(sourceRef, 0o600);
return { ok: true, action: "platform-infra-selfmedia-voice-secret-init", mutation: true, mode: "initialized", secret: secretSummary(config), valuesPrinted: false };
}
async function apply(configRoot: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
const config = readConfig();
if (!options.confirm) return { ok: false, action: "platform-infra-selfmedia-voice-apply", mutation: false, mode: "missing-confirm", error: "apply requires --confirm" };
const policy = policyChecks(config, renderBundle(config));
if (!policy.every((item) => item.ok)) return { ok: false, action: "platform-infra-selfmedia-voice-apply", mutation: false, mode: "policy-blocked", policy };
const secret = readSecret(config);
if (!options.wait) {
const job = startJob(
"platform_infra_selfmedia_voice_apply",
["bun", "scripts/cli.ts", "platform-infra", "selfmedia-voice", "apply", "--confirm", "--wait"],
"Build and converge the YAML-declared D518 voice service/FRPC and NC01 FRPS host-Docker runtimes",
);
return { ok: true, action: "platform-infra-selfmedia-voice-apply", mutation: true, mode: "async-job", job, statePath: job.stateFile, statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, valuesPrinted: false };
}
const bundle = renderBundle(config);
const frpsTarget = targetByRole(config, "frps");
const gpuTarget = targetByRole(config, "gpu-service");
const frps = await convergeTarget(configRoot, config, frpsTarget, runtimeFiles(config, bundle, secret, "frps"));
if (frps.exitCode !== 0) return { ok: false, action: "platform-infra-selfmedia-voice-apply", mutation: true, mode: "frps-failed", target: targetSummary(frpsTarget), result: compactCapture(frps, { full: true }), valuesPrinted: false };
const gpu = await convergeTarget(configRoot, config, gpuTarget, runtimeFiles(config, bundle, secret, "gpu-service"));
return { ok: gpu.exitCode === 0, action: "platform-infra-selfmedia-voice-apply", mutation: true, mode: "completed", targets: { frps: compactCapture(frps), gpu: compactCapture(gpu, { full: gpu.exitCode !== 0 }) }, secret: secret.summary, valuesPrinted: false };
}
async function status(configRoot: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
const config = readConfig();
const results: Record<string, unknown> = {};
for (const target of config.targets) {
const compose = target.role === "gpu-service" ? config.artifacts.d518Compose : config.artifacts.nc01Compose;
const probe = target.role === "gpu-service"
? `curl --silent --show-error --max-time 10 http://${config.runtime.bindAddress}:${config.runtime.port}${config.runtime.smoke.healthPath}`
: `printf '{"frpsControlPort":${config.frp.server.controlPort},"remotePort":${config.frp.tunnel.remotePort}}\\n'`;
const script = `set +e\ncd ${shQuote(target.workDir)}\ndocker compose --project-name ${shQuote(target.composeProject)} --env-file runtime.env -f compose.yaml ps --format json\nrc=$?\n${probe}\nexit $rc\n`;
const result = await capture(configRoot, target.route, ["sh"], script);
results[target.id] = { target: targetSummary(target), composeArtifact: compose, result: compactCapture(result, { full: options.full || options.raw }) };
}
return { ok: Object.values(results).every((value) => (value as { result: { exitCode: number } }).result.exitCode === 0), action: "platform-infra-selfmedia-voice-status", mutation: false, image: config.image, dependencies: config.dependencies, ports: { control: config.frp.server.controlPort, remote: config.frp.tunnel.remotePort, local: config.runtime.port }, publicBaseUrl: config.runtime.publicBaseUrl, secret: secretSummary(config), targets: results, valuesPrinted: false };
}
async function logs(configRoot: UniDeskConfig, options: LogsOptions): Promise<Record<string, unknown>> {
const config = readConfig();
const selected = config.targets.filter((target) => options.component === "all" || (target.role === "frps" ? options.component === "frps" : options.component === "voice" || options.component === "frpc"));
const results: Record<string, unknown> = {};
for (const target of selected) {
const services = target.role === "frps" ? [config.runtime.frpsServiceName] : options.component === "all" ? [config.runtime.serviceName, config.runtime.frpcServiceName] : [options.component];
const script = `cd ${shQuote(target.workDir)} && docker compose --project-name ${shQuote(target.composeProject)} --env-file runtime.env -f compose.yaml logs --no-color --tail ${options.lines} ${services.map(shQuote).join(" ")}`;
const result = await capture(configRoot, target.route, ["sh"], script);
results[target.id] = { exitCode: result.exitCode, stdout: redactText(result.stdout).slice(-12000), stderr: redactText(result.stderr).slice(-4000) };
}
return { ok: Object.values(results).every((value) => (value as { exitCode: number }).exitCode === 0), action: "platform-infra-selfmedia-voice-logs", mutation: false, component: options.component, lines: options.lines, targets: results, valuesPrinted: false };
}
async function validate(configRoot: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
const config = readConfig();
const current = await status(configRoot, options);
const frpsTarget = targetByRole(config, "frps");
const request = JSON.stringify({ model: config.image.modelSource.id, input: config.runtime.smoke.text, voice: config.runtime.smoke.voice, response_format: "wav" });
const script = `set -eu\ntmp=$(mktemp -d)\ntrap 'rm -rf "$tmp"' EXIT\ncurl --fail --silent --show-error --max-time 20 ${shQuote(`${config.runtime.publicBaseUrl}${config.runtime.smoke.healthPath}`)} >"$tmp/health.json"\ncurl --fail --silent --show-error --max-time ${config.runtime.smoke.timeoutSeconds} -H 'content-type: application/json' -d ${shQuote(request)} ${shQuote(`${config.runtime.publicBaseUrl}${config.runtime.smoke.speechPath}`)} >"$tmp/speech.wav"\nffprobe -v error -show_entries format=format_name,duration -of json "$tmp/speech.wav"\n`;
const smoke = await capture(configRoot, frpsTarget.route, ["sh"], script, { runtimeTimeoutMs: (config.runtime.smoke.timeoutSeconds + 30) * 1000 });
return { ok: current.ok === true && smoke.exitCode === 0, action: "platform-infra-selfmedia-voice-validate", mutation: false, status: current, smoke: compactCapture(smoke, { full: options.full || options.raw || smoke.exitCode !== 0 }), secret: secretSummary(config), valuesPrinted: false };
}
function renderBundle(config: VoiceConfig): { files: Record<string, string>; summary: Record<string, unknown>[]; disclosure: Record<string, unknown> } {
const artifact = (path: string) => readFileSync(resolveRepoPath(path), "utf8");
const replace = (text: string, values: Record<string, string | number>) => text.replace(/\{\{([A-Z_]+)\}\}/gu, (match, key: string) => key in values ? String(values[key]) : match);
const files = {
containerfile: artifact(config.artifacts.containerfile),
api: artifact(config.artifacts.api),
modelInit: artifact(config.artifacts.modelInit),
d518Compose: artifact(config.artifacts.d518Compose),
nc01Compose: artifact(config.artifacts.nc01Compose),
frpc: replace(artifact(config.artifacts.frpcTemplate), { SERVER_ADDRESS: config.frp.server.address, CONTROL_PORT: config.frp.server.controlPort, PROXY_NAME: config.frp.tunnel.proxyName, LOCAL_ADDRESS: config.frp.tunnel.localAddress, LOCAL_PORT: config.frp.tunnel.localPort, REMOTE_PORT: config.frp.tunnel.remotePort }),
frps: replace(artifact(config.artifacts.frpsTemplate), { CONTROL_PORT: config.frp.server.controlPort, REMOTE_PORT: config.frp.tunnel.remotePort }),
};
const summary = Object.entries(files).map(([name, value]) => ({ name, bytes: Buffer.byteLength(value), fingerprint: fingerprintValues({ value }, ["value"]) }));
return { files, summary, disclosure: { frpc: redactText(files.frpc), frps: redactText(files.frps), dependencyIdentity: config.dependencies, runtimeEnvKeys: runtimeEnv(config, targetByRole(config, "gpu-service")).split("\n").filter(Boolean).map((line) => line.split("=", 1)[0]) } };
}
function runtimeFiles(config: VoiceConfig, bundle: ReturnType<typeof renderBundle>, secret: ReturnType<typeof readSecret>, role: VoiceTarget["role"]): Record<string, string> {
const target = targetByRole(config, role);
const common = { "runtime.env": runtimeEnv(config, target), "secret.env": `${config.frp.secret.sourceKey}=${secret.value}\n` };
return role === "frps" ? { ...common, "compose.yaml": bundle.files.nc01Compose, "frps.toml": bundle.files.frps } : { ...common, "compose.yaml": bundle.files.d518Compose, "Containerfile": bundle.files.containerfile, "api.py": bundle.files.api, "model-init.sh": bundle.files.modelInit, "frpc.toml": bundle.files.frpc };
}
function runtimeEnv(config: VoiceConfig, target: VoiceTarget): string {
const values: Record<string, string | number> = target.role === "frps" ? {
FRPS_IMAGE: config.frp.images.frps,
FRPS_CONTROL_PORT: config.frp.server.controlPort,
FRP_REMOTE_PORT: config.frp.tunnel.remotePort,
} : {
VOICE_IMAGE: config.image.name,
BASE_IMAGE: `${config.image.base.repository}@${config.image.base.digest}`,
SOURCE_REPOSITORY: config.image.source.repository,
SOURCE_REF: config.image.source.ref,
SOURCE_SUBMODULES: config.image.source.submodules,
MODEL_ID: config.image.modelSource.id,
MODEL_REPOSITORY: config.image.modelSource.repository,
MODEL_BRANCH: config.image.modelSource.branch,
MODEL_REF: config.image.modelSource.ref,
MODEL_REQUIRE_BRANCH_HEAD_MATCH: String(config.image.modelSource.requireBranchHeadMatch),
REFERENCE_VOICE_ID: config.image.referenceVoice.id,
REFERENCE_WAV_PATH: config.image.referenceVoice.path,
REFERENCE_WAV_SHA256: config.image.referenceVoice.sha256,
REFERENCE_PROMPT_TEXT: config.image.referenceVoice.promptText,
PIP_VERSION: config.dependencies.pip.version,
PIP_INDEX_URL: config.dependencies.pip.indexUrl,
SETUPTOOLS_INSTALL_SPEC: config.dependencies.setuptools.installSpec,
TORCH_VERSION_IDENTITY: config.dependencies.torch.versionIdentity,
TORCH_LIBRARY_PATH: config.dependencies.torch.libraryPath,
TORCHAUDIO_VERSION_IDENTITY: config.dependencies.torchaudio.versionIdentity,
OPENAI_WHISPER_INSTALL_SPEC: config.dependencies.openaiWhisper.installSpec,
ONNXRUNTIME_GPU_INSTALL_SPEC: config.dependencies.onnxRuntimeGpu.installSpec,
ONNXRUNTIME_GPU_INDEX_URL: config.dependencies.onnxRuntimeGpu.indexUrl,
VOICE_BIND_ADDRESS: config.runtime.bindAddress,
VOICE_PORT: config.runtime.port,
VOICE_CACHE_DIR: requireAbsolute(target.cacheDir, `targets.${target.id}.cacheDir`),
VOICE_DATA_DIR: requireAbsolute(target.dataDir, `targets.${target.id}.dataDir`),
VOICE_DEVICE: config.runtime.inference.device,
VOICE_FP16: String(config.runtime.inference.fp16),
VOICE_LOAD_TRT: String(config.runtime.inference.loadTrt),
VOICE_LOAD_VLLM: String(config.runtime.inference.loadVllm),
VOICE_REPLICAS: config.runtime.inference.replicas,
VOICE_WORKERS: config.runtime.inference.workers,
VOICE_CONCURRENCY: config.runtime.inference.concurrency,
VOICE_SPEED_MIN: config.runtime.inference.speed.min,
VOICE_SPEED_MAX: config.runtime.inference.speed.max,
PYTORCH_CUDA_ALLOC_CONF: config.runtime.inference.cudaAllocator,
CUDA_MODULE_LOADING: config.runtime.inference.cudaModuleLoading,
FRPC_IMAGE: config.frp.images.frpc,
};
return Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n") + "\n";
}
async function convergeTarget(configRoot: UniDeskConfig, config: VoiceConfig, target: VoiceTarget, files: Record<string, string>) {
const encoded = Object.entries(files).map(([name, value]) => `printf %s ${shQuote(Buffer.from(value).toString("base64"))} | base64 -d > ${shQuote(name)}`).join("\n");
const script = `set -eu\numask 077\nmkdir -p ${shQuote(target.workDir)}\ncd ${shQuote(target.workDir)}\n${encoded}\nchmod 600 secret.env\ndocker compose --project-name ${shQuote(target.composeProject)} --env-file runtime.env -f compose.yaml config >/dev/null\ndocker compose --project-name ${shQuote(target.composeProject)} --env-file runtime.env -f compose.yaml up -d --build\n`;
return await capture(configRoot, target.route, ["sh"], script, { runtimeTimeoutMs: config.runtime.operations.applyTimeoutSeconds * 1000 });
}
function readSecret(config: VoiceConfig): { value: string; summary: Record<string, unknown> } {
const sourceRef = config.frp.secret.sourceRef;
if (!existsSync(sourceRef)) throw new Error(`${sourceRef} is missing; run platform-infra selfmedia-voice secret init --confirm`);
const values = parseEnvFile(readFileSync(sourceRef, "utf8"));
const value = values[config.frp.secret.sourceKey];
if (value === undefined || value.length < 32) throw new Error(`${sourceRef}.${config.frp.secret.sourceKey} must contain at least 32 characters`);
return { value, summary: { sourceRef, sourceKey: config.frp.secret.sourceKey, presence: true, fingerprint: fingerprintValues({ value }, ["value"]), valuesPrinted: false } };
}
function secretSummary(config: VoiceConfig): Record<string, unknown> {
if (!existsSync(config.frp.secret.sourceRef)) return { sourceRef: config.frp.secret.sourceRef, sourceKey: config.frp.secret.sourceKey, presence: false, fingerprint: null, valuesPrinted: false };
try { return readSecret(config).summary; } catch (error) { return { sourceRef: config.frp.secret.sourceRef, sourceKey: config.frp.secret.sourceKey, presence: true, fingerprint: null, error: error instanceof Error ? error.message : String(error), valuesPrinted: false }; }
}
function policyChecks(config: VoiceConfig, bundle: ReturnType<typeof renderBundle>): Array<{ id: string; ok: boolean; detail: string }> {
const rendered = Object.values(bundle.files).join("\n").toLowerCase();
return [
{ id: "no-caddy-domain-k8s-exposure", ok: !/(caddy|ingress|nodeport)/u.test(rendered), detail: "Caddy, domain routing, Ingress and NodePort are forbidden" },
{ id: "locked-source-refs", ok: /^[0-9a-f]{40}$/u.test(config.image.source.ref) && /^[0-9a-f]{40}$/u.test(config.image.modelSource.ref), detail: "CosyVoice and ModelScope refs are immutable commits" },
{ id: "locked-base-image", ok: /^sha256:[0-9a-f]{64}$/u.test(config.image.base.digest) && /^sha256:[0-9a-f]{64}$/u.test(config.image.base.imageId), detail: "base image digest and observed D518 image identity are declared" },
{ id: "modelscope-git-lfs", ok: config.image.modelSource.provider === "modelscope-git-lfs" && config.image.modelSource.requireBranchHeadMatch, detail: "model fetch uses Git/LFS and asserts branch head before locked checkout" },
{ id: "dependency-identity", ok: config.dependencies.torch.versionIdentity === config.dependencies.torchaudio.versionIdentity, detail: `torch/torchaudio ${config.dependencies.torch.versionIdentity}; ABI policy ${config.dependencies.torchaudio.abiPolicy}` },
{ id: "single-gpu-runtime", ok: config.runtime.inference.replicas === 1 && config.runtime.inference.workers === 1 && config.runtime.inference.concurrency === "serial", detail: "one replica, one worker and serialized inference prevent concurrent model copies" },
{ id: "rollout-approved", ok: config.rollout.approved, detail: `${config.rollout.prerequisite}: ${config.rollout.approved ? "approved" : "not approved"}` },
{ id: "host-docker-only", ok: config.targets.every((target) => !target.route.includes("k3s")), detail: "Both targets use host-Docker routes" },
{ id: "loopback-service", ok: config.runtime.bindAddress === "127.0.0.1" && config.frp.tunnel.localAddress === "127.0.0.1", detail: "D518 service is loopback-only behind FRPC" },
{ id: "dedicated-frps", ok: targetByRole(config, "frps").composeProject.includes("selfmedia-voice") && config.frp.server.controlPort === 22110, detail: "SelfMedia Voice owns a dedicated FRPS project and control port" },
];
}
function renderResult(command: string, data: Record<string, unknown>): RenderedCliResult | Record<string, unknown> {
if (process.argv.includes("--json") || process.argv.includes("--full") || process.argv.includes("--raw")) return data;
const lines = [
`PLATFORM-INFRA SELFMEDIA-VOICE ${command.toUpperCase()}`,
`ok=${String(data.ok)} action=${String(data.action ?? "-")} mutation=${String(data.mutation ?? false)} mode=${String(data.mode ?? "-")}`,
`public=${String(data.publicBaseUrl ?? "-")} valuesPrinted=false`,
];
const secret = data.secret as Record<string, unknown> | undefined;
if (secret !== undefined) lines.push(`secret sourceRef=${String(secret.sourceRef)} presence=${String(secret.presence)} fingerprint=${String(secret.fingerprint ?? "-")}`);
const job = data.job as Record<string, unknown> | undefined;
if (job !== undefined) lines.push(`job id=${String(job.id)} status=${String(job.status)} statePath=${String(data.statePath ?? job.stateFile ?? "-")}`);
const next = data.next as Record<string, unknown> | undefined;
if (next !== undefined) lines.push(...Object.entries(next).map(([key, value]) => `next.${key}=${String(value)}`));
return { ok: data.ok === true, command: `platform-infra selfmedia-voice ${command}`, renderedText: `${lines.join("\n")}\n`, contentType: "text/plain", projection: data };
}
function targetByRole(config: VoiceConfig, role: VoiceTarget["role"]): VoiceTarget {
const matches = config.targets.filter((target) => target.role === role);
if (matches.length !== 1) throw new Error(`${configLabel}.targets must contain exactly one ${role} target`);
return matches[0]!;
}
function targetSummary(target: VoiceTarget): Record<string, unknown> {
return { id: target.id, role: target.role, route: target.route, workDir: target.workDir, composeProject: target.composeProject, cacheDir: target.cacheDir ?? null, dataDir: target.dataDir ?? null };
}
function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0) throw new Error(`${configLabel}.${label} must be a non-empty string`);
return value;
}
function requireInteger(value: unknown, label: string): number {
if (!Number.isInteger(value)) throw new Error(`${configLabel}.${label} must be an integer`);
return value as number;
}
function requirePort(value: unknown, label: string): number {
const port = requireInteger(value, label);
if (port < 1 || port > 65535) throw new Error(`${configLabel}.${label} must be in 1..65535`);
return port;
}
function requireAbsolute(value: unknown, label: string): string {
const path = requireString(value, label);
if (!isAbsolute(path)) throw new Error(`${configLabel}.${label} must be an absolute path`);
return path;
}
function requireCommit(value: unknown, label: string): string {
const ref = requireString(value, label);
if (!/^[0-9a-f]{40}$/u.test(ref)) throw new Error(`${configLabel}.${label} must be a 40-character commit`);
return ref;
}
function requireSha256(value: unknown, label: string, prefix = true): string {
const hash = requireString(value, label);
const pattern = prefix ? /^sha256:[0-9a-f]{64}$/u : /^[0-9a-f]{64}$/u;
if (!pattern.test(hash)) throw new Error(`${configLabel}.${label} must be a SHA-256 identity`);
return hash;
}
+7
View File
@@ -399,6 +399,7 @@ export function platformInfraHelp(): unknown {
"nginx",
"langbot",
"n8n",
"selfmedia-voice",
"webterm",
"wechat-archive",
"observability",
@@ -449,6 +450,12 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra n8n status [--target G14] [--full|--raw]",
"bun scripts/cli.ts platform-infra n8n logs [--target G14] [--component app|frpc|all]",
"bun scripts/cli.ts platform-infra n8n validate [--target G14] [--full|--raw]",
"bun scripts/cli.ts platform-infra selfmedia-voice plan",
"bun scripts/cli.ts platform-infra selfmedia-voice secret init --confirm",
"bun scripts/cli.ts platform-infra selfmedia-voice apply --confirm",
"bun scripts/cli.ts platform-infra selfmedia-voice status",
"bun scripts/cli.ts platform-infra selfmedia-voice logs [--component voice|frpc|frps|all]",
"bun scripts/cli.ts platform-infra selfmedia-voice validate",
"bun scripts/cli.ts platform-infra webterm plan",
"bun scripts/cli.ts platform-infra webterm apply --confirm",
"bun scripts/cli.ts platform-infra webterm status",
+4
View File
@@ -58,6 +58,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin
const { runN8nCommand } = await import("../platform-infra-n8n");
return await runN8nCommand(config, args.slice(1));
}
if (target === "selfmedia-voice") {
const { runSelfMediaVoiceCommand } = await import("../platform-infra-selfmedia-voice");
return await runSelfMediaVoiceCommand(config, args.slice(1));
}
if (target === "webterm") {
const { runWebtermCommand } = await import("../platform-infra-webterm");
return await runWebtermCommand(config, args.slice(1));