|
|
|
@@ -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;
|
|
|
|
|