feat: extend G14 runtime automation
This commit is contained in:
+779
-116
File diff suppressed because it is too large
Load Diff
+141
-8
@@ -5,8 +5,8 @@ import { startJob } from "./jobs";
|
||||
import { runHwlabG14Command } from "./hwlab-g14";
|
||||
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane } from "./hwlab-node-lanes";
|
||||
|
||||
type SecretAction = "status" | "ensure";
|
||||
type SecretPreset = "openfga" | "master-server-admin-api-key" | "code-agent-provider" | "cloud-api-db";
|
||||
type SecretAction = "status" | "ensure" | "cleanup-owned-postgres";
|
||||
type SecretPreset = "openfga" | "master-server-admin-api-key" | "code-agent-provider" | "cloud-api-db" | "owned-postgres-cleanup";
|
||||
type DelegatedNodeDomain = "control-plane" | "git-mirror";
|
||||
|
||||
interface NodeSecretOptions {
|
||||
@@ -86,6 +86,8 @@ export function hwlabNodeHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-master-server-admin-api-key --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-cloud-api-v03-db",
|
||||
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-cloud-api-v03-db --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --dry-run",
|
||||
"bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-code-agent-provider",
|
||||
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm",
|
||||
],
|
||||
@@ -203,8 +205,8 @@ function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typeof par
|
||||
|
||||
function parseSecretOptions(args: string[]): NodeSecretOptions {
|
||||
const [actionRaw] = args;
|
||||
if (actionRaw !== "status" && actionRaw !== "ensure") {
|
||||
throw new Error("secret usage: status|ensure --node NODE --lane vNN --name hwlab-vNN-openfga|hwlab-vNN-master-server-admin-api-key|hwlab-cloud-api-vNN-db|hwlab-vNN-code-agent-provider [--dry-run|--confirm]");
|
||||
if (actionRaw !== "status" && actionRaw !== "ensure" && actionRaw !== "cleanup-owned-postgres") {
|
||||
throw new Error("secret usage: status|ensure --node NODE --lane vNN --name hwlab-vNN-openfga|hwlab-vNN-master-server-admin-api-key|hwlab-cloud-api-vNN-db|hwlab-vNN-code-agent-provider [--dry-run|--confirm] | cleanup-owned-postgres --node NODE --lane vNN [--dry-run|--confirm]");
|
||||
}
|
||||
const node = requiredOption(args, "--node");
|
||||
assertNodeId(node);
|
||||
@@ -216,6 +218,21 @@ function parseSecretOptions(args: string[]): NodeSecretOptions {
|
||||
const confirm = args.includes("--confirm");
|
||||
const explicitDryRun = args.includes("--dry-run");
|
||||
if (confirm && explicitDryRun) throw new Error("secret accepts only one of --confirm or --dry-run");
|
||||
if (actionRaw === "cleanup-owned-postgres") {
|
||||
if (lane === "v02") throw new Error("secret cleanup-owned-postgres is only for v0.3+ lanes after migration to G14 platform Postgres");
|
||||
if (key !== undefined) throw new Error("secret cleanup-owned-postgres does not accept --key");
|
||||
if (name !== spec.openFgaSecret && name !== spec.postgresSecret) throw new Error(`secret cleanup-owned-postgres for --lane ${lane} targets ${spec.postgresSecret}; omit --name or pass --name ${spec.postgresSecret}`);
|
||||
return {
|
||||
action: actionRaw,
|
||||
node,
|
||||
lane,
|
||||
name: spec.postgresSecret,
|
||||
preset: "owned-postgres-cleanup",
|
||||
confirm,
|
||||
dryRun: explicitDryRun || !confirm,
|
||||
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 180, 900),
|
||||
};
|
||||
}
|
||||
if (name === spec.masterAdminApiKeySecret) {
|
||||
if (key !== undefined && key !== MASTER_ADMIN_API_KEY_KEY) throw new Error(`secret ${name} supports only key ${MASTER_ADMIN_API_KEY_KEY}`);
|
||||
return {
|
||||
@@ -317,11 +334,14 @@ function runNodeSecret(options: NodeSecretOptions): Record<string, unknown> {
|
||||
? masterAdminApiKeySecretScript(options, spec)
|
||||
: options.preset === "cloud-api-db"
|
||||
? cloudApiDbSecretScript(options, spec)
|
||||
: codeAgentProviderSecretScript(options, spec);
|
||||
: options.preset === "owned-postgres-cleanup"
|
||||
? ownedPostgresCleanupScript(options, spec)
|
||||
: codeAgentProviderSecretScript(options, spec);
|
||||
const result = runTransScript(options.node, script, input, options.timeoutSeconds);
|
||||
const status = secretStatusFromText(statusText(result), result.exitCode === 0, result.exitCode, result.stderr, spec);
|
||||
const dryRunOk = options.action === "ensure" && options.dryRun && result.exitCode === 0;
|
||||
const ok = dryRunOk ? true : status.ok === true;
|
||||
const cleanupDryRunOk = options.action === "cleanup-owned-postgres" && options.dryRun && result.exitCode === 0;
|
||||
const ok = dryRunOk || cleanupDryRunOk ? true : status.ok === true;
|
||||
return {
|
||||
ok,
|
||||
command: `hwlab nodes secret ${options.action}`,
|
||||
@@ -331,13 +351,15 @@ function runNodeSecret(options: NodeSecretOptions): Record<string, unknown> {
|
||||
secret: options.name,
|
||||
key: options.key ?? null,
|
||||
preset: options.preset,
|
||||
mode: options.action === "status" ? "status" : options.dryRun ? "dry-run" : "confirmed-ensure",
|
||||
mode: options.action === "status" ? "status" : options.dryRun ? "dry-run" : options.action === "cleanup-owned-postgres" ? "confirmed-delete" : "confirmed-ensure",
|
||||
status,
|
||||
mutation: status.mutation === true,
|
||||
result: compactCommandResult(result),
|
||||
valuesRedacted: true,
|
||||
next: ok && options.action === "status" ? undefined : {
|
||||
ensure: `bun scripts/cli.ts hwlab nodes secret ensure --node ${options.node} --lane ${options.lane} --name ${options.name}${options.key ? ` --key ${options.key}` : ""} --confirm`,
|
||||
ensure: options.action === "cleanup-owned-postgres"
|
||||
? `bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node ${options.node} --lane ${options.lane} --confirm`
|
||||
: `bun scripts/cli.ts hwlab nodes secret ensure --node ${options.node} --lane ${options.lane} --name ${options.name}${options.key ? ` --key ${options.key}` : ""} --confirm`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -346,6 +368,76 @@ function runTransScript(node: string, script: string, input: string, timeoutSeco
|
||||
return runCommand(["/root/.local/bin/trans", `${node}:k3s`, "script", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 });
|
||||
}
|
||||
|
||||
function ownedPostgresCleanupScript(options: NodeSecretOptions, spec: RuntimeSecretSpec): string {
|
||||
const pvc = `data-${spec.postgresSecret}-0`;
|
||||
const platformService = "g14-platform-postgres";
|
||||
return [
|
||||
"set +e",
|
||||
`namespace=${shellQuote(spec.namespace)}`,
|
||||
`postgres_secret=${shellQuote(spec.postgresSecret)}`,
|
||||
`postgres_statefulset=${shellQuote(spec.postgresStatefulSet)}`,
|
||||
`pvc=${shellQuote(pvc)}`,
|
||||
`platform_service=${shellQuote(platformService)}`,
|
||||
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
|
||||
"preset=owned-postgres-cleanup",
|
||||
"exists_flag() { kind=\"$1\"; item=\"$2\"; kubectl -n \"$namespace\" get \"$kind\" \"$item\" >/dev/null 2>&1 && printf yes || printf no; }",
|
||||
"pv_name() { kubectl -n \"$namespace\" get pvc \"$pvc\" -o jsonpath='{.spec.volumeName}' 2>/dev/null; }",
|
||||
"phase_of_pvc() { kubectl -n \"$namespace\" get pvc \"$pvc\" -o jsonpath='{.status.phase}' 2>/dev/null; }",
|
||||
"before_secret_exists=$(exists_flag secret \"$postgres_secret\")",
|
||||
"before_pvc_exists=$(exists_flag pvc \"$pvc\")",
|
||||
"before_pvc_phase=$(phase_of_pvc)",
|
||||
"before_pv=$(pv_name)",
|
||||
"before_statefulset_exists=$(exists_flag statefulset \"$postgres_statefulset\")",
|
||||
"platform_service_exists=$(exists_flag service \"$platform_service\")",
|
||||
"action=observed",
|
||||
"mutation=false",
|
||||
"delete_secret_exit=",
|
||||
"delete_pvc_exit=",
|
||||
"if [ \"$dry_run\" = true ]; then",
|
||||
" if [ \"$before_secret_exists\" = yes ] || [ \"$before_pvc_exists\" = yes ]; then action=would-delete; else action=already-absent; fi",
|
||||
"else",
|
||||
" kubectl -n \"$namespace\" delete secret \"$postgres_secret\" --ignore-not-found=true >/tmp/hwlab-owned-postgres-secret-delete.out 2>/tmp/hwlab-owned-postgres-secret-delete.err",
|
||||
" delete_secret_exit=$?",
|
||||
" kubectl -n \"$namespace\" delete pvc \"$pvc\" --ignore-not-found=true >/tmp/hwlab-owned-postgres-pvc-delete.out 2>/tmp/hwlab-owned-postgres-pvc-delete.err",
|
||||
" delete_pvc_exit=$?",
|
||||
" if [ \"$delete_secret_exit\" -eq 0 ] && [ \"$delete_pvc_exit\" -eq 0 ]; then",
|
||||
" if [ \"$before_secret_exists\" = yes ] || [ \"$before_pvc_exists\" = yes ]; then action=deleted; mutation=true; else action=already-absent; fi",
|
||||
" else",
|
||||
" action=delete-failed",
|
||||
" fi",
|
||||
"fi",
|
||||
"after_secret_exists=$(exists_flag secret \"$postgres_secret\")",
|
||||
"after_pvc_exists=$(exists_flag pvc \"$pvc\")",
|
||||
"after_pvc_phase=$(phase_of_pvc)",
|
||||
"after_pv=$(pv_name)",
|
||||
"after_statefulset_exists=$(exists_flag statefulset \"$postgres_statefulset\")",
|
||||
"printf 'namespace\\t%s\\n' \"$namespace\"",
|
||||
"printf 'secret\\t%s\\n' \"$postgres_secret\"",
|
||||
"printf 'pvc\\t%s\\n' \"$pvc\"",
|
||||
"printf 'preset\\t%s\\n' \"$preset\"",
|
||||
"printf 'action\\t%s\\n' \"$action\"",
|
||||
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
|
||||
"printf 'mutation\\t%s\\n' \"$mutation\"",
|
||||
"printf 'beforeSecretExists\\t%s\\n' \"$before_secret_exists\"",
|
||||
"printf 'beforePvcExists\\t%s\\n' \"$before_pvc_exists\"",
|
||||
"printf 'beforePvcPhase\\t%s\\n' \"$before_pvc_phase\"",
|
||||
"printf 'beforePersistentVolume\\t%s\\n' \"$before_pv\"",
|
||||
"printf 'beforeStatefulSetExists\\t%s\\n' \"$before_statefulset_exists\"",
|
||||
"printf 'platformServiceExists\\t%s\\n' \"$platform_service_exists\"",
|
||||
"printf 'afterSecretExists\\t%s\\n' \"$after_secret_exists\"",
|
||||
"printf 'afterPvcExists\\t%s\\n' \"$after_pvc_exists\"",
|
||||
"printf 'afterPvcPhase\\t%s\\n' \"$after_pvc_phase\"",
|
||||
"printf 'afterPersistentVolume\\t%s\\n' \"$after_pv\"",
|
||||
"printf 'afterStatefulSetExists\\t%s\\n' \"$after_statefulset_exists\"",
|
||||
"printf 'deleteSecretExitCode\\t%s\\n' \"$delete_secret_exit\"",
|
||||
"printf 'deletePvcExitCode\\t%s\\n' \"$delete_pvc_exit\"",
|
||||
"if [ \"$platform_service_exists\" != yes ]; then exit 44; fi",
|
||||
"if [ \"$before_statefulset_exists\" = yes ] || [ \"$after_statefulset_exists\" = yes ]; then exit 45; fi",
|
||||
"if [ -n \"$delete_secret_exit\" ] && [ \"$delete_secret_exit\" != 0 ]; then exit \"$delete_secret_exit\"; fi",
|
||||
"if [ -n \"$delete_pvc_exit\" ] && [ \"$delete_pvc_exit\" != 0 ]; then exit \"$delete_pvc_exit\"; fi",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function openFgaSecretScript(options: NodeSecretOptions, spec: RuntimeSecretSpec): string {
|
||||
return [
|
||||
"set +e",
|
||||
@@ -803,6 +895,47 @@ function cloudApiDbSecretScript(options: NodeSecretOptions, spec: RuntimeSecretS
|
||||
|
||||
function secretStatusFromText(text: string, commandOk: boolean, exitCode: number | null, stderr: string, spec: RuntimeSecretSpec): Record<string, unknown> {
|
||||
const fields = keyValueLinesFromText(text);
|
||||
if (fields.preset === "owned-postgres-cleanup") {
|
||||
const absent = fields.afterSecretExists !== "yes" && fields.afterPvcExists !== "yes";
|
||||
const oldWorkloadAbsent = fields.afterStatefulSetExists !== "yes";
|
||||
const platformServiceReady = fields.platformServiceExists === "yes";
|
||||
return {
|
||||
ok: commandOk && absent && oldWorkloadAbsent && platformServiceReady,
|
||||
namespace: fields.namespace || spec.namespace,
|
||||
secret: fields.secret || spec.postgresSecret,
|
||||
pvc: fields.pvc || `data-${spec.postgresSecret}-0`,
|
||||
preset: "owned-postgres-cleanup",
|
||||
action: fields.action || null,
|
||||
dryRun: fields.dryRun === "true",
|
||||
mutation: fields.mutation === "true",
|
||||
before: {
|
||||
secretExists: fields.beforeSecretExists === "yes",
|
||||
pvcExists: fields.beforePvcExists === "yes",
|
||||
pvcPhase: fields.beforePvcPhase || null,
|
||||
persistentVolume: fields.beforePersistentVolume || null,
|
||||
statefulSetExists: fields.beforeStatefulSetExists === "yes",
|
||||
},
|
||||
after: {
|
||||
secretExists: fields.afterSecretExists === "yes",
|
||||
pvcExists: fields.afterPvcExists === "yes",
|
||||
pvcPhase: fields.afterPvcPhase || null,
|
||||
persistentVolume: fields.afterPersistentVolume || null,
|
||||
statefulSetExists: fields.afterStatefulSetExists === "yes",
|
||||
},
|
||||
platformService: {
|
||||
name: "g14-platform-postgres",
|
||||
exists: platformServiceReady,
|
||||
},
|
||||
deleteSecretExitCode: numericField(fields.deleteSecretExitCode),
|
||||
deletePvcExitCode: numericField(fields.deletePvcExitCode),
|
||||
exitCode,
|
||||
stderr: commandOk ? "" : stderr.trim().slice(0, 2000),
|
||||
valuesRedacted: true,
|
||||
summary: absent
|
||||
? `${fields.secret || spec.postgresSecret} and ${fields.pvc || `data-${spec.postgresSecret}-0`} absent`
|
||||
: `${fields.secret || spec.postgresSecret} or ${fields.pvc || `data-${spec.postgresSecret}-0`} still exists`,
|
||||
};
|
||||
}
|
||||
if (fields.preset === "master-server-admin-api-key") {
|
||||
const afterBytes = numericField(fields.afterApiKeyBytes);
|
||||
const healthy = fields.afterExists === "yes" && fields.afterApiKeyPresent === "yes" && typeof afterBytes === "number" && afterBytes > 0;
|
||||
|
||||
@@ -0,0 +1,848 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
|
||||
const g14K3sRoute = "G14:k3s";
|
||||
const namespace = "platform-infra";
|
||||
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";
|
||||
|
||||
interface DisclosureOptions {
|
||||
full: boolean;
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
interface SyncOptions extends DisclosureOptions {
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
interface CodexProfile {
|
||||
profile: string;
|
||||
accountName: string;
|
||||
configFile: string;
|
||||
authFile: string;
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
wireApi: string | null;
|
||||
model: string | null;
|
||||
envKey: string | null;
|
||||
apiKey: string | null;
|
||||
apiKeySource: "auth-json" | "env" | null;
|
||||
authOpenAIKeyShape: string;
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function codexPoolHelp(): unknown {
|
||||
return {
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate [--full|--raw]",
|
||||
],
|
||||
description: "Import ~/.codex/config.toml plus config.toml.* API-key profiles into one Sub2API OpenAI pool and expose one internal API_KEY stored in a k3s Secret.",
|
||||
target: {
|
||||
route: g14K3sRoute,
|
||||
namespace,
|
||||
serviceDns,
|
||||
poolGroupName,
|
||||
poolApiKeySecretName,
|
||||
poolApiKeySecretKey,
|
||||
secretValuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
||||
const [action = "plan"] = args;
|
||||
if (action === "plan") return codexPoolPlan();
|
||||
if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1)));
|
||||
if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1)));
|
||||
return {
|
||||
ok: false,
|
||||
error: "unsupported-platform-infra-sub2api-codex-pool-command",
|
||||
args,
|
||||
help: codexPoolHelp(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSyncOptions(args: string[]): SyncOptions {
|
||||
validateOptions(args, new Set(["--confirm", "--full", "--raw"]));
|
||||
const disclosure = parseDisclosureOptions(args.filter((arg) => arg !== "--confirm"));
|
||||
return { ...disclosure, confirm: args.includes("--confirm") };
|
||||
}
|
||||
|
||||
function parseDisclosureOptions(args: string[]): DisclosureOptions {
|
||||
validateOptions(args, new Set(["--full", "--raw"]));
|
||||
const raw = args.includes("--raw");
|
||||
return { full: raw || args.includes("--full"), raw };
|
||||
}
|
||||
|
||||
function validateOptions(args: string[], booleanOptions: Set<string>): void {
|
||||
for (const arg of args) {
|
||||
if (booleanOptions.has(arg)) continue;
|
||||
throw new Error(`unsupported option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function codexPoolPlan(): Record<string, unknown> {
|
||||
const profiles = collectCodexProfiles();
|
||||
const ok = profiles.length > 0 && profiles.every((profile) => profile.ok);
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-plan",
|
||||
source: {
|
||||
directory: join(homedir(), ".codex"),
|
||||
configPattern: "config.toml and config.toml.*",
|
||||
authPattern: "auth.json and auth.json.*",
|
||||
valuesPrinted: false,
|
||||
},
|
||||
target: poolTarget(),
|
||||
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}.`,
|
||||
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.",
|
||||
},
|
||||
next: ok
|
||||
? { sync: "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm" }
|
||||
: { fix: "Ensure every discovered config.toml profile has a base_url and either auth.json OPENAI_API_KEY or the configured env_key present in this shell." },
|
||||
};
|
||||
}
|
||||
|
||||
async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promise<Record<string, unknown>> {
|
||||
const profiles = collectCodexProfiles();
|
||||
const planOk = profiles.length > 0 && profiles.every((profile) => profile.ok);
|
||||
if (!options.confirm || !planOk) {
|
||||
return {
|
||||
...codexPoolPlan(),
|
||||
ok: !options.confirm ? planOk : false,
|
||||
mode: options.confirm ? "blocked-invalid-local-profile" : "dry-run",
|
||||
next: options.confirm
|
||||
? { fix: "Repair invalid local Codex profiles, then rerun sync --confirm." }
|
||||
: { confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm" },
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
pool: {
|
||||
groupName: poolGroupName,
|
||||
apiKeyName: poolApiKeyName,
|
||||
apiKeySecretName: poolApiKeySecretName,
|
||||
apiKeySecretKey: poolApiKeySecretKey,
|
||||
},
|
||||
profiles: profiles.map((profile) => ({
|
||||
profile: profile.profile,
|
||||
accountName: profile.accountName,
|
||||
configFile: profile.configFile,
|
||||
authFile: profile.authFile,
|
||||
provider: profile.provider,
|
||||
baseUrl: profile.baseUrl,
|
||||
wireApi: profile.wireApi,
|
||||
model: profile.model,
|
||||
apiKey: profile.apiKey,
|
||||
apiKeySource: profile.apiKeySource,
|
||||
apiKeyFingerprint: fingerprint(profile.apiKey ?? ""),
|
||||
})),
|
||||
};
|
||||
const result = await capture(config, g14K3sRoute, ["script"], syncScript(payload));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-sync",
|
||||
remote: compactCapture(result, { full: true }),
|
||||
parsed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-sync",
|
||||
local: {
|
||||
profiles: profiles.map(redactProfile),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
remote: parsed ?? compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
||||
next: {
|
||||
validate: "bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
||||
const result = await capture(config, g14K3sRoute, ["script"], validateScript());
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-validate",
|
||||
remote: compactCapture(result, { full: true }),
|
||||
parsed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-validate",
|
||||
summary: parsed,
|
||||
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
function collectCodexProfiles(): CodexProfile[] {
|
||||
const codexDir = join(homedir(), ".codex");
|
||||
if (!existsSync(codexDir)) return [];
|
||||
const configFiles = readdirSync(codexDir)
|
||||
.filter((file) => file === "config.toml" || file.startsWith("config.toml."))
|
||||
.sort((a, b) => {
|
||||
if (a === "config.toml") return -1;
|
||||
if (b === "config.toml") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
const seenAccountNames = new Set<string>();
|
||||
return configFiles.map((configFile) => {
|
||||
const suffix = configFile === "config.toml" ? "" : configFile.slice("config.toml.".length);
|
||||
const profile = suffix === "" ? "default" : suffix;
|
||||
const accountName = uniqueAccountName(profile, seenAccountNames);
|
||||
const authFile = suffix === "" ? "auth.json" : `auth.json.${suffix}`;
|
||||
const configPath = join(codexDir, configFile);
|
||||
const authPath = join(codexDir, authFile);
|
||||
const base: CodexProfile = {
|
||||
profile,
|
||||
accountName,
|
||||
configFile,
|
||||
authFile,
|
||||
provider: "",
|
||||
baseUrl: "",
|
||||
wireApi: null,
|
||||
model: null,
|
||||
envKey: null,
|
||||
apiKey: null,
|
||||
apiKeySource: null,
|
||||
authOpenAIKeyShape: existsSync(authPath) ? "unknown" : "missing",
|
||||
ok: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error("config is not a TOML object");
|
||||
const providers = parsed.model_providers;
|
||||
if (!isRecord(providers)) throw new Error("model_providers is missing");
|
||||
const provider = stringValue(parsed.model_provider) ?? Object.keys(providers)[0] ?? "";
|
||||
if (provider === "") throw new Error("model_provider is missing");
|
||||
const providerConfig = providers[provider];
|
||||
if (!isRecord(providerConfig)) throw new Error(`model provider ${provider} is missing`);
|
||||
const baseUrl = normalizeBaseUrl(stringValue(providerConfig.base_url));
|
||||
if (baseUrl === null) throw new Error(`model provider ${provider} base_url is missing or invalid`);
|
||||
base.provider = provider;
|
||||
base.baseUrl = baseUrl;
|
||||
base.envKey = stringValue(providerConfig.env_key);
|
||||
base.wireApi = stringValue(providerConfig.wire_api);
|
||||
base.model = stringValue(parsed.model);
|
||||
|
||||
const auth = readAuthAPIKey(authPath);
|
||||
base.authOpenAIKeyShape = auth.shape;
|
||||
if (auth.apiKey !== null) {
|
||||
base.apiKey = auth.apiKey;
|
||||
base.apiKeySource = "auth-json";
|
||||
} else if (base.envKey !== null && typeof process.env[base.envKey] === "string" && process.env[base.envKey]!.length > 0) {
|
||||
base.apiKey = process.env[base.envKey]!;
|
||||
base.apiKeySource = "env";
|
||||
}
|
||||
if (base.apiKey === null || base.apiKey.length === 0) {
|
||||
throw new Error(base.envKey === null ? "auth OPENAI_API_KEY is missing or empty" : `auth OPENAI_API_KEY is missing and env ${base.envKey} is not present`);
|
||||
}
|
||||
base.ok = true;
|
||||
return base;
|
||||
} catch (error) {
|
||||
base.error = error instanceof Error ? error.message : String(error);
|
||||
return base;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if (!isRecord(parsed)) return { apiKey: null, shape: "non-object" };
|
||||
const value = parsed.OPENAI_API_KEY;
|
||||
const shape = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
||||
if (typeof value === "string" && value.length > 0) return { apiKey: value, shape };
|
||||
return { apiKey: null, shape };
|
||||
}
|
||||
|
||||
function uniqueAccountName(profile: string, seen: Set<string>): string {
|
||||
const normalized = profile
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/gu, "-")
|
||||
.replace(/^-+|-+$/gu, "") || "default";
|
||||
let candidate = `unidesk-codex-${normalized}`;
|
||||
let counter = 2;
|
||||
while (seen.has(candidate)) {
|
||||
candidate = `unidesk-codex-${normalized}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
seen.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function redactProfile(profile: CodexProfile): Record<string, unknown> {
|
||||
return {
|
||||
profile: profile.profile,
|
||||
accountName: profile.accountName,
|
||||
configFile: profile.configFile,
|
||||
authFile: profile.authFile,
|
||||
provider: profile.provider || null,
|
||||
baseUrl: profile.baseUrl || null,
|
||||
wireApi: profile.wireApi,
|
||||
model: profile.model,
|
||||
envKey: profile.envKey,
|
||||
apiKeySource: profile.apiKeySource,
|
||||
apiKeyPresent: profile.apiKey !== null && profile.apiKey.length > 0,
|
||||
apiKeyBytes: profile.apiKey === null ? 0 : Buffer.byteLength(profile.apiKey, "utf8"),
|
||||
apiKeyFingerprint: profile.apiKey === null ? null : fingerprint(profile.apiKey),
|
||||
authOpenAIKeyShape: profile.authOpenAIKeyShape,
|
||||
ok: profile.ok,
|
||||
error: profile.error,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function poolTarget(): Record<string, unknown> {
|
||||
return {
|
||||
route: g14K3sRoute,
|
||||
namespace,
|
||||
service: serviceName,
|
||||
serviceDns,
|
||||
groupName: poolGroupName,
|
||||
apiKeyName: poolApiKeyName,
|
||||
apiKeySecret: `${namespace}/${poolApiKeySecretName}.${poolApiKeySecretKey}`,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
const trimmed = value.trim().replace(/\/+$/u, "");
|
||||
if (trimmed.length === 0) return null;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
return parsed.toString().replace(/\/+$/u, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function fingerprint(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function syncScript(payload: unknown): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
return remotePythonScript("sync", encoded);
|
||||
}
|
||||
|
||||
function validateScript(): string {
|
||||
return remotePythonScript("validate", "");
|
||||
}
|
||||
|
||||
function remotePythonScript(mode: "sync" | "validate", encodedPayload: string): string {
|
||||
return `
|
||||
set -u
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
NAMESPACE = "${namespace}"
|
||||
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}"
|
||||
MODE = "${mode}"
|
||||
PAYLOAD_B64 = "${encodedPayload}"
|
||||
|
||||
def run(cmd, input_bytes=None):
|
||||
return subprocess.run(cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
def text(data, limit=4000):
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode("utf-8", errors="replace")
|
||||
return data[-limit:]
|
||||
|
||||
def kubectl(args, input_obj=None):
|
||||
if isinstance(input_obj, str):
|
||||
input_bytes = input_obj.encode("utf-8")
|
||||
else:
|
||||
input_bytes = input_obj
|
||||
return run(["kubectl", *args], input_bytes)
|
||||
|
||||
def require_kubectl(args, input_obj=None, label="kubectl"):
|
||||
proc = kubectl(args, input_obj)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"{label} failed: {text(proc.stderr, 1000)}")
|
||||
return proc.stdout
|
||||
|
||||
def kube_json(args, label):
|
||||
raw = require_kubectl([*args, "-o", "json"], label=label)
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
def decode_secret_value(name, key):
|
||||
data = kube_json(["-n", NAMESPACE, "get", "secret", name], f"secret/{name}").get("data") or {}
|
||||
if key not in data:
|
||||
return None
|
||||
return base64.b64decode(data[key]).decode("utf-8")
|
||||
|
||||
def get_config_value(name, key):
|
||||
data = kube_json(["-n", NAMESPACE, "get", "configmap", name], f"configmap/{name}").get("data") or {}
|
||||
value = data.get(key)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
def select_app_pod():
|
||||
pods = kube_json(["-n", NAMESPACE, "get", "pods", "-l", "app.kubernetes.io/name=sub2api"], "sub2api pods").get("items") or []
|
||||
for pod in pods:
|
||||
status = pod.get("status") or {}
|
||||
if status.get("phase") != "Running":
|
||||
continue
|
||||
statuses = status.get("containerStatuses") or []
|
||||
if statuses and all(item.get("ready") is True for item in statuses):
|
||||
return pod["metadata"]["name"]
|
||||
if pods:
|
||||
return pods[0]["metadata"]["name"]
|
||||
raise RuntimeError("sub2api app pod not found")
|
||||
|
||||
APP_POD = select_app_pod()
|
||||
|
||||
def parse_curl_output(proc):
|
||||
stdout = proc.stdout.decode("utf-8", errors="replace")
|
||||
marker = "\\n__HTTP_CODE__:"
|
||||
pos = stdout.rfind(marker)
|
||||
if pos < 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"httpStatus": 0,
|
||||
"json": None,
|
||||
"body": stdout,
|
||||
"stderr": text(proc.stderr, 1000),
|
||||
"transportExitCode": proc.returncode,
|
||||
}
|
||||
body = stdout[:pos]
|
||||
status_text = stdout[pos + len(marker):].strip()
|
||||
try:
|
||||
http_status = int(status_text[-3:])
|
||||
except ValueError:
|
||||
http_status = 0
|
||||
try:
|
||||
parsed = json.loads(body) if body.strip() else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
return {
|
||||
"ok": proc.returncode == 0 and 200 <= http_status < 300,
|
||||
"httpStatus": http_status,
|
||||
"json": parsed,
|
||||
"body": body,
|
||||
"stderr": text(proc.stderr, 1000),
|
||||
"transportExitCode": proc.returncode,
|
||||
}
|
||||
|
||||
def curl_api(method, path, bearer=None, payload=None):
|
||||
body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
script = r'''
|
||||
set -eu
|
||||
method="$1"
|
||||
url="$2"
|
||||
token="\${3:-}"
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
cat > "$tmp"
|
||||
if [ -n "$token" ]; then
|
||||
if [ "$method" = "GET" ] && [ ! -s "$tmp" ]; then
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H "Authorization: Bearer $token" "$url"
|
||||
else
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "Authorization: Bearer $token" --data-binary @"$tmp" "$url"
|
||||
fi
|
||||
else
|
||||
if [ "$method" = "GET" ] && [ ! -s "$tmp" ]; then
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" "$url"
|
||||
else
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url"
|
||||
fi
|
||||
fi
|
||||
'''
|
||||
proc = run([
|
||||
"kubectl", "-n", NAMESPACE, "exec", "-i", APP_POD,
|
||||
"--", "sh", "-c", script, "sh", method, f"http://127.0.0.1:8080{path}", bearer or "",
|
||||
], body)
|
||||
return parse_curl_output(proc)
|
||||
|
||||
def envelope_data(parsed):
|
||||
if isinstance(parsed, dict) and "data" in parsed:
|
||||
return parsed.get("data")
|
||||
return parsed
|
||||
|
||||
def ensure_success(resp, label):
|
||||
parsed = resp.get("json")
|
||||
code = parsed.get("code") if isinstance(parsed, dict) else None
|
||||
if not resp.get("ok") or (code is not None and code != 0):
|
||||
message = parsed.get("message") if isinstance(parsed, dict) else text(resp.get("body", ""), 500)
|
||||
raise RuntimeError(f"{label} failed: http={resp.get('httpStatus')} message={message}")
|
||||
return envelope_data(parsed)
|
||||
|
||||
def extract_items(data):
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
if isinstance(data.get("items"), list):
|
||||
return data["items"]
|
||||
for key in ("groups", "accounts", "api_keys", "keys"):
|
||||
if isinstance(data.get(key), list):
|
||||
return data[key]
|
||||
return []
|
||||
|
||||
def find_access_token(data):
|
||||
if isinstance(data, dict):
|
||||
for key in ("access_token", "token"):
|
||||
if isinstance(data.get(key), str) and data[key]:
|
||||
return data[key]
|
||||
for value in data.values():
|
||||
token = find_access_token(value)
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
def login():
|
||||
admin_email = get_config_value("sub2api-config", "ADMIN_EMAIL") or "admin@sub2api.platform-infra.local"
|
||||
admin_password = decode_secret_value(APP_SECRET_NAME, "ADMIN_PASSWORD")
|
||||
if not admin_password:
|
||||
raise RuntimeError("ADMIN_PASSWORD missing from sub2api-secrets")
|
||||
data = ensure_success(curl_api("POST", "/api/v1/auth/login", payload={"email": admin_email, "password": admin_password}), "admin login")
|
||||
token = find_access_token(data)
|
||||
if not token:
|
||||
raise RuntimeError("admin login response did not contain access_token")
|
||||
return admin_email, token
|
||||
|
||||
def group_payload():
|
||||
return {
|
||||
"name": POOL_GROUP_NAME,
|
||||
"description": "UniDesk-managed Codex API-key pool for G14 k3s internal Sub2API clients.",
|
||||
"platform": "openai",
|
||||
"rate_multiplier": 1,
|
||||
"is_exclusive": False,
|
||||
"subscription_type": "standard",
|
||||
"allow_messages_dispatch": True,
|
||||
"require_oauth_only": False,
|
||||
"require_privacy_set": False,
|
||||
"rpm_limit": 0,
|
||||
}
|
||||
|
||||
def ensure_group(token):
|
||||
existing_data = ensure_success(curl_api("GET", "/api/v1/admin/groups/all?platform=openai", bearer=token), "list groups")
|
||||
existing = next((item for item in extract_items(existing_data) if item.get("name") == POOL_GROUP_NAME), None)
|
||||
payload = group_payload()
|
||||
if existing is None:
|
||||
created = ensure_success(curl_api("POST", "/api/v1/admin/groups", bearer=token, payload=payload), "create group")
|
||||
return created, "created"
|
||||
group_id = existing.get("id")
|
||||
if group_id is None:
|
||||
raise RuntimeError("existing group has no id")
|
||||
payload["status"] = "active"
|
||||
updated = ensure_success(curl_api("PUT", f"/api/v1/admin/groups/{group_id}", bearer=token, payload=payload), "update group")
|
||||
return updated if isinstance(updated, dict) else existing, "updated"
|
||||
|
||||
def list_accounts(token):
|
||||
path = "/api/v1/admin/accounts?page=1&page_size=200&platform=openai&type=apikey&search=" + quote("unidesk-codex-")
|
||||
data = ensure_success(curl_api("GET", path, bearer=token), "list accounts")
|
||||
return extract_items(data)
|
||||
|
||||
def account_payload(profile, group_id):
|
||||
return {
|
||||
"name": profile["accountName"],
|
||||
"notes": f"UniDesk-managed Codex profile {profile['profile']} from {profile['configFile']} and {profile['authFile']}; secret source={profile['apiKeySource']}; fingerprint={profile['apiKeyFingerprint']}.",
|
||||
"platform": "openai",
|
||||
"type": "apikey",
|
||||
"credentials": {
|
||||
"api_key": profile["apiKey"],
|
||||
"base_url": profile["baseUrl"],
|
||||
},
|
||||
"extra": {
|
||||
"openai_responses_mode": "force_responses",
|
||||
"unidesk_codex_profile": profile["profile"],
|
||||
"unidesk_managed": True,
|
||||
},
|
||||
"concurrency": 1,
|
||||
"priority": 1,
|
||||
"rate_multiplier": 1,
|
||||
"load_factor": 1,
|
||||
"group_ids": [group_id],
|
||||
"confirm_mixed_channel_risk": True,
|
||||
}
|
||||
|
||||
def ensure_accounts(token, profiles, group_id):
|
||||
existing = {item.get("name"): item for item in list_accounts(token)}
|
||||
results = []
|
||||
for profile in profiles:
|
||||
payload = account_payload(profile, group_id)
|
||||
current = existing.get(profile["accountName"])
|
||||
if current and current.get("id") is not None:
|
||||
account_id = current["id"]
|
||||
update_payload = dict(payload)
|
||||
update_payload.pop("platform", None)
|
||||
update_payload["status"] = "active"
|
||||
data = ensure_success(curl_api("PUT", f"/api/v1/admin/accounts/{account_id}", bearer=token, payload=update_payload), f"update account {profile['accountName']}")
|
||||
action = "updated"
|
||||
else:
|
||||
data = ensure_success(curl_api("POST", "/api/v1/admin/accounts", bearer=token, payload=payload), f"create account {profile['accountName']}")
|
||||
action = "created"
|
||||
results.append({
|
||||
"profile": profile["profile"],
|
||||
"accountName": profile["accountName"],
|
||||
"accountId": data.get("id") if isinstance(data, dict) else None,
|
||||
"action": action,
|
||||
"baseUrl": profile["baseUrl"],
|
||||
"apiKeySource": profile["apiKeySource"],
|
||||
"apiKeyFingerprint": profile["apiKeyFingerprint"],
|
||||
"valuesPrinted": False,
|
||||
})
|
||||
return results
|
||||
|
||||
def generate_api_key():
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "sk-unidesk-codex-" + "".join(secrets.choice(alphabet) for _ in range(48))
|
||||
|
||||
def ensure_api_key_secret(group_id):
|
||||
existing = None
|
||||
try:
|
||||
existing = decode_secret_value(POOL_API_KEY_SECRET_NAME, POOL_API_KEY_SECRET_KEY)
|
||||
except Exception:
|
||||
existing = None
|
||||
api_key = existing if existing else generate_api_key()
|
||||
secret_action = "kept-existing" if existing else "created"
|
||||
manifest = {
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": {
|
||||
"name": POOL_API_KEY_SECRET_NAME,
|
||||
"namespace": NAMESPACE,
|
||||
"labels": {
|
||||
"app.kubernetes.io/name": "sub2api",
|
||||
"app.kubernetes.io/part-of": "platform-infra",
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
"unidesk.ai/secret-purpose": "sub2api-codex-pool-api-key",
|
||||
},
|
||||
},
|
||||
"type": "Opaque",
|
||||
"stringData": {
|
||||
POOL_API_KEY_SECRET_KEY: api_key,
|
||||
"GROUP_ID": str(group_id),
|
||||
"GROUP_NAME": POOL_GROUP_NAME,
|
||||
"SERVICE_DNS": SERVICE_DNS,
|
||||
},
|
||||
}
|
||||
proc = kubectl(["apply", "--server-side", "--force-conflicts", f"--field-manager={FIELD_MANAGER}", "-f", "-"], json.dumps(manifest))
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"apply API key secret failed: {text(proc.stderr, 1000)}")
|
||||
return api_key, secret_action, text(proc.stdout, 1000)
|
||||
|
||||
def list_user_keys(token):
|
||||
data = ensure_success(curl_api("GET", "/api/v1/keys?page=1&page_size=200", bearer=token), "list user keys")
|
||||
return extract_items(data)
|
||||
|
||||
def ensure_sub2api_api_key(token, api_key, group_id):
|
||||
keys = list_user_keys(token)
|
||||
existing = next((item for item in keys if item.get("key") == api_key), None)
|
||||
action = "kept-existing"
|
||||
if existing is None:
|
||||
existing = next((item for item in keys if item.get("name") == POOL_API_KEY_NAME), None)
|
||||
if existing is None or existing.get("key") != api_key:
|
||||
payload = {
|
||||
"name": POOL_API_KEY_NAME,
|
||||
"group_id": group_id,
|
||||
"custom_key": api_key,
|
||||
"quota": 0,
|
||||
"rate_limit_5h": 0,
|
||||
"rate_limit_1d": 0,
|
||||
"rate_limit_7d": 0,
|
||||
}
|
||||
created = ensure_success(curl_api("POST", "/api/v1/keys", bearer=token, payload=payload), "create pool API key")
|
||||
existing = created if isinstance(created, dict) else existing
|
||||
action = "created"
|
||||
elif existing.get("id") is not None and existing.get("group_id") != group_id:
|
||||
updated = ensure_success(curl_api("PUT", f"/api/v1/keys/{existing['id']}", bearer=token, payload={"name": POOL_API_KEY_NAME, "group_id": group_id}), "update pool API key group")
|
||||
existing = updated if isinstance(updated, dict) else existing
|
||||
action = "updated-group"
|
||||
return {
|
||||
"action": action,
|
||||
"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,
|
||||
}
|
||||
|
||||
def validate_gateway(api_key):
|
||||
resp = curl_api("GET", "/v1/models", bearer=api_key)
|
||||
parsed = resp.get("json")
|
||||
model_count = None
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get("data"), list):
|
||||
model_count = len(parsed["data"])
|
||||
return {
|
||||
"ok": resp.get("ok"),
|
||||
"httpStatus": resp.get("httpStatus"),
|
||||
"transportExitCode": resp.get("transportExitCode"),
|
||||
"modelCount": model_count,
|
||||
"bodyPreview": text(resp.get("body", ""), 500) if not resp.get("ok") else "",
|
||||
"stderr": resp.get("stderr", ""),
|
||||
"method": "GET /v1/models",
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def api_key_preview(api_key):
|
||||
if len(api_key) <= 14:
|
||||
return "***"
|
||||
return api_key[:10] + "..." + api_key[-4:]
|
||||
|
||||
def run_sync():
|
||||
payload = json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8"))
|
||||
profiles = payload.get("profiles") or []
|
||||
if not profiles:
|
||||
raise RuntimeError("sync payload has no profiles")
|
||||
admin_email, token = login()
|
||||
group, group_action = ensure_group(token)
|
||||
group_id = group.get("id") if isinstance(group, dict) else None
|
||||
if group_id is None:
|
||||
raise RuntimeError("pool group id missing after ensure")
|
||||
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)
|
||||
gateway = validate_gateway(api_key)
|
||||
return {
|
||||
"ok": gateway["ok"] is True,
|
||||
"mode": "sync",
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": APP_POD,
|
||||
"admin": {"email": admin_email, "tokenPrinted": False},
|
||||
"pool": {"name": POOL_GROUP_NAME, "id": group_id, "action": group_action, "platform": group.get("platform") if isinstance(group, dict) else "openai"},
|
||||
"accounts": {
|
||||
"desired": len(profiles),
|
||||
"created": sum(1 for item in account_results if item["action"] == "created"),
|
||||
"updated": sum(1 for item in account_results if item["action"] == "updated"),
|
||||
"items": account_results,
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"apiKey": {
|
||||
"name": POOL_API_KEY_NAME,
|
||||
"secret": f"{NAMESPACE}/{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY}",
|
||||
"secretAction": secret_action,
|
||||
"secretApply": secret_apply_stdout,
|
||||
"sub2apiAction": api_key_result["action"],
|
||||
"sub2apiId": api_key_result["id"],
|
||||
"groupId": api_key_result["groupId"],
|
||||
"keyPreview": api_key_preview(api_key),
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"validation": {"gatewayModels": gateway},
|
||||
}
|
||||
|
||||
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")
|
||||
gateway = validate_gateway(api_key)
|
||||
return {
|
||||
"ok": gateway["ok"] is True,
|
||||
"mode": "validate",
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": APP_POD,
|
||||
"apiKey": {
|
||||
"secret": f"{NAMESPACE}/{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY}",
|
||||
"keyPreview": api_key_preview(api_key),
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"validation": {"gatewayModels": gateway},
|
||||
}
|
||||
|
||||
try:
|
||||
result = run_sync() if MODE == "sync" else run_validate()
|
||||
except Exception as exc:
|
||||
result = {
|
||||
"ok": False,
|
||||
"mode": MODE,
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": globals().get("APP_POD"),
|
||||
"error": str(exc),
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
sys.exit(0 if result.get("ok") else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
async function capture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise<SshCaptureResult> {
|
||||
return await runSshCommandCapture(config, target, args, input);
|
||||
}
|
||||
|
||||
function parseJsonOutput(stdout: string): Record<string, unknown> | null {
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function boolField(value: Record<string, unknown> | null, key: string, defaultValue: boolean): boolean {
|
||||
if (value === null) return defaultValue;
|
||||
const field = value[key];
|
||||
return typeof field === "boolean" ? field : defaultValue;
|
||||
}
|
||||
|
||||
function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record<string, unknown> {
|
||||
const full = options.full ?? false;
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
|
||||
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
|
||||
stdoutTail: full || result.exitCode !== 0 ? result.stdout.slice(-8000) : "",
|
||||
stderrTail: full || result.exitCode !== 0 ? result.stderr.slice(-4000) : "",
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,7 @@ interface Sub2ApiConfig {
|
||||
|
||||
export function platformInfraHelp(): unknown {
|
||||
return {
|
||||
command: "platform-infra sub2api plan|apply|status|validate",
|
||||
command: "platform-infra sub2api plan|apply|status|validate|codex-pool",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api plan",
|
||||
@@ -31,6 +31,9 @@ export function platformInfraHelp(): unknown {
|
||||
"bun scripts/cli.ts platform-infra sub2api apply --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api status [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api validate [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
],
|
||||
description: "Operate the G14 k3s internal-only Sub2API deployment in the shared platform-infra namespace. This entry creates no Ingress, NodePort, LoadBalancer, hostPort, hostNetwork, ResourceQuota, LimitRange, or CPU/memory resource requests/limits.",
|
||||
target: {
|
||||
@@ -42,6 +45,14 @@ export function platformInfraHelp(): unknown {
|
||||
resourceLimits: "unset-by-policy",
|
||||
versionConfigPath: configPath,
|
||||
},
|
||||
codexPool: {
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
],
|
||||
module: "scripts/src/platform-infra-sub2api-codex.ts",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +63,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin
|
||||
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(2)));
|
||||
if (action === "status") return await status(config, parseDisclosureOptions(args.slice(2)));
|
||||
if (action === "validate") return await validate(config, parseDisclosureOptions(args.slice(2)));
|
||||
if (action === "codex-pool") {
|
||||
const { runCodexPoolCommand } = await import("./platform-infra-sub2api-codex");
|
||||
return await runCodexPoolCommand(config, args.slice(2));
|
||||
}
|
||||
return unsupported(args);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user