diff --git a/config/platform-infra/selfmedia-voice.yaml b/config/platform-infra/selfmedia-voice.yaml index 322204c0..08dc5176 100644 --- a/config/platform-infra/selfmedia-voice.yaml +++ b/config/platform-infra/selfmedia-voice.yaml @@ -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 diff --git a/config/selfmedia.yaml b/config/selfmedia.yaml index c0ca9dfe..82018f3b 100644 --- a/config/selfmedia.yaml +++ b/config/selfmedia.yaml @@ -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 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R9.5.2_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R9.5.2_Task_Report.md new file mode 100644 index 00000000..20905ca7 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R9.5.2_Task_Report.md @@ -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 unit,apply 后 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 部署到 NC01;PR 合并前,Git source authority 尚未进入 `master`,因此运行面与 source 的最终收敛依赖本任务 PR 正常合并。 diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index d1c31652..5b6ade8e 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -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)。 diff --git a/scripts/src/platform-infra-selfmedia-voice.ts b/scripts/src/platform-infra-selfmedia-voice.ts index 6998fc0e..6a281a40 100644 --- a/scripts/src/platform-infra-selfmedia-voice.ts +++ b/scripts/src/platform-infra-selfmedia-voice.ts @@ -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 | 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 { 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(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 { 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 { 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 { + 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> { + 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> { + 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> { + 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 { + 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 { 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): RenderedC ]; const secret = data.secret as Record | undefined; if (secret !== undefined) lines.push(`secret sourceRef=${String(secret.sourceRef)} presence=${String(secret.presence)} fingerprint=${String(secret.fingerprint ?? "-")}`); + const bridge = data.bridge as Record | 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 | undefined; + if (result !== undefined) lines.push(`result exitCode=${String(result.exitCode ?? "-")} stdoutTail=${String(result.stdoutTail ?? "-").trim()}`); const job = data.job as Record | undefined; if (job !== undefined) lines.push(`job id=${String(job.id)} status=${String(job.status)} statePath=${String(data.statePath ?? job.stateFile ?? "-")}`); const next = data.next as Record | undefined;