From 34b2b119ba36de6a4e2215b89cfabf8962da8b04 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 15:42:36 +0200 Subject: [PATCH] fix: repair host Docker pool key during validate --- .../unidesk-sub2api/references/codex-pool.md | 2 +- .../remote-python-sync.ts | 39 ++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index e23b9cd9..bd414a78 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -40,7 +40,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - `manualAccounts.protected`: 已在 Sub2API 手动创建/维护、且必须排除在 UniDesk-managed Codex pool credentials 和 sentinel 控制之外的账号。默认不得改 credentials/status/schedulable/priority/capacity/loadFactor;只有显式声明 `proxyBinding` 时,`sync --confirm` 才允许把该账号的 `proxy_id` 对齐到 YAML 目标的 egress proxy;只有显式声明 `groupBinding.source: pool-group` 时,才允许把该账号加入统一消费 API key 使用的 pool group。`targetIds` 可选;省略表示所有 target 都保护该账号,设置后只在匹配 target 上纳入 proxy/group 窄同步和 sentinel-probe 拒绝列表,避免 PK01-only 手动账号漂移卡住 JD01 pool。 - Sentinel 配置、marker-only 判定、镜像、report/probe 和远端 job/poll 边界见 [sentinel.md](sentinel.md)。 -`sync --confirm` 会登录 Sub2API admin、创建/更新 group、创建/更新 YAML 中的 `unidesk-codex-*` accounts,并创建/复用统一 API key。k3s target 还会写入统一 API key Secret 并部署/更新哨兵资源;PK01 host-Docker target 只写 Sub2API runtime 和 host-Docker env 文件。host-Docker `validate` 也先使用 env 中的 Sub2API admin 凭据登录管理 API;如果 env 的统一 `API_KEY` 缺失或不匹配,但管理 API 已返回名为 YAML `pool.apiKeyName` 的现有 key,`validate` 只恢复该现有 key 到 host-Docker env 后继续校验,不创建新 key。`sync` 不把既有 managed account 直接恢复为 `schedulable=true`。恢复只由哨兵在读取 Sub2API runtime `schedulable=false` 后触发 recovery probe,并在 marker 命中时执行。`sync` 默认不删除 YAML 中缺席的 managed account。只有明确退役上游时才使用 `sync --confirm --prune-removed` 删除缺席且 `extra.unidesk_managed=true` 的 `unidesk-codex-*` account。对 `manualAccounts.protected`,`sync` 只执行 YAML 显式允许的窄同步;当前允许项是从目标 `egressProxy` 创建/更新 Sub2API internal proxy 记录并绑定 `proxy_id`,以及把受保护手动账号加入当前 `pool.groupName`。它仍不接管该账号凭据、status、schedulable、priority/capacity/loadFactor 或哨兵状态。若受保护手动账号在运行面缺失,`sync`/`validate` 会报告 manual account drift;不要自动创建、删除、接管或从 YAML 移除该账号。 +`sync --confirm` 会登录 Sub2API admin、创建/更新 group、创建/更新 YAML 中的 `unidesk-codex-*` accounts,并创建/复用统一 API key。k3s target 还会写入统一 API key Secret 并部署/更新哨兵资源;PK01 host-Docker target 只写 Sub2API runtime 和 host-Docker env 文件。host-Docker `validate` 也先使用 env 中的 Sub2API admin 凭据登录管理 API;如果 env 的统一 `API_KEY` 缺失或不匹配,它只通过管理 API 恢复或创建名为 YAML `pool.apiKeyName` 的统一消费 key 并写回 host-Docker env,不读取本地 profile 文件、不创建/更新账号、不 prune、不改变调度策略。`sync` 不把既有 managed account 直接恢复为 `schedulable=true`。恢复只由哨兵在读取 Sub2API runtime `schedulable=false` 后触发 recovery probe,并在 marker 命中时执行。`sync` 默认不删除 YAML 中缺席的 managed account。只有明确退役上游时才使用 `sync --confirm --prune-removed` 删除缺席且 `extra.unidesk_managed=true` 的 `unidesk-codex-*` account。对 `manualAccounts.protected`,`sync` 只执行 YAML 显式允许的窄同步;当前允许项是从目标 `egressProxy` 创建/更新 Sub2API internal proxy 记录并绑定 `proxy_id`,以及把受保护手动账号加入当前 `pool.groupName`。它仍不接管该账号凭据、status、schedulable、priority/capacity/loadFactor 或哨兵状态。若受保护手动账号在运行面缺失,`sync`/`validate` 会报告 manual account drift;不要自动创建、删除、接管或从 YAML 移除该账号。 `trace --request-id ` 是只读 request 追溯报表,不触发 probe、不修改账号。默认输出请求开始/最终状态、failover、`account_select_failed`、窗口内 `account_temp_unschedulable`、admin schedulable 写入计数和当前账号快照;`reason=failover-attempted-no-candidate` 表示 Sub2API 已进入自动切号,但排除当前失败账号后没有可用候选。需要机器处理时使用 `--raw`,需要原始匹配行时加 `--show-lines`。 diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts index 2ecdea08..2ee7a07a 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts @@ -1086,6 +1086,36 @@ def existing_sub2api_pool_api_key(token): return None return sub2api_key_value(existing) +def ensure_pool_api_key_for_validate(token, secret_value, source_action): + group, group_action = ensure_group(token) + group_id = group.get("id") if isinstance(group, dict) else None + if group_id is None: + raise RuntimeError("pool group id missing while ensuring validate API key") + api_key = secret_value or generate_api_key() + api_key_result = ensure_sub2api_api_key(token, api_key, group_id) + host_env_action = write_host_env_value(POOL_API_KEY_SECRET_KEY, api_key) + key_item = { + "id": api_key_result.get("id"), + "name": api_key_result.get("name") or POOL_API_KEY_NAME, + "group_id": api_key_result.get("groupId"), + "user_id": api_key_result.get("userId"), + "key": api_key, + } + return api_key, key_item, { + "ok": True, + "source": "sub2api-admin-api", + "sourceAction": source_action, + "secretPresent": bool(secret_value), + "lookup": "ensured-by-admin-api", + "groupAction": group_action, + "sub2apiAction": api_key_result.get("action"), + "sub2apiId": api_key_result.get("id"), + "hostEnvAction": host_env_action, + "hostEnvPath": HOST_DOCKER_ENV_PATH, + "hostEnvKey": POOL_API_KEY_SECRET_KEY, + "valuesPrinted": False, + } + def resolve_pool_api_key_for_validate(token): secret_value = None secret_error = None @@ -1126,10 +1156,9 @@ def resolve_pool_api_key_for_validate(token): "valuesPrinted": False, } if not secret_value: - detail = f"; envReadError={secret_error}" if secret_error else "" - raise RuntimeError(f"{pool_api_key_secret_location()} missing and Sub2API admin API key named {POOL_API_KEY_NAME} did not return a recoverable key{detail}") + return ensure_pool_api_key_for_validate(token, secret_value, "created-missing") matched_name = matched_item.get("name") if isinstance(matched_item, dict) else None - raise RuntimeError(f"{pool_api_key_secret_location()} present but does not match pool key {POOL_API_KEY_NAME}; matchedName={matched_name or '-'}; admin API did not return a recoverable named key") + return ensure_pool_api_key_for_validate(token, secret_value, "repaired-mismatch:" + (matched_name or "-")) if not secret_value: raise RuntimeError(f"{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY} missing") return secret_value, matched_item, { @@ -1722,10 +1751,10 @@ def ensure_sub2api_api_key(token, api_key, group_id): created = ensure_success(curl_api("POST", "/api/v1/keys", bearer=token, payload=payload), "create pool API key") existing = created if isinstance(created, dict) else existing action = "created" - elif existing.get("id") is not None and existing.get("group_id") != group_id: + elif existing.get("id") is not None and (existing.get("group_id") != group_id or existing.get("name") != POOL_API_KEY_NAME): updated = ensure_success(curl_api("PUT", f"/api/v1/keys/{existing['id']}", bearer=token, payload={"name": POOL_API_KEY_NAME, "group_id": group_id}), "update pool API key group") existing = updated if isinstance(updated, dict) else existing - action = "updated-group" + action = "updated-name-group" return { "action": action, "id": existing.get("id") if isinstance(existing, dict) else None,