feat: add Sub2API runtime configuration CRUD
This commit is contained in:
@@ -27,9 +27,9 @@ import { collectCodexProfiles, readCodexPoolConfig } from "./config";
|
||||
import { codexPoolSentinelProbeConfigFingerprint, fingerprint } from "./config-utils";
|
||||
import { apiKeyPreview, codexConsumerBaseUrl, fetchPoolApiKey, probePublicModels, validatePublicGatewayWithKey, writeLocalCodexConfig } from "./local-codex";
|
||||
import { manualBindingSourcePlan, poolTarget, prepareTargetPublicExposureSecret, protectedManualAccountNamesForTarget, resolvedManualAccountProtections, secretMaterialSummary, sentinelProfileSecrets, targetFrpPublicExposure, targetPublicExposureApplyScript, targetPublicExposureSummary } from "./public-exposure";
|
||||
import { codexPoolConfigSummary, compactProfile, compactRuntimeAccountState, compactSentinelProbeResult, redactProfile, renderSub2ApiTempUnschedulableCredentials } from "./redaction";
|
||||
import { codexPoolConfigSummary, compactProfile, compactSentinelProbeResult, redactProfile, renderSub2ApiTempUnschedulableCredentials } from "./redaction";
|
||||
import { boolField, capture, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote";
|
||||
import { cleanupProbesScript, runtimeStateScript, sentinelImageBuildScript, sentinelImageStatusScript, sentinelProbeScript, sentinelReportScript, syncScript, traceScript, validateScript } from "./remote-scripts";
|
||||
import { cleanupProbesScript, sentinelImageBuildScript, sentinelImageStatusScript, sentinelProbeScript, sentinelReportScript, syncScript, traceScript, validateScript } from "./remote-scripts";
|
||||
import { codexPoolSyncSummary, codexPoolValidationSummary, renderCodexPoolPlan, renderCodexPoolSentinelProbeResult, renderCodexPoolSyncResult, renderCodexPoolValidateResult, renderSentinelReport, renderTraceReport, renderedCliResult } from "./render";
|
||||
import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId, targetFlag } from "./runtime-target";
|
||||
import { codexPoolConfigPath, serviceName, sub2apiConfigPath } from "./types";
|
||||
@@ -317,35 +317,6 @@ export async function codexPoolValidate(config: UniDeskConfig, options: Disclosu
|
||||
return options.full ? response : renderCodexPoolValidateResult(response);
|
||||
}
|
||||
|
||||
export async function codexPoolRuntimeState(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
||||
const pool = readCodexPoolConfig();
|
||||
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
|
||||
const result = await runRemoteCodexPoolScript(config, "runtime-state", runtimeStateScript(pool, runtimeTarget), runtimeTarget);
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-runtime-state",
|
||||
remote: compactCapture(result, { full: true }),
|
||||
parsed,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-runtime-state",
|
||||
target: {
|
||||
id: runtimeTarget.id,
|
||||
route: runtimeTarget.route,
|
||||
namespace: runtimeTarget.namespace,
|
||||
runtimeMode: runtimeTarget.runtimeMode,
|
||||
},
|
||||
state: options.full ? parsed : compactRuntimeAccountState(parsed),
|
||||
remote: compactCapture(result, { full: result.exitCode !== 0 || parsed === null }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function codexPoolTrace(config: UniDeskConfig, options: TraceOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const pool = readCodexPoolConfig();
|
||||
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
|
||||
|
||||
import type { CodexPoolCaddyEdgeRetryConfig, CodexPoolConfig, CodexPoolLocalCodexConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualAccountsConfig, CodexPoolManualBindingKind, CodexPoolManualBindingProvider, CodexPoolManualBindingSource, CodexPoolManualBindingSourcesConfig, CodexPoolProfileConfig, CodexPoolPublicExposureConfig, CodexProfile, CodexSentinelProtectPolicy, CodexTempUnschedulablePolicy, CodexTempUnschedulableRule, OpenAIResponsesWebSocketsV2Mode } from "./types";
|
||||
import type { CodexPoolCaddyEdgeRetryConfig, CodexPoolConfig, CodexPoolLocalCodexConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualAccountsConfig, CodexPoolManualBindingKind, CodexPoolManualBindingProvider, CodexPoolManualBindingSource, CodexPoolManualBindingSourcesConfig, CodexPoolProfileConfig, CodexPoolPublicExposureConfig, CodexProfile, CodexRuntimeConfig, CodexRuntimeTemplate, CodexSentinelProtectPolicy, CodexTempUnschedulablePolicy, CodexTempUnschedulableRule, OpenAIResponsesWebSocketsV2Mode } from "./types";
|
||||
import { booleanValue, integerArrayConfigField, integerConfigField, isRecord, normalizeBaseUrl, numberValue, readRequiredBaseUrl, readRequiredDescription, readRequiredEmail, readRequiredPort, readRequiredPositiveNumber, requiredRecordConfigField, requiredStringConfigField, stringValue, validateKubernetesName } from "./config-utils";
|
||||
import { readAuthAPIKey, uniqueAccountName } from "./redaction";
|
||||
import { codexPoolConfigPath } from "./types";
|
||||
@@ -59,6 +59,7 @@ export function collectCodexProfiles(): CodexProfile[] {
|
||||
priority: entry.priority,
|
||||
capacity: entry.capacity ?? pool.defaultAccountCapacity,
|
||||
loadFactor: entry.loadFactor ?? pool.defaultAccountLoadFactor,
|
||||
tempUnschedulableTemplate: entry.tempUnschedulableTemplate,
|
||||
tempUnschedulable: entry.tempUnschedulable,
|
||||
authOpenAIKeyShape: existsSync(authPath) ? "unknown" : "missing",
|
||||
ok: false,
|
||||
@@ -127,13 +128,21 @@ export function readCodexPoolConfig(): CodexPoolConfig {
|
||||
if (!isRecord(pool)) throw new Error(`${codexPoolConfigPath}.pool must be a YAML object`);
|
||||
if (!isRecord(parsed.profiles)) throw new Error(`${codexPoolConfigPath}.profiles must be a YAML object`);
|
||||
rejectSchedulableYamlField(pool, "pool");
|
||||
const defaultTempUnschedulable = readTempUnschedulablePolicy(pool.defaultTempUnschedulable, "pool.defaultTempUnschedulable");
|
||||
const runtime = readRuntimeConfig(parsed.runtime);
|
||||
const defaultTempUnschedulableTemplate = readRuntimeTemplateRef(
|
||||
pool.defaultTempUnschedulableTemplate,
|
||||
"pool.defaultTempUnschedulableTemplate",
|
||||
runtime,
|
||||
);
|
||||
const defaultTempUnschedulable = runtime.templatesById[defaultTempUnschedulableTemplate]!.tempUnschedulable;
|
||||
const defaultAccountPriorityValue = readAccountPriority(pool.defaultAccountPriority, "pool.defaultAccountPriority");
|
||||
const defaultAccountCapacityValue = readAccountCapacity(pool.defaultAccountCapacity, "pool.defaultAccountCapacity");
|
||||
const defaultAccountLoadFactorValue = readAccountLoadFactor(pool.defaultAccountLoadFactor, "pool.defaultAccountLoadFactor");
|
||||
const defaultSentinelProtect = readSentinelProtectPolicy(pool.defaultSentinelProtect, "pool.defaultSentinelProtect");
|
||||
const profiles = readProfileConfig(
|
||||
parsed.profiles,
|
||||
runtime,
|
||||
defaultTempUnschedulableTemplate,
|
||||
defaultTempUnschedulable,
|
||||
defaultSentinelProtect,
|
||||
defaultAccountPriorityValue,
|
||||
@@ -165,9 +174,11 @@ export function readCodexPoolConfig(): CodexPoolConfig {
|
||||
defaultAccountPriority: defaultAccountPriorityValue,
|
||||
defaultAccountCapacity: defaultAccountCapacityValue,
|
||||
defaultAccountLoadFactor: defaultAccountLoadFactorValue,
|
||||
defaultTempUnschedulableTemplate,
|
||||
defaultTempUnschedulable,
|
||||
defaultSentinelProtect,
|
||||
profiles,
|
||||
runtime,
|
||||
manualAccounts,
|
||||
publicExposure: readPublicExposureConfig(parsed.publicExposure),
|
||||
localCodex: readLocalCodexConfig(parsed.localCodex),
|
||||
@@ -188,8 +199,46 @@ export function readCodexPoolConfig(): CodexPoolConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
export function readRuntimeConfig(value: unknown): CodexRuntimeConfig {
|
||||
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.runtime must be a YAML object`);
|
||||
if (!Array.isArray(value.templates)) throw new Error(`${codexPoolConfigPath}.runtime.templates must be a YAML array`);
|
||||
const templates: CodexRuntimeTemplate[] = value.templates.map((item, index) => {
|
||||
if (!isRecord(item)) throw new Error(`${codexPoolConfigPath}.runtime.templates[${index}] must be a YAML object`);
|
||||
const id = requiredStringConfigField(item, "id", `runtime.templates[${index}]`);
|
||||
if (!/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(id)) {
|
||||
throw new Error(`${codexPoolConfigPath}.runtime.templates[${index}].id has an unsupported format`);
|
||||
}
|
||||
const kind = requiredStringConfigField(item, "kind", `runtime.templates[${index}]`);
|
||||
if (kind !== "temp-unschedulable") {
|
||||
throw new Error(`${codexPoolConfigPath}.runtime.templates[${index}].kind must be temp-unschedulable`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
description: readRequiredDescription(item.description, `runtime.templates[${index}].description`, 300),
|
||||
tempUnschedulable: readTempUnschedulablePolicy(item.spec, `runtime.templates[${index}].spec`),
|
||||
};
|
||||
});
|
||||
if (templates.length === 0) throw new Error(`${codexPoolConfigPath}.runtime.templates must not be empty`);
|
||||
const templatesById: Record<string, CodexRuntimeTemplate> = {};
|
||||
for (const template of templates) {
|
||||
if (templatesById[template.id] !== undefined) throw new Error(`${codexPoolConfigPath}.runtime.templates duplicates id ${template.id}`);
|
||||
templatesById[template.id] = template;
|
||||
}
|
||||
return { templates, templatesById };
|
||||
}
|
||||
|
||||
export function readRuntimeTemplateRef(value: unknown, key: string, runtime: CodexRuntimeConfig): string {
|
||||
const id = stringValue(value);
|
||||
if (id === null) throw new Error(`${codexPoolConfigPath}.${key} must be a non-empty template id`);
|
||||
if (runtime.templatesById[id] === undefined) throw new Error(`${codexPoolConfigPath}.${key} references missing runtime template ${id}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function readProfileConfig(
|
||||
value: unknown,
|
||||
runtime: CodexRuntimeConfig,
|
||||
defaultTempUnschedulableTemplate: string,
|
||||
defaultTempUnschedulable: CodexTempUnschedulablePolicy,
|
||||
defaultSentinelProtect: CodexSentinelProtectPolicy,
|
||||
defaultPriority: number,
|
||||
@@ -221,7 +270,13 @@ export function readProfileConfig(
|
||||
const priority = readAccountPriority(entry.priority, `profiles.entries[${index}].priority`, defaultPriority);
|
||||
const capacity = entry.capacity === undefined || entry.capacity === null ? null : readAccountCapacity(entry.capacity, `profiles.entries[${index}].capacity`);
|
||||
const loadFactor = entry.loadFactor === undefined || entry.loadFactor === null ? null : readAccountLoadFactor(entry.loadFactor, `profiles.entries[${index}].loadFactor`);
|
||||
const tempUnschedulable = readTempUnschedulablePolicy(entry.tempUnschedulable, `profiles.entries[${index}].tempUnschedulable`, defaultTempUnschedulable);
|
||||
if (entry.tempUnschedulable !== undefined) {
|
||||
throw new Error(`${codexPoolConfigPath}.profiles.entries[${index}].tempUnschedulable was replaced by tempUnschedulableTemplate`);
|
||||
}
|
||||
const tempUnschedulableTemplate = entry.tempUnschedulableTemplate === undefined || entry.tempUnschedulableTemplate === null
|
||||
? defaultTempUnschedulableTemplate
|
||||
: readRuntimeTemplateRef(entry.tempUnschedulableTemplate, `profiles.entries[${index}].tempUnschedulableTemplate`, runtime);
|
||||
const tempUnschedulable = runtime.templatesById[tempUnschedulableTemplate]?.tempUnschedulable ?? defaultTempUnschedulable;
|
||||
return {
|
||||
profile,
|
||||
accountName,
|
||||
@@ -236,6 +291,7 @@ export function readProfileConfig(
|
||||
priority,
|
||||
capacity,
|
||||
loadFactor,
|
||||
tempUnschedulableTemplate,
|
||||
tempUnschedulable,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -22,8 +22,9 @@ import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "..
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
|
||||
|
||||
import type { ConfirmOptions, DisclosureOptions, SentinelImageOptions, SentinelProbeOptions, SentinelReportOptions, SyncOptions, TraceOptions } from "./types";
|
||||
import { codexPoolCleanupProbes, codexPoolConfigureLocal, codexPoolExpose, codexPoolPlan, codexPoolRuntimeState, codexPoolSentinelImage, codexPoolSentinelProbe, codexPoolSentinelReport, codexPoolSync, codexPoolTrace, codexPoolValidate } from "./actions";
|
||||
import { codexPoolCleanupProbes, codexPoolConfigureLocal, codexPoolExpose, codexPoolPlan, codexPoolSentinelImage, codexPoolSentinelProbe, codexPoolSentinelReport, codexPoolSync, codexPoolTrace, codexPoolValidate } from "./actions";
|
||||
import { renderCodexPoolPlan } from "./render";
|
||||
import { codexPoolRuntime } from "./runtime";
|
||||
import { defaultCodexPoolRuntimeTargetId } from "./runtime-target";
|
||||
import { codexPoolHelp } from "./types";
|
||||
|
||||
@@ -36,7 +37,7 @@ export async function runCodexPoolCommand(config: UniDeskConfig, args: string[])
|
||||
}
|
||||
if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1)));
|
||||
if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1)));
|
||||
if (action === "runtime-state") return await codexPoolRuntimeState(config, parseDisclosureOptions(args.slice(1)));
|
||||
if (action === "runtime") return await codexPoolRuntime(config, args.slice(1));
|
||||
if (action === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1)));
|
||||
if (action === "sentinel-image") return await codexPoolSentinelImage(config, parseSentinelImageOptions(args.slice(1)));
|
||||
if (action === "sentinel-probe") return await codexPoolSentinelProbe(config, parseSentinelProbeOptions(args.slice(1)));
|
||||
|
||||
@@ -70,6 +70,7 @@ export function redactProfile(profile: CodexProfile): Record<string, unknown> {
|
||||
priority: profile.priority,
|
||||
capacity: profile.capacity,
|
||||
loadFactor: profile.loadFactor,
|
||||
tempUnschedulableTemplate: profile.tempUnschedulableTemplate,
|
||||
tempUnschedulable: tempUnschedulableSummary(profile.tempUnschedulable),
|
||||
apiKeyPresent: profile.apiKey !== null && profile.apiKey.length > 0,
|
||||
apiKeyBytes: profile.apiKey === null ? 0 : Buffer.byteLength(profile.apiKey, "utf8"),
|
||||
@@ -93,6 +94,7 @@ export function compactProfile(profile: CodexProfile): Record<string, unknown> {
|
||||
sentinelProtectConsecutiveFailures: profile.sentinelProtect.enabled ? profile.sentinelProtect.consecutiveFailures : undefined,
|
||||
capacity: profile.capacity,
|
||||
loadFactor: profile.loadFactor,
|
||||
tempUnschedulableTemplate: profile.tempUnschedulableTemplate,
|
||||
tempUnschedulableEnabled: profile.tempUnschedulable.enabled,
|
||||
tempUnschedulableRuleCount: profile.tempUnschedulable.rules.length,
|
||||
apiKeyPresent: profile.apiKey !== null && profile.apiKey.length > 0,
|
||||
@@ -142,7 +144,14 @@ export function codexPoolConfigSummary(pool: CodexPoolConfig, target: CodexPoolR
|
||||
defaultAccountPriority: pool.defaultAccountPriority,
|
||||
defaultAccountCapacity: pool.defaultAccountCapacity,
|
||||
defaultAccountLoadFactor: pool.defaultAccountLoadFactor,
|
||||
defaultTempUnschedulableTemplate: pool.defaultTempUnschedulableTemplate,
|
||||
defaultTempUnschedulable: tempUnschedulableSummary(pool.defaultTempUnschedulable),
|
||||
runtimeTemplates: pool.runtime.templates.map((template) => ({
|
||||
id: template.id,
|
||||
kind: template.kind,
|
||||
description: template.description,
|
||||
tempUnschedulable: tempUnschedulableSummary(template.tempUnschedulable),
|
||||
})),
|
||||
defaultSentinelProtect: pool.defaultSentinelProtect,
|
||||
profileCount: pool.profiles.length,
|
||||
manualAccounts: {
|
||||
@@ -188,43 +197,6 @@ export function pickSummaryFields(item: Record<string, unknown>, keys: string[])
|
||||
return result;
|
||||
}
|
||||
|
||||
export function compactRuntimeAccountState(parsed: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (parsed === null) return null;
|
||||
const state = isRecord(parsed.state) ? parsed.state : {};
|
||||
const accounts = recordArray(state.accounts).map((item) => {
|
||||
const temp = isRecord(item.tempUnschedulable) ? item.tempUnschedulable : {};
|
||||
const poolMode = isRecord(item.poolMode) ? item.poolMode : {};
|
||||
const optimization = isRecord(item.optimization) ? item.optimization : {};
|
||||
return {
|
||||
...pickSummaryFields(item, ["accountName", "accountId", "type", "status", "schedulable", "proxyId"]),
|
||||
tempEnabled: temp.enabled,
|
||||
tempStatusCodes: temp.statusCodes,
|
||||
tempActive: temp.active,
|
||||
poolModeEnabled: poolMode.enabled,
|
||||
tempUnschedulableNeedsAlignment: optimization.tempUnschedulableNeedsAlignment,
|
||||
credentialsPrinted: false,
|
||||
};
|
||||
});
|
||||
const runtimeImage = isRecord(parsed.runtimeImage) ? parsed.runtimeImage : {};
|
||||
return {
|
||||
ok: parsed.ok,
|
||||
mode: parsed.mode,
|
||||
namespace: parsed.namespace,
|
||||
serviceDns: parsed.serviceDns,
|
||||
appPod: parsed.appPod,
|
||||
runtimeImage: pickSummaryFields(runtimeImage, ["container", "image", "ready", "restartCount", "startedAt", "health"]),
|
||||
group: state.group,
|
||||
yamlBaseline: state.yamlBaseline,
|
||||
summary: state.summary,
|
||||
accounts,
|
||||
disclosure: {
|
||||
full: "bun scripts/cli.ts platform-infra sub2api codex-pool runtime-state --full",
|
||||
raw: "bun scripts/cli.ts platform-infra sub2api codex-pool runtime-state --raw",
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function compactStatusBlock(block: unknown, keys: string[]): Record<string, unknown> | null {
|
||||
if (!isRecord(block)) return null;
|
||||
const items = recordArray(block.items);
|
||||
|
||||
@@ -30,7 +30,7 @@ function pyJson(value: unknown): string {
|
||||
return `json.loads(${JSON.stringify(JSON.stringify(value))})`;
|
||||
}
|
||||
|
||||
export function remotePythonScript(mode: "sync" | "validate" | "runtime-state" | "trace" | "cleanup-probes" | "sentinel-probe", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
const hostDockerEnvPath = target.runtimeMode === "host-docker" ? target.hostDockerEnvPath : null;
|
||||
return `
|
||||
set -u
|
||||
@@ -2437,134 +2437,6 @@ def account_temp_unschedulable_status(token):
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def normalized_int(value):
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def normalized_status_codes(value):
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
codes = []
|
||||
for item in value:
|
||||
code = normalized_int(item)
|
||||
if code is not None and 100 <= code <= 599 and code not in codes:
|
||||
codes.append(code)
|
||||
return sorted(codes)
|
||||
|
||||
def temp_unschedulable_active(until):
|
||||
if not isinstance(until, str) or not until.strip():
|
||||
return False
|
||||
try:
|
||||
parsed = datetime.fromisoformat(until.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed > datetime.now(timezone.utc)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def account_runtime_state(token):
|
||||
group = next((item for item in list_groups(token) if item.get("name") == POOL_GROUP_NAME), None)
|
||||
if not isinstance(group, dict) or group.get("id") is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"group": {"name": POOL_GROUP_NAME, "id": None, "found": False},
|
||||
"accounts": [],
|
||||
"error": "pool-group-not-found",
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
baseline = normalize_temp_unschedulable_credentials(EXPECTED_DEFAULT_TEMP_UNSCHEDULABLE)
|
||||
baseline_codes = sorted(set(rule.get("error_code") for rule in baseline["rules"] if isinstance(rule.get("error_code"), int)))
|
||||
protected_names = set(item.get("accountName") for item in MANUAL_ACCOUNT_PROTECTIONS if isinstance(item, dict) and isinstance(item.get("accountName"), str))
|
||||
accounts = list_accounts_for_group(token, group["id"])
|
||||
items = []
|
||||
for account in sorted(accounts, key=lambda item: str(item.get("name") or "")):
|
||||
detail = get_account_detail(token, account)
|
||||
credentials = runtime_account_credentials(detail)
|
||||
runtime_policy = normalize_temp_unschedulable_credentials(credentials)
|
||||
runtime_codes = sorted(set(rule.get("error_code") for rule in runtime_policy["rules"] if isinstance(rule.get("error_code"), int)))
|
||||
name = detail.get("name") or account.get("name")
|
||||
pool_mode = bool_value(credentials.get("pool_mode"))
|
||||
temp_until = detail.get("temp_unschedulable_until") or detail.get("tempUnschedulableUntil")
|
||||
temp_reason = detail.get("temp_unschedulable_reason") or detail.get("tempUnschedulableReason") or ""
|
||||
origin = "yaml-managed" if name in EXPECTED_ACCOUNT_TEMP_UNSCHEDULABLE else ("yaml-protected-manual" if name in protected_names else "runtime-manual")
|
||||
supports_policy = (detail.get("type") or account.get("type")) == "apikey"
|
||||
policy_matches = runtime_policy == baseline if supports_policy else None
|
||||
missing_codes = [code for code in baseline_codes if code not in runtime_codes] if supports_policy else []
|
||||
items.append({
|
||||
"accountName": name,
|
||||
"accountId": detail.get("id") or account.get("id"),
|
||||
"origin": origin,
|
||||
"platform": detail.get("platform") or account.get("platform"),
|
||||
"type": detail.get("type") or account.get("type"),
|
||||
"status": detail.get("status") or account.get("status"),
|
||||
"schedulable": detail.get("schedulable") if detail.get("schedulable") is not None else account.get("schedulable"),
|
||||
"proxyId": detail.get("proxy_id") if detail.get("proxy_id") is not None else account.get("proxy_id"),
|
||||
"priority": detail.get("priority") if detail.get("priority") is not None else account.get("priority"),
|
||||
"concurrency": detail.get("concurrency") if detail.get("concurrency") is not None else account.get("concurrency"),
|
||||
"currentConcurrency": account.get("current_concurrency"),
|
||||
"baseUrl": normalize_runtime_base_url(credentials.get("base_url")),
|
||||
"tempUnschedulable": {
|
||||
"enabled": runtime_policy["enabled"],
|
||||
"ruleCount": len(runtime_policy["rules"]),
|
||||
"statusCodes": runtime_codes,
|
||||
"rules": summarize_temp_unschedulable_rules(runtime_policy["rules"]),
|
||||
"until": temp_until,
|
||||
"reasonPreview": text(str(temp_reason), 500) if temp_reason else "",
|
||||
"stateRecorded": temp_until is not None or bool(temp_reason),
|
||||
"active": temp_unschedulable_active(temp_until),
|
||||
"matchesYamlBaseline": policy_matches,
|
||||
"missingYamlBaselineStatusCodes": missing_codes,
|
||||
},
|
||||
"poolMode": {
|
||||
"enabled": pool_mode,
|
||||
"retryCountConfigured": normalized_int(credentials.get("pool_mode_retry_count")) if "pool_mode_retry_count" in credentials else None,
|
||||
"retryStatusCodesConfigured": normalized_status_codes(credentials.get("pool_mode_retry_status_codes")) if "pool_mode_retry_status_codes" in credentials else None,
|
||||
},
|
||||
"optimization": {
|
||||
"tempUnschedulableNeedsAlignment": supports_policy and runtime_policy != baseline,
|
||||
"poolModeShouldBeDisabled": pool_mode,
|
||||
},
|
||||
"credentialsPrinted": False,
|
||||
})
|
||||
temp_alignment = [item["accountName"] for item in items if item["optimization"]["tempUnschedulableNeedsAlignment"]]
|
||||
pool_mode_enabled = [item["accountName"] for item in items if item["poolMode"]["enabled"]]
|
||||
return {
|
||||
"ok": True,
|
||||
"group": {
|
||||
"name": POOL_GROUP_NAME,
|
||||
"id": group.get("id"),
|
||||
"found": True,
|
||||
"platform": group.get("platform"),
|
||||
"status": group.get("status"),
|
||||
},
|
||||
"yamlBaseline": {
|
||||
"enabled": baseline["enabled"],
|
||||
"ruleCount": len(baseline["rules"]),
|
||||
"statusCodes": baseline_codes,
|
||||
},
|
||||
"summary": {
|
||||
"accountCount": len(items),
|
||||
"tempUnschedulableEnabledCount": sum(1 for item in items if item["tempUnschedulable"]["enabled"]),
|
||||
"tempUnschedulableNeedsAlignmentCount": len(temp_alignment),
|
||||
"tempUnschedulableNeedsAlignment": temp_alignment,
|
||||
"poolModeEnabledCount": len(pool_mode_enabled),
|
||||
"poolModeEnabled": pool_mode_enabled,
|
||||
"optimizationAvailable": bool(temp_alignment or pool_mode_enabled),
|
||||
},
|
||||
"accounts": items,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def account_capacity_status(token):
|
||||
accounts = list_accounts(token)
|
||||
by_name = {item.get("name"): item for item in accounts if isinstance(item.get("name"), str)}
|
||||
@@ -2905,21 +2777,6 @@ def run_validate():
|
||||
"validation": {"gatewayModels": gateway, "gatewayResponses": responses_smoke, "gatewayResponsesRecent": responses_evidence, "gatewayCompactRecent": compact_evidence},
|
||||
}
|
||||
|
||||
def run_runtime_state():
|
||||
admin_email, token, admin_compliance = login()
|
||||
state = account_runtime_state(token)
|
||||
return {
|
||||
"ok": state.get("ok") is True,
|
||||
"mode": "runtime-state",
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": APP_POD,
|
||||
"admin": {"email": admin_email, "tokenPrinted": False, "compliance": admin_compliance},
|
||||
"runtimeImage": app_pod_runtime_image(),
|
||||
"state": state,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def parse_log_line(line):
|
||||
json_start = line.find("{")
|
||||
if json_start < 0:
|
||||
@@ -3430,8 +3287,6 @@ def run_cleanup_probes():
|
||||
try:
|
||||
if MODE == "sync":
|
||||
result = run_sync()
|
||||
elif MODE == "runtime-state":
|
||||
result = run_runtime_state()
|
||||
elif MODE == "trace":
|
||||
result = run_trace()
|
||||
elif MODE == "cleanup-probes":
|
||||
|
||||
@@ -35,10 +35,6 @@ export function validateScript(pool: CodexPoolConfig, target: CodexPoolRuntimeTa
|
||||
return remotePythonScript("validate", "", pool, target);
|
||||
}
|
||||
|
||||
export function runtimeStateScript(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
return remotePythonScript("runtime-state", "", pool, target);
|
||||
}
|
||||
|
||||
export function traceScript(pool: CodexPoolConfig, options: TraceOptions, target: CodexPoolRuntimeTarget): string {
|
||||
const encoded = Buffer.from(JSON.stringify({
|
||||
requestId: options.requestId,
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function capture(config: UniDeskConfig, target: string, args: strin
|
||||
return await runSshCommandCapture(config, target, args, input);
|
||||
}
|
||||
|
||||
export type RemoteCodexPoolMode = "sync" | "validate" | "runtime-state" | "sentinel-probe" | "sentinel-image-status" | "sentinel-image-build";
|
||||
export type RemoteCodexPoolMode = "sync" | "validate" | "runtime" | "sentinel-probe" | "sentinel-image-status" | "sentinel-image-build";
|
||||
|
||||
export async function runRemoteCodexPoolScript(config: UniDeskConfig, mode: RemoteCodexPoolMode, script: string, target = codexPoolRuntimeTarget()): Promise<SshCaptureResult> {
|
||||
const jobName = `codex-pool-${mode}-${Date.now().toString(36)}`.slice(0, 63);
|
||||
@@ -83,7 +83,7 @@ export async function runRemoteCodexPoolScript(config: UniDeskConfig, mode: Remo
|
||||
|
||||
export function codexPoolModeCommand(mode: RemoteCodexPoolMode): string {
|
||||
if (mode === "sync") return "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm";
|
||||
if (mode === "runtime-state") return "bun scripts/cli.ts platform-infra sub2api codex-pool runtime-state";
|
||||
if (mode === "runtime") return "bun scripts/cli.ts platform-infra sub2api codex-pool runtime list";
|
||||
if (mode === "sentinel-probe") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account <accountName> --confirm";
|
||||
if (mode === "sentinel-image-status") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status";
|
||||
if (mode === "sentinel-image-build") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image build --confirm";
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import type { UniDeskConfig } from "../config";
|
||||
|
||||
import { desiredAccountNames } from "./accounts";
|
||||
import { readCodexPoolConfig } from "./config";
|
||||
import { protectedManualAccountsForTarget } from "./public-exposure";
|
||||
import { renderSub2ApiTempUnschedulableCredentials } from "./redaction";
|
||||
import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote";
|
||||
import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target";
|
||||
|
||||
type RuntimeAction = "list" | "get" | "errors" | "apply" | "delete";
|
||||
|
||||
interface RuntimeOptions {
|
||||
action: RuntimeAction;
|
||||
account: string | null;
|
||||
template: string | null;
|
||||
kind: "temp-unschedulable";
|
||||
confirm: boolean;
|
||||
full: boolean;
|
||||
raw: boolean;
|
||||
since: string;
|
||||
tail: number;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
||||
const options = parseRuntimeOptions(args);
|
||||
const pool = readCodexPoolConfig();
|
||||
const target = codexPoolRuntimeTarget(options.targetId);
|
||||
const selectedTemplate = options.template === null ? null : pool.runtime.templatesById[options.template];
|
||||
if (options.template !== null && selectedTemplate === undefined) {
|
||||
throw new Error(`unknown runtime template ${options.template}; available: ${pool.runtime.templates.map((item) => item.id).join(", ")}`);
|
||||
}
|
||||
const payload = {
|
||||
action: options.action,
|
||||
account: options.account,
|
||||
kind: options.kind,
|
||||
confirm: options.confirm,
|
||||
full: options.full,
|
||||
since: options.since,
|
||||
tail: options.tail,
|
||||
groupName: pool.groupName,
|
||||
adminEmailDefault: pool.adminEmailDefault,
|
||||
managedAccountNames: desiredAccountNames(pool),
|
||||
protectedManualAccountNames: protectedManualAccountsForTarget(pool, target).map((item) => item.accountName),
|
||||
templates: pool.runtime.templates.map((template) => ({
|
||||
id: template.id,
|
||||
kind: template.kind,
|
||||
description: template.description,
|
||||
credentials: renderSub2ApiTempUnschedulableCredentials(template.tempUnschedulable),
|
||||
})),
|
||||
selectedTemplate: selectedTemplate === null || selectedTemplate === undefined ? null : {
|
||||
id: selectedTemplate.id,
|
||||
kind: selectedTemplate.kind,
|
||||
credentials: renderSub2ApiTempUnschedulableCredentials(selectedTemplate.tempUnschedulable),
|
||||
},
|
||||
};
|
||||
const result = await runRemoteCodexPoolScript(config, "runtime", runtimeScript(payload, target), target);
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const ok = result.exitCode === 0 && boolField(parsed, "ok", false);
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-runtime",
|
||||
remote: compactCapture(result, { full: true }),
|
||||
parsed,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-runtime",
|
||||
target: {
|
||||
id: target.id,
|
||||
route: target.route,
|
||||
namespace: target.namespace,
|
||||
runtimeMode: target.runtimeMode,
|
||||
endpoint: target.serviceDns,
|
||||
},
|
||||
request: {
|
||||
operation: options.action,
|
||||
account: options.account,
|
||||
template: options.template,
|
||||
kind: options.kind,
|
||||
confirm: options.confirm,
|
||||
since: options.action === "errors" ? options.since : undefined,
|
||||
tail: options.action === "errors" ? options.tail : undefined,
|
||||
},
|
||||
runtime: parsed,
|
||||
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 || parsed === null }),
|
||||
disclosure: options.action === "list"
|
||||
? {
|
||||
get: "bun scripts/cli.ts platform-infra sub2api codex-pool runtime get --account <name-or-id>",
|
||||
errors: "bun scripts/cli.ts platform-infra sub2api codex-pool runtime errors [--account <name-or-id>] [--since 24h]",
|
||||
}
|
||||
: undefined,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeOptions(args: string[]): RuntimeOptions {
|
||||
const [actionRaw = "list", ...rest] = args;
|
||||
if (actionRaw !== "list" && actionRaw !== "get" && actionRaw !== "errors" && actionRaw !== "apply" && actionRaw !== "delete") {
|
||||
throw new Error("runtime usage: list|get|errors|apply|delete [--account <name-or-id>] [--since 24h] [--tail 50000] [--template <id>] [--kind temp-unschedulable] [--confirm] [--target <id>] [--full|--raw]");
|
||||
}
|
||||
let account: string | null = null;
|
||||
let template: string | null = null;
|
||||
let kind = "temp-unschedulable" as const;
|
||||
let confirm = false;
|
||||
let full = false;
|
||||
let raw = false;
|
||||
let since = "24h";
|
||||
let tail = 50_000;
|
||||
let targetId = defaultCodexPoolRuntimeTargetId();
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
const arg = rest[index]!;
|
||||
const readValue = (name: string): string => {
|
||||
const value = rest[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
index += 1;
|
||||
return value.trim();
|
||||
};
|
||||
if (arg === "--confirm") confirm = true;
|
||||
else if (arg === "--full") full = true;
|
||||
else if (arg === "--raw") raw = true;
|
||||
else if (arg === "--account") account = readValue("--account");
|
||||
else if (arg.startsWith("--account=")) account = arg.slice("--account=".length).trim();
|
||||
else if (arg === "--template") template = readValue("--template");
|
||||
else if (arg.startsWith("--template=")) template = arg.slice("--template=".length).trim();
|
||||
else if (arg === "--kind") kind = parseRuntimeKind(readValue("--kind"));
|
||||
else if (arg.startsWith("--kind=")) kind = parseRuntimeKind(arg.slice("--kind=".length));
|
||||
else if (arg === "--target") targetId = readValue("--target");
|
||||
else if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length).trim();
|
||||
else if (arg === "--since") since = readValue("--since");
|
||||
else if (arg.startsWith("--since=")) since = arg.slice("--since=".length).trim();
|
||||
else if (arg === "--tail") tail = parseRuntimeTail(readValue("--tail"));
|
||||
else if (arg.startsWith("--tail=")) tail = parseRuntimeTail(arg.slice("--tail=".length));
|
||||
else throw new Error(`unsupported runtime option: ${arg}`);
|
||||
}
|
||||
if ((actionRaw === "get" || actionRaw === "apply" || actionRaw === "delete") && !account) {
|
||||
throw new Error(`runtime ${actionRaw} requires --account <name-or-id>`);
|
||||
}
|
||||
if (account !== null && (account.length > 256 || /[\r\n]/u.test(account))) throw new Error("--account has an unsupported format");
|
||||
if (actionRaw === "apply" && !template) throw new Error("runtime apply requires --template <id>");
|
||||
if (actionRaw !== "apply" && template !== null) throw new Error(`runtime ${actionRaw} does not accept --template`);
|
||||
if (actionRaw !== "errors" && (since !== "24h" || tail !== 50_000)) throw new Error(`runtime ${actionRaw} does not accept --since or --tail`);
|
||||
if (!/^\d+[mhd]$/u.test(since)) throw new Error("--since must use <number>m, <number>h, or <number>d");
|
||||
if ((actionRaw === "list" || actionRaw === "get" || actionRaw === "errors") && confirm) throw new Error(`runtime ${actionRaw} does not accept --confirm`);
|
||||
if (full && raw) throw new Error("use only one of --full or --raw");
|
||||
return { action: actionRaw, account, template, kind, confirm, full, raw, since, tail, targetId };
|
||||
}
|
||||
|
||||
function parseRuntimeKind(value: string): "temp-unschedulable" {
|
||||
if (value !== "temp-unschedulable") throw new Error("--kind must be temp-unschedulable");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseRuntimeTail(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 100 || parsed > 200_000) throw new Error("--tail must be an integer from 100 to 200000");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function pyJson(value: unknown): string {
|
||||
return `json.loads(${JSON.stringify(JSON.stringify(value))})`;
|
||||
}
|
||||
|
||||
function runtimeScript(payload: unknown, target: ReturnType<typeof codexPoolRuntimeTarget>): string {
|
||||
return `
|
||||
set -u
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
RUNTIME_MODE = ${pyJson(target.runtimeMode)}
|
||||
NAMESPACE = ${pyJson(target.namespace)}
|
||||
HOST_DOCKER_APP_PORT = ${pyJson(target.hostDockerAppPort)}
|
||||
HOST_DOCKER_ENV_PATH = ${pyJson(target.hostDockerEnvPath)}
|
||||
HOST_DOCKER_APP_CONTAINER = "sub2api-app"
|
||||
APP_SECRET_NAME = ${pyJson(target.appSecretName)}
|
||||
PAYLOAD = ${pyJson(payload)}
|
||||
|
||||
def run(cmd, input_bytes=None):
|
||||
return subprocess.run(cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
def text(value, limit=1000):
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8", errors="replace")
|
||||
return str(value)[-limit:]
|
||||
|
||||
def docker(args):
|
||||
proc = run(["docker", *args])
|
||||
if proc.returncode == 0:
|
||||
return proc
|
||||
sudo_proc = run(["sudo", "-n", "docker", *args])
|
||||
return sudo_proc if sudo_proc.returncode == 0 else proc
|
||||
|
||||
def kubectl(args, input_bytes=None):
|
||||
return run(["kubectl", *args], input_bytes)
|
||||
|
||||
def kube_json(args, label):
|
||||
proc = kubectl([*args, "-o", "json"])
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"{label} failed: {text(proc.stderr)}")
|
||||
return json.loads(proc.stdout.decode("utf-8"))
|
||||
|
||||
def read_host_env():
|
||||
try:
|
||||
with open(HOST_DOCKER_ENV_PATH, "r", encoding="utf-8") as handle:
|
||||
lines = handle.read().splitlines()
|
||||
except PermissionError:
|
||||
proc = run(["sudo", "-n", "cat", HOST_DOCKER_ENV_PATH])
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError("read host env failed: " + text(proc.stderr))
|
||||
lines = proc.stdout.decode("utf-8", errors="replace").splitlines()
|
||||
values = {}
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1]
|
||||
values[key.strip()] = value
|
||||
return values
|
||||
|
||||
def select_app_pod():
|
||||
if RUNTIME_MODE == "host-docker":
|
||||
return HOST_DOCKER_APP_CONTAINER
|
||||
pods = kube_json(["-n", NAMESPACE, "get", "pods", "-l", "app.kubernetes.io/name=sub2api"], "sub2api pods").get("items") or []
|
||||
running = [item for item in pods if (item.get("status") or {}).get("phase") == "Running"]
|
||||
if not running:
|
||||
raise RuntimeError("sub2api app pod not found")
|
||||
return running[0]["metadata"]["name"]
|
||||
|
||||
APP_POD = select_app_pod()
|
||||
|
||||
def config_value(name, key, fallback=None):
|
||||
if RUNTIME_MODE == "host-docker":
|
||||
return read_host_env().get(key) or fallback
|
||||
data = kube_json(["-n", NAMESPACE, "get", "configmap", name], f"configmap/{name}").get("data") or {}
|
||||
return data.get(key) or fallback
|
||||
|
||||
def secret_value(name, key):
|
||||
if RUNTIME_MODE == "host-docker":
|
||||
return read_host_env().get(key)
|
||||
import base64
|
||||
data = kube_json(["-n", NAMESPACE, "get", "secret", name], f"secret/{name}").get("data") or {}
|
||||
return base64.b64decode(data[key]).decode("utf-8") if key in data else None
|
||||
|
||||
def parse_curl(proc):
|
||||
output = proc.stdout.decode("utf-8", errors="replace")
|
||||
marker = "\\n__HTTP_CODE__:"
|
||||
pos = output.rfind(marker)
|
||||
if pos < 0:
|
||||
return {"ok": False, "httpStatus": 0, "json": None, "body": output, "stderr": text(proc.stderr)}
|
||||
body = output[:pos]
|
||||
try:
|
||||
status = int(output[pos + len(marker):].strip()[-3:])
|
||||
except ValueError:
|
||||
status = 0
|
||||
try:
|
||||
parsed = json.loads(body) if body.strip() else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
return {"ok": proc.returncode == 0 and 200 <= status < 300, "httpStatus": status, "json": parsed, "body": body, "stderr": text(proc.stderr)}
|
||||
|
||||
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" ] && [ "$method" = GET ] && [ ! -s "$tmp" ]; then
|
||||
curl -sS -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H "Authorization: Bearer $token" "$url"
|
||||
elif [ -n "$token" ]; then
|
||||
curl -sS -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "Authorization: Bearer $token" --data-binary @"$tmp" "$url"
|
||||
elif [ "$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
|
||||
'''
|
||||
if RUNTIME_MODE == "host-docker":
|
||||
proc = run(["sh", "-c", script, "sh", method, f"http://127.0.0.1:{HOST_DOCKER_APP_PORT}{path}", bearer or ""], body)
|
||||
else:
|
||||
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(proc)
|
||||
|
||||
def data_of(response, label):
|
||||
parsed = response.get("json")
|
||||
code = parsed.get("code") if isinstance(parsed, dict) else None
|
||||
if response.get("ok") is not True or (code is not None and code != 0):
|
||||
message = parsed.get("message") if isinstance(parsed, dict) else text(response.get("body"), 500)
|
||||
raise RuntimeError(f"{label} failed: http={response.get('httpStatus')} message={message}")
|
||||
return parsed.get("data") if isinstance(parsed, dict) and "data" in parsed else parsed
|
||||
|
||||
def items_of(data):
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
for key in ("items", "groups", "accounts"):
|
||||
if isinstance(data.get(key), list):
|
||||
return data[key]
|
||||
return []
|
||||
|
||||
def login():
|
||||
email = config_value("sub2api-config", "ADMIN_EMAIL", PAYLOAD["adminEmailDefault"])
|
||||
password = secret_value(APP_SECRET_NAME, "ADMIN_PASSWORD")
|
||||
if not password:
|
||||
raise RuntimeError("ADMIN_PASSWORD missing")
|
||||
data = data_of(curl_api("POST", "/api/v1/auth/login", payload={"email": email, "password": password}), "admin login")
|
||||
token = None
|
||||
if isinstance(data, dict):
|
||||
token = data.get("access_token") or data.get("token")
|
||||
if not token:
|
||||
raise RuntimeError("admin login response has no token")
|
||||
return token
|
||||
|
||||
def list_groups(token):
|
||||
return items_of(data_of(curl_api("GET", "/api/v1/admin/groups/all?platform=openai", bearer=token), "list groups"))
|
||||
|
||||
def list_group_accounts(token, group_id):
|
||||
path = f"/api/v1/admin/accounts?group_id={group_id}&page=1&page_size=500&platform=openai"
|
||||
return items_of(data_of(curl_api("GET", path, bearer=token), "list group accounts"))
|
||||
|
||||
def account_detail(token, account_id):
|
||||
return data_of(curl_api("GET", f"/api/v1/admin/accounts/{account_id}", bearer=token), "get account")
|
||||
|
||||
def normalize_policy(credentials):
|
||||
if not isinstance(credentials, dict):
|
||||
credentials = {}
|
||||
enabled = credentials.get("temp_unschedulable_enabled") is True
|
||||
raw = credentials.get("temp_unschedulable_rules")
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
raw = []
|
||||
rules = raw if isinstance(raw, list) else []
|
||||
codes = sorted(set(item.get("error_code") for item in rules if isinstance(item, dict) and isinstance(item.get("error_code"), int)))
|
||||
return {"enabled": enabled, "ruleCount": len(rules), "statusCodes": codes, "rules": rules}
|
||||
|
||||
def template_summary(template):
|
||||
policy = normalize_policy(template.get("credentials"))
|
||||
return {"id": template.get("id"), "kind": template.get("kind"), "description": template.get("description"), **{key: policy[key] for key in ("enabled", "ruleCount", "statusCodes")}}
|
||||
|
||||
def matching_template(credentials):
|
||||
current = normalize_policy(credentials)
|
||||
for template in PAYLOAD["templates"]:
|
||||
if normalize_policy(template.get("credentials")) == current:
|
||||
return template.get("id")
|
||||
return None
|
||||
|
||||
def account_summary(detail, include_rules=False):
|
||||
credentials = detail.get("credentials") if isinstance(detail.get("credentials"), dict) else {}
|
||||
policy = normalize_policy(credentials)
|
||||
name = detail.get("name")
|
||||
management = "yaml-managed" if name in PAYLOAD["managedAccountNames"] else ("yaml-protected-manual" if name in PAYLOAD["protectedManualAccountNames"] else "runtime-manual")
|
||||
result = {
|
||||
"accountName": name,
|
||||
"accountId": detail.get("id"),
|
||||
"management": management,
|
||||
"type": detail.get("type"),
|
||||
"status": detail.get("status"),
|
||||
"schedulable": detail.get("schedulable"),
|
||||
"proxyId": detail.get("proxy_id"),
|
||||
"tempUnschedulable": {key: policy[key] for key in ("enabled", "ruleCount", "statusCodes")},
|
||||
"matchingTemplate": matching_template(credentials),
|
||||
"poolModeEnabled": credentials.get("pool_mode") is True,
|
||||
"credentialsPrinted": False,
|
||||
}
|
||||
if include_rules:
|
||||
result["tempUnschedulable"]["rules"] = policy["rules"]
|
||||
return result
|
||||
|
||||
def policy_summary(policy, include_rules=False):
|
||||
keys = ("enabled", "ruleCount", "statusCodes", "rules") if include_rules else ("enabled", "ruleCount", "statusCodes")
|
||||
return {key: policy[key] for key in keys}
|
||||
|
||||
def find_account(token, accounts, selector):
|
||||
exact = [item for item in accounts if str(item.get("id")) == selector or item.get("name") == selector]
|
||||
if len(exact) != 1:
|
||||
raise RuntimeError("account-not-found-or-ambiguous: " + selector)
|
||||
return account_detail(token, exact[0]["id"])
|
||||
|
||||
def runtime_log_lines():
|
||||
since = PAYLOAD.get("since", "24h")
|
||||
tail = str(PAYLOAD.get("tail", 50000))
|
||||
if RUNTIME_MODE == "host-docker":
|
||||
proc = docker(["logs", "--since", since, "--tail", tail, APP_POD])
|
||||
else:
|
||||
proc = kubectl(["-n", NAMESPACE, "logs", APP_POD, "--since=" + since, "--tail=" + tail])
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError("read Sub2API runtime logs failed: " + text(proc.stderr))
|
||||
output = proc.stdout + b"\\n" + proc.stderr
|
||||
return output.decode("utf-8", errors="replace").splitlines()
|
||||
|
||||
def compact_error(value):
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return " ".join(value.replace("\x00", "").split())[:240]
|
||||
|
||||
def observed_runtime_errors(accounts):
|
||||
selector = PAYLOAD.get("account")
|
||||
selected = [item for item in accounts if selector is None or str(item.get("id")) == str(selector) or item.get("name") == selector]
|
||||
if selector is not None and len(selected) != 1:
|
||||
raise RuntimeError("account-not-found-or-ambiguous: " + str(selector))
|
||||
selected_ids = set(item.get("id") for item in selected)
|
||||
stats = {}
|
||||
for item in selected:
|
||||
stats[item.get("id")] = {
|
||||
"accountName": item.get("name"),
|
||||
"accountId": item.get("id"),
|
||||
"upstreamEventCount": 0,
|
||||
"observedStatusCodes": {},
|
||||
"forwardFailureCount": 0,
|
||||
"forwardFailureStatusCodes": {},
|
||||
"lastSeenAt": None,
|
||||
"errorSamples": [],
|
||||
}
|
||||
scanned = 0
|
||||
for line in runtime_log_lines():
|
||||
if "account_upstream_error" not in line and "openai.forward_failed" not in line:
|
||||
continue
|
||||
json_start = line.find("{")
|
||||
if json_start < 0:
|
||||
continue
|
||||
try:
|
||||
item = json.loads(line[json_start:])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
account_id = item.get("account_id") if isinstance(item, dict) else None
|
||||
if account_id not in selected_ids:
|
||||
continue
|
||||
scanned += 1
|
||||
at = line.split("\t", 1)[0].strip() or None
|
||||
current = stats[account_id]
|
||||
if at and (current["lastSeenAt"] is None or at > current["lastSeenAt"]):
|
||||
current["lastSeenAt"] = at
|
||||
if "account_upstream_error" in line:
|
||||
code = item.get("status_code")
|
||||
if isinstance(code, int):
|
||||
key = str(code)
|
||||
current["observedStatusCodes"][key] = current["observedStatusCodes"].get(key, 0) + 1
|
||||
current["upstreamEventCount"] += 1
|
||||
continue
|
||||
current["forwardFailureCount"] += 1
|
||||
error = compact_error(item.get("error"))
|
||||
match = re.search(r"(?:upstream error:\\s*|status(?:_code)?[=:]\\s*)(\\d{3})", error, re.IGNORECASE)
|
||||
if match:
|
||||
key = match.group(1)
|
||||
current["forwardFailureStatusCodes"][key] = current["forwardFailureStatusCodes"].get(key, 0) + 1
|
||||
if error and error not in current["errorSamples"] and len(current["errorSamples"]) < 3:
|
||||
current["errorSamples"].append(error)
|
||||
rows = []
|
||||
aggregate = {}
|
||||
for item in selected:
|
||||
current = stats[item.get("id")]
|
||||
observed = [{"statusCode": int(code), "count": count} for code, count in sorted(current["observedStatusCodes"].items(), key=lambda pair: int(pair[0]))]
|
||||
forwarded = [{"statusCode": int(code), "count": count} for code, count in sorted(current["forwardFailureStatusCodes"].items(), key=lambda pair: int(pair[0]))]
|
||||
for entry in observed:
|
||||
key = str(entry["statusCode"])
|
||||
aggregate[key] = aggregate.get(key, 0) + entry["count"]
|
||||
current["observedStatusCodes"] = observed
|
||||
current["forwardFailureStatusCodes"] = forwarded
|
||||
if PAYLOAD.get("full") is not True:
|
||||
current["errorSamples"] = current["errorSamples"][:1]
|
||||
if current["upstreamEventCount"] > 0 or current["forwardFailureCount"] > 0 or selector is not None:
|
||||
rows.append(current)
|
||||
return {
|
||||
"window": {"since": PAYLOAD.get("since"), "tail": PAYLOAD.get("tail"), "matchedLogEvents": scanned},
|
||||
"aggregateObservedStatusCodes": [{"statusCode": int(code), "count": count} for code, count in sorted(aggregate.items(), key=lambda pair: int(pair[0]))],
|
||||
"accountCountWithErrors": sum(1 for item in rows if item["upstreamEventCount"] > 0 or item["forwardFailureCount"] > 0),
|
||||
"accounts": rows,
|
||||
"message": "observedStatusCodes comes only from account_upstream_error; forward failures are supplemental message evidence",
|
||||
}
|
||||
|
||||
def runtime_result():
|
||||
token = login()
|
||||
group = next((item for item in list_groups(token) if item.get("name") == PAYLOAD["groupName"]), None)
|
||||
if not isinstance(group, dict) or group.get("id") is None:
|
||||
raise RuntimeError("pool-group-not-found")
|
||||
accounts = list_group_accounts(token, group["id"])
|
||||
base = {
|
||||
"group": {"id": group.get("id"), "name": group.get("name"), "status": group.get("status")},
|
||||
"templates": [template_summary(item) for item in PAYLOAD["templates"]],
|
||||
"endpoint": "host-docker" if RUNTIME_MODE == "host-docker" else "k3s-service",
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
action = PAYLOAD["action"]
|
||||
if action == "list":
|
||||
details = [account_detail(token, item["id"]) for item in accounts]
|
||||
return {**base, "ok": True, "operation": "list", "mutation": False, "accountCount": len(details), "accounts": [account_summary(item) for item in sorted(details, key=lambda value: value.get("name") or "")]}
|
||||
if action == "errors":
|
||||
errors = observed_runtime_errors(accounts)
|
||||
return {**base, "ok": True, "operation": "errors", "mutation": False, "errors": errors}
|
||||
detail = find_account(token, accounts, str(PAYLOAD["account"]))
|
||||
if action == "get":
|
||||
return {**base, "ok": True, "operation": "get", "mutation": False, "account": account_summary(detail, True)}
|
||||
if detail.get("type") != "apikey":
|
||||
raise RuntimeError("runtime temp-unschedulable policy requires an apikey account")
|
||||
credentials = dict(detail.get("credentials") or {})
|
||||
before = normalize_policy(credentials)
|
||||
if action == "apply":
|
||||
template = PAYLOAD.get("selectedTemplate")
|
||||
if not isinstance(template, dict):
|
||||
raise RuntimeError("selected runtime template missing")
|
||||
desired_fields = template.get("credentials") or {}
|
||||
desired = dict(credentials)
|
||||
desired.update(desired_fields)
|
||||
after_plan = normalize_policy(desired)
|
||||
exists = "temp_unschedulable_enabled" in credentials or "temp_unschedulable_rules" in credentials
|
||||
change = "noop" if before == after_plan else ("update" if exists else "create")
|
||||
else:
|
||||
desired = dict(credentials)
|
||||
desired.pop("temp_unschedulable_enabled", None)
|
||||
desired.pop("temp_unschedulable_rules", None)
|
||||
after_plan = normalize_policy(desired)
|
||||
change = "delete" if before != after_plan or "temp_unschedulable_enabled" in credentials or "temp_unschedulable_rules" in credentials else "noop"
|
||||
include_plan_rules = PAYLOAD.get("full") is True
|
||||
plan = {
|
||||
"change": change,
|
||||
"account": account_summary(detail, include_plan_rules),
|
||||
"before": policy_summary(before, include_plan_rules),
|
||||
"after": policy_summary(after_plan, include_plan_rules),
|
||||
"template": PAYLOAD.get("selectedTemplate", {}).get("id") if isinstance(PAYLOAD.get("selectedTemplate"), dict) else None,
|
||||
"credentialsPrinted": False,
|
||||
}
|
||||
if PAYLOAD.get("confirm") is not True or change == "noop":
|
||||
return {**base, "ok": True, "operation": action, "mode": "dry-run" if PAYLOAD.get("confirm") is not True else "confirmed-noop", "mutation": False, "plan": plan}
|
||||
updated = data_of(curl_api("PUT", f"/api/v1/admin/accounts/{detail['id']}", bearer=token, payload={"credentials": desired}), "update account runtime config")
|
||||
after_detail = updated if isinstance(updated, dict) else account_detail(token, detail["id"])
|
||||
return {**base, "ok": True, "operation": action, "mode": "confirmed", "mutation": True, "plan": plan, "account": account_summary(after_detail, include_plan_rules)}
|
||||
|
||||
try:
|
||||
result = runtime_result()
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "operation": PAYLOAD.get("action"), "mutation": False, "error": str(exc), "valuesPrinted": False}
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
sys.exit(0 if result.get("ok") else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
@@ -139,6 +139,7 @@ export interface CodexProfile {
|
||||
priority: number;
|
||||
capacity: number;
|
||||
loadFactor: number;
|
||||
tempUnschedulableTemplate: string;
|
||||
tempUnschedulable: CodexTempUnschedulablePolicy;
|
||||
authOpenAIKeyShape: string;
|
||||
ok: boolean;
|
||||
@@ -159,6 +160,18 @@ export interface CodexTempUnschedulablePolicy {
|
||||
rules: CodexTempUnschedulableRule[];
|
||||
}
|
||||
|
||||
export interface CodexRuntimeTemplate {
|
||||
id: string;
|
||||
kind: "temp-unschedulable";
|
||||
description: string;
|
||||
tempUnschedulable: CodexTempUnschedulablePolicy;
|
||||
}
|
||||
|
||||
export interface CodexRuntimeConfig {
|
||||
templates: CodexRuntimeTemplate[];
|
||||
templatesById: Record<string, CodexRuntimeTemplate>;
|
||||
}
|
||||
|
||||
export interface CodexPoolConfig {
|
||||
version: number;
|
||||
kind: string;
|
||||
@@ -179,9 +192,11 @@ export interface CodexPoolConfig {
|
||||
defaultAccountPriority: number;
|
||||
defaultAccountCapacity: number;
|
||||
defaultAccountLoadFactor: number;
|
||||
defaultTempUnschedulableTemplate: string;
|
||||
defaultTempUnschedulable: CodexTempUnschedulablePolicy;
|
||||
defaultSentinelProtect: CodexSentinelProtectPolicy;
|
||||
profiles: CodexPoolProfileConfig[];
|
||||
runtime: CodexRuntimeConfig;
|
||||
manualAccounts: CodexPoolManualAccountsConfig;
|
||||
publicExposure: CodexPoolPublicExposureConfig;
|
||||
localCodex: CodexPoolLocalCodexConfig;
|
||||
@@ -251,6 +266,7 @@ export interface CodexPoolProfileConfig {
|
||||
priority: number;
|
||||
capacity: number | null;
|
||||
loadFactor: number | null;
|
||||
tempUnschedulableTemplate: string;
|
||||
tempUnschedulable: CodexTempUnschedulablePolicy;
|
||||
}
|
||||
|
||||
@@ -325,14 +341,18 @@ export function codexPoolHelp(): unknown {
|
||||
const pool = readCodexPoolConfig();
|
||||
const runtimeTarget = codexPoolRuntimeTarget();
|
||||
return {
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate|runtime-state|trace|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local",
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|trace|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local",
|
||||
output: "json, except trace and sentinel-report default to low-noise text tables",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan --target D601",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sync [--target D601] --confirm [--prune-removed]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate [--target D601] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime-state [--target PK01] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime list [--target PK01] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime get --account <name-or-id> [--target PK01] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime errors [--account <name-or-id>] [--since 24h] [--tail 50000] [--target PK01] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account <name-or-id> --template <id> [--target PK01] [--confirm]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --account <name-or-id> --kind temp-unschedulable [--target PK01] [--confirm]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool trace [--target D601] --request-id <requestId> [--since 24h|--tail 20000|--context-seconds 300|--show-lines|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status [--target D601]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image build [--target D601] --confirm",
|
||||
|
||||
Reference in New Issue
Block a user