refactor: split control-plane cli modules
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. local-codex module for scripts/src/platform-infra-sub2api-codex.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra-sub2api-codex.ts:3422-3685 for #903.
|
||||
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UniDeskConfig } from "../config";
|
||||
import { rootPath } from "../config";
|
||||
import type { RenderedCliResult } from "../output";
|
||||
import { applyPk01CaddyBlock, prepareFrpcSecret, renderFrpcManifest, type PublicServiceExposure, type PublicServiceTarget } from "../platform-infra-public-service";
|
||||
import { shortSha256Fingerprint } from "../platform-infra-ops-library";
|
||||
import {
|
||||
codexPoolSentinelSummary,
|
||||
codexPoolSentinelRuntimeImage,
|
||||
readCodexPoolSentinelConfig,
|
||||
renderCodexPoolSentinelManifest,
|
||||
type CodexPoolSentinelConfig,
|
||||
type CodexPoolSentinelProfileSecret,
|
||||
} from "../platform-infra-sub2api-codex-sentinel";
|
||||
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
|
||||
|
||||
import type { CodexLocalConsumerTomlOptions, CodexPoolConfig } from "./types";
|
||||
import { fingerprint, isRecord } from "./config-utils";
|
||||
import { capture, parseJsonOutput } from "./remote";
|
||||
import { codexPoolRuntimeTarget } from "./runtime-target";
|
||||
import { sub2apiConfigPath } from "./types";
|
||||
|
||||
export async function fetchPoolApiKey(config: UniDeskConfig, pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): Promise<{ apiKey: string | null; error: string | null }> {
|
||||
const result = await capture(config, target.route, ["sh"], `
|
||||
set -u
|
||||
kubectl -n ${target.namespace} get secret ${pool.apiKeySecretName} -o json
|
||||
`);
|
||||
if (result.exitCode !== 0) {
|
||||
return { apiKey: null, error: `read pool API key secret failed: ${result.stderr.slice(-1000)}` };
|
||||
}
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const data = isRecord(parsed?.data) ? parsed.data : null;
|
||||
const encoded = typeof data?.[pool.apiKeySecretKey] === "string" ? data[pool.apiKeySecretKey] : null;
|
||||
if (encoded === null) return { apiKey: null, error: `${target.namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey} missing` };
|
||||
try {
|
||||
const apiKey = Buffer.from(encoded, "base64").toString("utf8");
|
||||
return apiKey.length > 0 ? { apiKey, error: null } : { apiKey: null, error: "decoded API key is empty" };
|
||||
} catch (error) {
|
||||
return { apiKey: null, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget()): Record<string, unknown> {
|
||||
const codexDir = join(homedir(), ".codex");
|
||||
mkdirSync(codexDir, { recursive: true, mode: 0o700 });
|
||||
const configPath = join(codexDir, "config.toml");
|
||||
const authPath = join(codexDir, "auth.json");
|
||||
const backupConfigPath = join(codexDir, `config.toml.${pool.localCodex.backupSuffix}`);
|
||||
const backupAuthPath = join(codexDir, `auth.json.${pool.localCodex.backupSuffix}`);
|
||||
if (!existsSync(configPath)) throw new Error(`${configPath} missing`);
|
||||
if (!existsSync(authPath)) throw new Error(`${authPath} missing`);
|
||||
const configBackupAction = copyIfMissing(configPath, backupConfigPath);
|
||||
const authBackupAction = copyIfMissing(authPath, backupAuthPath);
|
||||
const currentToml = readFileSync(configPath, "utf8");
|
||||
const nextToml = updateCodexConfigToml(currentToml, pool, target);
|
||||
writeFileSync(configPath, nextToml, { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(configPath, 0o600);
|
||||
writeFileSync(authPath, `${JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(authPath, 0o600);
|
||||
return {
|
||||
ok: true,
|
||||
codexDir,
|
||||
config: {
|
||||
path: configPath,
|
||||
bytes: statSync(configPath).size,
|
||||
backupPath: backupConfigPath,
|
||||
backupAction: configBackupAction,
|
||||
backupBytes: statSync(backupConfigPath).size,
|
||||
},
|
||||
auth: {
|
||||
path: authPath,
|
||||
bytes: statSync(authPath).size,
|
||||
backupPath: backupAuthPath,
|
||||
backupAction: authBackupAction,
|
||||
backupBytes: statSync(backupAuthPath).size,
|
||||
openaiApiKeyShape: "string",
|
||||
openaiApiKeyBytes: Buffer.byteLength(apiKey, "utf8"),
|
||||
openaiApiKeyFingerprint: fingerprint(apiKey),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
provider: {
|
||||
name: pool.localCodex.providerName,
|
||||
baseUrl: codexConsumerBaseUrl(pool, target),
|
||||
wireApi: pool.localCodex.wireApi,
|
||||
modelContextWindow: pool.localCodex.modelContextWindow,
|
||||
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
|
||||
supportsWebSockets: pool.localCodex.supportsWebSockets,
|
||||
responsesWebSocketsV2: pool.localCodex.responsesWebSocketsV2,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function copyIfMissing(source: string, target: string): "created" | "kept-existing" {
|
||||
if (existsSync(target)) return "kept-existing";
|
||||
copyFileSync(source, target);
|
||||
chmodSync(target, 0o600);
|
||||
return "created";
|
||||
}
|
||||
|
||||
export function updateCodexConfigToml(current: string, pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): string {
|
||||
return renderCodexLocalConsumerToml(current, {
|
||||
providerName: pool.localCodex.providerName,
|
||||
baseUrl: codexConsumerBaseUrl(pool, target),
|
||||
wireApi: pool.localCodex.wireApi,
|
||||
modelContextWindow: pool.localCodex.modelContextWindow,
|
||||
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
|
||||
supportsWebSockets: pool.localCodex.supportsWebSockets,
|
||||
responsesWebSocketsV2: pool.localCodex.responsesWebSocketsV2,
|
||||
});
|
||||
}
|
||||
|
||||
export function codexConsumerBaseUrl(pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): string {
|
||||
void pool;
|
||||
const baseUrl = target.publicBaseUrl;
|
||||
if (baseUrl === null) throw new Error(`${sub2apiConfigPath}.targets[${target.id}].publicExposure.enabled must be true before resolving the Codex consumer base URL`);
|
||||
return `${baseUrl.replace(/\/+$/u, "")}/`;
|
||||
}
|
||||
|
||||
export function renderCodexLocalConsumerToml(current: string, options: CodexLocalConsumerTomlOptions): string {
|
||||
let next = upsertTopLevelTomlString(current, "model_provider", options.providerName);
|
||||
next = upsertTopLevelTomlInteger(next, "model_context_window", options.modelContextWindow);
|
||||
next = upsertTopLevelTomlInteger(next, "model_auto_compact_token_limit", options.modelAutoCompactTokenLimit);
|
||||
next = upsertProviderSection(next, options);
|
||||
next = upsertTomlSectionBoolean(next, "features", "responses_websockets_v2", options.responsesWebSocketsV2);
|
||||
return next.endsWith("\n") ? next : `${next}\n`;
|
||||
}
|
||||
|
||||
export function upsertTopLevelTomlString(text: string, key: string, value: string): string {
|
||||
const lines = text.split(/\r?\n/u);
|
||||
const firstSection = lines.findIndex((line) => /^\s*\[/.test(line));
|
||||
const end = firstSection === -1 ? lines.length : firstSection;
|
||||
for (let index = 0; index < end; index += 1) {
|
||||
if (new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(lines[index])) {
|
||||
lines[index] = `${key} = ${tomlString(value)}`;
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
lines.splice(0, 0, `${key} = ${tomlString(value)}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function upsertTopLevelTomlInteger(text: string, key: string, value: number): string {
|
||||
const lines = text.split(/\r?\n/u);
|
||||
const firstSection = lines.findIndex((line) => /^\s*\[/.test(line));
|
||||
const end = firstSection === -1 ? lines.length : firstSection;
|
||||
const assignment = `${key} = ${value}`;
|
||||
for (let index = 0; index < end; index += 1) {
|
||||
if (new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(lines[index])) {
|
||||
lines[index] = assignment;
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
lines.splice(end, 0, assignment);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function upsertProviderSection(text: string, options: CodexLocalConsumerTomlOptions): string {
|
||||
const { providerName, baseUrl, wireApi, supportsWebSockets } = options;
|
||||
const header = `[model_providers.${providerName}]`;
|
||||
const lines = text.split(/\r?\n/u);
|
||||
const start = lines.findIndex((line) => line.trim() === header);
|
||||
const canonical = [
|
||||
`name = ${tomlString(providerName)}`,
|
||||
`base_url = ${tomlString(baseUrl)}`,
|
||||
`wire_api = ${tomlString(wireApi)}`,
|
||||
"requires_openai_auth = true",
|
||||
`supports_websockets = ${tomlBoolean(supportsWebSockets)}`,
|
||||
];
|
||||
if (start === -1) {
|
||||
const needsBlank = lines.length > 0 && lines[lines.length - 1].trim() !== "";
|
||||
return [...lines, ...(needsBlank ? [""] : []), header, ...canonical].join("\n");
|
||||
}
|
||||
let end = lines.length;
|
||||
for (let index = start + 1; index < lines.length; index += 1) {
|
||||
if (/^\s*\[/.test(lines[index])) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const preserved = lines.slice(start + 1, end).filter((line) => {
|
||||
return !/^\s*(name|base_url|wire_api|requires_openai_auth|supports_websockets|env_key)\s*=/u.test(line);
|
||||
});
|
||||
const replacement = [header, ...canonical, ...preserved.filter((line) => line.trim() !== "")];
|
||||
return [...lines.slice(0, start), ...replacement, ...lines.slice(end)].join("\n");
|
||||
}
|
||||
|
||||
export function upsertTomlSectionBoolean(text: string, sectionName: string, key: string, value: boolean): string {
|
||||
const header = `[${sectionName}]`;
|
||||
const lines = text.split(/\r?\n/u);
|
||||
const start = lines.findIndex((line) => line.trim() === header);
|
||||
const assignment = `${key} = ${tomlBoolean(value)}`;
|
||||
if (start === -1) {
|
||||
const needsBlank = lines.length > 0 && lines[lines.length - 1].trim() !== "";
|
||||
return [...lines, ...(needsBlank ? [""] : []), header, assignment].join("\n");
|
||||
}
|
||||
let end = lines.length;
|
||||
for (let index = start + 1; index < lines.length; index += 1) {
|
||||
if (/^\s*\[/.test(lines[index])) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let index = start + 1; index < end; index += 1) {
|
||||
if (new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(lines[index])) {
|
||||
lines[index] = assignment;
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
return [...lines.slice(0, start + 1), assignment, ...lines.slice(start + 1)].join("\n");
|
||||
}
|
||||
|
||||
export async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget()): Promise<Record<string, unknown>> {
|
||||
const probe = await probePublicModels(pool, "with-api-key", apiKey, target);
|
||||
return {
|
||||
...probe,
|
||||
ok: probe.ok === true,
|
||||
apiKeyFingerprint: fingerprint(apiKey),
|
||||
keyPreview: apiKeyPreview(apiKey),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, target = codexPoolRuntimeTarget()): Promise<Record<string, unknown>> {
|
||||
void pool;
|
||||
if (target.publicBaseUrl === null) throw new Error(`${sub2apiConfigPath}.targets[${target.id}].publicExposure.enabled must be true before probing the public gateway`);
|
||||
const baseUrl = target.publicBaseUrl;
|
||||
const url = `${baseUrl.replace(/\/+$/u, "")}/v1/models`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const body = await response.text();
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = body.trim().length > 0 ? JSON.parse(body) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
const modelCount = isRecord(parsed) && Array.isArray(parsed.data) ? parsed.data.length : null;
|
||||
return {
|
||||
ok: response.ok,
|
||||
mode,
|
||||
method: "GET /v1/models",
|
||||
baseUrl,
|
||||
httpStatus: response.status,
|
||||
modelCount,
|
||||
bodyPreview: response.ok ? "" : body.slice(0, 500),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
mode,
|
||||
method: "GET /v1/models",
|
||||
baseUrl,
|
||||
httpStatus: 0,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function apiKeyPreview(apiKey: string): string {
|
||||
if (apiKey.length <= 14) return "***";
|
||||
return `${apiKey.slice(0, 10)}...${apiKey.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function tomlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function tomlBoolean(value: boolean): string {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
export function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user