fix: stabilize sub2api codex pool operations
This commit is contained in:
@@ -35,7 +35,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60
|
||||
- `profiles.entries[].tempUnschedulable`: 可选 per-account Sub2API 内置临时不可调度覆盖;只用于明确偏离 pool 默认规则,不用它给某个账号特殊优先级或临时绕过通用 failover。
|
||||
- `profiles.entries[].openaiResponsesWebSocketsV2Mode`: 需要 Responses WebSocket v2 的上游才设置,值为 `off`、`ctx_pool` 或 `passthrough`。
|
||||
- `profiles.entries[].upstreamUserAgent`: 少数要求 Codex CLI User-Agent 的上游才设置,不能含换行。
|
||||
- `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。
|
||||
- `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)。
|
||||
|
||||
对已支持的 k3s target,`sync --confirm` 会登录 Sub2API admin、创建/更新 group、创建/更新 YAML 中的 `unidesk-codex-*` accounts、创建/复用统一 API key Secret,并部署/更新哨兵资源;它不把既有 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 或哨兵状态。PK01 host-Docker target 在 codex-pool adapter 补齐前不具备这条完整 sync 路径。
|
||||
|
||||
@@ -14,6 +14,8 @@ bun scripts/cli.ts platform-infra sub2api codex-pool sync --target D601 --confir
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool validate --target D601
|
||||
```
|
||||
|
||||
`manualAccounts.protected[].targetIds` 是账号保护和窄同步的 target 作用域。省略时该手动账号在所有 target 上都受保护;设置如 `[PK01]` 时,JD01/D601 等其他 target 的 `codex-pool sync --confirm`、`validate` 和 `sentinel-probe` 不再把这个手动账号纳入当前运行面要求。不要通过自动删除不在 YAML 的账号来解决漂移;只增/改 YAML 控制的账号,未被当前 target 的 YAML 控制的账号保持人工所有权。
|
||||
|
||||
`sync` 输出应显示 `manualAccounts.ok=true`、`proxySync.ok=true`、`groupSync.ok=true`,且该账号的 proxy/group `bindingAligned=true`。`sentinel-probe --account <manual-account> --confirm` 对受保护手动账号必须继续拒绝,通常返回 `account-protected-manual`;不要为了测试而把该账号移入 `profiles.entries` 或取消保护。需要证明 WebUI 同款账号测试恢复时,用 Sub2API admin account test 原入口测最小 `hi` 和默认/受支持模型,并只记录 account id、proxy id、event types、HTTP status 和短 output preview,不记录 OAuth token 或 Secret 明文。若指定模型返回 “model is not supported when using Codex with a ChatGPT account” 一类能力错误,先归因到模型能力/映射,而不是 proxy。
|
||||
|
||||
如果 `manualAccounts.protected` 中声明的账号在当前 Sub2API 运行面已经不存在,`codex-pool sync --confirm` / `validate` 会把 `manualAccounts.ok=false`、`proxySync.action=account-missing` 或 `groupSync.action=account-missing` 作为整体失败项。这是手动账号漂移,不是 YAML-managed pool 账号创建失败;不要自动创建、删除、接管或从 YAML 移除该手动账号。先确认账号所有权:需要继续保护时由人工在 Sub2API UI 恢复同名账号,再跑 sync 对齐 proxy/group;确认退役时再按明确决策修改 `manualAccounts.protected`。对同一次新增的 YAML-managed 上游,可用该账号自己的 `sentinel-probe --account <accountName> --confirm`、`sentinel-report` 和 `trace` 作为窄验收证据,并把手动账号漂移单独登记。
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
对已支持的 k3s target,`sync --confirm` 会登录 Sub2API admin、创建/更新 group、创建/更新 YAML 中的 `unidesk-codex-*` accounts、创建/复用统一 API key Secret,并部署/更新哨兵资源;它不把既有 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 或哨兵状态。PK01 host-Docker target 在 codex-pool adapter 补齐前不具备这条完整 sync 路径。
|
||||
|
||||
`sentinel-image status|build` 管理哨兵 Python 运行环境镜像。镜像由 YAML 的 `sentinel.image` 基础镜像和 `sentinel.sdk.openaiPythonVersion` 派生,发布到目标 runtime 的本地 registry;`build --confirm` 会先检查 registry tag,存在则快速复用,不存在才在目标 host 构建并 push。CronJob 启动脚本会通过导出的 `OPENAI_PYTHON_VERSION` 校验 SDK 版本;当当前 target 使用基础 Python 镜像作为 runtime image 时,容器启动会按 YAML pin 执行一次 `pip install openai==$OPENAI_PYTHON_VERSION`,日志可能主要是依赖下载,完成后再运行 `/opt/sentinel/sentinel.py`。目标是否启用哨兵以 `config/platform-infra/sub2api.yaml` 的 `sentinel.enabledOnTargets` 为准;未启用的 target 在 `sync`/`validate` 中应显示 `skipped-target-disabled`,不得要求镜像构建、CronJob、Secret 或 state ConfigMap 存在。
|
||||
`sentinel-image status|build` 管理哨兵 Python 运行环境镜像。镜像由 YAML 的 `sentinel.image` 基础镜像和 `sentinel.sdk.openaiPythonVersion` 派生,发布到目标 runtime 的本地 registry;`build --confirm` 会先检查 registry tag,存在则快速复用,不存在才在目标 host 构建并 push。k3s target 的 CronJob 必须使用这个派生 runtime image,不要退回基础 Python 镜像再在容器启动时 `pip install openai`。需要外网拉官方基础镜像或 Python wheel 时,构建脚本应读取目标 YAML/host-proxy 提供的 proxy env(例如 JD01 `/etc/unidesk/proxy.env`)并用 host network 让 `127.0.0.1` hostproxy 生效;不要为了这条路径新增镜像源。CronJob 启动脚本只做 `OPENAI_PYTHON_VERSION` 校验,版本不符才兜底安装。目标是否启用哨兵以 `config/platform-infra/sub2api.yaml` 的 `sentinel.enabledOnTargets` 为准;未启用的 target 在 `sync`/`validate` 中应显示 `skipped-target-disabled`,不得要求镜像构建、CronJob、Secret 或 state ConfigMap 存在。
|
||||
|
||||
`sync --confirm` 同时会按 YAML 渲染账号级哨兵资源,并在 monitor 开启时先确保可复用哨兵镜像存在。当前目标是 `sentinel.monitor.enabled=true` + `sentinel.actions.enabled=true` 的 marker-only 自动冻结/恢复;不要手工 patch CronJob、Secret 或 Sub2API account。若 YAML 新增账号或修改 profile/base URL/API key fingerprint/upstream User-Agent/Responses WebSocket mode,sync 会从变更前 runtime state 写入 pending probe 记录并立即安排 sentinel probe,但不会把既有账号直接恢复为可调度;只有 sentinel 读取到 Sub2API runtime `schedulable=false` 后执行 recovery probe,且 marker 命中,才恢复 `schedulable=true`。sentinel 冻结/恢复只改 `schedulable=false|true`,不得顺手调用 Sub2API `recover-state` 清除请求路径临时不可调度或其他 runtime backoff。无关账号的既有成功/失败退避不能被重置。若 YAML 下调失败冻结最大窗口,sync 会把仍 active 的旧冻结状态迁移到当前最大窗口内并立即安排 recovery probe,但不会直接解冻。若怀疑某个账号被误判,先用 `codex-pool sentinel-probe --account <accountName> --confirm` 立即触发该账号测量;该命令从现有 CronJob 模板派生一次性 Job,复用同一份 Secret、ConfigMap、OpenAI SDK probe、token/cost 账本和冻结/恢复状态机。
|
||||
`sync --confirm` 同时会按 YAML 渲染账号级哨兵资源,并在 monitor 开启时先确保可复用哨兵镜像存在。当前目标是 `sentinel.monitor.enabled=true` + `sentinel.actions.enabled=true` 的 marker-only 自动冻结/恢复;不要手工 patch CronJob、Secret 或 Sub2API account。若 YAML 新增账号或修改 profile/base URL/API key fingerprint/upstream User-Agent/Responses WebSocket mode,sync 会从变更前 runtime state 写入 pending probe 记录并立即安排 sentinel probe,但不会把既有账号直接恢复为可调度;只有 sentinel 读取到 Sub2API runtime `schedulable=false` 后执行 recovery probe,且 marker 命中,才恢复 `schedulable=true`。sentinel 冻结/恢复只改 `schedulable=false|true`,不得顺手调用 Sub2API `recover-state` 清除请求路径临时不可调度或其他 runtime backoff。无关账号的既有成功/失败退避不能被重置。若 YAML 下调失败冻结最大窗口,sync 会把仍 active 的旧冻结状态迁移到当前最大窗口内并立即安排 recovery probe,但不会直接解冻。若怀疑某个账号被误判,先用 `codex-pool sentinel-probe --account <accountName> --confirm` 立即触发该账号测量;该命令从现有 CronJob 模板派生一次性 Job,复用同一份 Secret、ConfigMap、OpenAI SDK probe、token/cost 账本和冻结/恢复状态机。默认输出应是短文本摘要;逐账号完整 state 只走 `--full`/`--raw`。
|
||||
|
||||
`trace --request-id <requestId>` 是只读 request 追溯报表,不触发 probe、不修改账号。默认输出请求开始/最终状态、failover、`account_select_failed`、窗口内 `account_temp_unschedulable`、admin schedulable 写入计数和当前账号快照;`reason=failover-attempted-no-candidate` 表示 Sub2API 已进入自动切号,但排除当前失败账号后没有可用候选。需要机器处理时使用 `--raw`,需要原始匹配行时加 `--show-lines`。
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ manualAccounts:
|
||||
protected:
|
||||
- accountName: lucianepidgeon@gmail.com
|
||||
reason: Manually configured in Sub2API; keep outside UniDesk-managed Codex pool and sentinel control.
|
||||
targetIds: [PK01]
|
||||
proxyBinding:
|
||||
enabled: true
|
||||
source: pk01-local-egress-proxy
|
||||
@@ -216,7 +217,7 @@ sentinel:
|
||||
actions:
|
||||
enabled: true
|
||||
schedule: "*/1 * * * *"
|
||||
image: docker.m.daocloud.io/library/python:3.12-alpine
|
||||
image: python:3.12-alpine
|
||||
sdk:
|
||||
openaiPythonVersion: "2.41.1"
|
||||
serviceAccountName: sub2api-account-sentinel
|
||||
|
||||
@@ -100,15 +100,22 @@ export interface CodexPoolSentinelManifestOptions {
|
||||
}
|
||||
|
||||
export function codexPoolSentinelRuntimeImage(config: CodexPoolSentinelConfig): CodexPoolSentinelImageTarget {
|
||||
const [repository, tag = "latest"] = config.image.split(":");
|
||||
const tag = sentinelRuntimeImageTag(config.image, config.sdk.openaiPythonVersion);
|
||||
return {
|
||||
baseImage: config.image,
|
||||
runtimeImage: config.image,
|
||||
repository: repository ?? config.image,
|
||||
runtimeImage: `127.0.0.1:5000/platform-infra/sub2api-account-sentinel:${tag}`,
|
||||
repository: "127.0.0.1:5000/platform-infra/sub2api-account-sentinel",
|
||||
tag,
|
||||
};
|
||||
}
|
||||
|
||||
function sentinelRuntimeImageTag(baseImage: string, openaiPythonVersion: string): string {
|
||||
return `openai-${openaiPythonVersion}-${baseImage}`
|
||||
.replace(/[^A-Za-z0-9_.-]+/gu, "-")
|
||||
.replace(/^-+/u, "")
|
||||
.slice(0, 128);
|
||||
}
|
||||
|
||||
export function readCodexPoolSentinelConfig(value: unknown, sourcePath: string): CodexPoolSentinelConfig {
|
||||
if (!isRecord(value)) throw new Error(`${sourcePath}.sentinel must be a YAML object`);
|
||||
const monitor = readRequiredRecord(valueAt(value, "monitor"), `${sourcePath}.sentinel.monitor`);
|
||||
|
||||
@@ -26,11 +26,11 @@ import { desiredAccountNames } from "./accounts";
|
||||
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, resolvedManualAccountProtections, secretMaterialSummary, sentinelProfileSecrets, targetFrpPublicExposure, targetPublicExposureApplyScript, targetPublicExposureSummary } from "./public-exposure";
|
||||
import { manualBindingSourcePlan, poolTarget, prepareTargetPublicExposureSecret, protectedManualAccountNamesForTarget, resolvedManualAccountProtections, secretMaterialSummary, sentinelProfileSecrets, targetFrpPublicExposure, targetPublicExposureApplyScript, targetPublicExposureSummary } from "./public-exposure";
|
||||
import { codexPoolConfigSummary, compactProfile, compactSentinelProbeResult, redactProfile, renderSub2ApiTempUnschedulableCredentials } from "./redaction";
|
||||
import { boolField, capture, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote";
|
||||
import { cleanupProbesScript, sentinelProbeScript, sentinelReportScript, syncScript, traceScript, validateScript } from "./remote-scripts";
|
||||
import { codexPoolSyncSummary, codexPoolValidationSummary, renderSentinelReport, renderTraceReport, renderedCliResult } from "./render";
|
||||
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";
|
||||
|
||||
@@ -84,7 +84,7 @@ export function codexPoolPlan(options?: DisclosureOptions): Record<string, unkno
|
||||
};
|
||||
}
|
||||
|
||||
export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promise<Record<string, unknown>> {
|
||||
export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const pool = readCodexPoolConfig();
|
||||
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
|
||||
const profiles = collectCodexProfiles();
|
||||
@@ -108,7 +108,7 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions)
|
||||
};
|
||||
}
|
||||
if (!options.confirm || !planOk) {
|
||||
return {
|
||||
const plan = {
|
||||
...codexPoolPlan(options),
|
||||
ok: !options.confirm ? planOk : false,
|
||||
mode: options.confirm ? "blocked-invalid-local-profile" : "dry-run",
|
||||
@@ -116,6 +116,7 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions)
|
||||
? { fix: "Repair invalid local Codex profiles, then rerun sync --confirm." }
|
||||
: { confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm" },
|
||||
};
|
||||
return options.full || options.raw ? plan : renderCodexPoolPlan(plan);
|
||||
}
|
||||
|
||||
const sentinelImage = pool.sentinel.monitor.enabled && runtimeTarget.sentinelEnabled
|
||||
@@ -138,7 +139,7 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions)
|
||||
sentinel: {
|
||||
enabledForTarget: runtimeTarget.sentinelEnabled,
|
||||
manifest: runtimeTarget.sentinelEnabled
|
||||
? renderCodexPoolSentinelManifest(pool.sentinel, sentinelProfileSecrets(profiles), {
|
||||
? renderCodexPoolSentinelManifest(sentinelConfigForTarget(pool, runtimeTarget), sentinelProfileSecrets(profiles), {
|
||||
namespace: runtimeTarget.namespace,
|
||||
serviceName: runtimeTarget.serviceName,
|
||||
serviceDns: runtimeTarget.serviceDns,
|
||||
@@ -150,7 +151,7 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions)
|
||||
} : null,
|
||||
})
|
||||
: null,
|
||||
summary: codexPoolSentinelSummary(pool.sentinel),
|
||||
summary: codexPoolSentinelSummary(sentinelConfigForTarget(pool, runtimeTarget)),
|
||||
},
|
||||
pool: {
|
||||
groupName: pool.groupName,
|
||||
@@ -213,9 +214,14 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions)
|
||||
parsed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
const response = {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-sync",
|
||||
target: {
|
||||
id: runtimeTarget.id,
|
||||
route: runtimeTarget.route,
|
||||
namespace: runtimeTarget.namespace,
|
||||
},
|
||||
local: {
|
||||
profileCount: profiles.length,
|
||||
pruneRemoved: options.pruneRemoved,
|
||||
@@ -231,6 +237,7 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions)
|
||||
validate: `bun scripts/cli.ts platform-infra sub2api codex-pool validate${targetFlag(runtimeTarget)}`,
|
||||
},
|
||||
};
|
||||
return options.full ? response : renderCodexPoolSyncResult(response);
|
||||
}
|
||||
|
||||
export async function codexPoolSentinelImage(config: UniDeskConfig, options: SentinelImageOptions): Promise<Record<string, unknown>> {
|
||||
@@ -258,7 +265,7 @@ export async function runCodexPoolSentinelImage(config: UniDeskConfig, pool: Cod
|
||||
}
|
||||
const summary = {
|
||||
ok: true,
|
||||
mode: options.action === "status" ? "base-image-runtime" : options.dryRun ? "dry-run-base-image-runtime" : "skipped-build-base-image-runtime",
|
||||
mode: options.action === "status" ? "status" : options.dryRun ? "dry-run" : "build",
|
||||
target: {
|
||||
id: runtimeTarget.id,
|
||||
namespace: runtimeTarget.namespace,
|
||||
@@ -266,31 +273,46 @@ export async function runCodexPoolSentinelImage(config: UniDeskConfig, pool: Cod
|
||||
image: target.runtimeImage,
|
||||
baseImage: target.baseImage,
|
||||
openaiPythonVersion: pool.sentinel.sdk.openaiPythonVersion,
|
||||
sdkInstall: "container-startup-pinned",
|
||||
dockerRequired: false,
|
||||
registryPushRequired: false,
|
||||
mutation: false,
|
||||
sdkInstall: "prebuilt-runtime-image",
|
||||
dockerRequired: options.action === "status" || !options.dryRun,
|
||||
registryPushRequired: options.action === "build" && !options.dryRun,
|
||||
mutation: options.action === "build" && !options.dryRun,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
const script = options.action === "build" && !options.dryRun
|
||||
? sentinelImageBuildScript(pool, runtimeTarget)
|
||||
: sentinelImageStatusScript(pool, runtimeTarget);
|
||||
const result = await runRemoteCodexPoolScript(config, options.action === "build" && !options.dryRun ? "sentinel-image-build" : "sentinel-image-status", script, runtimeTarget);
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const ok = result.exitCode === 0 && boolField(parsed, "ok", options.dryRun);
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok: true,
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-sentinel-image",
|
||||
mode: options.action,
|
||||
mode: summary.mode,
|
||||
image: target,
|
||||
parsed: summary,
|
||||
remote: compactCapture(result, { full: true }),
|
||||
parsed: parsed ?? summary,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-sentinel-image",
|
||||
mode: options.action,
|
||||
mode: summary.mode,
|
||||
image: target,
|
||||
summary,
|
||||
summary: parsed === null ? summary : { ...summary, ...parsed },
|
||||
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
||||
function sentinelConfigForTarget(pool: CodexPoolConfig, target: ReturnType<typeof codexPoolRuntimeTarget>): CodexPoolConfig["sentinel"] {
|
||||
return {
|
||||
...pool.sentinel,
|
||||
protectedManualAccounts: protectedManualAccountNamesForTarget(pool, target),
|
||||
};
|
||||
}
|
||||
|
||||
export async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const pool = readCodexPoolConfig();
|
||||
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
|
||||
const result = await runRemoteCodexPoolScript(config, "validate", validateScript(pool, runtimeTarget), runtimeTarget);
|
||||
@@ -303,12 +325,18 @@ export async function codexPoolValidate(config: UniDeskConfig, options: Disclosu
|
||||
parsed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
const response = {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-validate",
|
||||
target: {
|
||||
id: runtimeTarget.id,
|
||||
route: runtimeTarget.route,
|
||||
namespace: runtimeTarget.namespace,
|
||||
},
|
||||
summary: options.full ? parsed : codexPoolValidationSummary(parsed),
|
||||
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
||||
};
|
||||
return options.full ? response : renderCodexPoolValidateResult(response);
|
||||
}
|
||||
|
||||
export async function codexPoolTrace(config: UniDeskConfig, options: TraceOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
@@ -357,10 +385,10 @@ export async function codexPoolSentinelReport(config: UniDeskConfig, options: Se
|
||||
return renderedCliResult(ok, "platform-infra sub2api codex-pool sentinel-report", text);
|
||||
}
|
||||
|
||||
export async function codexPoolSentinelProbe(config: UniDeskConfig, options: SentinelProbeOptions): Promise<Record<string, unknown>> {
|
||||
export async function codexPoolSentinelProbe(config: UniDeskConfig, options: SentinelProbeOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const pool = readCodexPoolConfig();
|
||||
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
|
||||
const protectedNames = new Set(pool.manualAccounts.protected.map((account) => account.accountName.toLowerCase()));
|
||||
const protectedNames = new Set(protectedManualAccountNamesForTarget(pool, runtimeTarget).map((account) => account.toLowerCase()));
|
||||
const protectedRequested = options.accounts.filter((account) => protectedNames.has(account.toLowerCase()));
|
||||
if (protectedRequested.length > 0) {
|
||||
return {
|
||||
@@ -403,20 +431,28 @@ export async function codexPoolSentinelProbe(config: UniDeskConfig, options: Sen
|
||||
};
|
||||
const result = await runRemoteCodexPoolScript(config, "sentinel-probe", sentinelProbeScript(payload, pool, runtimeTarget), runtimeTarget);
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const summary = compactSentinelProbeResult(parsed);
|
||||
const ok = summary?.ok === true || (result.exitCode === 0 && boolField(parsed, "ok", false));
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-sentinel-probe",
|
||||
remote: compactCapture(result, { full: true }),
|
||||
parsed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
const response = {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-sentinel-probe",
|
||||
summary: options.full ? parsed : compactSentinelProbeResult(parsed),
|
||||
target: {
|
||||
id: runtimeTarget.id,
|
||||
route: runtimeTarget.route,
|
||||
namespace: runtimeTarget.namespace,
|
||||
},
|
||||
summary: options.full ? parsed : summary,
|
||||
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
||||
};
|
||||
return options.full ? response : renderCodexPoolSentinelProbeResult(response);
|
||||
}
|
||||
|
||||
export async function codexPoolCleanupProbes(config: UniDeskConfig, options: ConfirmOptions): Promise<Record<string, unknown>> {
|
||||
|
||||
@@ -263,9 +263,10 @@ export function readManualAccountsConfig(value: unknown): CodexPoolManualAccount
|
||||
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;
|
||||
const targetIds = isRecord(entry) ? readManualAccountTargetIds(entry.targetIds, `${key}.targetIds`) : null;
|
||||
const proxyBinding = isRecord(entry) ? readManualAccountProxyBinding(entry.proxyBinding, `${key}.proxyBinding`, bindingSources) : null;
|
||||
const groupBinding = isRecord(entry) ? readManualAccountGroupBinding(entry.groupBinding, `${key}.groupBinding`, bindingSources) : null;
|
||||
return { accountName, reason, proxyBinding, groupBinding };
|
||||
return { accountName, reason, targetIds, proxyBinding, groupBinding };
|
||||
});
|
||||
return { bindingSources, protected: protectedAccounts };
|
||||
}
|
||||
@@ -380,6 +381,22 @@ export function readManualAccountReason(value: unknown, key: string): string | n
|
||||
return text;
|
||||
}
|
||||
|
||||
export function readManualAccountTargetIds(value: unknown, key: string): string[] | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!Array.isArray(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML array of target ids`);
|
||||
if (value.length === 0) throw new Error(`${codexPoolConfigPath}.${key} must not be empty`);
|
||||
const seen = new Set<string>();
|
||||
return value.map((item, index) => {
|
||||
const targetId = stringValue(item);
|
||||
if (targetId === null) throw new Error(`${codexPoolConfigPath}.${key}[${index}] must be a non-empty string`);
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error(`${codexPoolConfigPath}.${key}[${index}] has an unsupported target id format`);
|
||||
const normalized = targetId.toLowerCase();
|
||||
if (seen.has(normalized)) throw new Error(`${codexPoolConfigPath}.${key}[${index}] duplicates target ${targetId}`);
|
||||
seen.add(normalized);
|
||||
return targetId;
|
||||
});
|
||||
}
|
||||
|
||||
export 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()) {
|
||||
|
||||
@@ -23,12 +23,17 @@ import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
|
||||
|
||||
import type { ConfirmOptions, DisclosureOptions, SentinelImageOptions, SentinelProbeOptions, SentinelReportOptions, SyncOptions, TraceOptions } from "./types";
|
||||
import { codexPoolCleanupProbes, codexPoolConfigureLocal, codexPoolExpose, codexPoolPlan, codexPoolSentinelImage, codexPoolSentinelProbe, codexPoolSentinelReport, codexPoolSync, codexPoolTrace, codexPoolValidate } from "./actions";
|
||||
import { renderCodexPoolPlan } from "./render";
|
||||
import { defaultCodexPoolRuntimeTargetId } from "./runtime-target";
|
||||
import { codexPoolHelp } from "./types";
|
||||
|
||||
export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const [action = "plan"] = args;
|
||||
if (action === "plan") return codexPoolPlan(parseDisclosureOptions(args.slice(1)));
|
||||
if (action === "plan") {
|
||||
const options = parseDisclosureOptions(args.slice(1));
|
||||
const result = codexPoolPlan(options);
|
||||
return options.full || options.raw ? result : renderCodexPoolPlan(result);
|
||||
}
|
||||
if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1)));
|
||||
if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1)));
|
||||
if (action === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1)));
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
|
||||
|
||||
import type { CodexPoolConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProxyBinding, CodexPoolManualBindingSource, CodexPoolRuntimeTarget, CodexProfile } from "./types";
|
||||
import type { CodexPoolConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualBindingSource, CodexPoolRuntimeTarget, CodexProfile } from "./types";
|
||||
import { desiredAccountCapacityTotal } from "./accounts";
|
||||
import { readCodexPoolConfig } from "./config";
|
||||
import { codexConsumerBaseUrl } from "./local-codex";
|
||||
@@ -47,6 +47,7 @@ export function poolTarget(pool = readCodexPoolConfig(), target = codexPoolRunti
|
||||
source: `${sub2apiConfigPath}.targets[${target.id}].codexPool.sentinelImageBuild`,
|
||||
baseImageCachePolicy: target.sentinelImageBuild.baseImageCachePolicy,
|
||||
noProxy: target.sentinelImageBuild.noProxy,
|
||||
proxyEnvPath: target.sentinelImageBuild.proxyEnvPath,
|
||||
},
|
||||
minOwnerConcurrency: pool.minOwnerConcurrency,
|
||||
minOwnerConcurrencySource: pool.minOwnerConcurrencySource,
|
||||
@@ -88,14 +89,26 @@ export function sentinelProfileSecrets(profiles: CodexProfile[]): CodexPoolSenti
|
||||
}
|
||||
|
||||
export function resolvedManualAccountProtections(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): Record<string, unknown>[] {
|
||||
return pool.manualAccounts.protected.map((account) => ({
|
||||
return protectedManualAccountsForTarget(pool, target).map((account) => ({
|
||||
accountName: account.accountName,
|
||||
reason: account.reason,
|
||||
targetIds: account.targetIds,
|
||||
proxyBinding: resolveManualProxyBinding(account.proxyBinding, pool, target),
|
||||
groupBinding: resolveManualGroupBinding(account.groupBinding, pool),
|
||||
}));
|
||||
}
|
||||
|
||||
export function protectedManualAccountsForTarget(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): CodexPoolManualAccountProtection[] {
|
||||
return pool.manualAccounts.protected.filter((account) => (
|
||||
account.targetIds === null
|
||||
|| account.targetIds.some((id) => id.toLowerCase() === target.id.toLowerCase())
|
||||
));
|
||||
}
|
||||
|
||||
export function protectedManualAccountNamesForTarget(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string[] {
|
||||
return protectedManualAccountsForTarget(pool, target).map((account) => account.accountName);
|
||||
}
|
||||
|
||||
export function resolveManualProxyBinding(binding: CodexPoolManualAccountProxyBinding | null, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): Record<string, unknown> | null {
|
||||
if (binding === null) return null;
|
||||
const source = manualBindingSource(pool, binding.source);
|
||||
|
||||
@@ -24,7 +24,7 @@ import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
|
||||
import type { CodexPoolConfig, CodexPoolRuntimeTarget, CodexProfile, CodexTempUnschedulablePolicy } from "./types";
|
||||
import { desiredAccountCapacityTotal } from "./accounts";
|
||||
import { fingerprint, isRecord } from "./config-utils";
|
||||
import { manualBindingSourcePlan, targetPublicExposureSummary } from "./public-exposure";
|
||||
import { manualBindingSourcePlan, protectedManualAccountsForTarget, targetPublicExposureSummary } from "./public-exposure";
|
||||
|
||||
export function readAuthAPIKey(authPath: string): { apiKey: string | null; shape: string } {
|
||||
if (!existsSync(authPath)) return { apiKey: null, shape: "missing" };
|
||||
@@ -148,12 +148,22 @@ export function codexPoolConfigSummary(pool: CodexPoolConfig, target: CodexPoolR
|
||||
manualAccounts: {
|
||||
bindingSources: pool.manualAccounts.bindingSources.items.map(manualBindingSourcePlan),
|
||||
protectedCount: pool.manualAccounts.protected.length,
|
||||
targetProtectedCount: protectedManualAccountsForTarget(pool, target).length,
|
||||
targetProtected: protectedManualAccountsForTarget(pool, target).map((account) => ({
|
||||
accountName: account.accountName,
|
||||
targetIds: account.targetIds,
|
||||
proxyBindingEnabled: account.proxyBinding?.enabled ?? false,
|
||||
groupBindingEnabled: account.groupBinding?.enabled ?? false,
|
||||
})),
|
||||
protected: pool.manualAccounts.protected,
|
||||
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: targetPublicExposureSummary(target),
|
||||
localCodex: pool.localCodex,
|
||||
sentinel: codexPoolSentinelSummary(pool.sentinel),
|
||||
sentinel: {
|
||||
...codexPoolSentinelSummary(pool.sentinel),
|
||||
runtimeImage: codexPoolSentinelRuntimeImage(pool.sentinel).runtimeImage,
|
||||
},
|
||||
disclosure: {
|
||||
full: "bun scripts/cli.ts platform-infra sub2api codex-pool plan --full",
|
||||
},
|
||||
@@ -650,11 +660,24 @@ export function compactSentinelStatus(block: unknown): unknown {
|
||||
|
||||
export function compactSentinelProbeResult(parsed: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (parsed === null) return null;
|
||||
const probe = isRecord(parsed.probe) ? parsed.probe : {};
|
||||
const summary = isRecord(probe.summary) ? probe.summary : {};
|
||||
const probe = isRecord(parsed.probe) ? parsed.probe : parsed;
|
||||
const summary = isRecord(parsed.summary) ? parsed.summary : isRecord(probe.summary) ? probe.summary : {};
|
||||
const state = isRecord(parsed.sentinelState) ? parsed.sentinelState : {};
|
||||
const lastRun = isRecord(state.lastRun) ? state.lastRun : {};
|
||||
const requestedAccounts = Array.isArray(parsed.requestedAccounts) ? parsed.requestedAccounts.filter((item): item is string => typeof item === "string") : [];
|
||||
const directResults = recordArray(parsed.results).length > 0 ? recordArray(parsed.results) : recordArray(probe.results);
|
||||
const lastRunResults = recordArray(lastRun.results);
|
||||
const results = directResults.length > 0
|
||||
? directResults
|
||||
: requestedAccounts.length === 0
|
||||
? lastRunResults
|
||||
: lastRunResults.filter((item) => requestedAccounts.includes(String(item.accountName ?? "")));
|
||||
const allRequestedOk = requestedAccounts.length > 0
|
||||
&& requestedAccounts.every((account) => results.some((item) => item.accountName === account && item.markerMatched === true));
|
||||
return {
|
||||
ok: parsed.ok,
|
||||
ok: parsed.ok === true || allRequestedOk,
|
||||
jobExecutionOk: parsed.jobExecutionOk,
|
||||
markerOk: parsed.markerOk === true || allRequestedOk,
|
||||
mode: parsed.mode,
|
||||
namespace: parsed.namespace,
|
||||
job: parsed.job,
|
||||
@@ -672,7 +695,7 @@ export function compactSentinelProbeResult(parsed: Record<string, unknown> | nul
|
||||
gatewayFailureMonitor: summary.gatewayFailureMonitor,
|
||||
selection: summary.selection,
|
||||
},
|
||||
results: recordArray(probe.results).map((item) => pickSummaryFields(item, [
|
||||
results: results.map((item) => pickSummaryFields(item, [
|
||||
"accountName",
|
||||
"purpose",
|
||||
"ok",
|
||||
@@ -691,16 +714,29 @@ export function compactSentinelProbeResult(parsed: Record<string, unknown> | nul
|
||||
"sdk",
|
||||
"requestShape",
|
||||
])),
|
||||
actions: recordArray(probe.actions).map((item) => pickSummaryFields(item, [
|
||||
actions: recordArray(parsed.actions).concat(recordArray(probe.actions)).map((item) => pickSummaryFields(item, [
|
||||
"accountName",
|
||||
"taken",
|
||||
"type",
|
||||
"error",
|
||||
])),
|
||||
sentinelState: {
|
||||
quarantined: state.quarantined,
|
||||
recentAccounts: state.recentAccounts,
|
||||
lastRun: state.lastRun,
|
||||
quarantinedCount: recordArray(state.quarantined).length,
|
||||
recentAccountCount: recordArray(state.recentAccounts).length,
|
||||
lastRun: {
|
||||
at: lastRun.at,
|
||||
selected: lastRun.selected,
|
||||
okCount: lastRun.okCount,
|
||||
markerMismatchCount: lastRun.markerMismatchCount,
|
||||
transportFailureCount: lastRun.transportFailureCount,
|
||||
actionsTaken: lastRun.actionsTaken,
|
||||
runtimeSchedulable: isRecord(lastRun.runtimeSchedulable) ? {
|
||||
ok: lastRun.runtimeSchedulable.ok,
|
||||
schedulableCount: Array.isArray(lastRun.runtimeSchedulable.schedulableAccounts) ? lastRun.runtimeSchedulable.schedulableAccounts.length : undefined,
|
||||
unschedulableCount: recordArray(lastRun.runtimeSchedulable.unschedulableAccounts).length,
|
||||
missingCount: Array.isArray(lastRun.runtimeSchedulable.missingAccounts) ? lastRun.runtimeSchedulable.missingAccounts.length : undefined,
|
||||
} : undefined,
|
||||
},
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
|
||||
@@ -2986,11 +2986,23 @@ def run_sentinel_probe():
|
||||
logs = job_logs(job_name)
|
||||
parsed = parse_embedded_json(logs.get("stdout") or "")
|
||||
results = parsed.get("results") if isinstance(parsed, dict) and isinstance(parsed.get("results"), list) else []
|
||||
state_summary = sentinel_state_summary()
|
||||
last_run = state_summary.get("lastRun") if isinstance(state_summary, dict) and isinstance(state_summary.get("lastRun"), dict) else {}
|
||||
if not results and isinstance(last_run.get("results"), list):
|
||||
results = last_run.get("results")
|
||||
requested = set(accounts)
|
||||
measured = {item.get("accountName") for item in results if isinstance(item, dict)}
|
||||
missing = sorted(name for name in requested if name not in measured)
|
||||
marker_ok = len(missing) == 0 and all(isinstance(item, dict) and item.get("accountName") in requested and item.get("markerMatched") is True for item in results if isinstance(item, dict) and item.get("accountName") in requested)
|
||||
job_ok = status == "succeeded" and isinstance(parsed, dict) and parsed.get("ok") is True
|
||||
if not results and isinstance(last_run, dict):
|
||||
selected = int(last_run.get("selected") or 0)
|
||||
ok_count = int(last_run.get("okCount") or 0)
|
||||
marker_mismatch_count = int(last_run.get("markerMismatchCount") or 0)
|
||||
transport_failure_count = int(last_run.get("transportFailureCount") or 0)
|
||||
if selected >= len(requested) and ok_count >= len(requested) and marker_mismatch_count == 0 and transport_failure_count == 0:
|
||||
missing = []
|
||||
marker_ok = True
|
||||
job_ok = status == "succeeded" and logs.get("exitCode") == 0
|
||||
return {
|
||||
"ok": job_ok and marker_ok,
|
||||
"jobExecutionOk": job_ok,
|
||||
@@ -3009,7 +3021,17 @@ def run_sentinel_probe():
|
||||
"logsStderrTail": logs.get("stderr"),
|
||||
},
|
||||
"probe": parsed,
|
||||
"sentinelState": sentinel_state_summary(),
|
||||
"summary": {
|
||||
"at": last_run.get("at"),
|
||||
"selected": last_run.get("selected"),
|
||||
"okCount": last_run.get("okCount"),
|
||||
"markerMismatchCount": last_run.get("markerMismatchCount"),
|
||||
"transportFailureCount": last_run.get("transportFailureCount"),
|
||||
"actionsTaken": last_run.get("actionsTaken"),
|
||||
"selection": last_run.get("selection"),
|
||||
},
|
||||
"results": results,
|
||||
"sentinelState": state_summary,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@ tag=${shQuote(target.tag)}
|
||||
base_image=${shQuote(target.baseImage)}
|
||||
openai_version=${shQuote(sentinel.sdk.openaiPythonVersion)}
|
||||
base_image_cache_policy=${shQuote(targetRuntime.sentinelImageBuild.baseImageCachePolicy)}
|
||||
proxy_env_path=${shQuote(targetRuntime.sentinelImageBuild.proxyEnvPath ?? "")}
|
||||
work=/tmp/unidesk-sub2api-sentinel-image
|
||||
mkdir -p "$work"
|
||||
dockerfile_path="$work/sentinel.Dockerfile"
|
||||
@@ -380,6 +381,22 @@ fi
|
||||
base64 -d > "$dockerfile_path" <<'UNIDESK_SENTINEL_DOCKERFILE_B64'
|
||||
${dockerfileB64}
|
||||
UNIDESK_SENTINEL_DOCKERFILE_B64
|
||||
if [ -n "$proxy_env_path" ] && [ -r "$proxy_env_path" ]; then
|
||||
set -a
|
||||
. "$proxy_env_path"
|
||||
set +a
|
||||
fi
|
||||
HTTP_PROXY="$(printenv HTTP_PROXY 2>/dev/null || true)"
|
||||
HTTPS_PROXY="$(printenv HTTPS_PROXY 2>/dev/null || true)"
|
||||
ALL_PROXY="$(printenv ALL_PROXY 2>/dev/null || true)"
|
||||
http_proxy_value="$(printenv http_proxy 2>/dev/null || true)"
|
||||
https_proxy_value="$(printenv https_proxy 2>/dev/null || true)"
|
||||
all_proxy_value="$(printenv all_proxy 2>/dev/null || true)"
|
||||
if [ -z "$HTTP_PROXY" ]; then HTTP_PROXY="$http_proxy_value"; fi
|
||||
if [ -z "$HTTPS_PROXY" ]; then HTTPS_PROXY="$https_proxy_value"; fi
|
||||
if [ -z "$ALL_PROXY" ]; then ALL_PROXY="$all_proxy_value"; fi
|
||||
if [ -z "$HTTPS_PROXY" ]; then HTTPS_PROXY="$HTTP_PROXY"; fi
|
||||
if [ -z "$ALL_PROXY" ]; then ALL_PROXY="$HTTP_PROXY"; fi
|
||||
export NO_PROXY=${shQuote(targetRuntime.sentinelImageBuild.noProxy)}
|
||||
export no_proxy=$NO_PROXY
|
||||
set -- --pull
|
||||
@@ -389,9 +406,11 @@ if [ "$base_image_cache_policy" = "local-if-present" ] && docker image inspect "
|
||||
base_image_source="local-cache"
|
||||
fi
|
||||
docker build "$@" \\
|
||||
--network host \\
|
||||
--build-arg BASE_IMAGE="$base_image" \\
|
||||
--build-arg OPENAI_PYTHON_VERSION="$openai_version" \\
|
||||
--build-arg HTTP_PROXY= --build-arg HTTPS_PROXY= --build-arg http_proxy= --build-arg https_proxy= \\
|
||||
--build-arg HTTP_PROXY="$HTTP_PROXY" --build-arg HTTPS_PROXY="$HTTPS_PROXY" --build-arg ALL_PROXY="$ALL_PROXY" \\
|
||||
--build-arg http_proxy="$HTTP_PROXY" --build-arg https_proxy="$HTTPS_PROXY" --build-arg all_proxy="$ALL_PROXY" \\
|
||||
--build-arg NO_PROXY --build-arg no_proxy \\
|
||||
-f "$dockerfile_path" \\
|
||||
-t "$image" \\
|
||||
|
||||
@@ -84,6 +84,8 @@ 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 === "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";
|
||||
return "bun scripts/cli.ts platform-infra sub2api codex-pool validate";
|
||||
}
|
||||
|
||||
@@ -224,15 +226,21 @@ export function sleep(ms: number): Promise<void> {
|
||||
export function parseJsonOutput(stdout: string): Record<string, unknown> | null {
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
if (end === -1) return null;
|
||||
const starts = [trimmed.indexOf("{")];
|
||||
for (let index = trimmed.lastIndexOf("\n{"); index !== -1; index = trimmed.lastIndexOf("\n{", index - 1)) {
|
||||
starts.push(index + 1);
|
||||
}
|
||||
for (const start of starts.filter((item) => item !== -1 && item < end).sort((left, right) => right - left)) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function boolField(value: Record<string, unknown> | null, key: string, defaultValue: boolean): boolean {
|
||||
|
||||
@@ -28,6 +28,271 @@ export function renderedCliResult(ok: boolean, command: string, renderedText: st
|
||||
return { ok, command, renderedText, contentType: "text/plain" };
|
||||
}
|
||||
|
||||
export function renderCodexPoolPlan(plan: Record<string, unknown>): RenderedCliResult {
|
||||
const target = isRecord(plan.target) ? plan.target : {};
|
||||
const config = isRecord(plan.config) ? plan.config : {};
|
||||
const pool = isRecord(config.pool) ? config.pool : {};
|
||||
const manualAccounts = isRecord(pool.manualAccounts) ? pool.manualAccounts : {};
|
||||
const sentinel = isRecord(pool.sentinel) ? pool.sentinel : {};
|
||||
const profiles = recordArray(plan.profiles);
|
||||
const invalidProfiles = profiles.filter((profile) => profile.ok !== true);
|
||||
const lines: string[] = [];
|
||||
lines.push([
|
||||
"SUB2API CODEX POOL PLAN",
|
||||
`ok=${plan.ok === true ? "true" : "false"}`,
|
||||
`target=${target.id ?? "-"}`,
|
||||
`profiles=${profiles.length}`,
|
||||
`invalid=${invalidProfiles.length}`,
|
||||
].join(" "));
|
||||
lines.push(renderTable([
|
||||
["TARGET", "ROUTE", "NS", "MODE", "GROUP", "CAP", "PUBLIC", "CONSUMER"],
|
||||
[
|
||||
textValue(target.id),
|
||||
textValue(target.route),
|
||||
textValue(target.namespace),
|
||||
textValue(target.runtimeMode),
|
||||
textValue(pool.groupName ?? target.groupName),
|
||||
textValue(pool.accountCapacityTotal ?? target.accountCapacityTotal),
|
||||
textValue(target.publicBaseUrl),
|
||||
textValue(target.consumerBaseUrl),
|
||||
],
|
||||
]));
|
||||
lines.push(renderTable([
|
||||
["SENTINEL", "ACTIONS", "CRON", "BASE_IMAGE", "RUNTIME_IMAGE", "SDK"],
|
||||
[
|
||||
boolText(sentinel.monitorEnabled),
|
||||
boolText(sentinel.actionsEnabled),
|
||||
textValue(sentinel.cronJobName),
|
||||
textValue(sentinel.image),
|
||||
textValue(sentinel.runtimeImage),
|
||||
textValue(isRecord(sentinel.sdk) ? sentinel.sdk.openaiPythonVersion : undefined),
|
||||
],
|
||||
]));
|
||||
lines.push(renderTable([
|
||||
["MANUAL_PROTECTED", "TARGET_APPLIES", "POLICY"],
|
||||
[
|
||||
textValue(manualAccounts.protectedCount),
|
||||
textValue(manualAccounts.targetProtectedCount),
|
||||
"no credentials/prune/sentinel; only declared proxy/group bindings",
|
||||
],
|
||||
]));
|
||||
lines.push("");
|
||||
lines.push("PROFILES");
|
||||
lines.push(renderTable([
|
||||
["PROFILE", "ACCOUNT", "OK", "CAP", "LF", "PRI", "TRUST", "PROT"],
|
||||
...profiles.map((profile) => [
|
||||
textValue(profile.profile),
|
||||
textValue(profile.accountName),
|
||||
boolText(profile.ok),
|
||||
textValue(profile.capacity),
|
||||
textValue(profile.loadFactor),
|
||||
textValue(profile.priority),
|
||||
boolText(profile.trustUpstream),
|
||||
profile.sentinelProtectEnabled === true ? textValue(profile.sentinelProtectConsecutiveFailures) : "-",
|
||||
]),
|
||||
]));
|
||||
if (invalidProfiles.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("INVALID");
|
||||
lines.push(renderTable([
|
||||
["PROFILE", "ACCOUNT", "ERROR"],
|
||||
...invalidProfiles.map((profile) => [
|
||||
textValue(profile.profile),
|
||||
textValue(profile.accountName),
|
||||
textValue(profile.error),
|
||||
]),
|
||||
]));
|
||||
}
|
||||
const next = isRecord(plan.next) ? plan.next : {};
|
||||
const nextLines = Object.entries(next)
|
||||
.filter(([, value]) => typeof value === "string")
|
||||
.map(([key, value]) => ` ${key}: ${value}`);
|
||||
if (nextLines.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("NEXT");
|
||||
lines.push(...nextLines);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("Raw: bun scripts/cli.ts platform-infra sub2api codex-pool plan --raw");
|
||||
lines.push("Full: bun scripts/cli.ts platform-infra sub2api codex-pool plan --full");
|
||||
return renderedCliResult(plan.ok === true, "platform-infra sub2api codex-pool plan", lines.join("\n"));
|
||||
}
|
||||
|
||||
export function renderCodexPoolSyncResult(result: Record<string, unknown>): RenderedCliResult {
|
||||
const target = isRecord(result.target) ? result.target : {};
|
||||
const local = isRecord(result.local) ? result.local : {};
|
||||
const image = isRecord(result.sentinelImage) ? result.sentinelImage : {};
|
||||
const imageSummary = isRecord(image.summary) ? image.summary : {};
|
||||
const remote = isRecord(result.remote) ? result.remote : {};
|
||||
const accounts = isRecord(remote.accounts) ? remote.accounts : {};
|
||||
const lines = renderCodexPoolRemoteSummary("SUB2API CODEX POOL SYNC", result, target, remote);
|
||||
lines.push(renderTable([
|
||||
["LOCAL_PROFILES", "PRUNE", "IMAGE_MODE", "IMAGE", "SDK"],
|
||||
[
|
||||
textValue(local.profileCount),
|
||||
boolText(local.pruneRemoved),
|
||||
textValue(imageSummary.mode ?? image.mode),
|
||||
textValue(imageSummary.image ?? image.image),
|
||||
textValue(imageSummary.openaiPythonVersion),
|
||||
],
|
||||
]));
|
||||
lines.push(renderTable([
|
||||
["ACCOUNTS", "CREATED", "UPDATED", "PRUNED", "PRUNE_MODE", "ATTENTION"],
|
||||
[
|
||||
textValue(accounts.desired ?? accounts.itemCount),
|
||||
textValue(accounts.created),
|
||||
textValue(accounts.updated),
|
||||
textValue(accounts.pruned),
|
||||
textValue(accounts.pruneMode),
|
||||
textValue(recordArray(accounts.attentionItems).length),
|
||||
],
|
||||
]));
|
||||
appendCodexPoolCheckTable(lines, remote);
|
||||
appendNext(lines, result.next);
|
||||
lines.push("");
|
||||
lines.push("Full: bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm --full");
|
||||
lines.push("Raw: bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm --raw");
|
||||
return renderedCliResult(result.ok === true, "platform-infra sub2api codex-pool sync", lines.join("\n"));
|
||||
}
|
||||
|
||||
export function renderCodexPoolValidateResult(result: Record<string, unknown>): RenderedCliResult {
|
||||
const target = isRecord(result.target) ? result.target : {};
|
||||
const summary = isRecord(result.summary) ? result.summary : {};
|
||||
const lines = renderCodexPoolRemoteSummary("SUB2API CODEX POOL VALIDATE", result, target, summary);
|
||||
appendCodexPoolCheckTable(lines, summary);
|
||||
const validation = isRecord(summary.validation) ? summary.validation : {};
|
||||
lines.push(renderTable([
|
||||
["GATEWAY_MODELS", "GATEWAY_RESPONSES", "RESPONSES_RECENT", "COMPACT_RECENT"],
|
||||
[
|
||||
blockOk(validation.gatewayModels),
|
||||
blockOk(validation.gatewayResponses),
|
||||
blockOk(validation.gatewayResponsesRecent),
|
||||
blockOk(validation.gatewayCompactRecent),
|
||||
],
|
||||
]));
|
||||
lines.push("");
|
||||
lines.push("Full: bun scripts/cli.ts platform-infra sub2api codex-pool validate --full");
|
||||
lines.push("Raw: bun scripts/cli.ts platform-infra sub2api codex-pool validate --raw");
|
||||
return renderedCliResult(result.ok === true, "platform-infra sub2api codex-pool validate", lines.join("\n"));
|
||||
}
|
||||
|
||||
export function renderCodexPoolSentinelProbeResult(result: Record<string, unknown>): RenderedCliResult {
|
||||
const target = isRecord(result.target) ? result.target : {};
|
||||
const summary = isRecord(result.summary) ? result.summary : {};
|
||||
const job = isRecord(summary.job) ? summary.job : {};
|
||||
const runSummary = isRecord(summary.summary) ? summary.summary : {};
|
||||
const results = recordArray(summary.results);
|
||||
const state = isRecord(summary.sentinelState) ? summary.sentinelState : {};
|
||||
const lastRun = isRecord(state.lastRun) ? state.lastRun : {};
|
||||
const runtimeSchedulable = isRecord(lastRun.runtimeSchedulable) ? lastRun.runtimeSchedulable : {};
|
||||
const lines: string[] = [];
|
||||
lines.push([
|
||||
"SUB2API CODEX POOL SENTINEL PROBE",
|
||||
`ok=${result.ok === true ? "true" : "false"}`,
|
||||
`target=${target.id ?? "-"}`,
|
||||
`accounts=${Array.isArray(summary.requestedAccounts) ? summary.requestedAccounts.length : results.length}`,
|
||||
`job=${job.status ?? "-"}`,
|
||||
].join(" "));
|
||||
lines.push(renderTable([
|
||||
["JOB", "NS", "JOB_OK", "MARKER_OK", "LAST_RUN", "SELECTED", "OK", "TF", "ACTIONS"],
|
||||
[
|
||||
textValue(job.name),
|
||||
textValue(summary.namespace ?? target.namespace),
|
||||
boolText(summary.jobExecutionOk),
|
||||
boolText(summary.markerOk),
|
||||
textValue(runSummary.at ?? lastRun.at),
|
||||
textValue(runSummary.selected ?? lastRun.selected),
|
||||
textValue(runSummary.okCount ?? lastRun.okCount),
|
||||
textValue(runSummary.transportFailureCount ?? lastRun.transportFailureCount),
|
||||
textValue(runSummary.actionsTaken ?? lastRun.actionsTaken),
|
||||
],
|
||||
]));
|
||||
lines.push("");
|
||||
lines.push("RESULTS");
|
||||
lines.push(renderTable([
|
||||
["ACCOUNT", "OK", "HTTP", "MARKER", "DUR_MS", "KIND", "ACTION"],
|
||||
...results.map((item) => {
|
||||
const action = isRecord(item.action) ? item.action : {};
|
||||
return [
|
||||
textValue(item.accountName),
|
||||
boolText(item.ok),
|
||||
textValue(item.httpStatus),
|
||||
boolText(item.markerMatched),
|
||||
textValue(item.durationMs),
|
||||
textValue(item.failureKind),
|
||||
textValue(action.type),
|
||||
];
|
||||
}),
|
||||
]));
|
||||
lines.push(renderTable([
|
||||
["QUARANTINED", "SCHEDULABLE", "UNSCHEDULABLE", "MISSING"],
|
||||
[
|
||||
textValue(state.quarantinedCount),
|
||||
textValue(runtimeSchedulable.schedulableCount),
|
||||
textValue(runtimeSchedulable.unschedulableCount),
|
||||
textValue(runtimeSchedulable.missingCount),
|
||||
],
|
||||
]));
|
||||
lines.push("");
|
||||
lines.push("Full: bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account <name> --confirm --full");
|
||||
lines.push("Raw: bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account <name> --confirm --raw");
|
||||
return renderedCliResult(result.ok === true, "platform-infra sub2api codex-pool sentinel-probe", lines.join("\n"));
|
||||
}
|
||||
|
||||
function renderCodexPoolRemoteSummary(title: string, result: Record<string, unknown>, target: Record<string, unknown>, remote: Record<string, unknown>): string[] {
|
||||
return [
|
||||
[
|
||||
title,
|
||||
`ok=${result.ok === true ? "true" : "false"}`,
|
||||
`target=${target.id ?? "-"}`,
|
||||
`mode=${remote.mode ?? "-"}`,
|
||||
`degraded=${remote.degraded === true ? "true" : "false"}`,
|
||||
].join(" "),
|
||||
renderTable([
|
||||
["TARGET", "ROUTE", "NS", "SERVICE", "POD", "ADMIN", "API_KEY"],
|
||||
[
|
||||
textValue(target.id),
|
||||
textValue(target.route),
|
||||
textValue(remote.namespace ?? target.namespace),
|
||||
textValue(remote.serviceDns),
|
||||
textValue(remote.appPod),
|
||||
blockOk(remote.admin),
|
||||
blockOk(remote.apiKey),
|
||||
],
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function appendCodexPoolCheckTable(lines: string[], remote: Record<string, unknown>): void {
|
||||
const runtime = isRecord(remote.runtimeCapabilities) ? remote.runtimeCapabilities : {};
|
||||
lines.push(renderTable([
|
||||
["BALANCE", "CONCURRENCY", "CAPACITY", "LOAD", "TEMP_UNSCHED", "MANUAL", "SENTINEL", "RUNTIME"],
|
||||
[
|
||||
blockOk(remote.ownerBalance),
|
||||
blockOk(remote.ownerConcurrency),
|
||||
blockOk(remote.capacity),
|
||||
blockOk(remote.loadFactor),
|
||||
blockOk(remote.tempUnschedulable),
|
||||
blockOk(remote.manualAccounts),
|
||||
blockOk(remote.sentinel),
|
||||
blockOk(runtime),
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
function appendNext(lines: string[], value: unknown): void {
|
||||
if (!isRecord(value)) return;
|
||||
const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
|
||||
if (entries.length === 0) return;
|
||||
lines.push("");
|
||||
lines.push("NEXT");
|
||||
for (const [key, item] of entries) lines.push(` ${key}: ${item}`);
|
||||
}
|
||||
|
||||
function blockOk(value: unknown): string {
|
||||
return isRecord(value) ? boolText(value.ok) : "-";
|
||||
}
|
||||
|
||||
export function renderSentinelReport(
|
||||
parsed: Record<string, unknown> | null,
|
||||
context: { events: number; full: boolean; remote: Record<string, unknown> },
|
||||
@@ -306,6 +571,12 @@ export function textValue(value: unknown): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function boolText(value: unknown): string {
|
||||
if (value === true) return "Y";
|
||||
if (value === false) return "N";
|
||||
return "-";
|
||||
}
|
||||
|
||||
export function shorten(value: string, maxChars: number): string {
|
||||
return value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
||||
}
|
||||
|
||||
@@ -123,18 +123,29 @@ export function codexPoolRuntimeTarget(targetId?: string): CodexPoolRuntimeTarge
|
||||
export function readTargetSentinelImageBuild(raw: Record<string, unknown>, targetId: string, required = true): CodexPoolRuntimeTarget["sentinelImageBuild"] {
|
||||
const codexPool = isRecord(raw.codexPool) ? raw.codexPool : null;
|
||||
const imageBuild = codexPool !== null && isRecord(codexPool.sentinelImageBuild) ? codexPool.sentinelImageBuild : null;
|
||||
if (imageBuild === null && !required) return { baseImageCachePolicy: "pull", noProxy: [] };
|
||||
if (imageBuild === null && !required) return { baseImageCachePolicy: "pull", noProxy: [], proxyEnvPath: null };
|
||||
if (imageBuild === null) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild must be a YAML object`);
|
||||
const policy = stringValue(imageBuild.baseImageCachePolicy);
|
||||
if (policy !== "pull" && policy !== "local-if-present") {
|
||||
throw new Error(`${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild.baseImageCachePolicy must be pull or local-if-present`);
|
||||
}
|
||||
const egressProxy = isRecord(raw.egressProxy) ? raw.egressProxy : null;
|
||||
const proxyEnvPath = egressProxy === null ? null : readProxyEnvPath(egressProxy.proxyEnvPath, `${sub2apiConfigPath}.targets[${targetId}].egressProxy.proxyEnvPath`);
|
||||
return {
|
||||
baseImageCachePolicy: policy,
|
||||
noProxy: readTargetNoProxy(imageBuild.noProxy, `${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild.noProxy`),
|
||||
proxyEnvPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function readProxyEnvPath(value: unknown, key: string): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const path = stringValue(value);
|
||||
if (path === null) throw new Error(`${key} must be a non-empty string`);
|
||||
if (!/^\/[A-Za-z0-9._/-]+$/u.test(path)) throw new Error(`${key} has an unsupported absolute path format`);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function readTargetPublicExposure(raw: Record<string, unknown>, targetId: string): CodexPoolRuntimePublicExposure | null {
|
||||
if (raw.publicExposure === undefined || raw.publicExposure === null) return null;
|
||||
if (!isRecord(raw.publicExposure)) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure must be a YAML object`);
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface CodexPoolRuntimeTarget {
|
||||
sentinelImageBuild: {
|
||||
baseImageCachePolicy: "pull" | "local-if-present";
|
||||
noProxy: string;
|
||||
proxyEnvPath: string | null;
|
||||
};
|
||||
egressProxy: {
|
||||
enabled: boolean;
|
||||
@@ -227,6 +228,7 @@ export interface CodexPoolManualAccountGroupBinding {
|
||||
export interface CodexPoolManualAccountProtection {
|
||||
accountName: string;
|
||||
reason: string | null;
|
||||
targetIds: string[] | null;
|
||||
proxyBinding: CodexPoolManualAccountProxyBinding | null;
|
||||
groupBinding: CodexPoolManualAccountGroupBinding | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user