Protect manual Sub2API account from sentinel
This commit is contained in:
@@ -134,6 +134,10 @@ profiles:
|
||||
accountName: unidesk-codex-socap
|
||||
configFile: config.toml.socap
|
||||
authFile: auth.json.socap
|
||||
manualAccounts:
|
||||
protected:
|
||||
- accountName: lucianepidgeon@gmail.com
|
||||
reason: Manually configured in Sub2API; keep outside UniDesk-managed Codex pool and sentinel control.
|
||||
publicExposure:
|
||||
enabled: false
|
||||
proxyName: platform-infra-sub2api
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface CodexPoolSentinelConfig {
|
||||
usdPer1MOutputTokens: number;
|
||||
};
|
||||
historyLimit: number;
|
||||
protectedManualAccounts?: string[];
|
||||
}
|
||||
|
||||
export interface CodexPoolSentinelImageTarget {
|
||||
@@ -157,6 +158,7 @@ export function defaultCodexPoolSentinelConfig(): CodexPoolSentinelConfig {
|
||||
usdPer1MOutputTokens: 10,
|
||||
},
|
||||
historyLimit: 200,
|
||||
protectedManualAccounts: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,6 +297,7 @@ export function codexPoolSentinelSummary(config: CodexPoolSentinelConfig): Recor
|
||||
sdk: config.sdk,
|
||||
cadence: config.cadence,
|
||||
freeze: config.freeze,
|
||||
protectedManualAccountCount: config.protectedManualAccounts?.length ?? 0,
|
||||
accounting: {
|
||||
mode: "record-only",
|
||||
pricing: config.pricing,
|
||||
@@ -329,6 +332,7 @@ export function renderCodexPoolSentinelManifest(
|
||||
cadence: config.cadence,
|
||||
freeze: config.freeze,
|
||||
pricing: config.pricing,
|
||||
protectedManualAccounts: config.protectedManualAccounts ?? [],
|
||||
state: {
|
||||
configMapName: config.stateConfigMapName,
|
||||
historyLimit: config.historyLimit,
|
||||
@@ -778,6 +782,8 @@ class Sub2ApiAdmin:
|
||||
self.base = config["service"]["baseUrl"].rstrip("/")
|
||||
self.email = os.environ.get("ADMIN_EMAIL") or config["service"]["adminEmailDefault"]
|
||||
self.password = os.environ.get("ADMIN_PASSWORD") or ""
|
||||
protected = config.get("protectedManualAccounts") if isinstance(config.get("protectedManualAccounts"), list) else []
|
||||
self.protected_manual_accounts = set(str(item) for item in protected if isinstance(item, str) and item)
|
||||
self.token = None
|
||||
self.accounts_by_name = None
|
||||
|
||||
@@ -847,6 +853,14 @@ class Sub2ApiAdmin:
|
||||
return None
|
||||
|
||||
def set_schedulable(self, account_name, schedulable):
|
||||
if account_name in self.protected_manual_accounts:
|
||||
return {
|
||||
"accountId": None,
|
||||
"previousSchedulable": None,
|
||||
"schedulable": None,
|
||||
"skipped": True,
|
||||
"reason": "protected-manual-account",
|
||||
}
|
||||
account = self.account(account_name)
|
||||
if not account or account.get("id") is None:
|
||||
raise RuntimeError(f"account {account_name} not found")
|
||||
|
||||
@@ -146,11 +146,21 @@ interface CodexPoolConfig {
|
||||
defaultTempUnschedulable: CodexTempUnschedulablePolicy;
|
||||
defaultSentinelProtect: CodexSentinelProtectPolicy;
|
||||
profiles: CodexPoolProfileConfig[];
|
||||
manualAccounts: CodexPoolManualAccountsConfig;
|
||||
publicExposure: CodexPoolPublicExposureConfig;
|
||||
localCodex: CodexPoolLocalCodexConfig;
|
||||
sentinel: CodexPoolSentinelConfig;
|
||||
}
|
||||
|
||||
interface CodexPoolManualAccountsConfig {
|
||||
protected: CodexPoolManualAccountProtection[];
|
||||
}
|
||||
|
||||
interface CodexPoolManualAccountProtection {
|
||||
accountName: string;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
interface CodexPoolProfileConfig {
|
||||
profile: string;
|
||||
accountName: string | null;
|
||||
@@ -379,7 +389,7 @@ function parseSentinelProbeOptions(args: string[]): SentinelProbeOptions {
|
||||
}
|
||||
const uniqueAccounts = [...new Set(accounts)];
|
||||
if (uniqueAccounts.length === 0) throw new Error("sentinel-probe requires --account <accountName>");
|
||||
for (const account of uniqueAccounts) validateKubernetesName(account, "--account", false);
|
||||
for (const account of uniqueAccounts) validateSub2ApiAccountSelector(account, "--account");
|
||||
const disclosure = parseDisclosureOptions(disclosureArgs);
|
||||
return { ...disclosure, confirm, accounts: uniqueAccounts };
|
||||
}
|
||||
@@ -545,6 +555,12 @@ function splitAccountNames(value: string): string[] {
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function validateSub2ApiAccountSelector(value: string, option: string): void {
|
||||
if (value.length === 0 || value.length > 256) throw new Error(`${option} must be a non-empty account name up to 256 characters`);
|
||||
if (/[\r\n]/u.test(value)) throw new Error(`${option} must not contain newlines`);
|
||||
if (!/^[^<>"'`\\]+$/u.test(value)) throw new Error(`${option} contains unsupported characters`);
|
||||
}
|
||||
|
||||
function validateOptions(args: string[], booleanOptions: Set<string>): void {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]!;
|
||||
@@ -678,6 +694,9 @@ function codexPoolPlan(options?: DisclosureOptions): Record<string, unknown> {
|
||||
: `Legacy Codex-pool FRP exposure is disabled by YAML; Codex consumers for target ${runtimeTarget.id} use target-level public exposure ${consumerBaseUrl}.`,
|
||||
idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files; managed accounts missing from YAML are preserved unless --prune-removed is explicitly provided.",
|
||||
configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.",
|
||||
manualAccountProtection: pool.manualAccounts.protected.length === 0
|
||||
? "No manual Sub2API accounts are protected by YAML."
|
||||
: `${pool.manualAccounts.protected.length} manual Sub2API account(s) are protected from UniDesk-managed sync, prune, sentinel probe, and sentinel freeze paths.`,
|
||||
},
|
||||
next: ok
|
||||
? { sync: `bun scripts/cli.ts platform-infra sub2api codex-pool sync${targetFlag(runtimeTarget)} --confirm` }
|
||||
@@ -921,6 +940,18 @@ async function codexPoolSentinelReport(config: UniDeskConfig, options: SentinelR
|
||||
async function codexPoolSentinelProbe(config: UniDeskConfig, options: SentinelProbeOptions): Promise<Record<string, unknown>> {
|
||||
const pool = readCodexPoolConfig();
|
||||
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
|
||||
const protectedNames = new Set(pool.manualAccounts.protected.map((account) => account.accountName.toLowerCase()));
|
||||
const protectedRequested = options.accounts.filter((account) => protectedNames.has(account.toLowerCase()));
|
||||
if (protectedRequested.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
action: "platform-infra-sub2api-codex-pool-sentinel-probe",
|
||||
error: "account-protected-manual",
|
||||
protected: protectedRequested,
|
||||
message: "Protected manual Sub2API accounts are not YAML-managed and must not be probed by the UniDesk sentinel.",
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const configuredAccounts = desiredAccountNames(pool);
|
||||
const missing = options.accounts.filter((account) => !configuredAccounts.includes(account));
|
||||
if (missing.length > 0) {
|
||||
@@ -1270,6 +1301,8 @@ function readCodexPoolConfig(): CodexPoolConfig {
|
||||
defaultSentinelProtect,
|
||||
defaultAccountPriorityValue,
|
||||
);
|
||||
const manualAccounts = readManualAccountsConfig(parsed.manualAccounts, defaults.manualAccounts);
|
||||
assertProtectedManualAccountsNotManaged(profiles, manualAccounts);
|
||||
const declaredAccountCapacity = desiredProfileCapacityTotal(profiles, defaultAccountCapacityValue);
|
||||
const minOwnerConcurrencySource = pool.minOwnerConcurrency === undefined || pool.minOwnerConcurrency === null ? "auto" : "yaml";
|
||||
const minOwnerConcurrency = minOwnerConcurrencySource === "auto"
|
||||
@@ -1296,9 +1329,13 @@ function readCodexPoolConfig(): CodexPoolConfig {
|
||||
defaultTempUnschedulable,
|
||||
defaultSentinelProtect,
|
||||
profiles,
|
||||
manualAccounts,
|
||||
publicExposure: readPublicExposureConfig(parsed.publicExposure, defaults.publicExposure),
|
||||
localCodex: readLocalCodexConfig(parsed.localCodex, defaults.localCodex),
|
||||
sentinel: readCodexPoolSentinelConfig(parsed.sentinel, defaults.sentinel, codexPoolConfigPath),
|
||||
sentinel: {
|
||||
...readCodexPoolSentinelConfig(parsed.sentinel, defaults.sentinel, codexPoolConfigPath),
|
||||
protectedManualAccounts: manualAccounts.protected.map((account) => account.accountName),
|
||||
},
|
||||
};
|
||||
validateKubernetesName(config.groupName, "pool.groupName", false);
|
||||
validateKubernetesName(config.apiKeySecretName, "pool.apiKeySecretName", true);
|
||||
@@ -1335,6 +1372,9 @@ function defaultCodexPoolConfig(): CodexPoolConfig {
|
||||
defaultTempUnschedulable: defaultCodexTempUnschedulablePolicy(),
|
||||
defaultSentinelProtect: defaultCodexSentinelProtectPolicy(),
|
||||
profiles: [],
|
||||
manualAccounts: {
|
||||
protected: [],
|
||||
},
|
||||
publicExposure: {
|
||||
enabled: false,
|
||||
proxyName: "platform-infra-sub2api",
|
||||
@@ -1449,6 +1489,58 @@ function desiredProfileCapacityTotal(profiles: CodexPoolProfileConfig[], default
|
||||
return profiles.reduce((total, profile) => total + (profile.capacity ?? defaultCapacity), 0);
|
||||
}
|
||||
|
||||
function readManualAccountsConfig(value: unknown, defaults: CodexPoolManualAccountsConfig): CodexPoolManualAccountsConfig {
|
||||
if (value === undefined || value === null) return defaults;
|
||||
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.manualAccounts must be a YAML object`);
|
||||
const protectedRaw = value.protected;
|
||||
if (protectedRaw === undefined || protectedRaw === null) return { protected: [] };
|
||||
if (!Array.isArray(protectedRaw)) throw new Error(`${codexPoolConfigPath}.manualAccounts.protected must be a YAML array`);
|
||||
const seen = new Set<string>();
|
||||
const protectedAccounts = protectedRaw.map((entry, index): CodexPoolManualAccountProtection => {
|
||||
const key = `manualAccounts.protected[${index}]`;
|
||||
const accountName = typeof entry === "string"
|
||||
? readManualAccountName(entry, key)
|
||||
: isRecord(entry)
|
||||
? readManualAccountName(entry.accountName, `${key}.accountName`)
|
||||
: null;
|
||||
if (accountName === null) throw new Error(`${codexPoolConfigPath}.${key} must be an account name string or object with accountName`);
|
||||
const normalized = accountName.toLowerCase();
|
||||
if (seen.has(normalized)) throw new Error(`${codexPoolConfigPath}.${key}.accountName is duplicated in manualAccounts.protected`);
|
||||
seen.add(normalized);
|
||||
const reason = isRecord(entry) ? readManualAccountReason(entry.reason, `${key}.reason`) : null;
|
||||
return { accountName, reason };
|
||||
});
|
||||
return { protected: protectedAccounts };
|
||||
}
|
||||
|
||||
function readManualAccountName(value: unknown, key: string): string | null {
|
||||
const text = stringValue(value)?.trim() ?? null;
|
||||
if (text === null || text.length === 0) return null;
|
||||
if (text.length > 256) throw new Error(`${codexPoolConfigPath}.${key} must be at most 256 characters`);
|
||||
if (/[\r\n]/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} must not contain newlines`);
|
||||
if (!/^[^<>"'`\\]+$/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} contains unsupported characters`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function readManualAccountReason(value: unknown, key: string): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const text = stringValue(value)?.trim() ?? null;
|
||||
if (text === null || text.length === 0) return null;
|
||||
if (text.length > 240) throw new Error(`${codexPoolConfigPath}.${key} must be at most 240 characters`);
|
||||
if (/[\r\n]/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} must not contain newlines`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function assertProtectedManualAccountsNotManaged(profiles: CodexPoolProfileConfig[], manualAccounts: CodexPoolManualAccountsConfig): void {
|
||||
const protectedNames = new Set(manualAccounts.protected.map((account) => account.accountName.toLowerCase()));
|
||||
for (const [index, profile] of profiles.entries()) {
|
||||
const accountName = profile.accountName;
|
||||
if (accountName !== null && protectedNames.has(accountName.toLowerCase())) {
|
||||
throw new Error(`${codexPoolConfigPath}.profiles.entries[${index}].accountName is listed in manualAccounts.protected; protected manual accounts must not be YAML-managed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rejectSchedulableYamlField(value: Record<string, unknown>, key: string): void {
|
||||
if (Object.prototype.hasOwnProperty.call(value, "schedulable")) {
|
||||
throw new Error(`${codexPoolConfigPath}.${key}.schedulable is process control, not durable YAML config; use codex-pool sync actions instead`);
|
||||
@@ -1928,6 +2020,11 @@ function codexPoolConfigSummary(pool: CodexPoolConfig): Record<string, unknown>
|
||||
defaultTempUnschedulable: tempUnschedulableSummary(pool.defaultTempUnschedulable),
|
||||
defaultSentinelProtect: pool.defaultSentinelProtect,
|
||||
profileCount: pool.profiles.length,
|
||||
manualAccounts: {
|
||||
protectedCount: pool.manualAccounts.protected.length,
|
||||
protected: pool.manualAccounts.protected,
|
||||
controlPolicy: "manual accounts are not created, updated, pruned, probed, or frozen by UniDesk codex-pool sync/sentinel",
|
||||
},
|
||||
publicExposure: publicExposureSummary(pool),
|
||||
localCodex: pool.localCodex,
|
||||
sentinel: codexPoolSentinelSummary(pool.sentinel),
|
||||
@@ -2020,6 +2117,27 @@ function compactTempUnschedulableStatus(block: unknown): Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
function compactManualAccounts(block: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(block)) return null;
|
||||
const items = recordArray(block.items).map((item) => pickSummaryFields(item, [
|
||||
"accountName",
|
||||
"reason",
|
||||
"exists",
|
||||
"accountId",
|
||||
"status",
|
||||
"schedulable",
|
||||
"inYamlProfiles",
|
||||
"runtimeMarkedUnideskManaged",
|
||||
"controlPolicy",
|
||||
]));
|
||||
return {
|
||||
ok: block.ok,
|
||||
protectedCount: block.protectedCount,
|
||||
items,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function tempUnschedulableReasonSummary(value: unknown): Record<string, unknown> {
|
||||
const reason = compactText(value, 180);
|
||||
const statusCodeMatch = reason.match(/"status_code":(\d{3})/u) ?? reason.match(/OpenAI\s+(\d{3})/u) ?? reason.match(/\((\d{3})\)/u);
|
||||
@@ -2736,6 +2854,7 @@ function codexPoolValidationSummary(parsed: Record<string, unknown> | null): Rec
|
||||
loadFactor: compactStatusBlock(parsed.loadFactor, ["accountName", "accountId", "expectedLoadFactor", "runtimeLoadFactor", "priority", "status", "schedulable", "ok"]),
|
||||
webSocketsV2: compactStatusBlock(parsed.webSocketsV2, ["accountName", "accountId", "expectedMode", "runtimeMode", "runtimeEnabled", "status", "schedulable", "ok"]),
|
||||
tempUnschedulable: compactTempUnschedulableStatus(parsed.tempUnschedulable),
|
||||
manualAccounts: compactManualAccounts(parsed.manualAccounts),
|
||||
sentinel: compactSentinelStatus(parsed.sentinel),
|
||||
runtimeCapabilities: {
|
||||
ok: runtimeCapabilities.ok,
|
||||
@@ -4031,6 +4150,7 @@ EXPECTED_ACCOUNT_CAPACITIES = ${JSON.stringify(desiredAccountCapacityMap(pool))}
|
||||
EXPECTED_ACCOUNT_LOAD_FACTORS = ${JSON.stringify(desiredAccountLoadFactorMap(pool))}
|
||||
EXPECTED_ACCOUNT_WS_MODES = json.loads(${JSON.stringify(JSON.stringify(desiredAccountWebSocketsV2ModeMap(pool)))})
|
||||
EXPECTED_ACCOUNT_TEMP_UNSCHEDULABLE = json.loads(${JSON.stringify(JSON.stringify(desiredAccountTempUnschedulableMap(pool)))})
|
||||
MANUAL_ACCOUNT_PROTECTIONS = json.loads(${JSON.stringify(JSON.stringify(pool.manualAccounts.protected))})
|
||||
SENTINEL_CONFIG = json.loads(${JSON.stringify(JSON.stringify(pool.sentinel))})
|
||||
MODE = "${mode}"
|
||||
PAYLOAD_B64 = "${encodedPayload}"
|
||||
@@ -4338,6 +4458,44 @@ def list_accounts(token):
|
||||
data = ensure_success(curl_api("GET", path, bearer=token), "list accounts")
|
||||
return extract_items(data)
|
||||
|
||||
def find_account_by_name(token, name):
|
||||
path = "/api/v1/admin/accounts?page=1&page_size=20&platform=openai&type=apikey&search=" + quote(str(name))
|
||||
data = ensure_success(curl_api("GET", path, bearer=token), "find account " + str(name))
|
||||
for item in extract_items(data):
|
||||
if isinstance(item, dict) and item.get("name") == name:
|
||||
return item
|
||||
return None
|
||||
|
||||
def manual_account_protection_status(token):
|
||||
items = []
|
||||
desired_names = set(EXPECTED_ACCOUNT_CAPACITIES.keys())
|
||||
for protection in MANUAL_ACCOUNT_PROTECTIONS:
|
||||
if not isinstance(protection, dict):
|
||||
continue
|
||||
name = protection.get("accountName")
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
account = find_account_by_name(token, name)
|
||||
extra = account.get("extra") if isinstance(account, dict) and isinstance(account.get("extra"), dict) else {}
|
||||
items.append({
|
||||
"accountName": name,
|
||||
"reason": protection.get("reason") if isinstance(protection.get("reason"), str) else None,
|
||||
"exists": isinstance(account, dict),
|
||||
"accountId": account.get("id") if isinstance(account, dict) else None,
|
||||
"status": account.get("status") if isinstance(account, dict) else None,
|
||||
"schedulable": account.get("schedulable") if isinstance(account, dict) else None,
|
||||
"inYamlProfiles": name in desired_names,
|
||||
"runtimeMarkedUnideskManaged": extra.get("unidesk_managed") is True,
|
||||
"controlPolicy": "manual-protected: no create/update/prune/probe/freeze",
|
||||
"valuesPrinted": False,
|
||||
})
|
||||
return {
|
||||
"ok": True,
|
||||
"protectedCount": len(items),
|
||||
"items": items,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def list_probe_accounts(token):
|
||||
path = "/api/v1/admin/accounts?page=1&page_size=200&platform=openai&type=apikey&search=" + quote("unidesk-probe-")
|
||||
data = ensure_success(curl_api("GET", path, bearer=token), "list probe accounts")
|
||||
@@ -5943,6 +6101,7 @@ def run_sync():
|
||||
if group_id is None:
|
||||
raise RuntimeError("pool group id missing after ensure")
|
||||
existing_accounts = list_accounts(token)
|
||||
manual_account_protections = manual_account_protection_status(token)
|
||||
planned_account_results = planned_sentinel_account_results(profiles, existing_accounts)
|
||||
sentinel_quality_prepare = ensure_sentinel_state_for_sync(planned_account_results, True)
|
||||
if sentinel_quality_prepare.get("ok") is not True:
|
||||
@@ -5985,6 +6144,7 @@ def run_sync():
|
||||
"processControl": {"schedulableRestore": "sentinel marker probe only; sync does not restore schedulable for existing accounts", "durableConfig": False},
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"manualAccounts": manual_account_protections,
|
||||
"capacity": capacity_status,
|
||||
"loadFactor": load_factor_status,
|
||||
"webSocketsV2": ws_v2_status,
|
||||
@@ -6023,6 +6183,7 @@ def run_validate():
|
||||
load_factor_status = account_load_factor_status(token)
|
||||
ws_v2_status = account_ws_v2_status(token)
|
||||
temp_unschedulable_status = account_temp_unschedulable_status(token)
|
||||
manual_account_protections = manual_account_protection_status(token)
|
||||
gateway = validate_gateway(api_key)
|
||||
responses_smoke = validate_gateway_responses(api_key)
|
||||
compact_evidence = recent_compact_gateway_evidence()
|
||||
@@ -6050,6 +6211,7 @@ def run_validate():
|
||||
"loadFactor": load_factor_status,
|
||||
"webSocketsV2": ws_v2_status,
|
||||
"tempUnschedulable": temp_unschedulable_status,
|
||||
"manualAccounts": manual_account_protections,
|
||||
"sentinel": sentinel,
|
||||
"runtimeCapabilities": runtime_capabilities,
|
||||
"validation": {"gatewayModels": gateway, "gatewayResponses": responses_smoke, "gatewayResponsesRecent": responses_evidence, "gatewayCompactRecent": compact_evidence},
|
||||
|
||||
Reference in New Issue
Block a user