mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-12 08:06:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2ccf6b42c | ||
|
|
044bdc8663 | ||
|
|
24a9f12a78 | ||
|
|
1e14250b82 | ||
|
|
ea31bf0cf7 | ||
|
|
7a0502cabd | ||
|
|
d6eb319ac6 | ||
|
|
816677fc3d | ||
|
|
e0e51c7da6 | ||
|
|
dca573b748 | ||
|
|
d2f43f4edc | ||
|
|
00240fbb66 | ||
|
|
0f40d000d4 | ||
|
|
4759fe9e0b | ||
|
|
d7f6635346 | ||
|
|
2f545fb768 | ||
|
|
997b696953 | ||
|
|
687ebd0331 | ||
|
|
60e0d4ee92 | ||
|
|
312059ed74 | ||
|
|
0fba502b4d | ||
|
|
ef050837de | ||
|
|
be0715e96b | ||
|
|
16fa303a0c | ||
|
|
65088c0608 | ||
|
|
eab933584c | ||
|
|
f041140757 | ||
|
|
debba0e13c | ||
|
|
a0836ec3f2 | ||
|
|
dc0cdf2930 | ||
|
|
d76bb57b8c | ||
|
|
6434edd554 | ||
|
|
3a90d8d0b6 | ||
|
|
f3236d0410 | ||
|
|
ccb7357914 | ||
|
|
c0ad2ae656 | ||
|
|
798d4f518e | ||
|
|
6819682176 | ||
|
|
b2d4a7e833 | ||
|
|
01006380ee | ||
|
|
c79e032575 | ||
|
|
96cf46cca8 |
@@ -16,7 +16,8 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 开发独立网关插件 | [plugin-dev](../plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
@@ -25,6 +26,12 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
|
||||
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
|
||||
|
||||
## 前端任务
|
||||
|
||||
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
|
||||
- 开发前按该章节的参考入口和相邻页面核对布局、文案密度与操作位置,再选择共用组件
|
||||
- 交付前按该章节检查界面文案中的句号、分号,以及过大的标题、重复说明和说明块占用的空间,结合 [界面验证](../../../CONTRIBUTING.md#界面验证) 核对实际页面
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: plugin-dev
|
||||
description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编写 plugin.json、实现公开 SDK 处理器、管理页面、打包与本地验证。用于新建插件、修改插件或排查插件接入;不用于 OpenAI Codex 的 .codex-plugin 插件、Vue 插件或仅修改宿主插件管理功能。
|
||||
---
|
||||
|
||||
# Codex Proxy 插件开发
|
||||
|
||||
面向插件作者。插件在独立项目中实现,通过公开 SDK 与宿主通信,不向宿主业务模块添加分支。
|
||||
仅修改宿主安装器、Runtime 或管理端时,使用仓库开发指南,不按本技能创建插件工程。
|
||||
|
||||
## 先建立正确的模型
|
||||
|
||||
```text
|
||||
选择能力 → 作者清单 + SDK 处理器 → 构建二进制/页面 → cpr-plugin package
|
||||
↓
|
||||
实际业务验证 ← 查看权限并安装、自动准备配置 ← tar.gz + sha256
|
||||
```
|
||||
|
||||
- `contributes` 声明提供什么,`permissions` 声明访问域;安装接受记录派生授权,`bindings` 只决定功能挂载和请求匹配
|
||||
- 安装包、运行配置和已启用功能不是同一件事;安装成功不能代替功能验证
|
||||
- `trustedProcess` 与宿主使用相同系统身份,不是操作系统沙箱
|
||||
|
||||
## 开始工作
|
||||
|
||||
1. 确认插件用途、已有工程/输出目录、目标宿主版本与平台。已有项目沿用其结构;没有指定工程时先确认目录,不把插件源码塞进宿主 workspace
|
||||
2. 先读 [SDK 入口](../../../backend/crates/gateway-plugin/sdk/README.md) 和[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md),再按下表只选本次需要的能力章节
|
||||
3. 核对实际运行宿主的版本、目标版本的 [SDK Cargo.toml](../../../backend/crates/gateway-plugin/sdk/Cargo.toml) 与[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)。同时确认打包 CLI 使用相同作者清单合同;不要只看源码版本或依赖包版本号。清单版本、通信版本、能力版本各自独立
|
||||
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布
|
||||
|
||||
本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。
|
||||
|
||||
## 按任务选择能力
|
||||
|
||||
| 用户要做什么 | 优先使用 | 需要读取的合同 |
|
||||
| --- | --- | --- |
|
||||
| 改写请求/响应、处理流式内容、协议转换 | `middleware` | [洋葱中间件](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件) |
|
||||
| 改变模型选择或账号排序 | `model_router` / `scheduler` | [策略类型](../../../backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs),宿主继续复验范围、资格和租约 |
|
||||
| 统计成功失败、用量或观察 WS 帧 | `request_lifecycle` / `usage` / `web_socket_observer` | [路由、调度与观察](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#路由调度与观察),观察不改写业务结果 |
|
||||
| 接受外部客户端身份 | `frontend_authentication` | [数据面入口认证](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#数据面入口认证),映射现有 Client Key,不接管后台登录 |
|
||||
| 增加插件页面与管理操作 | `management` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) |
|
||||
| 提供终端命令 | `command_line` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) |
|
||||
|
||||
只声明实现了的能力。Provider 固定为宿主内置的 OpenAI 与 xAI;插件只能扩展已开放的处理与管理能力。
|
||||
网络、账号、模型调用和私有状态属于宿主回调与授权,不重复声明为顶层能力。
|
||||
|
||||
## 实现时的关键选择
|
||||
|
||||
### 清单与处理器
|
||||
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
|
||||
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
|
||||
- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin`
|
||||
- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求
|
||||
|
||||
### 中间件与宿主服务
|
||||
|
||||
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
|
||||
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
|
||||
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
|
||||
- 权限只声明 `network`、`models`、`accounts`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务
|
||||
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
|
||||
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
|
||||
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
|
||||
|
||||
### 只有需要页面时才创建前端
|
||||
|
||||
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
|
||||
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
|
||||
- 隔离页面通过 `window.codexProxyPlugin.request` 调用已注册管理路由;`models.responses` 通过普通请求链交付 JSON/SSE,支持取消且不暴露 Key 明文。不直接 `fetch` 宿主、不读取管理 Cookie 或借用宿主 Vue 实例
|
||||
- 管理路由的响应正文由插件业务定义,不默认套用宿主管理 API 的 `{ code, message, data }`;页面与处理器共享实际合同,GET 无正文时不附带 JSON 正文声明
|
||||
- `host.model.*` 子请求跳过发起插件,不能用它证明本插件中间件/路由已参与;需要完整请求链的示例使用页面模型桥或真实客户端
|
||||
- JS、CSS、图标等资源随包构建和声明;沿用宿主桥的主题同步。Vite 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本
|
||||
|
||||
## 交付
|
||||
|
||||
按[构建与验证](references/development.md#验证与交付)提供源码位置、能力/权限/绑定摘要、实际运行结果及验证缺口。
|
||||
开发插件不默认授权安装并启用本机进程、调用真实上游、发布依赖或推送仓库;超出当前授权时交付可安装包及操作步骤。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Codex Proxy 插件开发"
|
||||
short_description: "从能力选择到 SDK 实现、管理页面、打包与验证的插件开发指南"
|
||||
default_prompt: "使用 $plugin-dev 为 Codex Proxy RS 开发插件,先确认需要的能力,再按公开 SDK 实现、打包并验证。"
|
||||
@@ -0,0 +1,104 @@
|
||||
# 构建、联调与验证
|
||||
|
||||
仅在准备工程、构建或验证插件时读取。这里给出执行入口,不复制 SDK 协议或安装 API 的字段手册。
|
||||
|
||||
## 工程与依赖
|
||||
|
||||
| 位置 | 用途 |
|
||||
| --- | --- |
|
||||
| 主仓 `backend/crates/gateway-plugin/sdk/` | Rust 公开合同、会话辅助与能力文档 |
|
||||
| 主仓 `backend/apps/plugin-cli/` | `cpr-plugin package`,只校验与打包,不编译插件源码 |
|
||||
| 独立 `codex-proxy-plugins/examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
|
||||
| 独立 `codex-proxy-ui/` | 可选的 Vue UI 库、组件文档与 playground |
|
||||
|
||||
先检查用户给定工程,或主仓父目录下的同级仓库是否存在;读取实际仓库约定、README、`Cargo.toml`、`package.json` 与锁文件。
|
||||
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
|
||||
|
||||
本地源码联调可以使用 Cargo 路径依赖和 pnpm 的包级链接/override;路径必须真实可解析,并记录在消费方依赖配置中。
|
||||
这不等于允许从业务源码跨仓 `import .../src/...`,也不能证明第三方能独立安装。
|
||||
|
||||
对外交付前核实 UI/SDK 的可用版本和安装来源:仅引用确实存在的发布版本,或包含所需合同的固定 Git commit;
|
||||
不要把浮动 `main` 当版本,也不要自动发布依赖以补齐条件。本地 override 尚未移除时,明确说明独立安装缺口。
|
||||
所用工具链以目标项目的配置为准,不因本机安装了更新版本而顺手升级。
|
||||
|
||||
## 复用示例
|
||||
|
||||
找到 `codex-proxy-plugins` 后先读根目录与 `examples/workbench/README.md`。
|
||||
该仓库使用 `bash scripts/package` 构建页面、Rust 二进制并打包;前端依赖和工具配置位于示例的 `frontend/`,根目录不维护 Node 工程。执行前核对脚本适用的插件路径和目标平台。
|
||||
|
||||
| 任务 | 示例入口 |
|
||||
| --- | --- |
|
||||
| 身份、声明与资源 | `examples/workbench/plugin.json` |
|
||||
| 会话启动与处理器组合 | `examples/workbench/backend/src/main.rs`、`app.rs` |
|
||||
| 管理接口 | `examples/workbench/backend/src/management/` |
|
||||
| 页面与主题 | `examples/workbench/frontend/src/` |
|
||||
| 管理接口调用 | `examples/workbench/frontend/src/api/modules/` |
|
||||
| 包内静态资源构建 | `examples/workbench/frontend/vite.config.ts` |
|
||||
|
||||
若派生新插件,同时修改包名、二进制名、插件身份、扩展项 ID、注册描述和构建目标,不只改展示名称。
|
||||
保留当前项目确实需要的依赖、资源和权限;不要把示例中的全请求范围直接用到共享环境。
|
||||
|
||||
## 构建与打包
|
||||
|
||||
先运行目标工程已有的测试和构建入口。Rust 检查示意如下,路径、工具链和 target 按实际工程替换:
|
||||
|
||||
```bash
|
||||
cargo fmt --manifest-path /path/to/plugin/Cargo.toml -- --check
|
||||
cargo clippy --manifest-path /path/to/plugin/Cargo.toml --all-targets --all-features --locked -- -D warnings
|
||||
cargo test --manifest-path /path/to/plugin/Cargo.toml --locked
|
||||
cargo build --manifest-path /path/to/plugin/Cargo.toml --release --locked --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
新工程尚无锁文件时先生成并检查锁文件;不要把缺少锁文件的 `--locked` 失败当作源码问题。
|
||||
有页面时另跑该前端项目的检查与生产构建,确认产物路径、资源清单和 MIME 类型相符;无页面的插件无需 Node 项目。
|
||||
|
||||
先检查实际调用的 `cpr-plugin` 路径与构建来源。已有工具只有在支持目标作者清单时才复用;
|
||||
遇到派生字段被要求手写或支持能力不符时,对照目标 SDK 和 CLI 合同,不通过改写清单迁就过期工具。
|
||||
需要从目标宿主源码构建工具时,在主仓根目录运行以下命令,把 `--help` 换成实际参数即可,不必全局安装:
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
|
||||
```
|
||||
|
||||
直接打包的示意命令:
|
||||
|
||||
```bash
|
||||
cpr-plugin package \
|
||||
--manifest /path/to/plugin/plugin.json \
|
||||
--binary /path/to/plugin/target/x86_64-unknown-linux-gnu/release/plugin \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--resource-map web=frontend/dist \
|
||||
--output-dir /path/to/plugin/dist
|
||||
```
|
||||
|
||||
没有 `web` 资源时去掉 `--resource-map`。映射源相对于作者清单目录,而非命令执行目录;资源必须留在插件工程内。
|
||||
目标平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。
|
||||
|
||||
输出为 `.tar.gz` 与 `.sha256`,不是源码 zip、npm 包或页面目录。归档根目录应有生成后的 `plugin.json`、可执行文件和声明资源。
|
||||
安装时以本次输出的包摘要为准,核对包内版本、平台、能力和 `engines` 与实际运行宿主匹配;
|
||||
源码已更新不代表 `dist` 中旧包已重建。版本要求未满足时使用兼容宿主或确有依据的兼容声明,不跳过宿主检查。
|
||||
本地同版本反复试验与对外发布区分处理;发布新内容使用明确的新版本,不覆盖已发布版本。
|
||||
|
||||
## 验证与交付
|
||||
|
||||
按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
|
||||
只有获得目标环境操作授权才安装和启用;不把修改源码的授权扩展到生产实例或真实凭据操作。
|
||||
需要 API 自动化时查[插件管理 API](../../../../docs/api.md#12-插件管理),不要依据记忆拼路由。
|
||||
|
||||
按实际实现选择验证,不为不存在的能力增加测试任务:
|
||||
|
||||
| 实现范围 | 应验证的行为 |
|
||||
| --- | --- |
|
||||
| 中间件 | 匹配/不匹配范围、授权不足、下游错误与取消;支持流式时检查多帧、终态和断开;若涉及续链,使用真实目标客户端检查 HTTP/SSE、WS 及后续轮次 |
|
||||
| 模型路由/调度 | 真实入口能命中规则,未匹配请求保持原有行为;使用官方客户端正常携带的业务头和会话别名,确认投影不会拒绝合法请求,也不暴露身份字段 |
|
||||
| 生命周期/用量/WS 观察 | 完成一次实际模型请求并核对对应事件;WS 观察需确认实际上游采用 WS,HTTP 请求成功不能证明 WS 观察生效 |
|
||||
| 管理页面 | 从宿主打开,标题/副标题、明暗主题、加载与错误状态;管理桥请求成功;停用或版本切换后旧入口失效 |
|
||||
| 入口认证/CLI | 对应身份和账号范围、缺少授权、取消与错误;不把帮助输出、注册成功或一次健康探测当完整验收 |
|
||||
| 持久状态或升级 | 读写版本冲突、停用再启用后的持久状态、升级兼容性及迁移失败;版本配置恢复与私有状态迁移分别验证,不覆盖并发配置或把回滚版本误当恢复历史数据 |
|
||||
|
||||
真实请求按用户选择的客户端、模型和测试预算执行;没有这些能力或授权时列为缺口,不模拟成功。
|
||||
外部示例测试若依赖未提供的仓库或制品,应记录为未执行;当前示例可用于实际能力联调,但不能把被忽略的测试计为通过。
|
||||
临时账号、Key 或配置只在明确的测试范围内创建,记录并清理自己创建的对象,不输出密钥、原始请求或插件 secret。
|
||||
|
||||
交付简述:源码与产物位置、兼容范围、所需能力/权限/绑定、执行过的命令及结果、尚未验证的场景。
|
||||
只有确实生成了产物才报告“已打包”,只有目标环境业务验证成功才报告“已生效”;提交、推送与发布按用户授权另行执行。
|
||||
@@ -1,8 +1,10 @@
|
||||
version: 2
|
||||
|
||||
# 标签由维护者手动添加,各生态关闭默认打标,避免重新创建旧标签。
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
labels: []
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -16,6 +18,7 @@ updates:
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /backend
|
||||
labels: []
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -49,7 +52,8 @@ updates:
|
||||
- patch
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /frontend
|
||||
directory: /
|
||||
labels: []
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -65,6 +69,7 @@ updates:
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /deploy
|
||||
labels: []
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Cache Cargo mounts
|
||||
id: cargo-cache
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }}
|
||||
|
||||
- name: Build runtime image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: deploy/Dockerfile
|
||||
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
||||
@@ -318,6 +318,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ruby <<'RUBY' > release-platforms.json
|
||||
@@ -364,6 +365,8 @@ jobs:
|
||||
compose = compose.replace(default_image, versioned_image)
|
||||
(release_dir / "compose.yaml").write_text(compose)
|
||||
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
|
||||
shutil.rmtree(package_dir, ignore_errors=True)
|
||||
release_packages = []
|
||||
|
||||
for platform in platforms:
|
||||
platform_name = f"{platform['os']}-{platform['arch']}"
|
||||
@@ -372,8 +375,7 @@ jobs:
|
||||
if not binary_src.is_file():
|
||||
raise SystemExit(f"Missing binary artifact: {binary_src}")
|
||||
|
||||
shutil.rmtree(package_dir, ignore_errors=True)
|
||||
package_root = package_dir / APP_NAME
|
||||
package_root = package_dir / platform_name / APP_NAME
|
||||
web_dist = package_root / "web" / "dist"
|
||||
web_dist.mkdir(parents=True)
|
||||
|
||||
@@ -387,11 +389,37 @@ jobs:
|
||||
|
||||
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
|
||||
archive_path = release_dir / archive_name
|
||||
if platform["archive"] == "tar.gz":
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
archive.add(package_root, arcname=APP_NAME)
|
||||
else:
|
||||
if platform["archive"] != "tar.gz":
|
||||
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
|
||||
release_packages.append((platform, package_root, archive_path))
|
||||
|
||||
plugin_host_path = pathlib.Path("backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json")
|
||||
plugin_host = json.loads(plugin_host_path.read_text())
|
||||
if plugin_host.get("schema_version") != 1:
|
||||
raise SystemExit(f"Invalid plugin host compatibility declaration: {plugin_host_path}")
|
||||
|
||||
plugin_manifest_path = release_dir / "plugin-release-manifest.json"
|
||||
plugin_manifest_path.write_text(json.dumps({
|
||||
"schema_version": 1,
|
||||
"sealed": True,
|
||||
"gateway_version": version,
|
||||
"gateway_git_sha": os.environ["GIT_SHA"],
|
||||
"plugin_host": plugin_host,
|
||||
# 示例独立发版;当前不内置插件,仍保留更新与回滚所需的封口清单。
|
||||
"plugins": [],
|
||||
}, indent=2, sort_keys=True) + "\n")
|
||||
assets.append(plugin_manifest_path)
|
||||
|
||||
# 保留运行时清单目录合同,宿主包不再携带教学示例。
|
||||
for platform, package_root, archive_path in release_packages:
|
||||
official_dir = package_root / "plugins" / "official"
|
||||
official_dir.mkdir(parents=True)
|
||||
bundled_manifest = official_dir / plugin_manifest_path.name
|
||||
shutil.copy2(plugin_manifest_path, bundled_manifest)
|
||||
bundled_manifest.chmod(0o444)
|
||||
official_dir.chmod(0o555)
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
archive.add(package_root, arcname=APP_NAME)
|
||||
assets.append(archive_path)
|
||||
|
||||
checksum_path = release_dir / "checksums.txt"
|
||||
@@ -415,7 +443,7 @@ jobs:
|
||||
|
||||
build-docker-image:
|
||||
name: build-docker-image (${{ matrix.dockerPlatform }})
|
||||
needs: [release-info, quality, build-docker-binary]
|
||||
needs: [release-info, quality, build-docker-binary, package-assets]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }}
|
||||
@@ -448,6 +476,12 @@ jobs:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
|
||||
- name: Download sealed release assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: dist/release-assets
|
||||
|
||||
- name: Prepare Docker prebuilt assets
|
||||
shell: bash
|
||||
env:
|
||||
@@ -457,6 +491,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import os
|
||||
import pathlib
|
||||
@@ -472,10 +507,40 @@ jobs:
|
||||
binary_dest = target_dir / app_name
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
binary_dest.chmod(0o755)
|
||||
|
||||
release_dir = pathlib.Path("dist/release-assets")
|
||||
manifest_path = release_dir / "plugin-release-manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
if manifest.get("schema_version") != 1 or manifest.get("sealed") is not True:
|
||||
raise SystemExit("Invalid sealed plugin release manifest")
|
||||
official_dir = target_dir / "plugins" / "official"
|
||||
official_dir.mkdir(parents=True)
|
||||
bundled_manifest = official_dir / manifest_path.name
|
||||
shutil.copy2(manifest_path, bundled_manifest)
|
||||
bundled_manifest.chmod(0o444)
|
||||
selected = 0
|
||||
for plugin in manifest.get("plugins", []):
|
||||
artifacts = [
|
||||
artifact for artifact in plugin.get("artifacts", [])
|
||||
if artifact.get("release_os") == os.environ["MATRIX_OS"]
|
||||
and artifact.get("release_architecture") == os.environ["MATRIX_ARCH"]
|
||||
]
|
||||
if len(artifacts) != 1:
|
||||
raise SystemExit(f"Official plugin platform selection is not unique: {plugin.get('id')}")
|
||||
archive = release_dir / artifacts[0]["archive"]
|
||||
if not archive.is_file():
|
||||
raise SystemExit(f"Missing official plugin archive: {archive.name}")
|
||||
bundled_archive = official_dir / archive.name
|
||||
shutil.copy2(archive, bundled_archive)
|
||||
bundled_archive.chmod(0o444)
|
||||
selected += 1
|
||||
if selected != len(manifest.get("plugins", [])):
|
||||
raise SystemExit("Official plugin release bundle is incomplete")
|
||||
official_dir.chmod(0o555)
|
||||
PY
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
@@ -486,7 +551,7 @@ jobs:
|
||||
|
||||
- name: Build and push app image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: deploy/Dockerfile
|
||||
@@ -565,7 +630,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
@@ -684,6 +749,9 @@ jobs:
|
||||
**手动下载:**
|
||||
请从下方附件中下载适合当前平台的归档包。
|
||||
|
||||
附件包含与当前宿主版本及提交绑定的 `plugin-release-manifest.json`,当前没有默认内置插件。
|
||||
教学示例由独立的 `codex-proxy-plugins` 仓库维护和发布,不随宿主发行物分发。
|
||||
|
||||
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
|
||||
|
||||
## 📚 文档
|
||||
|
||||
@@ -38,9 +38,9 @@ jobs:
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --version 0.22.2 --locked
|
||||
|
||||
- name: Run cargo audit
|
||||
working-directory: backend
|
||||
run: cargo audit
|
||||
- name: Run Cargo audits
|
||||
run: |
|
||||
cargo audit --file backend/Cargo.lock
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
||||
+4
-1
@@ -7,8 +7,8 @@
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/.vite/
|
||||
/.pnpm-store/
|
||||
/frontend/.pnpm-store/
|
||||
/frontend/.pnpm-cache/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Release artifacts
|
||||
@@ -20,6 +20,9 @@
|
||||
|
||||
# Local configuration and secrets
|
||||
/deploy/config.yaml
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editors and operating systems
|
||||
/.idea/
|
||||
|
||||
+12
-1
@@ -45,6 +45,7 @@ AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下
|
||||
| 技能 | 用途 | 调用示例 |
|
||||
| --- | --- | --- |
|
||||
| [dev-guide](.agents/skills/dev-guide/SKILL.md) | 项目开发与文档导航 | `$dev-guide 修复当前问题` |
|
||||
| [plugin-dev](.agents/skills/plugin-dev/SKILL.md) | 网关插件开发、打包与验证 | `$plugin-dev 开发一个添加响应头的插件` |
|
||||
| [github-pr](.agents/skills/github-pr/SKILL.md) | 创建或审查 PR | `$github-pr 创建当前分支的 PR` / `$github-pr 审查 PR #编号` |
|
||||
| [github-issue](.agents/skills/github-issue/SKILL.md) | 整理或提交 Issue | `$github-issue 提交当前问题` |
|
||||
| [release](.agents/skills/release/SKILL.md) | 准备发布说明或正式发版 | `$release 整理本次发布说明` / `$release 发布 vX.Y.Z` |
|
||||
@@ -111,7 +112,7 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
|
||||
|
||||
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
开发前查找同类页面及同级 `codex-proxy-ui/src/components` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
|
||||
@@ -131,6 +132,16 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
|
||||
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
|
||||
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
|
||||
|
||||
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。开发服务通过 UI 包的 `development` 入口使用 Vue SFC,生产构建使用 `dist`。
|
||||
|
||||
pnpm 版本由 `frontend/package.json` 的 `packageManager` 固定,CI 和 Docker 从该字段读取。`frontend/pnpm-workspace.yaml` 的 `allowBuilds` 按仓库放行自有 UI 包的构建脚本;升级标签时核对源码并更新锁文件,无需逐次修改构建许可,不开放全部依赖脚本。
|
||||
|
||||
```bash
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
```
|
||||
|
||||
需要修改组件库时,按 [UI 源码联调指南](https://github.com/zyycn/codex-proxy-ui/blob/main/playground/guide/development.md) 建立临时本地链接;提交前恢复发布依赖并核对锁文件,不能将开发机目录写入发行依赖。独立插件的 SDK 固定到包含所需合同的 Git 提交,不依赖浮动分支。
|
||||
|
||||
前端从仓库根目录执行:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
</div>
|
||||
|
||||
> [!NOTE]
|
||||
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
|
||||
> 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议
|
||||
|
||||
## 快速预览
|
||||
|
||||
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计。
|
||||
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
|
||||
|
||||
| 登录信息 | 值 |
|
||||
| 登录信息 | 默认值 |
|
||||
| --- | --- |
|
||||
| 地址 | <https://codex-proxy-rs.ainz.cc> |
|
||||
| 登录身份 | 管理员 |
|
||||
@@ -31,17 +31,17 @@
|
||||
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
|
||||
|
||||
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
|
||||
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息。
|
||||
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息
|
||||
|
||||
## 快速开始
|
||||
|
||||
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
|
||||
|
||||
### 一键安装
|
||||
|
||||
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限。
|
||||
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/install.sh -o install.sh && bash install.sh
|
||||
@@ -49,7 +49,7 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
|
||||
|
||||
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
|
||||
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
|
||||
[添加账号与客户端密钥](#添加账号与客户端密钥)。
|
||||
[添加账号与客户端密钥](#添加账号与客户端密钥)
|
||||
|
||||
可在执行 `bash install.sh` 时传入环境变量:
|
||||
|
||||
@@ -66,40 +66,40 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
|
||||
```
|
||||
|
||||
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
|
||||
忽略传入的管理员密码,也不执行版本升级。
|
||||
忽略传入的管理员密码,也不执行版本升级
|
||||
|
||||
### 手动安装
|
||||
|
||||
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)。
|
||||
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)
|
||||
|
||||
### 登录管理端
|
||||
|
||||
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
|
||||
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
|
||||
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch。
|
||||
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch,导入时同时启用当前 Key 的日/周额度查询,默认刷新间隔为 30 分钟
|
||||
|
||||
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)。
|
||||
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)
|
||||
|
||||
### 添加账号与客户端密钥
|
||||
|
||||
1. 在「账号」中添加账号,完成授权或导入。
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置。
|
||||
1. 在「账号」中添加账号,完成授权或导入
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组,**不选分组表示可使用全部账号**
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置
|
||||
|
||||
## 客户端接入
|
||||
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
|
||||
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)。
|
||||
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
|
||||
|
||||
**其他 Responses API 客户端**:填写以下信息。
|
||||
**其他 Responses API 客户端**:填写以下信息:
|
||||
|
||||
| 配置 | 值 |
|
||||
| --- | --- |
|
||||
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
|
||||
| API Key | 管理端创建的客户端密钥 |
|
||||
|
||||
可用模型以该密钥查询到的模型列表为准:
|
||||
查询该密钥可见的模型目录:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/v1/models \
|
||||
@@ -108,14 +108,13 @@ curl http://127.0.0.1:8080/v1/models \
|
||||
|
||||
## 文档
|
||||
|
||||
- [客户端接入与生图](deploy/README.md#客户端配置)
|
||||
- [部署、备份与恢复](deploy/README.md)
|
||||
- [API 参考](docs/api.md)
|
||||
- [模型定价与手动同步](docs/api.md#模型定价)
|
||||
- [系统架构](docs/architecture.md)
|
||||
- [管理端主题](docs/theme.md)
|
||||
- [数据库迁移](backend/migrations/README.md)
|
||||
- [贡献与审查](CONTRIBUTING.md)
|
||||
| 任务 | 文档 |
|
||||
| --- | --- |
|
||||
| 部署与使用 | [部署、备份与恢复](deploy/README.md) · [客户端接入与生图](deploy/README.md#客户端配置) |
|
||||
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
|
||||
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
|
||||
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) |
|
||||
| 开发宿主 | [贡献与验证](CONTRIBUTING.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
|
||||
|
||||
## 社区
|
||||
|
||||
@@ -123,11 +122,10 @@ curl http://127.0.0.1:8080/v1/models \
|
||||
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
|
||||
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
|
||||
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
|
||||
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)。
|
||||
明确的 Bug 或功能需求请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),无需先发讨论。
|
||||
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)
|
||||
|
||||
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
|
||||
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [Apache License 2.0](LICENSE) 开源。
|
||||
本项目基于 [Apache License 2.0](LICENSE) 开源
|
||||
|
||||
Generated
+687
-71
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,13 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/gateway",
|
||||
"apps/plugin-cli",
|
||||
"crates/gateway-admin",
|
||||
"crates/gateway-api",
|
||||
"crates/gateway-core",
|
||||
"crates/gateway-host",
|
||||
"crates/gateway-plugin/runtime",
|
||||
"crates/gateway-plugin/sdk",
|
||||
"crates/gateway-protocol",
|
||||
"crates/gateway-store",
|
||||
"crates/providers/openai",
|
||||
|
||||
@@ -16,6 +16,7 @@ gateway-admin = { path = "../../crates/gateway-admin" }
|
||||
gateway-api = { path = "../../crates/gateway-api" }
|
||||
gateway-core = { path = "../../crates/gateway-core" }
|
||||
gateway-host = { path = "../../crates/gateway-host" }
|
||||
gateway-plugin-runtime = { path = "../../crates/gateway-plugin/runtime" }
|
||||
gateway-store = { path = "../../crates/gateway-store" }
|
||||
provider-openai = { path = "../../crates/providers/openai" }
|
||||
provider-xai = { path = "../../crates/providers/xai" }
|
||||
|
||||
@@ -52,6 +52,47 @@ impl LoadableConfig for GatewayConfig {
|
||||
|
||||
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。
|
||||
pub async fn run() -> Result<(), BootstrapError> {
|
||||
launch(None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret。
|
||||
pub struct PluginCommand {
|
||||
pub instance_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub arguments: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginCommand {
|
||||
fn is_help(&self) -> bool {
|
||||
self.name.is_none()
|
||||
|| self
|
||||
.arguments
|
||||
.iter()
|
||||
.any(|value| matches!(value.as_str(), "--help" | "-h"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn plugin_command(
|
||||
command: PluginCommand,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
launch(Some(command)).await
|
||||
}
|
||||
|
||||
async fn launch(
|
||||
command: Option<PluginCommand>,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
macro_rules! or_shutdown {
|
||||
($runtime:expr, $result:expr) => {
|
||||
match $result {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
$runtime.shutdown().await;
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let config = gateway_host::load_config::<GatewayConfig>()?;
|
||||
let GatewayConfig {
|
||||
schema_version: _,
|
||||
@@ -63,47 +104,217 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
openai,
|
||||
} = config;
|
||||
|
||||
let host = gateway_host::initialize(host).await?;
|
||||
let plugin_cache = host.runtime_data_dir().join("plugins");
|
||||
let host = if command.is_some() {
|
||||
gateway_host::initialize_command_line(host).await?
|
||||
} else {
|
||||
gateway_host::initialize(host).await?
|
||||
};
|
||||
host.report_startup_ready("Host");
|
||||
let mut store = gateway_store::initialize(store).await?;
|
||||
let help = command.as_ref().is_some_and(PluginCommand::is_help);
|
||||
let mut store = if help {
|
||||
gateway_store::initialize_read_only(store).await?
|
||||
} else if command.is_some() {
|
||||
gateway_store::initialize_command_line(store).await?
|
||||
} else {
|
||||
gateway_store::initialize(store).await?
|
||||
};
|
||||
host.report_startup_ready("Store");
|
||||
let version = host
|
||||
.system_operations()
|
||||
.version()
|
||||
.await
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?
|
||||
.version;
|
||||
let plugin_http = std::sync::Arc::new(gateway_host::outbound::HttpClient::new()?);
|
||||
if let Some(command) = command.as_ref().filter(|_| help) {
|
||||
let runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
|
||||
let commands = or_shutdown!(runtime, runtime.prepare_command_line().await);
|
||||
let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref());
|
||||
commands.shutdown().await;
|
||||
runtime.shutdown().await;
|
||||
return Ok(gateway_plugin_runtime::PluginCommandOutput {
|
||||
stdout: stdout?,
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
saved_accounts: 0,
|
||||
});
|
||||
}
|
||||
let provider_ports = store.provider_ports();
|
||||
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
|
||||
host.report_startup_ready("OpenAI Provider");
|
||||
let mut xai = provider_xai::initialize(provider_ports).await?;
|
||||
host.report_startup_ready("xAI Provider");
|
||||
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
|
||||
let mut core = gateway_core::initialize(store.core_ports(), providers).await?;
|
||||
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
|
||||
let admin_providers = gateway_admin::ports::provider::ProviderAdminRegistry::new([
|
||||
openai.admin_provider(),
|
||||
xai.admin_provider(),
|
||||
])
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
|
||||
if let Some(command) = command {
|
||||
let instance_id = or_shutdown!(
|
||||
plugin_runtime,
|
||||
command
|
||||
.instance_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失"))
|
||||
);
|
||||
let name = or_shutdown!(
|
||||
plugin_runtime,
|
||||
command
|
||||
.name
|
||||
.as_deref()
|
||||
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失"))
|
||||
);
|
||||
let core = gateway_core::prepare_command_plane(
|
||||
store.core_ports(),
|
||||
providers.clone(),
|
||||
Some(plugin_runtime.clone()),
|
||||
Some(plugin_runtime.observer_registry()),
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.middleware_registry()),
|
||||
);
|
||||
let accounts = gateway_admin::initialize_plugin_accounts(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().accounts(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_account_ports(&accounts));
|
||||
let keys = gateway_admin::initialize_plugin_client_keys(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().client_keys(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys));
|
||||
let core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
|
||||
or_shutdown!(plugin_runtime, store.start_command_line_writes());
|
||||
let result = commands
|
||||
.execute_cancellable(instance_id, name, &command.arguments, &host.cancellation())
|
||||
.await;
|
||||
commands.shutdown().await;
|
||||
plugin_runtime.shutdown().await;
|
||||
store.shutdown_command_line_writes().await?;
|
||||
return Ok(result?);
|
||||
}
|
||||
let core = gateway_core::prepare(
|
||||
store.core_ports(),
|
||||
providers,
|
||||
Some(plugin_runtime.clone()),
|
||||
Some(plugin_runtime.observer_registry()),
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.middleware_registry()),
|
||||
Some(plugin_runtime.frontend_authentication_registry()),
|
||||
);
|
||||
let plugin_accounts = gateway_admin::initialize_plugin_accounts(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().accounts(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_account_ports(&plugin_accounts)
|
||||
);
|
||||
let plugin_keys = gateway_admin::initialize_plugin_client_keys(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().client_keys(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_client_key_ports(&plugin_keys)
|
||||
);
|
||||
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
host.report_startup_ready("Core");
|
||||
let mut admin = gateway_admin::initialize(
|
||||
admin,
|
||||
client,
|
||||
store.admin_ports(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
providers: vec![openai.admin_provider(), xai.admin_provider()],
|
||||
snapshot: core.snapshot_control(),
|
||||
account_probe: core.account_probe(),
|
||||
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
client_distribution: host.client_distribution_resolver(),
|
||||
system: host.system_operations(),
|
||||
client_key_verifier: core.client_key_verifier(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let host_version = or_shutdown!(
|
||||
plugin_runtime,
|
||||
version
|
||||
.trim_start_matches('v')
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))
|
||||
);
|
||||
let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new(
|
||||
gateway_plugin_runtime::PackageLimits::default(),
|
||||
host_version,
|
||||
));
|
||||
let plugin_distribution = std::sync::Arc::new(or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http)
|
||||
));
|
||||
let mut admin = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_admin::initialize_with_plugin_accounts(
|
||||
admin,
|
||||
client,
|
||||
store.admin_ports(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
providers: admin_providers,
|
||||
plugin_preparation: plugin_runtime.clone(),
|
||||
plugin_management: plugin_runtime.clone(),
|
||||
published_snapshot: core.snapshots(),
|
||||
plugin_inspector,
|
||||
plugin_distribution,
|
||||
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
snapshot: core.snapshot_control(),
|
||||
account_probe: core.account_probe(),
|
||||
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
client_distribution: host.client_distribution_resolver(),
|
||||
system: host.system_operations(),
|
||||
client_key_verifier: core.client_key_verifier(),
|
||||
},
|
||||
plugin_accounts,
|
||||
)
|
||||
.await
|
||||
);
|
||||
let official_files = host.official_plugin_release_files();
|
||||
let official_identity = host.official_plugin_release_identity();
|
||||
let official_import = or_shutdown!(
|
||||
plugin_runtime,
|
||||
admin
|
||||
.services()
|
||||
.plugins()
|
||||
.import_official_release(
|
||||
official_files.as_ref(),
|
||||
&official_identity,
|
||||
&gateway_admin::model::MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::System,
|
||||
request_id: "startup-official-plugin-import".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
);
|
||||
if official_import.artifacts > 0 {
|
||||
host.report_startup_ready("Official plugins");
|
||||
}
|
||||
host.report_startup_ready("Admin");
|
||||
|
||||
let mut probes = store.health_probes();
|
||||
probes.extend(core.health_probes());
|
||||
probes.push(host.logging_health_probe());
|
||||
let api = gateway_api::initialize(
|
||||
api,
|
||||
core.execution_service(),
|
||||
admin.services(),
|
||||
probes,
|
||||
host.worker_health(),
|
||||
host.connection_lifecycle(),
|
||||
)?;
|
||||
let api = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_api::initialize(
|
||||
api,
|
||||
core.execution_service(),
|
||||
admin.services(),
|
||||
probes,
|
||||
host.worker_health(),
|
||||
host.connection_lifecycle(),
|
||||
)
|
||||
);
|
||||
host.report_startup_ready("API");
|
||||
|
||||
let mut plan = store.take_worker_contributions();
|
||||
@@ -111,22 +322,67 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
plan.extend(openai.take_worker_contributions());
|
||||
plan.extend(xai.take_worker_contributions());
|
||||
plan.extend(admin.take_worker_contributions());
|
||||
host.start_workers(plan, store.worker_leader_lease())?;
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
host.start_workers(plan, store.worker_leader_lease())
|
||||
);
|
||||
host.report_startup_ready("Workers");
|
||||
host.serve(api.router()).await?;
|
||||
Ok(())
|
||||
let served = host.serve(api.router()).await;
|
||||
plugin_runtime.shutdown().await;
|
||||
served?;
|
||||
Ok(gateway_plugin_runtime::PluginCommandOutput {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
saved_accounts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_plugins(
|
||||
store: &gateway_store::StoreBundle,
|
||||
plugin_cache: std::path::PathBuf,
|
||||
version: &str,
|
||||
http: std::sync::Arc<gateway_host::outbound::HttpClient>,
|
||||
) -> Result<std::sync::Arc<gateway_plugin_runtime::PluginRuntime>, BootstrapError> {
|
||||
Ok(std::sync::Arc::new(
|
||||
gateway_plugin_runtime::PluginRuntime::new(
|
||||
store.admin_ports().plugins(),
|
||||
store.admin_ports().plugin_state(),
|
||||
gateway_plugin_runtime::PluginRuntimeConfig {
|
||||
cache_directory: plugin_cache,
|
||||
host_version: version
|
||||
.trim_start_matches('v')
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?,
|
||||
package_limits: gateway_plugin_runtime::PackageLimits::default(),
|
||||
rpc_limits: gateway_plugin_runtime::RpcLimits::default(),
|
||||
restart_circuit: Default::default(),
|
||||
},
|
||||
http,
|
||||
std::sync::Arc::new(gateway_host::process::ProcessSupervisor::default()),
|
||||
)
|
||||
.with_oauth_pending(store.provider_ports().oauth_pending()),
|
||||
))
|
||||
}
|
||||
|
||||
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BootstrapError {
|
||||
#[error(transparent)]
|
||||
Worker(#[from] gateway_core::task::WorkerDefinitionError),
|
||||
#[error(transparent)]
|
||||
PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError),
|
||||
#[error(transparent)]
|
||||
Config(#[from] gateway_host::ConfigError),
|
||||
#[error(transparent)]
|
||||
Host(#[from] gateway_host::HostError),
|
||||
#[error(transparent)]
|
||||
Outbound(#[from] gateway_host::outbound::HttpError),
|
||||
#[error(transparent)]
|
||||
Store(#[from] gateway_store::StoreError),
|
||||
#[error(transparent)]
|
||||
CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError),
|
||||
#[error(transparent)]
|
||||
OpenAi(#[from] provider_openai::OpenAiInitializeError),
|
||||
#[error(transparent)]
|
||||
Xai(#[from] provider_xai::XaiInitializeError),
|
||||
|
||||
@@ -1,19 +1,84 @@
|
||||
use std::{env, error::Error, io};
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
io::{self, Write},
|
||||
process::ExitCode,
|
||||
};
|
||||
|
||||
use provider_openai::ensure_rustls_provider;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => code,
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<ExitCode, Box<dyn Error + Send + Sync>> {
|
||||
ensure_rustls_provider();
|
||||
let mut arguments = env::args_os().skip(1);
|
||||
match arguments.next().as_deref().and_then(|value| value.to_str()) {
|
||||
let command = arguments
|
||||
.next()
|
||||
.map(|value| {
|
||||
value
|
||||
.into_string()
|
||||
.map_err(|_| invalid_cli("command must be UTF-8"))
|
||||
})
|
||||
.transpose()?;
|
||||
match command.as_deref() {
|
||||
None | Some("serve") => {
|
||||
reject_extra_arguments(arguments)?;
|
||||
runtime()?.block_on(codex_proxy_rs::bootstrap::run())?;
|
||||
Ok(())
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
Some(command) => {
|
||||
Err(invalid_cli(&format!("unknown command {command:?}; expected serve")).into())
|
||||
Some("help" | "--help" | "-h") => {
|
||||
reject_extra_arguments(arguments)?;
|
||||
println!(
|
||||
"Usage: codex-proxy-rs [serve]\n codex-proxy-rs plugin <instance-id> <command> [options]\n\n --help Show help without loading configuration\n --version Show version\n plugin --help List enabled plugin commands without starting HTTP"
|
||||
);
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
Some("--version" | "-V") => {
|
||||
reject_extra_arguments(arguments)?;
|
||||
println!(
|
||||
"{}",
|
||||
option_env!("CPR_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
|
||||
);
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
Some("plugin") => {
|
||||
let arguments = arguments
|
||||
.map(|value| {
|
||||
value
|
||||
.into_string()
|
||||
.map_err(|_| invalid_cli("plugin arguments must be UTF-8"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut arguments = arguments.into_iter();
|
||||
let instance_id = arguments
|
||||
.next()
|
||||
.filter(|value| !matches!(value.as_str(), "--help" | "-h"));
|
||||
let name = arguments
|
||||
.next()
|
||||
.filter(|value| !matches!(value.as_str(), "--help" | "-h"));
|
||||
if instance_id.is_none() && name.is_some() {
|
||||
return Err(invalid_cli("unexpected plugin help arguments").into());
|
||||
}
|
||||
let output = runtime()?.block_on(codex_proxy_rs::bootstrap::plugin_command(
|
||||
codex_proxy_rs::bootstrap::PluginCommand {
|
||||
instance_id,
|
||||
name,
|
||||
arguments: arguments.collect(),
|
||||
},
|
||||
))?;
|
||||
io::stdout().lock().write_all(output.stdout.as_bytes())?;
|
||||
io::stderr().lock().write_all(output.stderr.as_bytes())?;
|
||||
Ok(ExitCode::from(output.exit_code))
|
||||
}
|
||||
Some(_) => Err(invalid_cli("unknown command; expected serve, plugin, or --help").into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,13 @@ use syn::{Item, visit::Visit};
|
||||
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
|
||||
pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
|
||||
"apps/gateway",
|
||||
"apps/plugin-cli",
|
||||
"crates/gateway-admin",
|
||||
"crates/gateway-api",
|
||||
"crates/gateway-core",
|
||||
"crates/gateway-host",
|
||||
"crates/gateway-plugin/runtime",
|
||||
"crates/gateway-plugin/sdk",
|
||||
"crates/gateway-protocol",
|
||||
"crates/gateway-store",
|
||||
"crates/providers/openai",
|
||||
@@ -149,6 +152,13 @@ fn gateway_protocol_has_no_workspace_dependencies() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_sdk_has_no_workspace_dependencies() {
|
||||
for name in dependency_names("crates/gateway-plugin/sdk") {
|
||||
assert!(!name.starts_with("gateway-") && !name.starts_with("provider-"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_crates_do_not_depend_on_each_other() {
|
||||
assert_no_dependency("crates/providers/openai", &["provider-xai"]);
|
||||
@@ -166,25 +176,34 @@ fn gateway_admin_stays_free_of_infrastructure_dependencies() {
|
||||
/// workspace 包名到冻结成员路径的映射。
|
||||
const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
|
||||
("codex-proxy-rs", "apps/gateway"),
|
||||
("codex-proxy-plugin-cli", "apps/plugin-cli"),
|
||||
("gateway-admin", "crates/gateway-admin"),
|
||||
("gateway-api", "crates/gateway-api"),
|
||||
("gateway-core", "crates/gateway-core"),
|
||||
("gateway-host", "crates/gateway-host"),
|
||||
("gateway-plugin-runtime", "crates/gateway-plugin/runtime"),
|
||||
("gateway-plugin-sdk", "crates/gateway-plugin/sdk"),
|
||||
("gateway-protocol", "crates/gateway-protocol"),
|
||||
("gateway-store", "crates/gateway-store"),
|
||||
("provider-openai", "crates/providers/openai"),
|
||||
("provider-xai", "crates/providers/xai"),
|
||||
];
|
||||
|
||||
/// Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
|
||||
/// SDK 与 Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
|
||||
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
("crates/gateway-plugin/sdk", &["call", "client"]),
|
||||
("crates/gateway-plugin/runtime", &[]),
|
||||
("crates/gateway-api", &["admin", "auth", "openai"]),
|
||||
(
|
||||
"crates/gateway-host",
|
||||
&[
|
||||
"client_distribution",
|
||||
"config",
|
||||
"outbound",
|
||||
"official_plugins",
|
||||
"pricing",
|
||||
"plugin_distribution",
|
||||
"process",
|
||||
"proxy_probe",
|
||||
"serve",
|
||||
"system_update",
|
||||
@@ -207,10 +226,12 @@ const ROOT_TEST_SCENARIOS: &[(&str, &[&str])] = &[
|
||||
|
||||
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表。
|
||||
const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
("codex-proxy-plugin-cli", "gateway-plugin-sdk"),
|
||||
("codex-proxy-rs", "gateway-admin"),
|
||||
("codex-proxy-rs", "gateway-api"),
|
||||
("codex-proxy-rs", "gateway-core"),
|
||||
("codex-proxy-rs", "gateway-host"),
|
||||
("codex-proxy-rs", "gateway-plugin-runtime"),
|
||||
("codex-proxy-rs", "gateway-store"),
|
||||
("codex-proxy-rs", "provider-openai"),
|
||||
("codex-proxy-rs", "provider-xai"),
|
||||
@@ -220,6 +241,11 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
("gateway-api", "gateway-protocol"),
|
||||
("gateway-host", "gateway-admin"),
|
||||
("gateway-host", "gateway-core"),
|
||||
("gateway-plugin-runtime", "gateway-host"),
|
||||
("gateway-plugin-runtime", "gateway-core"),
|
||||
("gateway-plugin-runtime", "gateway-admin"),
|
||||
("gateway-plugin-runtime", "gateway-plugin-sdk"),
|
||||
("gateway-plugin-runtime", "gateway-protocol"),
|
||||
("gateway-store", "gateway-admin"),
|
||||
("gateway-store", "gateway-core"),
|
||||
("provider-openai", "gateway-admin"),
|
||||
@@ -349,13 +375,18 @@ fn production_sources_do_not_host_tests() {
|
||||
|
||||
#[test]
|
||||
fn workspace_modules_follow_conventional_file_layout() {
|
||||
let targets = test_source_roots();
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let member_root = backend_root().join(member);
|
||||
assert_module_tree(&member_root.join("src"), &["lib.rs", "main.rs"]);
|
||||
|
||||
let tests = member_root.join("tests");
|
||||
if tests.is_dir() {
|
||||
assert_module_tree(&tests, &["main.rs"]);
|
||||
let roots = targets.get(&tests).expect("Cargo test source roots");
|
||||
assert_module_tree(
|
||||
&tests,
|
||||
&roots.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,6 +399,7 @@ enum ModuleLayout {
|
||||
|
||||
#[test]
|
||||
fn integration_tests_mirror_production_module_tree() {
|
||||
let targets = test_source_roots();
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let member_root = backend_root().join(member);
|
||||
let tests_root = member_root.join("tests");
|
||||
@@ -376,9 +408,13 @@ fn integration_tests_mirror_production_module_tree() {
|
||||
}
|
||||
|
||||
let production = module_layouts(&member_root.join("src"), &["lib.rs", "main.rs"]);
|
||||
let tests = module_layouts(&tests_root, &["main.rs"]);
|
||||
let roots = targets.get(&tests_root).expect("Cargo test source roots");
|
||||
let tests = module_layouts(
|
||||
&tests_root,
|
||||
&roots.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
for (module, test_layout) in tests {
|
||||
if module == Path::new("support") {
|
||||
if module.starts_with("support") {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -408,6 +444,28 @@ fn integration_tests_mirror_production_module_tree() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust 子进程 fixture 是 Cargo 的独立 crate 根,不能要求它由测试模块再次声明。
|
||||
fn test_source_roots() -> BTreeMap<PathBuf, Vec<String>> {
|
||||
let metadata = cargo_metadata_json();
|
||||
let mut roots = BTreeMap::<PathBuf, Vec<String>>::new();
|
||||
for package in metadata["packages"].as_array().expect("Cargo packages") {
|
||||
let root = Path::new(package["manifest_path"].as_str().expect("package manifest"))
|
||||
.parent()
|
||||
.expect("package directory")
|
||||
.join("tests");
|
||||
for target in package["targets"].as_array().expect("Cargo targets") {
|
||||
let source = Path::new(target["src_path"].as_str().expect("target source"));
|
||||
if let Ok(relative) = source.strip_prefix(&root) {
|
||||
roots
|
||||
.entry(root.clone())
|
||||
.or_default()
|
||||
.push(relative.to_str().expect("UTF-8 source path").to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn module_layouts(root: &Path, crate_roots: &[&str]) -> BTreeMap<PathBuf, ModuleLayout> {
|
||||
super::rust_files(root)
|
||||
.into_iter()
|
||||
|
||||
@@ -9,6 +9,54 @@ const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
|
||||
const ADMIN_PASSWORD: &str = "test-admin-password";
|
||||
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
|
||||
|
||||
#[test]
|
||||
fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(directory.path().join("deploy")).unwrap();
|
||||
fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap();
|
||||
for flag in ["--help", "-h", "help", "--version", "-V"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.arg(flag)
|
||||
.current_dir(directory.path())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{flag}");
|
||||
assert!(!output.stdout.is_empty());
|
||||
assert!(output.stderr.is_empty());
|
||||
}
|
||||
for args in [
|
||||
vec!["unknown"],
|
||||
vec!["--help", "extra"],
|
||||
vec!["serve", "extra"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.args(args)
|
||||
.current_dir(directory.path())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_command_is_not_interpreted_as_serve() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.arg(std::ffi::OsString::from_vec(vec![0xff]))
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
|
||||
use gateway_admin::ports::proxy::ProxyProbe;
|
||||
@@ -85,7 +133,7 @@ async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
|
||||
});
|
||||
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
|
||||
let result = probe.test(&proxy).await;
|
||||
let result = probe.test(&proxy, false).await;
|
||||
assert!(result.success, "{}", result.message);
|
||||
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
|
||||
tokio::time::timeout(Duration::from_secs(5), server)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "codex-proxy-plugin-cli"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "cpr-plugin"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.7", features = ["derive"] }
|
||||
flate2 = "1.1.9"
|
||||
gateway-plugin-sdk = { path = "../../crates/gateway-plugin/sdk" }
|
||||
hex = "0.4.3"
|
||||
serde_json = "1.0.150"
|
||||
sha2 = "0.11.0"
|
||||
tar = "0.4.44"
|
||||
thiserror = "2.0.18"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,51 @@
|
||||
# Codex Proxy Plugin CLI
|
||||
|
||||
`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。
|
||||
当前输出使用 `manifestVersion: 3`、`protocolVersion: 4` 和 SDK `0.4` 合同。
|
||||
|
||||
## 安装
|
||||
|
||||
从仓库根目录安装到本地工具目录:
|
||||
|
||||
```bash
|
||||
cargo install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
|
||||
```
|
||||
|
||||
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径。
|
||||
|
||||
## 打包
|
||||
|
||||
先构建目标平台的插件二进制;插件提供管理页面时,还需构建页面静态资源。打包命令传入作者清单、二进制和所需的资源目录映射,通过 `cpr-plugin package --help` 查看完整参数:
|
||||
|
||||
```bash
|
||||
cpr-plugin package \
|
||||
--manifest /path/to/plugin/plugin.json \
|
||||
--binary /path/to/plugin/target/x86_64-unknown-linux-gnu/release/plugin \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--resource-map web=web/dist \
|
||||
--output-dir /path/to/output
|
||||
```
|
||||
|
||||
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖。
|
||||
|
||||
作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 的 `request`/`attempt` 仍须显式选择。
|
||||
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的
|
||||
`plugin.json` 写入补全后的贡献项字段,不保留另一份作者声明。
|
||||
完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md)。
|
||||
|
||||
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。完整开发示例在同级 `codex-proxy-plugins/examples/workbench` 仓库目录;本工具不负责源码构建、上传或启用插件。
|
||||
|
||||
### 图标资源
|
||||
|
||||
在作者清单的 `icon` 中指定图标,并在 `resources` 中登记对应路径与 MIME 类型,配置方式见
|
||||
[SDK 插件图标](../../crates/gateway-plugin/sdk/docs/manifest.md#插件图标)。图标与其他声明资源一样参与打包和摘要计算,
|
||||
无需额外命令参数;工具保留文件原始内容,宿主安装时验证图标内容与资源限制。
|
||||
|
||||
## 开发
|
||||
|
||||
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`。
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli -- package --help
|
||||
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
|
||||
```
|
||||
@@ -0,0 +1,562 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Read as _, Write as _},
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use flate2::{Compression, GzBuilder};
|
||||
use gateway_plugin_sdk::{
|
||||
Manifest, ManifestError, PROTOCOL_VERSION, Package, PackageTarget, valid_package_path,
|
||||
};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use tar::{Builder, EntryType, Header, HeaderMode};
|
||||
use thiserror::Error;
|
||||
|
||||
const MAXIMUM_COMPRESSED_BYTES: u64 = 32 * 1024 * 1024;
|
||||
const MAXIMUM_EXPANDED_BYTES: u64 = 128 * 1024 * 1024;
|
||||
const MAXIMUM_MANIFEST_BYTES: u64 = 64 * 1024;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "cpr-plugin", version, about = "Codex Proxy 插件开发工具")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
#[command(about = "校验清单并打包已编译的插件")]
|
||||
Package(Options),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum PackagerError {
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
#[error("failed to {action} {path}: {source}")]
|
||||
Io {
|
||||
action: &'static str,
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
#[error("source or generated manifest is invalid: {0}")]
|
||||
Manifest(#[from] ManifestError),
|
||||
#[error("failed to encode generated manifest: {0}")]
|
||||
EncodeManifest(#[source] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct Options {
|
||||
#[arg(long, value_name = "FILE", help = "作者清单 plugin.json")]
|
||||
manifest: PathBuf,
|
||||
#[arg(long, value_name = "FILE", help = "已编译的目标平台插件二进制")]
|
||||
binary: PathBuf,
|
||||
#[arg(long, value_name = "TRIPLE", help = "目标平台 triple")]
|
||||
target: String,
|
||||
#[arg(long, value_name = "DIRECTORY", help = "归档与 SHA-256 文件的输出目录")]
|
||||
output_dir: PathBuf,
|
||||
#[arg(
|
||||
long = "resource-map",
|
||||
value_name = "PREFIX=DIRECTORY",
|
||||
help = "包内资源前缀到项目内目录的映射,可重复传入"
|
||||
)]
|
||||
resource_maps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct RuntimeTarget {
|
||||
os: &'static str,
|
||||
architecture: &'static str,
|
||||
}
|
||||
|
||||
impl RuntimeTarget {
|
||||
fn parse(triple: &str) -> Result<Self, PackagerError> {
|
||||
match triple {
|
||||
"x86_64-unknown-linux-gnu" => Ok(Self {
|
||||
os: "linux",
|
||||
architecture: "x86_64",
|
||||
}),
|
||||
"aarch64-unknown-linux-gnu" => Ok(Self {
|
||||
os: "linux",
|
||||
architecture: "aarch64",
|
||||
}),
|
||||
"aarch64-apple-darwin" => Ok(Self {
|
||||
os: "macos",
|
||||
architecture: "aarch64",
|
||||
}),
|
||||
_ => Err(PackagerError::Invalid(format!(
|
||||
"unsupported plugin target {triple}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PackageOutput {
|
||||
archive: PathBuf,
|
||||
checksum: PathBuf,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), PackagerError> {
|
||||
match Cli::parse().command {
|
||||
Commands::Package(options) => {
|
||||
let output = package(&options)?;
|
||||
println!("{}", output.archive.display());
|
||||
println!("{}", output.checksum.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_resource_maps(values: &[String]) -> Result<BTreeMap<String, PathBuf>, PackagerError> {
|
||||
let mut resource_maps = BTreeMap::new();
|
||||
for value in values {
|
||||
let (prefix, directory) = value.split_once('=').ok_or_else(|| {
|
||||
PackagerError::Invalid(
|
||||
"--resource-map must use package-prefix=source-directory".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if !valid_resource_prefix(prefix) {
|
||||
return Err(PackagerError::Invalid(format!(
|
||||
"invalid resource-map package prefix {prefix}"
|
||||
)));
|
||||
}
|
||||
let directory = PathBuf::from(directory);
|
||||
if !safe_relative_directory(&directory) {
|
||||
return Err(PackagerError::Invalid(format!(
|
||||
"resource-map source directory must be a non-empty project-relative path: {}",
|
||||
directory.display()
|
||||
)));
|
||||
}
|
||||
if resource_maps.insert(prefix.to_owned(), directory).is_some() {
|
||||
return Err(PackagerError::Invalid(format!(
|
||||
"duplicate resource-map package prefix {prefix}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(resource_maps)
|
||||
}
|
||||
|
||||
fn valid_resource_prefix(prefix: &str) -> bool {
|
||||
valid_package_path(prefix) && !prefix.contains('/')
|
||||
}
|
||||
|
||||
fn safe_relative_directory(path: &Path) -> bool {
|
||||
!path.as_os_str().is_empty()
|
||||
&& !path.is_absolute()
|
||||
&& path
|
||||
.components()
|
||||
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||
}
|
||||
|
||||
fn package(options: &Options) -> Result<PackageOutput, PackagerError> {
|
||||
let target = RuntimeTarget::parse(&options.target)?;
|
||||
let resource_maps = parse_resource_maps(&options.resource_maps)?;
|
||||
let manifest_bytes = read_regular_file(&options.manifest, MAXIMUM_MANIFEST_BYTES)?;
|
||||
let mut manifest = Manifest::from_author_slice(&manifest_bytes)?;
|
||||
|
||||
let project_directory = options
|
||||
.manifest
|
||||
.parent()
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let canonical_project = canonicalize(project_directory)?;
|
||||
let mut sources = BTreeMap::from([(manifest.main.clone(), options.binary.clone())]);
|
||||
let mut used_maps = BTreeSet::new();
|
||||
for package_path in manifest.resources.keys() {
|
||||
let source = resource_source(
|
||||
project_directory,
|
||||
package_path,
|
||||
&resource_maps,
|
||||
&mut used_maps,
|
||||
)?;
|
||||
let canonical_source = canonicalize(&source)?;
|
||||
if !canonical_source.starts_with(&canonical_project) {
|
||||
return Err(PackagerError::Invalid(format!(
|
||||
"resource source escapes the plugin project: {}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
if sources.insert(package_path.clone(), source).is_some() {
|
||||
return Err(PackagerError::Invalid(format!(
|
||||
"plugin main and resource share package path {package_path}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if used_maps.len() != resource_maps.len() {
|
||||
let unused = resource_maps
|
||||
.keys()
|
||||
.filter(|prefix| !used_maps.contains(*prefix))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
return Err(PackagerError::Invalid(format!(
|
||||
"unused resource-map package prefix: {}",
|
||||
unused.join(", ")
|
||||
)));
|
||||
}
|
||||
reject_case_folded_paths(sources.keys())?;
|
||||
|
||||
let mut files = BTreeMap::new();
|
||||
let mut source_bytes = 0u64;
|
||||
for (package_path, source) in sources {
|
||||
let remaining = MAXIMUM_EXPANDED_BYTES
|
||||
.checked_sub(source_bytes)
|
||||
.ok_or_else(package_limit)?;
|
||||
let bytes = read_regular_file(&source, remaining)?;
|
||||
source_bytes = source_bytes
|
||||
.checked_add(u64::try_from(bytes.len()).map_err(|_| package_limit())?)
|
||||
.ok_or_else(package_limit)?;
|
||||
files.insert(package_path, bytes);
|
||||
}
|
||||
let digests = files
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.clone(), hex::encode(Sha256::digest(bytes))))
|
||||
.collect();
|
||||
manifest.package = Some(Package {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
target: PackageTarget {
|
||||
os: target.os.to_owned(),
|
||||
architecture: target.architecture.to_owned(),
|
||||
},
|
||||
files: digests,
|
||||
});
|
||||
manifest.validate()?;
|
||||
let mut generated_manifest =
|
||||
serde_json::to_vec_pretty(&manifest).map_err(PackagerError::EncodeManifest)?;
|
||||
generated_manifest.push(b'\n');
|
||||
if generated_manifest.len() as u64 > MAXIMUM_MANIFEST_BYTES {
|
||||
return Err(package_limit());
|
||||
}
|
||||
|
||||
create_directory(&options.output_dir)?;
|
||||
let plugin_id = manifest.plugin_id()?;
|
||||
let archive_name = format!("{plugin_id}-{}-{}.tar.gz", manifest.version, options.target);
|
||||
let archive_path = options.output_dir.join(&archive_name);
|
||||
let archive_temporary = options
|
||||
.output_dir
|
||||
.join(format!(".{archive_name}.{}.tmp", std::process::id()));
|
||||
let mut temporary = TemporaryFile::create(archive_temporary)?;
|
||||
write_archive(
|
||||
temporary.file_mut(),
|
||||
&generated_manifest,
|
||||
&manifest.main,
|
||||
&files,
|
||||
)?;
|
||||
temporary.sync_all()?;
|
||||
let compressed_bytes = temporary.metadata_len()?;
|
||||
if compressed_bytes > MAXIMUM_COMPRESSED_BYTES {
|
||||
return Err(package_limit());
|
||||
}
|
||||
temporary.persist(&archive_path)?;
|
||||
|
||||
let archive_digest = sha256_file(&archive_path)?;
|
||||
let checksum_path = options.output_dir.join(format!("{archive_name}.sha256"));
|
||||
let checksum_temporary = options
|
||||
.output_dir
|
||||
.join(format!(".{archive_name}.sha256.{}.tmp", std::process::id()));
|
||||
let mut checksum = TemporaryFile::create(checksum_temporary)?;
|
||||
let checksum_temporary_path = checksum.path().to_owned();
|
||||
writeln!(checksum.file_mut(), "{archive_digest} {archive_name}").map_err(|source| {
|
||||
PackagerError::Io {
|
||||
action: "write",
|
||||
path: checksum_temporary_path,
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
checksum.sync_all()?;
|
||||
checksum.persist(&checksum_path)?;
|
||||
|
||||
Ok(PackageOutput {
|
||||
archive: archive_path,
|
||||
checksum: checksum_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn resource_source(
|
||||
project_directory: &Path,
|
||||
package_path: &str,
|
||||
mappings: &BTreeMap<String, PathBuf>,
|
||||
used_mappings: &mut BTreeSet<String>,
|
||||
) -> Result<PathBuf, PackagerError> {
|
||||
let (prefix, remainder) = package_path
|
||||
.split_once('/')
|
||||
.map_or((package_path, None), |(prefix, remainder)| {
|
||||
(prefix, Some(remainder))
|
||||
});
|
||||
if let Some(directory) = mappings.get(prefix) {
|
||||
let remainder = remainder.ok_or_else(|| {
|
||||
PackagerError::Invalid(format!(
|
||||
"mapped resource {package_path} must have a path below prefix {prefix}"
|
||||
))
|
||||
})?;
|
||||
used_mappings.insert(prefix.to_owned());
|
||||
Ok(project_directory.join(directory).join(remainder))
|
||||
} else {
|
||||
Ok(project_directory.join(package_path))
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_case_folded_paths<'a>(
|
||||
paths: impl IntoIterator<Item = &'a String>,
|
||||
) -> Result<(), PackagerError> {
|
||||
let mut folded = BTreeSet::new();
|
||||
for path in paths {
|
||||
if !folded.insert(path.to_ascii_lowercase()) {
|
||||
return Err(PackagerError::Invalid(format!(
|
||||
"package path collides case-insensitively: {path}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_archive(
|
||||
output: &mut File,
|
||||
manifest: &[u8],
|
||||
main_path: &str,
|
||||
files: &BTreeMap<String, Vec<u8>>,
|
||||
) -> Result<(), PackagerError> {
|
||||
let encoder = GzBuilder::new().mtime(0).write(output, Compression::best());
|
||||
let limited = LimitedWriter::new(encoder, MAXIMUM_EXPANDED_BYTES);
|
||||
let mut archive = Builder::new(limited);
|
||||
archive.mode(HeaderMode::Deterministic);
|
||||
append_file(&mut archive, "plugin.json", manifest, 0o644)?;
|
||||
for (path, bytes) in files {
|
||||
let mode = if path == main_path { 0o755 } else { 0o644 };
|
||||
append_file(&mut archive, path, bytes, mode)?;
|
||||
}
|
||||
archive.finish().map_err(|source| PackagerError::Io {
|
||||
action: "finish archive",
|
||||
path: PathBuf::from("<temporary archive>"),
|
||||
source,
|
||||
})?;
|
||||
let limited = archive.into_inner().map_err(|source| PackagerError::Io {
|
||||
action: "finish archive",
|
||||
path: PathBuf::from("<temporary archive>"),
|
||||
source,
|
||||
})?;
|
||||
limited
|
||||
.into_inner()
|
||||
.finish()
|
||||
.map_err(|source| PackagerError::Io {
|
||||
action: "finish compression",
|
||||
path: PathBuf::from("<temporary archive>"),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_file<W: io::Write>(
|
||||
archive: &mut Builder<W>,
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
mode: u32,
|
||||
) -> Result<(), PackagerError> {
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_entry_type(EntryType::Regular);
|
||||
header.set_mode(mode);
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
header.set_mtime(0);
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_cksum();
|
||||
archive
|
||||
.append_data(&mut header, path, bytes)
|
||||
.map_err(|source| PackagerError::Io {
|
||||
action: "append archive entry",
|
||||
path: PathBuf::from(path),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
struct LimitedWriter<W> {
|
||||
inner: W,
|
||||
written: u64,
|
||||
maximum: u64,
|
||||
}
|
||||
|
||||
impl<W> LimitedWriter<W> {
|
||||
const fn new(inner: W, maximum: u64) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
written: 0,
|
||||
maximum,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_inner(self) -> W {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl<W> io::Write for LimitedWriter<W>
|
||||
where
|
||||
W: io::Write,
|
||||
{
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
let length = u64::try_from(bytes.len())
|
||||
.map_err(|_| io::Error::other("plugin package exceeds expanded size limit"))?;
|
||||
if length > self.maximum.saturating_sub(self.written) {
|
||||
return Err(io::Error::other(
|
||||
"plugin package exceeds expanded size limit",
|
||||
));
|
||||
}
|
||||
let written = self.inner.write(bytes)?;
|
||||
self.written = self
|
||||
.written
|
||||
.checked_add(written as u64)
|
||||
.ok_or_else(|| io::Error::other("plugin package size overflow"))?;
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
struct TemporaryFile {
|
||||
path: PathBuf,
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl TemporaryFile {
|
||||
fn create(path: PathBuf) -> Result<Self, PackagerError> {
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
.map_err(|source| PackagerError::Io {
|
||||
action: "create",
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
Ok(Self { path, file })
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn file_mut(&mut self) -> &mut File {
|
||||
&mut self.file
|
||||
}
|
||||
|
||||
fn sync_all(&mut self) -> Result<(), PackagerError> {
|
||||
let path = self.path.clone();
|
||||
self.file_mut()
|
||||
.sync_all()
|
||||
.map_err(|source| PackagerError::Io {
|
||||
action: "sync",
|
||||
path,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn metadata_len(&self) -> Result<u64, PackagerError> {
|
||||
self.file
|
||||
.metadata()
|
||||
.map(|metadata| metadata.len())
|
||||
.map_err(|source| PackagerError::Io {
|
||||
action: "inspect",
|
||||
path: self.path.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn persist(mut self, destination: &Path) -> Result<(), PackagerError> {
|
||||
fs::rename(&self.path, destination).map_err(|source| PackagerError::Io {
|
||||
action: "publish",
|
||||
path: destination.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
self.path.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TemporaryFile {
|
||||
fn drop(&mut self) {
|
||||
if !self.path.as_os_str().is_empty() {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_regular_file(path: &Path, maximum_bytes: u64) -> Result<Vec<u8>, PackagerError> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|source| PackagerError::Io {
|
||||
action: "inspect",
|
||||
path: path.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(PackagerError::Invalid(format!(
|
||||
"package input must be a regular non-symlink file: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if metadata.len() > maximum_bytes {
|
||||
return Err(package_limit());
|
||||
}
|
||||
let file = File::open(path).map_err(|source| PackagerError::Io {
|
||||
action: "open",
|
||||
path: path.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or_default());
|
||||
file.take(maximum_bytes.saturating_add(1))
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|source| PackagerError::Io {
|
||||
action: "read",
|
||||
path: path.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
if bytes.len() as u64 > maximum_bytes {
|
||||
return Err(package_limit());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn canonicalize(path: &Path) -> Result<PathBuf, PackagerError> {
|
||||
fs::canonicalize(path).map_err(|source| PackagerError::Io {
|
||||
action: "canonicalize",
|
||||
path: path.to_owned(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_directory(path: &Path) -> Result<(), PackagerError> {
|
||||
fs::create_dir_all(path).map_err(|source| PackagerError::Io {
|
||||
action: "create directory",
|
||||
path: path.to_owned(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String, PackagerError> {
|
||||
let mut file = File::open(path).map_err(|source| PackagerError::Io {
|
||||
action: "open",
|
||||
path: path.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buffer).map_err(|source| PackagerError::Io {
|
||||
action: "hash",
|
||||
path: path.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..read]);
|
||||
}
|
||||
Ok(hex::encode(digest.finalize()))
|
||||
}
|
||||
|
||||
fn package_limit() -> PackagerError {
|
||||
PackagerError::Invalid("plugin package exceeds installation size limits".to_owned())
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, File},
|
||||
io::Read as _,
|
||||
process::{Command, Output},
|
||||
};
|
||||
|
||||
use flate2::read::MultiGzDecoder;
|
||||
use gateway_plugin_sdk::{Capability, MANIFEST_VERSION, Manifest, PROTOCOL_VERSION, Stage};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
fn cli(arguments: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cpr-plugin"))
|
||||
.args(arguments)
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_rejected(arguments: &[&str], message: &str) {
|
||||
let output = cli(arguments);
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"{arguments:?} unexpectedly succeeded"
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains(message),
|
||||
"{arguments:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn package_arguments() -> Vec<&'static str> {
|
||||
vec![
|
||||
"package",
|
||||
"--manifest",
|
||||
"plugin.json",
|
||||
"--binary",
|
||||
"plugin-bin",
|
||||
"--target",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"--output-dir",
|
||||
"dist",
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_help_lists_package_command() {
|
||||
let output = cli(&["--help"]);
|
||||
assert!(output.status.success());
|
||||
let help = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(help.contains("cpr-plugin <COMMAND>"), "{help}");
|
||||
assert!(help.contains("package"), "{help}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_uses_the_user_command_name() {
|
||||
let output = cli(&["--version"]);
|
||||
assert!(output.status.success());
|
||||
assert_eq!(
|
||||
String::from_utf8(output.stdout).unwrap(),
|
||||
format!("cpr-plugin {}\n", env!("CARGO_PKG_VERSION"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_help_does_not_require_inputs() {
|
||||
let output = cli(&["package", "--help"]);
|
||||
assert!(output.status.success());
|
||||
let help = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(help.contains("cpr-plugin package"), "{help}");
|
||||
assert!(help.contains("--resource-map"), "{help}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_subcommand() {
|
||||
assert_rejected(&[], "Usage: cpr-plugin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_subcommand() {
|
||||
assert_rejected(&["publish"], "unrecognized subcommand");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_package_arguments() {
|
||||
assert_rejected(&["package"], "--manifest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_single_value_options() {
|
||||
let mut arguments = package_arguments();
|
||||
arguments.extend(["--manifest", "other.json"]);
|
||||
assert_rejected(&arguments, "cannot be used multiple times");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_targets() {
|
||||
let mut arguments = package_arguments();
|
||||
arguments[6] = "x86_64-pc-windows-msvc";
|
||||
assert_rejected(&arguments, "unsupported plugin target");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_resource_mappings_before_reading_files() {
|
||||
for (mapping, message) in [
|
||||
("web=../web/dist", "project-relative path"),
|
||||
("web=/web/dist", "project-relative path"),
|
||||
("web=", "project-relative path"),
|
||||
("web/assets=web/dist", "invalid resource-map package prefix"),
|
||||
("web", "must use package-prefix=source-directory"),
|
||||
] {
|
||||
let mut arguments = package_arguments();
|
||||
arguments.extend(["--resource-map", mapping]);
|
||||
assert_rejected(&arguments, message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_resource_prefixes() {
|
||||
let mut arguments = package_arguments();
|
||||
arguments.extend([
|
||||
"--resource-map",
|
||||
"web=web/dist",
|
||||
"--resource-map",
|
||||
"web=other",
|
||||
]);
|
||||
assert_rejected(&arguments, "duplicate resource-map package prefix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packages_only_generated_resources_with_v3_metadata() {
|
||||
assert_packaged_resources("assets/icon.png", "image/png", b"png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packages_svg_icons_with_their_declared_mime_and_digest() {
|
||||
assert_packaged_resources(
|
||||
"assets/icon.svg",
|
||||
"image/svg+xml",
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 2h20v20H2z"/></svg>"#,
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_packaged_resources(icon_path: &str, icon_mime: &str, icon: &[u8]) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let project = directory.path().join("example");
|
||||
fs::create_dir_all(project.join("web/dist")).unwrap();
|
||||
fs::create_dir_all(project.join("assets")).unwrap();
|
||||
let mut source: serde_json::Value = serde_json::from_slice(source_manifest()).unwrap();
|
||||
source["icon"] = icon_path.into();
|
||||
let resources = source["resources"].as_object_mut().unwrap();
|
||||
resources.remove("assets/icon.png");
|
||||
resources.insert(icon_path.into(), icon_mime.into());
|
||||
fs::write(
|
||||
project.join("plugin.json"),
|
||||
serde_json::to_vec(&source).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(project.join("LICENSE"), b"license").unwrap();
|
||||
fs::write(project.join(icon_path), icon).unwrap();
|
||||
fs::write(project.join("plugin-bin"), b"binary").unwrap();
|
||||
fs::write(project.join("web/index.html"), b"source html").unwrap();
|
||||
fs::write(project.join("web/dist/index.html"), b"built html").unwrap();
|
||||
fs::write(project.join("web/dist/app.js"), b"built js").unwrap();
|
||||
fs::write(project.join("web/dist/app.css"), b"built css").unwrap();
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_cpr-plugin"))
|
||||
.arg("package")
|
||||
.arg("--manifest")
|
||||
.arg(project.join("plugin.json"))
|
||||
.arg("--binary")
|
||||
.arg(project.join("plugin-bin"))
|
||||
.args(["--target", "x86_64-unknown-linux-gnu"])
|
||||
.arg("--output-dir")
|
||||
.arg(project.join("dist"))
|
||||
.args(["--resource-map", "web=web/dist"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let archive_name = "codex-proxy.request-workbench-0.1.0-x86_64-unknown-linux-gnu.tar.gz";
|
||||
let archive_path = project.join("dist").join(archive_name);
|
||||
let checksum_path = project.join("dist").join(format!("{archive_name}.sha256"));
|
||||
assert_eq!(
|
||||
String::from_utf8(output.stdout).unwrap(),
|
||||
format!("{}\n{}\n", archive_path.display(), checksum_path.display())
|
||||
);
|
||||
let archive = File::open(&archive_path).unwrap();
|
||||
let mut package = tar::Archive::new(MultiGzDecoder::new(archive));
|
||||
let mut entries = BTreeMap::new();
|
||||
for entry in package.entries().unwrap() {
|
||||
let mut entry = entry.unwrap();
|
||||
let path = entry.path().unwrap().to_string_lossy().into_owned();
|
||||
let mut bytes = Vec::new();
|
||||
entry.read_to_end(&mut bytes).unwrap();
|
||||
entries.insert(path, bytes);
|
||||
}
|
||||
assert_eq!(
|
||||
entries.keys().cloned().collect::<Vec<_>>(),
|
||||
vec![
|
||||
"LICENSE".to_owned(),
|
||||
icon_path.to_owned(),
|
||||
"bin/plugin".to_owned(),
|
||||
"plugin.json".to_owned(),
|
||||
"web/app.css".to_owned(),
|
||||
"web/app.js".to_owned(),
|
||||
"web/index.html".to_owned(),
|
||||
]
|
||||
);
|
||||
assert_eq!(entries["web/index.html"], b"built html");
|
||||
let manifest: Manifest = serde_json::from_slice(&entries["plugin.json"]).unwrap();
|
||||
assert_eq!(manifest.manifest_version, MANIFEST_VERSION);
|
||||
assert_eq!(
|
||||
manifest.contributes[&Capability::Middleware].id,
|
||||
"codex-proxy.request-workbench.middleware"
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.contributes[&Capability::Management].stages,
|
||||
[Stage::Management]
|
||||
);
|
||||
assert_eq!(manifest.resources[icon_path], icon_mime);
|
||||
assert_eq!(entries[icon_path], icon);
|
||||
let package = manifest.package.unwrap();
|
||||
assert_eq!(package.files[icon_path], hex::encode(Sha256::digest(icon)));
|
||||
assert_eq!(package.protocol_version, PROTOCOL_VERSION);
|
||||
assert_eq!(package.target.os, "linux");
|
||||
assert_eq!(package.target.architecture, "x86_64");
|
||||
assert_eq!(package.files.len(), 6);
|
||||
let checksum = fs::read_to_string(checksum_path).unwrap();
|
||||
let digest = hex::encode(Sha256::digest(fs::read(archive_path).unwrap()));
|
||||
assert_eq!(checksum, format!("{digest} {archive_name}\n"));
|
||||
}
|
||||
|
||||
fn source_manifest() -> &'static [u8] {
|
||||
br#"{
|
||||
"manifestVersion":3,
|
||||
"name":"request-workbench",
|
||||
"displayName":"Request Workbench",
|
||||
"publisher":"codex-proxy",
|
||||
"version":"0.1.0",
|
||||
"description":"test package",
|
||||
"license":"Apache-2.0",
|
||||
"engines":{"codex-proxy-rs":">=3.12.0, <4.0.0"},
|
||||
"main":"bin/plugin",
|
||||
"runtime":"trustedProcess",
|
||||
"contributes":{
|
||||
"middleware":{"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"management":{}
|
||||
},
|
||||
"permissions":["requests","accounts"],
|
||||
"icon":"assets/icon.png",
|
||||
"resources":{"LICENSE":"text/plain","assets/icon.png":"image/png","web/index.html":"text/html","web/app.js":"text/javascript","web/app.css":"text/css"}
|
||||
}"#
|
||||
}
|
||||
@@ -16,7 +16,7 @@ argon2 = "0.6.0"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
chrono = "0.4.45"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
futures = "0.3.32"
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
heck = "0.5.0"
|
||||
@@ -25,6 +25,7 @@ rand_core = { version = "0.6.4", features = ["getrandom"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||
semver = "1.0.27"
|
||||
sha2 = "0.11"
|
||||
subtle = "2.6.1"
|
||||
thiserror = "2.0.18"
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::{fmt, path::Path, sync::Arc, time::Duration};
|
||||
use gateway_core::{
|
||||
engine::execution::ClientKeyVerifier,
|
||||
engine::probe::AccountProbe,
|
||||
routing::ProviderKind,
|
||||
runtime::SnapshotControl,
|
||||
task::{
|
||||
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest,
|
||||
@@ -22,34 +21,39 @@ pub mod freeze_recovery;
|
||||
pub mod model;
|
||||
pub mod ports;
|
||||
mod use_case;
|
||||
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
|
||||
|
||||
pub use use_case::key_usage::KeyUsageService;
|
||||
|
||||
pub use use_case::{
|
||||
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
|
||||
backup::BackupService, client_distribution::ClientDistributionService,
|
||||
client_keys::ClientKeyService, import_tasks::ImportTasksService,
|
||||
observability::ObservabilityService, openai::OpenAiService, proxies::ProxiesService,
|
||||
settings::SettingsService, system::SystemService, xai::XaiService,
|
||||
account_groups::AccountGroupService,
|
||||
accounts::AccountsService,
|
||||
auth::AuthService,
|
||||
backup::BackupService,
|
||||
client_distribution::ClientDistributionService,
|
||||
client_keys::ClientKeyService,
|
||||
credentials::{CredentialsService, ProviderCredentials},
|
||||
import_tasks::ImportTasksService,
|
||||
observability::ObservabilityService,
|
||||
proxies::ProxiesService,
|
||||
settings::SettingsService,
|
||||
system::SystemService,
|
||||
};
|
||||
|
||||
use model::{AdminError, AdminErrorKind};
|
||||
use model::AdminError;
|
||||
use ports::{
|
||||
client_distribution::ClientDistributionResolver,
|
||||
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry},
|
||||
store::AdminStorePorts,
|
||||
system::SystemOperations,
|
||||
client_distribution::ClientDistributionResolver, plugin_accounts::PluginAccountAccess,
|
||||
plugin_client_keys::PluginClientKeyAccess, provider::ProviderAdminRegistry,
|
||||
store::AdminStorePorts, system::SystemOperations,
|
||||
};
|
||||
use use_case::{
|
||||
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
|
||||
auth::DefaultAuthService, backup::DefaultBackupService,
|
||||
client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService,
|
||||
observability::DefaultObservabilityService, openai::DefaultOpenAiService,
|
||||
settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService,
|
||||
observability::DefaultObservabilityService, settings::DefaultSettingsService,
|
||||
system::DefaultSystemService,
|
||||
};
|
||||
|
||||
const OPENAI_PROVIDER_KIND: &str = "openai";
|
||||
const XAI_PROVIDER_KIND: &str = "xai";
|
||||
const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12;
|
||||
const WEAK_ADMIN_PASSWORDS: &[&str] = &[
|
||||
"",
|
||||
@@ -182,6 +186,8 @@ pub enum AdminConfigError {
|
||||
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
|
||||
#[derive(Clone)]
|
||||
pub struct AdminServices {
|
||||
plugins: Arc<PluginsService>,
|
||||
plugin_management: Arc<PluginManagementService>,
|
||||
proxies: Arc<dyn ProxiesService>,
|
||||
auth: Arc<dyn AuthService>,
|
||||
key_usage: Arc<dyn KeyUsageService>,
|
||||
@@ -192,13 +198,23 @@ pub struct AdminServices {
|
||||
observability: Arc<dyn ObservabilityService>,
|
||||
settings: Arc<dyn SettingsService>,
|
||||
system: Arc<dyn SystemService>,
|
||||
openai: Arc<dyn OpenAiService>,
|
||||
xai: Arc<dyn XaiService>,
|
||||
credentials: Arc<CredentialsService>,
|
||||
plugin_accounts: Arc<dyn PluginAccountAccess>,
|
||||
backups: Arc<dyn BackupService>,
|
||||
import_tasks: Arc<dyn ImportTasksService>,
|
||||
}
|
||||
|
||||
impl AdminServices {
|
||||
#[must_use]
|
||||
pub fn plugin_management(&self) -> &PluginManagementService {
|
||||
&self.plugin_management
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugins(&self) -> &PluginsService {
|
||||
&self.plugins
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn import_tasks(&self) -> &dyn ImportTasksService {
|
||||
self.import_tasks.as_ref()
|
||||
@@ -261,13 +277,14 @@ impl AdminServices {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn openai(&self) -> &dyn OpenAiService {
|
||||
self.openai.as_ref()
|
||||
pub fn credentials(&self) -> &CredentialsService {
|
||||
self.credentials.as_ref()
|
||||
}
|
||||
|
||||
/// Runtime 只持有该窄端口的 Weak;AdminBundle 保持实际生命周期。
|
||||
#[must_use]
|
||||
pub fn xai(&self) -> &dyn XaiService {
|
||||
self.xai.as_ref()
|
||||
pub fn plugin_accounts_handle(&self) -> Arc<dyn PluginAccountAccess> {
|
||||
Arc::clone(&self.plugin_accounts)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -296,8 +313,13 @@ impl AdminBundle {
|
||||
|
||||
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
|
||||
pub struct AdminRuntimePorts {
|
||||
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
|
||||
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
|
||||
pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle,
|
||||
pub plugin_distribution: Arc<dyn ports::plugins::PluginDistribution>,
|
||||
pub plugin_inspector: Arc<dyn ports::plugins::PluginPackageInspector>,
|
||||
pub pricing_source: Arc<dyn ports::pricing::PricingSource>,
|
||||
pub providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
pub providers: ProviderAdminRegistry,
|
||||
pub snapshot: Arc<dyn SnapshotControl>,
|
||||
pub account_probe: Arc<dyn AccountProbe>,
|
||||
pub proxy_probe: Arc<dyn ports::proxy::ProxyProbe>,
|
||||
@@ -306,18 +328,44 @@ pub struct AdminRuntimePorts {
|
||||
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
|
||||
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。
|
||||
/// 配置非法或默认管理员初始化失败时返回错误。
|
||||
pub async fn initialize(
|
||||
config: AdminConfig,
|
||||
client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
runtime: AdminRuntimePorts,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
initialize_inner(config, client_config, store, runtime, None).await
|
||||
}
|
||||
|
||||
/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例。
|
||||
pub async fn initialize_with_plugin_accounts(
|
||||
config: AdminConfig,
|
||||
client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
runtime: AdminRuntimePorts,
|
||||
plugin_accounts: Arc<dyn PluginAccountAccess>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
initialize_inner(config, client_config, store, runtime, Some(plugin_accounts)).await
|
||||
}
|
||||
|
||||
async fn initialize_inner(
|
||||
mut config: AdminConfig,
|
||||
mut client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
runtime: AdminRuntimePorts,
|
||||
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
let AdminRuntimePorts {
|
||||
plugin_preparation,
|
||||
plugin_management,
|
||||
published_snapshot,
|
||||
plugin_distribution,
|
||||
plugin_inspector,
|
||||
pricing_source,
|
||||
providers,
|
||||
snapshot,
|
||||
@@ -333,20 +381,14 @@ pub async fn initialize(
|
||||
client_config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
let registry = ProviderAdminRegistry::new(providers).map_err(map_provider_registry_error)?;
|
||||
let openai = registry
|
||||
.require(&provider_kind(OPENAI_PROVIDER_KIND)?)
|
||||
.map_err(map_provider_registry_error)?;
|
||||
let xai = registry
|
||||
.require(&provider_kind(XAI_PROVIDER_KIND)?)
|
||||
.map_err(map_provider_registry_error)?;
|
||||
let registry = providers;
|
||||
|
||||
let auth = Arc::new(DefaultAuthService::new(
|
||||
config.default_username,
|
||||
config.session_ttl_minutes,
|
||||
client_config.session_ttl_minutes,
|
||||
store.auth(),
|
||||
client_key_verifier,
|
||||
client_key_verifier.clone(),
|
||||
));
|
||||
auth.ensure_default_admin(config.default_password.expose())
|
||||
.await?;
|
||||
@@ -370,27 +412,44 @@ pub async fn initialize(
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
|
||||
store.plugins(),
|
||||
plugin_inspector.clone(),
|
||||
));
|
||||
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
auth.clone(),
|
||||
client_key_verifier,
|
||||
store.client_keys(),
|
||||
store.observability(),
|
||||
system.clone(),
|
||||
));
|
||||
let openai = Arc::new(DefaultOpenAiService::new(
|
||||
openai,
|
||||
let credentials = Arc::new(CredentialsService::new(
|
||||
registry.clone(),
|
||||
store.accounts(),
|
||||
store.proxies(),
|
||||
snapshot.clone(),
|
||||
));
|
||||
let xai = Arc::new(DefaultXaiService::new(
|
||||
xai,
|
||||
store.accounts(),
|
||||
store.proxies(),
|
||||
snapshot.clone(),
|
||||
));
|
||||
let import_tasks =
|
||||
use_case::import_tasks::DefaultImportTasksService::new(openai.clone(), xai.clone());
|
||||
let plugin_accounts = plugin_accounts.unwrap_or_else(|| {
|
||||
initialize_plugin_accounts(registry.clone(), store.accounts(), snapshot.clone())
|
||||
});
|
||||
let import_tasks = use_case::import_tasks::DefaultImportTasksService::new(credentials.clone());
|
||||
let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
|
||||
let services = AdminServices {
|
||||
plugin_management: Arc::new(PluginManagementService::new(
|
||||
plugin_management,
|
||||
store.plugins(),
|
||||
published_snapshot.clone(),
|
||||
)),
|
||||
plugins: Arc::new(PluginsService::new(
|
||||
store.plugins(),
|
||||
plugin_inspector,
|
||||
PluginDistributionPorts::new(plugin_distribution, store.proxies()),
|
||||
snapshot.clone(),
|
||||
plugin_preparation,
|
||||
published_snapshot,
|
||||
store.plugin_state(),
|
||||
)),
|
||||
key_usage,
|
||||
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
|
||||
store.proxies(),
|
||||
@@ -423,9 +482,9 @@ pub async fn initialize(
|
||||
registry,
|
||||
pricing_source,
|
||||
)),
|
||||
system: Arc::new(DefaultSystemService::new(system)),
|
||||
openai,
|
||||
xai,
|
||||
system,
|
||||
credentials,
|
||||
plugin_accounts,
|
||||
import_tasks,
|
||||
backups,
|
||||
};
|
||||
@@ -457,6 +516,29 @@ pub async fn initialize(
|
||||
})
|
||||
}
|
||||
|
||||
/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计。
|
||||
pub fn initialize_plugin_accounts(
|
||||
providers: ports::provider::ProviderAdminRegistry,
|
||||
accounts: Arc<dyn ports::store::AccountStore>,
|
||||
snapshot: Arc<dyn gateway_core::runtime::SnapshotControl>,
|
||||
) -> Arc<dyn PluginAccountAccess> {
|
||||
Arc::new(use_case::plugin_accounts::DefaultPluginAccountAccess::new(
|
||||
providers, accounts, snapshot,
|
||||
))
|
||||
}
|
||||
|
||||
/// 为 Runtime 创建只暴露非秘密 Client Key 目录的窄端口。
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_client_keys(
|
||||
providers: ports::provider::ProviderAdminRegistry,
|
||||
store: Arc<dyn ports::store::ClientKeyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Arc<dyn PluginClientKeyAccess> {
|
||||
let service: Arc<dyn ClientKeyService> =
|
||||
Arc::new(DefaultClientKeyService::new(store, snapshot, providers));
|
||||
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
|
||||
}
|
||||
|
||||
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
|
||||
fn backup_worker_contribution(
|
||||
task: backup::task::BackupTask,
|
||||
@@ -506,24 +588,3 @@ fn freeze_recovery_worker_contribution(
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
|
||||
ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法"))
|
||||
}
|
||||
|
||||
fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
|
||||
let kind = match error.kind() {
|
||||
ProviderAdminErrorKind::Invalid | ProviderAdminErrorKind::Unsupported => {
|
||||
AdminErrorKind::Invalid
|
||||
}
|
||||
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
|
||||
ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => {
|
||||
AdminErrorKind::Unavailable
|
||||
}
|
||||
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
AdminError::new(kind, "Provider 注册表初始化失败")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ pub struct AccountGroupAccountSummary {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountGroupCapacity {
|
||||
pub used_slots: Option<u64>,
|
||||
pub total_slots: u64,
|
||||
/// `None` 表示可用账号中存在不限制并发的账号。
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// Successful, downstream-committed USD request costs for group accounts.
|
||||
@@ -55,7 +56,7 @@ pub struct AccountGroupMemberFact {
|
||||
pub group_id: AccountGroupId,
|
||||
pub account_id: String,
|
||||
pub status: AccountStatusFacts,
|
||||
pub total_slots: u64,
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// Lightweight group reference embedded in account and client-key views.
|
||||
|
||||
@@ -18,6 +18,18 @@ pub use gateway_core::account::{
|
||||
QuotaState, resolve_account_status,
|
||||
};
|
||||
|
||||
/// 账号可用的管理操作。
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProviderAccountCapabilities {
|
||||
pub quota: bool,
|
||||
pub quota_refresh: bool,
|
||||
pub profile: bool,
|
||||
pub subscription: bool,
|
||||
pub avatar: bool,
|
||||
pub reset_credits: bool,
|
||||
pub consume_reset_credit: bool,
|
||||
}
|
||||
|
||||
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountImportSettings {
|
||||
|
||||
@@ -101,7 +101,8 @@ impl From<ClientAuthenticationError> for LoginError {
|
||||
fn from(error: ClientAuthenticationError) -> Self {
|
||||
match error {
|
||||
ClientAuthenticationError::InvalidKey => Self::InvalidCredentials,
|
||||
ClientAuthenticationError::SnapshotUnavailable => Self::Unavailable,
|
||||
ClientAuthenticationError::SnapshotUnavailable
|
||||
| ClientAuthenticationError::ProviderUnavailable => Self::Unavailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Client API Key 的 Command、Result 与安全秘密类型。
|
||||
|
||||
use std::{fmt, num::NonZeroU16};
|
||||
use std::{collections::BTreeMap, fmt, num::NonZeroU16};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
@@ -12,6 +12,11 @@ use gateway_core::{
|
||||
|
||||
use super::{AdminModelError, Revision, account_groups::AccountGroupRef};
|
||||
|
||||
pub type ProviderRequestProfileOverrides =
|
||||
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
|
||||
pub type ProviderRequestProfileOverrideUpdates =
|
||||
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
|
||||
|
||||
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ClientKeyPageSize(NonZeroU16);
|
||||
@@ -86,8 +91,7 @@ pub struct ClientKeyListQuery {
|
||||
/// 不含完整明文 Key 的管理投影。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyRecord {
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub request_profile_overrides: ProviderRequestProfileOverrides,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -146,8 +150,7 @@ impl fmt::Debug for ClientKeySecret {
|
||||
/// API 提交的 Client Key 创建命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub request_profile_overrides: ProviderRequestProfileOverrides,
|
||||
pub custom_key: Option<PlaintextClientApiKey>,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -159,8 +162,7 @@ pub struct CreateClientKey {
|
||||
/// 管理用例生成 ID 与明文后的持久化命令。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct NewClientKey {
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub request_profile_overrides: ProviderRequestProfileOverrides,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -185,8 +187,7 @@ impl fmt::Debug for NewClientKey {
|
||||
/// 修改 Client Key 的公开策略字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKey {
|
||||
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
|
||||
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
|
||||
pub request_profile_override_updates: ProviderRequestProfileOverrideUpdates,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
|
||||
@@ -11,6 +11,8 @@ pub mod client_keys;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod plugin_client_keys;
|
||||
pub mod plugins;
|
||||
pub mod pricing;
|
||||
pub mod provider_credentials;
|
||||
pub mod proxies;
|
||||
|
||||
@@ -556,6 +556,8 @@ pub struct UsageListRecord {
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
|
||||
pub provider_account_notes: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -990,7 +992,8 @@ pub struct DashboardWireAttribute {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardCapacity {
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub total_slots: u64,
|
||||
/// `None` 表示可用账号池不限制并发。
|
||||
pub total_slots: Option<u64>,
|
||||
pub used_slots: Option<u64>,
|
||||
pub available_slots: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! 插件可见的 Client Key 非秘密资源投影。
|
||||
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
|
||||
use super::PageSize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKeyCursor {
|
||||
pub name: String,
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKeyListQuery {
|
||||
pub cursor: Option<PluginClientKeyCursor>,
|
||||
pub limit: PageSize,
|
||||
}
|
||||
|
||||
/// 插件只能发现调用所需的公开身份,不取得 Key 前缀、策略细节或明文。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKey {
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKeyPage {
|
||||
pub items: Vec<PluginClientKey>,
|
||||
pub next_cursor: Option<PluginClientKeyCursor>,
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use secrecy::SecretString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{PluginArtifactMetadata, PluginSource, PluginSourceEgress};
|
||||
use crate::model::Revision;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginUpdateSource {
|
||||
Builtin,
|
||||
Upload,
|
||||
Url { url: String },
|
||||
Github { repository: String },
|
||||
}
|
||||
|
||||
/// 检查策略只决定查询目标,任何策略都不会自动安装或切换运行实例。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginUpdatePolicy {
|
||||
Manual {},
|
||||
Stable {},
|
||||
Pinned {
|
||||
tag: String,
|
||||
#[serde(default)]
|
||||
allow_prerelease: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for PluginUpdatePolicy {
|
||||
fn default() -> Self {
|
||||
Self::Manual {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginSourceBinding {
|
||||
pub plugin_id: String,
|
||||
pub source: PluginUpdateSource,
|
||||
#[serde(default)]
|
||||
pub policy: PluginUpdatePolicy,
|
||||
#[serde(default)]
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginDistributionEgress {
|
||||
pub source: PluginSourceEgress,
|
||||
pub proxy: OutboundProxy,
|
||||
}
|
||||
|
||||
impl PluginDistributionEgress {
|
||||
#[must_use]
|
||||
pub fn new(id: String, revision: Revision, proxy: OutboundProxy) -> Self {
|
||||
Self {
|
||||
source: PluginSourceEgress {
|
||||
id,
|
||||
revision: revision.get(),
|
||||
},
|
||||
proxy,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn cache_key(&self) -> String {
|
||||
format!("{}:{}", self.source.id, self.source.revision)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for PluginDistributionEgress {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PluginDistributionEgress")
|
||||
.field("source", &self.source)
|
||||
.field("proxy", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginUpdateCheck {
|
||||
pub binding: PluginSourceBinding,
|
||||
/// Release 元数据不是已校验的插件包,不据此声明平台或 SDK 兼容。
|
||||
pub release: PluginRelease,
|
||||
}
|
||||
|
||||
impl From<&PluginSource> for PluginUpdateSource {
|
||||
fn from(source: &PluginSource) -> Self {
|
||||
match source {
|
||||
PluginSource::Builtin { .. } => Self::Builtin,
|
||||
PluginSource::Upload => Self::Upload,
|
||||
PluginSource::Url { url, .. } => Self::Url { url: url.clone() },
|
||||
PluginSource::Github { repository, .. } => Self::Github {
|
||||
repository: repository.to_ascii_lowercase(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DownloadPurpose {
|
||||
Metadata,
|
||||
Artifact,
|
||||
}
|
||||
|
||||
/// 授权目标以完整 origin 和路径段边界匹配;摘要、名称和列表均不包含 secret。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SourceCredentialInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub origin: String,
|
||||
pub path_prefix: String,
|
||||
pub purposes: Vec<DownloadPurpose>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum SourceAuthentication {
|
||||
Github {
|
||||
token: SecretString,
|
||||
},
|
||||
Bearer {
|
||||
token: SecretString,
|
||||
},
|
||||
Basic {
|
||||
username: String,
|
||||
password: SecretString,
|
||||
},
|
||||
Header {
|
||||
name: String,
|
||||
value: SecretString,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SourceCredential {
|
||||
pub info: SourceCredentialInfo,
|
||||
pub authentication: SourceAuthentication,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct GithubReleaseQuery {
|
||||
pub repository: String,
|
||||
pub tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allow_prerelease: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReleaseAsset {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRelease {
|
||||
pub repository: String,
|
||||
pub tag: String,
|
||||
pub name: String,
|
||||
pub prerelease: bool,
|
||||
pub assets: Vec<ReleaseAsset>,
|
||||
pub queried_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 下载意图不承载凭据;引用在 Admin 中解析后只交给 Host 的本次调用。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum RemotePluginLocation {
|
||||
Url {
|
||||
url: String,
|
||||
sha256: Option<String>,
|
||||
},
|
||||
Github {
|
||||
repository: String,
|
||||
tag: String,
|
||||
asset: String,
|
||||
#[serde(default)]
|
||||
allow_prerelease: bool,
|
||||
sha256: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct RemotePluginVerify {
|
||||
pub location: RemotePluginLocation,
|
||||
#[serde(default)]
|
||||
pub credential_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
/// 更新已有插件时锁定身份;首次安装从包内清单读取。
|
||||
pub expected_plugin_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct RemotePluginInstall {
|
||||
pub plugin_id: String,
|
||||
pub version: String,
|
||||
pub location: RemotePluginLocation,
|
||||
#[serde(default)]
|
||||
pub credential_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct DownloadedPlugin {
|
||||
pub archive: Arc<[u8]>,
|
||||
pub sha256: String,
|
||||
pub source: PluginSource,
|
||||
}
|
||||
|
||||
/// 只读包校验结果,不代表实例配置、授权或私有状态已完成运行时准备。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifiedPluginArtifact {
|
||||
pub metadata: PluginArtifactMetadata,
|
||||
pub source: PluginSource,
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use secrecy::SecretString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::Revision;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginPermissionGrant {
|
||||
pub permission: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginFailurePolicy {
|
||||
Reject,
|
||||
Delegate,
|
||||
Observe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginCapabilityBinding {
|
||||
pub contribution: String,
|
||||
pub stage: String,
|
||||
pub order: i32,
|
||||
pub failure_policy: PluginFailurePolicy,
|
||||
#[serde(default)]
|
||||
pub client_key_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub account_group_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub provider_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub models: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub identity_bindings: Vec<PluginFrontendIdentityBinding>,
|
||||
}
|
||||
|
||||
/// 外部 principal 到既有 Client Key 的显式宿主映射。
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginFrontendIdentityBinding {
|
||||
pub principal: String,
|
||||
pub client_key_id: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PluginFrontendIdentityBinding {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PluginFrontendIdentityBinding")
|
||||
.field("principal", &"[REDACTED]")
|
||||
.field("client_key_id", &self.client_key_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置和 secret 分离;Debug 与 Serialize 不得无意展开实例配置。
|
||||
#[derive(Clone)]
|
||||
pub struct PluginInstance {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub artifact_sha256: String,
|
||||
pub enabled: bool,
|
||||
pub trusted_process: bool,
|
||||
pub configuration: serde_json::Value,
|
||||
pub secrets: BTreeMap<String, SecretString>,
|
||||
pub grants: Vec<PluginPermissionGrant>,
|
||||
pub bindings: Vec<PluginCapabilityBinding>,
|
||||
pub revision: Revision,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginInstanceSnapshot {
|
||||
pub config_revision: Revision,
|
||||
pub instances: Vec<PluginInstance>,
|
||||
}
|
||||
|
||||
pub struct PluginInstanceMutation {
|
||||
pub config_revision: Revision,
|
||||
pub instance: PluginInstance,
|
||||
}
|
||||
|
||||
/// 控制面展示的单实例运行状态;它描述当前进程观察到的事实,不替代持久启用配置。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PluginInstanceRuntimeStatus {
|
||||
Disabled,
|
||||
AwaitingPublication,
|
||||
Preparing,
|
||||
Running,
|
||||
Blocked,
|
||||
PreparationFailed,
|
||||
Faulted,
|
||||
Draining,
|
||||
}
|
||||
|
||||
/// 已脱敏且可安全返回给管理员的运行失败摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstanceRuntimeFailure {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 当前发布视图与代次索引的只读诊断。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstanceRuntime {
|
||||
pub status: PluginInstanceRuntimeStatus,
|
||||
pub actual_revision: Option<u64>,
|
||||
pub actual_artifact_sha256: Option<String>,
|
||||
pub failure: Option<PluginInstanceRuntimeFailure>,
|
||||
pub draining_revisions: Vec<u64>,
|
||||
}
|
||||
|
||||
pub struct PluginInstanceView {
|
||||
pub instance: PluginInstance,
|
||||
pub configuration_required: bool,
|
||||
pub running: bool,
|
||||
pub published_revision: Option<u64>,
|
||||
pub runtime: PluginInstanceRuntime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ConfigurePluginInstance {
|
||||
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口。
|
||||
pub creation_id: Option<String>,
|
||||
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容。
|
||||
pub expected_revision: Option<u64>,
|
||||
/// 管理员确认停用的同插件配置,与当前配置在同一事务中切换。
|
||||
#[serde(default)]
|
||||
pub replace_instances: Vec<PluginInstanceReplacement>,
|
||||
pub name: String,
|
||||
pub artifact_sha256: String,
|
||||
pub enabled: bool,
|
||||
pub configuration: serde_json::Value,
|
||||
/// 缺省保留旧值;显式空对象清除全部 secret。
|
||||
pub secrets: Option<BTreeMap<String, SecretString>>,
|
||||
pub bindings: Vec<PluginCapabilityBinding>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginInstanceReplacement {
|
||||
pub id: String,
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
/// 恢复已安装旧版本的配置快照,并重新通过目标版本校验。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct RollbackPluginInstance {
|
||||
pub artifact_sha256: String,
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRollbackTarget {
|
||||
pub artifact_sha256: String,
|
||||
pub version: String,
|
||||
pub platforms: Vec<String>,
|
||||
}
|
||||
|
||||
/// 静态候选不启动插件;实际回滚仍须复验配置、平台和私有状态兼容性。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRollbackPlan {
|
||||
pub instance_revision: u64,
|
||||
pub current_version: String,
|
||||
pub targets: Vec<PluginRollbackTarget>,
|
||||
}
|
||||
|
||||
/// 版本配置快照只用于服务端恢复,敏感值不实现 Debug 或 Serialize。
|
||||
#[derive(Clone)]
|
||||
pub struct PluginVersionConfiguration {
|
||||
pub configuration: serde_json::Value,
|
||||
pub secrets: BTreeMap<String, SecretString>,
|
||||
pub bindings: Vec<PluginCapabilityBinding>,
|
||||
}
|
||||
|
||||
/// 切换版本的只读草稿,不包含密钥值,也不会启动插件或写入配置。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginVersionPlan {
|
||||
pub instance_revision: u64,
|
||||
pub artifact_sha256: String,
|
||||
pub configuration: serde_json::Value,
|
||||
pub secret_fields: Vec<String>,
|
||||
pub bindings: Vec<PluginCapabilityBinding>,
|
||||
pub restored: bool,
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 页面和业务调用绑定同一实例版本;制品摘要单独作为不可变资源身份。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginManagementTarget {
|
||||
pub instance_id: String,
|
||||
pub artifact_sha256: String,
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginManagementView {
|
||||
pub target: PluginManagementTarget,
|
||||
pub name: String,
|
||||
pub configuration_schema: serde_json::Value,
|
||||
pub pages: Vec<PluginManagementPage>,
|
||||
pub routes: Vec<PluginManagementRoute>,
|
||||
pub resources: Vec<PluginManagementResource>,
|
||||
pub callbacks: Vec<PluginManagementCallback>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginManagementCallback {
|
||||
pub path: String,
|
||||
pub response_content_types: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct StartPluginManagementCallback {
|
||||
pub path: String,
|
||||
pub ttl_seconds: u32,
|
||||
}
|
||||
|
||||
/// state 是短期一次性秘密,只交给发起登录的管理员,不进入 Debug 或日志。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginManagementCallbackTicket {
|
||||
pub state: String,
|
||||
pub expires_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginManagementPage {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub entry: String,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginManagementRoute {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub request_content_types: Vec<String>,
|
||||
pub response_content_types: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginManagementResource {
|
||||
pub path: String,
|
||||
pub content_type: String,
|
||||
pub public: bool,
|
||||
}
|
||||
|
||||
/// 原始正文可能含业务数据,不能派生内容型 Debug 或写入诊断日志。
|
||||
pub struct PluginManagementRequest {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub query: String,
|
||||
pub content_type: Option<String>,
|
||||
pub body: Vec<u8>,
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
pub struct PluginManagementResponse {
|
||||
pub status: u16,
|
||||
pub content_type: String,
|
||||
pub body: Arc<[u8]>,
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::Revision;
|
||||
|
||||
pub mod distribution;
|
||||
pub mod instances;
|
||||
pub mod management;
|
||||
pub mod official;
|
||||
pub mod state;
|
||||
|
||||
/// 一个宿主发行版明确承诺支持的插件能力版本。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginHostCapability {
|
||||
pub capability: String,
|
||||
pub versions: Vec<u32>,
|
||||
}
|
||||
|
||||
/// 随宿主发行物封口的插件合同;Runtime 与发布清单共用这一份声明。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginHostCompatibility {
|
||||
pub schema_version: u32,
|
||||
pub manifest_schema_versions: Vec<u32>,
|
||||
pub protocol_versions: Vec<u32>,
|
||||
pub capabilities: Vec<PluginHostCapability>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginHostCompatibility {
|
||||
/// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配。
|
||||
#[must_use]
|
||||
pub fn is_valid(&self) -> bool {
|
||||
if self.schema_version != 1
|
||||
|| !valid_versions(&self.manifest_schema_versions)
|
||||
|| !valid_versions(&self.protocol_versions)
|
||||
|| self.capabilities.is_empty()
|
||||
|| self.capabilities.len() > 64
|
||||
|| self.permissions.len() > 64
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut capabilities = BTreeSet::new();
|
||||
if self.capabilities.iter().any(|entry| {
|
||||
!valid_identifier(&entry.capability)
|
||||
|| !valid_versions(&entry.versions)
|
||||
|| !capabilities.insert(entry.capability.as_str())
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
let mut permissions = BTreeSet::new();
|
||||
!self.permissions.iter().any(|permission| {
|
||||
!valid_identifier(permission) || !permissions.insert(permission.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn supports_capability(&self, capability: &str, version: u32) -> bool {
|
||||
self.capabilities
|
||||
.iter()
|
||||
.any(|entry| entry.capability == capability && entry.versions.contains(&version))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn supports_permission(&self, permission: &str) -> bool {
|
||||
self.permissions.iter().any(|entry| entry == permission)
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_versions(versions: &[u32]) -> bool {
|
||||
!versions.is_empty()
|
||||
&& versions.len() <= 16
|
||||
&& versions.iter().all(|version| *version > 0)
|
||||
&& versions.iter().collect::<BTreeSet<_>>().len() == versions.len()
|
||||
}
|
||||
|
||||
fn valid_identifier(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn valid_plugin_id(value: &str) -> bool {
|
||||
if value.len() > 64 {
|
||||
return false;
|
||||
}
|
||||
let Some((publisher, name)) = value.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
valid_plugin_segment(publisher) && valid_plugin_segment(name)
|
||||
}
|
||||
|
||||
fn valid_plugin_segment(value: &str) -> bool {
|
||||
value
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(u8::is_ascii_alphanumeric)
|
||||
&& value
|
||||
.as_bytes()
|
||||
.last()
|
||||
.is_some_and(u8::is_ascii_alphanumeric)
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
}
|
||||
|
||||
/// 已安装包对目标宿主的静态要求;不包含包体、配置或 secret。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginCompatibilityRequirements {
|
||||
pub host_version: String,
|
||||
pub manifest_schema_version: u32,
|
||||
pub protocol_version: u32,
|
||||
pub capabilities: Vec<(String, u32)>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginSourceEgress {
|
||||
pub id: String,
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
/// 安装来源只保存公开定位信息,下载凭据和出站代理通过独立记录引用。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginSource {
|
||||
Builtin {
|
||||
release: String,
|
||||
},
|
||||
Upload,
|
||||
Url {
|
||||
url: String,
|
||||
credential_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
outbound_proxy: Option<PluginSourceEgress>,
|
||||
},
|
||||
Github {
|
||||
repository: String,
|
||||
tag: String,
|
||||
asset: String,
|
||||
credential_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
outbound_proxy: Option<PluginSourceEgress>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PluginSource {
|
||||
#[must_use]
|
||||
pub fn credential_ids(&self) -> &[String] {
|
||||
match self {
|
||||
Self::Url { credential_ids, .. } | Self::Github { credential_ids, .. } => {
|
||||
credential_ids
|
||||
}
|
||||
Self::Builtin { .. } | Self::Upload => &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn outbound_proxy(&self) -> Option<&PluginSourceEgress> {
|
||||
match self {
|
||||
Self::Url { outbound_proxy, .. } | Self::Github { outbound_proxy, .. } => {
|
||||
outbound_proxy.as_ref()
|
||||
}
|
||||
Self::Builtin { .. } | Self::Upload => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理端保留的单项贡献声明;map key 是能力标识,`id` 是实例绑定引用的扩展项身份。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginContribution {
|
||||
pub id: String,
|
||||
pub version: u32,
|
||||
pub stages: Vec<String>,
|
||||
pub input_formats: Vec<String>,
|
||||
pub output_formats: Vec<String>,
|
||||
}
|
||||
|
||||
/// 制品清单中的展示图标;路径只用于选择已校验的包资源。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PluginArtifactIcon {
|
||||
Path(String),
|
||||
Themed(PluginArtifactIconVariants),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginArtifactIconVariants {
|
||||
pub light: String,
|
||||
pub dark: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PluginIconTheme {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// 从不可变制品中读取的已校验图标字节。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginArtifactIconResource {
|
||||
pub content_type: String,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 管理端使用的静态描述,不承载 SDK 的运行时消息或宿主实现类型。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginPermissionDescription {
|
||||
pub permission: String,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginArtifactMetadata {
|
||||
pub plugin_id: String,
|
||||
pub version: String,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub publisher: String,
|
||||
pub author: Option<String>,
|
||||
pub description: String,
|
||||
pub license: String,
|
||||
pub sha256: String,
|
||||
pub platforms: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<PluginArtifactIcon>,
|
||||
pub contributes: BTreeMap<String, PluginContribution>,
|
||||
pub requested_permissions: Vec<String>,
|
||||
pub configuration_schema: serde_json::Value,
|
||||
pub secret_fields: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub state_namespaces: Vec<state::PluginStateSchema>,
|
||||
}
|
||||
|
||||
/// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据。
|
||||
#[derive(Clone)]
|
||||
pub struct InspectedPluginArtifact {
|
||||
pub metadata: PluginArtifactMetadata,
|
||||
pub archive: Arc<[u8]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct InstalledPluginArtifact {
|
||||
pub metadata: PluginArtifactMetadata,
|
||||
pub source: PluginSource,
|
||||
pub installed_at: chrono::DateTime<chrono::Utc>,
|
||||
pub accepted_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
pub struct PluginArtifactMutation {
|
||||
pub config_revision: Revision,
|
||||
pub artifact: InstalledPluginArtifact,
|
||||
}
|
||||
|
||||
pub struct PluginInstallResult {
|
||||
pub mutation: PluginArtifactMutation,
|
||||
pub default_instance_id: Option<String>,
|
||||
pub configuration_required: bool,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use super::super::Revision;
|
||||
|
||||
/// 编译进宿主二进制的发行身份;不能由运行时配置或插件清单覆盖。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OfficialPluginReleaseIdentity {
|
||||
pub gateway_version: String,
|
||||
pub gateway_git_sha: String,
|
||||
}
|
||||
|
||||
/// 启动时导入官方制品的结果;导入只持久化制品,不创建或启用实例。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OfficialPluginImport {
|
||||
pub artifacts: usize,
|
||||
pub config_revision: Option<Revision>,
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::Revision;
|
||||
|
||||
/// 已由 Runtime 校验的命名空间 schema 与配额事实。
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginStateSchema {
|
||||
pub namespace: String,
|
||||
pub schema_version: u32,
|
||||
pub schema_sha256: String,
|
||||
pub schema: serde_json::Value,
|
||||
pub maximum_records: u32,
|
||||
pub maximum_bytes: u64,
|
||||
pub maximum_value_bytes: u32,
|
||||
#[serde(default)]
|
||||
pub migrates_from: Vec<u32>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PluginStateSchema {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PluginStateSchema")
|
||||
.field("namespace", &self.namespace)
|
||||
.field("schema_version", &self.schema_version)
|
||||
.field("schema_sha256", &self.schema_sha256)
|
||||
.field("schema", &"[REDACTED]")
|
||||
.field("maximum_records", &self.maximum_records)
|
||||
.field("maximum_bytes", &self.maximum_bytes)
|
||||
.field("maximum_value_bytes", &self.maximum_value_bytes)
|
||||
.field("migrates_from", &self.migrates_from)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginStateConfiguration {
|
||||
#[serde(default)]
|
||||
pub namespaces: Vec<PluginStateSchema>,
|
||||
}
|
||||
|
||||
/// 与实例保存同事务提交的状态配置;transition 只引用已完成的 staging 数据。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PluginStateCommit {
|
||||
pub configuration: PluginStateConfiguration,
|
||||
pub transition_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginStateOwnerRequest {
|
||||
pub instance_id: String,
|
||||
pub artifact_sha256: String,
|
||||
pub instance_revision: Revision,
|
||||
pub configuration: PluginStateConfiguration,
|
||||
}
|
||||
|
||||
/// Store 签发的实例私有状态 fence。插件协议中从不传输这些字段。
|
||||
#[derive(Clone)]
|
||||
pub struct PluginStateOwner {
|
||||
instance_id: String,
|
||||
artifact_sha256: String,
|
||||
instance_revision: Revision,
|
||||
namespaces: BTreeMap<String, PluginStateNamespaceOwner>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginStateNamespaceOwner {
|
||||
generation_id: String,
|
||||
fence: String,
|
||||
schema_version: u32,
|
||||
}
|
||||
|
||||
impl PluginStateOwner {
|
||||
#[must_use]
|
||||
pub fn from_store(
|
||||
instance_id: String,
|
||||
artifact_sha256: String,
|
||||
instance_revision: Revision,
|
||||
namespaces: BTreeMap<String, PluginStateNamespaceOwner>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instance_id,
|
||||
artifact_sha256,
|
||||
instance_revision,
|
||||
namespaces,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn instance_id(&self) -> &str {
|
||||
&self.instance_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn artifact_sha256(&self) -> &str {
|
||||
&self.artifact_sha256
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn instance_revision(&self) -> Revision {
|
||||
self.instance_revision
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn namespace(&self, namespace: &str) -> Option<&PluginStateNamespaceOwner> {
|
||||
self.namespaces.get(namespace)
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginStateNamespaceOwner {
|
||||
#[must_use]
|
||||
pub fn from_store(generation_id: String, fence: String, schema_version: u32) -> Self {
|
||||
Self {
|
||||
generation_id,
|
||||
fence,
|
||||
schema_version,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn generation_id(&self) -> &str {
|
||||
&self.generation_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn fence(&self) -> &str {
|
||||
&self.fence
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn schema_version(&self) -> u32 {
|
||||
self.schema_version
|
||||
}
|
||||
}
|
||||
|
||||
/// 私有值可能含插件自己的敏感状态,故意不实现 `Debug`。
|
||||
#[derive(Clone)]
|
||||
pub struct PluginStateRecord {
|
||||
pub key: String,
|
||||
pub value: serde_json::Value,
|
||||
pub version: u64,
|
||||
pub schema_version: u32,
|
||||
}
|
||||
|
||||
pub struct PutPluginState {
|
||||
pub namespace: String,
|
||||
pub key: String,
|
||||
pub value: serde_json::Value,
|
||||
/// `None` 是 create-only;已有记录必须提交精确版本。
|
||||
pub expected_version: Option<u64>,
|
||||
}
|
||||
|
||||
pub struct DeletePluginState {
|
||||
pub namespace: String,
|
||||
pub key: String,
|
||||
pub expected_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PluginStateWrite {
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginStateTransition {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub artifact_sha256: String,
|
||||
pub namespaces: Vec<PluginStateMigrationNamespace>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginStateMigrationNamespace {
|
||||
pub namespace: String,
|
||||
pub from_schema_version: u32,
|
||||
pub to_schema_version: u32,
|
||||
}
|
||||
|
||||
pub struct PluginStateMigrationBatch {
|
||||
pub cursor: Option<String>,
|
||||
pub records: Vec<PluginStateRecord>,
|
||||
}
|
||||
|
||||
pub struct ApplyPluginStateMigration {
|
||||
pub transition_id: String,
|
||||
pub namespace: String,
|
||||
pub cursor: Option<String>,
|
||||
/// Runtime 刚读取的有序源键;Store 会在写入前重新读取并逐项比对。
|
||||
pub expected_keys: Vec<String>,
|
||||
pub changes: Vec<PluginStateMigrationChange>,
|
||||
}
|
||||
|
||||
pub struct PluginStateMigrationChange {
|
||||
pub key: String,
|
||||
pub action: PluginStateMigrationAction,
|
||||
}
|
||||
|
||||
pub enum PluginStateMigrationAction {
|
||||
Keep,
|
||||
Replace(serde_json::Value),
|
||||
Delete,
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use chrono::{DateTime, NaiveDate, Utc};
|
||||
use futures::Stream;
|
||||
use gateway_core::{
|
||||
account::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
|
||||
operation::RawJsonPayload,
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -15,7 +16,7 @@ use serde_json::{Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
AdminError, MutationActor, MutationContext, Revision,
|
||||
AdminError, MutationActor, MutationContext, PageSize, Revision,
|
||||
accounts::{
|
||||
AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState,
|
||||
},
|
||||
@@ -384,8 +385,8 @@ enum StoredAuthorizationOwnerV1 {
|
||||
System,
|
||||
}
|
||||
|
||||
/// 启动 Provider OAuth Authorization Code 流程。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Provider 授权请求。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct StartAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub name: String,
|
||||
@@ -425,8 +426,8 @@ pub trait AuthorizationCommitGuard: Send + 'static {
|
||||
|
||||
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者。
|
||||
pub enum PreparedAuthorizationCredential {
|
||||
Create(PreparedCredentialCreate),
|
||||
Reauthorize(PreparedCredentialRotation),
|
||||
Create(Box<PreparedCredentialCreate>),
|
||||
Reauthorize(Box<PreparedCredentialRotation>),
|
||||
}
|
||||
|
||||
/// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回。
|
||||
@@ -449,18 +450,87 @@ impl fmt::Debug for PreparedAuthorizationCommit {
|
||||
/// Store 可持久化的 OAuth credential facts,不携带 Provider guard。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthorizationCredentialCommit {
|
||||
Create(PreparedCredentialCreate),
|
||||
Reauthorize(PreparedCredentialRotationFacts),
|
||||
Create(Box<PreparedCredentialCreate>),
|
||||
Reauthorize(Box<PreparedCredentialRotationFacts>),
|
||||
}
|
||||
|
||||
/// Admin 交给 Store 的 OAuth 原子事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AuthorizationCommit {
|
||||
pub key: AuthorizationReceiptKey,
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub pending: PendingAuthorizationMutation,
|
||||
pub credential: AuthorizationCredentialCommit,
|
||||
}
|
||||
|
||||
/// 回执以 Provider、授权流和管理员身份绑定;不持久化原始 flow 或回调材料。
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct AuthorizationReceiptKey {
|
||||
provider_kind: ProviderKind,
|
||||
flow_digest: String,
|
||||
owner_digest: String,
|
||||
}
|
||||
|
||||
impl AuthorizationReceiptKey {
|
||||
pub fn new(
|
||||
provider_kind: ProviderKind,
|
||||
flow: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Self, AdminError> {
|
||||
use sha2::{Digest as _, Sha256};
|
||||
if flow.trim().is_empty() || flow.len() > 1024 || flow.chars().any(char::is_control) {
|
||||
return Err(AdminError::invalid("授权流程标识无效"));
|
||||
}
|
||||
Ok(Self {
|
||||
provider_kind,
|
||||
flow_digest: hex::encode(Sha256::digest(flow.as_bytes())),
|
||||
owner_digest: authorization_owner_digest(context),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.provider_kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn flow_digest(&self) -> &str {
|
||||
&self.flow_digest
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn owner_digest(&self) -> &str {
|
||||
&self.owner_digest
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn matches_context(&self, context: &MutationContext) -> bool {
|
||||
authorization_owner_digest(context) == self.owner_digest
|
||||
}
|
||||
}
|
||||
|
||||
fn authorization_owner_digest(context: &MutationContext) -> String {
|
||||
use sha2::{Digest as _, Sha256};
|
||||
let owner = match &context.actor {
|
||||
MutationActor::AdminSession { admin_user_id } => format!("admin_session:{admin_user_id}"),
|
||||
MutationActor::AdminApiKey => "admin_api_key".into(),
|
||||
MutationActor::System => "system".into(),
|
||||
};
|
||||
hex::encode(Sha256::digest(owner.as_bytes()))
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizationReceiptKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("AuthorizationReceiptKey([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthorizationCommitResult {
|
||||
pub result: CredentialMutationResult,
|
||||
pub newly_committed: bool,
|
||||
}
|
||||
|
||||
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard。
|
||||
///
|
||||
/// Store 成功时先结束 credential guard,再消费 OAuth claim;失败时丢弃 credential guard 并
|
||||
@@ -494,6 +564,7 @@ impl PreparedAuthorizationCommit {
|
||||
pub(crate) fn into_commit(
|
||||
self,
|
||||
settings: Option<AccountImportSettings>,
|
||||
key: AuthorizationReceiptKey,
|
||||
) -> AuthorizationCommitSettlement {
|
||||
let (credential, guard) = match self.credential {
|
||||
PreparedAuthorizationCredential::Create(credential) => {
|
||||
@@ -502,13 +573,14 @@ impl PreparedAuthorizationCommit {
|
||||
PreparedAuthorizationCredential::Reauthorize(prepared) => {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
(
|
||||
AuthorizationCredentialCommit::Reauthorize(facts),
|
||||
AuthorizationCredentialCommit::Reauthorize(Box::new(facts)),
|
||||
Some(guard),
|
||||
)
|
||||
}
|
||||
};
|
||||
AuthorizationCommitSettlement {
|
||||
command: AuthorizationCommit {
|
||||
key,
|
||||
settings,
|
||||
pending: self.pending,
|
||||
credential,
|
||||
@@ -519,13 +591,8 @@ impl PreparedAuthorizationCommit {
|
||||
}
|
||||
|
||||
pub(crate) async fn abort(self) -> Result<(), AdminError> {
|
||||
let AuthorizationCommitSettlement {
|
||||
credential_guard,
|
||||
authorization_guard,
|
||||
..
|
||||
} = self.into_commit(None);
|
||||
drop(credential_guard);
|
||||
if let Some(guard) = authorization_guard {
|
||||
drop(self.credential);
|
||||
if let Some(guard) = self.authorization_guard {
|
||||
guard.abort().await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -616,6 +683,8 @@ pub struct PreparedCredentialRotationFacts {
|
||||
pub plan_type: Option<String>,
|
||||
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐。
|
||||
pub preserve_profile: bool,
|
||||
/// 连接配置变更保留当前凭据健康状态与错误事实。
|
||||
pub preserve_credential_state: bool,
|
||||
pub provider_material: ProviderDocument,
|
||||
pub has_refresh_token: bool,
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
@@ -637,6 +706,13 @@ impl PreparedCredentialRotation {
|
||||
Self { facts, guard }
|
||||
}
|
||||
|
||||
/// 仅修改连接配置时,不把配置更新视为凭据恢复成功。
|
||||
#[must_use]
|
||||
pub fn preserving_credential_state(mut self) -> Self {
|
||||
self.facts.preserve_credential_state = true;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn facts(&self) -> &PreparedCredentialRotationFacts {
|
||||
&self.facts
|
||||
@@ -1060,6 +1136,62 @@ pub struct ProviderModels {
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Provider 原生模型目录正文;wire 语义由对应 Provider 拥有,公共层只搬运不解释字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderModelCatalogDocument {
|
||||
pub document: RawJsonPayload,
|
||||
pub model_count: usize,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 插件账号回调的服务端过滤条件;Provider 缺省时跨全部已注册类型分页。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginAccountListQuery {
|
||||
pub provider_kind: Option<ProviderKind>,
|
||||
pub cursor: Option<ProviderAccountId>,
|
||||
pub limit: PageSize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginAccountPage {
|
||||
pub accounts: Vec<AccountRecord>,
|
||||
pub next_cursor: Option<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// 原始凭据与当前账号 revision 的同一读取结果。
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct PluginAccountCredential {
|
||||
pub account: AccountRecord,
|
||||
pub provider_material: ProviderDocument,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PluginAccountCredential {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PluginAccountCredential")
|
||||
.field("account", &self.account)
|
||||
.field("provider_material", &self.provider_material)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime 已完成 wire 校验、Admin 仍需绑定当前账号和 revision 的 prepared facts。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PreparedPluginAccountSave {
|
||||
Create(PreparedCredentialCreate),
|
||||
Replace {
|
||||
facts: PreparedCredentialRotationFacts,
|
||||
authentication_kind: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginAccountSaveResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_id: ProviderAccountId,
|
||||
pub credential_revision: Revision,
|
||||
}
|
||||
|
||||
/// Provider 执行 refresh 时所需的当前公共账号事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PrepareCredentialRefresh {
|
||||
@@ -1095,6 +1227,7 @@ impl fmt::Debug for ProviderExport {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
pub capabilities: super::accounts::ProviderAccountCapabilities,
|
||||
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
|
||||
pub plan_type_display: Option<String>,
|
||||
pub projection: gateway_core::account::AccountStatusProjection,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use gateway_core::account::{OutboundProxy, RequestLocation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{PageSize, Revision, account_groups::AccountGroupRef};
|
||||
|
||||
@@ -57,6 +58,7 @@ pub struct ProxyAccountPage {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProxyTestResult {
|
||||
pub location: ProxyLocationDetection,
|
||||
pub success: bool,
|
||||
pub latency_ms: u64,
|
||||
pub exit_ip: Option<std::net::IpAddr>,
|
||||
@@ -67,6 +69,8 @@ pub struct ProxyTestResult {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyRecord {
|
||||
pub auto_location: bool,
|
||||
pub detected_location: Option<DetectedProxyLocation>,
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -89,6 +93,9 @@ pub struct ProxyPage {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProxy {
|
||||
pub auto_location: bool,
|
||||
/// 由管理用例探测,随配置原子保存,不接受 HTTP 调用方提供结果。
|
||||
pub test: Option<ProxyTestResult>,
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
pub name: String,
|
||||
pub proxy: OutboundProxy,
|
||||
@@ -96,6 +103,8 @@ pub struct NewProxy {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateProxy {
|
||||
pub auto_location: Option<bool>,
|
||||
pub test: Option<ProxyTestResult>,
|
||||
/// 外层为空保留配置,内层为空恢复全局继承。
|
||||
pub location: Option<Option<gateway_core::account::RequestLocation>>,
|
||||
pub id: String,
|
||||
@@ -109,3 +118,62 @@ pub struct ProxyMutation {
|
||||
pub config_revision: Revision,
|
||||
pub record: ProxyRecord,
|
||||
}
|
||||
|
||||
/// 检测失败与连通性分开,双栈冲突不得继续应用旧位置。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum ProxyLocationDetection {
|
||||
#[default]
|
||||
NotRequested,
|
||||
Detected {
|
||||
location: RequestLocation,
|
||||
},
|
||||
Failed {
|
||||
message: String,
|
||||
},
|
||||
Conflict,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DetectedProxyLocation {
|
||||
pub location: RequestLocation,
|
||||
pub exit_ipv4: Option<std::net::Ipv4Addr>,
|
||||
pub exit_ipv6: Option<std::net::Ipv6Addr>,
|
||||
pub detected_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ProxyRecord {
|
||||
#[must_use]
|
||||
pub fn effective_location(&self) -> Option<&RequestLocation> {
|
||||
if self.auto_location {
|
||||
self.detected_location
|
||||
.as_ref()
|
||||
.map(|detected| &detected.location)
|
||||
} else {
|
||||
self.location.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// 未知出口可暂用上次成功结果,已知出口变化或时区冲突必须撤销旧覆盖。
|
||||
#[must_use]
|
||||
pub fn detected_location_after_test(
|
||||
&self,
|
||||
result: &ProxyTestResult,
|
||||
) -> Option<DetectedProxyLocation> {
|
||||
match &result.location {
|
||||
ProxyLocationDetection::Detected { location } => Some(DetectedProxyLocation {
|
||||
location: location.clone(),
|
||||
exit_ipv4: result.exit_ipv4,
|
||||
exit_ipv6: result.exit_ipv6,
|
||||
detected_at: Utc::now(),
|
||||
}),
|
||||
ProxyLocationDetection::Failed { .. } => self.detected_location.clone().filter(|old| {
|
||||
!result.success
|
||||
|| (old.exit_ipv4 == result.exit_ipv4 && old.exit_ipv6 == result.exit_ipv6)
|
||||
}),
|
||||
ProxyLocationDetection::Conflict => None,
|
||||
ProxyLocationDetection::NotRequested => self.detected_location.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,19 @@ pub struct QuotaForecastSource {
|
||||
pub usd: Option<f64>,
|
||||
}
|
||||
|
||||
/// 为目标周期选择唯一的预测源窗口;缺少同周期窗口时复用最短的可统计窗口。
|
||||
///
|
||||
/// Admin 的历史查询与最终投影必须共用这里的顺序,避免查询不会进入响应的窗口。
|
||||
pub(crate) fn quota_forecast_source_window(
|
||||
quota: &ProviderQuota,
|
||||
period: AccountUsagePeriod,
|
||||
) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
|
||||
quota
|
||||
.usage_windows()
|
||||
.find(|(_, source_period)| *source_period == period)
|
||||
.or_else(|| quota.usage_windows().min_by_key(|(_, period)| *period))
|
||||
}
|
||||
|
||||
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。
|
||||
/// 本地日志不能证明站外消耗或完整留存,因此即使样本充足也不声称官方额度。
|
||||
#[must_use]
|
||||
@@ -53,10 +66,7 @@ pub fn account_quota_forecasts(
|
||||
samples: &[QuotaForecastSample],
|
||||
) -> [AccountQuotaForecast; 2] {
|
||||
[AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly].map(|period| {
|
||||
let selected = quota
|
||||
.usage_windows()
|
||||
.find(|(_, source_period)| *source_period == period)
|
||||
.or_else(|| quota.usage_windows().min_by_key(|(_, period)| *period));
|
||||
let selected = quota_forecast_source_window(quota, period);
|
||||
let mut forecast = AccountQuotaForecast {
|
||||
period,
|
||||
target_seconds: match period {
|
||||
@@ -108,7 +118,7 @@ impl AccountQuotaForecast {
|
||||
let percent = window
|
||||
.used_percent
|
||||
.filter(|p| p.is_finite() && (0.0..=100.0).contains(p));
|
||||
let usage = sample.map(|sample| &sample.usage);
|
||||
let usage = sample.map(|sample| &sample.cycle_usage);
|
||||
let usd = usage
|
||||
.filter(|usage| usage.known_cost_count > 0)
|
||||
.map(|usage| usage.usd)
|
||||
@@ -175,14 +185,21 @@ impl AccountQuotaForecast {
|
||||
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
|
||||
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
|
||||
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
|
||||
let capacity_factor = 100.0 / sample.sampled_percent;
|
||||
let factor = capacity_factor * self.target_seconds as f64 / seconds as f64;
|
||||
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
|
||||
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
|
||||
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
|
||||
// 已发生的用量保持本周期累计,只把近期消耗比例用于尚未使用的额度。
|
||||
let factor = self.target_seconds as f64 / seconds as f64;
|
||||
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
|
||||
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
|
||||
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
|
||||
let remaining_tokens = Some(sample.usage.tokens)
|
||||
.filter(|tokens| *tokens > 0)
|
||||
.and_then(|value| estimate(value as f64, remaining_factor));
|
||||
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
|
||||
self.estimated_tokens = remaining_tokens
|
||||
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
|
||||
self.remaining_usd = Some(&sample.usage)
|
||||
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
|
||||
.and_then(|usage| estimate(usage.usd, remaining_factor));
|
||||
self.estimated_usd = usd
|
||||
.zip(self.remaining_usd)
|
||||
.and_then(|(used, remaining)| estimate(used + remaining, factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
|
||||
@@ -197,8 +214,8 @@ fn estimate(value: f64, factor: f64) -> Option<f64> {
|
||||
(estimate.is_finite() && estimate >= 0.0).then_some(estimate)
|
||||
}
|
||||
|
||||
fn estimate_tokens(value: u64, factor: f64) -> Option<u64> {
|
||||
estimate(value as f64, factor)
|
||||
fn estimate_tokens(value: f64, factor: f64) -> Option<u64> {
|
||||
estimate(value, factor)
|
||||
.filter(|value| value.round() < u64::MAX as f64)
|
||||
.map(|value| value.round() as u64)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ pub struct QuotaForecastSample {
|
||||
pub observation_count: usize,
|
||||
pub pending_request_count: u64,
|
||||
pub discontinuous: bool,
|
||||
/// 本周期已记录用量;近期采样仅用于预测剩余量,不能覆盖这份累计值。
|
||||
pub cycle_usage: QuotaForecastUsage,
|
||||
pub usage: QuotaForecastUsage,
|
||||
}
|
||||
|
||||
@@ -113,12 +115,13 @@ pub fn select_forecast_sample(
|
||||
current: QuotaForecastPoint,
|
||||
mut history: Vec<QuotaForecastPoint>,
|
||||
pending_request_count: u64,
|
||||
interrupted: bool,
|
||||
) -> QuotaForecastSample {
|
||||
history.retain(|point| {
|
||||
start_at <= point.observed_at
|
||||
&& point.observed_at < current.observed_at
|
||||
&& point.used_percent.is_finite()
|
||||
&& (0.0..100.0).contains(&point.used_percent)
|
||||
&& (0.0..=100.0).contains(&point.used_percent)
|
||||
});
|
||||
history.sort_by_key(|point| point.observed_at);
|
||||
let mut sample = QuotaForecastSample {
|
||||
@@ -131,10 +134,13 @@ pub fn select_forecast_sample(
|
||||
block_count: 0,
|
||||
observation_count: history.len(),
|
||||
pending_request_count,
|
||||
discontinuous: false,
|
||||
discontinuous: interrupted,
|
||||
cycle_usage: current.usage.clone(),
|
||||
usage: current.usage.clone(),
|
||||
};
|
||||
history.push(current);
|
||||
// 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量。
|
||||
let mut cycle_baseline = interrupted.then(|| &history[0]);
|
||||
let mut anchors: Vec<&QuotaForecastPoint> = Vec::new();
|
||||
let mut high_water: Option<&QuotaForecastPoint> = None;
|
||||
for point in &history {
|
||||
@@ -149,6 +155,7 @@ pub fn select_forecast_sample(
|
||||
}
|
||||
sample.discontinuous = true;
|
||||
anchors.clear();
|
||||
cycle_baseline = Some(point);
|
||||
}
|
||||
}
|
||||
high_water = Some(point);
|
||||
@@ -162,6 +169,13 @@ pub fn select_forecast_sample(
|
||||
let Some(current) = history.last() else {
|
||||
return sample;
|
||||
};
|
||||
if let Some(baseline) = cycle_baseline {
|
||||
let Some(usage) = current.usage.difference(&baseline.usage) else {
|
||||
sample.discontinuous = true;
|
||||
return sample;
|
||||
};
|
||||
sample.cycle_usage = usage;
|
||||
}
|
||||
if anchors.len() < 2 {
|
||||
return sample;
|
||||
}
|
||||
|
||||
@@ -4,21 +4,28 @@ use std::{collections::BTreeMap, fmt};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
use gateway_core::routing::{ProviderKind, PublicModelId, UpstreamModelId};
|
||||
|
||||
use super::Revision;
|
||||
|
||||
/// 客户端模型到上游模型的全局精确映射。
|
||||
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
|
||||
|
||||
/// Provider-owned 请求画像选择;Provider ID 是唯一命名空间。
|
||||
pub type ProviderRequestProfiles =
|
||||
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
|
||||
|
||||
/// Provider-owned 请求画像的部分更新;`None` 只表示显式删除该 Provider 的历史选择。
|
||||
pub type ProviderRequestProfileUpdates =
|
||||
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
|
||||
|
||||
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射。
|
||||
pub use gateway_core::account::RotationStrategy;
|
||||
|
||||
/// 完整运行设置事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeSettings {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub request_profiles: ProviderRequestProfiles,
|
||||
pub config_revision: Revision,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
@@ -44,14 +51,17 @@ pub struct RuntimeSettings {
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 原子替换运行设置的命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
/// 只覆盖提交的 Provider;未提交项保留当前持久值。
|
||||
pub request_profile_updates: ProviderRequestProfileUpdates,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
@@ -76,6 +86,9 @@ pub struct ReplaceRuntimeSettings {
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
}
|
||||
|
||||
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
|
||||
@@ -134,10 +147,8 @@ impl RuntimeSettings {
|
||||
&self,
|
||||
provider: &str,
|
||||
) -> Option<&gateway_core::account::OpaqueProviderData> {
|
||||
match provider {
|
||||
"openai" => self.openai_client_profile.as_ref(),
|
||||
"xai" => self.xai_client_profile.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
ProviderKind::new(provider.to_owned())
|
||||
.ok()
|
||||
.and_then(|provider| self.request_profiles.get(&provider))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ pub struct SystemOperationState {
|
||||
pub struct SystemUpdateStatus {
|
||||
pub previous_version: Option<String>,
|
||||
pub current_version: Option<String>,
|
||||
pub need_restart: bool,
|
||||
pub operation: SystemOperationState,
|
||||
}
|
||||
|
||||
@@ -103,7 +104,6 @@ pub enum SystemOperationAccepted {
|
||||
operation_id: String,
|
||||
deployment_mode: String,
|
||||
message: String,
|
||||
need_restart: bool,
|
||||
target_version: String,
|
||||
},
|
||||
Rollback {
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod plugin_accounts;
|
||||
pub mod plugin_client_keys;
|
||||
pub mod plugin_management;
|
||||
pub mod plugin_release;
|
||||
pub mod plugins;
|
||||
pub mod pricing;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! 插件宿主访问账号权威所需的窄端口。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::account::ProviderAccountId;
|
||||
|
||||
use crate::model::{
|
||||
AdminError, MutationContext,
|
||||
provider_credentials::{
|
||||
PluginAccountCredential, PluginAccountListQuery, PluginAccountPage,
|
||||
PluginAccountSaveResult, PreparedPluginAccountSave,
|
||||
},
|
||||
};
|
||||
|
||||
/// Runtime 只取得账号读写用例,不接触 Store、SQL 或完整管理服务集合。
|
||||
#[async_trait]
|
||||
pub trait PluginAccountAccess: Send + Sync {
|
||||
async fn list(&self, query: PluginAccountListQuery) -> Result<PluginAccountPage, AdminError>;
|
||||
|
||||
async fn get_runtime(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::accounts::AccountRecord, AdminError>;
|
||||
|
||||
async fn get_credential(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<PluginAccountCredential, AdminError>;
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
command: PreparedPluginAccountSave,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginAccountSaveResult, AdminError>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! 插件发现 Client Key 公开身份的窄端口。
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::model::{
|
||||
AdminError,
|
||||
plugin_client_keys::{PluginClientKeyListQuery, PluginClientKeyPage},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginClientKeyAccess: Send + Sync {
|
||||
async fn list(
|
||||
&self,
|
||||
query: PluginClientKeyListQuery,
|
||||
) -> Result<PluginClientKeyPage, AdminError>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::runtime::extensions::ExtensionSetReference;
|
||||
|
||||
use crate::model::{
|
||||
AdminError,
|
||||
plugins::management::{
|
||||
PluginManagementRequest, PluginManagementResponse, PluginManagementTarget,
|
||||
PluginManagementView,
|
||||
},
|
||||
};
|
||||
|
||||
/// 仅按 Admin 固定的发布引用分派;Runtime 不自行选择当前代次,也不拥有管理员身份。
|
||||
#[async_trait]
|
||||
pub trait PluginManagement: Send + Sync {
|
||||
/// 复核固定发布代次中的模型访问域;调用方已校验持久实例仍为当前目标。
|
||||
async fn authorize_models(
|
||||
&self,
|
||||
published: &ExtensionSetReference,
|
||||
target: &PluginManagementTarget,
|
||||
) -> Result<(), AdminError>;
|
||||
|
||||
async fn start_callback(
|
||||
&self,
|
||||
published: &ExtensionSetReference,
|
||||
target: &PluginManagementTarget,
|
||||
command: crate::model::plugins::management::StartPluginManagementCallback,
|
||||
context: &crate::model::auth::AdminRequestContext,
|
||||
) -> Result<crate::model::plugins::management::PluginManagementCallbackTicket, AdminError>;
|
||||
|
||||
async fn callback(
|
||||
&self,
|
||||
published: &ExtensionSetReference,
|
||||
target: &PluginManagementTarget,
|
||||
state: &str,
|
||||
request: PluginManagementRequest,
|
||||
) -> Result<PluginManagementResponse, AdminError>;
|
||||
|
||||
async fn views(
|
||||
&self,
|
||||
published: &ExtensionSetReference,
|
||||
) -> Result<Vec<PluginManagementView>, AdminError>;
|
||||
|
||||
async fn resource(
|
||||
&self,
|
||||
published: &ExtensionSetReference,
|
||||
target: &PluginManagementTarget,
|
||||
path: &str,
|
||||
public: bool,
|
||||
) -> Result<PluginManagementResponse, AdminError>;
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
published: &ExtensionSetReference,
|
||||
target: &PluginManagementTarget,
|
||||
request: PluginManagementRequest,
|
||||
) -> Result<PluginManagementResponse, AdminError>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OfficialPluginReleaseReadErrorKind {
|
||||
Invalid,
|
||||
NotFound,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// 文件系统错误不携带部署路径,避免把宿主布局传播到管理域和日志之外。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("official plugin release files are unavailable")]
|
||||
pub struct OfficialPluginReleaseReadError {
|
||||
kind: OfficialPluginReleaseReadErrorKind,
|
||||
}
|
||||
|
||||
impl OfficialPluginReleaseReadError {
|
||||
#[must_use]
|
||||
pub const fn new(kind: OfficialPluginReleaseReadErrorKind) -> Self {
|
||||
Self { kind }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(self) -> OfficialPluginReleaseReadErrorKind {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
/// Host 只暴露与宿主发行物同信任边界的只读文件,不解释官方身份或插件包。
|
||||
#[async_trait]
|
||||
pub trait OfficialPluginReleaseFiles: Send + Sync {
|
||||
async fn manifest(&self) -> Result<Option<Arc<[u8]>>, OfficialPluginReleaseReadError>;
|
||||
|
||||
async fn artifact(&self, file_name: &str) -> Result<Arc<[u8]>, OfficialPluginReleaseReadError>;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::plugins::distribution::{
|
||||
DownloadedPlugin, GithubReleaseQuery, PluginDistributionEgress, PluginRelease,
|
||||
PluginSourceBinding, PluginUpdateSource, RemotePluginLocation, SourceCredential,
|
||||
SourceCredentialInfo,
|
||||
};
|
||||
use crate::model::plugins::instances::{
|
||||
PluginInstance, PluginInstanceMutation, PluginInstanceReplacement, PluginInstanceRuntime,
|
||||
PluginInstanceSnapshot, PluginVersionConfiguration,
|
||||
};
|
||||
use crate::model::plugins::state::{
|
||||
ApplyPluginStateMigration, DeletePluginState, PluginStateCommit, PluginStateConfiguration,
|
||||
PluginStateMigrationBatch, PluginStateOwner, PluginStateOwnerRequest, PluginStateRecord,
|
||||
PluginStateTransition, PluginStateWrite, PutPluginState,
|
||||
};
|
||||
use crate::model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
plugins::{
|
||||
InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMutation,
|
||||
PluginCompatibilityRequirements, PluginSource,
|
||||
},
|
||||
};
|
||||
use gateway_core::runtime::extensions::ExtensionSetReference;
|
||||
|
||||
/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态。
|
||||
#[async_trait]
|
||||
pub trait PluginPreparation: Send + Sync {
|
||||
/// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置。
|
||||
async fn configuration_ready(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
metadata: &crate::model::plugins::PluginArtifactMetadata,
|
||||
) -> Result<bool, AdminError>;
|
||||
async fn validate(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
) -> Result<PluginStateConfiguration, AdminError>;
|
||||
/// 本次修改的实例 revision 必须等于候选 config_revision,准备失败时拒绝提交;历史故障可隔离。
|
||||
async fn prepare(
|
||||
&self,
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
) -> Result<ExtensionSetReference, AdminError>;
|
||||
/// 返回当前进程的只读运行事实;缺省实现让不托管插件进程的组合保持兼容。
|
||||
async fn runtime_diagnostics(
|
||||
&self,
|
||||
_snapshot: &PluginInstanceSnapshot,
|
||||
_published_revision: Option<u64>,
|
||||
_published: Option<&ExtensionSetReference>,
|
||||
) -> Option<BTreeMap<String, PluginInstanceRuntime>> {
|
||||
None
|
||||
}
|
||||
async fn activate_state(
|
||||
&self,
|
||||
_prepared: &ExtensionSetReference,
|
||||
_instance: &PluginInstance,
|
||||
) -> Result<(), AdminError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn quiesce_instance(
|
||||
&self,
|
||||
_instance_id: &str,
|
||||
_artifact_sha256: &str,
|
||||
_revision: Revision,
|
||||
) {
|
||||
}
|
||||
async fn migrate_state(
|
||||
&self,
|
||||
_prepared: &ExtensionSetReference,
|
||||
_transition: PluginStateTransition,
|
||||
) -> Result<(), AdminError> {
|
||||
Err(AdminError::unavailable("插件状态迁移运行时不可用"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PluginStateStoreErrorKind {
|
||||
Invalid,
|
||||
NotFound,
|
||||
Conflict,
|
||||
Quota,
|
||||
PermissionDenied,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("plugin state operation failed")]
|
||||
pub struct PluginStateStoreError {
|
||||
kind: PluginStateStoreErrorKind,
|
||||
}
|
||||
|
||||
impl PluginStateStoreError {
|
||||
#[must_use]
|
||||
pub const fn new(kind: PluginStateStoreErrorKind) -> Self {
|
||||
Self { kind }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> PluginStateStoreErrorKind {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
pub type PluginStateStoreResult<T> = Result<T, PluginStateStoreError>;
|
||||
|
||||
/// 私有状态的窄端口;不暴露 SQL、连接或跨命名空间事务。
|
||||
#[async_trait]
|
||||
pub trait PluginStateStore: Send + Sync {
|
||||
async fn load_owner(
|
||||
&self,
|
||||
request: PluginStateOwnerRequest,
|
||||
) -> PluginStateStoreResult<Option<PluginStateOwner>>;
|
||||
async fn get(
|
||||
&self,
|
||||
owner: &PluginStateOwner,
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
) -> PluginStateStoreResult<Option<PluginStateRecord>>;
|
||||
async fn put(
|
||||
&self,
|
||||
owner: &PluginStateOwner,
|
||||
command: PutPluginState,
|
||||
) -> PluginStateStoreResult<PluginStateWrite>;
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: &PluginStateOwner,
|
||||
command: DeletePluginState,
|
||||
) -> PluginStateStoreResult<bool>;
|
||||
async fn transition_required(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
target: &PluginStateConfiguration,
|
||||
) -> PluginStateStoreResult<bool>;
|
||||
async fn begin_transition(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
expected_instance_revision: Revision,
|
||||
artifact_sha256: &str,
|
||||
target: PluginStateConfiguration,
|
||||
) -> PluginStateStoreResult<PluginStateTransition>;
|
||||
async fn migration_batch(
|
||||
&self,
|
||||
transition_id: &str,
|
||||
namespace: &str,
|
||||
maximum_records: u32,
|
||||
) -> PluginStateStoreResult<PluginStateMigrationBatch>;
|
||||
async fn apply_migration_batch(
|
||||
&self,
|
||||
command: ApplyPluginStateMigration,
|
||||
) -> PluginStateStoreResult<()>;
|
||||
async fn abort_transition(&self, transition_id: &str) -> PluginStateStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Host 拥有联网、重定向、凭据作用域、限流与有界下载,不解释插件包。
|
||||
#[async_trait]
|
||||
pub trait PluginDistribution: Send + Sync {
|
||||
fn validate_source(&self, source: &PluginUpdateSource) -> Result<(), AdminError>;
|
||||
fn validate_credential(&self, credential: &SourceCredential) -> Result<(), AdminError>;
|
||||
async fn query_release(
|
||||
&self,
|
||||
query: GithubReleaseQuery,
|
||||
credentials: Vec<SourceCredential>,
|
||||
egress: Option<PluginDistributionEgress>,
|
||||
) -> Result<PluginRelease, AdminError>;
|
||||
async fn download(
|
||||
&self,
|
||||
location: RemotePluginLocation,
|
||||
credentials: Vec<SourceCredential>,
|
||||
egress: Option<PluginDistributionEgress>,
|
||||
) -> Result<DownloadedPlugin, AdminError>;
|
||||
}
|
||||
|
||||
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin。
|
||||
#[async_trait]
|
||||
pub trait PluginPackageInspector: Send + Sync {
|
||||
/// 当前宿主对权限标识的说明,供展示投影使用,不属于不可变制品元数据。
|
||||
fn permission_descriptions(
|
||||
&self,
|
||||
permissions: &[String],
|
||||
) -> Vec<crate::model::plugins::PluginPermissionDescription> {
|
||||
permissions
|
||||
.iter()
|
||||
.map(
|
||||
|permission| crate::model::plugins::PluginPermissionDescription {
|
||||
permission: permission.clone(),
|
||||
label: permission.clone(),
|
||||
description: String::new(),
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn inspect(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
expected_sha256: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError>;
|
||||
|
||||
/// 从已安装且摘要匹配的制品中读取可展示的静态图标,不启动插件实例。
|
||||
async fn icon(
|
||||
&self,
|
||||
_archive: Arc<[u8]>,
|
||||
_expected_sha256: String,
|
||||
_theme: crate::model::plugins::PluginIconTheme,
|
||||
) -> Result<Option<crate::model::plugins::PluginArtifactIconResource>, AdminError> {
|
||||
Err(AdminError::unavailable("插件图标读取不可用"))
|
||||
}
|
||||
|
||||
/// 只读取已校验包的静态宿主要求;不得准备或启动插件进程。
|
||||
async fn compatibility(
|
||||
&self,
|
||||
_archive: Arc<[u8]>,
|
||||
_expected_sha256: String,
|
||||
) -> Result<PluginCompatibilityRequirements, AdminError> {
|
||||
Err(AdminError::unavailable("插件兼容性检查不可用"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginStore: Send + Sync {
|
||||
/// 只复核精确管理目标的启用、接受事实和版本,不读取实例配置或密钥。
|
||||
async fn management_target_is_current(
|
||||
&self,
|
||||
target: &crate::model::plugins::management::PluginManagementTarget,
|
||||
) -> AdminStoreResult<bool>;
|
||||
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot>;
|
||||
/// 读取对应制品最近一次启用时提交的配置,密钥始终留在服务端。
|
||||
async fn load_version_configuration(
|
||||
&self,
|
||||
_id: &str,
|
||||
_digest: &str,
|
||||
) -> AdminStoreResult<Option<PluginVersionConfiguration>> {
|
||||
Ok(None)
|
||||
}
|
||||
/// 只返回有可恢复配置的制品摘要,不读取敏感值。
|
||||
async fn configuration_versions(&self, _id: &str) -> AdminStoreResult<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn save_instance(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
expected_revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<PluginInstanceMutation>;
|
||||
async fn save_instance_with_state(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
expected_revision: Revision,
|
||||
state: PluginStateCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<PluginInstanceMutation> {
|
||||
if !state.configuration.namespaces.is_empty() || state.transition_id.is_some() {
|
||||
return Err(super::store::AdminStoreError::new(
|
||||
super::store::AdminStoreErrorKind::Unavailable,
|
||||
"plugin state",
|
||||
"plugin state store is unavailable",
|
||||
));
|
||||
}
|
||||
self.save_instance(instance, expected_revision, context)
|
||||
.await
|
||||
}
|
||||
/// 保存目标并停用明确确认的配置,必须共享事务和配置版本检查。
|
||||
async fn save_instance_replacing(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
expected_revision: Revision,
|
||||
state: PluginStateCommit,
|
||||
replacements: &[PluginInstanceReplacement],
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<PluginInstanceMutation> {
|
||||
if !replacements.is_empty() {
|
||||
return Err(super::store::AdminStoreError::new(
|
||||
super::store::AdminStoreErrorKind::Unavailable,
|
||||
"plugin",
|
||||
"atomic plugin configuration switching is unavailable",
|
||||
));
|
||||
}
|
||||
self.save_instance_with_state(instance, expected_revision, state, context)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_instance(
|
||||
&self,
|
||||
id: &str,
|
||||
expected_revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>>;
|
||||
async fn change_update_source(
|
||||
&self,
|
||||
binding: PluginSourceBinding,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>>;
|
||||
async fn load_source_credential(&self, id: &str) -> AdminStoreResult<SourceCredential>;
|
||||
async fn save_source_credential(
|
||||
&self,
|
||||
credential: SourceCredential,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
async fn delete_source_credential(
|
||||
&self,
|
||||
id: &str,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>>;
|
||||
async fn load_artifact(&self, digest: &str) -> AdminStoreResult<InspectedPluginArtifact>;
|
||||
async fn install_artifact(
|
||||
&self,
|
||||
artifact: InspectedPluginArtifact,
|
||||
source: PluginSource,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation>;
|
||||
/// 接受不可变制品声明的全部能力域;接受事实不随实例设置变化。
|
||||
async fn accept_artifact(
|
||||
&self,
|
||||
digest: &str,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation>;
|
||||
/// 删除制品及其无引用下载凭据,最后一个版本同时删除来源规则,清理与审计必须原子提交。
|
||||
async fn delete_artifact(
|
||||
&self,
|
||||
digest: &str,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Provider 管理能力与动态注册表。
|
||||
//! 原生 Provider 管理能力与启动时注册表。
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
@@ -15,9 +15,9 @@ use crate::model::observability::{
|
||||
};
|
||||
use crate::model::provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
|
||||
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModelCatalogDocument, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
|
||||
};
|
||||
@@ -117,6 +117,15 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
|
||||
fn provider_kind(&self) -> &ProviderKind;
|
||||
|
||||
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询。
|
||||
fn account_capabilities(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
_authentication_kind: &str,
|
||||
) -> crate::model::accounts::ProviderAccountCapabilities {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。
|
||||
fn client_profile_options(
|
||||
&self,
|
||||
@@ -124,6 +133,11 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 没有持久选择时使用的 Provider 默认画像;只返回已准备的本地事实。
|
||||
fn default_client_profile(&self) -> Option<gateway_core::account::OpaqueProviderData> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 校验并投影客户端身份,结果不含认证或账号材料。
|
||||
fn preview_client_profile(
|
||||
&self,
|
||||
@@ -150,7 +164,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
async fn account_facts_changed(&self, _account_ids: &[ProviderAccountId]) {}
|
||||
|
||||
/// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账。
|
||||
fn connection_test_operation(
|
||||
async fn connection_test_operation(
|
||||
&self,
|
||||
upstream_model: &UpstreamModelId,
|
||||
input_text: &str,
|
||||
@@ -180,7 +194,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
pending: PendingAuthorizationMutation,
|
||||
pending: crate::model::provider_credentials::PendingAuthorizationMutation,
|
||||
) -> Result<AuthorizationStarted, ProviderAdminError>;
|
||||
|
||||
async fn complete_authorization(
|
||||
@@ -266,20 +280,27 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
refresh: bool,
|
||||
) -> Result<ProviderModels, ProviderAdminError>;
|
||||
|
||||
/// 导出该账号的 Provider 原生模型目录正文;不提供原生目录的 Provider 使用默认拒绝。
|
||||
async fn model_catalog_document(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderModelCatalogDocument, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
async fn export_credentials(
|
||||
&self,
|
||||
credentials: Vec<ProviderExportCredentialInput>,
|
||||
) -> Result<ProviderExport, ProviderAdminError>;
|
||||
}
|
||||
|
||||
/// 按 ProviderKind 动态发现管理能力;不含具体 Provider 分支。
|
||||
/// 启动时注册原生 Provider,按 ProviderKind 查找管理能力。
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderAdminRegistry {
|
||||
providers: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
|
||||
}
|
||||
|
||||
impl ProviderAdminRegistry {
|
||||
#[must_use]
|
||||
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
|
||||
self.providers
|
||||
.iter()
|
||||
@@ -339,7 +360,7 @@ impl ProviderAdminRegistry {
|
||||
let plan_type = explicit_plan_type(plan_type)?.trim();
|
||||
let provider = ProviderKind::new(provider_kind.to_owned())
|
||||
.ok()
|
||||
.and_then(|kind| self.providers.get(&kind));
|
||||
.and_then(|kind| self.require(&kind).ok());
|
||||
let display = provider.map_or_else(
|
||||
|| plan_type.to_owned(),
|
||||
|provider| provider.plan_type_display(plan_type),
|
||||
@@ -365,7 +386,7 @@ impl ProviderAdminRegistry {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 动态分派 Provider-owned 费用规则,不含任何具体 Provider 分支。
|
||||
/// 按平台计算请求费用。
|
||||
pub fn calculated_billing(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
|
||||
@@ -49,7 +49,7 @@ pub trait ProxyStore: Send + Sync {
|
||||
revision: Revision,
|
||||
result: ProxyTestResult,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyRecord>;
|
||||
) -> AdminStoreResult<ProxyMutation>;
|
||||
}
|
||||
|
||||
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
|
||||
@@ -62,5 +62,5 @@ pub struct ProxyImportReservation {
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyProbe: Send + Sync {
|
||||
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult;
|
||||
async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ use crate::model::{
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialMutationResult, CredentialRotationCommit, ProviderExportCredentialInput,
|
||||
CredentialMutationResult, CredentialRotationCommit, PluginAccountListQuery,
|
||||
PluginAccountPage, ProviderExportCredentialInput,
|
||||
},
|
||||
quota_forecast_sampling::QuotaForecastHistory,
|
||||
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
|
||||
@@ -88,6 +89,12 @@ pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
|
||||
/// 账号目录与公共账号写操作。
|
||||
#[async_trait]
|
||||
pub trait AccountStore: Send + Sync {
|
||||
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页。
|
||||
async fn list_plugin_accounts(
|
||||
&self,
|
||||
query: PluginAccountListQuery,
|
||||
) -> AdminStoreResult<PluginAccountPage>;
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: AccountListQuery,
|
||||
@@ -123,12 +130,24 @@ pub trait AccountStore: Send + Sync {
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<CredentialDetails>>;
|
||||
|
||||
/// 插件按全局账号 ID 读取时,由数据库记录提供权威 Provider 归属。
|
||||
async fn credential_details_by_id(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<CredentialDetails>>;
|
||||
|
||||
async fn load_credentials_for_export(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
account_ids: &[gateway_core::account::ProviderAccountId],
|
||||
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
|
||||
|
||||
/// 插件凭据读取只接收账号 ID,不接受调用方另行声明 Provider。
|
||||
async fn load_credential_for_plugin(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<ProviderExportCredentialInput>>;
|
||||
|
||||
async fn commit_credential_import(
|
||||
&self,
|
||||
command: CredentialImportCommit,
|
||||
@@ -139,7 +158,13 @@ pub trait AccountStore: Send + Sync {
|
||||
&self,
|
||||
command: AuthorizationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult>;
|
||||
) -> AdminStoreResult<crate::model::provider_credentials::AuthorizationCommitResult>;
|
||||
|
||||
/// 已提交的授权结果独立于 Redis 临时状态;只有原管理员身份可读。
|
||||
async fn authorization_receipt(
|
||||
&self,
|
||||
key: &crate::model::provider_credentials::AuthorizationReceiptKey,
|
||||
) -> AdminStoreResult<Option<crate::model::provider_credentials::CredentialMutationResult>>;
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
&self,
|
||||
@@ -488,9 +513,25 @@ pub struct AdminStorePorts {
|
||||
observability: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
backup: BackupStorePorts,
|
||||
plugins: Arc<dyn super::plugins::PluginStore>,
|
||||
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
|
||||
}
|
||||
|
||||
impl AdminStorePorts {
|
||||
#[must_use]
|
||||
pub fn plugins(&self) -> Arc<dyn super::plugins::PluginStore> {
|
||||
Arc::clone(&self.plugins)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugin_state(&self) -> Arc<dyn super::plugins::PluginStateStore> {
|
||||
Arc::clone(&self.plugin_state)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "组合根需显式注入各领域窄端口,不能用服务定位器隐藏依赖"
|
||||
)]
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
accounts: AdminAccountStorePorts,
|
||||
@@ -499,6 +540,8 @@ impl AdminStorePorts {
|
||||
observability: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
backup: BackupStorePorts,
|
||||
plugins: Arc<dyn super::plugins::PluginStore>,
|
||||
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
@@ -507,6 +550,8 @@ impl AdminStorePorts {
|
||||
observability,
|
||||
settings,
|
||||
backup,
|
||||
plugins,
|
||||
plugin_state,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
//! Host 为系统管理用例提供的进程与操作系统能力。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::{pin::Pin, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
|
||||
use crate::model::Revision;
|
||||
use crate::model::system::{
|
||||
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
};
|
||||
|
||||
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据。
|
||||
#[derive(Clone)]
|
||||
pub struct SystemUpdateCandidate {
|
||||
pub target_version: String,
|
||||
pub release_manifest: Arc<[u8]>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SystemUpdateCandidate {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("SystemUpdateCandidate")
|
||||
.field("target_version", &self.target_version)
|
||||
.field("release_manifest_bytes", &self.release_manifest.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Host 系统操作失败类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemOperationErrorKind {
|
||||
@@ -51,6 +69,18 @@ impl SystemOperationError {
|
||||
/// 每个订阅者独占的系统事件流。
|
||||
pub type SystemUpdateEventStream = Pin<Box<dyn Stream<Item = SystemUpdateEvent> + Send + 'static>>;
|
||||
|
||||
/// Admin 对启用实例和制品作兼容决策;Host 只负责候选下载、校验与文件交换。
|
||||
#[async_trait]
|
||||
pub trait SystemUpdatePreflight: Send + Sync {
|
||||
async fn validate(
|
||||
&self,
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError>;
|
||||
|
||||
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。
|
||||
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>;
|
||||
}
|
||||
|
||||
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
|
||||
#[async_trait]
|
||||
pub trait SystemOperations: Send + Sync {
|
||||
@@ -66,11 +96,15 @@ pub trait SystemOperations: Send + Sync {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError>;
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
async fn rollback(
|
||||
&self,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
}
|
||||
|
||||
@@ -231,8 +231,8 @@ fn project_group_runtime(
|
||||
})
|
||||
})
|
||||
},
|
||||
total_slots: available_ids.iter().fold(0_u64, |sum, account_id| {
|
||||
sum.saturating_add(slots_by_account.get(*account_id).copied().unwrap_or(0))
|
||||
total_slots: available_ids.iter().try_fold(0_u64, |sum, account_id| {
|
||||
Some(sum.saturating_add(slots_by_account.get(*account_id).copied().flatten()?))
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! 统一账号目录与跨 Provider 动态分派。
|
||||
//! 统一账号目录与原生 Provider 分派。
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
@@ -23,12 +26,14 @@ use crate::{
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh,
|
||||
ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
|
||||
QuotaLocalUsageAttribution,
|
||||
AccountRefreshResult, AccountUsagePeriod, ConsumeProviderResetCredit,
|
||||
PrepareCredentialRefresh, ProviderModelCatalogDocument, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
|
||||
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
|
||||
},
|
||||
quota_forecast::{
|
||||
AccountQuotaForecastReport, account_quota_forecasts, quota_forecast_source_window,
|
||||
},
|
||||
quota_forecast::{AccountQuotaForecastReport, account_quota_forecasts},
|
||||
quota_forecast_sampling::{QuotaForecastPoint, select_forecast_sample},
|
||||
},
|
||||
ports::{
|
||||
@@ -140,6 +145,11 @@ pub trait AccountsService: Send + Sync {
|
||||
refresh: bool,
|
||||
) -> Result<ProviderModels, AdminError>;
|
||||
|
||||
async fn model_catalog_document(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderModelCatalogDocument, AdminError>;
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
@@ -210,7 +220,7 @@ impl DefaultAccountsService {
|
||||
) -> Result<
|
||||
(
|
||||
AccountPageItem,
|
||||
Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
),
|
||||
AdminError,
|
||||
> {
|
||||
@@ -314,14 +324,25 @@ impl DefaultAccountsService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "rolling account usage"))?;
|
||||
let rolling_usage = rolling_usage.into_iter().next();
|
||||
let mut quota = provider
|
||||
let mut quota = match provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: refresh_quota,
|
||||
rolling_usage: rolling_usage.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider quota"))?;
|
||||
{
|
||||
Ok(quota) => quota,
|
||||
// 凭据更新后的账号投影不要求 Provider 提供额度;显式刷新仍须支持该操作。
|
||||
Err(error)
|
||||
if !refresh_quota
|
||||
&& error.kind()
|
||||
== crate::ports::provider::ProviderAdminErrorKind::Unsupported =>
|
||||
{
|
||||
empty_quota()
|
||||
}
|
||||
Err(error) => return Err(map_provider_error(error, "provider quota")),
|
||||
};
|
||||
let mut stored = if refresh_quota {
|
||||
self.load_account(account_id).await?
|
||||
} else {
|
||||
@@ -342,6 +363,8 @@ impl DefaultAccountsService {
|
||||
.and_then(|(window, _)| window.local_usage.clone())
|
||||
});
|
||||
Ok(AccountDirectoryItem {
|
||||
capabilities: provider
|
||||
.account_capabilities(account_id, &stored.account.authentication_kind),
|
||||
plan_type_display: self.providers.resolve_account_plan(
|
||||
stored.account.provider_kind.as_str(),
|
||||
&mut stored.account.plan_type,
|
||||
@@ -358,6 +381,7 @@ impl DefaultAccountsService {
|
||||
#[async_trait]
|
||||
impl AccountsService for DefaultAccountsService {
|
||||
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError> {
|
||||
let providers = &self.providers;
|
||||
let runtime = self
|
||||
.account_runtime
|
||||
.active_rate_limits()
|
||||
@@ -393,7 +417,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
|
||||
// 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
|
||||
// 该账号降级为空额度投影,其余账号与页面状态照常返回。
|
||||
let provider = match self.providers.require(&account.provider_kind) {
|
||||
let provider = match providers.require(&account.provider_kind) {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
@@ -434,13 +458,22 @@ impl AccountsService for DefaultAccountsService {
|
||||
.into_iter()
|
||||
.zip(quotas)
|
||||
.map(|(mut item, quota)| {
|
||||
let id = ProviderAccountId::new(item.account.id.clone())
|
||||
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
|
||||
let capabilities = providers
|
||||
.require(&item.account.provider_kind)
|
||||
.map(|provider| {
|
||||
provider.account_capabilities(&id, &item.account.authentication_kind)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let usage = api_key_usage.remove(&item.account.id).or_else(|| {
|
||||
quota
|
||||
.usage_window()
|
||||
.and_then(|(window, _)| window.local_usage.clone())
|
||||
});
|
||||
AccountDirectoryItem {
|
||||
plan_type_display: self.providers.resolve_account_plan(
|
||||
Ok(AccountDirectoryItem {
|
||||
capabilities,
|
||||
plan_type_display: providers.resolve_account_plan(
|
||||
item.account.provider_kind.as_str(),
|
||||
&mut item.account.plan_type,
|
||||
Some("a),
|
||||
@@ -449,9 +482,9 @@ impl AccountsService for DefaultAccountsService {
|
||||
account: item.account,
|
||||
projection: item.projection,
|
||||
quota,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<Vec<_>, AdminError>>()?;
|
||||
Ok(AccountDirectoryPage {
|
||||
config_revision: page.config_revision,
|
||||
items,
|
||||
@@ -465,6 +498,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
context: &MutationContext,
|
||||
account_ids: Vec<ProviderAccountId>,
|
||||
) -> Result<AccountExportBundle, AdminError> {
|
||||
let providers = &self.providers;
|
||||
if account_ids.is_empty() || account_ids.len() > 200 {
|
||||
return Err(AdminError::invalid("账号导出数量必须在 1 到 200 之间"));
|
||||
}
|
||||
@@ -483,12 +517,18 @@ impl AccountsService for DefaultAccountsService {
|
||||
}) {
|
||||
return Err(AdminError::invalid("账号导出列表包含重复 ID"));
|
||||
}
|
||||
let mut documents = Vec::with_capacity(grouped.len());
|
||||
for (provider_kind, ids) in grouped {
|
||||
let provider = self
|
||||
.providers
|
||||
.require(&provider_kind)
|
||||
.map_err(|error| map_provider_error(error, "provider account export"))?;
|
||||
// 混选时先确认全部 Provider 已注册,拒绝路径不读取任何一组明文凭据。
|
||||
let export_groups = grouped
|
||||
.into_iter()
|
||||
.map(|(provider_kind, ids)| {
|
||||
let provider = providers
|
||||
.require(&provider_kind)
|
||||
.map_err(|error| map_provider_error(error, "provider account export"))?;
|
||||
Ok((provider_kind, provider, ids))
|
||||
})
|
||||
.collect::<Result<Vec<_>, AdminError>>()?;
|
||||
let mut documents = Vec::with_capacity(export_groups.len());
|
||||
for (provider_kind, provider, ids) in export_groups {
|
||||
let credentials = self
|
||||
.accounts
|
||||
.load_credentials_for_export(&provider_kind, &ids)
|
||||
@@ -548,19 +588,39 @@ impl AccountsService for DefaultAccountsService {
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
let result = self
|
||||
.accounts
|
||||
.recover_account(&account_id, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account recovery"))?;
|
||||
let (stored, provider) = self.provider_for_account(&account_id).await?;
|
||||
let config_revision = if stored.account.enabled {
|
||||
self.accounts
|
||||
.recover_account(&account_id, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account recovery"))?
|
||||
.config_revision
|
||||
} else {
|
||||
// 停用只表示不参与调度,重新启用不能抹除已观测的额度、凭据或冷却事实。
|
||||
self.accounts
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
account_ids: vec![account_id.to_string()],
|
||||
enabled: Some(true),
|
||||
concurrency_limit: None,
|
||||
weight: None,
|
||||
model_access: None,
|
||||
group_ids: None,
|
||||
outbound_proxy: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "enable provider account"))?
|
||||
.config_revision
|
||||
};
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
let account = self.load_directory_item(&account_id, false).await?;
|
||||
Ok(AccountRefreshResult {
|
||||
config_revision: result.config_revision,
|
||||
config_revision,
|
||||
account,
|
||||
})
|
||||
}
|
||||
@@ -626,7 +686,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
let mut providers = BTreeMap::<
|
||||
ProviderKind,
|
||||
(
|
||||
Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
Vec<ProviderAccountId>,
|
||||
),
|
||||
>::new();
|
||||
@@ -690,7 +750,15 @@ impl AccountsService for DefaultAccountsService {
|
||||
.map_err(|error| map_provider_error(error, "forecast quota snapshot"))?;
|
||||
let now = Utc::now();
|
||||
let mut samples = Vec::new();
|
||||
for (window, _) in quota.usage_windows() {
|
||||
let mut selected_keys = BTreeSet::new();
|
||||
for period in [AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly] {
|
||||
let Some((window, _)) = quota_forecast_source_window("a, period) else {
|
||||
continue;
|
||||
};
|
||||
// 缺少一个周期时两个结果会复用同一窗口,只查询一次历史快照。
|
||||
if !selected_keys.insert(window.key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let (Some(mut query), Some(observed), Some(percent)) = (
|
||||
quota_usage_window(account_id.as_str(), window),
|
||||
quota.observed_at,
|
||||
@@ -740,7 +808,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
usage: point.usage,
|
||||
});
|
||||
}
|
||||
let mut sample = select_forecast_sample(
|
||||
let sample = select_forecast_sample(
|
||||
window.key.clone(),
|
||||
query.range.start,
|
||||
QuotaForecastPoint {
|
||||
@@ -750,13 +818,8 @@ impl AccountsService for DefaultAccountsService {
|
||||
},
|
||||
points,
|
||||
history.pending_request_count,
|
||||
interrupted,
|
||||
);
|
||||
if interrupted
|
||||
&& sample.method
|
||||
== crate::model::quota_forecast_sampling::QuotaForecastMethod::Cumulative
|
||||
{
|
||||
sample.discontinuous = true;
|
||||
}
|
||||
samples.push(sample);
|
||||
}
|
||||
Ok(AccountQuotaForecastReport {
|
||||
@@ -872,6 +935,17 @@ impl AccountsService for DefaultAccountsService {
|
||||
.map_err(|error| map_provider_error(error, "provider model catalog"))
|
||||
}
|
||||
|
||||
async fn model_catalog_document(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderModelCatalogDocument, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
provider
|
||||
.model_catalog_document(account_id)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider model catalog document"))
|
||||
}
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
@@ -882,6 +956,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
let model = upstream_model.as_str().to_owned();
|
||||
let operation = provider
|
||||
.connection_test_operation(&upstream_model, CONNECTION_TEST_INPUT)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider connection test"))?;
|
||||
let initial = vec![
|
||||
AccountConnectionTestEvent::Started {
|
||||
@@ -897,12 +972,15 @@ impl AccountsService for DefaultAccountsService {
|
||||
let probe = Arc::clone(&self.probe);
|
||||
let terminal = futures::stream::once(async move {
|
||||
let result = probe
|
||||
.probe(AccountProbeRequest {
|
||||
account_id,
|
||||
provider_kind: account.provider_kind,
|
||||
upstream_model,
|
||||
operation,
|
||||
})
|
||||
.probe(
|
||||
AccountProbeRequest {
|
||||
account_id,
|
||||
provider_kind: account.provider_kind,
|
||||
upstream_model,
|
||||
operation,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(result) => result
|
||||
|
||||
@@ -113,18 +113,11 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
context: &MutationContext,
|
||||
command: CreateClientKey,
|
||||
) -> Result<CreatedClientKey, AdminError> {
|
||||
for (provider, profile) in [
|
||||
("openai", &command.openai_client_profile_override),
|
||||
("xai", &command.xai_client_profile_override),
|
||||
] {
|
||||
if let Some(profile) = profile {
|
||||
let kind = gateway_core::routing::ProviderKind::new(provider)
|
||||
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
|
||||
self.providers
|
||||
.require(&kind)
|
||||
.and_then(|provider| provider.preview_client_profile(profile))
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
for (provider, profile) in &command.request_profile_overrides {
|
||||
self.providers
|
||||
.require(provider)
|
||||
.and_then(|provider| provider.preview_client_profile(profile))
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
|
||||
@@ -139,8 +132,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.store
|
||||
.create_client_key(
|
||||
NewClientKey {
|
||||
openai_client_profile_override: command.openai_client_profile_override,
|
||||
xai_client_profile_override: command.xai_client_profile_override,
|
||||
request_profile_overrides: command.request_profile_overrides,
|
||||
id,
|
||||
name: command.name,
|
||||
label: command.label,
|
||||
@@ -165,15 +157,10 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
context: &MutationContext,
|
||||
command: UpdateClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError> {
|
||||
for (provider, profile) in [
|
||||
("openai", &command.openai_client_profile_override),
|
||||
("xai", &command.xai_client_profile_override),
|
||||
] {
|
||||
if let Some(Some(profile)) = profile {
|
||||
let kind = gateway_core::routing::ProviderKind::new(provider)
|
||||
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
|
||||
for (provider, profile) in &command.request_profile_override_updates {
|
||||
if let Some(profile) = profile {
|
||||
self.providers
|
||||
.require(&kind)
|
||||
.require(provider)
|
||||
.and_then(|provider| provider.preview_client_profile(profile))
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
|
||||
+89
-90
@@ -1,63 +1,38 @@
|
||||
//! OpenAI 管理资源的中立 ProviderAdmin 委托。
|
||||
//! 已冻结 Provider 的凭据工作流;Admin 保持事务、审计与发布的唯一所有权。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialMutationResult, ImportCredentials, PrepareCredentialImport,
|
||||
PrepareCredentialRotation, RotateCredential, StartAuthorization,
|
||||
AuthorizationCommitResult, AuthorizationReceiptKey, AuthorizationStarted,
|
||||
CompleteAuthorization, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialImportCommit, CredentialImportResult, CredentialMutationResult,
|
||||
ImportCredentials, PrepareCredentialImport, PrepareCredentialRotation,
|
||||
RotateCredential, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
};
|
||||
|
||||
use super::{
|
||||
use super::super::{
|
||||
commit_authorization, commit_credential_rotation, delete_credentials, map_provider_error,
|
||||
map_store_error, pending_authorization, publish_committed,
|
||||
publish_credentials_and_observe_quota, required_credential, validate_authorization_commit,
|
||||
validate_prepared_import, validate_prepared_rotation,
|
||||
};
|
||||
|
||||
/// OpenAI 固定管理路由消费的服务。
|
||||
#[async_trait]
|
||||
pub trait OpenAiService: Send + Sync {
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError>;
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
command: StartAuthorization,
|
||||
) -> Result<AuthorizationStarted, AdminError>;
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn rotate(
|
||||
&self,
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
) -> Result<CredentialDeletionResult, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultOpenAiService {
|
||||
pub struct ProviderCredentials {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultOpenAiService {
|
||||
impl ProviderCredentials {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
@@ -72,16 +47,12 @@ impl DefaultOpenAiService {
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OpenAiService for DefaultOpenAiService {
|
||||
async fn import_document(
|
||||
pub async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let proxy_reservation = super::import_proxy_binding(
|
||||
let proxy_reservation = super::super::import_proxy_binding(
|
||||
self.proxies.as_ref(),
|
||||
command.outbound_proxy_id.as_deref(),
|
||||
)
|
||||
@@ -98,11 +69,11 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
document: command.document,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI credential import"))?;
|
||||
.map_err(|error| map_provider_error(error, "provider credential import"))?;
|
||||
validate_prepared_import(
|
||||
self.provider.provider_kind(),
|
||||
&prepared,
|
||||
"OpenAI credential import",
|
||||
"provider credential import",
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
@@ -115,7 +86,7 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
|
||||
.map_err(|error| map_store_error(error, "provider credential import"))?;
|
||||
drop(proxy_reservation);
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
@@ -128,7 +99,7 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn start_authorization(
|
||||
pub async fn start_authorization(
|
||||
&self,
|
||||
command: StartAuthorization,
|
||||
) -> Result<AuthorizationStarted, AdminError> {
|
||||
@@ -137,53 +108,16 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
self.proxies.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&command,
|
||||
"OpenAI credential",
|
||||
"provider credential",
|
||||
)
|
||||
.await?;
|
||||
self.provider
|
||||
.start_authorization(pending)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI authorization"))
|
||||
.map_err(|error| map_provider_error(error, "provider authorization"))
|
||||
}
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
mut command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.context.clone();
|
||||
let settings = command.settings.take();
|
||||
let prepared = self
|
||||
.provider
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI authorization"))?;
|
||||
let prepared = validate_authorization_commit(
|
||||
self.provider.provider_kind(),
|
||||
&context,
|
||||
prepared,
|
||||
"OpenAI authorization",
|
||||
)
|
||||
.await?;
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
settings,
|
||||
&context,
|
||||
"OpenAI authorization",
|
||||
)
|
||||
.await?;
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&result.account_id),
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn rotate(
|
||||
pub async fn rotate(
|
||||
&self,
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
@@ -204,7 +138,7 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&account_id,
|
||||
"OpenAI credential rotation",
|
||||
"provider credential rotation",
|
||||
)
|
||||
.await?;
|
||||
let account = details.credential;
|
||||
@@ -215,14 +149,14 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
provider_material: command.provider_material,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI credential rotation"))?;
|
||||
validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?;
|
||||
.map_err(|error| map_provider_error(error, "provider credential rotation"))?;
|
||||
validate_prepared_rotation(&account, &prepared, "provider credential rotation")?;
|
||||
let result = commit_credential_rotation(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
command.settings,
|
||||
&context,
|
||||
"OpenAI credential rotation",
|
||||
"provider credential rotation",
|
||||
)
|
||||
.await?;
|
||||
if disable_account {
|
||||
@@ -235,7 +169,7 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
pub async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
) -> Result<CredentialDeletionResult, AdminError> {
|
||||
@@ -243,10 +177,75 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
"OpenAI credential",
|
||||
"provider credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl super::CredentialsService {
|
||||
pub async fn complete_authorization(
|
||||
&self,
|
||||
kind: &gateway_core::routing::ProviderKind,
|
||||
mut command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.context.clone();
|
||||
let key = AuthorizationReceiptKey::new(kind.clone(), &command.flow_id, &context)?;
|
||||
if let Some(result) = self
|
||||
.accounts
|
||||
.authorization_receipt(&key)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "authorization receipt"))?
|
||||
{
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
return Ok(result);
|
||||
}
|
||||
let scope = self.for_provider(kind)?;
|
||||
let provider = &scope.provider;
|
||||
let settings = command.settings.take();
|
||||
let prepared = provider
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider authorization"))?;
|
||||
let prepared =
|
||||
validate_authorization_commit(kind, &context, prepared, "provider authorization")
|
||||
.await?;
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
key,
|
||||
settings,
|
||||
&context,
|
||||
"provider authorization",
|
||||
)
|
||||
.await?;
|
||||
let result = self
|
||||
.publish_authorization(provider, result, &context.request_id)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn publish_authorization(
|
||||
&self,
|
||||
provider: &Arc<dyn ProviderAdmin>,
|
||||
outcome: AuthorizationCommitResult,
|
||||
request_id: &str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let result = outcome.result;
|
||||
if outcome.newly_committed {
|
||||
publish_credentials_and_observe_quota(
|
||||
provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&result.account_id),
|
||||
request_id,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
mod flow;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_core::{routing::ProviderKind, runtime::SnapshotControl};
|
||||
|
||||
use crate::{
|
||||
model::AdminError,
|
||||
ports::{provider::ProviderAdminRegistry, proxy::ProxyStore, store::AccountStore},
|
||||
};
|
||||
|
||||
pub use flow::ProviderCredentials;
|
||||
|
||||
/// 所有 Provider 共用凭据用例;每次操作从已发布目录冻结自己的管理实现。
|
||||
pub struct CredentialsService {
|
||||
providers: ProviderAdminRegistry,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl CredentialsService {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
providers: ProviderAdminRegistry,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
providers,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_provider(&self, kind: &ProviderKind) -> Result<ProviderCredentials, AdminError> {
|
||||
let provider = self
|
||||
.providers
|
||||
.require(kind)
|
||||
.map_err(|error| super::map_provider_error(error, "provider credentials"))?;
|
||||
Ok(ProviderCredentials::new(
|
||||
provider,
|
||||
self.accounts.clone(),
|
||||
self.proxies.clone(),
|
||||
self.snapshot.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use gateway_core::{
|
||||
use tokio::{sync::Notify, time::Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{openai::OpenAiService, xai::XaiService};
|
||||
use super::credentials::CredentialsService;
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind, MutationActor, MutationContext,
|
||||
import_tasks::{
|
||||
@@ -129,17 +129,15 @@ struct Registry {
|
||||
pub(crate) struct DefaultImportTasksService {
|
||||
registry: Mutex<Registry>,
|
||||
notify: Notify,
|
||||
openai: Arc<dyn OpenAiService>,
|
||||
xai: Arc<dyn XaiService>,
|
||||
credentials: Arc<CredentialsService>,
|
||||
}
|
||||
|
||||
impl DefaultImportTasksService {
|
||||
pub(crate) fn new(openai: Arc<dyn OpenAiService>, xai: Arc<dyn XaiService>) -> Arc<Self> {
|
||||
pub(crate) fn new(credentials: Arc<CredentialsService>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
registry: Mutex::default(),
|
||||
notify: Notify::new(),
|
||||
openai,
|
||||
xai,
|
||||
credentials,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -177,11 +175,10 @@ impl DefaultImportTasksService {
|
||||
|
||||
async fn execute(&self, id: Uuid, index: usize, input: ImportTaskInput) {
|
||||
let operation = async {
|
||||
match input.provider.as_str() {
|
||||
"openai" => self.openai.import_document(input.command).await,
|
||||
"xai" => self.xai.import_document(input.command).await,
|
||||
_ => Err(AdminError::invalid("不支持的导入平台")),
|
||||
}
|
||||
self.credentials
|
||||
.for_provider(&input.provider)?
|
||||
.import_document(input.command)
|
||||
.await
|
||||
};
|
||||
let result = AssertUnwindSafe(operation).catch_unwind().await;
|
||||
let mut registry = self.registry();
|
||||
@@ -222,7 +219,7 @@ impl ImportTasksService for DefaultImportTasksService {
|
||||
if command.items.is_empty()
|
||||
|| command.items.len() > MAX_IMPORT_TASK_ITEMS
|
||||
|| command.items.iter().any(|item| {
|
||||
!matches!(item.provider.as_str(), "openai" | "xai")
|
||||
self.credentials.for_provider(&item.provider).is_err()
|
||||
|| item.command.context.actor != command.context.actor
|
||||
})
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Key 自助查询只从统一会话取得范围,复用现有观测和额度账本。
|
||||
//! Key 自助查询从统一会话或只读 Key 校验取得范围,复用现有观测和额度账本。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -6,7 +6,7 @@ use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
AuthService,
|
||||
AuthService, SystemService,
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
auth::SessionSubject,
|
||||
@@ -18,15 +18,33 @@ use crate::{
|
||||
observability::{
|
||||
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
|
||||
},
|
||||
system::SystemVersion,
|
||||
},
|
||||
ports::store::{ClientKeyStore, ObservabilityStore},
|
||||
};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
budget::ClientBudgetStatus,
|
||||
execution::{ClientAuthenticationError, ClientKeyVerifier},
|
||||
},
|
||||
policy::ClientApiKeyId,
|
||||
};
|
||||
|
||||
use super::{map_store_error, observability::health_timeline_at};
|
||||
|
||||
#[async_trait]
|
||||
pub trait KeyUsageService: Send + Sync {
|
||||
/// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入。
|
||||
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError>;
|
||||
|
||||
/// 使用 Core 已认证的宿主身份查询额度,不接收插件自报的 Key ID。
|
||||
async fn budget_for_client(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
) -> Result<Option<ClientBudgetStatus>, AdminError>;
|
||||
|
||||
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError>;
|
||||
|
||||
async fn config(&self, session_id: Option<&str>)
|
||||
-> Result<Option<ClientKeySecret>, AdminError>;
|
||||
|
||||
@@ -45,20 +63,26 @@ pub trait KeyUsageService: Send + Sync {
|
||||
|
||||
pub(crate) struct DefaultKeyUsageService {
|
||||
auth: Arc<dyn AuthService>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
system: Arc<dyn SystemService>,
|
||||
}
|
||||
|
||||
impl DefaultKeyUsageService {
|
||||
pub(crate) fn new(
|
||||
auth: Arc<dyn AuthService>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
system: Arc<dyn SystemService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth,
|
||||
verifier,
|
||||
keys,
|
||||
observations,
|
||||
system,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +113,41 @@ fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
|
||||
|
||||
#[async_trait]
|
||||
impl KeyUsageService for DefaultKeyUsageService {
|
||||
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError> {
|
||||
let id = match self.verifier.verify_client_key(plaintext) {
|
||||
Ok(id) => id,
|
||||
Err(ClientAuthenticationError::InvalidKey) => return Ok(None),
|
||||
Err(
|
||||
ClientAuthenticationError::SnapshotUnavailable
|
||||
| ClientAuthenticationError::ProviderUnavailable,
|
||||
) => {
|
||||
return Err(AdminError::new(
|
||||
AdminErrorKind::Unavailable,
|
||||
"密钥验证暂时不可用",
|
||||
));
|
||||
}
|
||||
};
|
||||
self.budget_for_client(&id).await
|
||||
}
|
||||
|
||||
async fn budget_for_client(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
) -> Result<Option<ClientBudgetStatus>, AdminError> {
|
||||
self.keys
|
||||
.get_client_key(id)
|
||||
.await
|
||||
.map(|key| key.filter(|key| key.enabled).map(|key| key.budget))
|
||||
.map_err(|error| map_store_error(error, "key usage budget"))
|
||||
}
|
||||
|
||||
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError> {
|
||||
if self.key_id(session_id).await?.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.system.version().await.map(Some)
|
||||
}
|
||||
|
||||
async fn config(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
|
||||
@@ -6,16 +6,17 @@ pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod credentials;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod openai;
|
||||
pub mod plugin_accounts;
|
||||
pub mod plugin_client_keys;
|
||||
pub(crate) mod plugin_update;
|
||||
pub mod plugins;
|
||||
pub mod proxies;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod xai;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
@@ -102,7 +103,7 @@ async fn publish_committed(
|
||||
}
|
||||
|
||||
async fn publish_credentials_and_observe_quota(
|
||||
provider: &Arc<dyn ProviderAdmin>,
|
||||
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
snapshot: &dyn SnapshotControl,
|
||||
revision: crate::model::Revision,
|
||||
account_ids: &[ProviderAccountId],
|
||||
@@ -112,7 +113,7 @@ async fn publish_credentials_and_observe_quota(
|
||||
publish_committed(snapshot, revision).await?;
|
||||
|
||||
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果。
|
||||
let provider = Arc::clone(provider);
|
||||
let provider = provider.clone();
|
||||
let account_ids = account_ids.to_vec();
|
||||
let request_id = request_id.to_owned();
|
||||
tokio::spawn(async move {
|
||||
@@ -151,6 +152,18 @@ async fn required_credential(
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
|
||||
}
|
||||
|
||||
async fn required_plugin_credential(
|
||||
accounts: &dyn AccountStore,
|
||||
account_id: &ProviderAccountId,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialDetails, AdminError> {
|
||||
accounts
|
||||
.credential_details_by_id(account_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
|
||||
}
|
||||
|
||||
async fn import_proxy_binding(
|
||||
proxies: &dyn crate::ports::proxy::ProxyStore,
|
||||
id: Option<&str>,
|
||||
@@ -233,7 +246,13 @@ fn validate_prepared_rotation(
|
||||
prepared: &PreparedCredentialRotation,
|
||||
_resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
let facts = prepared.facts();
|
||||
validate_prepared_rotation_facts(account, prepared.facts())
|
||||
}
|
||||
|
||||
fn validate_prepared_rotation_facts(
|
||||
account: &crate::model::accounts::AccountRecord,
|
||||
facts: &crate::model::provider_credentials::PreparedCredentialRotationFacts,
|
||||
) -> Result<(), AdminError> {
|
||||
if facts.account_id.as_str() != account.id.as_str()
|
||||
|| facts.provider_kind != account.provider_kind
|
||||
{
|
||||
@@ -294,10 +313,11 @@ async fn validate_authorization_commit(
|
||||
async fn commit_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
key: crate::model::provider_credentials::AuthorizationReceiptKey,
|
||||
settings: Option<crate::model::accounts::AccountImportSettings>,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
) -> Result<crate::model::provider_credentials::AuthorizationCommitResult, AdminError> {
|
||||
if settings.is_some()
|
||||
&& matches!(
|
||||
&prepared.credential,
|
||||
@@ -311,14 +331,19 @@ async fn commit_authorization(
|
||||
command,
|
||||
credential_guard,
|
||||
authorization_guard,
|
||||
} = prepared.into_commit(settings);
|
||||
} = prepared.into_commit(settings, key);
|
||||
match accounts.commit_authorization(command, context).await {
|
||||
Ok(result) => {
|
||||
if let Some(guard) = credential_guard {
|
||||
if let Some(guard) = credential_guard
|
||||
&& result.newly_committed
|
||||
{
|
||||
guard.finish();
|
||||
}
|
||||
if let Some(guard) = authorization_guard {
|
||||
guard.commit().await?;
|
||||
if let Some(guard) = authorization_guard
|
||||
&& let Err(error) = guard.commit().await
|
||||
{
|
||||
// 账号与回执已经原子提交,Redis 清理失败不能把确定的成功改写为失败。
|
||||
tracing::warn!(resource, settlement_error = %error, "authorization committed but pending cleanup failed");
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -192,42 +192,29 @@ impl DefaultObservabilityService {
|
||||
average(first_token_latency_sum_ms, first_token_latency_count);
|
||||
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
|
||||
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
|
||||
let mut configurations = std::collections::BTreeMap::new();
|
||||
if let Some(configuration) = &settings.openai_client_profile {
|
||||
configurations.insert(
|
||||
gateway_core::routing::ProviderKind::new("openai").expect("static provider kind"),
|
||||
configuration.clone(),
|
||||
);
|
||||
}
|
||||
if let Some(configuration) = &settings.xai_client_profile {
|
||||
configurations.insert(
|
||||
gateway_core::routing::ProviderKind::new("xai").expect("static provider kind"),
|
||||
configuration.clone(),
|
||||
);
|
||||
}
|
||||
let wire_profiles = self.providers.dashboard_wire_profiles(&configurations);
|
||||
let wire_profiles = self
|
||||
.providers
|
||||
.dashboard_wire_profiles(&settings.request_profiles);
|
||||
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
|
||||
let total_slots = runtime_slots.as_ref().map_or_else(
|
||||
|| {
|
||||
observation
|
||||
.provider_accounts
|
||||
.normal
|
||||
.saturating_mul(max_concurrent_per_account)
|
||||
},
|
||||
|slots| {
|
||||
slots
|
||||
.inherited_accounts
|
||||
.saturating_mul(max_concurrent_per_account)
|
||||
.saturating_add(slots.overridden_slots)
|
||||
},
|
||||
);
|
||||
let (inherited_accounts, overridden_slots) = runtime_slots
|
||||
.as_ref()
|
||||
.map_or((observation.provider_accounts.normal, 0), |slots| {
|
||||
(slots.inherited_accounts, slots.overridden_slots)
|
||||
});
|
||||
let total_slots = (max_concurrent_per_account > 0 || inherited_accounts == 0).then(|| {
|
||||
inherited_accounts
|
||||
.saturating_mul(max_concurrent_per_account)
|
||||
.saturating_add(overridden_slots)
|
||||
});
|
||||
let used_slots = runtime_slots.and_then(|slots| slots.used_slots);
|
||||
Ok(DashboardResult {
|
||||
capacity: DashboardCapacity {
|
||||
max_concurrent_per_account,
|
||||
total_slots,
|
||||
used_slots,
|
||||
available_slots: used_slots.map(|used| total_slots.saturating_sub(used)),
|
||||
available_slots: used_slots
|
||||
.zip(total_slots)
|
||||
.map(|(used, total)| total.saturating_sub(used)),
|
||||
},
|
||||
rotation_strategy: settings.rotation_strategy,
|
||||
observation,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! 插件宿主账号回调;复用凭据事务,不建立第二套账号权威。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
provider_credentials::{
|
||||
CredentialImportCommit, CredentialRotationCommit, PluginAccountCredential,
|
||||
PluginAccountListQuery, PluginAccountPage, PluginAccountSaveResult,
|
||||
PreparedCredentialImport, PreparedPluginAccountSave,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
plugin_accounts::PluginAccountAccess, provider::ProviderAdminRegistry, store::AccountStore,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{
|
||||
map_provider_error, map_store_error, publish_credentials_and_observe_quota,
|
||||
required_credential, required_plugin_credential, validate_prepared_import,
|
||||
validate_prepared_rotation_facts,
|
||||
};
|
||||
|
||||
pub(crate) struct DefaultPluginAccountAccess {
|
||||
providers: ProviderAdminRegistry,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultPluginAccountAccess {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
providers: ProviderAdminRegistry,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
providers,
|
||||
accounts,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
fn provider(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
) -> Result<std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
|
||||
self.providers
|
||||
.require(provider_kind)
|
||||
.map_err(|error| map_provider_error(error, "plugin account access"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginAccountAccess for DefaultPluginAccountAccess {
|
||||
async fn list(&self, query: PluginAccountListQuery) -> Result<PluginAccountPage, AdminError> {
|
||||
self.accounts
|
||||
.list_plugin_accounts(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin account list"))
|
||||
}
|
||||
|
||||
async fn get_runtime(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::accounts::AccountRecord, AdminError> {
|
||||
Ok(
|
||||
required_plugin_credential(
|
||||
self.accounts.as_ref(),
|
||||
account_id,
|
||||
"plugin account runtime",
|
||||
)
|
||||
.await?
|
||||
.credential,
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_credential(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<PluginAccountCredential, AdminError> {
|
||||
let credential = self
|
||||
.accounts
|
||||
.load_credential_for_plugin(account_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin credential"))?
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))?;
|
||||
Ok(PluginAccountCredential {
|
||||
account: credential.account,
|
||||
provider_material: credential.provider_material,
|
||||
})
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
command: PreparedPluginAccountSave,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginAccountSaveResult, AdminError> {
|
||||
match command {
|
||||
PreparedPluginAccountSave::Create(prepared) => {
|
||||
let provider_kind = prepared.provider_kind.clone();
|
||||
let account_id = prepared.account_id.clone();
|
||||
let provider = self.provider(&provider_kind)?;
|
||||
let prepared = PreparedCredentialImport {
|
||||
provider_kind: provider_kind.clone(),
|
||||
credentials: vec![prepared],
|
||||
};
|
||||
validate_prepared_import(&provider_kind, &prepared, "plugin account save")?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
outbound_proxy: None,
|
||||
settings: None,
|
||||
prepared,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin account save"))?;
|
||||
if result.credential_ids.as_slice() != std::slice::from_ref(&account_id) {
|
||||
return Err(AdminError::internal("账号保存结果与准备目标不一致"));
|
||||
}
|
||||
let credential_revision = required_credential(
|
||||
self.accounts.as_ref(),
|
||||
&provider_kind,
|
||||
&account_id,
|
||||
"plugin account save",
|
||||
)
|
||||
.await?
|
||||
.credential
|
||||
.credential_revision;
|
||||
publish_credentials_and_observe_quota(
|
||||
&provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&account_id),
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(PluginAccountSaveResult {
|
||||
config_revision: result.config_revision,
|
||||
account_id,
|
||||
credential_revision,
|
||||
})
|
||||
}
|
||||
PreparedPluginAccountSave::Replace {
|
||||
facts,
|
||||
authentication_kind,
|
||||
} => {
|
||||
let account = required_credential(
|
||||
self.accounts.as_ref(),
|
||||
&facts.provider_kind,
|
||||
&facts.account_id,
|
||||
"plugin account save",
|
||||
)
|
||||
.await?
|
||||
.credential;
|
||||
validate_prepared_rotation_facts(&account, &facts)?;
|
||||
if account.authentication_kind != authentication_kind {
|
||||
return Err(AdminError::conflict("插件准备结果与当前认证类型不一致"));
|
||||
}
|
||||
let provider = self.provider(&facts.provider_kind)?;
|
||||
let command = CredentialRotationCommit {
|
||||
prepared: facts,
|
||||
settings: None,
|
||||
};
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_rotation(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin account save"))?;
|
||||
let credential_revision = result
|
||||
.credential_revision
|
||||
.ok_or_else(|| AdminError::internal("账号保存未返回凭据版本"))?;
|
||||
publish_credentials_and_observe_quota(
|
||||
&provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&result.account_id),
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(PluginAccountSaveResult {
|
||||
config_revision: result.config_revision,
|
||||
account_id: result.account_id,
|
||||
credential_revision,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 插件 Client Key 目录;复用管理服务并收窄为非秘密投影。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPageSize,
|
||||
ClientKeySort, ClientKeySortField, SortDirection,
|
||||
},
|
||||
plugin_client_keys::{
|
||||
PluginClientKey, PluginClientKeyCursor, PluginClientKeyListQuery, PluginClientKeyPage,
|
||||
},
|
||||
},
|
||||
ports::plugin_client_keys::PluginClientKeyAccess,
|
||||
use_case::client_keys::ClientKeyService,
|
||||
};
|
||||
|
||||
pub(crate) struct DefaultPluginClientKeyAccess {
|
||||
service: Arc<dyn ClientKeyService>,
|
||||
}
|
||||
|
||||
impl DefaultPluginClientKeyAccess {
|
||||
pub(crate) fn new(service: Arc<dyn ClientKeyService>) -> Self {
|
||||
Self { service }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginClientKeyAccess for DefaultPluginClientKeyAccess {
|
||||
async fn list(
|
||||
&self,
|
||||
query: PluginClientKeyListQuery,
|
||||
) -> Result<PluginClientKeyPage, AdminError> {
|
||||
let sort = ClientKeySort {
|
||||
field: ClientKeySortField::Name,
|
||||
direction: SortDirection::Asc,
|
||||
};
|
||||
let page_size = ClientKeyPageSize::new(query.limit.get())
|
||||
.map_err(|_| AdminError::internal("插件 Client Key 页大小不合法"))?;
|
||||
let page = self
|
||||
.service
|
||||
.list(ClientKeyListQuery {
|
||||
cursor: query.cursor.map(|cursor| ClientKeyCursor {
|
||||
sort,
|
||||
value: ClientKeyCursorValue::Name(cursor.name),
|
||||
id: cursor.id,
|
||||
}),
|
||||
page_size,
|
||||
search: None,
|
||||
sort,
|
||||
})
|
||||
.await?;
|
||||
let next_cursor = page
|
||||
.next_cursor
|
||||
.map(|cursor| match cursor.value {
|
||||
ClientKeyCursorValue::Name(name)
|
||||
if cursor.sort.field == ClientKeySortField::Name
|
||||
&& cursor.sort.direction == SortDirection::Asc =>
|
||||
{
|
||||
Ok(PluginClientKeyCursor {
|
||||
name,
|
||||
id: cursor.id,
|
||||
})
|
||||
}
|
||||
_ => Err(AdminError::internal(
|
||||
"Client Key 服务返回了不匹配的插件分页游标",
|
||||
)),
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(PluginClientKeyPage {
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| PluginClientKey {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
enabled: item.enabled,
|
||||
})
|
||||
.collect(),
|
||||
next_cursor,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
model::{AdminErrorKind, Revision},
|
||||
ports::{
|
||||
plugins::{PluginPackageInspector, PluginStore},
|
||||
store::AdminStoreErrorKind,
|
||||
system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemUpdateCandidate,
|
||||
SystemUpdatePreflight,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
use super::plugins::official::update_compatibility;
|
||||
|
||||
/// 只读取启用实例及其固定包体,检查目标宿主合同;不准备或执行插件。
|
||||
pub(crate) struct PluginSystemUpdatePreflight {
|
||||
store: Arc<dyn PluginStore>,
|
||||
inspector: Arc<dyn PluginPackageInspector>,
|
||||
}
|
||||
|
||||
impl PluginSystemUpdatePreflight {
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn PluginStore>,
|
||||
inspector: Arc<dyn PluginPackageInspector>,
|
||||
) -> Self {
|
||||
Self { store, inspector }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemUpdatePreflight for PluginSystemUpdatePreflight {
|
||||
async fn validate(
|
||||
&self,
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError> {
|
||||
let target = semver::Version::parse(candidate.target_version.trim_start_matches('v'))
|
||||
.map_err(|_| invalid("目标网关版本不合法"))?;
|
||||
let compatibility = update_compatibility(
|
||||
candidate.release_manifest.as_ref(),
|
||||
candidate.target_version.trim_start_matches('v'),
|
||||
)
|
||||
.map_err(|_| invalid("目标发行物缺少有效的插件兼容声明"))?;
|
||||
let snapshot = self.store.load_instances().await.map_err(map_store_error)?;
|
||||
let mut inspected = BTreeMap::new();
|
||||
for instance in snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|instance| instance.enabled)
|
||||
{
|
||||
let requirements = if let Some(requirements) = inspected.get(&instance.artifact_sha256)
|
||||
{
|
||||
requirements
|
||||
} else {
|
||||
let artifact = self
|
||||
.store
|
||||
.load_artifact(&instance.artifact_sha256)
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
let requirements = self
|
||||
.inspector
|
||||
.compatibility(artifact.archive, instance.artifact_sha256.clone())
|
||||
.await
|
||||
.map_err(map_inspection_error)?;
|
||||
inspected.insert(instance.artifact_sha256.clone(), requirements);
|
||||
inspected
|
||||
.get(&instance.artifact_sha256)
|
||||
.ok_or_else(|| internal("插件兼容性检查结果不可用"))?
|
||||
};
|
||||
let host_versions = semver::VersionReq::parse(&requirements.host_version)
|
||||
.map_err(|_| incompatible(&instance.id))?;
|
||||
if !host_versions.matches(&target)
|
||||
|| !compatibility
|
||||
.manifest_schema_versions
|
||||
.contains(&requirements.manifest_schema_version)
|
||||
|| !compatibility
|
||||
.protocol_versions
|
||||
.contains(&requirements.protocol_version)
|
||||
|| requirements
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|(capability, version)| {
|
||||
!compatibility.supports_capability(capability, *version)
|
||||
})
|
||||
|| requirements
|
||||
.permissions
|
||||
.iter()
|
||||
.any(|permission| !compatibility.supports_permission(permission))
|
||||
{
|
||||
return Err(incompatible(&instance.id));
|
||||
}
|
||||
}
|
||||
Ok(snapshot.config_revision)
|
||||
}
|
||||
|
||||
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError> {
|
||||
let current = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(map_store_error)?
|
||||
.config_revision;
|
||||
if current != expected {
|
||||
return Err(conflict("插件配置已变化,请重新执行系统更新"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_store_error(error: crate::ports::store::AdminStoreError) -> SystemOperationError {
|
||||
match error.kind() {
|
||||
AdminStoreErrorKind::StaleRevision
|
||||
| AdminStoreErrorKind::DuplicateName
|
||||
| AdminStoreErrorKind::Conflict => conflict("插件配置已变化,请重新执行系统更新"),
|
||||
AdminStoreErrorKind::Invalid | AdminStoreErrorKind::NotFound => {
|
||||
conflict("启用插件的制品不完整,无法执行系统更新")
|
||||
}
|
||||
AdminStoreErrorKind::Unavailable => internal("插件配置暂不可读取"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_inspection_error(error: crate::model::AdminError) -> SystemOperationError {
|
||||
match error.kind() {
|
||||
AdminErrorKind::Invalid | AdminErrorKind::Conflict | AdminErrorKind::NotFound => {
|
||||
conflict("启用插件的制品无法通过兼容性检查")
|
||||
}
|
||||
_ => internal("插件兼容性检查暂不可用"),
|
||||
}
|
||||
}
|
||||
|
||||
fn incompatible(instance_id: &str) -> SystemOperationError {
|
||||
conflict(format!("启用插件实例 {instance_id} 与目标网关版本不兼容"))
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> SystemOperationError {
|
||||
SystemOperationError::new(SystemOperationErrorKind::Invalid, message)
|
||||
}
|
||||
|
||||
fn conflict(message: impl Into<String>) -> SystemOperationError {
|
||||
SystemOperationError::new(SystemOperationErrorKind::Conflict, message)
|
||||
}
|
||||
|
||||
fn internal(message: impl Into<String>) -> SystemOperationError {
|
||||
SystemOperationError::new(SystemOperationErrorKind::Internal, message)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use super::super::{map_store_error, publish_committed};
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
plugins::{
|
||||
InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactIconResource,
|
||||
PluginArtifactMetadata, PluginArtifactMutation, PluginIconTheme, PluginInstallResult,
|
||||
PluginSource,
|
||||
distribution::VerifiedPluginArtifact,
|
||||
instances::{
|
||||
ConfigurePluginInstance, PluginCapabilityBinding, PluginFailurePolicy,
|
||||
PluginInstance, PluginPermissionGrant,
|
||||
},
|
||||
},
|
||||
},
|
||||
ports::plugins::{
|
||||
PluginDistribution, PluginPackageInspector, PluginPreparation, PluginStateStore,
|
||||
PluginStore,
|
||||
},
|
||||
ports::proxy::ProxyStore,
|
||||
};
|
||||
|
||||
use super::state::PluginStateService;
|
||||
|
||||
/// 远程插件来源访问所需的传输与受管出站代理目录。
|
||||
pub struct PluginDistributionPorts {
|
||||
pub(super) transport: Arc<dyn PluginDistribution>,
|
||||
pub(super) proxies: Arc<dyn ProxyStore>,
|
||||
}
|
||||
|
||||
impl PluginDistributionPorts {
|
||||
#[must_use]
|
||||
pub fn new(transport: Arc<dyn PluginDistribution>, proxies: Arc<dyn ProxyStore>) -> Self {
|
||||
Self { transport, proxies }
|
||||
}
|
||||
}
|
||||
|
||||
/// 所有安装入口在取得完整包后进入同一校验与事务流程。
|
||||
pub struct PluginsService {
|
||||
pub(super) store: Arc<dyn PluginStore>,
|
||||
pub(super) inspector: Arc<dyn PluginPackageInspector>,
|
||||
pub(super) distribution: PluginDistributionPorts,
|
||||
pub(super) snapshots: Arc<dyn SnapshotControl>,
|
||||
pub(super) preparation: Arc<dyn PluginPreparation>,
|
||||
pub(super) published: gateway_core::runtime::RuntimeSnapshotHandle,
|
||||
pub(super) state: PluginStateService,
|
||||
}
|
||||
|
||||
impl PluginsService {
|
||||
pub fn permission_descriptions(
|
||||
&self,
|
||||
permissions: &[String],
|
||||
) -> Vec<crate::model::plugins::PluginPermissionDescription> {
|
||||
self.inspector.permission_descriptions(permissions)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn PluginStore>,
|
||||
inspector: Arc<dyn PluginPackageInspector>,
|
||||
distribution: PluginDistributionPorts,
|
||||
snapshots: Arc<dyn SnapshotControl>,
|
||||
preparation: Arc<dyn PluginPreparation>,
|
||||
published: gateway_core::runtime::RuntimeSnapshotHandle,
|
||||
state: Arc<dyn PluginStateStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
inspector,
|
||||
distribution,
|
||||
snapshots,
|
||||
preparation,
|
||||
published,
|
||||
state: PluginStateService::new(state),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Result<Vec<InstalledPluginArtifact>, AdminError> {
|
||||
self.store
|
||||
.list_artifacts()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))
|
||||
}
|
||||
|
||||
pub async fn icon(
|
||||
&self,
|
||||
digest: &str,
|
||||
theme: PluginIconTheme,
|
||||
) -> Result<PluginArtifactIconResource, AdminError> {
|
||||
if !valid_digest(digest) {
|
||||
return Err(AdminError::invalid("插件制品摘要不合法"));
|
||||
}
|
||||
let artifact = self
|
||||
.store
|
||||
.load_artifact(digest)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
if artifact.metadata.icon.is_none() {
|
||||
return Err(AdminError::not_found("插件制品未提供图标"));
|
||||
}
|
||||
self.inspector
|
||||
.icon(artifact.archive, digest.to_owned(), theme)
|
||||
.await?
|
||||
.ok_or_else(|| AdminError::not_found("插件制品未提供图标"))
|
||||
}
|
||||
|
||||
pub async fn verify_upload(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
) -> Result<VerifiedPluginArtifact, AdminError> {
|
||||
let artifact = self.inspector.inspect(archive, None).await?;
|
||||
Ok(VerifiedPluginArtifact {
|
||||
metadata: artifact.metadata,
|
||||
source: PluginSource::Upload,
|
||||
})
|
||||
}
|
||||
|
||||
/// 管理上传固定为自定义来源;调用方不能借通用参数声明官方身份。
|
||||
pub async fn install_upload(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
expected_sha256: Option<String>,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstallResult, AdminError> {
|
||||
if expected_sha256.is_none() {
|
||||
return Err(AdminError::invalid("安装需要已校验的插件包摘要"));
|
||||
}
|
||||
let artifact = self.inspector.inspect(archive, expected_sha256).await?;
|
||||
let mutation = self
|
||||
.persist(artifact, PluginSource::Upload, context)
|
||||
.await?;
|
||||
self.complete_install(mutation, context).await
|
||||
}
|
||||
|
||||
/// 接受已导入但尚未安装的制品;官方发行导入也必须经过管理员的这一步。
|
||||
pub async fn accept_artifact(
|
||||
&self,
|
||||
digest: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstallResult, AdminError> {
|
||||
if !valid_digest(digest) {
|
||||
return Err(AdminError::invalid("插件制品摘要不合法"));
|
||||
}
|
||||
let mutation = self
|
||||
.store
|
||||
.accept_artifact(digest, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
|
||||
self.finish_install(mutation, context).await
|
||||
}
|
||||
|
||||
pub(super) async fn complete_install(
|
||||
&self,
|
||||
mutation: PluginArtifactMutation,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstallResult, AdminError> {
|
||||
let digest = mutation.artifact.metadata.sha256.clone();
|
||||
self.accept_artifact(&digest, context).await
|
||||
}
|
||||
|
||||
async fn finish_install(
|
||||
&self,
|
||||
mut mutation: PluginArtifactMutation,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstallResult, AdminError> {
|
||||
let metadata = mutation.artifact.metadata.clone();
|
||||
let creation_id = default_instance_id(&metadata.plugin_id);
|
||||
let configuration = configuration_defaults(&metadata);
|
||||
let bindings = default_bindings(&metadata);
|
||||
let probe = default_instance(
|
||||
&metadata,
|
||||
&creation_id,
|
||||
configuration.clone(),
|
||||
bindings.clone(),
|
||||
false,
|
||||
mutation.config_revision,
|
||||
);
|
||||
let configuration_required = !self
|
||||
.preparation
|
||||
.configuration_ready(probe, &metadata)
|
||||
.await?;
|
||||
|
||||
// 其它管理员写入或同插件的并发安装可能推进全局 revision;稳定 creation ID
|
||||
// 配合每轮重新读取,既不会复制默认实例,也不会覆盖先完成的另一个版本。
|
||||
for _ in 0..3 {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
if let Some((default_instance_id, required)) = self
|
||||
.existing_plugin_instance(&snapshot, &metadata, &creation_id)
|
||||
.await?
|
||||
{
|
||||
mutation.config_revision = snapshot.config_revision;
|
||||
return Ok(PluginInstallResult {
|
||||
mutation,
|
||||
default_instance_id,
|
||||
configuration_required: required,
|
||||
});
|
||||
}
|
||||
|
||||
let input = ConfigurePluginInstance {
|
||||
replace_instances: Vec::new(),
|
||||
creation_id: Some(creation_id.clone()),
|
||||
expected_revision: None,
|
||||
name: metadata.display_name.clone(),
|
||||
artifact_sha256: metadata.sha256.clone(),
|
||||
enabled: !configuration_required,
|
||||
configuration: configuration.clone(),
|
||||
secrets: None,
|
||||
bindings: bindings.clone(),
|
||||
};
|
||||
match self.configure_instance(None, input, context).await {
|
||||
Ok(configured) => {
|
||||
mutation.config_revision = configured.config_revision;
|
||||
return Ok(PluginInstallResult {
|
||||
mutation,
|
||||
default_instance_id: Some(configured.instance.id),
|
||||
configuration_required,
|
||||
});
|
||||
}
|
||||
Err(error) if error.kind() == crate::model::AdminErrorKind::Conflict => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(AdminError::conflict(
|
||||
"插件安装期间配置持续变化,请刷新后重试",
|
||||
))
|
||||
}
|
||||
|
||||
async fn existing_plugin_instance(
|
||||
&self,
|
||||
snapshot: &crate::model::plugins::instances::PluginInstanceSnapshot,
|
||||
metadata: &PluginArtifactMetadata,
|
||||
default_id: &str,
|
||||
) -> Result<Option<(Option<String>, bool)>, AdminError> {
|
||||
let artifacts = self
|
||||
.store
|
||||
.list_artifacts()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let matching_digests = artifacts
|
||||
.into_iter()
|
||||
.filter(|artifact| artifact.metadata.plugin_id == metadata.plugin_id)
|
||||
.map(|artifact| artifact.metadata.sha256)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let Some(instance) = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| matching_digests.contains(&instance.artifact_sha256))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if instance.id == default_id && instance.artifact_sha256 == metadata.sha256 {
|
||||
let required = !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), metadata)
|
||||
.await?;
|
||||
Ok(Some((Some(instance.id.clone()), required)))
|
||||
} else {
|
||||
Ok(Some((None, false)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn persist(
|
||||
&self,
|
||||
artifact: InspectedPluginArtifact,
|
||||
source: PluginSource,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginArtifactMutation, AdminError> {
|
||||
let mutation = self
|
||||
.store
|
||||
.install_artifact(artifact, source, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
|
||||
Ok(mutation)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
&self,
|
||||
digest: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError> {
|
||||
let revision = self
|
||||
.store
|
||||
.delete_artifact(digest, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), revision).await?;
|
||||
Ok(revision)
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_digest(digest: &str) -> bool {
|
||||
digest.len() == 64
|
||||
&& digest
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
|
||||
fn default_instance_id(plugin_id: &str) -> String {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"codex-proxy-rs/plugin-default-instance/v1\0");
|
||||
hash.update(plugin_id.as_bytes());
|
||||
let digest = hash.finalize();
|
||||
let mut bytes = [0_u8; 16];
|
||||
bytes.copy_from_slice(&digest[..16]);
|
||||
// RFC 9562 UUIDv8 保留给应用自定义派生;variant 固定为 RFC 4122。
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x80;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
uuid::Uuid::from_bytes(bytes).to_string()
|
||||
}
|
||||
|
||||
pub(super) fn configuration_defaults(metadata: &PluginArtifactMetadata) -> serde_json::Value {
|
||||
let mut configuration = schema_default(&metadata.configuration_schema)
|
||||
.filter(serde_json::Value::is_object)
|
||||
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
|
||||
merge_property_defaults(&mut configuration, &metadata.configuration_schema);
|
||||
if let Some(configuration) = configuration.as_object_mut() {
|
||||
for secret in &metadata.secret_fields {
|
||||
configuration.remove(secret);
|
||||
}
|
||||
}
|
||||
configuration
|
||||
}
|
||||
|
||||
fn schema_default(schema: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
let mut value = if let Some(value) = schema.get("default") {
|
||||
value.clone()
|
||||
} else {
|
||||
let properties = schema.get("properties")?.as_object()?;
|
||||
let mut object = serde_json::Map::new();
|
||||
for (name, property) in properties {
|
||||
if let Some(value) = schema_default(property) {
|
||||
object.insert(name.clone(), value);
|
||||
}
|
||||
}
|
||||
if object.is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::Value::Object(object)
|
||||
};
|
||||
merge_property_defaults(&mut value, schema);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn merge_property_defaults(value: &mut serde_json::Value, schema: &serde_json::Value) {
|
||||
let (Some(value), Some(properties)) = (
|
||||
value.as_object_mut(),
|
||||
schema
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
for (name, property) in properties {
|
||||
if let Some(current) = value.get_mut(name) {
|
||||
merge_property_defaults(current, property);
|
||||
} else if let Some(default) = schema_default(property) {
|
||||
value.insert(name.clone(), default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec<PluginCapabilityBinding> {
|
||||
metadata
|
||||
.contributes
|
||||
.iter()
|
||||
.filter(|(capability, _)| {
|
||||
!matches!(
|
||||
capability.as_str(),
|
||||
"frontend_authentication" | "management" | "command_line"
|
||||
)
|
||||
})
|
||||
.flat_map(|(_, contribution)| {
|
||||
contribution
|
||||
.stages
|
||||
.iter()
|
||||
.map(|stage| PluginCapabilityBinding {
|
||||
contribution: contribution.id.clone(),
|
||||
stage: stage.clone(),
|
||||
order: 0,
|
||||
failure_policy: if stage == "observation" {
|
||||
PluginFailurePolicy::Observe
|
||||
} else {
|
||||
PluginFailurePolicy::Reject
|
||||
},
|
||||
client_key_ids: Vec::new(),
|
||||
account_group_ids: Vec::new(),
|
||||
provider_ids: Vec::new(),
|
||||
models: Vec::new(),
|
||||
identity_bindings: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_instance(
|
||||
metadata: &PluginArtifactMetadata,
|
||||
id: &str,
|
||||
configuration: serde_json::Value,
|
||||
bindings: Vec<PluginCapabilityBinding>,
|
||||
enabled: bool,
|
||||
revision: Revision,
|
||||
) -> PluginInstance {
|
||||
PluginInstance {
|
||||
id: id.to_owned(),
|
||||
name: metadata.display_name.clone(),
|
||||
artifact_sha256: metadata.sha256.clone(),
|
||||
enabled,
|
||||
trusted_process: true,
|
||||
configuration,
|
||||
secrets: Default::default(),
|
||||
grants: metadata
|
||||
.requested_permissions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|permission| PluginPermissionGrant { permission })
|
||||
.collect(),
|
||||
bindings,
|
||||
revision,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::PluginsService;
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
plugins::{
|
||||
InspectedPluginArtifact, PluginInstallResult, PluginSource,
|
||||
distribution::{
|
||||
GithubReleaseQuery, PluginDistributionEgress, PluginRelease, PluginSourceBinding,
|
||||
PluginUpdateCheck, PluginUpdatePolicy, PluginUpdateSource, RemotePluginInstall,
|
||||
RemotePluginLocation, RemotePluginVerify, SourceAuthentication, SourceCredential,
|
||||
SourceCredentialInfo, VerifiedPluginArtifact,
|
||||
},
|
||||
},
|
||||
},
|
||||
ports::store::AdminStoreErrorKind,
|
||||
use_case::{map_store_error, publish_committed},
|
||||
};
|
||||
|
||||
impl PluginsService {
|
||||
pub async fn update_sources(
|
||||
&self,
|
||||
) -> Result<Vec<crate::model::plugins::distribution::PluginSourceBinding>, AdminError> {
|
||||
self.store
|
||||
.list_update_sources()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))
|
||||
}
|
||||
|
||||
pub async fn change_update_source(
|
||||
&self,
|
||||
mut binding: crate::model::plugins::distribution::PluginSourceBinding,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError> {
|
||||
if matches!(binding.source, PluginUpdateSource::Builtin) {
|
||||
return Err(AdminError::invalid("官方来源只由发行清单确认"));
|
||||
}
|
||||
if let PluginUpdateSource::Github { repository } = &mut binding.source {
|
||||
repository.make_ascii_lowercase();
|
||||
}
|
||||
self.distribution
|
||||
.transport
|
||||
.validate_source(&binding.source)?;
|
||||
validate_update_policy(&binding)?;
|
||||
validate_source_egress(&binding)?;
|
||||
self.resolve_egress(binding.outbound_proxy_id.as_deref())
|
||||
.await?;
|
||||
let revision = self
|
||||
.store
|
||||
.change_update_source(binding, context)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), revision).await?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
pub async fn check_update(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
credential_ids: &[String],
|
||||
) -> Result<PluginUpdateCheck, AdminError> {
|
||||
let binding = self
|
||||
.update_sources()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|binding| binding.plugin_id == plugin_id)
|
||||
.ok_or_else(|| AdminError::not_found("插件更新来源不存在"))?;
|
||||
validate_update_policy(&binding)?;
|
||||
let PluginUpdateSource::Github { repository } = &binding.source else {
|
||||
return Err(AdminError::invalid(
|
||||
"该来源不提供 Release 更新查询,请显式选择并安装插件包",
|
||||
));
|
||||
};
|
||||
let (tag, allow_prerelease) = match &binding.policy {
|
||||
PluginUpdatePolicy::Manual {} => {
|
||||
return Err(AdminError::invalid("请先为该来源设置更新检查策略"));
|
||||
}
|
||||
PluginUpdatePolicy::Stable {} => (None, false),
|
||||
PluginUpdatePolicy::Pinned {
|
||||
tag,
|
||||
allow_prerelease,
|
||||
} => (Some(tag.clone()), *allow_prerelease),
|
||||
};
|
||||
let release = self
|
||||
.query_release(
|
||||
GithubReleaseQuery {
|
||||
repository: repository.clone(),
|
||||
tag,
|
||||
allow_prerelease,
|
||||
},
|
||||
credential_ids,
|
||||
binding.outbound_proxy_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
// 查询期间来源或策略被修改时返回冲突,不把旧来源的候选交给下一步安装。
|
||||
if !self.update_sources().await?.contains(&binding) {
|
||||
return Err(AdminError::conflict("插件更新来源已变更,请重新检查"));
|
||||
}
|
||||
Ok(PluginUpdateCheck { binding, release })
|
||||
}
|
||||
pub async fn source_credentials(&self) -> Result<Vec<SourceCredentialInfo>, AdminError> {
|
||||
self.store
|
||||
.list_source_credentials()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))
|
||||
}
|
||||
|
||||
pub async fn create_source_credential(
|
||||
&self,
|
||||
mut info: SourceCredentialInfo,
|
||||
authentication: SourceAuthentication,
|
||||
context: &MutationContext,
|
||||
) -> Result<SourceCredentialInfo, AdminError> {
|
||||
info.id = uuid::Uuid::now_v7().to_string();
|
||||
let credential = SourceCredential {
|
||||
info: info.clone(),
|
||||
authentication,
|
||||
};
|
||||
self.distribution
|
||||
.transport
|
||||
.validate_credential(&credential)?;
|
||||
let revision = self
|
||||
.store
|
||||
.save_source_credential(credential, context)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), revision).await?;
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub async fn delete_source_credential(
|
||||
&self,
|
||||
id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError> {
|
||||
let revision = self
|
||||
.store
|
||||
.delete_source_credential(id, context)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), revision).await?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
pub async fn query_release(
|
||||
&self,
|
||||
query: GithubReleaseQuery,
|
||||
credential_ids: &[String],
|
||||
outbound_proxy_id: Option<&str>,
|
||||
) -> Result<PluginRelease, AdminError> {
|
||||
let egress = self.resolve_egress(outbound_proxy_id).await?;
|
||||
let release = self
|
||||
.distribution
|
||||
.transport
|
||||
.query_release(
|
||||
query,
|
||||
self.credentials(credential_ids).await?,
|
||||
egress.clone(),
|
||||
)
|
||||
.await?;
|
||||
self.ensure_egress_current(egress.as_ref()).await?;
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
pub async fn install_remote(
|
||||
&self,
|
||||
input: RemotePluginInstall,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstallResult, AdminError> {
|
||||
if !crate::model::plugins::valid_plugin_id(&input.plugin_id)
|
||||
|| input.version.is_empty()
|
||||
|| input.version.len() > 128
|
||||
{
|
||||
return Err(AdminError::invalid("安装需要已解析的插件 ID 与版本"));
|
||||
}
|
||||
let digest = match &input.location {
|
||||
RemotePluginLocation::Url { sha256, .. }
|
||||
| RemotePluginLocation::Github { sha256, .. } => sha256,
|
||||
};
|
||||
if digest.is_none() {
|
||||
return Err(AdminError::invalid("安装需要已校验的插件包摘要"));
|
||||
}
|
||||
let (artifact, source) = self
|
||||
.inspect_remote(RemotePluginVerify {
|
||||
location: input.location,
|
||||
credential_ids: input.credential_ids,
|
||||
outbound_proxy_id: input.outbound_proxy_id,
|
||||
expected_plugin_id: Some(input.plugin_id),
|
||||
})
|
||||
.await?;
|
||||
if artifact.metadata.version != input.version {
|
||||
return Err(AdminError::invalid("插件清单版本与安装选择不符"));
|
||||
}
|
||||
let mutation = self.persist(artifact, source, context).await?;
|
||||
self.complete_install(mutation, context).await
|
||||
}
|
||||
|
||||
pub async fn verify_remote(
|
||||
&self,
|
||||
input: RemotePluginVerify,
|
||||
) -> Result<VerifiedPluginArtifact, AdminError> {
|
||||
let (artifact, source) = self.inspect_remote(input).await?;
|
||||
Ok(VerifiedPluginArtifact {
|
||||
metadata: artifact.metadata,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
async fn inspect_remote(
|
||||
&self,
|
||||
input: RemotePluginVerify,
|
||||
) -> Result<(InspectedPluginArtifact, PluginSource), AdminError> {
|
||||
if input
|
||||
.expected_plugin_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| !crate::model::plugins::valid_plugin_id(id))
|
||||
{
|
||||
return Err(AdminError::invalid("预期插件 ID 不合法"));
|
||||
}
|
||||
let source = match &input.location {
|
||||
RemotePluginLocation::Url { url, .. } => PluginUpdateSource::Url { url: url.clone() },
|
||||
RemotePluginLocation::Github { repository, .. } => PluginUpdateSource::Github {
|
||||
repository: repository.to_ascii_lowercase(),
|
||||
},
|
||||
};
|
||||
let outbound_proxy_id = input.outbound_proxy_id.clone();
|
||||
self.distribution.transport.validate_source(&source)?;
|
||||
let bindings = self.update_sources().await?;
|
||||
let expected_binding = bindings
|
||||
.iter()
|
||||
.find(|binding| Some(&binding.plugin_id) == input.expected_plugin_id.as_ref());
|
||||
if expected_binding.is_some_and(|binding| {
|
||||
binding.source != source || binding.outbound_proxy_id != outbound_proxy_id
|
||||
}) {
|
||||
return Err(AdminError::conflict(
|
||||
"插件来源与已保存配置不符,请先显式修改来源",
|
||||
));
|
||||
}
|
||||
let egress = self.resolve_egress(outbound_proxy_id.as_deref()).await?;
|
||||
let download = self
|
||||
.distribution
|
||||
.transport
|
||||
.download(
|
||||
input.location,
|
||||
self.credentials(&input.credential_ids).await?,
|
||||
egress.clone(),
|
||||
)
|
||||
.await?;
|
||||
self.ensure_egress_current(egress.as_ref()).await?;
|
||||
let artifact = self
|
||||
.inspector
|
||||
.inspect(download.archive, Some(download.sha256))
|
||||
.await?;
|
||||
if input
|
||||
.expected_plugin_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| *id != artifact.metadata.plugin_id)
|
||||
{
|
||||
return Err(AdminError::invalid("插件清单身份与安装选择不符"));
|
||||
}
|
||||
// 首次解析前身份未知,按包内身份复核下载前的来源绑定。
|
||||
let binding = bindings
|
||||
.iter()
|
||||
.find(|binding| binding.plugin_id == artifact.metadata.plugin_id);
|
||||
if binding.is_some_and(|binding| {
|
||||
binding.source != source || binding.outbound_proxy_id != outbound_proxy_id
|
||||
}) {
|
||||
return Err(AdminError::conflict(
|
||||
"插件来源与已保存配置不符,请先显式修改来源",
|
||||
));
|
||||
}
|
||||
if PluginUpdateSource::from(&download.source) != source {
|
||||
return Err(AdminError::invalid("下载来源与校验选择不符"));
|
||||
}
|
||||
if download.source.outbound_proxy() != egress.as_ref().map(|egress| &egress.source) {
|
||||
return Err(AdminError::invalid("下载出站身份与安装选择不符"));
|
||||
}
|
||||
let current_binding = self
|
||||
.update_sources()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|binding| binding.plugin_id == artifact.metadata.plugin_id);
|
||||
if current_binding.as_ref() != binding {
|
||||
return Err(AdminError::conflict("插件更新来源已变更,请重新校验"));
|
||||
}
|
||||
Ok((artifact, download.source))
|
||||
}
|
||||
|
||||
async fn resolve_egress(
|
||||
&self,
|
||||
outbound_proxy_id: Option<&str>,
|
||||
) -> Result<Option<PluginDistributionEgress>, AdminError> {
|
||||
let Some(id) = outbound_proxy_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) {
|
||||
return Err(AdminError::invalid("插件来源出站代理不合法"));
|
||||
}
|
||||
let record = self
|
||||
.distribution
|
||||
.proxies
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
Ok(Some(PluginDistributionEgress::new(
|
||||
record.id,
|
||||
record.revision,
|
||||
record.proxy,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn ensure_egress_current(
|
||||
&self,
|
||||
egress: Option<&PluginDistributionEgress>,
|
||||
) -> Result<(), AdminError> {
|
||||
let Some(egress) = egress else {
|
||||
return Ok(());
|
||||
};
|
||||
let current = match self.distribution.proxies.get(&egress.source.id).await {
|
||||
Ok(current) => current,
|
||||
Err(error) if error.kind() == AdminStoreErrorKind::NotFound => {
|
||||
return Err(AdminError::conflict("插件来源出站代理已变更,请重新校验"));
|
||||
}
|
||||
Err(error) => return Err(map_store_error(error, "proxy")),
|
||||
};
|
||||
if current.revision.get() != egress.source.revision || current.proxy != egress.proxy {
|
||||
return Err(AdminError::conflict("插件来源出站代理已变更,请重新校验"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn credentials(&self, ids: &[String]) -> Result<Vec<SourceCredential>, AdminError> {
|
||||
if ids.len() > 8 || ids.iter().collect::<BTreeSet<_>>().len() != ids.len() {
|
||||
return Err(AdminError::invalid("下载凭据不能重复,且最多选择 8 个"));
|
||||
}
|
||||
let mut credentials = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
credentials.push(
|
||||
self.store
|
||||
.load_source_credential(id)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?,
|
||||
);
|
||||
}
|
||||
Ok(credentials)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_update_policy(binding: &PluginSourceBinding) -> Result<(), AdminError> {
|
||||
if !matches!(binding.source, PluginUpdateSource::Github { .. })
|
||||
&& !matches!(binding.policy, PluginUpdatePolicy::Manual {})
|
||||
{
|
||||
return Err(AdminError::invalid(
|
||||
"只有 GitHub 来源支持 Release 更新检查策略",
|
||||
));
|
||||
}
|
||||
if let PluginUpdatePolicy::Pinned { tag, .. } = &binding.policy
|
||||
&& (tag.is_empty()
|
||||
|| tag.len() > 128
|
||||
|| tag.chars().any(char::is_control)
|
||||
|| tag.trim() != tag)
|
||||
{
|
||||
return Err(AdminError::invalid("固定 Release tag 不合法"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_source_egress(binding: &PluginSourceBinding) -> Result<(), AdminError> {
|
||||
if binding.outbound_proxy_id.is_some()
|
||||
&& matches!(
|
||||
binding.source,
|
||||
PluginUpdateSource::Builtin | PluginUpdateSource::Upload
|
||||
)
|
||||
{
|
||||
return Err(AdminError::invalid("本地或官方来源不能配置出站代理"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId};
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
use super::PluginsService;
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
plugins::instances::{
|
||||
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
|
||||
PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus,
|
||||
PluginInstanceSnapshot, PluginInstanceView, PluginPermissionGrant,
|
||||
},
|
||||
plugins::state::{PluginStateCommit, PluginStateConfiguration},
|
||||
},
|
||||
use_case::{map_store_error, publish_committed},
|
||||
};
|
||||
|
||||
impl PluginsService {
|
||||
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let published = self.published.snapshot_for_diagnostics();
|
||||
let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get());
|
||||
let published_ready = published
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| snapshot.extensions().is_none_or(|set| set.can_serve()));
|
||||
let mut diagnostics = self
|
||||
.preparation
|
||||
.runtime_diagnostics(
|
||||
&snapshot,
|
||||
published_revision,
|
||||
published
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.extensions()),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let target_revision = snapshot.config_revision.get();
|
||||
let artifacts = self
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut views = Vec::with_capacity(snapshot.instances.len());
|
||||
for instance in snapshot.instances {
|
||||
let metadata = artifacts
|
||||
.get(&instance.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
|
||||
let configuration_required = !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), metadata)
|
||||
.await?;
|
||||
let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| {
|
||||
let running = instance.enabled
|
||||
&& published_ready
|
||||
&& published_revision == Some(target_revision);
|
||||
PluginInstanceRuntime {
|
||||
status: if !instance.enabled {
|
||||
PluginInstanceRuntimeStatus::Disabled
|
||||
} else if running {
|
||||
PluginInstanceRuntimeStatus::Running
|
||||
} else {
|
||||
PluginInstanceRuntimeStatus::AwaitingPublication
|
||||
},
|
||||
actual_revision: running.then_some(instance.revision.get()),
|
||||
actual_artifact_sha256: running.then(|| instance.artifact_sha256.clone()),
|
||||
failure: None,
|
||||
draining_revisions: Vec::new(),
|
||||
}
|
||||
});
|
||||
views.push(PluginInstanceView {
|
||||
configuration_required,
|
||||
running: runtime.status == PluginInstanceRuntimeStatus::Running,
|
||||
published_revision,
|
||||
runtime,
|
||||
instance,
|
||||
});
|
||||
}
|
||||
Ok(views)
|
||||
}
|
||||
|
||||
pub async fn configure_instance(
|
||||
&self,
|
||||
id: Option<&str>,
|
||||
input: ConfigurePluginInstance,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
if id.is_some() && input.creation_id.is_some() {
|
||||
return Err(AdminError::invalid("编辑配置不能使用创建标识"));
|
||||
}
|
||||
if id.is_none() && input.expected_revision.is_some() {
|
||||
return Err(AdminError::invalid("创建配置不能指定已有配置版本"));
|
||||
}
|
||||
if let Some(creation_id) = &input.creation_id {
|
||||
let parsed = uuid::Uuid::parse_str(creation_id)
|
||||
.map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?;
|
||||
if parsed.to_string() != *creation_id {
|
||||
return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式"));
|
||||
}
|
||||
}
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let existing = match id {
|
||||
Some(id) => Some(
|
||||
snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?,
|
||||
),
|
||||
None => input.creation_id.as_ref().and_then(|creation_id| {
|
||||
snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == *creation_id)
|
||||
.cloned()
|
||||
}),
|
||||
};
|
||||
if let Some(existing) = &existing {
|
||||
if id.is_none() && !same_creation_request(existing, &input) {
|
||||
return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑"));
|
||||
}
|
||||
if input
|
||||
.expected_revision
|
||||
.is_some_and(|revision| revision != existing.revision.get())
|
||||
{
|
||||
return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试"));
|
||||
}
|
||||
}
|
||||
self.configure_from_snapshot(snapshot, existing, input, context)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn configure_from_snapshot(
|
||||
&self,
|
||||
mut snapshot: PluginInstanceSnapshot,
|
||||
existing: Option<PluginInstance>,
|
||||
input: ConfigurePluginInstance,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let expected_revision = snapshot.config_revision;
|
||||
let candidate_revision = next_revision(expected_revision)?;
|
||||
let artifacts = self
|
||||
.store
|
||||
.list_artifacts()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let artifact = artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.metadata.sha256 == input.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
|
||||
if artifact.accepted_at.is_none() {
|
||||
return Err(AdminError::invalid("请先安装并接受该版本声明的权限"));
|
||||
}
|
||||
let same_plugin = |digest: &str| {
|
||||
artifacts.iter().any(|item| {
|
||||
item.metadata.sha256 == digest
|
||||
&& item.metadata.plugin_id == artifact.metadata.plugin_id
|
||||
})
|
||||
};
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|previous| !same_plugin(&previous.artifact_sha256))
|
||||
{
|
||||
return Err(AdminError::invalid("不能将已有配置切换为另一个插件"));
|
||||
}
|
||||
if input.enabled
|
||||
&& snapshot.instances.iter().any(|other| {
|
||||
other.enabled
|
||||
&& existing
|
||||
.as_ref()
|
||||
.is_none_or(|current| current.id != other.id)
|
||||
&& same_plugin(&other.artifact_sha256)
|
||||
&& !input
|
||||
.replace_instances
|
||||
.iter()
|
||||
.any(|replacement| replacement.id == other.id)
|
||||
})
|
||||
{
|
||||
return Err(AdminError::conflict(
|
||||
"此插件已有启用配置,请先切换或停用旧配置",
|
||||
));
|
||||
}
|
||||
let saved = if let Some(previous) = &existing
|
||||
&& previous.artifact_sha256 != input.artifact_sha256
|
||||
&& input.secrets.is_none()
|
||||
{
|
||||
self.store
|
||||
.load_version_configuration(&previous.id, &input.artifact_sha256)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let instance = PluginInstance {
|
||||
id: existing.as_ref().map_or_else(
|
||||
|| {
|
||||
input
|
||||
.creation_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| uuid::Uuid::now_v7().to_string())
|
||||
},
|
||||
|instance| instance.id.clone(),
|
||||
),
|
||||
name: input.name.trim().to_owned(),
|
||||
artifact_sha256: input.artifact_sha256,
|
||||
enabled: input.enabled,
|
||||
trusted_process: true,
|
||||
configuration: input.configuration,
|
||||
secrets: input
|
||||
.secrets
|
||||
.or_else(|| saved.map(|saved| saved.secrets))
|
||||
.unwrap_or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.map_or_else(Default::default, |instance| instance.secrets.clone())
|
||||
}),
|
||||
grants: artifact
|
||||
.metadata
|
||||
.requested_permissions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|permission| PluginPermissionGrant { permission })
|
||||
.collect(),
|
||||
bindings: input.bindings,
|
||||
revision: candidate_revision,
|
||||
};
|
||||
validate_input(&instance)?;
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256)
|
||||
&& !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), &artifact.metadata)
|
||||
.await?
|
||||
{
|
||||
return Err(AdminError::invalid(
|
||||
"目标版本仍缺少必填设置,请补充后再切换",
|
||||
));
|
||||
}
|
||||
|
||||
let replacements = input.replace_instances;
|
||||
if !replacements.is_empty() {
|
||||
if !instance.enabled || replacements.len() > 256 {
|
||||
return Err(AdminError::invalid(
|
||||
"仅启用配置时可以切换其他配置,单次最多 256 项",
|
||||
));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for replacement in &replacements {
|
||||
if replacement.id == instance.id || !ids.insert(&replacement.id) {
|
||||
return Err(AdminError::invalid("停用列表不能重复或包含当前配置"));
|
||||
}
|
||||
let previous = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|item| item.id == replacement.id)
|
||||
.ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?;
|
||||
if !previous.enabled || previous.revision.get() != replacement.expected_revision {
|
||||
return Err(AdminError::conflict("待停用配置已变更,请重新确认"));
|
||||
}
|
||||
if !artifacts.iter().any(|item| {
|
||||
item.metadata.sha256 == previous.artifact_sha256
|
||||
&& item.metadata.plugin_id == artifact.metadata.plugin_id
|
||||
}) {
|
||||
return Err(AdminError::invalid("只能切换同一插件的配置"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let state = self.preparation.validate(instance.clone()).await?;
|
||||
if let Some(existing) = &existing
|
||||
&& self.state.transition_required(&existing.id, &state).await?
|
||||
{
|
||||
return self
|
||||
.configure_instance_with_state_transition(
|
||||
snapshot,
|
||||
existing.clone(),
|
||||
instance,
|
||||
state,
|
||||
&replacements,
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
snapshot.instances.retain(|item| item.id != instance.id);
|
||||
snapshot.instances.push(instance.clone());
|
||||
snapshot.config_revision = candidate_revision;
|
||||
apply_replacements(&mut snapshot, &replacements);
|
||||
let prepared = self.preparation.prepare(snapshot.clone()).await?;
|
||||
let result = self
|
||||
.store
|
||||
.save_instance_replacing(
|
||||
instance,
|
||||
expected_revision,
|
||||
PluginStateCommit {
|
||||
configuration: state,
|
||||
transition_id: None,
|
||||
},
|
||||
&replacements,
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let prepared = if result.config_revision == candidate_revision
|
||||
&& result.instance.revision == candidate_revision
|
||||
{
|
||||
prepared
|
||||
} else {
|
||||
snapshot.config_revision = result.config_revision;
|
||||
snapshot
|
||||
.instances
|
||||
.retain(|item| item.id != result.instance.id);
|
||||
snapshot.instances.push(result.instance.clone());
|
||||
apply_replacements(&mut snapshot, &replacements);
|
||||
self.preparation.prepare(snapshot).await?
|
||||
};
|
||||
self.preparation
|
||||
.activate_state(&prepared, &result.instance)
|
||||
.await?;
|
||||
// prepared 的强引用跨过提交与唯一发布入口,防止候选在被读取之前回收。
|
||||
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
|
||||
drop(prepared);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn configure_instance_with_state_transition(
|
||||
&self,
|
||||
mut snapshot: PluginInstanceSnapshot,
|
||||
previous: PluginInstance,
|
||||
mut target: PluginInstance,
|
||||
target_state: PluginStateConfiguration,
|
||||
replacements: &[PluginInstanceReplacement],
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let previous_state = self.preparation.validate(previous.clone()).await?;
|
||||
let original_revision = snapshot.config_revision;
|
||||
// 即使最终保持停用,迁移也必须由目标制品的受控候选执行。
|
||||
let mut migration_instance = target.clone();
|
||||
migration_instance.enabled = true;
|
||||
snapshot.config_revision = migration_instance.revision;
|
||||
snapshot
|
||||
.instances
|
||||
.retain(|instance| instance.id != migration_instance.id);
|
||||
snapshot.instances.push(migration_instance);
|
||||
apply_replacements(&mut snapshot, replacements);
|
||||
let migration_prepared = self.preparation.prepare(snapshot).await?;
|
||||
|
||||
let mut disabled_revision = None;
|
||||
let disabled = if previous.enabled {
|
||||
let mut disabled = previous.clone();
|
||||
disabled.enabled = false;
|
||||
let mutation = self
|
||||
.store
|
||||
.save_instance_with_state(
|
||||
disabled,
|
||||
original_revision,
|
||||
PluginStateCommit {
|
||||
configuration: previous_state.clone(),
|
||||
transition_id: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
|
||||
self.preparation
|
||||
.quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision)
|
||||
.await;
|
||||
disabled_revision = Some(mutation.config_revision);
|
||||
mutation.instance
|
||||
} else {
|
||||
previous.clone()
|
||||
};
|
||||
let expected_revision = disabled_revision.unwrap_or(original_revision);
|
||||
let transition = match self
|
||||
.state
|
||||
.begin_transition(
|
||||
&disabled.id,
|
||||
disabled.revision,
|
||||
&target.artifact_sha256,
|
||||
target_state.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(transition) => transition,
|
||||
Err(error) => {
|
||||
return Err(self
|
||||
.recover_failed_state_transition(
|
||||
None,
|
||||
&previous,
|
||||
&previous_state,
|
||||
disabled_revision,
|
||||
context,
|
||||
error,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
};
|
||||
if let Err(error) = self
|
||||
.preparation
|
||||
.migrate_state(&migration_prepared, transition.clone())
|
||||
.await
|
||||
{
|
||||
return Err(self
|
||||
.recover_failed_state_transition(
|
||||
Some(&transition.id),
|
||||
&previous,
|
||||
&previous_state,
|
||||
disabled_revision,
|
||||
context,
|
||||
error,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
|
||||
let mut current = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
if current.config_revision != expected_revision {
|
||||
return Err(self
|
||||
.recover_failed_state_transition(
|
||||
Some(&transition.id),
|
||||
&previous,
|
||||
&previous_state,
|
||||
disabled_revision,
|
||||
context,
|
||||
AdminError::conflict("迁移期间插件配置已发生变化"),
|
||||
)
|
||||
.await);
|
||||
}
|
||||
let candidate_revision = next_revision(expected_revision)?;
|
||||
target.revision = candidate_revision;
|
||||
current.instances.retain(|item| item.id != target.id);
|
||||
current.instances.push(target.clone());
|
||||
current.config_revision = candidate_revision;
|
||||
apply_replacements(&mut current, replacements);
|
||||
let prepared = match self.preparation.prepare(current.clone()).await {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
return Err(self
|
||||
.recover_failed_state_transition(
|
||||
Some(&transition.id),
|
||||
&previous,
|
||||
&previous_state,
|
||||
disabled_revision,
|
||||
context,
|
||||
error,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
};
|
||||
let result = match self
|
||||
.store
|
||||
.save_instance_replacing(
|
||||
target,
|
||||
expected_revision,
|
||||
PluginStateCommit {
|
||||
configuration: target_state,
|
||||
transition_id: Some(transition.id.clone()),
|
||||
},
|
||||
replacements,
|
||||
context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
return Err(self
|
||||
.recover_failed_state_transition(
|
||||
Some(&transition.id),
|
||||
&previous,
|
||||
&previous_state,
|
||||
disabled_revision,
|
||||
context,
|
||||
map_store_error(error, "plugin"),
|
||||
)
|
||||
.await);
|
||||
}
|
||||
};
|
||||
let prepared = if result.config_revision == candidate_revision
|
||||
&& result.instance.revision == candidate_revision
|
||||
{
|
||||
prepared
|
||||
} else {
|
||||
current.config_revision = result.config_revision;
|
||||
current
|
||||
.instances
|
||||
.retain(|item| item.id != result.instance.id);
|
||||
current.instances.push(result.instance.clone());
|
||||
apply_replacements(&mut current, replacements);
|
||||
self.preparation.prepare(current).await?
|
||||
};
|
||||
self.preparation
|
||||
.activate_state(&prepared, &result.instance)
|
||||
.await?;
|
||||
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
|
||||
drop(migration_prepared);
|
||||
drop(prepared);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn recover_failed_state_transition(
|
||||
&self,
|
||||
transition_id: Option<&str>,
|
||||
previous: &PluginInstance,
|
||||
previous_state: &PluginStateConfiguration,
|
||||
disabled_revision: Option<Revision>,
|
||||
context: &MutationContext,
|
||||
original_error: AdminError,
|
||||
) -> AdminError {
|
||||
if let Some(transition_id) = transition_id {
|
||||
self.state.abort_transition(transition_id).await;
|
||||
}
|
||||
let Some(disabled_revision) = disabled_revision else {
|
||||
return original_error;
|
||||
};
|
||||
let restored = async {
|
||||
let mut snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
if snapshot.config_revision != disabled_revision {
|
||||
return Err(AdminError::conflict(
|
||||
"状态迁移失败;实例已保持停用,且配置已被其他操作修改",
|
||||
));
|
||||
}
|
||||
let candidate_revision = next_revision(disabled_revision)?;
|
||||
let mut restore = previous.clone();
|
||||
restore.enabled = true;
|
||||
restore.revision = candidate_revision;
|
||||
snapshot.instances.retain(|item| item.id != restore.id);
|
||||
snapshot.instances.push(restore.clone());
|
||||
snapshot.config_revision = candidate_revision;
|
||||
let prepared = self.preparation.prepare(snapshot.clone()).await?;
|
||||
let mutation = self
|
||||
.store
|
||||
.save_instance_with_state(
|
||||
restore,
|
||||
disabled_revision,
|
||||
PluginStateCommit {
|
||||
configuration: previous_state.clone(),
|
||||
transition_id: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let prepared = if mutation.config_revision == candidate_revision
|
||||
&& mutation.instance.revision == candidate_revision
|
||||
{
|
||||
prepared
|
||||
} else {
|
||||
snapshot.config_revision = mutation.config_revision;
|
||||
snapshot
|
||||
.instances
|
||||
.retain(|item| item.id != mutation.instance.id);
|
||||
snapshot.instances.push(mutation.instance.clone());
|
||||
self.preparation.prepare(snapshot).await?
|
||||
};
|
||||
self.preparation
|
||||
.activate_state(&prepared, &mutation.instance)
|
||||
.await?;
|
||||
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
|
||||
Ok::<(), AdminError>(())
|
||||
}
|
||||
.await;
|
||||
match restored {
|
||||
Ok(()) => original_error,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error_kind = ?error.kind(),
|
||||
"plugin state migration failed and the previous instance could not be restored"
|
||||
);
|
||||
AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_instance(
|
||||
&self,
|
||||
id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let instance = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
if instance.enabled {
|
||||
return Err(AdminError::conflict("请先停用插件实例"));
|
||||
}
|
||||
let revision = self
|
||||
.store
|
||||
.delete_instance(id, snapshot.config_revision, context)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), revision).await?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
/// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同。
|
||||
pub async fn disable_instance(
|
||||
&self,
|
||||
id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let PluginInstanceSnapshot {
|
||||
config_revision,
|
||||
instances,
|
||||
} = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let mut instance = instances
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
instance.enabled = false;
|
||||
let result = self
|
||||
.store
|
||||
.save_instance(instance, config_revision, context)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_replacements(
|
||||
snapshot: &mut PluginInstanceSnapshot,
|
||||
replacements: &[PluginInstanceReplacement],
|
||||
) {
|
||||
for instance in &mut snapshot.instances {
|
||||
if replacements.iter().any(|item| item.id == instance.id) {
|
||||
instance.enabled = false;
|
||||
instance.revision = snapshot.config_revision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_creation_request(existing: &PluginInstance, input: &ConfigurePluginInstance) -> bool {
|
||||
let same_secrets = input
|
||||
.secrets
|
||||
.as_ref()
|
||||
.map_or(existing.secrets.is_empty(), |secrets| {
|
||||
secrets.len() == existing.secrets.len()
|
||||
&& secrets.iter().all(|(key, value)| {
|
||||
existing
|
||||
.secrets
|
||||
.get(key)
|
||||
.is_some_and(|current| current.expose_secret() == value.expose_secret())
|
||||
})
|
||||
});
|
||||
existing.name == input.name.trim()
|
||||
&& existing.artifact_sha256 == input.artifact_sha256
|
||||
&& existing.enabled == input.enabled
|
||||
&& existing.configuration == input.configuration
|
||||
&& existing.bindings == input.bindings
|
||||
&& same_secrets
|
||||
}
|
||||
|
||||
fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> {
|
||||
if instance.name.is_empty()
|
||||
|| instance.name.len() > 128
|
||||
|| instance.name.chars().any(char::is_control)
|
||||
|| instance.artifact_sha256.len() != 64
|
||||
|| !instance
|
||||
.artifact_sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
|| !instance.configuration.is_object()
|
||||
|| serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024)
|
||||
|| instance.secrets.len() > 64
|
||||
|| instance.grants.len() > 32
|
||||
|| instance.bindings.len() > 64
|
||||
{
|
||||
return Err(AdminError::invalid("插件实例配置不合法或超过大小限制"));
|
||||
}
|
||||
use secrecy::ExposeSecret as _;
|
||||
if instance
|
||||
.secrets
|
||||
.iter()
|
||||
.any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192)
|
||||
|| instance
|
||||
.secrets
|
||||
.values()
|
||||
.map(|value| value.expose_secret().len())
|
||||
.sum::<usize>()
|
||||
> 48 * 1024
|
||||
{
|
||||
return Err(AdminError::invalid("插件敏感配置超过大小限制"));
|
||||
}
|
||||
if instance.bindings.iter().any(|binding| {
|
||||
binding.contribution.is_empty()
|
||||
|| binding.contribution.len() > 128
|
||||
|| !binding
|
||||
.contribution
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-'))
|
||||
|| binding.client_key_ids.len() > 256
|
||||
|| binding.account_group_ids.len() > 256
|
||||
|| binding.client_key_ids.iter().collect::<BTreeSet<_>>().len()
|
||||
!= binding.client_key_ids.len()
|
||||
|| binding
|
||||
.account_group_ids
|
||||
.iter()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len()
|
||||
!= binding.account_group_ids.len()
|
||||
|| binding
|
||||
.client_key_ids
|
||||
.iter()
|
||||
.any(|id| ClientApiKeyId::new(id.clone()).is_err())
|
||||
|| binding
|
||||
.account_group_ids
|
||||
.iter()
|
||||
.any(|id| AccountGroupId::new(id.clone()).is_err())
|
||||
|| binding.identity_bindings.len() > 256
|
||||
|| binding
|
||||
.identity_bindings
|
||||
.iter()
|
||||
.map(|identity| &identity.principal)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len()
|
||||
!= binding.identity_bindings.len()
|
||||
|| binding.identity_bindings.iter().any(|identity| {
|
||||
identity.principal.trim() != identity.principal
|
||||
|| identity.principal.is_empty()
|
||||
|| identity.principal.len() > 256
|
||||
|| identity.principal.chars().any(char::is_control)
|
||||
|| ClientApiKeyId::new(identity.client_key_id.clone()).is_err()
|
||||
})
|
||||
}) {
|
||||
return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_revision(revision: Revision) -> Result<Revision, AdminError> {
|
||||
Revision::new(
|
||||
revision
|
||||
.get()
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("配置版本不合法"))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_core::runtime::{RuntimeSnapshotHandle, extensions::ExtensionSetReference};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
plugins::{
|
||||
instances::PluginInstance,
|
||||
management::{
|
||||
PluginManagementRequest, PluginManagementResponse, PluginManagementTarget,
|
||||
PluginManagementView,
|
||||
},
|
||||
},
|
||||
},
|
||||
ports::{plugin_management::PluginManagement, plugins::PluginStore},
|
||||
};
|
||||
|
||||
/// 菜单是发布视图的投影;每次业务动作仍复核持久实例,旧页面不能保留已撤销权限。
|
||||
pub struct PluginManagementService {
|
||||
runtime: Arc<dyn PluginManagement>,
|
||||
store: Arc<dyn PluginStore>,
|
||||
published: RuntimeSnapshotHandle,
|
||||
}
|
||||
|
||||
impl PluginManagementService {
|
||||
/// 页面发起模型请求前复核持久目标与当前发布代次;长响应可重复调用以收敛撤销。
|
||||
pub async fn authorize_models(
|
||||
&self,
|
||||
target: &PluginManagementTarget,
|
||||
) -> Result<(), AdminError> {
|
||||
let reference = self.authorize(target).await?;
|
||||
self.runtime.authorize_models(&reference, target).await
|
||||
}
|
||||
|
||||
pub async fn start_callback(
|
||||
&self,
|
||||
target: &PluginManagementTarget,
|
||||
command: crate::model::plugins::management::StartPluginManagementCallback,
|
||||
context: &crate::model::auth::AdminRequestContext,
|
||||
) -> Result<crate::model::plugins::management::PluginManagementCallbackTicket, AdminError> {
|
||||
let reference = self.authorize(target).await?;
|
||||
self.runtime
|
||||
.start_callback(&reference, target, command, context)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn callback(
|
||||
&self,
|
||||
target: &PluginManagementTarget,
|
||||
state: &str,
|
||||
request: PluginManagementRequest,
|
||||
) -> Result<PluginManagementResponse, AdminError> {
|
||||
let reference = self.authorize(target).await?;
|
||||
self.runtime
|
||||
.callback(&reference, target, state, request)
|
||||
.await
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
runtime: Arc<dyn PluginManagement>,
|
||||
store: Arc<dyn PluginStore>,
|
||||
published: RuntimeSnapshotHandle,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
store,
|
||||
published,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn views(&self) -> Result<Vec<PluginManagementView>, AdminError> {
|
||||
let Some(reference) = self.reference()? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut views = self.runtime.views(&reference).await?;
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|_| AdminError::unavailable("无法复核插件页面授权"))?;
|
||||
views.retain(|view| {
|
||||
snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.any(|instance| matches(instance, &view.target))
|
||||
});
|
||||
Ok(views)
|
||||
}
|
||||
|
||||
pub async fn resource(
|
||||
&self,
|
||||
target: &PluginManagementTarget,
|
||||
path: &str,
|
||||
public: bool,
|
||||
) -> Result<PluginManagementResponse, AdminError> {
|
||||
let reference = self.authorize(target).await?;
|
||||
self.runtime
|
||||
.resource(&reference, target, path, public)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
&self,
|
||||
target: &PluginManagementTarget,
|
||||
request: PluginManagementRequest,
|
||||
) -> Result<PluginManagementResponse, AdminError> {
|
||||
let reference = self.authorize(target).await?;
|
||||
self.runtime.handle(&reference, target, request).await
|
||||
}
|
||||
|
||||
fn reference(&self) -> Result<Option<ExtensionSetReference>, AdminError> {
|
||||
self.published
|
||||
.acquire()
|
||||
.map(|snapshot| snapshot.extensions().cloned())
|
||||
.map_err(|_| AdminError::unavailable("插件发布视图暂不可用"))
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
target: &PluginManagementTarget,
|
||||
) -> Result<ExtensionSetReference, AdminError> {
|
||||
let reference = self
|
||||
.reference()?
|
||||
.ok_or_else(|| AdminError::not_found("插件管理扩展不存在"))?;
|
||||
let current = self
|
||||
.store
|
||||
.management_target_is_current(target)
|
||||
.await
|
||||
.map_err(|_| AdminError::unavailable("无法复核插件管理授权"))?;
|
||||
if !current {
|
||||
return Err(AdminError::conflict("插件版本或授权已变化,请刷新页面"));
|
||||
}
|
||||
Ok(reference)
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(instance: &PluginInstance, target: &PluginManagementTarget) -> bool {
|
||||
instance.enabled
|
||||
&& instance.trusted_process
|
||||
&& instance.id == target.instance_id
|
||||
&& instance.artifact_sha256 == target.artifact_sha256
|
||||
&& instance.revision.get() == target.revision
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod artifacts;
|
||||
mod distribution;
|
||||
mod instances;
|
||||
mod management;
|
||||
pub(crate) mod official;
|
||||
mod state;
|
||||
mod versions;
|
||||
|
||||
pub use artifacts::{PluginDistributionPorts, PluginsService};
|
||||
pub use management::PluginManagementService;
|
||||
@@ -0,0 +1,362 @@
|
||||
use std::{collections::BTreeSet, path::Path};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationActor, MutationContext,
|
||||
plugins::{
|
||||
InspectedPluginArtifact, PluginHostCompatibility, PluginSource,
|
||||
official::{OfficialPluginImport, OfficialPluginReleaseIdentity},
|
||||
},
|
||||
},
|
||||
ports::plugin_release::{
|
||||
OfficialPluginReleaseFiles, OfficialPluginReleaseReadError,
|
||||
OfficialPluginReleaseReadErrorKind,
|
||||
},
|
||||
};
|
||||
|
||||
use super::PluginsService;
|
||||
|
||||
const MANIFEST_SCHEMA_VERSION: u32 = 1;
|
||||
const MAXIMUM_MANIFEST_BYTES: usize = 256 * 1024;
|
||||
const MAXIMUM_PLUGINS: usize = 64;
|
||||
const MAXIMUM_ARTIFACTS_PER_PLUGIN: usize = 8;
|
||||
const MAXIMUM_VERSION_BYTES: usize = 128;
|
||||
const MAXIMUM_PUBLISHER_BYTES: usize = 256;
|
||||
const MAXIMUM_STABILITY_BYTES: usize = 32;
|
||||
const MAXIMUM_ARCHIVE_NAME_BYTES: usize = 255;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SealedReleaseManifest {
|
||||
schema_version: u32,
|
||||
sealed: bool,
|
||||
gateway_version: String,
|
||||
gateway_git_sha: String,
|
||||
plugin_host: PluginHostCompatibility,
|
||||
plugins: Vec<OfficialPlugin>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct OfficialPlugin {
|
||||
id: String,
|
||||
version: String,
|
||||
publisher: String,
|
||||
stability: String,
|
||||
artifacts: Vec<OfficialArtifact>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct OfficialArtifact {
|
||||
release_os: String,
|
||||
release_architecture: String,
|
||||
target: String,
|
||||
os: String,
|
||||
architecture: String,
|
||||
archive: String,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
struct ValidatedOfficialArtifact {
|
||||
inspected: InspectedPluginArtifact,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Platform {
|
||||
release_os: &'static str,
|
||||
release_architecture: &'static str,
|
||||
target: &'static str,
|
||||
runtime_os: &'static str,
|
||||
runtime_architecture: &'static str,
|
||||
}
|
||||
|
||||
impl PluginsService {
|
||||
/// 从与宿主发行物同信任边界的封口清单导入官方包。
|
||||
///
|
||||
/// 所有清单项和包体会先完成静态校验,再进入与上传/远程安装相同的持久化用例。
|
||||
pub async fn import_official_release(
|
||||
&self,
|
||||
files: &dyn OfficialPluginReleaseFiles,
|
||||
identity: &OfficialPluginReleaseIdentity,
|
||||
context: &MutationContext,
|
||||
) -> Result<OfficialPluginImport, AdminError> {
|
||||
if !matches!(context.actor, MutationActor::System) {
|
||||
return Err(AdminError::new(
|
||||
AdminErrorKind::Forbidden,
|
||||
"官方插件只能由宿主发行导入",
|
||||
));
|
||||
}
|
||||
let Some(bytes) = files.manifest().await.map_err(map_read_error)? else {
|
||||
return Ok(OfficialPluginImport {
|
||||
artifacts: 0,
|
||||
config_revision: None,
|
||||
});
|
||||
};
|
||||
let ValidatedManifest {
|
||||
gateway_version,
|
||||
plugins,
|
||||
plugins_len,
|
||||
} = validate_manifest(&bytes, identity)?;
|
||||
let platform = current_platform()
|
||||
.ok_or_else(|| AdminError::invalid("当前平台不支持官方插件发行物"))?;
|
||||
let mut validated = Vec::with_capacity(plugins.len());
|
||||
|
||||
// 先完成整份清单的静态检查;持久化故障后的已提交前缀由下次启动幂等补齐。
|
||||
for plugin in plugins {
|
||||
let artifact = select_artifact(&plugin, platform)?;
|
||||
let archive = files
|
||||
.artifact(&artifact.archive)
|
||||
.await
|
||||
.map_err(map_read_error)?;
|
||||
let inspected = self
|
||||
.inspector
|
||||
.inspect(archive, Some(artifact.sha256.clone()))
|
||||
.await?;
|
||||
validate_inspected(&plugin, &artifact, &inspected)?;
|
||||
validated.push(ValidatedOfficialArtifact { inspected });
|
||||
}
|
||||
|
||||
let mut revision = None;
|
||||
for artifact in validated {
|
||||
let mutation = self
|
||||
.persist(
|
||||
artifact.inspected,
|
||||
PluginSource::Builtin {
|
||||
release: gateway_version.clone(),
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await?;
|
||||
revision = Some(mutation.config_revision);
|
||||
}
|
||||
Ok(OfficialPluginImport {
|
||||
artifacts: plugins_len,
|
||||
config_revision: revision,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ValidatedManifest {
|
||||
gateway_version: String,
|
||||
plugins: Vec<OfficialPlugin>,
|
||||
plugins_len: usize,
|
||||
}
|
||||
|
||||
fn validate_manifest(
|
||||
bytes: &[u8],
|
||||
identity: &OfficialPluginReleaseIdentity,
|
||||
) -> Result<ValidatedManifest, AdminError> {
|
||||
let manifest = parse_manifest(bytes)?;
|
||||
if manifest.gateway_version != identity.gateway_version
|
||||
|| manifest.gateway_git_sha != identity.gateway_git_sha
|
||||
{
|
||||
return Err(invalid_manifest());
|
||||
}
|
||||
let plugins_len = manifest.plugins.len();
|
||||
Ok(ValidatedManifest {
|
||||
gateway_version: manifest.gateway_version,
|
||||
plugins: manifest.plugins,
|
||||
plugins_len,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn update_compatibility(
|
||||
bytes: &[u8],
|
||||
target_version: &str,
|
||||
) -> Result<PluginHostCompatibility, AdminError> {
|
||||
let manifest = parse_manifest(bytes)?;
|
||||
(manifest.gateway_version == target_version)
|
||||
.then_some(manifest.plugin_host)
|
||||
.ok_or_else(invalid_manifest)
|
||||
}
|
||||
|
||||
fn parse_manifest(bytes: &[u8]) -> Result<SealedReleaseManifest, AdminError> {
|
||||
if bytes.len() > MAXIMUM_MANIFEST_BYTES {
|
||||
return Err(invalid_manifest());
|
||||
}
|
||||
let manifest: SealedReleaseManifest =
|
||||
serde_json::from_slice(bytes).map_err(|_| invalid_manifest())?;
|
||||
if manifest.schema_version != MANIFEST_SCHEMA_VERSION
|
||||
|| !manifest.sealed
|
||||
|| !valid_text(&manifest.gateway_version, MAXIMUM_VERSION_BYTES)
|
||||
|| !valid_git_sha(&manifest.gateway_git_sha)
|
||||
|| !manifest.plugin_host.is_valid()
|
||||
|| manifest.plugins.len() > MAXIMUM_PLUGINS
|
||||
{
|
||||
return Err(invalid_manifest());
|
||||
}
|
||||
let mut plugin_ids = BTreeSet::new();
|
||||
let mut archives = BTreeSet::new();
|
||||
for plugin in &manifest.plugins {
|
||||
if !crate::model::plugins::valid_plugin_id(&plugin.id)
|
||||
|| !valid_text(&plugin.version, MAXIMUM_VERSION_BYTES)
|
||||
|| !valid_text(&plugin.publisher, MAXIMUM_PUBLISHER_BYTES)
|
||||
|| plugin.id.split_once('.').map(|(publisher, _)| publisher)
|
||||
!= Some(plugin.publisher.as_str())
|
||||
|| !valid_text(&plugin.stability, MAXIMUM_STABILITY_BYTES)
|
||||
|| !plugin_ids.insert(plugin.id.as_str())
|
||||
|| plugin.artifacts.is_empty()
|
||||
|| plugin.artifacts.len() > MAXIMUM_ARTIFACTS_PER_PLUGIN
|
||||
{
|
||||
return Err(invalid_manifest());
|
||||
}
|
||||
let mut platforms = BTreeSet::new();
|
||||
for artifact in &plugin.artifacts {
|
||||
if !valid_artifact(plugin, artifact)
|
||||
|| !platforms.insert((
|
||||
artifact.release_os.as_str(),
|
||||
artifact.release_architecture.as_str(),
|
||||
))
|
||||
|| !archives.insert(artifact.archive.as_str())
|
||||
{
|
||||
return Err(invalid_manifest());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn valid_artifact(plugin: &OfficialPlugin, artifact: &OfficialArtifact) -> bool {
|
||||
supported_platform(artifact).is_some()
|
||||
&& valid_archive_name(&artifact.archive)
|
||||
&& valid_sha256(&artifact.sha256)
|
||||
&& artifact.archive
|
||||
== format!(
|
||||
"{}-{}-{}-{}.tar.gz",
|
||||
plugin.id, plugin.version, artifact.os, artifact.architecture
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_platform(artifact: &OfficialArtifact) -> Option<Platform> {
|
||||
SUPPORTED_PLATFORMS.iter().copied().find(|platform| {
|
||||
artifact.release_os == platform.release_os
|
||||
&& artifact.release_architecture == platform.release_architecture
|
||||
&& artifact.target == platform.target
|
||||
&& artifact.os == platform.runtime_os
|
||||
&& artifact.architecture == platform.runtime_architecture
|
||||
})
|
||||
}
|
||||
|
||||
fn select_artifact(
|
||||
plugin: &OfficialPlugin,
|
||||
current: Platform,
|
||||
) -> Result<OfficialArtifact, AdminError> {
|
||||
let mut matching = plugin.artifacts.iter().filter(|artifact| {
|
||||
artifact.release_os == current.release_os
|
||||
&& artifact.release_architecture == current.release_architecture
|
||||
&& artifact.target == current.target
|
||||
&& artifact.os == current.runtime_os
|
||||
&& artifact.architecture == current.runtime_architecture
|
||||
});
|
||||
let selected = matching.next().cloned().ok_or_else(invalid_manifest)?;
|
||||
if matching.next().is_some() {
|
||||
return Err(invalid_manifest());
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
fn validate_inspected(
|
||||
plugin: &OfficialPlugin,
|
||||
artifact: &OfficialArtifact,
|
||||
inspected: &InspectedPluginArtifact,
|
||||
) -> Result<(), AdminError> {
|
||||
if inspected.metadata.plugin_id != plugin.id
|
||||
|| inspected.metadata.publisher != plugin.publisher
|
||||
|| inspected.metadata.version != plugin.version
|
||||
|| inspected.metadata.sha256 != artifact.sha256
|
||||
|| !inspected
|
||||
.metadata
|
||||
.platforms
|
||||
.iter()
|
||||
.any(|value| value == &format!("{}-{}", artifact.os, artifact.architecture))
|
||||
{
|
||||
return Err(AdminError::invalid("官方插件包身份与发行清单不一致"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_text(value: &str, maximum: usize) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= maximum
|
||||
&& value.trim() == value
|
||||
&& !value.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
fn valid_git_sha(value: &str) -> bool {
|
||||
value.len() == 40
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
|
||||
}
|
||||
|
||||
fn valid_sha256(value: &str) -> bool {
|
||||
value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
|
||||
}
|
||||
|
||||
fn valid_archive_name(value: &str) -> bool {
|
||||
value.len() <= MAXIMUM_ARCHIVE_NAME_BYTES
|
||||
&& value.ends_with(".tar.gz")
|
||||
&& Path::new(value).file_name().and_then(|name| name.to_str()) == Some(value)
|
||||
&& Path::new(value).components().count() == 1
|
||||
&& !value.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
fn map_read_error(error: OfficialPluginReleaseReadError) -> AdminError {
|
||||
match error.kind() {
|
||||
OfficialPluginReleaseReadErrorKind::Invalid
|
||||
| OfficialPluginReleaseReadErrorKind::NotFound => invalid_manifest(),
|
||||
OfficialPluginReleaseReadErrorKind::Unavailable => {
|
||||
AdminError::unavailable("官方插件发行物暂不可读取")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_manifest() -> AdminError {
|
||||
AdminError::invalid("官方插件发行清单不合法或不属于当前宿主发行物")
|
||||
}
|
||||
|
||||
fn current_platform() -> Option<Platform> {
|
||||
SUPPORTED_PLATFORMS.iter().copied().find(|platform| {
|
||||
platform.runtime_os == std::env::consts::OS
|
||||
&& platform.runtime_architecture == std::env::consts::ARCH
|
||||
&& target_environment_matches(platform.target)
|
||||
})
|
||||
}
|
||||
|
||||
fn target_environment_matches(target: &str) -> bool {
|
||||
match target {
|
||||
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
|
||||
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
const SUPPORTED_PLATFORMS: &[Platform] = &[
|
||||
Platform {
|
||||
release_os: "linux",
|
||||
release_architecture: "amd64",
|
||||
target: "x86_64-unknown-linux-gnu",
|
||||
runtime_os: "linux",
|
||||
runtime_architecture: "x86_64",
|
||||
},
|
||||
Platform {
|
||||
release_os: "linux",
|
||||
release_architecture: "arm64",
|
||||
target: "aarch64-unknown-linux-gnu",
|
||||
runtime_os: "linux",
|
||||
runtime_architecture: "aarch64",
|
||||
},
|
||||
Platform {
|
||||
release_os: "darwin",
|
||||
release_architecture: "arm64",
|
||||
target: "aarch64-apple-darwin",
|
||||
runtime_os: "macos",
|
||||
runtime_architecture: "aarch64",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,73 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, Revision,
|
||||
plugins::state::{PluginStateConfiguration, PluginStateTransition},
|
||||
},
|
||||
ports::plugins::{PluginStateStore, PluginStateStoreError, PluginStateStoreErrorKind},
|
||||
};
|
||||
|
||||
pub(super) struct PluginStateService {
|
||||
store: Arc<dyn PluginStateStore>,
|
||||
}
|
||||
|
||||
impl PluginStateService {
|
||||
#[must_use]
|
||||
pub(super) fn new(store: Arc<dyn PluginStateStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
pub(super) async fn transition_required(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
target: &PluginStateConfiguration,
|
||||
) -> Result<bool, AdminError> {
|
||||
self.store
|
||||
.transition_required(instance_id, target)
|
||||
.await
|
||||
.map_err(map_state_error)
|
||||
}
|
||||
|
||||
pub(super) async fn begin_transition(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
expected_instance_revision: Revision,
|
||||
artifact_sha256: &str,
|
||||
target: PluginStateConfiguration,
|
||||
) -> Result<PluginStateTransition, AdminError> {
|
||||
self.store
|
||||
.begin_transition(
|
||||
instance_id,
|
||||
expected_instance_revision,
|
||||
artifact_sha256,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map_err(map_state_error)
|
||||
}
|
||||
|
||||
pub(super) async fn abort_transition(&self, transition_id: &str) {
|
||||
if let Err(error) = self.store.abort_transition(transition_id).await {
|
||||
tracing::warn!(
|
||||
error_kind = ?error.kind(),
|
||||
"failed to discard plugin state staging generation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_state_error(error: PluginStateStoreError) -> AdminError {
|
||||
match error.kind() {
|
||||
PluginStateStoreErrorKind::Invalid => AdminError::invalid("插件状态声明不合法"),
|
||||
PluginStateStoreErrorKind::NotFound => AdminError::not_found("插件状态实例不存在"),
|
||||
PluginStateStoreErrorKind::Conflict => {
|
||||
AdminError::conflict("插件状态版本不兼容或已发生并发变更")
|
||||
}
|
||||
PluginStateStoreErrorKind::Quota => AdminError::conflict("插件状态迁移超过声明配额"),
|
||||
PluginStateStoreErrorKind::PermissionDenied => {
|
||||
AdminError::conflict("插件状态 fence 已失效")
|
||||
}
|
||||
PluginStateStoreErrorKind::Unavailable => AdminError::unavailable("插件状态存储暂不可用"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use super::{
|
||||
PluginsService,
|
||||
artifacts::{configuration_defaults, default_bindings},
|
||||
};
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
plugins::{
|
||||
PluginArtifactMetadata,
|
||||
instances::{
|
||||
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
|
||||
PluginRollbackPlan, PluginRollbackTarget, PluginVersionConfiguration,
|
||||
PluginVersionPlan, RollbackPluginInstance,
|
||||
},
|
||||
},
|
||||
},
|
||||
use_case::map_store_error,
|
||||
};
|
||||
|
||||
impl PluginsService {
|
||||
pub async fn rollback_instance(
|
||||
&self,
|
||||
id: &str,
|
||||
input: RollbackPluginInstance,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let existing = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
if existing.revision.get() != input.expected_revision {
|
||||
return Err(AdminError::conflict("插件实例已变更,请重新确认回滚目标"));
|
||||
}
|
||||
let artifacts = self.list().await?;
|
||||
let current = artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.metadata.sha256 == existing.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("当前插件制品不存在"))?;
|
||||
let target = artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.metadata.sha256 == input.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("回滚目标制品尚未安装"))?;
|
||||
if rollback_version(¤t.metadata, &target.metadata)?.is_none() {
|
||||
return Err(AdminError::invalid("回滚目标必须是同一插件的较早版本"));
|
||||
}
|
||||
let saved = self
|
||||
.store
|
||||
.load_version_configuration(id, &input.artifact_sha256)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?
|
||||
.ok_or_else(|| {
|
||||
AdminError::invalid("该版本没有可恢复的配置,请通过版本切换检查当前设置")
|
||||
})?;
|
||||
let configuration = ConfigurePluginInstance {
|
||||
creation_id: None,
|
||||
expected_revision: None,
|
||||
replace_instances: Vec::new(),
|
||||
name: existing.name.clone(),
|
||||
artifact_sha256: input.artifact_sha256,
|
||||
enabled: existing.enabled,
|
||||
configuration: saved.configuration,
|
||||
secrets: Some(saved.secrets),
|
||||
bindings: saved.bindings,
|
||||
};
|
||||
// 沿用同一份快照的 CAS 和状态迁移流程,不能在检查目标后重新读取并覆盖并发配置。
|
||||
self.configure_from_snapshot(snapshot, Some(existing), configuration, context)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn rollback_plan(&self, id: &str) -> Result<PluginRollbackPlan, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let instance = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
let artifacts = self.list().await?;
|
||||
let current = artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.metadata.sha256 == instance.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("当前插件制品不存在"))?;
|
||||
let saved_versions = self
|
||||
.store
|
||||
.configuration_versions(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let mut targets = Vec::new();
|
||||
for artifact in artifacts.iter().filter(|artifact| {
|
||||
artifact.accepted_at.is_some() && saved_versions.contains(&artifact.metadata.sha256)
|
||||
}) {
|
||||
if let Some(version) = rollback_version(¤t.metadata, &artifact.metadata)? {
|
||||
targets.push((
|
||||
version,
|
||||
PluginRollbackTarget {
|
||||
artifact_sha256: artifact.metadata.sha256.clone(),
|
||||
version: artifact.metadata.version.clone(),
|
||||
platforms: artifact.metadata.platforms.clone(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
targets.sort_by(|(left, left_target), (right, right_target)| {
|
||||
right.cmp_precedence(left).then_with(|| {
|
||||
left_target
|
||||
.artifact_sha256
|
||||
.cmp(&right_target.artifact_sha256)
|
||||
})
|
||||
});
|
||||
Ok(PluginRollbackPlan {
|
||||
instance_revision: instance.revision.get(),
|
||||
current_version: current.metadata.version.clone(),
|
||||
targets: targets.into_iter().map(|(_, target)| target).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn version_plan(
|
||||
&self,
|
||||
id: &str,
|
||||
digest: &str,
|
||||
) -> Result<PluginVersionPlan, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let instance = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
let (configuration, restored) = self.version_configuration(instance, digest).await?;
|
||||
Ok(PluginVersionPlan {
|
||||
instance_revision: instance.revision.get(),
|
||||
artifact_sha256: digest.to_owned(),
|
||||
configuration: configuration.configuration,
|
||||
secret_fields: configuration.secrets.into_keys().collect(),
|
||||
bindings: configuration.bindings,
|
||||
restored,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn switch_instance_version(
|
||||
&self,
|
||||
id: &str,
|
||||
input: RollbackPluginInstance,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let existing = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
if existing.revision.get() != input.expected_revision {
|
||||
return Err(AdminError::conflict("插件设置已变更,请刷新后重试"));
|
||||
}
|
||||
if existing.artifact_sha256 == input.artifact_sha256 {
|
||||
return Err(AdminError::invalid("已在使用此版本"));
|
||||
}
|
||||
let (configuration, _) = self
|
||||
.version_configuration(&existing, &input.artifact_sha256)
|
||||
.await?;
|
||||
let request = ConfigurePluginInstance {
|
||||
creation_id: None,
|
||||
expected_revision: Some(existing.revision.get()),
|
||||
replace_instances: Vec::new(),
|
||||
name: existing.name.clone(),
|
||||
artifact_sha256: input.artifact_sha256,
|
||||
enabled: existing.enabled,
|
||||
configuration: configuration.configuration,
|
||||
secrets: Some(configuration.secrets),
|
||||
bindings: configuration.bindings,
|
||||
};
|
||||
// 同一次读取的 CAS 覆盖草稿计算、准备与提交,不能覆盖并发设置。
|
||||
self.configure_from_snapshot(snapshot, Some(existing), request, context)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn version_configuration(
|
||||
&self,
|
||||
instance: &PluginInstance,
|
||||
digest: &str,
|
||||
) -> Result<(PluginVersionConfiguration, bool), AdminError> {
|
||||
let artifacts = self
|
||||
.store
|
||||
.list_artifacts()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let current = artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.metadata.sha256 == instance.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("当前插件版本不存在"))?;
|
||||
let target = artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.metadata.sha256 == digest && artifact.accepted_at.is_some())
|
||||
.ok_or_else(|| AdminError::not_found("目标插件版本尚未安装"))?;
|
||||
if current.metadata.plugin_id != target.metadata.plugin_id {
|
||||
return Err(AdminError::invalid("只能切换同一插件的版本"));
|
||||
}
|
||||
if digest != instance.artifact_sha256
|
||||
&& let Some(saved) = self
|
||||
.store
|
||||
.load_version_configuration(&instance.id, digest)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?
|
||||
{
|
||||
return Ok((saved, true));
|
||||
}
|
||||
let mut configuration = instance.configuration.clone();
|
||||
// 只补充缺失的默认值,保留显式值及未知字段,让不兼容项由校验反馈给用户。
|
||||
merge_missing_defaults(&mut configuration, configuration_defaults(&target.metadata));
|
||||
Ok((
|
||||
PluginVersionConfiguration {
|
||||
configuration,
|
||||
secrets: instance.secrets.clone(),
|
||||
bindings: version_bindings(instance, ¤t.metadata, &target.metadata),
|
||||
},
|
||||
false,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn version_bindings(
|
||||
instance: &PluginInstance,
|
||||
current: &PluginArtifactMetadata,
|
||||
target: &PluginArtifactMetadata,
|
||||
) -> Vec<crate::model::plugins::instances::PluginCapabilityBinding> {
|
||||
let mut bindings = default_bindings(target);
|
||||
// 已关闭的既有能力继续关闭,新声明能力才采用包默认值。
|
||||
bindings.retain(|binding| {
|
||||
let capability = target
|
||||
.contributes
|
||||
.iter()
|
||||
.find(|(_, contribution)| contribution.id == binding.contribution)
|
||||
.map(|(capability, _)| capability);
|
||||
let previous = capability.and_then(|capability| current.contributes.get(capability));
|
||||
previous.is_none_or(|contribution| {
|
||||
!contribution.stages.contains(&binding.stage)
|
||||
|| instance
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|old| old.contribution == contribution.id && old.stage == binding.stage)
|
||||
})
|
||||
});
|
||||
for previous in &instance.bindings {
|
||||
let Some((capability, _)) = current
|
||||
.contributes
|
||||
.iter()
|
||||
.find(|(_, contribution)| contribution.id == previous.contribution)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(contribution) = target
|
||||
.contributes
|
||||
.get(capability)
|
||||
.filter(|contribution| contribution.stages.contains(&previous.stage))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut binding = previous.clone();
|
||||
binding.contribution.clone_from(&contribution.id);
|
||||
if let Some(default) = bindings.iter_mut().find(|default| {
|
||||
default.contribution == binding.contribution && default.stage == binding.stage
|
||||
}) {
|
||||
*default = binding;
|
||||
} else {
|
||||
bindings.push(binding);
|
||||
}
|
||||
}
|
||||
bindings
|
||||
}
|
||||
|
||||
fn rollback_version(
|
||||
current: &PluginArtifactMetadata,
|
||||
target: &PluginArtifactMetadata,
|
||||
) -> Result<Option<semver::Version>, AdminError> {
|
||||
if current.plugin_id != target.plugin_id {
|
||||
return Ok(None);
|
||||
}
|
||||
let current_version = semver::Version::parse(¤t.version)
|
||||
.map_err(|_| AdminError::invalid("当前插件版本无效"))?;
|
||||
let target_version = semver::Version::parse(&target.version)
|
||||
.map_err(|_| AdminError::invalid("回滚目标版本无效"))?;
|
||||
Ok(target_version
|
||||
.cmp_precedence(¤t_version)
|
||||
.is_lt()
|
||||
.then_some(target_version))
|
||||
}
|
||||
|
||||
fn merge_missing_defaults(current: &mut serde_json::Value, defaults: serde_json::Value) {
|
||||
let (Some(current), serde_json::Value::Object(defaults)) = (current.as_object_mut(), defaults)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (key, default) in defaults {
|
||||
if let Some(value) = current.get_mut(&key) {
|
||||
merge_missing_defaults(value, default);
|
||||
} else {
|
||||
current.insert(key, default);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,10 +56,15 @@ pub trait ProxiesService: Send + Sync {
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
detect_location: bool,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError>;
|
||||
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定。
|
||||
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError>;
|
||||
async fn probe(
|
||||
&self,
|
||||
proxy: &OutboundProxy,
|
||||
detect_location: bool,
|
||||
) -> Result<ProxyTestResult, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultProxiesService {
|
||||
@@ -186,6 +191,11 @@ impl ProxiesService for DefaultProxiesService {
|
||||
.map(|location| location.normalized())
|
||||
.transpose()
|
||||
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
|
||||
command.test = if command.auto_location {
|
||||
Some(self.probe(&command.proxy, true).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let result = self
|
||||
.store
|
||||
.create(command, context)
|
||||
@@ -206,6 +216,24 @@ impl ProxiesService for DefaultProxiesService {
|
||||
.map(|location| location.map(|value| value.normalized()).transpose())
|
||||
.transpose()
|
||||
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
|
||||
// 只在开启自动模式或连接地址变化时检测,普通编辑不刷新已识别位置。
|
||||
command.test = None;
|
||||
if command.auto_location.is_some() || command.proxy.is_some() {
|
||||
let current = self
|
||||
.store
|
||||
.get(&command.id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
if current.revision != command.revision {
|
||||
return Err(AdminError::conflict("代理已被修改,请刷新后重试"));
|
||||
}
|
||||
let proxy = command.proxy.as_ref().unwrap_or(¤t.proxy);
|
||||
if command.auto_location.unwrap_or(current.auto_location)
|
||||
&& (!current.auto_location || proxy != ¤t.proxy)
|
||||
{
|
||||
command.test = Some(self.probe(proxy, true).await?);
|
||||
}
|
||||
}
|
||||
let result = self
|
||||
.store
|
||||
.update(command, context)
|
||||
@@ -230,17 +258,22 @@ impl ProxiesService for DefaultProxiesService {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError> {
|
||||
async fn probe(
|
||||
&self,
|
||||
proxy: &OutboundProxy,
|
||||
detect_location: bool,
|
||||
) -> Result<ProxyTestResult, AdminError> {
|
||||
let _permit = self.test_slots.try_acquire().map_err(|_| {
|
||||
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
|
||||
})?;
|
||||
Ok(self.probe.test(proxy).await)
|
||||
Ok(self.probe.test(proxy, detect_location).await)
|
||||
}
|
||||
|
||||
async fn test(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
detect_location: bool,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError> {
|
||||
let _permit = self.test_slots.try_acquire().map_err(|_| {
|
||||
@@ -254,11 +287,20 @@ impl ProxiesService for DefaultProxiesService {
|
||||
if record.revision != revision {
|
||||
return Err(AdminError::conflict("代理已被修改,请刷新后重新测试"));
|
||||
}
|
||||
let result = self.probe.test(&record.proxy).await;
|
||||
self.store
|
||||
// 手动解析只为本次测试请求位置,不改变自动跟随的持久配置。
|
||||
let result = self
|
||||
.probe
|
||||
.test(&record.proxy, record.auto_location || detect_location)
|
||||
.await;
|
||||
let mutation = self
|
||||
.store
|
||||
.record_test(id, revision, result, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
if record.effective_location() != mutation.record.effective_location() {
|
||||
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
|
||||
}
|
||||
Ok(mutation.record)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ impl DefaultSettingsService {
|
||||
fn profile_provider(
|
||||
&self,
|
||||
provider: &str,
|
||||
) -> Result<Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
|
||||
) -> Result<std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
|
||||
let kind = gateway_core::routing::ProviderKind::new(provider)
|
||||
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
|
||||
self.providers
|
||||
@@ -129,6 +129,14 @@ impl SettingsService for DefaultSettingsService {
|
||||
{
|
||||
return Err(AdminError::invalid("请选择 1 至 10000 个模型"));
|
||||
}
|
||||
let providers = self.providers.pricing_catalog();
|
||||
if command
|
||||
.models
|
||||
.keys()
|
||||
.any(|provider| !providers.contains_key(provider))
|
||||
{
|
||||
return Err(AdminError::invalid("Provider 不支持价目管理"));
|
||||
}
|
||||
let mut current = self.pricing_source.fetch().await?;
|
||||
if current != command.preview {
|
||||
return Err(AdminError::invalid(
|
||||
@@ -262,8 +270,8 @@ impl SettingsService for DefaultSettingsService {
|
||||
&self,
|
||||
provider: &str,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
|
||||
let mut options = self
|
||||
.profile_provider(provider)?
|
||||
let profile_provider = self.profile_provider(provider)?;
|
||||
let mut options = profile_provider
|
||||
.client_profile_options()
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?
|
||||
.into_inner();
|
||||
@@ -272,6 +280,7 @@ impl SettingsService for DefaultSettingsService {
|
||||
.await?
|
||||
.client_profile(provider)
|
||||
.cloned()
|
||||
.or_else(|| profile_provider.default_client_profile())
|
||||
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
|
||||
options.insert(
|
||||
"globalConfiguration".to_owned(),
|
||||
@@ -295,6 +304,7 @@ impl SettingsService for DefaultSettingsService {
|
||||
.await?
|
||||
.client_profile(provider)
|
||||
.cloned()
|
||||
.or_else(|| profile_provider.default_client_profile())
|
||||
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
|
||||
(&global, "global")
|
||||
};
|
||||
@@ -316,16 +326,38 @@ impl SettingsService for DefaultSettingsService {
|
||||
async fn replace(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ReplaceRuntimeSettings,
|
||||
mut command: ReplaceRuntimeSettings,
|
||||
) -> Result<RuntimeSettings, AdminError> {
|
||||
validate_settings(&command)?;
|
||||
for (provider, profile) in [
|
||||
("openai", &command.openai_client_profile),
|
||||
("xai", &command.xai_client_profile),
|
||||
] {
|
||||
if let Some(profile) = profile {
|
||||
self.profile_provider(provider)?
|
||||
.preview_client_profile(profile)
|
||||
if command
|
||||
.request_profile_updates
|
||||
.values()
|
||||
.any(Option::is_some)
|
||||
{
|
||||
let current = self
|
||||
.store
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "runtime settings"))?;
|
||||
command.request_profile_updates.retain(|provider, update| {
|
||||
!update
|
||||
.as_ref()
|
||||
.is_some_and(|profile| current.request_profiles.get(provider) == Some(profile))
|
||||
});
|
||||
}
|
||||
if command
|
||||
.request_profile_updates
|
||||
.values()
|
||||
.any(Option::is_some)
|
||||
{
|
||||
let providers = &self.providers;
|
||||
for (provider, profile) in &command.request_profile_updates {
|
||||
let Some(profile) = profile else {
|
||||
continue;
|
||||
};
|
||||
providers
|
||||
.require(provider)
|
||||
.and_then(|provider| provider.preview_client_profile(profile))
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
}
|
||||
@@ -381,7 +413,6 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
|
||||
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
|
||||
&& command.refresh_margin_seconds > 0
|
||||
&& command.refresh_concurrency > 0
|
||||
&& command.max_concurrent_per_account > 0
|
||||
&& command.max_waiting_per_key <= 1_000
|
||||
&& command.max_waiting_per_account <= 1_000
|
||||
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
|
||||
@@ -391,6 +422,11 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
|
||||
&& valid_client_version(command.min_codex_desktop_version.as_deref())
|
||||
&& valid_client_version(command.min_codex_cli_version.as_deref())
|
||||
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
|
||||
&& gateway_core::provider_ports::valid_warmup_schedule_time(
|
||||
&command.account_warmup_schedule_time,
|
||||
)
|
||||
&& valid_probe_model(command.account_warmup_model.as_deref())
|
||||
&& (!command.account_warmup_enabled || command.account_warmup_model.is_some())
|
||||
&& i64::try_from(command.request_interval_ms).is_ok()
|
||||
&& (2..=1_000).contains(&command.account_auto_freeze_threshold)
|
||||
&& (60..=3_600).contains(&command.account_auto_freeze_window_seconds)
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::{
|
||||
},
|
||||
ports::system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
|
||||
SystemUpdatePreflight,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,12 +33,19 @@ pub trait SystemService: Send + Sync {
|
||||
/// 保持 Host 能力窄边界的默认系统用例。
|
||||
pub(crate) struct DefaultSystemService {
|
||||
operations: Arc<dyn SystemOperations>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
}
|
||||
|
||||
impl DefaultSystemService {
|
||||
#[must_use]
|
||||
pub(crate) const fn new(operations: Arc<dyn SystemOperations>) -> Self {
|
||||
Self { operations }
|
||||
pub(crate) fn new(
|
||||
operations: Arc<dyn SystemOperations>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Self {
|
||||
Self {
|
||||
operations,
|
||||
preflight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +74,7 @@ impl SystemService for DefaultSystemService {
|
||||
.map(|version| version.trim().to_owned())
|
||||
.filter(|version| !version.is_empty());
|
||||
self.operations
|
||||
.perform_update(target_version)
|
||||
.perform_update(target_version, Arc::clone(&self.preflight))
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
@@ -79,7 +87,10 @@ impl SystemService for DefaultSystemService {
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError> {
|
||||
self.operations.rollback().await.map_err(map_system_error)
|
||||
self.operations
|
||||
.rollback(Arc::clone(&self.preflight))
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError> {
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
//! xAI 管理资源的中立 ProviderAdmin 委托。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialMutationResult, ImportCredentials, PrepareCredentialImport,
|
||||
StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
};
|
||||
|
||||
use super::{
|
||||
commit_authorization, delete_credentials, map_provider_error, map_store_error,
|
||||
pending_authorization, publish_committed, publish_credentials_and_observe_quota,
|
||||
validate_authorization_commit, validate_prepared_import,
|
||||
};
|
||||
|
||||
/// xAI 固定管理路由消费的服务。
|
||||
#[async_trait]
|
||||
pub trait XaiService: Send + Sync {
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError>;
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
command: StartAuthorization,
|
||||
) -> Result<AuthorizationStarted, AdminError>;
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
) -> Result<CredentialDeletionResult, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultXaiService {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultXaiService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl XaiService for DefaultXaiService {
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let proxy_reservation = super::import_proxy_binding(
|
||||
self.proxies.as_ref(),
|
||||
command.outbound_proxy_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let outbound_proxy = proxy_reservation
|
||||
.as_ref()
|
||||
.map(|reservation| reservation.binding.clone());
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
default_outbound_proxy: outbound_proxy
|
||||
.as_ref()
|
||||
.map(|binding| binding.proxy.clone()),
|
||||
document: command.document,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "xAI credential import"))?;
|
||||
validate_prepared_import(
|
||||
self.provider.provider_kind(),
|
||||
&prepared,
|
||||
"xAI credential import",
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
outbound_proxy,
|
||||
prepared,
|
||||
settings: command.settings,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "xAI credential import"))?;
|
||||
drop(proxy_reservation);
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
&result.credential_ids,
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
command: StartAuthorization,
|
||||
) -> Result<AuthorizationStarted, AdminError> {
|
||||
let pending = pending_authorization(
|
||||
self.accounts.as_ref(),
|
||||
self.proxies.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&command,
|
||||
"xAI credential",
|
||||
)
|
||||
.await?;
|
||||
self.provider
|
||||
.start_authorization(pending)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "xAI authorization"))
|
||||
}
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
mut command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.context.clone();
|
||||
let settings = command.settings.take();
|
||||
let prepared = self
|
||||
.provider
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "xAI authorization"))?;
|
||||
let prepared = validate_authorization_commit(
|
||||
self.provider.provider_kind(),
|
||||
&context,
|
||||
prepared,
|
||||
"xAI authorization",
|
||||
)
|
||||
.await?;
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
settings,
|
||||
&context,
|
||||
"xAI authorization",
|
||||
)
|
||||
.await?;
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&result.account_id),
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
) -> Result<CredentialDeletionResult, AdminError> {
|
||||
let result = delete_credentials(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
"xAI credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,23 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
|
||||
.filter_map(|window| {
|
||||
let usage = window.local_usage.as_ref()?;
|
||||
let usd = usage.costs.iter().find(|cost| cost.currency == "USD");
|
||||
let usage = QuotaForecastUsage {
|
||||
request_count: usage.request_count,
|
||||
tokens: usage.total_tokens.unwrap_or(0),
|
||||
input_tokens: usage.input_tokens.unwrap_or(0),
|
||||
output_tokens: usage.output_tokens.unwrap_or(0),
|
||||
cached_tokens: usage.cached_tokens.unwrap_or(0),
|
||||
missing_token_count: u64::from(usage.total_tokens.is_none()),
|
||||
known_cost_count: if usd.is_some() {
|
||||
usage.cost_coverage.known_count()
|
||||
} else {
|
||||
0
|
||||
},
|
||||
unavailable_cost_count: usage.cost_coverage.partial_count
|
||||
+ usage.cost_coverage.unavailable_count,
|
||||
usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()),
|
||||
excluded_request_count: 0,
|
||||
};
|
||||
Some(QuotaForecastSample {
|
||||
key: window.key.clone(),
|
||||
method: QuotaForecastMethod::Cumulative,
|
||||
@@ -89,23 +106,8 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
|
||||
observation_count: 0,
|
||||
pending_request_count: 0,
|
||||
discontinuous: false,
|
||||
usage: QuotaForecastUsage {
|
||||
request_count: usage.request_count,
|
||||
tokens: usage.total_tokens.unwrap_or(0),
|
||||
input_tokens: usage.input_tokens.unwrap_or(0),
|
||||
output_tokens: usage.output_tokens.unwrap_or(0),
|
||||
cached_tokens: usage.cached_tokens.unwrap_or(0),
|
||||
missing_token_count: u64::from(usage.total_tokens.is_none()),
|
||||
known_cost_count: if usd.is_some() {
|
||||
usage.cost_coverage.known_count()
|
||||
} else {
|
||||
0
|
||||
},
|
||||
unavailable_cost_count: usage.cost_coverage.partial_count
|
||||
+ usage.cost_coverage.unavailable_count,
|
||||
usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()),
|
||||
excluded_request_count: 0,
|
||||
},
|
||||
cycle_usage: usage.clone(),
|
||||
usage,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -139,6 +141,22 @@ fn corresponding_actual_window_takes_priority_and_retains_its_duration() {
|
||||
assert_eq!(week.estimated_tokens, Some(1_129));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_selection_keeps_provider_order_with_the_maximum_window_count() {
|
||||
let windows = (0..64)
|
||||
.map(|index| {
|
||||
if index % 2 == 0 {
|
||||
window(&format!("week-{index}"), 7)
|
||||
} else {
|
||||
window(&format!("month-{index}"), 30)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let [week, month] = forecast("a(windows));
|
||||
assert_eq!(week.source.unwrap().label, "week-0");
|
||||
assert_eq!(month.source.unwrap().label, "month-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_or_model_specific_windows_are_not_account_capacity() {
|
||||
let mut model = window("model", 7);
|
||||
@@ -238,7 +256,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
|
||||
sample.block_count = 2;
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
|
||||
assert_eq!(week.estimated_tokens, Some(10_000));
|
||||
assert_eq!(week.estimated_tokens, Some(9_000));
|
||||
assert_eq!(week.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.remaining_tokens, Some(8_000));
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
@@ -250,6 +268,7 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.usage.missing_token_count = 1;
|
||||
sample.cycle_usage = sample.usage.clone();
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
@@ -304,6 +323,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
|
||||
sample.usage.unavailable_cost_count = 2;
|
||||
sample.usage.excluded_request_count = 1;
|
||||
sample.pending_request_count = 1;
|
||||
sample.cycle_usage = sample.usage.clone();
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
@@ -345,6 +365,7 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
|
||||
unavailable_cost_count: 2,
|
||||
..Default::default()
|
||||
};
|
||||
sample.cycle_usage = sample.usage.clone();
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.unavailable_reason.is_some());
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
@@ -380,3 +401,50 @@ fn oversized_window_or_estimate_is_unavailable_without_panics_or_saturation() {
|
||||
assert!(week.estimated_tokens.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast() {
|
||||
let mut source = quota(vec![window("week", 7)]);
|
||||
source.windows[0].used_percent = Some(92.0);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.method = QuotaForecastMethod::Incremental;
|
||||
sample.start_at = now() - Duration::hours(20);
|
||||
sample.baseline_percent = 75.0;
|
||||
sample.sampled_percent = 17.0;
|
||||
sample.block_count = 3;
|
||||
sample.usage.tokens = 141_226_903;
|
||||
sample.usage.usd = 237.21007884;
|
||||
sample.cycle_usage.tokens = 1_084_786_565;
|
||||
sample.cycle_usage.usd = 1_412.57957384;
|
||||
let [cycle, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
|
||||
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
|
||||
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
|
||||
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
|
||||
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
|
||||
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
|
||||
assert!(
|
||||
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
|
||||
let mut source = quota(vec![window("week", 7)]);
|
||||
source.windows[0].used_percent = Some(100.0);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.method = QuotaForecastMethod::Incremental;
|
||||
sample.start_at = now() - Duration::hours(2);
|
||||
sample.baseline_percent = 85.0;
|
||||
sample.sampled_percent = 15.0;
|
||||
sample.block_count = 3;
|
||||
sample.usage.tokens = 100;
|
||||
sample.usage.usd = 0.1;
|
||||
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_000));
|
||||
assert_eq!(cycle.estimated_usd, Some(2.0));
|
||||
assert_eq!(cycle.remaining_tokens, Some(0));
|
||||
assert_eq!(cycle.remaining_usd, Some(0.0));
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ fn point(minute: i64, percent: f64, tokens: u64) -> QuotaForecastPoint {
|
||||
}
|
||||
|
||||
fn select(points: Vec<QuotaForecastPoint>, current: QuotaForecastPoint) -> QuotaForecastSample {
|
||||
select_forecast_sample("week".to_owned(), time(0), current, points, 2)
|
||||
select_forecast_sample("week".to_owned(), time(0), current, points, 2, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -55,6 +55,7 @@ fn recent_blocks_and_tail_replace_old_workload_without_averaging_individual_rati
|
||||
assert_eq!(sample.baseline_percent, 10.0);
|
||||
assert_eq!(sample.sampled_percent, 17.0);
|
||||
assert_eq!(sample.usage.tokens, 700);
|
||||
assert_eq!(sample.cycle_usage.tokens, 900);
|
||||
assert_eq!(sample.block_count, 3);
|
||||
}
|
||||
|
||||
@@ -77,6 +78,7 @@ fn large_percentage_reversal_restarts_sampling_without_crossing_old_consumption(
|
||||
);
|
||||
assert_eq!(sample.baseline_percent, 1.0);
|
||||
assert_eq!(sample.usage.tokens, 70);
|
||||
assert_eq!(sample.cycle_usage.tokens, 70);
|
||||
assert_eq!(sample.sampled_percent, 7.0);
|
||||
assert!(!sample.discontinuous);
|
||||
let waiting = select(
|
||||
@@ -122,3 +124,41 @@ fn counter_regressions_fail_closed_and_future_points_are_not_used() {
|
||||
assert_eq!(future.method, QuotaForecastMethod::Cumulative);
|
||||
assert_eq!(future.observation_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_history_is_retained_to_detect_a_reset_with_an_unchanged_deadline() {
|
||||
let sample = select(
|
||||
vec![point(1, 100.0, 10_000), point(2, 0.0, 10_000)],
|
||||
point(3, 10.0, 10_100),
|
||||
);
|
||||
assert_eq!(sample.cycle_usage.tokens, 100);
|
||||
assert_eq!(sample.usage.tokens, 100);
|
||||
assert_eq!(sample.sampled_percent, 10.0);
|
||||
assert!(!sample.discontinuous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_window_restarts_cycle_totals_at_first_matching_observation() {
|
||||
let sample = select_forecast_sample(
|
||||
"week".to_owned(),
|
||||
time(0),
|
||||
point(4, 12.0, 10_120),
|
||||
vec![point(2, 0.0, 10_000), point(3, 5.0, 10_050)],
|
||||
0,
|
||||
true,
|
||||
);
|
||||
assert_eq!(sample.cycle_usage.tokens, 120);
|
||||
assert!((sample.cycle_usage.usd - 0.12).abs() < 1e-10);
|
||||
assert_eq!(sample.usage.tokens, 120);
|
||||
assert!(!sample.discontinuous);
|
||||
let waiting = select_forecast_sample(
|
||||
"week".to_owned(),
|
||||
time(0),
|
||||
point(4, 2.0, 10_020),
|
||||
vec![],
|
||||
0,
|
||||
true,
|
||||
);
|
||||
assert_eq!(waiting.cycle_usage.tokens, 0);
|
||||
assert!(waiting.discontinuous);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn group_query_service_enriches_only_current_page_members_with_runtime_fac
|
||||
group.capacity,
|
||||
AccountGroupCapacity {
|
||||
used_slots: Some(2),
|
||||
total_slots: 4,
|
||||
total_slots: Some(4),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,40 @@ async fn group_query_service_enriches_only_current_page_members_with_runtime_fac
|
||||
#[derive(Default)]
|
||||
struct FakeGroupStore {
|
||||
requested_groups: Mutex<Vec<String>>,
|
||||
members: Option<Vec<AccountGroupMemberFact>>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_capacity_only_becomes_unlimited_for_available_unlimited_members() {
|
||||
for (available_slots, unavailable_slots, expected) in
|
||||
[(None, Some(3), None), (Some(4), None, Some(4))]
|
||||
{
|
||||
let mut available = member("acct_available", 4);
|
||||
available.total_slots = available_slots;
|
||||
let mut unavailable = member("acct_limited", 3);
|
||||
unavailable.total_slots = unavailable_slots;
|
||||
let groups = Arc::new(FakeGroupStore {
|
||||
members: Some(vec![available, unavailable]),
|
||||
..Default::default()
|
||||
});
|
||||
let service = AdminHarness::new()
|
||||
.account_groups(groups)
|
||||
.account_runtime(Arc::new(FakeRuntimeStore::default()))
|
||||
.build()
|
||||
.await;
|
||||
let page = service
|
||||
.account_groups()
|
||||
.list(AccountGroupListQuery {
|
||||
page: 1,
|
||||
page_size: PageSize::new(20).expect("page size"),
|
||||
search: None,
|
||||
enabled: None,
|
||||
})
|
||||
.await
|
||||
.expect("group capacity");
|
||||
assert_eq!(page.items[0].capacity.total_slots, expected);
|
||||
assert_eq!(page.items[0].capacity.used_slots, Some(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -115,7 +149,10 @@ impl AccountGroupStore for FakeGroupStore {
|
||||
.iter()
|
||||
.map(|group_id| group_id.as_str().to_owned())
|
||||
.collect();
|
||||
Ok(vec![member("acct_available", 4), member("acct_limited", 3)])
|
||||
Ok(self
|
||||
.members
|
||||
.clone()
|
||||
.unwrap_or_else(|| vec![member("acct_available", 4), member("acct_limited", 3)]))
|
||||
}
|
||||
|
||||
async fn create_account_group(
|
||||
@@ -218,7 +255,7 @@ fn group_record() -> AccountGroupRecord {
|
||||
},
|
||||
capacity: AccountGroupCapacity {
|
||||
used_slots: None,
|
||||
total_slots: 0,
|
||||
total_slots: Some(0),
|
||||
},
|
||||
usage: AccountGroupUsage {
|
||||
today_usd: DecimalAmount::from_str("1").expect("today usage"),
|
||||
@@ -242,7 +279,7 @@ fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
},
|
||||
total_slots,
|
||||
total_slots: Some(total_slots),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ use gateway_admin::{
|
||||
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
|
||||
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
PreparedCredentialRotation, PreparedCredentialRotationFacts, PreparedPluginAccountSave,
|
||||
ProviderDocument, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileActivityInsights, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription,
|
||||
@@ -65,6 +65,7 @@ pub(super) type EventLog = Arc<Mutex<Vec<&'static str>>>;
|
||||
|
||||
pub(super) struct FakeProviderAdmin {
|
||||
kind: ProviderKind,
|
||||
capabilities: Mutex<gateway_admin::model::accounts::ProviderAccountCapabilities>,
|
||||
events: EventLog,
|
||||
failure: Mutex<Option<ProviderAdminError>>,
|
||||
import_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
|
||||
@@ -89,6 +90,7 @@ impl FakeProviderAdmin {
|
||||
pub(super) fn new(kind: &str, events: EventLog) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
capabilities: Mutex::default(),
|
||||
events,
|
||||
failure: Mutex::new(None),
|
||||
import_gate: Mutex::new(None),
|
||||
@@ -244,6 +246,7 @@ impl FakeProviderAdmin {
|
||||
email: account.email.clone(),
|
||||
plan_type: account.plan_type.clone(),
|
||||
preserve_profile: false,
|
||||
preserve_credential_state: false,
|
||||
provider_material: document(),
|
||||
has_refresh_token: account.has_refresh_token,
|
||||
access_token_expires_at: account
|
||||
@@ -258,6 +261,13 @@ impl FakeProviderAdmin {
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdmin for FakeProviderAdmin {
|
||||
fn account_capabilities(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
_authentication_kind: &str,
|
||||
) -> gateway_admin::model::accounts::ProviderAccountCapabilities {
|
||||
*self.capabilities.lock().unwrap()
|
||||
}
|
||||
fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.kind
|
||||
}
|
||||
@@ -300,7 +310,7 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
self.record("provider.account_facts_changed");
|
||||
}
|
||||
|
||||
fn connection_test_operation(
|
||||
async fn connection_test_operation(
|
||||
&self,
|
||||
model: &gateway_core::routing::UpstreamModelId,
|
||||
input: &str,
|
||||
@@ -364,7 +374,7 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
pending: PendingAuthorizationMutation,
|
||||
pending: gateway_admin::model::provider_credentials::PendingAuthorizationMutation,
|
||||
) -> Result<AuthorizationStarted, ProviderAdminError> {
|
||||
self.record("provider.start_authorization");
|
||||
self.require_available()?;
|
||||
@@ -398,12 +408,17 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
.then(|| pending.clone());
|
||||
let credential = match pending.target() {
|
||||
AuthorizationMutationTarget::Create { name } => {
|
||||
PreparedAuthorizationCredential::Create(prepared_create(self.kind.clone(), name))
|
||||
PreparedAuthorizationCredential::Create(Box::new(prepared_create(
|
||||
self.kind.clone(),
|
||||
name,
|
||||
)))
|
||||
}
|
||||
AuthorizationMutationTarget::Reauthorize { account_id } => {
|
||||
let mut account = account_record(self.kind.as_str());
|
||||
account.id = account_id.as_str().to_owned();
|
||||
PreparedAuthorizationCredential::Reauthorize(self.prepared_rotation(&account))
|
||||
PreparedAuthorizationCredential::Reauthorize(Box::new(
|
||||
self.prepared_rotation(&account),
|
||||
))
|
||||
}
|
||||
};
|
||||
let prepared = PreparedAuthorizationCommit::new(pending, credential);
|
||||
@@ -539,6 +554,12 @@ pub(super) struct FakeAccountStore {
|
||||
accounts: Mutex<Vec<AccountRecord>>,
|
||||
account_after_probe: Mutex<Option<AccountRecord>>,
|
||||
fail_commit: Mutex<bool>,
|
||||
receipts: Mutex<
|
||||
std::collections::BTreeMap<
|
||||
gateway_admin::model::provider_credentials::AuthorizationReceiptKey,
|
||||
gateway_admin::model::provider_credentials::CredentialMutationResult,
|
||||
>,
|
||||
>,
|
||||
audit_requests: Mutex<Vec<String>>,
|
||||
import_settings: Mutex<Vec<Option<gateway_admin::model::accounts::AccountImportSettings>>>,
|
||||
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
|
||||
@@ -559,6 +580,7 @@ impl FakeAccountStore {
|
||||
accounts: Mutex::new(vec![account]),
|
||||
account_after_probe: Mutex::new(None),
|
||||
fail_commit: Mutex::new(false),
|
||||
receipts: Mutex::new(Default::default()),
|
||||
audit_requests: Mutex::new(Vec::new()),
|
||||
import_settings: Mutex::new(Vec::new()),
|
||||
quota_window_usage: Mutex::new(Vec::new()),
|
||||
@@ -654,6 +676,17 @@ impl FakeAccountStore {
|
||||
|
||||
#[async_trait]
|
||||
impl AccountStore for FakeAccountStore {
|
||||
async fn list_plugin_accounts(
|
||||
&self,
|
||||
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
|
||||
) -> AdminStoreResult<gateway_admin::model::provider_credentials::PluginAccountPage> {
|
||||
Err(AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"plugin accounts",
|
||||
"unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
_: AccountListQuery,
|
||||
@@ -756,6 +789,24 @@ impl AccountStore for FakeAccountStore {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn credential_details_by_id(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<CredentialDetails>> {
|
||||
self.record("store.credential_details_by_id");
|
||||
let account = self
|
||||
.accounts
|
||||
.lock()
|
||||
.expect("accounts")
|
||||
.iter()
|
||||
.find(|account| account.id == account_id.as_str())
|
||||
.cloned();
|
||||
Ok(account.map(|credential| CredentialDetails {
|
||||
config_revision: revision(1),
|
||||
credential,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn load_credentials_for_export(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
@@ -784,6 +835,24 @@ impl AccountStore for FakeAccountStore {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_credential_for_plugin(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<ProviderExportCredentialInput>> {
|
||||
self.record("store.load_credential_for_plugin");
|
||||
Ok(self
|
||||
.accounts
|
||||
.lock()
|
||||
.expect("accounts")
|
||||
.iter()
|
||||
.find(|account| account.id == account_id.as_str())
|
||||
.cloned()
|
||||
.map(|account| ProviderExportCredentialInput {
|
||||
account,
|
||||
provider_material: document(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn commit_credential_import(
|
||||
&self,
|
||||
command: CredentialImportCommit,
|
||||
@@ -811,11 +880,20 @@ impl AccountStore for FakeAccountStore {
|
||||
&self,
|
||||
command: AuthorizationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
) -> AdminStoreResult<gateway_admin::model::provider_credentials::AuthorizationCommitResult>
|
||||
{
|
||||
self.import_settings
|
||||
.lock()
|
||||
.expect("import settings")
|
||||
.push(command.settings);
|
||||
if let Some(result) = self.receipts.lock().unwrap().get(&command.key).cloned() {
|
||||
return Ok(
|
||||
gateway_admin::model::provider_credentials::AuthorizationCommitResult {
|
||||
result,
|
||||
newly_committed: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.record("store.commit_authorization");
|
||||
self.record_context(context);
|
||||
self.require_commit()?;
|
||||
@@ -826,11 +904,28 @@ impl AccountStore for FakeAccountStore {
|
||||
Some(revision(credential.expected_credential_revision.get() + 1)),
|
||||
),
|
||||
};
|
||||
Ok(CredentialMutationResult {
|
||||
let result = CredentialMutationResult {
|
||||
config_revision: revision(2),
|
||||
account_id,
|
||||
credential_revision,
|
||||
})
|
||||
};
|
||||
self.receipts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(command.key, result.clone());
|
||||
Ok(
|
||||
gateway_admin::model::provider_credentials::AuthorizationCommitResult {
|
||||
result,
|
||||
newly_committed: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn authorization_receipt(
|
||||
&self,
|
||||
key: &gateway_admin::model::provider_credentials::AuthorizationReceiptKey,
|
||||
) -> AdminStoreResult<Option<CredentialMutationResult>> {
|
||||
Ok(self.receipts.lock().unwrap().get(key).cloned())
|
||||
}
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
@@ -935,6 +1030,13 @@ impl AccountStore for FakeAccountStore {
|
||||
self.record("store.batch_update_accounts");
|
||||
self.record_context(context);
|
||||
self.require_commit()?;
|
||||
if let Some(enabled) = command.enabled {
|
||||
for account in self.accounts.lock().expect("accounts").iter_mut() {
|
||||
if command.account_ids.contains(&account.id) {
|
||||
account.enabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AccountsUpdateResult {
|
||||
config_revision: revision(2),
|
||||
account_ids: command
|
||||
@@ -990,8 +1092,7 @@ impl SettingsStore for StaticSettingsStore {
|
||||
}
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_profiles: Default::default(),
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: revision(1),
|
||||
@@ -1017,6 +1118,9 @@ impl SettingsStore for StaticSettingsStore {
|
||||
account_auto_freeze_probe_enabled: true,
|
||||
account_auto_freeze_probe_model: None,
|
||||
account_auto_freeze_adaptive_concurrency: true,
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
updated_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
@@ -1314,6 +1418,121 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() {
|
||||
assert_eq!(store.audit_requests(), ["refresh-request"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_account_save_publishes_native_credential_change() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let snapshot = Arc::new(RecordingPluginAccountPublication(events.clone()));
|
||||
let access = gateway_admin::initialize_plugin_accounts(
|
||||
ProviderAdminRegistry::new([provider.clone() as Arc<dyn ProviderAdmin>]).unwrap(),
|
||||
store.clone(),
|
||||
snapshot,
|
||||
);
|
||||
let result = access
|
||||
.save(
|
||||
PreparedPluginAccountSave::Replace {
|
||||
facts: plugin_rotation_facts(&account_record("openai")),
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
},
|
||||
&context("plugin-explicit-save"),
|
||||
)
|
||||
.await
|
||||
.expect("save prepared plugin account facts");
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
provider.quota_started.notified(),
|
||||
)
|
||||
.await
|
||||
.expect("start post-commit quota observation");
|
||||
assert_eq!(result.account_id.as_str(), "acct_test");
|
||||
assert_eq!(result.credential_revision, revision(2));
|
||||
assert_eq!(store.audit_requests(), ["plugin-explicit-save"]);
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.credential_details",
|
||||
"store.commit_rotation",
|
||||
"provider.account_facts_changed",
|
||||
"snapshot.publish_committed",
|
||||
"provider.quota"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_account_reads_resolve_the_authoritative_provider_from_the_account_id() {
|
||||
let events = events();
|
||||
let store = FakeAccountStore::new("xai", events.clone());
|
||||
// 读取端口不能让调用方提供或伪造 Provider;注册表中即使只有另一 Provider,
|
||||
// 也必须按 Store 中该 account ID 的权威事实返回。
|
||||
let registered_provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let access = gateway_admin::initialize_plugin_accounts(
|
||||
ProviderAdminRegistry::new([registered_provider as Arc<dyn ProviderAdmin>]).unwrap(),
|
||||
store,
|
||||
Arc::new(RecordingPluginAccountPublication(events.clone())),
|
||||
);
|
||||
let account_id = ProviderAccountId::new("acct_test").expect("account ID");
|
||||
|
||||
let runtime = access
|
||||
.get_runtime(&account_id)
|
||||
.await
|
||||
.expect("load plugin account runtime");
|
||||
let credential = access
|
||||
.get_credential(&account_id)
|
||||
.await
|
||||
.expect("load plugin account credential");
|
||||
|
||||
assert_eq!(runtime.provider_kind.as_str(), "xai");
|
||||
assert_eq!(credential.account.provider_kind.as_str(), "xai");
|
||||
assert_eq!(credential.account.id, account_id.as_str());
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.credential_details_by_id",
|
||||
"store.load_credential_for_plugin",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
struct RecordingPluginAccountPublication(EventLog);
|
||||
|
||||
impl gateway_core::runtime::SnapshotControl for RecordingPluginAccountPublication {
|
||||
fn publish_committed(
|
||||
&self,
|
||||
revision: gateway_core::routing::ConfigRevision,
|
||||
) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(revision.get(), 2);
|
||||
self.0.lock().unwrap().push("snapshot.publish_committed");
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_account_save_rejects_stale_authentication_kind_before_commit() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
|
||||
let error = services
|
||||
.plugin_accounts_handle()
|
||||
.save(
|
||||
PreparedPluginAccountSave::Replace {
|
||||
facts: plugin_rotation_facts(&account_record("openai")),
|
||||
authentication_kind: "api_key".to_owned(),
|
||||
},
|
||||
&context("plugin-stale-auth-kind"),
|
||||
)
|
||||
.await
|
||||
.expect_err("authentication kind change must be rejected");
|
||||
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict);
|
||||
assert_eq!(recorded(&events), ["store.credential_details"]);
|
||||
assert!(store.audit_requests().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_quota_refresh_should_return_the_updated_account_status() {
|
||||
let events = events();
|
||||
@@ -1360,7 +1579,6 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let mut account = account_record("openai");
|
||||
account.enabled = false;
|
||||
account.credential_state = CredentialState::Invalid;
|
||||
account.quota = QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
@@ -1407,6 +1625,110 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
|
||||
assert_eq!(store.audit_requests(), ["recover-request"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_recover_disabled_should_only_enable_scheduling_and_preserve_quota() {
|
||||
for has_error in [false, true] {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let mut account = account_record("openai");
|
||||
account.enabled = false;
|
||||
if has_error {
|
||||
account.credential_state = CredentialState::Invalid;
|
||||
account.last_error_reason =
|
||||
Some(gateway_core::account::AccountErrorReason::CredentialInvalid);
|
||||
account.last_error_message = Some("invalid credential".to_owned());
|
||||
account.quota = QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
std::time::SystemTime::now(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
let quota = ProviderQuota {
|
||||
observed_at: Some(Utc::now()),
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "primary".to_owned(),
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "5小时限额".to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(5 * 60 * 60),
|
||||
used_percent: Some(92.0),
|
||||
reset_at: Some(Utc::now() + TimeDelta::hours(1)),
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}],
|
||||
..empty_quota()
|
||||
};
|
||||
provider.set_quota(quota.clone());
|
||||
let store = FakeAccountStore::with_account(account.clone(), events.clone());
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
|
||||
let result = services
|
||||
.accounts()
|
||||
.recover(
|
||||
&context("enable-request"),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await
|
||||
.expect("enable disabled account");
|
||||
|
||||
account.enabled = true;
|
||||
assert_eq!(result.config_revision, revision(2));
|
||||
assert_eq!(result.account.account, account);
|
||||
assert_eq!(result.account.quota, quota);
|
||||
assert_eq!(
|
||||
result.account.projection.status,
|
||||
if has_error {
|
||||
gateway_admin::model::accounts::AccountStatus::Error
|
||||
} else {
|
||||
gateway_admin::model::accounts::AccountStatus::Normal
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.load_account",
|
||||
"store.batch_update_accounts",
|
||||
"provider.account_facts_changed",
|
||||
"store.load_account",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["enable-request"]);
|
||||
let requests = provider.quota_requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(!requests[0].refresh);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_recover_disabled_should_not_publish_when_enabling_fails() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let mut account = account_record("openai");
|
||||
account.enabled = false;
|
||||
let store = FakeAccountStore::with_account(account.clone(), events.clone());
|
||||
store.fail_next_commit();
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
|
||||
let result = services
|
||||
.accounts()
|
||||
.recover(
|
||||
&context("enable-failed"),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(*store.accounts.lock().expect("accounts"), [account]);
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
["store.load_account", "store.batch_update_accounts"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_update_should_commit_then_release_disabled_account_and_publish_facts() {
|
||||
let events = events();
|
||||
@@ -1534,6 +1856,12 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
#[tokio::test]
|
||||
async fn accounts_list_should_return_complete_directory_semantics() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let capabilities = gateway_admin::model::accounts::ProviderAccountCapabilities {
|
||||
quota: true,
|
||||
profile: true,
|
||||
..Default::default()
|
||||
};
|
||||
*provider.capabilities.lock().unwrap() = capabilities;
|
||||
let mut stored = account_record("openai");
|
||||
stored.plan_type = Some(" self_serve_business_prolite ".to_owned());
|
||||
let store = FakeAccountStore::with_account(stored, events());
|
||||
@@ -1555,6 +1883,7 @@ async fn accounts_list_should_return_complete_directory_semantics() {
|
||||
assert_eq!(page.summary.total, 1);
|
||||
assert_eq!(page.summary.normal, 1);
|
||||
let account = page.items.first().expect("account item");
|
||||
assert_eq!(account.capabilities, capabilities);
|
||||
assert_eq!(account.account.provider_kind.as_str(), "openai");
|
||||
assert_eq!(
|
||||
account.projection.status,
|
||||
@@ -1580,6 +1909,7 @@ async fn accounts_list_should_return_complete_directory_semantics() {
|
||||
.await
|
||||
.expect("account detail");
|
||||
assert_eq!(detail.plan_type_display, account.plan_type_display);
|
||||
assert_eq!(detail.capabilities, account.capabilities);
|
||||
assert_eq!(detail.account.plan_type, account.account.plan_type);
|
||||
}
|
||||
|
||||
@@ -1929,6 +2259,11 @@ async fn quota_forecast_reads_raw_snapshot_and_limits_usage_to_observation_time(
|
||||
report.forecasts[0].source.as_ref().unwrap().observed_at,
|
||||
Some(observed)
|
||||
);
|
||||
assert_eq!(
|
||||
report.forecasts[1].source.as_ref().unwrap().label,
|
||||
report.forecasts[0].source.as_ref().unwrap().label,
|
||||
"缺少月窗口时两个展示周期复用同一周窗口"
|
||||
);
|
||||
let queries = store.quota_window_queries();
|
||||
assert_eq!(queries.len(), 1);
|
||||
assert_eq!(queries[0].range.start, reset - TimeDelta::days(7));
|
||||
@@ -1959,6 +2294,70 @@ async fn quota_forecast_does_not_query_usage_without_a_current_snapshot() {
|
||||
assert!(!provider.quota_requests.lock().unwrap()[0].refresh);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_forecast_only_queries_the_two_selected_source_windows() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let now = Utc::now();
|
||||
let observed = now - TimeDelta::minutes(1);
|
||||
let reset = now + TimeDelta::days(1);
|
||||
let windows = (0..64)
|
||||
.map(|index| {
|
||||
let monthly = index % 2 == 1;
|
||||
ProviderQuotaWindow {
|
||||
key: if monthly {
|
||||
format!("month-{index}")
|
||||
} else {
|
||||
format!("week-{index}")
|
||||
},
|
||||
group: if monthly { "monthly" } else { "shortTerm" }.to_owned(),
|
||||
label: format!("window-{index}"),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(if monthly { 30 } else { 7 } * 86_400),
|
||||
used_percent: Some(20.0),
|
||||
reset_at: Some(reset),
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
provider.set_quota(ProviderQuota {
|
||||
plan_type: Some("pro".to_owned()),
|
||||
observed_at: Some(observed),
|
||||
windows,
|
||||
..empty_quota()
|
||||
});
|
||||
let mut account = account_record("openai");
|
||||
account.created_at = now - TimeDelta::days(60);
|
||||
let store = FakeAccountStore::with_account(account, events());
|
||||
let report = accounts_service(provider, store.clone())
|
||||
.await
|
||||
.accounts()
|
||||
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.expect("quota forecast");
|
||||
|
||||
let queries = store.quota_window_queries();
|
||||
assert_eq!(
|
||||
queries
|
||||
.iter()
|
||||
.map(|query| query.key.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["week-0", "month-1"]
|
||||
);
|
||||
assert_eq!(
|
||||
report.forecasts[0].source.as_ref().unwrap().label,
|
||||
"window-0"
|
||||
);
|
||||
assert_eq!(
|
||||
report.forecasts[1].source.as_ref().unwrap().label,
|
||||
"window-1"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_changed_bucket() {
|
||||
use gateway_admin::model::quota_forecast_sampling::QuotaForecastHistoryPoint;
|
||||
@@ -2040,6 +2439,29 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
.unwrap()
|
||||
.contains("不连续")
|
||||
);
|
||||
// 新额度段已有足够观测后恢复预测,但总量不能带回重置前的累计用量。
|
||||
let mut first = make_point(1, 5.0, 1_750, 0);
|
||||
first.completed_at = now - TimeDelta::minutes(45);
|
||||
first.started_at = first.completed_at - TimeDelta::seconds(10);
|
||||
let mut next = make_point(1, 20.0, 1_900, 0);
|
||||
next.completed_at = now - TimeDelta::minutes(20);
|
||||
next.started_at = next.completed_at - TimeDelta::seconds(10);
|
||||
store
|
||||
.quota_forecast_history
|
||||
.lock()
|
||||
.unwrap()
|
||||
.points
|
||||
.extend([first, next]);
|
||||
let result = services
|
||||
.accounts()
|
||||
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let cycle = &result.forecasts[0];
|
||||
assert!(cycle.unavailable_reason.is_none());
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
|
||||
assert_eq!(cycle.remaining_tokens, Some(429));
|
||||
assert_eq!(cycle.estimated_tokens, Some(679));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2397,6 +2819,52 @@ async fn reset_credit_oauth_refresh_should_reuse_the_exact_consume_command() {
|
||||
assert!(recorded(&events).contains(&"store.commit_refresh"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_credit_refresh_without_quota_should_complete_with_the_same_command() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("example", events.clone());
|
||||
provider.fail_next(ProviderAdminErrorKind::CredentialRefreshRequired);
|
||||
*provider.quota_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Unsupported);
|
||||
let store = FakeAccountStore::new("example", events.clone());
|
||||
let services = accounts_service(provider.clone(), store).await;
|
||||
let command = ConsumeProviderResetCredit {
|
||||
account_id: ProviderAccountId::new("acct_test").unwrap(),
|
||||
credit_id: Some("credit_1".into()),
|
||||
redeem_request_id: uuid::Uuid::parse_str("dbabeb20-040e-4e9f-8615-0b7c81e79987").unwrap(),
|
||||
};
|
||||
|
||||
let result = services
|
||||
.accounts()
|
||||
.consume_reset_credit(&context("reset-without-quota"), command.clone())
|
||||
.await
|
||||
.expect("缺少可选额度能力不能中断凭据刷新后的消费重试");
|
||||
|
||||
assert_eq!(result.code, "reset");
|
||||
assert_eq!(provider.reset_credit_commands(), [command.clone(), command]);
|
||||
assert_eq!(
|
||||
recorded(&events)
|
||||
.iter()
|
||||
.filter(|event| **event == "store.commit_refresh")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_quota_refresh_should_still_reject_an_unsupported_operation() {
|
||||
let provider = FakeProviderAdmin::new("example", events());
|
||||
*provider.quota_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Unsupported);
|
||||
let services = accounts_service(provider, FakeAccountStore::new("example", events())).await;
|
||||
|
||||
let error = services
|
||||
.accounts()
|
||||
.quota(&ProviderAccountId::new("acct_test").unwrap(), true)
|
||||
.await
|
||||
.expect_err("显式刷新不能被解释为空额度投影");
|
||||
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_credit_unknown_result_should_keep_a_stable_admin_kind() {
|
||||
let events = events();
|
||||
@@ -2577,7 +3045,7 @@ fn prepared_create(provider_kind: ProviderKind, name: &str) -> PreparedCredentia
|
||||
prepared_create_with_id(provider_kind, "acct_prepared", name)
|
||||
}
|
||||
|
||||
fn prepared_create_with_id(
|
||||
pub(super) fn prepared_create_with_id(
|
||||
provider_kind: ProviderKind,
|
||||
account_id: &str,
|
||||
name: &str,
|
||||
@@ -2614,6 +3082,24 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_rotation_facts(account: &AccountRecord) -> PreparedCredentialRotationFacts {
|
||||
PreparedCredentialRotationFacts {
|
||||
account_id: ProviderAccountId::new(account.id.clone()).expect("account ID"),
|
||||
provider_kind: account.provider_kind.clone(),
|
||||
expected_credential_revision: account.credential_revision,
|
||||
replacement_identity: None,
|
||||
name: "plugin refreshed account".to_owned(),
|
||||
email: account.email.clone(),
|
||||
plan_type: account.plan_type.clone(),
|
||||
preserve_profile: true,
|
||||
preserve_credential_state: false,
|
||||
provider_material: document(),
|
||||
has_refresh_token: account.has_refresh_token,
|
||||
access_token_expires_at: account.access_token_expires_at,
|
||||
next_refresh_at: account.next_refresh_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_profile_statistics() -> ProviderProfileStatistics {
|
||||
ProviderProfileStatistics {
|
||||
display_name: Some("Preview user".to_owned()),
|
||||
@@ -2802,6 +3288,7 @@ impl AccountProbe for SuccessfulAccountProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Ok(AccountProbeResult {
|
||||
@@ -2817,6 +3304,7 @@ impl AccountProbe for FailingAccountProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
let detail = ClientVisibleUpstreamError::new(
|
||||
@@ -2846,6 +3334,7 @@ impl AccountProbe for FreeModelQuotaProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async move {
|
||||
self.store.set_account_after_probe(account_record("xai"));
|
||||
@@ -2889,3 +3378,29 @@ pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImport
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregistered_provider_accounts_have_no_live_management_capabilities() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::with_account(account_record("retired-provider"), events());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let page = services
|
||||
.accounts()
|
||||
.list(AccountListQuery {
|
||||
page: 1,
|
||||
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
|
||||
provider_kind: None,
|
||||
group_filter: None,
|
||||
search: None,
|
||||
status: None,
|
||||
sort: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.items.len(), 1);
|
||||
assert_eq!(
|
||||
page.items[0].account.provider_kind.as_str(),
|
||||
"retired-provider"
|
||||
);
|
||||
assert_eq!(page.items[0].capabilities, Default::default());
|
||||
}
|
||||
|
||||
@@ -6,20 +6,26 @@ use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminErrorKind, MutationActor, MutationContext, Revision,
|
||||
AdminErrorKind, MutationActor, MutationContext, PageSize, Revision,
|
||||
client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage,
|
||||
ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField,
|
||||
CreateClientKey, DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection,
|
||||
UpdateClientKey,
|
||||
},
|
||||
plugin_client_keys::{
|
||||
PluginClientKey, PluginClientKeyCursor, PluginClientKeyListQuery, PluginClientKeyPage,
|
||||
},
|
||||
},
|
||||
ports::provider::{ProviderAdmin, ProviderAdminRegistry},
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestClientKeyStore {
|
||||
plaintexts: Mutex<Vec<String>>,
|
||||
list_queries: Mutex<Vec<ClientKeyListQuery>>,
|
||||
list_response: Mutex<Option<ClientKeyPage>>,
|
||||
create_error: Option<AdminStoreErrorKind>,
|
||||
update_error: Option<AdminStoreErrorKind>,
|
||||
resets: Mutex<Vec<gateway_admin::model::client_keys::ResetClientKeyBudget>>,
|
||||
@@ -44,6 +50,10 @@ impl ClientKeyStore for TestClientKeyStore {
|
||||
}
|
||||
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
self.list_queries.lock().unwrap().push(query.clone());
|
||||
if let Some(page) = self.list_response.lock().unwrap().clone() {
|
||||
return Ok(page);
|
||||
}
|
||||
assert_eq!(query.page_size.get(), u16::MAX);
|
||||
Ok(ClientKeyPage {
|
||||
config_revision: Revision::new(1).expect("revision"),
|
||||
@@ -74,8 +84,7 @@ impl ClientKeyStore for TestClientKeyStore {
|
||||
}
|
||||
self.plaintexts.lock().unwrap().push(key.plaintext);
|
||||
let record = ClientKeyRecord {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
request_profile_overrides: Default::default(),
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
@@ -196,6 +205,91 @@ async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
|
||||
assert_eq!(page.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_client_key_list_projects_only_public_identity_and_uses_stable_name_cursor() {
|
||||
let now = Utc::now();
|
||||
let key_id = ClientApiKeyId::new("key_public").expect("key ID");
|
||||
let next_id = ClientApiKeyId::new("key_next").expect("next key ID");
|
||||
let sort = ClientKeySort {
|
||||
field: ClientKeySortField::Name,
|
||||
direction: SortDirection::Asc,
|
||||
};
|
||||
let store = Arc::new(TestClientKeyStore {
|
||||
list_response: Mutex::new(Some(ClientKeyPage {
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
items: vec![ClientKeyRecord {
|
||||
request_profile_overrides: Default::default(),
|
||||
id: key_id.clone(),
|
||||
name: "Public identity".to_owned(),
|
||||
label: Some("private management label".to_owned()),
|
||||
groups: Vec::new(),
|
||||
provider_kinds: Vec::new(),
|
||||
prefix: "sk_must_not_escape".to_owned(),
|
||||
enabled: true,
|
||||
limits: RateLimits::unlimited(),
|
||||
budget: Default::default(),
|
||||
last_used_at: Some(now),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}],
|
||||
total: 1,
|
||||
next_cursor: Some(ClientKeyCursor {
|
||||
sort,
|
||||
value: ClientKeyCursorValue::Name("Public identity".to_owned()),
|
||||
id: next_id.clone(),
|
||||
}),
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
let providers = ProviderAdminRegistry::new(Vec::<Arc<dyn ProviderAdmin>>::new())
|
||||
.expect("empty provider registry");
|
||||
let access = gateway_admin::initialize_plugin_client_keys(
|
||||
providers,
|
||||
store.clone(),
|
||||
Arc::new(super::NoopSnapshot),
|
||||
);
|
||||
let input_cursor = PluginClientKeyCursor {
|
||||
name: "Before".to_owned(),
|
||||
id: ClientApiKeyId::new("key_before").expect("cursor key ID"),
|
||||
};
|
||||
|
||||
let page = access
|
||||
.list(PluginClientKeyListQuery {
|
||||
cursor: Some(input_cursor.clone()),
|
||||
limit: PageSize::new(25).expect("page size"),
|
||||
})
|
||||
.await
|
||||
.expect("list plugin client keys");
|
||||
|
||||
assert_eq!(
|
||||
page,
|
||||
PluginClientKeyPage {
|
||||
items: vec![PluginClientKey {
|
||||
id: key_id,
|
||||
name: "Public identity".to_owned(),
|
||||
enabled: true,
|
||||
}],
|
||||
next_cursor: Some(PluginClientKeyCursor {
|
||||
name: "Public identity".to_owned(),
|
||||
id: next_id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
*store.list_queries.lock().unwrap(),
|
||||
vec![ClientKeyListQuery {
|
||||
cursor: Some(ClientKeyCursor {
|
||||
sort,
|
||||
value: ClientKeyCursorValue::Name(input_cursor.name),
|
||||
id: input_cursor.id,
|
||||
}),
|
||||
page_size: ClientKeyPageSize::new(25).expect("page size"),
|
||||
search: None,
|
||||
sort,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_preserves_migrated_keys_and_keeps_default_generation() {
|
||||
let store = Arc::new(TestClientKeyStore::default());
|
||||
@@ -260,8 +354,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
|
||||
.update(
|
||||
&mutation_context(),
|
||||
UpdateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
request_profile_override_updates: Default::default(),
|
||||
id: ClientApiKeyId::new("key_existing").unwrap(),
|
||||
name: "Migration".to_owned(),
|
||||
label: None,
|
||||
@@ -281,8 +374,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
|
||||
|
||||
fn create_command(key: Option<&str>) -> CreateClientKey {
|
||||
CreateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
request_profile_overrides: Default::default(),
|
||||
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
|
||||
name: "Migration".to_owned(),
|
||||
label: None,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
mod openai;
|
||||
mod xai;
|
||||
+88
-48
@@ -11,7 +11,7 @@ use gateway_admin::{
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
|
||||
use super::accounts::{
|
||||
use super::super::accounts::{
|
||||
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, revision,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,9 @@ async fn openai_delete_should_commit_then_release_provider_resources() {
|
||||
let services = service(provider, store.clone()).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.delete(deletion("acct_test"))
|
||||
.await
|
||||
.expect("delete credential");
|
||||
@@ -48,10 +50,12 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: Some(super::accounts::import_settings()),
|
||||
settings: Some(super::super::accounts::import_settings()),
|
||||
context: context("import-openai"),
|
||||
document: document(),
|
||||
})
|
||||
@@ -79,7 +83,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
|
||||
);
|
||||
assert_eq!(
|
||||
store.import_settings(),
|
||||
[Some(super::accounts::import_settings())]
|
||||
[Some(super::super::accounts::import_settings())]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["import-openai"]);
|
||||
}
|
||||
@@ -106,7 +110,9 @@ async fn openai_import_should_expose_only_explicit_public_errors_without_committ
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider, store.clone()).await;
|
||||
let error = services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
@@ -132,7 +138,9 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
let result = services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
@@ -177,7 +185,9 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
let result = services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
@@ -204,7 +214,9 @@ async fn openai_authorization_create_should_observe_initial_quota() {
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-start-openai-create"),
|
||||
@@ -214,13 +226,16 @@ async fn openai_authorization_create_should_observe_initial_quota() {
|
||||
.await
|
||||
.expect("start authorization");
|
||||
services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
settings: Some(super::accounts::import_settings()),
|
||||
context: context("oauth-start-openai-create"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
})
|
||||
.credentials()
|
||||
.complete_authorization(
|
||||
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
|
||||
CompleteAuthorization {
|
||||
settings: Some(super::super::accounts::import_settings()),
|
||||
context: context("oauth-start-openai-create"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("complete authorization");
|
||||
|
||||
@@ -246,7 +261,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
|
||||
);
|
||||
assert_eq!(
|
||||
store.import_settings(),
|
||||
[Some(super::accounts::import_settings())]
|
||||
[Some(super::super::accounts::import_settings())]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["oauth-start-openai-create"]);
|
||||
}
|
||||
@@ -260,7 +275,9 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-start-openai-quota-failure"),
|
||||
@@ -270,13 +287,16 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
|
||||
.await
|
||||
.expect("start authorization");
|
||||
let result = services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
settings: None,
|
||||
context: context("oauth-start-openai-quota-failure"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
})
|
||||
.credentials()
|
||||
.complete_authorization(
|
||||
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
|
||||
CompleteAuthorization {
|
||||
settings: None,
|
||||
context: context("oauth-start-openai-quota-failure"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("quota observation must not fail authorization");
|
||||
|
||||
@@ -302,7 +322,9 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
|
||||
};
|
||||
|
||||
services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-store-retry"),
|
||||
@@ -312,13 +334,19 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
|
||||
.await
|
||||
.expect("start authorization");
|
||||
services
|
||||
.openai()
|
||||
.complete_authorization(command())
|
||||
.credentials()
|
||||
.complete_authorization(
|
||||
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
|
||||
command(),
|
||||
)
|
||||
.await
|
||||
.expect_err("Store commit must fail");
|
||||
services
|
||||
.openai()
|
||||
.complete_authorization(command())
|
||||
.credentials()
|
||||
.complete_authorization(
|
||||
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
|
||||
command(),
|
||||
)
|
||||
.await
|
||||
.expect("released claim must permit retry");
|
||||
|
||||
@@ -350,7 +378,9 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
|
||||
let services = service(provider, store).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
@@ -370,7 +400,9 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-start-openai"),
|
||||
@@ -396,13 +428,16 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
|
||||
provider.set_current_credential_revision(revision(2));
|
||||
|
||||
let result = services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
settings: None,
|
||||
context: context("oauth-complete-openai"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
})
|
||||
.credentials()
|
||||
.complete_authorization(
|
||||
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
|
||||
CompleteAuthorization {
|
||||
settings: None,
|
||||
context: context("oauth-complete-openai"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("complete reauthorization");
|
||||
|
||||
@@ -427,7 +462,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
|
||||
}
|
||||
|
||||
async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
super::super::AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store)
|
||||
.build()
|
||||
@@ -448,7 +483,9 @@ async fn reauthorization_with_import_settings_releases_claim_without_committing(
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider, store.clone()).await;
|
||||
services
|
||||
.openai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("reauthorization-settings"),
|
||||
@@ -458,13 +495,16 @@ async fn reauthorization_with_import_settings_releases_claim_without_committing(
|
||||
.await
|
||||
.expect("start reauthorization");
|
||||
let error = services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
settings: Some(super::accounts::import_settings()),
|
||||
context: context("reauthorization-settings"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
})
|
||||
.credentials()
|
||||
.complete_authorization(
|
||||
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
|
||||
CompleteAuthorization {
|
||||
settings: Some(super::super::accounts::import_settings()),
|
||||
context: context("reauthorization-settings"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("reauthorization must preserve settings");
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid);
|
||||
+47
-18
@@ -10,7 +10,9 @@ use gateway_admin::{
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
|
||||
use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, document, events, recorded};
|
||||
use super::super::accounts::{
|
||||
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
@@ -20,10 +22,12 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
|
||||
services
|
||||
.xai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: Some(super::accounts::import_settings()),
|
||||
settings: Some(super::super::accounts::import_settings()),
|
||||
context: context("import-xai"),
|
||||
document: document(),
|
||||
})
|
||||
@@ -50,7 +54,7 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
);
|
||||
assert_eq!(
|
||||
store.import_settings(),
|
||||
[Some(super::accounts::import_settings())]
|
||||
[Some(super::super::accounts::import_settings())]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["import-xai"]);
|
||||
}
|
||||
@@ -64,7 +68,9 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
services
|
||||
.xai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
@@ -101,7 +107,9 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
let result = services
|
||||
.xai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
@@ -127,7 +135,9 @@ async fn xai_authorization_should_observe_quota_without_affecting_commit() {
|
||||
let store = FakeAccountStore::new("xai", events.clone());
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
services
|
||||
.xai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap())
|
||||
.unwrap()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-xai-create"),
|
||||
@@ -137,13 +147,16 @@ async fn xai_authorization_should_observe_quota_without_affecting_commit() {
|
||||
.await
|
||||
.expect("start authorization");
|
||||
let result = services
|
||||
.xai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
settings: None,
|
||||
context: context("oauth-xai-create"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
})
|
||||
.credentials()
|
||||
.complete_authorization(
|
||||
&gateway_core::routing::ProviderKind::new("xai").unwrap(),
|
||||
CompleteAuthorization {
|
||||
settings: None,
|
||||
context: context("oauth-xai-create"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("quota observation must not fail authorization");
|
||||
|
||||
@@ -186,8 +199,22 @@ async fn credential_import_should_return_while_initial_quota_is_pending() {
|
||||
};
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
match kind {
|
||||
"openai" => services.openai().import_document(command).await,
|
||||
_ => services.xai().import_document(command).await,
|
||||
"openai" => {
|
||||
services
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(command)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
services
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(command)
|
||||
.await
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -208,7 +235,9 @@ async fn xai_import_store_failure_should_not_observe_quota_or_invalidate_provide
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
services
|
||||
.xai()
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
@@ -226,7 +255,7 @@ async fn xai_import_store_failure_should_not_observe_quota_or_invalidate_provide
|
||||
}
|
||||
|
||||
async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
super::super::AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store)
|
||||
.build()
|
||||
@@ -25,8 +25,7 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event
|
||||
|
||||
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
|
||||
RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_profiles: Default::default(),
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: revision(1),
|
||||
@@ -52,6 +51,9 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
|
||||
account_auto_freeze_probe_enabled: probe_enabled,
|
||||
account_auto_freeze_probe_model: Some("gpt-5.5".to_owned()),
|
||||
account_auto_freeze_adaptive_concurrency: adaptive,
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -201,6 +203,7 @@ impl AccountProbe for SuccessfulProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
|
||||
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Ok(AccountProbeResult {
|
||||
@@ -216,6 +219,7 @@ impl AccountProbe for FailingProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
|
||||
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Err(AccountProbeError::new(
|
||||
|
||||
@@ -4,14 +4,15 @@ mod auth;
|
||||
mod auth_key;
|
||||
mod backup;
|
||||
mod client_keys;
|
||||
mod credentials;
|
||||
mod freeze_recovery;
|
||||
mod import_tasks;
|
||||
mod observability;
|
||||
mod openai;
|
||||
mod plugin_update;
|
||||
mod plugins;
|
||||
mod proxies;
|
||||
mod settings;
|
||||
mod system;
|
||||
mod xai;
|
||||
|
||||
use std::{
|
||||
str::FromStr,
|
||||
@@ -49,10 +50,10 @@ use gateway_admin::{
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
|
||||
CredentialImportCommit, CredentialImportResult, CredentialMutationResult,
|
||||
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
|
||||
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
PreparedCredentialImport, PreparedCredentialRotation, ProviderExport,
|
||||
ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
CredentialRotationCommit, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
|
||||
ProviderModels, ProviderQuota,
|
||||
},
|
||||
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
|
||||
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
|
||||
@@ -101,6 +102,8 @@ pub(super) struct AdminHarness {
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
plugin_store: Arc<dyn gateway_admin::ports::plugins::PluginStore>,
|
||||
plugin_inspector: Arc<dyn gateway_admin::ports::plugins::PluginPackageInspector>,
|
||||
client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
@@ -126,6 +129,8 @@ impl AdminHarness {
|
||||
],
|
||||
probe: Arc::new(UnavailableProbe),
|
||||
system: Arc::new(UnavailableSystem),
|
||||
plugin_store: Arc::new(plugins::TestPluginPorts),
|
||||
plugin_inspector: Arc::new(plugins::TestPluginPorts),
|
||||
client_key_verifier: Arc::new(UnavailableClientKeyVerifier),
|
||||
}
|
||||
}
|
||||
@@ -215,6 +220,16 @@ impl AdminHarness {
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn plugins(
|
||||
mut self,
|
||||
store: Arc<dyn gateway_admin::ports::plugins::PluginStore>,
|
||||
inspector: Arc<dyn gateway_admin::ports::plugins::PluginPackageInspector>,
|
||||
) -> Self {
|
||||
self.plugin_store = store;
|
||||
self.plugin_inspector = inspector;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) async fn build(self) -> AdminServices {
|
||||
self.build_bundle().await.services()
|
||||
}
|
||||
@@ -241,10 +256,20 @@ impl AdminHarness {
|
||||
self.observability,
|
||||
self.settings,
|
||||
self.backup,
|
||||
self.plugin_store,
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
plugin_preparation: Arc::new(plugins::TestPluginPorts),
|
||||
plugin_management: Arc::new(plugins::TestPluginPorts),
|
||||
published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle::default(),
|
||||
plugin_inspector: self.plugin_inspector,
|
||||
plugin_distribution: Arc::new(plugins::TestPluginPorts),
|
||||
pricing_source: Arc::new(UnavailablePricingSource),
|
||||
providers: self.providers,
|
||||
providers: gateway_admin::ports::provider::ProviderAdminRegistry::new(
|
||||
self.providers,
|
||||
)
|
||||
.unwrap(),
|
||||
snapshot: Arc::new(NoopSnapshot),
|
||||
account_probe: self.probe,
|
||||
proxy_probe: Arc::new(proxies::TestProxies::default()),
|
||||
@@ -416,6 +441,13 @@ impl AccountGroupStore for UnavailableAccountGroupStore {
|
||||
|
||||
#[async_trait]
|
||||
impl AccountStore for UnavailableStore {
|
||||
async fn list_plugin_accounts(
|
||||
&self,
|
||||
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
|
||||
) -> AdminStoreResult<gateway_admin::model::provider_credentials::PluginAccountPage> {
|
||||
Err(unavailable("plugin accounts"))
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
_: AccountListQuery,
|
||||
@@ -462,6 +494,13 @@ impl AccountStore for UnavailableStore {
|
||||
Err(unavailable("credential"))
|
||||
}
|
||||
|
||||
async fn credential_details_by_id(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<CredentialDetails>> {
|
||||
Err(unavailable("credential"))
|
||||
}
|
||||
|
||||
async fn load_credentials_for_export(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
@@ -470,6 +509,13 @@ impl AccountStore for UnavailableStore {
|
||||
Err(unavailable("credential export"))
|
||||
}
|
||||
|
||||
async fn load_credential_for_plugin(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<ProviderExportCredentialInput>> {
|
||||
Err(unavailable("credential export"))
|
||||
}
|
||||
|
||||
async fn commit_credential_import(
|
||||
&self,
|
||||
_: CredentialImportCommit,
|
||||
@@ -478,11 +524,21 @@ impl AccountStore for UnavailableStore {
|
||||
Err(unavailable("credential import"))
|
||||
}
|
||||
|
||||
async fn authorization_receipt(
|
||||
&self,
|
||||
_: &gateway_admin::model::provider_credentials::AuthorizationReceiptKey,
|
||||
) -> AdminStoreResult<
|
||||
Option<gateway_admin::model::provider_credentials::CredentialMutationResult>,
|
||||
> {
|
||||
Err(unavailable("authorization receipt"))
|
||||
}
|
||||
|
||||
async fn commit_authorization(
|
||||
&self,
|
||||
_: AuthorizationCommit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
) -> AdminStoreResult<gateway_admin::model::provider_credentials::AuthorizationCommitResult>
|
||||
{
|
||||
Err(unavailable("authorization"))
|
||||
}
|
||||
|
||||
@@ -784,6 +840,10 @@ impl UnavailableProvider {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn test_provider(kind: &str) -> Arc<dyn ProviderAdmin> {
|
||||
Arc::new(UnavailableProvider::new(kind))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdmin for UnavailableProvider {
|
||||
fn provider_kind(&self) -> &ProviderKind {
|
||||
@@ -792,7 +852,7 @@ impl ProviderAdmin for UnavailableProvider {
|
||||
|
||||
async fn account_unavailable(&self, _: &ProviderAccountId) {}
|
||||
|
||||
fn connection_test_operation(
|
||||
async fn connection_test_operation(
|
||||
&self,
|
||||
_: &gateway_core::routing::UpstreamModelId,
|
||||
_: &str,
|
||||
@@ -823,7 +883,7 @@ impl ProviderAdmin for UnavailableProvider {
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
_: PendingAuthorizationMutation,
|
||||
_: gateway_admin::model::provider_credentials::PendingAuthorizationMutation,
|
||||
) -> Result<AuthorizationStarted, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
@@ -955,6 +1015,7 @@ impl AccountProbe for UnavailableProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Err(GatewayError::new(
|
||||
@@ -985,6 +1046,7 @@ impl SystemOperations for UnavailableSystem {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
@@ -993,7 +1055,10 @@ impl SystemOperations for UnavailableSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
async fn rollback(
|
||||
&self,
|
||||
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
|
||||
@@ -237,11 +237,45 @@ async fn dashboard_summary_should_project_rebuildable_runtime_slots() {
|
||||
.expect("dashboard summary")
|
||||
.capacity;
|
||||
|
||||
assert_eq!(capacity.total_slots, 7);
|
||||
assert_eq!(capacity.total_slots, Some(7));
|
||||
assert_eq!(capacity.used_slots, Some(2));
|
||||
assert_eq!(capacity.available_slots, Some(5));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_capacity_distinguishes_unlimited_inheritance_finite_overrides_and_empty_pool() {
|
||||
for (inherited_accounts, overridden_slots, expected_total, expected_available) in [
|
||||
(2, 5, None, None),
|
||||
(0, 5, Some(5), Some(3)),
|
||||
(0, 0, Some(0), Some(0)),
|
||||
] {
|
||||
let now = Utc::now();
|
||||
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
|
||||
store.replace_runtime_slots(Some(DashboardRuntimeSlots {
|
||||
inherited_accounts,
|
||||
overridden_slots,
|
||||
used_slots: Some(2),
|
||||
}));
|
||||
let services = super::AdminHarness::new()
|
||||
.observability(store)
|
||||
.settings(Arc::new(FixtureSettingsStore {
|
||||
max_concurrent_per_account: 0,
|
||||
}))
|
||||
.provider(super::dashboard_profile_provider())
|
||||
.build()
|
||||
.await;
|
||||
let capacity = services
|
||||
.observability()
|
||||
.dashboard_summary(observation_range(now), TrendKind::Usage)
|
||||
.await
|
||||
.expect("dashboard")
|
||||
.capacity;
|
||||
assert_eq!(capacity.total_slots, expected_total);
|
||||
assert_eq!(capacity.available_slots, expected_available);
|
||||
assert_eq!(capacity.used_slots, Some(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_share_one_current_observation_time_across_runtime_facts() {
|
||||
let historical_end = Utc::now() - Duration::hours(1);
|
||||
@@ -749,7 +783,9 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
}
|
||||
}
|
||||
|
||||
struct FixtureSettingsStore;
|
||||
struct FixtureSettingsStore {
|
||||
max_concurrent_per_account: u32,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for FixtureSettingsStore {
|
||||
@@ -772,15 +808,14 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
}
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_profiles: Default::default(),
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: Revision::new(1).expect("revision"),
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 1,
|
||||
max_concurrent_per_account: self.max_concurrent_per_account,
|
||||
request_interval_ms: 0,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
@@ -800,6 +835,9 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
account_auto_freeze_probe_enabled: true,
|
||||
account_auto_freeze_probe_model: None,
|
||||
account_auto_freeze_adaptive_concurrency: true,
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -834,7 +872,9 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
async fn observability_services(store: Arc<FixtureObservabilityStore>) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.observability(store)
|
||||
.settings(Arc::new(FixtureSettingsStore))
|
||||
.settings(Arc::new(FixtureSettingsStore {
|
||||
max_concurrent_per_account: 1,
|
||||
}))
|
||||
.provider(super::dashboard_profile_provider())
|
||||
.build()
|
||||
.await
|
||||
@@ -845,7 +885,9 @@ async fn observability_services_with_calculated_billing(
|
||||
) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.observability(store)
|
||||
.settings(Arc::new(FixtureSettingsStore))
|
||||
.settings(Arc::new(FixtureSettingsStore {
|
||||
max_concurrent_per_account: 1,
|
||||
}))
|
||||
.provider(super::calculated_billing_provider())
|
||||
.build()
|
||||
.await
|
||||
@@ -880,6 +922,7 @@ fn total_record(
|
||||
provider_account_ref: None,
|
||||
provider_account_name: None,
|
||||
provider_account_email: None,
|
||||
provider_account_notes: None,
|
||||
provider_account_authentication_kind: None,
|
||||
upstream_model_id: Some("gpt-5.5".to_owned()),
|
||||
upstream_transport: None,
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
plugins::{
|
||||
InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMetadata,
|
||||
PluginArtifactMutation, PluginCompatibilityRequirements, PluginSource,
|
||||
distribution::{PluginSourceBinding, SourceCredential, SourceCredentialInfo},
|
||||
instances::{PluginInstance, PluginInstanceMutation, PluginInstanceSnapshot},
|
||||
},
|
||||
system::{
|
||||
SystemOperationAccepted, SystemOperationState, SystemOperationStatus,
|
||||
SystemUpdateDetail, SystemUpdateStatus, SystemVersion,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
plugins::{PluginPackageInspector, PluginStore},
|
||||
store::AdminStoreResult,
|
||||
system::{
|
||||
SystemOperationError, SystemOperations, SystemUpdateCandidate, SystemUpdateEventStream,
|
||||
SystemUpdatePreflight,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
revisions: std::sync::Mutex<Vec<Revision>>,
|
||||
requirements: PluginCompatibilityRequirements,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(requirements: PluginCompatibilityRequirements, revisions: &[u64]) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
revisions: std::sync::Mutex::new(
|
||||
revisions
|
||||
.iter()
|
||||
.map(|revision| Revision::new(*revision).expect("revision"))
|
||||
.collect(),
|
||||
),
|
||||
requirements,
|
||||
})
|
||||
}
|
||||
|
||||
fn next_revision(&self) -> Revision {
|
||||
let mut revisions = self.revisions.lock().expect("revisions");
|
||||
if revisions.len() > 1 {
|
||||
revisions.remove(0)
|
||||
} else {
|
||||
*revisions.first().expect("fixture revision")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageInspector for Fixture {
|
||||
async fn inspect(
|
||||
&self,
|
||||
_: Arc<[u8]>,
|
||||
_: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn compatibility(
|
||||
&self,
|
||||
_: Arc<[u8]>,
|
||||
expected_sha256: String,
|
||||
) -> Result<PluginCompatibilityRequirements, AdminError> {
|
||||
assert_eq!(expected_sha256, "a".repeat(64));
|
||||
Ok(self.requirements.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginStore for Fixture {
|
||||
async fn management_target_is_current(
|
||||
&self,
|
||||
target: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(self
|
||||
.load_instances()
|
||||
.await?
|
||||
.instances
|
||||
.iter()
|
||||
.any(|instance| {
|
||||
instance.enabled
|
||||
&& instance.trusted_process
|
||||
&& instance.id == target.instance_id
|
||||
&& instance.artifact_sha256 == target.artifact_sha256
|
||||
&& instance.revision.get() == target.revision
|
||||
}))
|
||||
}
|
||||
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot> {
|
||||
Ok(PluginInstanceSnapshot {
|
||||
config_revision: self.next_revision(),
|
||||
instances: vec![PluginInstance {
|
||||
id: "enabled-fixture".into(),
|
||||
name: "Enabled fixture".into(),
|
||||
artifact_sha256: "a".repeat(64),
|
||||
enabled: true,
|
||||
trusted_process: true,
|
||||
configuration: serde_json::json!({}),
|
||||
secrets: BTreeMap::new(),
|
||||
grants: Vec::new(),
|
||||
bindings: Vec::new(),
|
||||
revision: Revision::new(1).expect("revision"),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_artifact(&self, digest: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
assert_eq!(digest, "a".repeat(64));
|
||||
Ok(InspectedPluginArtifact {
|
||||
metadata: PluginArtifactMetadata {
|
||||
plugin_id: "test.fixture".into(),
|
||||
version: "1.0.0".into(),
|
||||
name: "fixture".into(),
|
||||
display_name: "Fixture".into(),
|
||||
publisher: "test".into(),
|
||||
author: Some("project".into()),
|
||||
description: "fixture".into(),
|
||||
license: "MIT".into(),
|
||||
sha256: digest.into(),
|
||||
platforms: Vec::new(),
|
||||
icon: None,
|
||||
contributes: BTreeMap::new(),
|
||||
requested_permissions: Vec::new(),
|
||||
configuration_schema: serde_json::json!({}),
|
||||
secret_fields: Vec::new(),
|
||||
state_namespaces: Vec::new(),
|
||||
},
|
||||
archive: Arc::from([1_u8]),
|
||||
})
|
||||
}
|
||||
|
||||
async fn save_instance(
|
||||
&self,
|
||||
_: PluginInstance,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginInstanceMutation> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn delete_instance(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn change_update_source(
|
||||
&self,
|
||||
_: PluginSourceBinding,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn save_source_credential(
|
||||
&self,
|
||||
_: SourceCredential,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn delete_source_credential(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn install_artifact(
|
||||
&self,
|
||||
_: InspectedPluginArtifact,
|
||||
_: PluginSource,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn accept_artifact(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PreflightingSystem {
|
||||
candidate: SystemUpdateCandidate,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemOperations for PreflightingSystem {
|
||||
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream {
|
||||
Box::pin(futures::stream::empty())
|
||||
}
|
||||
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
let revision = preflight.validate(self.candidate.clone()).await?;
|
||||
preflight.confirm_revision(revision).await?;
|
||||
Ok(SystemOperationAccepted::Update {
|
||||
operation_id: "fixture".into(),
|
||||
deployment_mode: "fixture".into(),
|
||||
message: "accepted".into(),
|
||||
target_version: self.candidate.target_version.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
|
||||
Ok(SystemUpdateStatus {
|
||||
previous_version: None,
|
||||
current_version: None,
|
||||
need_restart: false,
|
||||
operation: SystemOperationState {
|
||||
operation_id: None,
|
||||
kind: None,
|
||||
status: SystemOperationStatus::Idle,
|
||||
target_version: None,
|
||||
message: None,
|
||||
error: None,
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn rollback(
|
||||
&self,
|
||||
_: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision() {
|
||||
let fixture = Fixture::new(requirements("^1.0", "executor", "log"), &[7]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture)
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("executor", "log"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()))
|
||||
.await
|
||||
.expect("compatible update");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_rejects_missing_target_capability_and_revision_changes() {
|
||||
let incompatible = Fixture::new(requirements("^1.0", "executor", "log"), &[7]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(incompatible.clone(), incompatible)
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("models", "log"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let stale = Fixture::new(requirements("^1.0", "executor", "log"), &[7, 8]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(stale.clone(), stale)
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("executor", "log"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn requirements(
|
||||
host_version: &str,
|
||||
capability: &str,
|
||||
permission: &str,
|
||||
) -> PluginCompatibilityRequirements {
|
||||
PluginCompatibilityRequirements {
|
||||
host_version: host_version.into(),
|
||||
manifest_schema_version: 2,
|
||||
protocol_version: 3,
|
||||
capabilities: vec![(capability.into(), 1)],
|
||||
permissions: vec![permission.into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(capability: &str, permission: &str) -> SystemUpdateCandidate {
|
||||
let manifest = serde_json::to_vec(&serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"sealed": true,
|
||||
"gateway_version": "1.2.0",
|
||||
"gateway_git_sha": "a".repeat(40),
|
||||
"plugin_host": {
|
||||
"schema_version": 1,
|
||||
"manifest_schema_versions": [2],
|
||||
"protocol_versions": [3],
|
||||
"capabilities": [{ "capability": capability, "versions": [1] }],
|
||||
"permissions": [permission],
|
||||
},
|
||||
"plugins": [],
|
||||
}))
|
||||
.expect("manifest");
|
||||
SystemUpdateCandidate {
|
||||
target_version: "1.2.0".into(),
|
||||
release_manifest: manifest.into(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::{
|
||||
PluginDistributionPorts, PluginsService,
|
||||
model::{
|
||||
AdminError, MutationActor, MutationContext, Revision,
|
||||
plugins::{
|
||||
InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMetadata,
|
||||
PluginArtifactMutation, PluginContribution, PluginSource,
|
||||
distribution::{PluginSourceBinding, SourceCredential, SourceCredentialInfo},
|
||||
instances::{PluginInstance, PluginInstanceMutation, PluginInstanceSnapshot},
|
||||
state::PluginStateConfiguration,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
plugins::{PluginPreparation, PluginStore},
|
||||
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
routing::ConfigRevision,
|
||||
runtime::{
|
||||
RuntimeSnapshotHandle, SnapshotControl,
|
||||
extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference},
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::TestPluginPorts;
|
||||
|
||||
const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
struct FixtureData {
|
||||
artifact: InstalledPluginArtifact,
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
saves: usize,
|
||||
publications: Vec<u64>,
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
data: Mutex<FixtureData>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(metadata: PluginArtifactMetadata, existing: Option<PluginInstance>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
data: Mutex::new(FixtureData {
|
||||
artifact: InstalledPluginArtifact {
|
||||
metadata,
|
||||
source: PluginSource::Upload,
|
||||
installed_at: chrono::Utc::now(),
|
||||
accepted_at: None,
|
||||
},
|
||||
snapshot: PluginInstanceSnapshot {
|
||||
config_revision: revision(1),
|
||||
instances: existing.into_iter().collect(),
|
||||
},
|
||||
saves: 0,
|
||||
publications: Vec::new(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn service(self: &Arc<Self>) -> PluginsService {
|
||||
let unused = Arc::new(TestPluginPorts);
|
||||
PluginsService::new(
|
||||
self.clone(),
|
||||
unused.clone(),
|
||||
PluginDistributionPorts::new(unused.clone(), unused.clone()),
|
||||
self.clone(),
|
||||
self.clone(),
|
||||
RuntimeSnapshotHandle::default(),
|
||||
unused,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapshotControl for Fixture {
|
||||
fn publish_committed(&self, revision: ConfigRevision) -> BoxFuture<'_, ()> {
|
||||
self.data.lock().unwrap().publications.push(revision.get());
|
||||
Box::pin(async {})
|
||||
}
|
||||
}
|
||||
|
||||
struct Lease;
|
||||
|
||||
impl ExtensionSetLease for Lease {
|
||||
fn is_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPreparation for Fixture {
|
||||
async fn configuration_ready(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
_: &gateway_admin::model::plugins::PluginArtifactMetadata,
|
||||
) -> Result<bool, AdminError> {
|
||||
Ok(instance
|
||||
.configuration
|
||||
.get("requiredName")
|
||||
.is_some_and(serde_json::Value::is_string))
|
||||
}
|
||||
|
||||
async fn validate(&self, _: PluginInstance) -> Result<PluginStateConfiguration, AdminError> {
|
||||
Ok(PluginStateConfiguration {
|
||||
namespaces: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn prepare(
|
||||
&self,
|
||||
_: PluginInstanceSnapshot,
|
||||
) -> Result<ExtensionSetReference, AdminError> {
|
||||
Ok(ExtensionSetReference::new(
|
||||
ExtensionSetId::new("install-fixture".into()).unwrap(),
|
||||
Arc::new(Lease),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginStore for Fixture {
|
||||
async fn management_target_is_current(
|
||||
&self,
|
||||
target: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(self
|
||||
.load_instances()
|
||||
.await?
|
||||
.instances
|
||||
.iter()
|
||||
.any(|instance| {
|
||||
instance.enabled
|
||||
&& instance.trusted_process
|
||||
&& instance.id == target.instance_id
|
||||
&& instance.artifact_sha256 == target.artifact_sha256
|
||||
&& instance.revision.get() == target.revision
|
||||
}))
|
||||
}
|
||||
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot> {
|
||||
Ok(self.data.lock().unwrap().snapshot.clone())
|
||||
}
|
||||
|
||||
async fn save_instance(
|
||||
&self,
|
||||
mut instance: PluginInstance,
|
||||
expected_revision: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginInstanceMutation> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
if data.snapshot.config_revision != expected_revision {
|
||||
return Err(store_error(AdminStoreErrorKind::Conflict));
|
||||
}
|
||||
let next = revision(expected_revision.get() + 1);
|
||||
instance.revision = next;
|
||||
data.snapshot.config_revision = next;
|
||||
data.snapshot
|
||||
.instances
|
||||
.retain(|current| current.id != instance.id);
|
||||
data.snapshot.instances.push(instance.clone());
|
||||
data.saves += 1;
|
||||
Ok(PluginInstanceMutation {
|
||||
config_revision: next,
|
||||
instance,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_instance(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(store_error(AdminStoreErrorKind::Unavailable))
|
||||
}
|
||||
|
||||
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn change_update_source(
|
||||
&self,
|
||||
_: PluginSourceBinding,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(store_error(AdminStoreErrorKind::Unavailable))
|
||||
}
|
||||
|
||||
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
|
||||
Err(store_error(AdminStoreErrorKind::Unavailable))
|
||||
}
|
||||
|
||||
async fn save_source_credential(
|
||||
&self,
|
||||
_: SourceCredential,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(store_error(AdminStoreErrorKind::Unavailable))
|
||||
}
|
||||
|
||||
async fn delete_source_credential(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(store_error(AdminStoreErrorKind::Unavailable))
|
||||
}
|
||||
|
||||
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
|
||||
Ok(vec![self.data.lock().unwrap().artifact.clone()])
|
||||
}
|
||||
|
||||
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
Err(store_error(AdminStoreErrorKind::Unavailable))
|
||||
}
|
||||
|
||||
async fn install_artifact(
|
||||
&self,
|
||||
_: InspectedPluginArtifact,
|
||||
_: PluginSource,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation> {
|
||||
Err(store_error(AdminStoreErrorKind::Unavailable))
|
||||
}
|
||||
|
||||
async fn accept_artifact(
|
||||
&self,
|
||||
digest: &str,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
if data.artifact.metadata.sha256 != digest {
|
||||
return Err(store_error(AdminStoreErrorKind::NotFound));
|
||||
}
|
||||
if data.artifact.accepted_at.is_none() {
|
||||
data.artifact.accepted_at = Some(chrono::Utc::now());
|
||||
data.snapshot.config_revision = revision(data.snapshot.config_revision.get() + 1);
|
||||
}
|
||||
Ok(PluginArtifactMutation {
|
||||
config_revision: data.snapshot.config_revision,
|
||||
artifact: data.artifact.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult<Revision> {
|
||||
Err(store_error(AdminStoreErrorKind::Unavailable))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acceptance_creates_one_stable_default_instance_with_defaults_and_bindings() {
|
||||
let fixture = Fixture::new(metadata(true), None);
|
||||
let service = fixture.service();
|
||||
|
||||
let first = service.accept_artifact(DIGEST, &context()).await.unwrap();
|
||||
let repeated = service.accept_artifact(DIGEST, &context()).await.unwrap();
|
||||
assert_eq!(first.default_instance_id, repeated.default_instance_id);
|
||||
assert!(!first.configuration_required);
|
||||
assert!(!repeated.configuration_required);
|
||||
|
||||
let data = fixture.data.lock().unwrap();
|
||||
assert_eq!(data.saves, 1, "同一摘要重试不能复制默认实例");
|
||||
assert_eq!(data.snapshot.instances.len(), 1);
|
||||
let instance = &data.snapshot.instances[0];
|
||||
assert_eq!(
|
||||
Some(instance.id.as_str()),
|
||||
first.default_instance_id.as_deref()
|
||||
);
|
||||
assert!(instance.enabled);
|
||||
assert_eq!(instance.configuration["mode"], "safe");
|
||||
assert_eq!(instance.configuration["nested"]["limit"], 3);
|
||||
assert_eq!(instance.configuration["requiredName"], "ready");
|
||||
assert!(instance.configuration.get("token").is_none());
|
||||
assert_eq!(
|
||||
instance
|
||||
.bindings
|
||||
.iter()
|
||||
.map(|binding| (
|
||||
binding.contribution.as_str(),
|
||||
binding.stage.as_str(),
|
||||
&binding.failure_policy,
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(
|
||||
"test.example.middleware",
|
||||
"observation",
|
||||
&gateway_admin::model::plugins::instances::PluginFailurePolicy::Observe,
|
||||
),
|
||||
(
|
||||
"test.example.middleware",
|
||||
"request",
|
||||
&gateway_admin::model::plugins::instances::PluginFailurePolicy::Reject,
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(instance.bindings.iter().all(|binding| !matches!(
|
||||
binding.contribution.as_str(),
|
||||
"test.example.auth" | "test.example.cli" | "test.example.management"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_required_configuration_creates_a_disabled_pending_instance() {
|
||||
let fixture = Fixture::new(metadata(false), None);
|
||||
let result = fixture
|
||||
.service()
|
||||
.accept_artifact(DIGEST, &context())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.configuration_required);
|
||||
assert!(result.default_instance_id.is_some());
|
||||
let data = fixture.data.lock().unwrap();
|
||||
assert_eq!(data.snapshot.instances.len(), 1);
|
||||
assert!(!data.snapshot.instances[0].enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_plugin_instance_prevents_an_extra_default_instance() {
|
||||
let metadata = metadata(true);
|
||||
let existing = PluginInstance {
|
||||
id: uuid::Uuid::now_v7().to_string(),
|
||||
name: "Existing".into(),
|
||||
artifact_sha256: metadata.sha256.clone(),
|
||||
enabled: false,
|
||||
trusted_process: false,
|
||||
configuration: json!({}),
|
||||
secrets: BTreeMap::new(),
|
||||
grants: Vec::new(),
|
||||
bindings: Vec::new(),
|
||||
revision: revision(1),
|
||||
};
|
||||
let fixture = Fixture::new(metadata, Some(existing));
|
||||
let result = fixture
|
||||
.service()
|
||||
.accept_artifact(DIGEST, &context())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.default_instance_id, None);
|
||||
let data = fixture.data.lock().unwrap();
|
||||
assert_eq!(data.saves, 0);
|
||||
assert_eq!(data.snapshot.instances.len(), 1);
|
||||
}
|
||||
|
||||
fn metadata(with_required_default: bool) -> PluginArtifactMetadata {
|
||||
let required_name = if with_required_default {
|
||||
json!({"type":"string","default":"ready"})
|
||||
} else {
|
||||
json!({"type":"string"})
|
||||
};
|
||||
PluginArtifactMetadata {
|
||||
plugin_id: "test.example".into(),
|
||||
version: "1.0.0".into(),
|
||||
name: "example".into(),
|
||||
display_name: "Example".into(),
|
||||
publisher: "test".into(),
|
||||
author: None,
|
||||
description: "fixture".into(),
|
||||
license: "MIT".into(),
|
||||
sha256: DIGEST.into(),
|
||||
platforms: vec!["linux-x86_64".into()],
|
||||
icon: None,
|
||||
contributes: BTreeMap::from([
|
||||
(
|
||||
"command_line".into(),
|
||||
contribution("test.example.cli", &["command"]),
|
||||
),
|
||||
(
|
||||
"frontend_authentication".into(),
|
||||
contribution("test.example.auth", &["authentication"]),
|
||||
),
|
||||
(
|
||||
"management".into(),
|
||||
contribution("test.example.management", &["management"]),
|
||||
),
|
||||
(
|
||||
"middleware".into(),
|
||||
contribution("test.example.middleware", &["observation", "request"]),
|
||||
),
|
||||
]),
|
||||
requested_permissions: vec!["network".into()],
|
||||
configuration_schema: json!({
|
||||
"type":"object",
|
||||
"properties": {
|
||||
"mode": {"type":"string","default":"safe"},
|
||||
"nested": {
|
||||
"type":"object",
|
||||
"default": {},
|
||||
"properties": {"limit":{"type":"integer","default":3}}
|
||||
},
|
||||
"requiredName": required_name,
|
||||
"token": {"type":"string","default":"never-copy-secret"}
|
||||
},
|
||||
"required":["requiredName"]
|
||||
}),
|
||||
secret_fields: vec!["token".into()],
|
||||
state_namespaces: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn contribution(id: &str, stages: &[&str]) -> PluginContribution {
|
||||
PluginContribution {
|
||||
id: id.into(),
|
||||
version: 1,
|
||||
stages: stages.iter().map(|stage| (*stage).into()).collect(),
|
||||
input_formats: Vec::new(),
|
||||
output_formats: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn revision(value: u64) -> Revision {
|
||||
Revision::new(value).unwrap()
|
||||
}
|
||||
|
||||
fn context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "install-fixture".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_error(kind: AdminStoreErrorKind) -> AdminStoreError {
|
||||
AdminStoreError::new(kind, "plugin", "install fixture")
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::{
|
||||
PluginDistributionPorts, PluginsService,
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationActor, MutationContext, Revision,
|
||||
plugins::{
|
||||
InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMetadata,
|
||||
PluginArtifactMutation, PluginSource, PluginSourceEgress,
|
||||
distribution::{
|
||||
DownloadedPlugin, GithubReleaseQuery, PluginDistributionEgress, PluginRelease,
|
||||
PluginSourceBinding, PluginUpdatePolicy, PluginUpdateSource, RemotePluginInstall,
|
||||
RemotePluginLocation, RemotePluginVerify, SourceCredential, SourceCredentialInfo,
|
||||
},
|
||||
instances::{PluginInstance, PluginInstanceMutation, PluginInstanceSnapshot},
|
||||
},
|
||||
proxies::{
|
||||
NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery, ProxyMutation,
|
||||
ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
plugins::{PluginDistribution, PluginPackageInspector, PluginStore},
|
||||
proxy::{ProxyImportReservation, ProxyStore},
|
||||
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
account::{OutboundProxy, ProviderAccountId},
|
||||
routing::ConfigRevision,
|
||||
runtime::{RuntimeSnapshotHandle, SnapshotControl},
|
||||
};
|
||||
|
||||
use super::TestPluginPorts;
|
||||
|
||||
struct Fixture {
|
||||
binding: Mutex<PluginSourceBinding>,
|
||||
queries: Mutex<Vec<GithubReleaseQuery>>,
|
||||
publications: Mutex<Vec<u64>>,
|
||||
changes: Mutex<usize>,
|
||||
query_fails: bool,
|
||||
change_during_query: bool,
|
||||
package: Option<InspectedPluginArtifact>,
|
||||
downloads: Mutex<usize>,
|
||||
change_during_download: bool,
|
||||
proxy: Mutex<Option<ProxyRecord>>,
|
||||
egresses: Mutex<Vec<Option<PluginSourceEgress>>>,
|
||||
change_proxy_during_download: bool,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(policy: PluginUpdatePolicy) -> Self {
|
||||
Self {
|
||||
binding: Mutex::new(PluginSourceBinding {
|
||||
plugin_id: "test.example".into(),
|
||||
source: PluginUpdateSource::Github {
|
||||
repository: "owner/plugin".into(),
|
||||
},
|
||||
policy,
|
||||
outbound_proxy_id: None,
|
||||
}),
|
||||
queries: Mutex::new(vec![]),
|
||||
publications: Mutex::new(vec![]),
|
||||
changes: Mutex::new(0),
|
||||
query_fails: false,
|
||||
change_during_query: false,
|
||||
package: None,
|
||||
downloads: Mutex::new(0),
|
||||
change_during_download: false,
|
||||
proxy: Mutex::new(None),
|
||||
egresses: Mutex::new(vec![]),
|
||||
change_proxy_during_download: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn service(self: &Arc<Self>) -> PluginsService {
|
||||
let unused = Arc::new(TestPluginPorts);
|
||||
PluginsService::new(
|
||||
self.clone(),
|
||||
self.clone(),
|
||||
PluginDistributionPorts::new(self.clone(), self.clone()),
|
||||
self.clone(),
|
||||
unused.clone(),
|
||||
RuntimeSnapshotHandle::default(),
|
||||
unused,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapshotControl for Fixture {
|
||||
fn publish_committed(&self, revision: ConfigRevision) -> BoxFuture<'_, ()> {
|
||||
self.publications.lock().unwrap().push(revision.get());
|
||||
Box::pin(async {})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyStore for Fixture {
|
||||
async fn reserve_import(&self, _: &str) -> AdminStoreResult<ProxyImportReservation> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn list_accounts(&self, _: ProxyAccountListQuery) -> AdminStoreResult<ProxyAccountPage> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord> {
|
||||
self.proxy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.filter(|record| record.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
AdminStoreError::new(AdminStoreErrorKind::NotFound, "proxy", "fixture not found")
|
||||
})
|
||||
}
|
||||
|
||||
async fn remove_account(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &ProviderAccountId,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn record_test(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: ProxyTestResult,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginDistribution for Fixture {
|
||||
fn validate_source(&self, _: &PluginUpdateSource) -> Result<(), AdminError> {
|
||||
Ok(())
|
||||
}
|
||||
fn validate_credential(&self, _: &SourceCredential) -> Result<(), AdminError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn query_release(
|
||||
&self,
|
||||
query: GithubReleaseQuery,
|
||||
credentials: Vec<SourceCredential>,
|
||||
egress: Option<PluginDistributionEgress>,
|
||||
) -> Result<PluginRelease, AdminError> {
|
||||
assert!(credentials.is_empty());
|
||||
self.egresses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(egress.map(|egress| egress.source));
|
||||
self.queries.lock().unwrap().push(query.clone());
|
||||
if self.query_fails {
|
||||
return Err(AdminError::unavailable("fixture rate limited"));
|
||||
}
|
||||
if self.change_during_query {
|
||||
self.binding.lock().unwrap().policy = PluginUpdatePolicy::Manual {};
|
||||
}
|
||||
Ok(PluginRelease {
|
||||
repository: query.repository,
|
||||
tag: query.tag.unwrap_or_else(|| "v2.0.0".into()),
|
||||
name: "fixture".into(),
|
||||
prerelease: query.allow_prerelease,
|
||||
assets: vec![],
|
||||
queried_at: chrono::Utc::now(),
|
||||
expires_at: chrono::Utc::now(),
|
||||
})
|
||||
}
|
||||
async fn download(
|
||||
&self,
|
||||
location: RemotePluginLocation,
|
||||
_: Vec<SourceCredential>,
|
||||
egress: Option<PluginDistributionEgress>,
|
||||
) -> Result<DownloadedPlugin, AdminError> {
|
||||
let outbound_proxy = egress.as_ref().map(|egress| egress.source.clone());
|
||||
self.egresses.lock().unwrap().push(outbound_proxy.clone());
|
||||
let package = self.package.as_ref().expect("检查更新不能下载产物");
|
||||
*self.downloads.lock().unwrap() += 1;
|
||||
if self.change_during_download {
|
||||
self.binding.lock().unwrap().policy = PluginUpdatePolicy::Manual {};
|
||||
}
|
||||
if self.change_proxy_during_download {
|
||||
self.proxy.lock().unwrap().as_mut().unwrap().revision = Revision::new(2).unwrap();
|
||||
}
|
||||
let RemotePluginLocation::Github {
|
||||
repository,
|
||||
tag,
|
||||
asset,
|
||||
..
|
||||
} = location
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
Ok(DownloadedPlugin {
|
||||
archive: package.archive.clone(),
|
||||
sha256: package.metadata.sha256.clone(),
|
||||
source: PluginSource::Github {
|
||||
repository: repository.to_ascii_lowercase(),
|
||||
tag,
|
||||
asset,
|
||||
credential_ids: vec![],
|
||||
outbound_proxy,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageInspector for Fixture {
|
||||
async fn inspect(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
expected_sha256: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError> {
|
||||
let package = self.package.as_ref().expect("检查 Release 不能校验插件包");
|
||||
assert_eq!(archive, package.archive);
|
||||
if let Some(digest) = expected_sha256 {
|
||||
assert_eq!(digest, package.metadata.sha256);
|
||||
}
|
||||
Ok(package.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginStore for Fixture {
|
||||
async fn management_target_is_current(
|
||||
&self,
|
||||
target: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(self
|
||||
.load_instances()
|
||||
.await?
|
||||
.instances
|
||||
.iter()
|
||||
.any(|instance| {
|
||||
instance.enabled
|
||||
&& instance.trusted_process
|
||||
&& instance.id == target.instance_id
|
||||
&& instance.artifact_sha256 == target.artifact_sha256
|
||||
&& instance.revision.get() == target.revision
|
||||
}))
|
||||
}
|
||||
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>> {
|
||||
Ok(vec![self.binding.lock().unwrap().clone()])
|
||||
}
|
||||
async fn change_update_source(
|
||||
&self,
|
||||
binding: PluginSourceBinding,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
*self.binding.lock().unwrap() = binding;
|
||||
*self.changes.lock().unwrap() += 1;
|
||||
Ok(Revision::new(2).unwrap())
|
||||
}
|
||||
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn save_instance(
|
||||
&self,
|
||||
_: PluginInstance,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginInstanceMutation> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn delete_instance(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
|
||||
Err(super::unavailable())
|
||||
}
|
||||
async fn save_source_credential(
|
||||
&self,
|
||||
_: SourceCredential,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn delete_source_credential(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn install_artifact(
|
||||
&self,
|
||||
_: InspectedPluginArtifact,
|
||||
_: PluginSource,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation> {
|
||||
panic!("检查更新不能安装产物")
|
||||
}
|
||||
async fn accept_artifact(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
fn context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "plugin-update-test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_check_uses_persisted_policy_without_downloading_writing_or_publishing() {
|
||||
for (policy, tag, prerelease) in [
|
||||
(PluginUpdatePolicy::Stable {}, None, false),
|
||||
(
|
||||
PluginUpdatePolicy::Pinned {
|
||||
tag: "v2.0.0-rc.1".into(),
|
||||
allow_prerelease: true,
|
||||
},
|
||||
Some("v2.0.0-rc.1"),
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let fixture = Arc::new(Fixture::new(policy.clone()));
|
||||
let check = fixture
|
||||
.service()
|
||||
.check_update("test.example", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(check.binding.policy, policy);
|
||||
assert_eq!(
|
||||
fixture.queries.lock().unwrap().as_slice(),
|
||||
&[GithubReleaseQuery {
|
||||
repository: "owner/plugin".into(),
|
||||
tag: tag.map(str::to_owned),
|
||||
allow_prerelease: prerelease,
|
||||
}]
|
||||
);
|
||||
assert_eq!(*fixture.changes.lock().unwrap(), 0);
|
||||
assert!(fixture.publications.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_check_never_turns_query_failure_or_changed_source_into_no_updates() {
|
||||
for changed in [false, true] {
|
||||
let mut fixture = Fixture::new(PluginUpdatePolicy::Stable {});
|
||||
fixture.query_fails = !changed;
|
||||
fixture.change_during_query = changed;
|
||||
let fixture = Arc::new(fixture);
|
||||
let error = fixture
|
||||
.service()
|
||||
.check_update("test.example", &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
if changed {
|
||||
AdminErrorKind::Conflict
|
||||
} else {
|
||||
AdminErrorKind::Unavailable
|
||||
}
|
||||
);
|
||||
assert!(fixture.publications.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_check_rejects_manual_missing_and_duplicate_credential_selections_before_query() {
|
||||
let fixture = Arc::new(Fixture::new(PluginUpdatePolicy::Manual {}));
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.check_update("test.example", &[])
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.check_update("missing", &[])
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::NotFound
|
||||
);
|
||||
fixture.binding.lock().unwrap().policy = PluginUpdatePolicy::Stable {};
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.check_update("test.example", &["same".into(), "same".into()])
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
assert!(fixture.queries.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_source_validates_policy_and_publishes_only_explicit_valid_changes() {
|
||||
let fixture = Arc::new(Fixture::new(PluginUpdatePolicy::Manual {}));
|
||||
let original = fixture.binding.lock().unwrap().clone();
|
||||
for (source, policy) in [
|
||||
(PluginUpdateSource::Upload, PluginUpdatePolicy::Stable {}),
|
||||
(PluginUpdateSource::Builtin, PluginUpdatePolicy::Manual {}),
|
||||
(
|
||||
original.source.clone(),
|
||||
PluginUpdatePolicy::Pinned {
|
||||
tag: " ".into(),
|
||||
allow_prerelease: false,
|
||||
},
|
||||
),
|
||||
] {
|
||||
let error = fixture
|
||||
.service()
|
||||
.change_update_source(
|
||||
PluginSourceBinding {
|
||||
plugin_id: "test.example".into(),
|
||||
source,
|
||||
policy,
|
||||
outbound_proxy_id: None,
|
||||
},
|
||||
&context(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
}
|
||||
assert_eq!(*fixture.changes.lock().unwrap(), 0);
|
||||
let binding = PluginSourceBinding {
|
||||
plugin_id: "test.example".into(),
|
||||
source: PluginUpdateSource::Github {
|
||||
repository: "Owner/Plugin".into(),
|
||||
},
|
||||
policy: PluginUpdatePolicy::Stable {},
|
||||
outbound_proxy_id: None,
|
||||
};
|
||||
fixture
|
||||
.service()
|
||||
.change_update_source(binding, &context())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fixture.binding.lock().unwrap().source, original.source);
|
||||
assert_eq!(*fixture.changes.lock().unwrap(), 1);
|
||||
assert_eq!(fixture.publications.lock().unwrap().as_slice(), &[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_source_payload_defaults_to_manual_and_rejects_unknown_policy_fields() {
|
||||
let binding: PluginSourceBinding = serde_json::from_value(
|
||||
serde_json::json!({"pluginId":"test.example","source":{"kind":"upload"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(binding.policy, PluginUpdatePolicy::Manual {});
|
||||
assert!(serde_json::from_value::<PluginSourceBinding>(serde_json::json!({"pluginId":"test.example","source":{"kind":"github","repository":"owner/plugin"},"policy":{"kind":"stable","autoInstall":true}})).is_err());
|
||||
}
|
||||
|
||||
fn preview_fixture() -> Fixture {
|
||||
let mut fixture = Fixture::new(PluginUpdatePolicy::Stable {});
|
||||
fixture.package = Some(InspectedPluginArtifact {
|
||||
metadata: PluginArtifactMetadata {
|
||||
plugin_id: "test.example".into(),
|
||||
version: "2.0.0".into(),
|
||||
name: "example".into(),
|
||||
display_name: "Example".into(),
|
||||
publisher: "test".into(),
|
||||
author: Some("Tests".into()),
|
||||
description: String::new(),
|
||||
license: "MIT".into(),
|
||||
sha256: "a".repeat(64),
|
||||
platforms: vec!["linux-x86_64".into()],
|
||||
icon: None,
|
||||
contributes: Default::default(),
|
||||
requested_permissions: vec!["log".into()],
|
||||
configuration_schema: serde_json::json!({"type":"object"}),
|
||||
secret_fields: vec![],
|
||||
state_namespaces: vec![],
|
||||
},
|
||||
archive: b"bounded archive fixture".as_slice().into(),
|
||||
});
|
||||
fixture
|
||||
}
|
||||
|
||||
fn source_proxy() -> ProxyRecord {
|
||||
let now = chrono::Utc::now();
|
||||
ProxyRecord {
|
||||
auto_location: false,
|
||||
detected_location: None,
|
||||
location: None,
|
||||
id: "source-proxy".into(),
|
||||
name: "Source proxy".into(),
|
||||
proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:18080").unwrap(),
|
||||
revision: Revision::new(1).unwrap(),
|
||||
account_count: 0,
|
||||
last_test_at: None,
|
||||
last_test: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_request() -> RemotePluginVerify {
|
||||
RemotePluginVerify {
|
||||
expected_plugin_id: Some("test.example".into()),
|
||||
credential_ids: vec![],
|
||||
outbound_proxy_id: None,
|
||||
location: RemotePluginLocation::Github {
|
||||
repository: "Owner/Plugin".into(),
|
||||
tag: "v2.0.0".into(),
|
||||
asset: "plugin.tar.gz".into(),
|
||||
allow_prerelease: false,
|
||||
sha256: Some("a".repeat(64)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn install_request() -> RemotePluginInstall {
|
||||
let preview = preview_request();
|
||||
RemotePluginInstall {
|
||||
plugin_id: "test.example".into(),
|
||||
version: "2.0.0".into(),
|
||||
credential_ids: preview.credential_ids,
|
||||
outbound_proxy_id: preview.outbound_proxy_id,
|
||||
location: preview.location,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_source_proxy_is_resolved_for_queries_and_frozen_into_verified_source() {
|
||||
let mut fixture = preview_fixture();
|
||||
fixture.binding.get_mut().unwrap().outbound_proxy_id = Some("source-proxy".into());
|
||||
*fixture.proxy.get_mut().unwrap() = Some(source_proxy());
|
||||
let fixture = Arc::new(fixture);
|
||||
fixture
|
||||
.service()
|
||||
.check_update("test.example", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
let mut request = preview_request();
|
||||
request.outbound_proxy_id = Some("source-proxy".into());
|
||||
let verified = fixture.service().verify_remote(request).await.unwrap();
|
||||
assert!(matches!(
|
||||
verified.source.outbound_proxy(),
|
||||
Some(PluginSourceEgress { id, revision: 1 }) if id == "source-proxy"
|
||||
));
|
||||
assert_eq!(
|
||||
fixture.egresses.lock().unwrap().as_slice(),
|
||||
&[
|
||||
Some(PluginSourceEgress {
|
||||
id: "source-proxy".into(),
|
||||
revision: 1,
|
||||
}),
|
||||
Some(PluginSourceEgress {
|
||||
id: "source-proxy".into(),
|
||||
revision: 1,
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_revision_change_during_download_rejects_the_verified_candidate() {
|
||||
let mut fixture = preview_fixture();
|
||||
fixture.binding.get_mut().unwrap().outbound_proxy_id = Some("source-proxy".into());
|
||||
*fixture.proxy.get_mut().unwrap() = Some(source_proxy());
|
||||
fixture.change_proxy_during_download = true;
|
||||
let fixture = Arc::new(fixture);
|
||||
let mut request = preview_request();
|
||||
request.outbound_proxy_id = Some("source-proxy".into());
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.verify_remote(request)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Conflict
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verifying_remote_package_uses_inspector_and_returns_fixed_facts_without_installing() {
|
||||
let fixture = Arc::new(preview_fixture());
|
||||
let verified = fixture
|
||||
.service()
|
||||
.verify_remote(preview_request())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(verified.metadata.sha256, "a".repeat(64));
|
||||
assert_eq!(verified.metadata.requested_permissions, vec!["log"]);
|
||||
assert!(
|
||||
matches!(verified.source, PluginSource::Github { ref repository, ref tag, ref asset, .. }
|
||||
if repository == "owner/plugin" && tag == "v2.0.0" && asset == "plugin.tar.gz")
|
||||
);
|
||||
assert_eq!(*fixture.downloads.lock().unwrap(), 1);
|
||||
assert_eq!(*fixture.changes.lock().unwrap(), 0);
|
||||
assert!(fixture.publications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_install_rejects_non_namespaced_plugin_ids_before_download() {
|
||||
for plugin_id in [
|
||||
"example",
|
||||
"Test.example",
|
||||
"test.-example",
|
||||
"test.example_legacy",
|
||||
"test.example.extra",
|
||||
] {
|
||||
let fixture = Arc::new(preview_fixture());
|
||||
let mut request = preview_request();
|
||||
request.expected_plugin_id = Some(plugin_id.into());
|
||||
let error = fixture.service().verify_remote(request).await.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid, "{plugin_id}");
|
||||
assert_eq!(*fixture.downloads.lock().unwrap(), 0, "{plugin_id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installation_rejects_identity_version_and_source_changes_without_persisting() {
|
||||
for change in 0..4 {
|
||||
let mut fixture = preview_fixture();
|
||||
match change {
|
||||
0 => fixture.package.as_mut().unwrap().metadata.plugin_id = "foreign".into(),
|
||||
1 => fixture.package.as_mut().unwrap().metadata.version = "3.0.0".into(),
|
||||
2 => fixture.binding.lock().unwrap().source = PluginUpdateSource::Upload,
|
||||
_ => fixture.change_during_download = true,
|
||||
}
|
||||
let fixture = Arc::new(fixture);
|
||||
let error = fixture
|
||||
.service()
|
||||
.install_remote(install_request(), &context())
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
if change < 2 {
|
||||
AdminErrorKind::Invalid
|
||||
} else {
|
||||
AdminErrorKind::Conflict
|
||||
}
|
||||
);
|
||||
assert_eq!(*fixture.downloads.lock().unwrap(), usize::from(change != 2));
|
||||
assert!(fixture.publications.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preview_discovers_identity_and_version_from_manifest_without_installing() {
|
||||
let fixture = Arc::new(preview_fixture());
|
||||
let mut request = preview_request();
|
||||
request.expected_plugin_id = None;
|
||||
let RemotePluginLocation::Github { sha256, .. } = &mut request.location else {
|
||||
unreachable!()
|
||||
};
|
||||
*sha256 = None;
|
||||
let verified = fixture.service().verify_remote(request).await.unwrap();
|
||||
assert_eq!(verified.metadata.plugin_id, "test.example");
|
||||
assert_eq!(verified.metadata.version, "2.0.0");
|
||||
assert!(fixture.publications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovered_identity_cannot_bypass_saved_source_or_concurrent_changes() {
|
||||
for changed_during_download in [false, true] {
|
||||
let mut fixture = preview_fixture();
|
||||
if changed_during_download {
|
||||
fixture.change_during_download = true;
|
||||
} else {
|
||||
fixture.binding.get_mut().unwrap().source = PluginUpdateSource::Upload;
|
||||
}
|
||||
let fixture = Arc::new(fixture);
|
||||
let mut request = preview_request();
|
||||
request.expected_plugin_id = None;
|
||||
let error = fixture.service().verify_remote(request).await.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert!(fixture.publications.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installation_requires_pinned_digest_before_downloading() {
|
||||
let fixture = Arc::new(preview_fixture());
|
||||
let mut request = install_request();
|
||||
let RemotePluginLocation::Github { sha256, .. } = &mut request.location else {
|
||||
unreachable!()
|
||||
};
|
||||
*sha256 = None;
|
||||
let error = fixture
|
||||
.service()
|
||||
.install_remote(request, &context())
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
assert_eq!(*fixture.downloads.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_preview_inspects_without_persisting_or_publishing() {
|
||||
let fixture = Arc::new(preview_fixture());
|
||||
let archive = fixture.package.as_ref().unwrap().archive.clone();
|
||||
let verified = fixture.service().verify_upload(archive).await.unwrap();
|
||||
assert_eq!(verified.metadata.plugin_id, "test.example");
|
||||
assert_eq!(verified.source, PluginSource::Upload);
|
||||
assert!(fixture.publications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_preview_accepts_only_a_location_without_identity_or_digest() {
|
||||
let input: RemotePluginVerify = serde_json::from_value(serde_json::json!({
|
||||
"location": {"kind": "url", "url": "https://example.org/plugin.tar.gz"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(input.expected_plugin_id.is_none());
|
||||
assert!(matches!(
|
||||
input.location,
|
||||
RemotePluginLocation::Url { sha256: None, .. }
|
||||
));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
use async_trait::async_trait;
|
||||
use gateway_admin::model::plugins::distribution::{
|
||||
DownloadedPlugin, GithubReleaseQuery, PluginRelease, RemotePluginLocation, SourceCredential,
|
||||
SourceCredentialInfo,
|
||||
};
|
||||
use gateway_admin::model::plugins::distribution::{PluginSourceBinding, PluginUpdateSource};
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
plugins::{
|
||||
InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMutation, PluginSource,
|
||||
state::{
|
||||
ApplyPluginStateMigration, DeletePluginState, PluginStateConfiguration,
|
||||
PluginStateMigrationBatch, PluginStateOwner, PluginStateOwnerRequest,
|
||||
PluginStateRecord, PluginStateTransition, PluginStateWrite, PutPluginState,
|
||||
},
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
plugins::{
|
||||
PluginPackageInspector, PluginStateStore, PluginStateStoreError,
|
||||
PluginStateStoreErrorKind, PluginStateStoreResult, PluginStore,
|
||||
},
|
||||
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
|
||||
},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod artifacts;
|
||||
mod distribution;
|
||||
mod instances;
|
||||
mod official;
|
||||
|
||||
pub(super) struct TestPluginPorts;
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::proxy::ProxyStore for TestPluginPorts {
|
||||
async fn reserve_import(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> AdminStoreResult<gateway_admin::ports::proxy::ProxyImportReservation> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_: gateway_admin::model::proxies::ProxyListQuery,
|
||||
) -> AdminStoreResult<gateway_admin::model::proxies::ProxyPage> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
_: gateway_admin::model::proxies::ProxyAccountListQuery,
|
||||
) -> AdminStoreResult<gateway_admin::model::proxies::ProxyAccountPage> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
async fn get(&self, _: &str) -> AdminStoreResult<gateway_admin::model::proxies::ProxyRecord> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
async fn remove_account(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &gateway_core::account::ProviderAccountId,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
_: gateway_admin::model::proxies::NewProxy,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::proxies::ProxyMutation> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
_: gateway_admin::model::proxies::UpdateProxy,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::proxies::ProxyMutation> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
async fn record_test(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: gateway_admin::model::proxies::ProxyTestResult,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::proxies::ProxyMutation> {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPorts {
|
||||
async fn authorize_models(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
) -> Result<(), AdminError> {
|
||||
Err(AdminError::not_found("unused plugin fixture"))
|
||||
}
|
||||
|
||||
async fn start_callback(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
_: gateway_admin::model::plugins::management::StartPluginManagementCallback,
|
||||
_: &gateway_admin::model::auth::AdminRequestContext,
|
||||
) -> Result<gateway_admin::model::plugins::management::PluginManagementCallbackTicket, AdminError>
|
||||
{
|
||||
Err(AdminError::not_found("unused plugin fixture"))
|
||||
}
|
||||
async fn callback(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
_: &str,
|
||||
_: gateway_admin::model::plugins::management::PluginManagementRequest,
|
||||
) -> Result<gateway_admin::model::plugins::management::PluginManagementResponse, AdminError>
|
||||
{
|
||||
Err(AdminError::not_found("unused plugin fixture"))
|
||||
}
|
||||
async fn views(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
) -> Result<Vec<gateway_admin::model::plugins::management::PluginManagementView>, AdminError>
|
||||
{
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn resource(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
_: &str,
|
||||
_: bool,
|
||||
) -> Result<gateway_admin::model::plugins::management::PluginManagementResponse, AdminError>
|
||||
{
|
||||
Err(AdminError::not_found("unused plugin fixture"))
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
_: gateway_admin::model::plugins::management::PluginManagementRequest,
|
||||
) -> Result<gateway_admin::model::plugins::management::PluginManagementResponse, AdminError>
|
||||
{
|
||||
Err(AdminError::not_found("unused plugin fixture"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts {
|
||||
async fn configuration_ready(
|
||||
&self,
|
||||
_: gateway_admin::model::plugins::instances::PluginInstance,
|
||||
_: &gateway_admin::model::plugins::PluginArtifactMetadata,
|
||||
) -> Result<bool, AdminError> {
|
||||
Err(AdminError::invalid("unused plugin fixture"))
|
||||
}
|
||||
|
||||
async fn validate(
|
||||
&self,
|
||||
_: gateway_admin::model::plugins::instances::PluginInstance,
|
||||
) -> Result<gateway_admin::model::plugins::state::PluginStateConfiguration, AdminError> {
|
||||
Err(AdminError::invalid("unused plugin fixture"))
|
||||
}
|
||||
async fn prepare(
|
||||
&self,
|
||||
_: gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
|
||||
) -> Result<gateway_core::runtime::extensions::ExtensionSetReference, AdminError> {
|
||||
Err(AdminError::invalid("unused plugin fixture"))
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable() -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"plugin",
|
||||
"unused plugin fixture",
|
||||
)
|
||||
}
|
||||
|
||||
fn state_unavailable<T>() -> PluginStateStoreResult<T> {
|
||||
Err(PluginStateStoreError::new(
|
||||
PluginStateStoreErrorKind::Unavailable,
|
||||
))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginStateStore for TestPluginPorts {
|
||||
async fn load_owner(
|
||||
&self,
|
||||
_: PluginStateOwnerRequest,
|
||||
) -> PluginStateStoreResult<Option<PluginStateOwner>> {
|
||||
state_unavailable()
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_: &PluginStateOwner,
|
||||
_: &str,
|
||||
_: &str,
|
||||
) -> PluginStateStoreResult<Option<PluginStateRecord>> {
|
||||
state_unavailable()
|
||||
}
|
||||
async fn put(
|
||||
&self,
|
||||
_: &PluginStateOwner,
|
||||
_: PutPluginState,
|
||||
) -> PluginStateStoreResult<PluginStateWrite> {
|
||||
state_unavailable()
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_: &PluginStateOwner,
|
||||
_: DeletePluginState,
|
||||
) -> PluginStateStoreResult<bool> {
|
||||
state_unavailable()
|
||||
}
|
||||
async fn transition_required(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &PluginStateConfiguration,
|
||||
) -> PluginStateStoreResult<bool> {
|
||||
state_unavailable()
|
||||
}
|
||||
async fn begin_transition(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &str,
|
||||
_: PluginStateConfiguration,
|
||||
) -> PluginStateStoreResult<PluginStateTransition> {
|
||||
state_unavailable()
|
||||
}
|
||||
async fn migration_batch(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: u32,
|
||||
) -> PluginStateStoreResult<PluginStateMigrationBatch> {
|
||||
state_unavailable()
|
||||
}
|
||||
async fn apply_migration_batch(
|
||||
&self,
|
||||
_: ApplyPluginStateMigration,
|
||||
) -> PluginStateStoreResult<()> {
|
||||
state_unavailable()
|
||||
}
|
||||
async fn abort_transition(&self, _: &str) -> PluginStateStoreResult<()> {
|
||||
state_unavailable()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginStore for TestPluginPorts {
|
||||
async fn management_target_is_current(
|
||||
&self,
|
||||
target: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(self
|
||||
.load_instances()
|
||||
.await?
|
||||
.instances
|
||||
.iter()
|
||||
.any(|instance| {
|
||||
instance.enabled
|
||||
&& instance.trusted_process
|
||||
&& instance.id == target.instance_id
|
||||
&& instance.artifact_sha256 == target.artifact_sha256
|
||||
&& instance.revision.get() == target.revision
|
||||
}))
|
||||
}
|
||||
async fn load_instances(
|
||||
&self,
|
||||
) -> AdminStoreResult<gateway_admin::model::plugins::instances::PluginInstanceSnapshot> {
|
||||
Err(unavailable())
|
||||
}
|
||||
async fn save_instance(
|
||||
&self,
|
||||
_: gateway_admin::model::plugins::instances::PluginInstance,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::plugins::instances::PluginInstanceMutation> {
|
||||
Err(unavailable())
|
||||
}
|
||||
async fn delete_instance(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable())
|
||||
}
|
||||
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn change_update_source(
|
||||
&self,
|
||||
_: PluginSourceBinding,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
|
||||
Err(unavailable())
|
||||
}
|
||||
async fn save_source_credential(
|
||||
&self,
|
||||
_: SourceCredential,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable())
|
||||
}
|
||||
async fn delete_source_credential(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
Err(unavailable())
|
||||
}
|
||||
async fn install_artifact(
|
||||
&self,
|
||||
_: InspectedPluginArtifact,
|
||||
_: PluginSource,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation> {
|
||||
Err(unavailable())
|
||||
}
|
||||
async fn accept_artifact(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation> {
|
||||
Err(unavailable())
|
||||
}
|
||||
async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageInspector for TestPluginPorts {
|
||||
async fn inspect(
|
||||
&self,
|
||||
_: Arc<[u8]>,
|
||||
_: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError> {
|
||||
Err(AdminError::invalid("unused plugin fixture"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginDistribution for TestPluginPorts {
|
||||
fn validate_source(&self, _: &PluginUpdateSource) -> Result<(), AdminError> {
|
||||
Err(AdminError::invalid("unused plugin fixture"))
|
||||
}
|
||||
|
||||
fn validate_credential(&self, _: &SourceCredential) -> Result<(), AdminError> {
|
||||
Err(AdminError::invalid("unused plugin fixture"))
|
||||
}
|
||||
async fn query_release(
|
||||
&self,
|
||||
_: GithubReleaseQuery,
|
||||
_: Vec<SourceCredential>,
|
||||
_: Option<gateway_admin::model::plugins::distribution::PluginDistributionEgress>,
|
||||
) -> Result<PluginRelease, AdminError> {
|
||||
Err(AdminError::invalid("unused plugin fixture"))
|
||||
}
|
||||
async fn download(
|
||||
&self,
|
||||
_: RemotePluginLocation,
|
||||
_: Vec<SourceCredential>,
|
||||
_: Option<gateway_admin::model::plugins::distribution::PluginDistributionEgress>,
|
||||
) -> Result<DownloadedPlugin, AdminError> {
|
||||
Err(AdminError::invalid("unused plugin fixture"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::{
|
||||
PluginDistributionPorts, PluginsService,
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationActor, MutationContext, Revision,
|
||||
plugins::{
|
||||
InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMetadata,
|
||||
PluginArtifactMutation, PluginSource,
|
||||
distribution::{PluginSourceBinding, SourceCredential, SourceCredentialInfo},
|
||||
instances::{PluginInstance, PluginInstanceMutation, PluginInstanceSnapshot},
|
||||
official::OfficialPluginReleaseIdentity,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
plugin_release::{
|
||||
OfficialPluginReleaseFiles, OfficialPluginReleaseReadError,
|
||||
OfficialPluginReleaseReadErrorKind,
|
||||
},
|
||||
plugins::{PluginPackageInspector, PluginStore},
|
||||
store::AdminStoreResult,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
routing::ConfigRevision,
|
||||
runtime::{RuntimeSnapshotHandle, SnapshotControl},
|
||||
};
|
||||
|
||||
use super::TestPluginPorts;
|
||||
|
||||
const VERSION: &str = "3.13.0";
|
||||
const GIT_SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
struct Fixture {
|
||||
manifest: Option<Arc<[u8]>>,
|
||||
archives: BTreeMap<String, Arc<[u8]>>,
|
||||
inspected_publisher: String,
|
||||
installs: std::sync::Mutex<Vec<(String, PluginSource)>>,
|
||||
publications: std::sync::Mutex<Vec<u64>>,
|
||||
fail_archive: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(plugins: &[(&str, u8)]) -> Arc<Self> {
|
||||
let artifacts = plugins
|
||||
.iter()
|
||||
.map(|(id, marker)| plugin_entry(id, *marker))
|
||||
.collect::<Vec<_>>();
|
||||
let manifest = serde_json::to_vec(&serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"sealed": true,
|
||||
"gateway_version": VERSION,
|
||||
"gateway_git_sha": GIT_SHA,
|
||||
"plugin_host": plugin_host(),
|
||||
"plugins": artifacts,
|
||||
}))
|
||||
.expect("manifest");
|
||||
let archives = plugins
|
||||
.iter()
|
||||
.map(|(id, marker)| {
|
||||
(
|
||||
format!(
|
||||
"{id}-1.0.0-{}-{}.tar.gz",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
),
|
||||
Arc::<[u8]>::from(vec![*marker]),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Arc::new(Self {
|
||||
manifest: Some(manifest.into()),
|
||||
archives,
|
||||
inspected_publisher: "test".into(),
|
||||
installs: std::sync::Mutex::new(Vec::new()),
|
||||
publications: std::sync::Mutex::new(Vec::new()),
|
||||
fail_archive: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn service(self: &Arc<Self>) -> PluginsService {
|
||||
let unused = Arc::new(TestPluginPorts);
|
||||
PluginsService::new(
|
||||
self.clone(),
|
||||
self.clone(),
|
||||
PluginDistributionPorts::new(unused.clone(), unused.clone()),
|
||||
self.clone(),
|
||||
unused.clone(),
|
||||
RuntimeSnapshotHandle::default(),
|
||||
unused,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OfficialPluginReleaseFiles for Fixture {
|
||||
async fn manifest(&self) -> Result<Option<Arc<[u8]>>, OfficialPluginReleaseReadError> {
|
||||
Ok(self.manifest.clone())
|
||||
}
|
||||
|
||||
async fn artifact(&self, file_name: &str) -> Result<Arc<[u8]>, OfficialPluginReleaseReadError> {
|
||||
self.archives.get(file_name).cloned().ok_or_else(|| {
|
||||
OfficialPluginReleaseReadError::new(OfficialPluginReleaseReadErrorKind::NotFound)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageInspector for Fixture {
|
||||
async fn inspect(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
expected_sha256: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError> {
|
||||
if self
|
||||
.fail_archive
|
||||
.as_ref()
|
||||
.is_some_and(|expected| archive.as_ref() == expected)
|
||||
{
|
||||
return Err(AdminError::invalid("fixture rejected package"));
|
||||
}
|
||||
let marker = archive.first().copied().expect("marker");
|
||||
let name = format!("official-{marker}");
|
||||
let id = format!("test.{name}");
|
||||
Ok(InspectedPluginArtifact {
|
||||
metadata: PluginArtifactMetadata {
|
||||
plugin_id: id,
|
||||
version: "1.0.0".into(),
|
||||
name,
|
||||
display_name: "Official fixture".into(),
|
||||
publisher: self.inspected_publisher.clone(),
|
||||
author: Some("project".into()),
|
||||
description: String::new(),
|
||||
license: "MIT".into(),
|
||||
sha256: expected_sha256.expect("expected digest"),
|
||||
platforms: vec![format!(
|
||||
"{}-{}",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
)],
|
||||
icon: None,
|
||||
contributes: Default::default(),
|
||||
requested_permissions: Vec::new(),
|
||||
configuration_schema: serde_json::json!({}),
|
||||
secret_fields: Vec::new(),
|
||||
state_namespaces: Vec::new(),
|
||||
},
|
||||
archive,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapshotControl for Fixture {
|
||||
fn publish_committed(&self, revision: ConfigRevision) -> BoxFuture<'_, ()> {
|
||||
self.publications.lock().unwrap().push(revision.get());
|
||||
Box::pin(async {})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginStore for Fixture {
|
||||
async fn management_target_is_current(
|
||||
&self,
|
||||
target: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(self
|
||||
.load_instances()
|
||||
.await?
|
||||
.instances
|
||||
.iter()
|
||||
.any(|instance| {
|
||||
instance.enabled
|
||||
&& instance.trusted_process
|
||||
&& instance.id == target.instance_id
|
||||
&& instance.artifact_sha256 == target.artifact_sha256
|
||||
&& instance.revision.get() == target.revision
|
||||
}))
|
||||
}
|
||||
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn save_instance(
|
||||
&self,
|
||||
_: PluginInstance,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginInstanceMutation> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn delete_instance(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn change_update_source(
|
||||
&self,
|
||||
_: PluginSourceBinding,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn save_source_credential(
|
||||
&self,
|
||||
_: SourceCredential,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn delete_source_credential(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn install_artifact(
|
||||
&self,
|
||||
artifact: InspectedPluginArtifact,
|
||||
source: PluginSource,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation> {
|
||||
self.installs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((artifact.metadata.plugin_id.clone(), source.clone()));
|
||||
Ok(PluginArtifactMutation {
|
||||
config_revision: Revision::new(2).unwrap(),
|
||||
artifact: InstalledPluginArtifact {
|
||||
metadata: artifact.metadata,
|
||||
source,
|
||||
installed_at: chrono::Utc::now(),
|
||||
accepted_at: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
async fn accept_artifact(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult<Revision> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_release_validates_all_packages_then_uses_builtin_install_path() {
|
||||
let fixture = Fixture::new(&[("test.official-1", 1)]);
|
||||
let result = fixture
|
||||
.service()
|
||||
.import_official_release(fixture.as_ref(), &identity(), &system_context())
|
||||
.await
|
||||
.expect("official import");
|
||||
|
||||
assert_eq!(result.artifacts, 1);
|
||||
assert_eq!(result.config_revision.map(Revision::get), Some(2));
|
||||
assert_eq!(
|
||||
fixture.installs.lock().unwrap().as_slice(),
|
||||
&[(
|
||||
"test.official-1".into(),
|
||||
PluginSource::Builtin {
|
||||
release: VERSION.into()
|
||||
}
|
||||
)]
|
||||
);
|
||||
assert_eq!(fixture.publications.lock().unwrap().as_slice(), &[2]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_release_never_persists_a_valid_prefix_when_later_validation_fails() {
|
||||
let mut fixture = Fixture::new(&[("test.official-1", 1), ("test.official-2", 2)]);
|
||||
Arc::get_mut(&mut fixture).unwrap().fail_archive = Some(vec![2]);
|
||||
let error = fixture
|
||||
.service()
|
||||
.import_official_release(fixture.as_ref(), &identity(), &system_context())
|
||||
.await
|
||||
.expect_err("second package must fail");
|
||||
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
assert!(fixture.installs.lock().unwrap().is_empty());
|
||||
assert!(fixture.publications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_release_rejects_publisher_identity_mismatches() {
|
||||
let fixture = Fixture::new(&[("other.official-1", 1)]);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.import_official_release(fixture.as_ref(), &identity(), &system_context())
|
||||
.await
|
||||
.expect_err("publisher must match the plugin ID namespace")
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
assert!(fixture.installs.lock().unwrap().is_empty());
|
||||
|
||||
let mut fixture = Fixture::new(&[("test.official-1", 1)]);
|
||||
Arc::get_mut(&mut fixture).unwrap().inspected_publisher = "other".into();
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.import_official_release(fixture.as_ref(), &identity(), &system_context())
|
||||
.await
|
||||
.expect_err("inspected publisher must match the sealed release")
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
assert!(fixture.installs.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_release_rejects_non_system_callers_and_mismatched_build_identity() {
|
||||
let fixture = Fixture::new(&[("test.official-1", 1)]);
|
||||
let admin = MutationContext {
|
||||
actor: MutationActor::AdminApiKey,
|
||||
request_id: "fixture".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.import_official_release(fixture.as_ref(), &identity(), &admin)
|
||||
.await
|
||||
.expect_err("admin caller")
|
||||
.kind(),
|
||||
AdminErrorKind::Forbidden
|
||||
);
|
||||
let wrong = OfficialPluginReleaseIdentity {
|
||||
gateway_version: "9.9.9".into(),
|
||||
gateway_git_sha: GIT_SHA.into(),
|
||||
};
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.import_official_release(fixture.as_ref(), &wrong, &system_context())
|
||||
.await
|
||||
.expect_err("mismatched identity")
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
assert!(fixture.installs.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
fn identity() -> OfficialPluginReleaseIdentity {
|
||||
OfficialPluginReleaseIdentity {
|
||||
gateway_version: VERSION.into(),
|
||||
gateway_git_sha: GIT_SHA.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_host() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"manifest_schema_versions": [2],
|
||||
"protocol_versions": [3],
|
||||
"capabilities": [{ "capability": "executor", "versions": [1] }],
|
||||
"permissions": ["log"],
|
||||
})
|
||||
}
|
||||
|
||||
fn system_context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "official-import-fixture".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_entry(id: &str, marker: u8) -> serde_json::Value {
|
||||
let (release_os, release_architecture, target) = release_platform();
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"version": "1.0.0",
|
||||
"publisher": "test",
|
||||
"stability": "experimental",
|
||||
"artifacts": [{
|
||||
"release_os": release_os,
|
||||
"release_architecture": release_architecture,
|
||||
"target": target,
|
||||
"os": std::env::consts::OS,
|
||||
"architecture": std::env::consts::ARCH,
|
||||
"archive": format!("{id}-1.0.0-{}-{}.tar.gz", std::env::consts::OS, std::env::consts::ARCH),
|
||||
"sha256": format!("{marker:01x}").repeat(64),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn release_platform() -> (&'static str, &'static str, &'static str) {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("linux", "x86_64") => ("linux", "amd64", "x86_64-unknown-linux-gnu"),
|
||||
("linux", "aarch64") => ("linux", "arm64", "aarch64-unknown-linux-gnu"),
|
||||
("macos", "aarch64") => ("darwin", "arm64", "aarch64-apple-darwin"),
|
||||
platform => panic!("unsupported test platform: {platform:?}"),
|
||||
}
|
||||
}
|
||||
@@ -97,14 +97,14 @@ impl ProxyStore for TestProxies {
|
||||
_: Revision,
|
||||
_: ProxyTestResult,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyRecord> {
|
||||
) -> AdminStoreResult<ProxyMutation> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyProbe for TestProxies {
|
||||
async fn test(&self, _: &OutboundProxy) -> ProxyTestResult {
|
||||
async fn test(&self, _: &OutboundProxy, _: bool) -> ProxyTestResult {
|
||||
panic!("unexpected proxy probe")
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,8 @@ async fn authorization_uses_selected_proxy_regardless_of_probe_status() {
|
||||
.accounts(FakeAccountStore::new(kind, events.clone()))
|
||||
.proxies(Arc::new(TestProxies {
|
||||
record: Some(ProxyRecord {
|
||||
auto_location: false,
|
||||
detected_location: None,
|
||||
location: None,
|
||||
id: "proxy_oauth".to_owned(),
|
||||
name: "授权出口".to_owned(),
|
||||
@@ -132,6 +134,7 @@ async fn authorization_uses_selected_proxy_regardless_of_probe_status() {
|
||||
account_count: 0,
|
||||
last_test_at: probe_success.map(|_| now),
|
||||
last_test: probe_success.map(|success| ProxyTestResult {
|
||||
location: Default::default(),
|
||||
success,
|
||||
latency_ms: 10,
|
||||
exit_ip: None,
|
||||
@@ -153,9 +156,19 @@ async fn authorization_uses_selected_proxy_regardless_of_probe_status() {
|
||||
reauthorization: None,
|
||||
};
|
||||
let result = if kind == "openai" {
|
||||
services.openai().start_authorization(command).await
|
||||
services
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.start_authorization(command)
|
||||
.await
|
||||
} else {
|
||||
services.xai().start_authorization(command).await
|
||||
services
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap())
|
||||
.unwrap()
|
||||
.start_authorization(command)
|
||||
.await
|
||||
};
|
||||
assert!(result.is_ok(), "{kind}, {probe_success:?}: {result:?}");
|
||||
assert_eq!(recorded(&events), ["provider.start_authorization"]);
|
||||
@@ -263,9 +276,19 @@ async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_err
|
||||
document: document(),
|
||||
};
|
||||
let result = if kind == "openai" {
|
||||
services.openai().import_document(command).await
|
||||
services
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(command)
|
||||
.await
|
||||
} else {
|
||||
services.xai().import_document(command).await
|
||||
services
|
||||
.credentials()
|
||||
.for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap())
|
||||
.unwrap()
|
||||
.import_document(command)
|
||||
.await
|
||||
};
|
||||
assert_eq!(result.is_err(), failure.is_some());
|
||||
let events = recorded(&events);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminErrorKind, MutationContext,
|
||||
AdminErrorKind, MutationContext, Revision,
|
||||
pricing::{PricingChange, PricingSyncPreview, StoredPricing, SyncPricing, UpdatePricing},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
RuntimeSettings,
|
||||
@@ -13,10 +19,15 @@ use gateway_admin::{
|
||||
|
||||
struct UnusedSettingsStore;
|
||||
|
||||
#[derive(Default)]
|
||||
struct PricingSettingsStore {
|
||||
updated: Mutex<Option<UpdatePricing>>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_profile_preview_rejects_unknown_provider_before_loading_settings() {
|
||||
let services = super::AdminHarness::new()
|
||||
.settings(std::sync::Arc::new(UnusedSettingsStore))
|
||||
.settings(Arc::new(UnusedSettingsStore))
|
||||
.build()
|
||||
.await;
|
||||
let error = services
|
||||
@@ -27,6 +38,61 @@ async fn client_profile_preview_rejects_unknown_provider_before_loading_settings
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pricing_accepts_native_provider_and_rejects_unknown_sync_provider() {
|
||||
let store = Arc::new(PricingSettingsStore::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.settings(store.clone())
|
||||
.provider(super::test_provider("xai"))
|
||||
.build()
|
||||
.await;
|
||||
let context = MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::System,
|
||||
request_id: "request-native-pricing".to_owned(),
|
||||
};
|
||||
let pricing = serde_json::from_value(serde_json::json!({
|
||||
"multiplierBps": 10000,
|
||||
"bands": {"standard": {
|
||||
"input": "1", "output": "2", "cacheRead": "0", "cacheWrite": "0"
|
||||
}}
|
||||
}))
|
||||
.expect("native pricing");
|
||||
let update = UpdatePricing {
|
||||
provider: "xai".to_owned(),
|
||||
models: vec!["example-model".to_owned()],
|
||||
change: PricingChange::Replace(pricing),
|
||||
};
|
||||
|
||||
services
|
||||
.settings()
|
||||
.update_pricing(&context, update.clone())
|
||||
.await
|
||||
.expect("native provider pricing");
|
||||
assert_eq!(
|
||||
store.updated.lock().expect("updated pricing").as_ref(),
|
||||
Some(&update)
|
||||
);
|
||||
|
||||
let error = services
|
||||
.settings()
|
||||
.sync_pricing(
|
||||
&context,
|
||||
SyncPricing {
|
||||
preview: PricingSyncPreview {
|
||||
prices: BTreeMap::new(),
|
||||
skipped: Vec::new(),
|
||||
},
|
||||
models: BTreeMap::from([(
|
||||
"unregistered-provider".to_owned(),
|
||||
BTreeSet::from(["example-model".to_owned()]),
|
||||
)]),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown provider cannot enter pricing sync");
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for UnusedSettingsStore {
|
||||
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
|
||||
@@ -78,10 +144,65 @@ impl SettingsStore for UnusedSettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for PricingSettingsStore {
|
||||
async fn load_pricing(&self) -> AdminStoreResult<StoredPricing> {
|
||||
Ok(StoredPricing::default())
|
||||
}
|
||||
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
_: gateway_admin::model::pricing::PricingSyncChanges,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
panic!("unexpected pricing sync")
|
||||
}
|
||||
|
||||
async fn update_pricing(
|
||||
&self,
|
||||
command: UpdatePricing,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
*self.updated.lock().expect("updated pricing") = Some(command);
|
||||
Ok(Revision::new(2).expect("revision"))
|
||||
}
|
||||
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn replace_runtime_settings(
|
||||
&self,
|
||||
_: ReplaceRuntimeSettings,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unused())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
let services = super::AdminHarness::new()
|
||||
.settings(std::sync::Arc::new(UnusedSettingsStore))
|
||||
.settings(Arc::new(UnusedSettingsStore))
|
||||
.build()
|
||||
.await;
|
||||
let error = services
|
||||
@@ -92,8 +213,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
request_id: "request-settings".to_owned(),
|
||||
},
|
||||
ReplaceRuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_profile_updates: Default::default(),
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
model_mappings: Default::default(),
|
||||
@@ -118,6 +238,9 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
account_auto_freeze_probe_enabled: true,
|
||||
account_auto_freeze_probe_model: None,
|
||||
account_auto_freeze_adaptive_concurrency: true,
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -54,19 +54,20 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
*self.target.lock().expect("target") = Some(target_version.clone());
|
||||
Ok(SystemOperationAccepted::Update {
|
||||
operation_id: "operation-update".to_owned(),
|
||||
deployment_mode: "source".to_owned(),
|
||||
message: "accepted".to_owned(),
|
||||
need_restart: true,
|
||||
target_version: target_version.unwrap_or_else(|| "latest".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
|
||||
Ok(SystemUpdateStatus {
|
||||
need_restart: false,
|
||||
previous_version: None,
|
||||
current_version: Some("1.0.0".to_owned()),
|
||||
operation: SystemOperationState {
|
||||
@@ -82,7 +83,10 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
})
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
async fn rollback(
|
||||
&self,
|
||||
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Ok(SystemOperationAccepted::Rollback {
|
||||
operation_id: "operation-rollback".to_owned(),
|
||||
message: "accepted".to_owned(),
|
||||
|
||||
@@ -21,6 +21,7 @@ futures = "0.3.32"
|
||||
gateway-admin = { path = "../gateway-admin" }
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
gateway-protocol = { path = "../gateway-protocol" }
|
||||
headers = "0.4.1"
|
||||
secrecy = "0.10"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
|
||||
@@ -126,7 +126,7 @@ struct AccountGroupAccountSummaryView {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountGroupCapacityView {
|
||||
used_slots: Option<u64>,
|
||||
total_slots: u64,
|
||||
total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user