fix: bind protected sub2api manual accounts to pool group
This commit is contained in:
@@ -164,10 +164,16 @@ interface CodexPoolManualAccountProxyBinding {
|
||||
proxyName: string;
|
||||
}
|
||||
|
||||
interface CodexPoolManualAccountGroupBinding {
|
||||
enabled: boolean;
|
||||
source: "pool-group";
|
||||
}
|
||||
|
||||
interface CodexPoolManualAccountProtection {
|
||||
accountName: string;
|
||||
reason: string | null;
|
||||
proxyBinding: CodexPoolManualAccountProxyBinding | null;
|
||||
groupBinding: CodexPoolManualAccountGroupBinding | null;
|
||||
}
|
||||
|
||||
interface CodexPoolProfileConfig {
|
||||
@@ -703,11 +709,11 @@ function codexPoolPlan(options?: DisclosureOptions): Record<string, unknown> {
|
||||
: runtimeTarget.publicBaseUrl === null
|
||||
? "Public FRP exposure is disabled by YAML."
|
||||
: `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.",
|
||||
idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files for YAML-managed profiles only; 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.`,
|
||||
: `${pool.manualAccounts.protected.length} manual Sub2API account(s) are protected from UniDesk-managed credentials, prune, sentinel probe, and sentinel freeze paths; only explicitly declared proxy/group bindings are reconciled.`,
|
||||
},
|
||||
next: ok
|
||||
? { sync: `bun scripts/cli.ts platform-infra sub2api codex-pool sync${targetFlag(runtimeTarget)} --confirm` }
|
||||
@@ -1520,7 +1526,8 @@ function readManualAccountsConfig(value: unknown, defaults: CodexPoolManualAccou
|
||||
seen.add(normalized);
|
||||
const reason = isRecord(entry) ? readManualAccountReason(entry.reason, `${key}.reason`) : null;
|
||||
const proxyBinding = isRecord(entry) ? readManualAccountProxyBinding(entry.proxyBinding, `${key}.proxyBinding`) : null;
|
||||
return { accountName, reason, proxyBinding };
|
||||
const groupBinding = isRecord(entry) ? readManualAccountGroupBinding(entry.groupBinding, `${key}.groupBinding`) : null;
|
||||
return { accountName, reason, proxyBinding, groupBinding };
|
||||
});
|
||||
return { protected: protectedAccounts };
|
||||
}
|
||||
@@ -1541,6 +1548,18 @@ function readManualAccountProxyBinding(value: unknown, key: string): CodexPoolMa
|
||||
};
|
||||
}
|
||||
|
||||
function readManualAccountGroupBinding(value: unknown, key: string): CodexPoolManualAccountGroupBinding | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`);
|
||||
const enabled = value.enabled === undefined ? true : value.enabled === true;
|
||||
const source = stringValue(value.source) ?? "pool-group";
|
||||
if (source !== "pool-group") throw new Error(`${codexPoolConfigPath}.${key}.source must be pool-group`);
|
||||
return {
|
||||
enabled,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function readManualAccountName(value: unknown, key: string): string | null {
|
||||
const text = stringValue(value)?.trim() ?? null;
|
||||
if (text === null || text.length === 0) return null;
|
||||
@@ -2051,7 +2070,7 @@ function codexPoolConfigSummary(pool: CodexPoolConfig): Record<string, unknown>
|
||||
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",
|
||||
controlPolicy: "manual accounts are not created, credential-updated, pruned, probed, or frozen by UniDesk codex-pool sync/sentinel; optional proxy_id and pool group membership bindings are narrow YAML-controlled exceptions",
|
||||
},
|
||||
publicExposure: publicExposureSummary(pool),
|
||||
localCodex: pool.localCodex,
|
||||
@@ -2158,6 +2177,7 @@ function compactManualAccounts(block: unknown): Record<string, unknown> | null {
|
||||
"inYamlProfiles",
|
||||
"runtimeMarkedUnideskManaged",
|
||||
"proxyBinding",
|
||||
"groupBinding",
|
||||
"controlPolicy",
|
||||
]));
|
||||
const proxySync = isRecord(block.proxySync)
|
||||
@@ -2181,11 +2201,31 @@ function compactManualAccounts(block: unknown): Record<string, unknown> | null {
|
||||
valuesPrinted: false,
|
||||
}
|
||||
: undefined;
|
||||
const groupSync = isRecord(block.groupSync)
|
||||
? {
|
||||
ok: block.groupSync.ok,
|
||||
itemCount: block.groupSync.itemCount,
|
||||
items: recordArray(block.groupSync.items).map((item) => pickSummaryFields(item, [
|
||||
"accountName",
|
||||
"accountId",
|
||||
"enabled",
|
||||
"ok",
|
||||
"action",
|
||||
"source",
|
||||
"poolGroupName",
|
||||
"poolGroupId",
|
||||
"bindingAligned",
|
||||
"controlPolicy",
|
||||
])),
|
||||
valuesPrinted: false,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
ok: block.ok,
|
||||
protectedCount: block.protectedCount,
|
||||
items,
|
||||
proxySync,
|
||||
groupSync,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -4494,9 +4534,12 @@ def group_payload():
|
||||
"rpm_limit": 0,
|
||||
}
|
||||
|
||||
def list_groups(token):
|
||||
data = ensure_success(curl_api("GET", "/api/v1/admin/groups/all?platform=openai", bearer=token), "list groups")
|
||||
return extract_items(data)
|
||||
|
||||
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)
|
||||
existing = next((item for item in list_groups(token) 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")
|
||||
@@ -4508,6 +4551,26 @@ def ensure_group(token):
|
||||
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_for_group(token, group_id):
|
||||
path = f"/api/v1/admin/accounts?group_id={group_id}&page=1&page_size=500&platform=openai"
|
||||
data = ensure_success(curl_api("GET", path, bearer=token), f"list accounts for group {group_id}")
|
||||
return extract_items(data)
|
||||
|
||||
def account_group_ids(token, account):
|
||||
if not isinstance(account, dict) or account.get("id") is None:
|
||||
return []
|
||||
account_id = account.get("id")
|
||||
account_name = account.get("name")
|
||||
ids = []
|
||||
for group in list_groups(token):
|
||||
group_id = group.get("id") if isinstance(group, dict) else None
|
||||
if group_id is None:
|
||||
continue
|
||||
members = list_accounts_for_group(token, group_id)
|
||||
if any(item.get("id") == account_id or item.get("name") == account_name for item in members if isinstance(item, dict)):
|
||||
ids.append(group_id)
|
||||
return sorted(set(ids))
|
||||
|
||||
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")
|
||||
@@ -4689,7 +4752,119 @@ def ensure_manual_account_proxy_bindings(token):
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def manual_account_protection_status(token):
|
||||
def manual_group_binding_enabled(protection):
|
||||
binding = protection.get("groupBinding") if isinstance(protection, dict) else None
|
||||
if not isinstance(binding, dict) or binding.get("enabled") is not True:
|
||||
return False
|
||||
if binding.get("source") != "pool-group":
|
||||
raise RuntimeError("manual account groupBinding source must be pool-group")
|
||||
return True
|
||||
|
||||
def manual_group_status(token, account, protection, group_id):
|
||||
enabled = manual_group_binding_enabled(protection)
|
||||
if not enabled:
|
||||
return {
|
||||
"enabled": False,
|
||||
"ok": True,
|
||||
"action": "not-configured",
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
group_accounts = list_accounts_for_group(token, group_id)
|
||||
account_id = account.get("id") if isinstance(account, dict) else None
|
||||
account_name = account.get("name") if isinstance(account, dict) else None
|
||||
binding_aligned = any(
|
||||
item.get("id") == account_id or item.get("name") == account_name
|
||||
for item in group_accounts
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
return {
|
||||
"enabled": True,
|
||||
"ok": binding_aligned,
|
||||
"action": "validate",
|
||||
"source": "pool-group",
|
||||
"poolGroupName": POOL_GROUP_NAME,
|
||||
"poolGroupId": group_id,
|
||||
"bindingAligned": binding_aligned,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def ensure_manual_account_group_bindings(token, group_id):
|
||||
items = []
|
||||
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
|
||||
if not manual_group_binding_enabled(protection):
|
||||
items.append({
|
||||
"accountName": name,
|
||||
"enabled": False,
|
||||
"action": "not-configured",
|
||||
"ok": True,
|
||||
"valuesPrinted": False,
|
||||
})
|
||||
continue
|
||||
account = find_account_by_name(token, name)
|
||||
if not isinstance(account, dict):
|
||||
items.append({
|
||||
"accountName": name,
|
||||
"enabled": True,
|
||||
"action": "account-missing",
|
||||
"ok": False,
|
||||
"poolGroupName": POOL_GROUP_NAME,
|
||||
"poolGroupId": group_id,
|
||||
"valuesPrinted": False,
|
||||
})
|
||||
continue
|
||||
extra = account.get("extra") if isinstance(account.get("extra"), dict) else {}
|
||||
if extra.get("unidesk_managed") is True:
|
||||
items.append({
|
||||
"accountName": name,
|
||||
"accountId": account.get("id"),
|
||||
"enabled": True,
|
||||
"action": "refused-managed-runtime-account",
|
||||
"ok": False,
|
||||
"poolGroupName": POOL_GROUP_NAME,
|
||||
"poolGroupId": group_id,
|
||||
"valuesPrinted": False,
|
||||
})
|
||||
continue
|
||||
existing_group_ids = account_group_ids(token, account)
|
||||
desired_group_ids = sorted(set(existing_group_ids + [group_id]))
|
||||
action = "unchanged"
|
||||
if group_id not in existing_group_ids:
|
||||
updated = ensure_success(curl_api("PUT", f"/api/v1/admin/accounts/{account['id']}", bearer=token, payload={"group_ids": desired_group_ids}), f"bind manual account group {name}")
|
||||
account = updated if isinstance(updated, dict) else account
|
||||
action = "bound"
|
||||
binding_aligned = any(
|
||||
item.get("id") == account.get("id") or item.get("name") == name
|
||||
for item in list_accounts_for_group(token, group_id)
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
items.append({
|
||||
"accountName": name,
|
||||
"accountId": account.get("id"),
|
||||
"enabled": True,
|
||||
"ok": binding_aligned,
|
||||
"action": action,
|
||||
"source": "pool-group",
|
||||
"poolGroupName": POOL_GROUP_NAME,
|
||||
"poolGroupId": group_id,
|
||||
"previousGroupIds": existing_group_ids,
|
||||
"desiredGroupIds": desired_group_ids,
|
||||
"bindingAligned": binding_aligned,
|
||||
"controlPolicy": "manual-protected: only pool group membership is YAML-controlled; credentials/status/schedulable are untouched and sentinel does not probe it",
|
||||
"valuesPrinted": False,
|
||||
})
|
||||
return {
|
||||
"ok": all(item.get("ok") is True for item in items),
|
||||
"itemCount": len(items),
|
||||
"items": items,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def manual_account_protection_status(token, group_id=None):
|
||||
items = []
|
||||
desired_names = set(EXPECTED_ACCOUNT_CAPACITIES.keys())
|
||||
for protection in MANUAL_ACCOUNT_PROTECTIONS:
|
||||
@@ -4701,6 +4876,7 @@ def manual_account_protection_status(token):
|
||||
account = find_account_by_name(token, name)
|
||||
extra = account.get("extra") if isinstance(account, dict) and isinstance(account.get("extra"), dict) else {}
|
||||
proxy_status = manual_proxy_status(token, account, protection)
|
||||
group_status = manual_group_status(token, account, protection, group_id) if group_id is not None else {"enabled": False, "ok": True, "action": "not-checked", "valuesPrinted": False}
|
||||
items.append({
|
||||
"accountName": name,
|
||||
"reason": protection.get("reason") if isinstance(protection.get("reason"), str) else None,
|
||||
@@ -4711,8 +4887,9 @@ def manual_account_protection_status(token):
|
||||
"inYamlProfiles": name in desired_names,
|
||||
"runtimeMarkedUnideskManaged": extra.get("unidesk_managed") is True,
|
||||
"proxyBinding": proxy_status,
|
||||
"ok": proxy_status.get("ok") is True,
|
||||
"controlPolicy": "manual-protected: no create/update/prune/probe/freeze; optional proxy_id binding only when proxyBinding is configured",
|
||||
"groupBinding": group_status,
|
||||
"ok": proxy_status.get("ok") is True and group_status.get("ok") is True,
|
||||
"controlPolicy": "manual-protected: no create/update/prune/probe/freeze; optional proxy_id and pool group membership binding only when configured",
|
||||
"valuesPrinted": False,
|
||||
})
|
||||
return {
|
||||
@@ -6334,7 +6511,8 @@ def run_sync():
|
||||
protected_frozen_names = active_sentinel_quarantine_names()
|
||||
account_results, pruned_account_results = ensure_accounts(token, profiles, group_id, prune_removed, protected_frozen_names, existing_accounts)
|
||||
manual_account_proxy_bindings = ensure_manual_account_proxy_bindings(token)
|
||||
manual_account_protections = manual_account_protection_status(token)
|
||||
manual_account_group_bindings = ensure_manual_account_group_bindings(token, group_id)
|
||||
manual_account_protections = manual_account_protection_status(token, group_id)
|
||||
capacity_status = account_capacity_status(token)
|
||||
load_factor_status = account_load_factor_status(token)
|
||||
ws_v2_status = account_ws_v2_status(token)
|
||||
@@ -6352,7 +6530,7 @@ def run_sync():
|
||||
sentinel_quality = ensure_sentinel_state_for_sync(account_results)
|
||||
sentinel_reassert = reassert_sentinel_freezes_after_sync(token)
|
||||
return {
|
||||
"ok": gateway["ok"] is True and responses_smoke["ok"] is True and owner_concurrency["ok"] is True and capacity_status["ok"] is True and load_factor_status["ok"] is True and ws_v2_status["ok"] is True and temp_unschedulable_status["ok"] is True and manual_account_proxy_bindings.get("ok") is True and manual_account_protections.get("ok") is True and sentinel.get("ok") is True and sentinel_quality_prepare.get("ok") is True and sentinel_quality.get("ok") is True and sentinel_reassert.get("ok") is True and runtime_capabilities.get("ok") is True,
|
||||
"ok": gateway["ok"] is True and responses_smoke["ok"] is True and owner_concurrency["ok"] is True and capacity_status["ok"] is True and load_factor_status["ok"] is True and ws_v2_status["ok"] is True and temp_unschedulable_status["ok"] is True and manual_account_proxy_bindings.get("ok") is True and manual_account_group_bindings.get("ok") is True and manual_account_protections.get("ok") is True and sentinel.get("ok") is True and sentinel_quality_prepare.get("ok") is True and sentinel_quality.get("ok") is True and sentinel_reassert.get("ok") is True and runtime_capabilities.get("ok") is True,
|
||||
"degraded": bool(responses_smoke.get("degraded")) or bool(compact_evidence.get("degraded")) or bool(responses_evidence.get("degraded")) or runtime_capabilities.get("ok") is not True,
|
||||
"mode": "sync",
|
||||
"namespace": NAMESPACE,
|
||||
@@ -6371,7 +6549,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, "proxySync": manual_account_proxy_bindings},
|
||||
"manualAccounts": {**manual_account_protections, "proxySync": manual_account_proxy_bindings, "groupSync": manual_account_group_bindings},
|
||||
"capacity": capacity_status,
|
||||
"loadFactor": load_factor_status,
|
||||
"webSocketsV2": ws_v2_status,
|
||||
@@ -6410,7 +6588,8 @@ 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)
|
||||
pool_group_id = key_item.get("group_id") if isinstance(key_item, dict) else None
|
||||
manual_account_protections = manual_account_protection_status(token, pool_group_id)
|
||||
gateway = validate_gateway(api_key)
|
||||
responses_smoke = validate_gateway_responses(api_key)
|
||||
compact_evidence = recent_compact_gateway_evidence()
|
||||
@@ -6429,6 +6608,7 @@ def run_validate():
|
||||
"secret": f"{NAMESPACE}/{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY}",
|
||||
"sub2apiId": key_item.get("id") if isinstance(key_item, dict) else None,
|
||||
"userId": key_item.get("user_id") if isinstance(key_item, dict) else None,
|
||||
"groupId": key_item.get("group_id") if isinstance(key_item, dict) else None,
|
||||
"keyPreview": api_key_preview(api_key),
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user