fix: 建立 SelfMedia Pod 语音 bridge

This commit is contained in:
Codex
2026-07-15 17:18:48 +02:00
parent a532dbd751
commit 10f769b6d3
5 changed files with 186 additions and 4 deletions
+18 -1
View File
@@ -6,7 +6,8 @@ metadata:
owner: unidesk
relatedIssues:
- 2139
mdtodo: R9.4
- 2216
mdtodo: R9.5.2
rollout:
prerequisite: R9.1.1 CosyVoice3 GPU HTTP/WAV smoke
@@ -92,6 +93,22 @@ frp:
localAddress: 127.0.0.1
localPort: 18327
bridge:
targetId: NC01
mode: systemd-socket-proxyd
listenAddress: 10.42.0.1
listenPort: 24318
upstreamAddress: 127.0.0.1
upstreamPort: 24317
baseUrl: http://10.42.0.1:24318
socketProxydPath: /usr/lib/systemd/systemd-socket-proxyd
socketUnitName: unidesk-selfmedia-voice-bridge.socket
socketUnitPath: /etc/systemd/system/unidesk-selfmedia-voice-bridge.socket
serviceUnitName: unidesk-selfmedia-voice-bridge.service
serviceUnitPath: /etc/systemd/system/unidesk-selfmedia-voice-bridge.service
probePath: /v1/models
timeoutSeconds: 10
targets:
- id: D518
role: gpu-service
+2 -2
View File
@@ -96,7 +96,7 @@ delivery:
serviceAccountName: selfmedia
imagePullPolicy: IfNotPresent
environment:
SELFMEDIA_TTS_BASE_URL: http://10.42.0.1:24317
SELFMEDIA_TTS_BASE_URL: http://10.42.0.1:24318
command:
- /usr/bin/tini
- --
@@ -239,7 +239,7 @@ delivery:
allowedTcpEndpoints:
- cidr: 10.42.0.1/32
ports:
- 24317
- 24318
codex:
package: "@openai/codex"
version: 0.144.1
@@ -0,0 +1,22 @@
# R9.5.2 任务报告
## 结果
NC01 的 `24317` 由 Docker proxy 监听。host 访问该端口有 HTTP 响应,但 `selfmedia` namespace 现有 Pod 访问 `10.42.0.1:24317` 明确连接失败,确认问题位于 Pod 到 host Docker published port 的 hairpin 路径。
`config/platform-infra/selfmedia-voice.yaml` 新增 `systemd-socket-proxyd` bridge 声明:监听 `10.42.0.1:24318`,转发至 `127.0.0.1:24317``platform-infra selfmedia-voice bridge plan|apply|status|validate` 负责渲染、安装和核对 systemd socket/service unit,不新增 Caddy、Ingress、NodePort 或第二配置来源。
`config/selfmedia.yaml` 只修改开发目标:`SELFMEDIA_TTS_BASE_URL``networkPolicy.allowedTcpEndpoints` 切到 `10.42.0.1:24318`;生产目标继续保持空 endpoint 和空允许列表。
## 验证
- Target worktree`NC01:/root/unidesk/.worktree/selfmedia-voice-bridge`
- `bun --check scripts/src/platform-infra-selfmedia-voice.ts` 通过。
- `bun test scripts/src/selfmedia-config.test.ts` 通过,结果为 `1 pass, 0 fail`
- bridge plan 渲染 socket/service unitapply 后 socket 为 `active/enabled`unit 内容匹配,host probe `http://10.42.0.1:24318/v1/models` 返回 HTTP `404`,证明请求已到达 upstream 而非连接拒绝。
-`selfmedia` namespace 创建带唯一标签的短生命周期 smoke Pod 和只允许 `10.42.0.1/32:24318` 的临时 NetworkPolicy,请求 `/v1/models` 返回 HTTP `404``connectionRefused=false`;随后 Pod 与临时 NetworkPolicy 均已删除。
- 配置解析确认开发 endpoint/egress 为 `24318`,生产 endpoint 仍为空且允许列表仍为空。
## 边界
未修改现有 Deployment/Pod,未修改生产 SelfMedia endpoint,未打印 Secret,未人工创建 PipelineRun、mirror sync 或 Argo sync。bridge 已通过当前分支受控 CLI 部署到 NC01PR 合并前,Git source authority 尚未进入 `master`,因此运行面与 source 的最终收敛依赖本任务 PR 正常合并。
@@ -126,3 +126,13 @@
### R9.5 [in_progress]
将 D518 CosyVoice3 公网端点以 YAML-first 接入 SELFMEDIA 开发集群,吸收远程语音适配、精确 egress 与 PaC target 分组改动,只滚动 master/4317,并实际执行一期 voice step 完成 WAV 与时间轴 smoke,生产 release/4319 保持不变,完成任务后将详细报告写入任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.5_Task_Report.md)。
#### R9.5.2 [completed]
依据 [UniDesk #2216](https://github.com/pikasTech/unidesk/issues/2216) 为 SelfMedia 开发 Pod 建立 NC01 host-native 语音 bridge
- `config/platform-infra/selfmedia-voice.yaml` 声明 Pod 可达监听地址、独立端口、loopback upstream、systemd unit 和 probe
- 受控 CLI 提供 bridge plan、apply、status、validate 单步;
- 开发 `SELFMEDIA_TTS_BASE_URL` 与精确 NetworkPolicy egress 切到 bridge,生产 endpoint 保持空;
- 不 patch Deployment/Pod,不人工触发 PipelineRun、mirror 或 Argo
- 完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.5.2_Task_Report.md)。
+134 -1
View File
@@ -21,6 +21,23 @@ interface VoiceTarget {
sourceDir?: string;
}
interface VoiceBridge {
targetId: string;
mode: "systemd-socket-proxyd";
listenAddress: string;
listenPort: number;
upstreamAddress: string;
upstreamPort: number;
baseUrl: string;
socketProxydPath: string;
socketUnitName: string;
socketUnitPath: string;
serviceUnitName: string;
serviceUnitPath: string;
probePath: string;
timeoutSeconds: number;
}
interface VoiceConfig {
version: number;
kind: "platform-infra-selfmedia-voice";
@@ -48,6 +65,7 @@ interface VoiceConfig {
server: { targetId: string; address: string; controlPort: number; workDir: string; composeProject: string };
tunnel: { targetId: string; proxyName: string; remotePort: number; localAddress: string; localPort: number };
};
bridge: VoiceBridge;
targets: VoiceTarget[];
runtime: {
serviceName: string;
@@ -82,6 +100,10 @@ interface LogsOptions extends CommonOptions { component: "all" | "voice" | "frpc
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 === "bridge" && (subaction === undefined || subaction === "plan")) return renderResult("bridge plan", bridgePlan(parseCommon(args.slice(subaction === undefined ? 1 : 2))));
if (action === "bridge" && subaction === "apply") return renderResult("bridge apply", await bridgeApply(config, parseApply(args.slice(2))));
if (action === "bridge" && subaction === "status") return renderResult("bridge status", await bridgeStatus(config, parseCommon(args.slice(2))));
if (action === "bridge" && subaction === "validate") return renderResult("bridge validate", await bridgeValidate(config, parseCommon(args.slice(2))));
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))));
@@ -97,6 +119,10 @@ function help(): Record<string, unknown> {
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 bridge plan [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra selfmedia-voice bridge apply --confirm",
"bun scripts/cli.ts platform-infra selfmedia-voice bridge status [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra selfmedia-voice bridge validate [--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]",
@@ -152,7 +178,7 @@ function parseLogs(args: string[]): LogsOptions {
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`);
if (config.metadata.mdtodo !== "R9.5.2") throw new Error(`${configLabel}.metadata.mdtodo must be R9.5.2`);
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");
@@ -187,6 +213,27 @@ function readConfig(): VoiceConfig {
requirePort(config.frp.server.controlPort, "frp.server.controlPort");
requirePort(config.frp.tunnel.remotePort, "frp.tunnel.remotePort");
requirePort(config.frp.tunnel.localPort, "frp.tunnel.localPort");
if (config.bridge.mode !== "systemd-socket-proxyd") throw new Error(`${configLabel}.bridge.mode must be systemd-socket-proxyd`);
requireString(config.bridge.targetId, "bridge.targetId");
requireString(config.bridge.listenAddress, "bridge.listenAddress");
requirePort(config.bridge.listenPort, "bridge.listenPort");
requireString(config.bridge.upstreamAddress, "bridge.upstreamAddress");
requirePort(config.bridge.upstreamPort, "bridge.upstreamPort");
requireAbsolute(config.bridge.socketProxydPath, "bridge.socketProxydPath");
requireAbsolute(config.bridge.socketUnitPath, "bridge.socketUnitPath");
requireAbsolute(config.bridge.serviceUnitPath, "bridge.serviceUnitPath");
requireString(config.bridge.socketUnitName, "bridge.socketUnitName");
requireString(config.bridge.serviceUnitName, "bridge.serviceUnitName");
requireString(config.bridge.probePath, "bridge.probePath");
requireInteger(config.bridge.timeoutSeconds, "bridge.timeoutSeconds");
if (config.bridge.timeoutSeconds < 1) throw new Error(`${configLabel}.bridge.timeoutSeconds must be positive`);
if (config.bridge.listenAddress === "0.0.0.0" || config.bridge.listenAddress === "127.0.0.1") throw new Error(`${configLabel}.bridge.listenAddress must be a pod-reachable host address`);
if (config.bridge.upstreamAddress !== "127.0.0.1") throw new Error(`${configLabel}.bridge.upstreamAddress must stay loopback-only`);
if (config.bridge.listenPort === config.bridge.upstreamPort) throw new Error(`${configLabel}.bridge.listenPort must differ from bridge.upstreamPort`);
if (config.bridge.baseUrl !== `http://${config.bridge.listenAddress}:${config.bridge.listenPort}`) throw new Error(`${configLabel}.bridge.baseUrl must match bridge listen address and port`);
if (config.bridge.socketUnitPath !== `/etc/systemd/system/${config.bridge.socketUnitName}`) throw new Error(`${configLabel}.bridge.socketUnitPath must match bridge.socketUnitName`);
if (config.bridge.serviceUnitPath !== `/etc/systemd/system/${config.bridge.serviceUnitName}`) throw new Error(`${configLabel}.bridge.serviceUnitPath must match bridge.serviceUnitName`);
if (config.bridge.socketUnitName.replace(/\.socket$/u, "") !== config.bridge.serviceUnitName.replace(/\.service$/u, "")) throw new Error(`${configLabel}.bridge socket and service units must share a basename`);
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`);
@@ -196,6 +243,7 @@ function readConfig(): VoiceConfig {
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`);
if (!config.targets.some((target) => target.id === config.bridge.targetId)) throw new Error(`${configLabel}.bridge.targetId must reference a declared target`);
for (const target of config.targets) {
requireString(target.route, `targets.${target.id}.route`);
requireAbsolute(target.workDir, `targets.${target.id}.workDir`);
@@ -227,6 +275,7 @@ function plan(options: CommonOptions): Record<string, unknown> {
dependencies: config.dependencies,
ports: { frpsControl: config.frp.server.controlPort, remote: config.frp.tunnel.remotePort, d518Loopback: config.runtime.port },
publicBaseUrl: config.runtime.publicBaseUrl,
bridge: bridgeSummary(config),
lowMemory: config.runtime.inference,
secret,
artifacts: bundle.summary,
@@ -236,12 +285,92 @@ function plan(options: CommonOptions): Record<string, unknown> {
next: {
secretInit: "bun scripts/cli.ts platform-infra selfmedia-voice secret init --confirm",
apply: "bun scripts/cli.ts platform-infra selfmedia-voice apply --confirm",
bridgeApply: "bun scripts/cli.ts platform-infra selfmedia-voice bridge apply --confirm",
bridgeValidate: "bun scripts/cli.ts platform-infra selfmedia-voice bridge validate",
status: "bun scripts/cli.ts platform-infra selfmedia-voice status",
validate: "bun scripts/cli.ts platform-infra selfmedia-voice validate",
},
};
}
function bridgePlan(options: CommonOptions): Record<string, unknown> {
const config = readConfig();
const units = renderBridgeUnits(config);
return {
ok: true,
action: "platform-infra-selfmedia-voice-bridge-plan",
mutation: false,
bridge: bridgeSummary(config),
artifacts: Object.entries(units).map(([name, value]) => ({ name, bytes: Buffer.byteLength(value), fingerprint: fingerprintValues({ value }, ["value"]) })),
...(options.full || options.raw ? { rendered: units } : {}),
next: {
apply: "bun scripts/cli.ts platform-infra selfmedia-voice bridge apply --confirm",
status: "bun scripts/cli.ts platform-infra selfmedia-voice bridge status",
validate: "bun scripts/cli.ts platform-infra selfmedia-voice bridge validate",
},
};
}
async function bridgeApply(configRoot: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
const config = readConfig();
if (!options.confirm) return { ok: false, action: "platform-infra-selfmedia-voice-bridge-apply", mutation: false, mode: "missing-confirm", error: "bridge apply requires --confirm" };
const result = await convergeBridge(configRoot, config);
return { ok: result.exitCode === 0, action: "platform-infra-selfmedia-voice-bridge-apply", mutation: true, mode: "completed", bridge: bridgeSummary(config), result: compactCapture(result, { full: true }), valuesPrinted: false };
}
async function bridgeStatus(configRoot: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
const config = readConfig();
const result = await capture(configRoot, bridgeTarget(config).route, ["sh"], bridgeStatusScript(config), { runtimeTimeoutMs: (config.bridge.timeoutSeconds + 5) * 1000 });
return { ok: result.exitCode === 0, action: "platform-infra-selfmedia-voice-bridge-status", mutation: false, bridge: bridgeSummary(config), result: compactCapture(result, { full: true }), valuesPrinted: false };
}
async function bridgeValidate(configRoot: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
const status = await bridgeStatus(configRoot, options);
return { ...status, action: "platform-infra-selfmedia-voice-bridge-validate" };
}
function renderBridgeUnits(config: VoiceConfig): { socket: string; service: string } {
const bridge = config.bridge;
return {
socket: `[Unit]\nDescription=UniDesk SelfMedia Voice Pod bridge socket\nAfter=network-online.target\nWants=network-online.target\n\n[Socket]\nListenStream=${bridge.listenAddress}:${bridge.listenPort}\nNoDelay=true\n\n[Install]\nWantedBy=sockets.target\n`,
service: `[Unit]\nDescription=UniDesk SelfMedia Voice Pod bridge service\nAfter=network-online.target docker.service\nWants=network-online.target\n\n[Service]\nExecStart=${bridge.socketProxydPath} ${bridge.upstreamAddress}:${bridge.upstreamPort}\nNoNewPrivileges=true\nPrivateTmp=true\nProtectHome=true\nProtectSystem=strict\n`,
};
}
async function convergeBridge(configRoot: UniDeskConfig, config: VoiceConfig) {
const units = renderBridgeUnits(config);
const bridge = config.bridge;
const script = `set -eu\ntmp=$(mktemp -d)\ntrap 'rm -rf "$tmp"' EXIT\nprintf %s ${shQuote(Buffer.from(units.socket).toString("base64"))} | base64 -d >"$tmp/socket"\nprintf %s ${shQuote(Buffer.from(units.service).toString("base64"))} | base64 -d >"$tmp/service"\ninstall -m 0644 "$tmp/socket" ${shQuote(bridge.socketUnitPath)}\ninstall -m 0644 "$tmp/service" ${shQuote(bridge.serviceUnitPath)}\nsystemctl daemon-reload\nsystemctl enable --now ${shQuote(bridge.socketUnitName)}\nsystemctl restart ${shQuote(bridge.socketUnitName)}\n${bridgeStatusScript(config)}\n`;
return await capture(configRoot, bridgeTarget(config).route, ["sh"], script, { runtimeTimeoutMs: (bridge.timeoutSeconds + 15) * 1000 });
}
function bridgeStatusScript(config: VoiceConfig): string {
const units = renderBridgeUnits(config);
const bridge = config.bridge;
const probeUrl = `${bridge.baseUrl}${bridge.probePath}`;
return `set +e\ntmp=$(mktemp -d)\ntrap 'rm -rf "$tmp"' EXIT\nprintf %s ${shQuote(Buffer.from(units.socket).toString("base64"))} | base64 -d >"$tmp/socket"\nprintf %s ${shQuote(Buffer.from(units.service).toString("base64"))} | base64 -d >"$tmp/service"\nsocket_active=$(systemctl is-active ${shQuote(bridge.socketUnitName)} 2>/dev/null)\nsocket_enabled=$(systemctl is-enabled ${shQuote(bridge.socketUnitName)} 2>/dev/null)\nservice_active=$(systemctl is-active ${shQuote(bridge.serviceUnitName)} 2>/dev/null)\nsocket_matches=false\nservice_matches=false\ncmp -s "$tmp/socket" ${shQuote(bridge.socketUnitPath)} && socket_matches=true\ncmp -s "$tmp/service" ${shQuote(bridge.serviceUnitPath)} && service_matches=true\nhttp_code=$(curl --silent --show-error --max-time ${bridge.timeoutSeconds} -o /dev/null -w '%{http_code}' ${shQuote(probeUrl)})\nprobe_rc=$?\nprintf '{"socketActive":"%s","socketEnabled":"%s","serviceActive":"%s","socketMatches":%s,"serviceMatches":%s,"probe":{"url":"%s","httpCode":"%s","exitCode":%s}}\\n' "$socket_active" "$socket_enabled" "$service_active" "$socket_matches" "$service_matches" ${shQuote(probeUrl)} "$http_code" "$probe_rc"\n[ "$socket_active" = active ] && [ "$socket_enabled" = enabled ] && [ "$socket_matches" = true ] && [ "$service_matches" = true ] && [ "$probe_rc" -eq 0 ]\n`;
}
function bridgeSummary(config: VoiceConfig): Record<string, unknown> {
const bridge = config.bridge;
return {
target: bridgeTarget(config).id,
mode: bridge.mode,
listen: `${bridge.listenAddress}:${bridge.listenPort}`,
upstream: `${bridge.upstreamAddress}:${bridge.upstreamPort}`,
baseUrl: bridge.baseUrl,
socketUnit: { name: bridge.socketUnitName, path: bridge.socketUnitPath },
serviceUnit: { name: bridge.serviceUnitName, path: bridge.serviceUnitPath },
probePath: bridge.probePath,
};
}
function bridgeTarget(config: VoiceConfig): VoiceTarget {
const matches = config.targets.filter((target) => target.id === config.bridge.targetId);
if (matches.length !== 1) throw new Error(`${configLabel}.bridge.targetId must reference exactly one target`);
return matches[0]!;
}
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) };
@@ -447,6 +576,10 @@ function renderResult(command: string, data: Record<string, unknown>): RenderedC
];
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 bridge = data.bridge as Record<string, unknown> | undefined;
if (bridge !== undefined) lines.push(`bridge listen=${String(bridge.listen ?? "-")} upstream=${String(bridge.upstream ?? "-")} baseUrl=${String(bridge.baseUrl ?? "-")}`);
const result = data.result as Record<string, unknown> | undefined;
if (result !== undefined) lines.push(`result exitCode=${String(result.exitCode ?? "-")} stdoutTail=${String(result.stdoutTail ?? "-").trim()}`);
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;