feat: add platform infra codex pool config
This commit is contained in:
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { rootPath } from "./config";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
|
||||
const g14K3sRoute = "G14:k3s";
|
||||
@@ -11,10 +12,12 @@ const serviceName = "sub2api";
|
||||
const serviceDns = `${serviceName}.${namespace}.svc.cluster.local:8080`;
|
||||
const fieldManager = "unidesk-platform-infra";
|
||||
const appSecretName = "sub2api-secrets";
|
||||
const poolGroupName = "unidesk-codex-pool";
|
||||
const poolApiKeyName = "unidesk-codex-pool-api-key";
|
||||
const poolApiKeySecretName = "sub2api-codex-pool-api-key";
|
||||
const poolApiKeySecretKey = "API_KEY";
|
||||
const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml");
|
||||
const defaultPoolGroupName = "unidesk-codex-pool";
|
||||
const defaultPoolApiKeyName = "unidesk-codex-pool-api-key";
|
||||
const defaultPoolApiKeySecretName = "sub2api-codex-pool-api-key";
|
||||
const defaultPoolApiKeySecretKey = "API_KEY";
|
||||
const defaultMinOwnerBalanceUsd = 1000;
|
||||
|
||||
interface DisclosureOptions {
|
||||
full: boolean;
|
||||
@@ -42,7 +45,16 @@ interface CodexProfile {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface CodexPoolConfig {
|
||||
groupName: string;
|
||||
apiKeyName: string;
|
||||
apiKeySecretName: string;
|
||||
apiKeySecretKey: string;
|
||||
minOwnerBalanceUsd: number;
|
||||
}
|
||||
|
||||
export function codexPoolHelp(): unknown {
|
||||
const pool = readCodexPoolConfig();
|
||||
return {
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate",
|
||||
output: "json",
|
||||
@@ -56,9 +68,10 @@ export function codexPoolHelp(): unknown {
|
||||
route: g14K3sRoute,
|
||||
namespace,
|
||||
serviceDns,
|
||||
poolGroupName,
|
||||
poolApiKeySecretName,
|
||||
poolApiKeySecretKey,
|
||||
configPath: codexPoolConfigPath,
|
||||
poolGroupName: pool.groupName,
|
||||
poolApiKeySecretName: pool.apiKeySecretName,
|
||||
poolApiKeySecretKey: pool.apiKeySecretKey,
|
||||
secretValuesPrinted: false,
|
||||
},
|
||||
};
|
||||
@@ -97,6 +110,7 @@ function validateOptions(args: string[], booleanOptions: Set<string>): void {
|
||||
}
|
||||
|
||||
function codexPoolPlan(): Record<string, unknown> {
|
||||
const pool = readCodexPoolConfig();
|
||||
const profiles = collectCodexProfiles();
|
||||
const ok = profiles.length > 0 && profiles.every((profile) => profile.ok);
|
||||
return {
|
||||
@@ -109,11 +123,15 @@ function codexPoolPlan(): Record<string, unknown> {
|
||||
valuesPrinted: false,
|
||||
},
|
||||
target: poolTarget(),
|
||||
config: {
|
||||
path: codexPoolConfigPath,
|
||||
pool,
|
||||
},
|
||||
profiles: profiles.map(redactProfile),
|
||||
decision: {
|
||||
accountType: "openai/apikey",
|
||||
grouping: `All discovered Codex profiles are bound to one Sub2API group named ${poolGroupName}.`,
|
||||
unifiedApiKey: `The client-facing API_KEY is controlled by k3s Secret ${namespace}/${poolApiKeySecretName}.${poolApiKeySecretKey}.`,
|
||||
grouping: `All discovered Codex profiles are bound to one Sub2API group named ${pool.groupName}.`,
|
||||
unifiedApiKey: `The client-facing API_KEY is controlled by k3s Secret ${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}.`,
|
||||
idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files.",
|
||||
configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.",
|
||||
},
|
||||
@@ -124,6 +142,7 @@ function codexPoolPlan(): Record<string, unknown> {
|
||||
}
|
||||
|
||||
async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promise<Record<string, unknown>> {
|
||||
const pool = readCodexPoolConfig();
|
||||
const profiles = collectCodexProfiles();
|
||||
const planOk = profiles.length > 0 && profiles.every((profile) => profile.ok);
|
||||
if (!options.confirm || !planOk) {
|
||||
@@ -139,10 +158,11 @@ async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promi
|
||||
|
||||
const payload = {
|
||||
pool: {
|
||||
groupName: poolGroupName,
|
||||
apiKeyName: poolApiKeyName,
|
||||
apiKeySecretName: poolApiKeySecretName,
|
||||
apiKeySecretKey: poolApiKeySecretKey,
|
||||
groupName: pool.groupName,
|
||||
apiKeyName: pool.apiKeyName,
|
||||
apiKeySecretName: pool.apiKeySecretName,
|
||||
apiKeySecretKey: pool.apiKeySecretKey,
|
||||
minOwnerBalanceUsd: pool.minOwnerBalanceUsd,
|
||||
},
|
||||
profiles: profiles.map((profile) => ({
|
||||
profile: profile.profile,
|
||||
@@ -158,7 +178,7 @@ async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promi
|
||||
apiKeyFingerprint: fingerprint(profile.apiKey ?? ""),
|
||||
})),
|
||||
};
|
||||
const result = await capture(config, g14K3sRoute, ["script"], syncScript(payload));
|
||||
const result = await capture(config, g14K3sRoute, ["script"], syncScript(payload, pool));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
if (options.raw) {
|
||||
return {
|
||||
@@ -183,7 +203,8 @@ async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promi
|
||||
}
|
||||
|
||||
async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
||||
const result = await capture(config, g14K3sRoute, ["script"], validateScript());
|
||||
const pool = readCodexPoolConfig();
|
||||
const result = await capture(config, g14K3sRoute, ["script"], validateScript(pool));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
if (options.raw) {
|
||||
return {
|
||||
@@ -274,6 +295,35 @@ function collectCodexProfiles(): CodexProfile[] {
|
||||
});
|
||||
}
|
||||
|
||||
function readCodexPoolConfig(): CodexPoolConfig {
|
||||
const defaults: CodexPoolConfig = {
|
||||
groupName: defaultPoolGroupName,
|
||||
apiKeyName: defaultPoolApiKeyName,
|
||||
apiKeySecretName: defaultPoolApiKeySecretName,
|
||||
apiKeySecretKey: defaultPoolApiKeySecretKey,
|
||||
minOwnerBalanceUsd: defaultMinOwnerBalanceUsd,
|
||||
};
|
||||
if (!existsSync(codexPoolConfigPath)) return defaults;
|
||||
const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error(`${codexPoolConfigPath} must contain a YAML object`);
|
||||
const pool = parsed.pool;
|
||||
if (!isRecord(pool)) throw new Error(`${codexPoolConfigPath}.pool must be a YAML object`);
|
||||
const config: CodexPoolConfig = {
|
||||
groupName: stringValue(pool.groupName) ?? defaults.groupName,
|
||||
apiKeyName: stringValue(pool.apiKeyName) ?? defaults.apiKeyName,
|
||||
apiKeySecretName: stringValue(pool.apiKeySecretName) ?? defaults.apiKeySecretName,
|
||||
apiKeySecretKey: stringValue(pool.apiKeySecretKey) ?? defaults.apiKeySecretKey,
|
||||
minOwnerBalanceUsd: numberValue(pool.minOwnerBalanceUsd) ?? defaults.minOwnerBalanceUsd,
|
||||
};
|
||||
validateKubernetesName(config.groupName, "pool.groupName", false);
|
||||
validateKubernetesName(config.apiKeySecretName, "pool.apiKeySecretName", true);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(config.apiKeySecretKey)) {
|
||||
throw new Error(`${codexPoolConfigPath}.pool.apiKeySecretKey must be a valid secret key name`);
|
||||
}
|
||||
if (config.minOwnerBalanceUsd <= 0) throw new Error(`${codexPoolConfigPath}.pool.minOwnerBalanceUsd must be > 0`);
|
||||
return config;
|
||||
}
|
||||
|
||||
function readAuthAPIKey(authPath: string): { apiKey: string | null; shape: string } {
|
||||
if (!existsSync(authPath)) return { apiKey: null, shape: "missing" };
|
||||
const parsed = JSON.parse(readFileSync(authPath, "utf8")) as unknown;
|
||||
@@ -321,15 +371,16 @@ function redactProfile(profile: CodexProfile): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function poolTarget(): Record<string, unknown> {
|
||||
function poolTarget(pool = readCodexPoolConfig()): Record<string, unknown> {
|
||||
return {
|
||||
route: g14K3sRoute,
|
||||
namespace,
|
||||
service: serviceName,
|
||||
serviceDns,
|
||||
groupName: poolGroupName,
|
||||
apiKeyName: poolApiKeyName,
|
||||
apiKeySecret: `${namespace}/${poolApiKeySecretName}.${poolApiKeySecretKey}`,
|
||||
configPath: codexPoolConfigPath,
|
||||
groupName: pool.groupName,
|
||||
apiKeyName: pool.apiKeyName,
|
||||
apiKeySecret: `${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -351,6 +402,20 @@ function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateKubernetesName(value: string, key: string, strictDns: boolean): void {
|
||||
const pattern = strictDns ? /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u : /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/u;
|
||||
if (!pattern.test(value)) throw new Error(`${codexPoolConfigPath}.${key} has an unsupported format`);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -359,16 +424,16 @@ function fingerprint(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function syncScript(payload: unknown): string {
|
||||
function syncScript(payload: unknown, pool: CodexPoolConfig): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
return remotePythonScript("sync", encoded);
|
||||
return remotePythonScript("sync", encoded, pool);
|
||||
}
|
||||
|
||||
function validateScript(): string {
|
||||
return remotePythonScript("validate", "");
|
||||
function validateScript(pool: CodexPoolConfig): string {
|
||||
return remotePythonScript("validate", "", pool);
|
||||
}
|
||||
|
||||
function remotePythonScript(mode: "sync" | "validate", encodedPayload: string): string {
|
||||
function remotePythonScript(mode: "sync" | "validate", encodedPayload: string, pool: CodexPoolConfig): string {
|
||||
return `
|
||||
set -u
|
||||
python3 - <<'PY'
|
||||
@@ -386,10 +451,11 @@ SERVICE_NAME = "${serviceName}"
|
||||
SERVICE_DNS = "${serviceDns}"
|
||||
FIELD_MANAGER = "${fieldManager}"
|
||||
APP_SECRET_NAME = "${appSecretName}"
|
||||
POOL_GROUP_NAME = "${poolGroupName}"
|
||||
POOL_API_KEY_NAME = "${poolApiKeyName}"
|
||||
POOL_API_KEY_SECRET_NAME = "${poolApiKeySecretName}"
|
||||
POOL_API_KEY_SECRET_KEY = "${poolApiKeySecretKey}"
|
||||
POOL_GROUP_NAME = "${pool.groupName}"
|
||||
POOL_API_KEY_NAME = "${pool.apiKeyName}"
|
||||
POOL_API_KEY_SECRET_NAME = "${pool.apiKeySecretName}"
|
||||
POOL_API_KEY_SECRET_KEY = "${pool.apiKeySecretKey}"
|
||||
MIN_OWNER_BALANCE_USD = ${JSON.stringify(pool.minOwnerBalanceUsd)}
|
||||
MODE = "${mode}"
|
||||
PAYLOAD_B64 = "${encodedPayload}"
|
||||
|
||||
@@ -706,6 +772,38 @@ def ensure_sub2api_api_key(token, api_key, group_id):
|
||||
"id": existing.get("id") if isinstance(existing, dict) else None,
|
||||
"name": existing.get("name") if isinstance(existing, dict) else POOL_API_KEY_NAME,
|
||||
"groupId": existing.get("group_id") if isinstance(existing, dict) else group_id,
|
||||
"userId": existing.get("user_id") if isinstance(existing, dict) else None,
|
||||
}
|
||||
|
||||
def get_admin_user(token, user_id):
|
||||
data = ensure_success(curl_api("GET", f"/api/v1/admin/users/{user_id}", bearer=token), "get API key owner")
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("API key owner response is not an object")
|
||||
return data
|
||||
|
||||
def ensure_pool_owner_balance(token, user_id):
|
||||
user = get_admin_user(token, user_id)
|
||||
current = float(user.get("balance") or 0)
|
||||
if current >= MIN_OWNER_BALANCE_USD:
|
||||
return {
|
||||
"action": "kept-existing",
|
||||
"userId": user_id,
|
||||
"balanceBefore": current,
|
||||
"balanceAfter": current,
|
||||
"minimumBalanceUsd": MIN_OWNER_BALANCE_USD,
|
||||
}
|
||||
updated = ensure_success(curl_api("POST", f"/api/v1/admin/users/{user_id}/balance", bearer=token, payload={
|
||||
"balance": MIN_OWNER_BALANCE_USD,
|
||||
"operation": "set",
|
||||
"notes": "UniDesk Sub2API Codex pool internal API key bootstrap balance.",
|
||||
}), "set API key owner balance")
|
||||
after = float(updated.get("balance") or MIN_OWNER_BALANCE_USD) if isinstance(updated, dict) else MIN_OWNER_BALANCE_USD
|
||||
return {
|
||||
"action": "set",
|
||||
"userId": user_id,
|
||||
"balanceBefore": current,
|
||||
"balanceAfter": after,
|
||||
"minimumBalanceUsd": MIN_OWNER_BALANCE_USD,
|
||||
}
|
||||
|
||||
def validate_gateway(api_key):
|
||||
@@ -744,6 +842,7 @@ def run_sync():
|
||||
account_results = ensure_accounts(token, profiles, group_id)
|
||||
api_key, secret_action, secret_apply_stdout = ensure_api_key_secret(group_id)
|
||||
api_key_result = ensure_sub2api_api_key(token, api_key, group_id)
|
||||
owner_balance = ensure_pool_owner_balance(token, api_key_result["userId"])
|
||||
gateway = validate_gateway(api_key)
|
||||
return {
|
||||
"ok": gateway["ok"] is True,
|
||||
@@ -768,9 +867,11 @@ def run_sync():
|
||||
"sub2apiAction": api_key_result["action"],
|
||||
"sub2apiId": api_key_result["id"],
|
||||
"groupId": api_key_result["groupId"],
|
||||
"userId": api_key_result["userId"],
|
||||
"keyPreview": api_key_preview(api_key),
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"ownerBalance": owner_balance,
|
||||
"validation": {"gatewayModels": gateway},
|
||||
}
|
||||
|
||||
@@ -778,6 +879,11 @@ def run_validate():
|
||||
api_key = decode_secret_value(POOL_API_KEY_SECRET_NAME, POOL_API_KEY_SECRET_KEY)
|
||||
if not api_key:
|
||||
raise RuntimeError(f"{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY} missing")
|
||||
admin_email, token = login()
|
||||
key_item = next((item for item in list_user_keys(token) if item.get("key") == api_key), None)
|
||||
owner_balance = None
|
||||
if key_item is not None and key_item.get("user_id") is not None:
|
||||
owner_balance = ensure_pool_owner_balance(token, key_item["user_id"])
|
||||
gateway = validate_gateway(api_key)
|
||||
return {
|
||||
"ok": gateway["ok"] is True,
|
||||
@@ -785,11 +891,15 @@ def run_validate():
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": APP_POD,
|
||||
"admin": {"email": admin_email, "tokenPrinted": False},
|
||||
"apiKey": {
|
||||
"secret": f"{NAMESPACE}/{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY}",
|
||||
"sub2apiId": key_item.get("id") if isinstance(key_item, dict) else None,
|
||||
"userId": key_item.get("user_id") if isinstance(key_item, dict) else None,
|
||||
"keyPreview": api_key_preview(api_key),
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"ownerBalance": owner_balance,
|
||||
"validation": {"gatewayModels": gateway},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user