mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
fix(accounts): export native catalog from selected account
This commit is contained in:
@@ -16,6 +16,7 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 开发独立网关插件 | [plugin-dev](../plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
|
||||
@@ -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)。当前源码支持某能力,不代表较旧宿主支持;清单版本、通信版本、能力版本也不能混用
|
||||
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布
|
||||
|
||||
本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。
|
||||
|
||||
## 按任务选择能力
|
||||
|
||||
| 用户要做什么 | 优先使用 | 需要读取的合同 |
|
||||
| --- | --- | --- |
|
||||
| 改写请求/响应、处理流式内容、协议转换 | `middleware` | [洋葱中间件](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件) |
|
||||
| 接入新的模型服务与账号 | `executor`,按需组合 `models`、`authentication`、`quota`、`account_management`、`billing`、`request_profile` | [Provider](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#provider)及 `call::provider` / `call::auth` |
|
||||
| 改变模型选择或账号排序 | `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) |
|
||||
| 自动刷新凭据、额度、模型或画像资料 | `maintenance` 及对应 Provider 能力 | [Provider](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#provider),不是任意 cron 或常驻任务入口 |
|
||||
|
||||
只声明实现了的能力。Provider 专属能力需要注册 Provider,并具有 `executor`;普通中间件或页面不需要虚构 Provider。
|
||||
网络、账号、模型调用和私有状态属于宿主回调与授权,不重复声明为顶层能力。
|
||||
|
||||
## 实现时的关键选择
|
||||
|
||||
### 清单与处理器
|
||||
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,完整扩展项 ID 属于自己的插件命名空间;运行注册必须与作者清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;Provider 可用业务组简写,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 实例
|
||||
- `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,98 @@
|
||||
# 构建、联调与验证
|
||||
|
||||
仅在准备工程、构建或验证插件时读取。这里给出执行入口,不复制 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、页面和相关权限;Provider、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` |
|
||||
| 管理接口与 Provider | `examples/workbench/backend/src/management/`、`provider/` |
|
||||
| 页面与主题 | `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`。需要本地构建工具时,在主仓根目录运行以下命令,把 `--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`、可执行文件和声明资源。
|
||||
本地同版本反复试验与对外发布区分处理;发布新内容使用明确的新版本,不覆盖已发布版本。
|
||||
|
||||
## 验证与交付
|
||||
|
||||
按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
|
||||
只有获得目标环境操作授权才安装和启用;不把修改源码的授权扩展到生产实例或真实凭据操作。
|
||||
需要 API 自动化时查[插件管理 API](../../../../docs/api.md#12-插件管理),不要依据记忆拼路由。
|
||||
|
||||
按实际实现选择验证,不为不存在的能力增加测试任务:
|
||||
|
||||
| 实现范围 | 应验证的行为 |
|
||||
| --- | --- |
|
||||
| 中间件 | 匹配/不匹配范围、授权不足、下游错误与取消;支持流式时检查多帧、终态和断开;若涉及续链,使用真实目标客户端检查 HTTP/SSE、WS 及后续轮次 |
|
||||
| Provider | 导入或登录、模型发现、实际执行、错误及用量;仅对声明支持的协议、资料、额度等操作验收 |
|
||||
| 管理页面 | 从宿主打开,标题/副标题、明暗主题、加载与错误状态;管理桥请求成功;停用或版本切换后旧入口失效 |
|
||||
| 入口认证/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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,6 +507,36 @@ 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
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
> [!NOTE]
|
||||
> 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议
|
||||
|
||||
## 功能
|
||||
|
||||
| 场景 | 能力 |
|
||||
| --- | --- |
|
||||
| 客户端接入 | Responses HTTP / SSE / WebSocket、Images、模型目录与 standalone Search |
|
||||
| 多账号与路由 | OpenAI、xAI 及插件 Provider,账号分组、模型范围、出站代理与安全重试 |
|
||||
| 使用与管理 | 管理端、Key 自助用量页、费用限额、请求记录与错误诊断 |
|
||||
| 插件扩展 | 制品访问域确认、自动默认配置,中间件、管理页面和其他声明能力 |
|
||||
| 运维 | 单副本部署,PostgreSQL / Redis、S3/R2 备份与同一大版本内在线更新 |
|
||||
|
||||
## 快速预览
|
||||
|
||||
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
|
||||
@@ -99,7 +109,7 @@ API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage`
|
||||
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
|
||||
| API Key | 管理端创建的客户端密钥 |
|
||||
|
||||
可用模型以该密钥查询到的模型列表为准:
|
||||
查询该密钥可见的模型目录:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/v1/models \
|
||||
@@ -108,14 +118,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) |
|
||||
|
||||
## 社区
|
||||
|
||||
|
||||
Generated
+620
-5
@@ -8,6 +8,20 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -32,6 +46,56 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
@@ -588,6 +652,21 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
@@ -624,18 +703,42 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borrow-or-share"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -712,6 +815,46 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
@@ -727,6 +870,21 @@ version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "codex-proxy-plugin-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"flate2",
|
||||
"gateway-plugin-sdk",
|
||||
"hex",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-proxy-rs"
|
||||
version = "0.0.0"
|
||||
@@ -736,6 +894,7 @@ dependencies = [
|
||||
"gateway-api",
|
||||
"gateway-core",
|
||||
"gateway-host",
|
||||
"gateway-plugin-runtime",
|
||||
"gateway-store",
|
||||
"provider-openai",
|
||||
"provider-xai",
|
||||
@@ -748,6 +907,18 @@ dependencies = [
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -1181,6 +1352,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -1236,12 +1416,32 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.14.0"
|
||||
@@ -1275,10 +1475,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
|
||||
dependencies = [
|
||||
"borrow-or-share",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
@@ -1326,6 +1537,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fraction"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500"
|
||||
dependencies = [
|
||||
"num",
|
||||
"num-bigint",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
@@ -1464,6 +1685,7 @@ dependencies = [
|
||||
"hex",
|
||||
"rand_core 0.6.4",
|
||||
"secrecy",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
@@ -1489,6 +1711,7 @@ dependencies = [
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"gateway-protocol",
|
||||
"headers",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1530,6 +1753,8 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"config",
|
||||
"filetime",
|
||||
@@ -1538,8 +1763,18 @@ dependencies = [
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"hex",
|
||||
"http 1.4.0",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"ipnet",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"scraper",
|
||||
"secrecy",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
@@ -1549,12 +1784,61 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gateway-plugin-runtime"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"futures",
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"gateway-host",
|
||||
"gateway-plugin-sdk",
|
||||
"gateway-protocol",
|
||||
"gateway-store",
|
||||
"hex",
|
||||
"image",
|
||||
"jsonschema",
|
||||
"roxmltree",
|
||||
"secrecy",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sqlx",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gateway-plugin-sdk"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gateway-protocol"
|
||||
version = "0.0.0"
|
||||
@@ -1634,9 +1918,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1653,6 +1939,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.14.0"
|
||||
@@ -2075,6 +2371,34 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -2091,6 +2415,12 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -2118,6 +2448,59 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6a806f80c1f5560431009ce5ec29b59d38f950e0cd7db5f1b8925e6d8104e21"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
"data-encoding",
|
||||
"email_address",
|
||||
"fancy-regex",
|
||||
"fraction",
|
||||
"getrandom 0.3.4",
|
||||
"itoa",
|
||||
"jsonschema-regex",
|
||||
"jsonschema-value",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"percent-encoding",
|
||||
"referencing",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum",
|
||||
"unicode-general-category",
|
||||
"uuid-simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-regex"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb862addfa7782108933abcf842fe25334523a9df5b89ea4d9620e3dd7b42181"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-value"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a05cd404c5ff6e2731dbbf7e290a27417750acfb59e329560a1d0af384c93fb0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
"fraction",
|
||||
"getrandom 0.3.4",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"serde_json",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "11.1.0"
|
||||
@@ -2257,6 +2640,12 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "micromap"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -2273,6 +2662,16 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
@@ -2294,6 +2693,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
@@ -2326,6 +2735,45 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-cmp"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2341,6 +2789,27 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2366,6 +2835,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
@@ -2657,6 +3132,19 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2825,6 +3313,18 @@ dependencies = [
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
@@ -3011,6 +3511,43 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3b4a92fac7e28c27de3ad26df2ecb9e652f227b258e845af034052e5b22c96c"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.17.1",
|
||||
"itoa",
|
||||
"micromap",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.0"
|
||||
@@ -3025,9 +3562,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.15"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -3332,6 +3869,10 @@ name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
@@ -3632,6 +4173,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
@@ -3699,6 +4241,7 @@ dependencies = [
|
||||
"sqlx-core",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3734,6 +4277,7 @@ dependencies = [
|
||||
"stringprep",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
@@ -3760,6 +4304,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3803,6 +4348,33 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4321,6 +4893,12 @@ version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -4384,6 +4962,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.1"
|
||||
@@ -4395,6 +4979,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid-simd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
|
||||
dependencies = [
|
||||
"outref",
|
||||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -4552,6 +5146,12 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.2"
|
||||
@@ -4976,9 +5576,9 @@ checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
@@ -5007,3 +5607,18 @@ dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -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,234 @@ 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) {
|
||||
// 帮助在原生 Provider 初始化前返回,避免写默认画像、发现模型或初始化管理员。
|
||||
let runtime = prepare_plugins(
|
||||
&store,
|
||||
plugin_cache,
|
||||
&version,
|
||||
ProviderRegistry::default(),
|
||||
gateway_admin::ports::provider::ProviderAdminRegistry::new([])
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?,
|
||||
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,
|
||||
providers,
|
||||
gateway_admin::ports::provider::ProviderAdminRegistry::new([
|
||||
openai.admin_provider(),
|
||||
xai.admin_provider(),
|
||||
])
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?,
|
||||
plugin_http.clone(),
|
||||
)?;
|
||||
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(),
|
||||
plugin_runtime.provider_registry(),
|
||||
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(
|
||||
plugin_runtime.admin_registry(core.snapshots()),
|
||||
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(
|
||||
plugin_runtime.admin_registry(core.snapshots()),
|
||||
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());
|
||||
// CLI 不恢复主进程的准入状态,不构造 API、不初始化管理员或启动 Worker。
|
||||
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(),
|
||||
plugin_runtime.provider_registry(),
|
||||
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 admin_providers = plugin_runtime.admin_registry(core.snapshots());
|
||||
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 +339,76 @@ 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())?;
|
||||
plan.extend(or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.worker_contributions(core.snapshots())
|
||||
));
|
||||
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,
|
||||
providers: ProviderRegistry,
|
||||
admin_providers: gateway_admin::ports::provider::ProviderAdminRegistry,
|
||||
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(),
|
||||
continuation_drain: Default::default(),
|
||||
restart_circuit: Default::default(),
|
||||
},
|
||||
providers,
|
||||
admin_providers,
|
||||
http,
|
||||
std::sync::Arc::new(gateway_host::process::ProcessSupervisor::default()),
|
||||
)
|
||||
.with_provider_ports(store.provider_ports()),
|
||||
))
|
||||
}
|
||||
|
||||
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
|
||||
#[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;
|
||||
|
||||
@@ -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,65 @@
|
||||
# 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` 仍须显式选择。
|
||||
Provider 使用一个业务组声明协议与可选功能,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"contributes": {
|
||||
"provider": {
|
||||
"capabilities": ["models", "quota", "maintenance"],
|
||||
"inputFormats": ["openai"],
|
||||
"outputFormats": ["openai"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的
|
||||
`plugin.json` 会写入展开后的 `executor`、所选 Provider 子能力及完整贡献项字段,不保留另一份作者声明。
|
||||
完整规则见 [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,267 @@
|
||||
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.contributes[&Capability::Executor].input_formats,
|
||||
["openai"]
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.contributes[&Capability::Quota].stages,
|
||||
[Stage::Management, Stage::Maintenance]
|
||||
);
|
||||
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":{},
|
||||
"provider":{"capabilities":["quota"],"inputFormats":["openai"],"outputFormats":["openai"]}
|
||||
},
|
||||
"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,13 +381,7 @@ 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,
|
||||
@@ -370,7 +412,11 @@ pub async fn initialize(
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
let system = Arc::new(DefaultSystemService::new(system));
|
||||
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,
|
||||
@@ -378,22 +424,32 @@ pub async fn initialize(
|
||||
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(),
|
||||
@@ -427,8 +483,8 @@ pub async fn initialize(
|
||||
pricing_source,
|
||||
)),
|
||||
system,
|
||||
openai,
|
||||
xai,
|
||||
credentials,
|
||||
plugin_accounts,
|
||||
import_tasks,
|
||||
backups,
|
||||
};
|
||||
@@ -460,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,
|
||||
@@ -509,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 注册表初始化失败")
|
||||
}
|
||||
|
||||
@@ -204,6 +204,8 @@ pub struct AccountPage {
|
||||
pub items: Vec<AccountPageItem>,
|
||||
pub total: u64,
|
||||
pub summary: AccountSummary,
|
||||
/// 全部持久化账号的平台,不受筛选、分页和插件启停影响。
|
||||
pub providers: Vec<String>,
|
||||
}
|
||||
|
||||
/// 同一状态快照下的账号事实与唯一状态投影。
|
||||
|
||||
@@ -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,7 +11,10 @@ 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_capabilities;
|
||||
pub mod provider_credentials;
|
||||
pub mod proxies;
|
||||
pub mod quota_forecast;
|
||||
|
||||
@@ -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,193 @@
|
||||
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,
|
||||
}
|
||||
|
||||
/// 从当前发布视图、弱代次索引和 continuation retention 合成的只读诊断。
|
||||
#[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 retained_continuations: u32,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 账号页面消费的能力描述;支持声明不代替实际操作时的账号范围与权限校验。
|
||||
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderCredentialDescriptor {
|
||||
pub provider: ProviderKind,
|
||||
pub capabilities: ProviderCredentialCapabilities,
|
||||
}
|
||||
|
||||
/// 当前账号可用的管理能力,由已发布 Provider 投影;不代替执行时的权限与身份校验。
|
||||
#[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, Default, Clone, PartialEq)]
|
||||
pub struct ProviderCredentialCapabilities {
|
||||
pub import: Option<Value>,
|
||||
pub login: Option<ProviderLoginCapability>,
|
||||
pub refresh: bool,
|
||||
pub export: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderLoginCapability {
|
||||
pub input_schema: Value,
|
||||
pub completion: AuthorizationCompletion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthorizationCompletion {
|
||||
Callback,
|
||||
Poll,
|
||||
}
|
||||
@@ -16,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,
|
||||
},
|
||||
@@ -385,13 +385,19 @@ 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,
|
||||
pub reauthorization: Option<ProviderAccountId>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
pub input: ProviderDocument,
|
||||
}
|
||||
|
||||
pub struct PrepareAuthorization {
|
||||
pub pending: PendingAuthorizationMutation,
|
||||
pub input: ProviderDocument,
|
||||
}
|
||||
|
||||
/// OAuth 流程启动结果。
|
||||
@@ -426,8 +432,8 @@ pub trait AuthorizationCommitGuard: Send + 'static {
|
||||
|
||||
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者。
|
||||
pub enum PreparedAuthorizationCredential {
|
||||
Create(PreparedCredentialCreate),
|
||||
Reauthorize(PreparedCredentialRotation),
|
||||
Create(Vec<PreparedCredentialCreate>),
|
||||
Reauthorize(Box<PreparedCredentialRotation>),
|
||||
}
|
||||
|
||||
/// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回。
|
||||
@@ -450,18 +456,131 @@ impl fmt::Debug for PreparedAuthorizationCommit {
|
||||
/// Store 可持久化的 OAuth credential facts,不携带 Provider guard。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthorizationCredentialCommit {
|
||||
Create(PreparedCredentialCreate),
|
||||
Reauthorize(PreparedCredentialRotationFacts),
|
||||
Create(Vec<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: CredentialBatchMutationResult,
|
||||
pub newly_committed: bool,
|
||||
}
|
||||
|
||||
/// 同一授权事务产生的账号集合;重新授权的集合始终只有原账号。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialBatchMutationResult {
|
||||
pub config_revision: Revision,
|
||||
pub accounts: Vec<AuthorizedAccount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthorizedAccount {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub credential_revision: Option<Revision>,
|
||||
}
|
||||
|
||||
impl From<CredentialMutationResult> for CredentialBatchMutationResult {
|
||||
fn from(result: CredentialMutationResult) -> Self {
|
||||
Self {
|
||||
config_revision: result.config_revision,
|
||||
accounts: vec![AuthorizedAccount {
|
||||
account_id: result.account_id,
|
||||
credential_revision: result.credential_revision,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询可以附带浏览器返回值;敏感材料不能进入 Debug 或通用审计。
|
||||
pub struct PollAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub flow_id: String,
|
||||
pub callback_url: Option<String>,
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
}
|
||||
|
||||
pub enum PreparedAuthorizationPoll {
|
||||
Pending { retry_after: std::time::Duration },
|
||||
Complete(Box<PreparedAuthorizationCommit>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthorizationPollResult {
|
||||
Pending { retry_after: std::time::Duration },
|
||||
Complete(CredentialBatchMutationResult),
|
||||
}
|
||||
|
||||
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard。
|
||||
///
|
||||
/// Store 成功时先结束 credential guard,再消费 OAuth claim;失败时丢弃 credential guard 并
|
||||
@@ -495,6 +614,7 @@ impl PreparedAuthorizationCommit {
|
||||
pub(crate) fn into_commit(
|
||||
self,
|
||||
settings: Option<AccountImportSettings>,
|
||||
key: AuthorizationReceiptKey,
|
||||
) -> AuthorizationCommitSettlement {
|
||||
let (credential, guard) = match self.credential {
|
||||
PreparedAuthorizationCredential::Create(credential) => {
|
||||
@@ -503,13 +623,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,
|
||||
@@ -520,13 +641,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(())
|
||||
@@ -1069,6 +1185,62 @@ pub struct ProviderModelCatalogDocument {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PluginAccountSaveReason {
|
||||
Explicit,
|
||||
ModelDiscovery,
|
||||
Maintenance,
|
||||
}
|
||||
|
||||
/// Runtime 已完成 wire 校验、Admin 仍需绑定当前账号和 revision 的 prepared facts。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PreparedPluginAccountSave {
|
||||
Create(PreparedCredentialCreate),
|
||||
Replace {
|
||||
facts: PreparedCredentialRotationFacts,
|
||||
authentication_kind: String,
|
||||
reason: PluginAccountSaveReason,
|
||||
},
|
||||
}
|
||||
|
||||
#[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 {
|
||||
@@ -1104,6 +1276,7 @@ impl fmt::Debug for ProviderExport {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
pub capabilities: super::provider_capabilities::ProviderAccountCapabilities,
|
||||
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
|
||||
pub plan_type_display: Option<String>,
|
||||
pub projection: gateway_core::account::AccountStatusProjection,
|
||||
@@ -1118,6 +1291,7 @@ pub struct AccountDirectoryPage {
|
||||
pub items: Vec<AccountDirectoryItem>,
|
||||
pub total: u64,
|
||||
pub summary: AccountSummary,
|
||||
pub providers: Vec<String>,
|
||||
}
|
||||
|
||||
/// 凭据刷新提交后的完整账号结果。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -50,8 +57,8 @@ pub struct RuntimeSettings {
|
||||
/// 原子替换运行设置的命令。
|
||||
#[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,
|
||||
@@ -134,10 +141,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
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 provider_extensions;
|
||||
pub mod proxy;
|
||||
pub mod store;
|
||||
pub mod system;
|
||||
|
||||
@@ -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,331 @@
|
||||
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;其依赖的持久化事实必须按当前 source revision 读取。
|
||||
/// 本次修改的实例 revision 必须等于候选 config_revision,准备失败时拒绝提交;历史故障可隔离。
|
||||
async fn prepare(
|
||||
&self,
|
||||
source_revision: Revision,
|
||||
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>;
|
||||
}
|
||||
@@ -15,12 +15,11 @@ use crate::model::observability::{
|
||||
};
|
||||
use crate::model::provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
|
||||
ProviderModelCatalogDocument, ProviderModels, ProviderProfileAvatar, ProviderProfileStatistics,
|
||||
ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits,
|
||||
ProviderSubscription, explicit_plan_type,
|
||||
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
|
||||
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModelCatalogDocument, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
|
||||
};
|
||||
use crate::model::{
|
||||
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
|
||||
@@ -118,6 +117,21 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
|
||||
fn provider_kind(&self) -> &ProviderKind;
|
||||
|
||||
fn credential_capabilities(
|
||||
&self,
|
||||
) -> crate::model::provider_capabilities::ProviderCredentialCapabilities {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// 只读取当前代次的声明及账号类型,不执行网络或数据库查询。
|
||||
fn account_capabilities(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
_authentication_kind: &str,
|
||||
) -> crate::model::provider_capabilities::ProviderAccountCapabilities {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。
|
||||
fn client_profile_options(
|
||||
&self,
|
||||
@@ -125,6 +139,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,
|
||||
@@ -151,7 +170,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,
|
||||
@@ -181,7 +200,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
pending: PendingAuthorizationMutation,
|
||||
command: crate::model::provider_credentials::PrepareAuthorization,
|
||||
) -> Result<AuthorizationStarted, ProviderAdminError>;
|
||||
|
||||
async fn complete_authorization(
|
||||
@@ -189,6 +208,14 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
command: CompleteAuthorization,
|
||||
) -> Result<PreparedAuthorizationCommit, ProviderAdminError>;
|
||||
|
||||
async fn poll_authorization(
|
||||
&self,
|
||||
_command: crate::model::provider_credentials::PollAuthorization,
|
||||
) -> Result<crate::model::provider_credentials::PreparedAuthorizationPoll, ProviderAdminError>
|
||||
{
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
async fn prepare_rotation(
|
||||
&self,
|
||||
command: PrepareCredentialRotation,
|
||||
@@ -284,16 +311,69 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
/// 按 ProviderKind 动态发现管理能力;不含具体 Provider 分支。
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderAdminRegistry {
|
||||
providers: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
|
||||
pub(super) providers: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
|
||||
pub(super) extensions: Option<(
|
||||
super::provider_extensions::ProviderAdminExtensionIndex,
|
||||
gateway_core::runtime::RuntimeSnapshotHandle,
|
||||
)>,
|
||||
pub(super) snapshot: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
|
||||
}
|
||||
|
||||
impl ProviderAdminRegistry {
|
||||
#[must_use]
|
||||
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
|
||||
self.providers
|
||||
/// 返回当前冻结代次中真正提供客户端画像选项的 Provider。
|
||||
pub fn client_profile_providers(&self) -> Result<Vec<ProviderKind>, ProviderAdminError> {
|
||||
let frozen = self.freeze()?;
|
||||
let mut supported = Vec::new();
|
||||
for (kind, provider) in frozen.providers.iter() {
|
||||
match provider.client_profile_options() {
|
||||
Ok(_) => supported.push(kind.clone()),
|
||||
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Ok(supported)
|
||||
}
|
||||
|
||||
/// 一次读取冻结整个目录,能力与执行器始终来自同一已发布代次。
|
||||
pub fn credential_descriptors(
|
||||
&self,
|
||||
) -> Result<
|
||||
Vec<crate::model::provider_capabilities::ProviderCredentialDescriptor>,
|
||||
ProviderAdminError,
|
||||
> {
|
||||
Ok(self
|
||||
.freeze()?
|
||||
.providers
|
||||
.iter()
|
||||
.map(|(kind, provider)| {
|
||||
crate::model::provider_capabilities::ProviderCredentialDescriptor {
|
||||
provider: kind.clone(),
|
||||
capabilities: provider.credential_capabilities(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn pricing_catalog(
|
||||
&self,
|
||||
) -> Result<gateway_core::metering::PricingOverrides, ProviderAdminError> {
|
||||
Ok(self
|
||||
.freeze()?
|
||||
.providers
|
||||
.iter()
|
||||
.map(|(kind, provider)| (kind.as_str().to_owned(), provider.pricing_catalog()))
|
||||
.collect()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 一个管理用例可显式冻结目录,批量操作不会混入下一份配置。
|
||||
pub fn freeze(&self) -> Result<Self, ProviderAdminError> {
|
||||
let Some((index, snapshots)) = &self.extensions else {
|
||||
return Ok(self.clone());
|
||||
};
|
||||
let snapshot = snapshots
|
||||
.acquire()
|
||||
.map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))?;
|
||||
index.resolve(snapshot)
|
||||
}
|
||||
|
||||
/// 创建无重复 ProviderKind 的注册表。
|
||||
@@ -313,17 +393,25 @@ impl ProviderAdminRegistry {
|
||||
}
|
||||
Ok(Self {
|
||||
providers: Arc::new(registered),
|
||||
extensions: None,
|
||||
snapshot: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn require(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
) -> Result<Arc<dyn ProviderAdmin>, ProviderAdminError> {
|
||||
self.providers
|
||||
.get(provider_kind)
|
||||
.cloned()
|
||||
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
) -> Result<super::provider_extensions::ProviderAdminHandle, ProviderAdminError> {
|
||||
if let Some(provider) = self.providers.get(provider_kind) {
|
||||
return Ok(super::provider_extensions::ProviderAdminHandle {
|
||||
provider: provider.clone(),
|
||||
snapshot: self.snapshot.clone(),
|
||||
});
|
||||
}
|
||||
if self.extensions.is_some() {
|
||||
return self.freeze()?.require(provider_kind);
|
||||
}
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。
|
||||
@@ -348,7 +436,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),
|
||||
@@ -364,14 +452,16 @@ impl ProviderAdminRegistry {
|
||||
ProviderKind,
|
||||
gateway_core::account::OpaqueProviderData,
|
||||
>,
|
||||
) -> Vec<DashboardWireProfile> {
|
||||
self.providers
|
||||
) -> Result<Vec<DashboardWireProfile>, ProviderAdminError> {
|
||||
Ok(self
|
||||
.freeze()?
|
||||
.providers
|
||||
.iter()
|
||||
.filter_map(|(kind, provider)| match configurations.get(kind) {
|
||||
Some(configuration) => provider.configured_wire_profile(configuration),
|
||||
None => provider.dashboard_wire_profile(),
|
||||
})
|
||||
.collect()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 动态分派 Provider-owned 费用规则,不含任何具体 Provider 分支。
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! 管理操作解析 Core 已发布的扩展集合;不持有独立的当前版本指针。
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, RwLock, Weak},
|
||||
};
|
||||
|
||||
use gateway_core::{
|
||||
routing::{ProviderKind, RuntimeSnapshot},
|
||||
runtime::{RuntimeSnapshotHandle, extensions::ExtensionSetId},
|
||||
};
|
||||
|
||||
use super::provider::{
|
||||
ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderAdminExtensionIndex {
|
||||
base: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
|
||||
sets: Arc<RwLock<BTreeMap<ExtensionSetId, Weak<ProviderAdminRegistry>>>>,
|
||||
}
|
||||
|
||||
impl ProviderAdminExtensionIndex {
|
||||
#[must_use]
|
||||
pub fn new(base: ProviderAdminRegistry) -> Self {
|
||||
Self {
|
||||
base: base.providers,
|
||||
sets: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
&self,
|
||||
id: ExtensionSetId,
|
||||
providers: impl IntoIterator<Item = Arc<dyn ProviderAdmin>>,
|
||||
) -> Result<Arc<ProviderAdminRegistry>, ProviderAdminError> {
|
||||
let registry = Arc::new(ProviderAdminRegistry::new(
|
||||
self.base.values().cloned().chain(providers),
|
||||
)?);
|
||||
let mut sets = self
|
||||
.sets
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
sets.retain(|_, registry| registry.strong_count() != 0);
|
||||
if sets.contains_key(&id) {
|
||||
return Err(ProviderAdminError::new(ProviderAdminErrorKind::Conflict));
|
||||
}
|
||||
sets.insert(id, Arc::downgrade(®istry));
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn registry(&self, snapshots: RuntimeSnapshotHandle) -> ProviderAdminRegistry {
|
||||
ProviderAdminRegistry {
|
||||
providers: self.base.clone(),
|
||||
extensions: Some((self.clone(), snapshots)),
|
||||
snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve(
|
||||
&self,
|
||||
snapshot: Arc<RuntimeSnapshot>,
|
||||
) -> Result<ProviderAdminRegistry, ProviderAdminError> {
|
||||
let reference = snapshot
|
||||
.extensions()
|
||||
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))?;
|
||||
if !reference.can_serve() {
|
||||
return Err(ProviderAdminError::new(ProviderAdminErrorKind::Unavailable));
|
||||
}
|
||||
let registry = self
|
||||
.sets
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(reference.id())
|
||||
.and_then(Weak::upgrade)
|
||||
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))?;
|
||||
Ok(ProviderAdminRegistry {
|
||||
providers: registry.providers.clone(),
|
||||
extensions: None,
|
||||
snapshot: Some(snapshot),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理操作及其后台观测保活同一集合,避免发布切换在 await 期间终止旧进程。
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderAdminHandle {
|
||||
pub(super) provider: Arc<dyn ProviderAdmin>,
|
||||
pub(super) snapshot: Option<Arc<RuntimeSnapshot>>,
|
||||
}
|
||||
|
||||
impl ProviderAdminHandle {
|
||||
/// 管理准备与 Core 探测共享完整快照,不能在两次 await 之间重新解析插件版本。
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Option<Arc<RuntimeSnapshot>> {
|
||||
self.snapshot.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ProviderAdminHandle {
|
||||
type Target = dyn ProviderAdmin;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.provider.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<dyn ProviderAdmin> for ProviderAdminHandle {
|
||||
fn as_ref(&self) -> &(dyn ProviderAdmin + 'static) {
|
||||
self.provider.as_ref()
|
||||
}
|
||||
}
|
||||
@@ -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::CredentialBatchMutationResult>>;
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
&self,
|
||||
@@ -396,6 +421,9 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
|
||||
|
||||
/// 区间内请求及错误事实的平台集合,不依赖当前注册或账号是否仍存在。
|
||||
async fn usage_provider_kinds(&self, range: TimeRange) -> AdminStoreResult<Vec<String>>;
|
||||
|
||||
async fn usage_record_detail(&self, request_id: &str) -> AdminStoreResult<UsageDetail>;
|
||||
|
||||
async fn usage_summary(
|
||||
@@ -488,9 +516,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 +543,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 +553,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>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! 统一账号目录与跨 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,
|
||||
ProviderModelCatalogDocument, 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::{
|
||||
@@ -215,7 +220,7 @@ impl DefaultAccountsService {
|
||||
) -> Result<
|
||||
(
|
||||
AccountPageItem,
|
||||
Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
crate::ports::provider_extensions::ProviderAdminHandle,
|
||||
),
|
||||
AdminError,
|
||||
> {
|
||||
@@ -319,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 {
|
||||
@@ -347,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,
|
||||
@@ -363,6 +381,10 @@ impl DefaultAccountsService {
|
||||
#[async_trait]
|
||||
impl AccountsService for DefaultAccountsService {
|
||||
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError> {
|
||||
let providers = self
|
||||
.providers
|
||||
.freeze()
|
||||
.map_err(|error| map_provider_error(error, "provider directory"))?;
|
||||
let runtime = self
|
||||
.account_runtime
|
||||
.active_rate_limits()
|
||||
@@ -398,7 +420,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!(
|
||||
@@ -439,13 +461,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),
|
||||
@@ -454,14 +485,15 @@ impl AccountsService for DefaultAccountsService {
|
||||
account: item.account,
|
||||
projection: item.projection,
|
||||
quota,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<Vec<_>, AdminError>>()?;
|
||||
Ok(AccountDirectoryPage {
|
||||
config_revision: page.config_revision,
|
||||
items,
|
||||
total: page.total,
|
||||
summary: page.summary,
|
||||
providers: page.providers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -470,6 +502,10 @@ impl AccountsService for DefaultAccountsService {
|
||||
context: &MutationContext,
|
||||
account_ids: Vec<ProviderAccountId>,
|
||||
) -> Result<AccountExportBundle, AdminError> {
|
||||
let providers = self
|
||||
.providers
|
||||
.freeze()
|
||||
.map_err(|error| map_provider_error(error, "provider export"))?;
|
||||
if account_ids.is_empty() || account_ids.len() > 200 {
|
||||
return Err(AdminError::invalid("账号导出数量必须在 1 到 200 之间"));
|
||||
}
|
||||
@@ -488,12 +524,21 @@ 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"))?;
|
||||
// 混选时先验证同一发布代次的全部能力,拒绝路径不读取任何一组明文凭据。
|
||||
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"))?;
|
||||
if !provider.credential_capabilities().export {
|
||||
return Err(AdminError::invalid("所选账号包含不支持导出的平台"));
|
||||
}
|
||||
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)
|
||||
@@ -651,7 +696,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
let mut providers = BTreeMap::<
|
||||
ProviderKind,
|
||||
(
|
||||
Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
crate::ports::provider_extensions::ProviderAdminHandle,
|
||||
Vec<ProviderAccountId>,
|
||||
),
|
||||
>::new();
|
||||
@@ -715,7 +760,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,
|
||||
@@ -913,6 +966,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 {
|
||||
@@ -928,12 +982,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,
|
||||
},
|
||||
provider.snapshot(),
|
||||
)
|
||||
.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"))?;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
//! 已冻结 Provider 的凭据工作流;Admin 保持事务、审计与发布的唯一所有权。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
provider_credentials::{
|
||||
AuthorizationCommitResult, AuthorizationPollResult, AuthorizationReceiptKey,
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialBatchMutationResult,
|
||||
CredentialDeletion, CredentialDeletionResult, CredentialImportCommit,
|
||||
CredentialImportResult, CredentialMutationResult, ImportCredentials, PollAuthorization,
|
||||
PrepareAuthorization, PrepareCredentialImport, PrepareCredentialRotation,
|
||||
PreparedAuthorizationCredential, PreparedAuthorizationPoll, RotateCredential,
|
||||
StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider_extensions::ProviderAdminHandle, store::AccountStore},
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
pub struct ProviderCredentials {
|
||||
provider: ProviderAdminHandle,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl ProviderCredentials {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
provider: ProviderAdminHandle,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
pub async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let proxy_reservation = super::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, "provider credential import"))?;
|
||||
validate_prepared_import(
|
||||
self.provider.provider_kind(),
|
||||
&prepared,
|
||||
"provider 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, "provider 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)
|
||||
}
|
||||
|
||||
pub 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,
|
||||
"provider credential",
|
||||
)
|
||||
.await?;
|
||||
self.provider
|
||||
.start_authorization(PrepareAuthorization {
|
||||
pending,
|
||||
input: command.input,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider authorization"))
|
||||
}
|
||||
|
||||
pub async fn rotate(
|
||||
&self,
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.mutation.context;
|
||||
let account_id = command.mutation.account_id;
|
||||
if command
|
||||
.settings
|
||||
.as_ref()
|
||||
.is_some_and(|settings| settings.account_id != account_id.as_str())
|
||||
{
|
||||
return Err(AdminError::invalid("凭据和账号设置的目标不一致"));
|
||||
}
|
||||
let disable_account = command
|
||||
.settings
|
||||
.as_ref()
|
||||
.is_some_and(|settings| !settings.enabled);
|
||||
let details = required_credential(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&account_id,
|
||||
"provider credential rotation",
|
||||
)
|
||||
.await?;
|
||||
let account = details.credential;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_rotation(PrepareCredentialRotation {
|
||||
account: account.clone(),
|
||||
provider_material: command.provider_material,
|
||||
})
|
||||
.await
|
||||
.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,
|
||||
"provider credential rotation",
|
||||
)
|
||||
.await?;
|
||||
if disable_account {
|
||||
self.provider.account_unavailable(&account_id).await;
|
||||
}
|
||||
self.provider
|
||||
.account_facts_changed(std::slice::from_ref(&result.account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
) -> Result<CredentialDeletionResult, AdminError> {
|
||||
let result = delete_credentials(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
"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 single_authorization_result(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?;
|
||||
// 回调完成入口保留单账号合同;多账号授权通过显式轮询结果交付,不能提交后再截断。
|
||||
if matches!(&prepared.credential, PreparedAuthorizationCredential::Create(accounts) if accounts.len() != 1)
|
||||
{
|
||||
prepared.abort().await?;
|
||||
return Err(AdminError::invalid("多账号授权需要使用轮询入口"));
|
||||
}
|
||||
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?;
|
||||
single_authorization_result(result)
|
||||
}
|
||||
|
||||
pub async fn poll_authorization(
|
||||
&self,
|
||||
kind: &gateway_core::routing::ProviderKind,
|
||||
mut command: PollAuthorization,
|
||||
) -> Result<AuthorizationPollResult, 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(AuthorizationPollResult::Complete(result));
|
||||
}
|
||||
let scope = self.for_provider(kind)?;
|
||||
let provider = &scope.provider;
|
||||
let settings = command.settings.take();
|
||||
let prepared = provider
|
||||
.poll_authorization(command)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider authorization poll"))?;
|
||||
let prepared = match prepared {
|
||||
PreparedAuthorizationPoll::Pending { retry_after } => {
|
||||
if retry_after.is_zero() || retry_after > std::time::Duration::from_secs(300) {
|
||||
return Err(AdminError::internal("Provider 授权轮询间隔无效"));
|
||||
}
|
||||
return Ok(AuthorizationPollResult::Pending { retry_after });
|
||||
}
|
||||
PreparedAuthorizationPoll::Complete(prepared) => *prepared,
|
||||
};
|
||||
let prepared =
|
||||
validate_authorization_commit(kind, &context, prepared, "provider authorization poll")
|
||||
.await?;
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
key,
|
||||
settings,
|
||||
&context,
|
||||
"provider authorization poll",
|
||||
)
|
||||
.await?;
|
||||
let result = self
|
||||
.publish_authorization(provider, result, &context.request_id)
|
||||
.await?;
|
||||
Ok(AuthorizationPollResult::Complete(result))
|
||||
}
|
||||
|
||||
async fn publish_authorization(
|
||||
&self,
|
||||
provider: &ProviderAdminHandle,
|
||||
outcome: AuthorizationCommitResult,
|
||||
request_id: &str,
|
||||
) -> Result<CredentialBatchMutationResult, AdminError> {
|
||||
let result = outcome.result;
|
||||
if outcome.newly_committed {
|
||||
let ids = result
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|account| account.account_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
publish_credentials_and_observe_quota(
|
||||
provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
&ids,
|
||||
request_id,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn single_authorization_result(
|
||||
result: CredentialBatchMutationResult,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let [account] = result
|
||||
.accounts
|
||||
.try_into()
|
||||
.map_err(|_| AdminError::invalid("多账号授权需要使用轮询入口"))?;
|
||||
Ok(CredentialMutationResult {
|
||||
config_revision: result.config_revision,
|
||||
account_id: account.account_id,
|
||||
credential_revision: account.credential_revision,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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 {
|
||||
pub fn providers(
|
||||
&self,
|
||||
) -> Result<Vec<crate::model::provider_capabilities::ProviderCredentialDescriptor>, AdminError>
|
||||
{
|
||||
self.providers
|
||||
.credential_descriptors()
|
||||
.map_err(|error| super::map_provider_error(error, "provider capabilities"))
|
||||
}
|
||||
|
||||
#[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
|
||||
})
|
||||
{
|
||||
|
||||
@@ -37,6 +37,12 @@ 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>)
|
||||
@@ -111,15 +117,25 @@ impl KeyUsageService for DefaultKeyUsageService {
|
||||
let id = match self.verifier.verify_client_key(plaintext) {
|
||||
Ok(id) => id,
|
||||
Err(ClientAuthenticationError::InvalidKey) => return Ok(None),
|
||||
Err(ClientAuthenticationError::SnapshotUnavailable) => {
|
||||
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)
|
||||
.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"))
|
||||
|
||||
@@ -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::{
|
||||
@@ -101,8 +102,18 @@ async fn publish_committed(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_committed_deferred(
|
||||
snapshot: &dyn SnapshotControl,
|
||||
revision: crate::model::Revision,
|
||||
) -> Result<(), AdminError> {
|
||||
let revision = ConfigRevision::new(revision.get())
|
||||
.map_err(|_| AdminError::internal("已提交的配置版本不合法"))?;
|
||||
snapshot.publish_committed_deferred(revision).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_credentials_and_observe_quota(
|
||||
provider: &Arc<dyn ProviderAdmin>,
|
||||
provider: &crate::ports::provider_extensions::ProviderAdminHandle,
|
||||
snapshot: &dyn SnapshotControl,
|
||||
revision: crate::model::Revision,
|
||||
account_ids: &[ProviderAccountId],
|
||||
@@ -112,7 +123,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 +162,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 +256,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
|
||||
{
|
||||
@@ -265,8 +294,16 @@ async fn validate_authorization_commit(
|
||||
let matches_target = match (prepared.pending.target(), &prepared.credential) {
|
||||
(
|
||||
AuthorizationMutationTarget::Create { .. },
|
||||
PreparedAuthorizationCredential::Create(credential),
|
||||
) => credential.provider_kind == *provider_kind,
|
||||
PreparedAuthorizationCredential::Create(credentials),
|
||||
) => {
|
||||
let mut identities = std::collections::BTreeSet::new();
|
||||
!credentials.is_empty()
|
||||
&& credentials.len() <= 200
|
||||
&& credentials.iter().all(|credential| {
|
||||
credential.provider_kind == *provider_kind
|
||||
&& identities.insert(&credential.account_id)
|
||||
})
|
||||
}
|
||||
(
|
||||
AuthorizationMutationTarget::Reauthorize { account_id },
|
||||
PreparedAuthorizationCredential::Reauthorize(credential),
|
||||
@@ -294,10 +331,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 +349,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)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ pub trait ObservabilityService: Send + Sync {
|
||||
async fn dashboard_trend(&self, range: TimeRange, kind: TrendKind)
|
||||
-> Result<Trend, AdminError>;
|
||||
async fn usage_records(&self, query: UsageQuery) -> Result<UsagePage, AdminError>;
|
||||
async fn usage_provider_kinds(&self, range: TimeRange) -> Result<Vec<String>, AdminError>;
|
||||
async fn usage_record_detail(&self, request_id: &str) -> Result<UsageDetail, AdminError>;
|
||||
async fn usage_summary(
|
||||
&self,
|
||||
@@ -192,20 +193,10 @@ 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)
|
||||
.map_err(|error| super::map_provider_error(error, "provider profiles"))?;
|
||||
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
|
||||
let (inherited_accounts, overridden_slots) = runtime_slots
|
||||
.as_ref()
|
||||
@@ -283,6 +274,13 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
async fn usage_provider_kinds(&self, range: TimeRange) -> Result<Vec<String>, AdminError> {
|
||||
self.store
|
||||
.usage_provider_kinds(range)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage providers"))
|
||||
}
|
||||
|
||||
async fn usage_record_detail(&self, request_id: &str) -> Result<UsageDetail, AdminError> {
|
||||
if request_id.trim().is_empty() {
|
||||
return Err(AdminError::invalid("用量记录 ID 不能为空"));
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
//! OpenAI 管理资源的中立 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,
|
||||
PrepareCredentialRotation, RotateCredential, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
};
|
||||
|
||||
use 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 {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultOpenAiService {
|
||||
#[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 OpenAiService for DefaultOpenAiService {
|
||||
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, "OpenAI credential import"))?;
|
||||
validate_prepared_import(
|
||||
self.provider.provider_kind(),
|
||||
&prepared,
|
||||
"OpenAI 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, "OpenAI 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,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await?;
|
||||
self.provider
|
||||
.start_authorization(pending)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI 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(
|
||||
&self,
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.mutation.context;
|
||||
let account_id = command.mutation.account_id;
|
||||
if command
|
||||
.settings
|
||||
.as_ref()
|
||||
.is_some_and(|settings| settings.account_id != account_id.as_str())
|
||||
{
|
||||
return Err(AdminError::invalid("凭据和账号设置的目标不一致"));
|
||||
}
|
||||
let disable_account = command
|
||||
.settings
|
||||
.as_ref()
|
||||
.is_some_and(|settings| !settings.enabled);
|
||||
let details = required_credential(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&account_id,
|
||||
"OpenAI credential rotation",
|
||||
)
|
||||
.await?;
|
||||
let account = details.credential;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_rotation(PrepareCredentialRotation {
|
||||
account: account.clone(),
|
||||
provider_material: command.provider_material,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI credential rotation"))?;
|
||||
validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?;
|
||||
let result = commit_credential_rotation(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
command.settings,
|
||||
&context,
|
||||
"OpenAI credential rotation",
|
||||
)
|
||||
.await?;
|
||||
if disable_account {
|
||||
self.provider.account_unavailable(&account_id).await;
|
||||
}
|
||||
self.provider
|
||||
.account_facts_changed(std::slice::from_ref(&result.account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).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,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! 插件宿主账号回调;复用凭据事务,不建立第二套账号权威。
|
||||
|
||||
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, PluginAccountSaveReason,
|
||||
PluginAccountSaveResult, PreparedCredentialImport, PreparedPluginAccountSave,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
plugin_accounts::PluginAccountAccess, provider::ProviderAdminRegistry, store::AccountStore,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{
|
||||
map_provider_error, map_store_error, publish_committed_deferred,
|
||||
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<crate::ports::provider_extensions::ProviderAdminHandle, 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,
|
||||
reason,
|
||||
} => {
|
||||
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 = if reason == PluginAccountSaveReason::Explicit {
|
||||
Some(self.provider(&facts.provider_kind)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let command = CredentialRotationCommit {
|
||||
prepared: facts,
|
||||
settings: None,
|
||||
};
|
||||
let result = match reason {
|
||||
PluginAccountSaveReason::Explicit => {
|
||||
self.accounts
|
||||
.commit_credential_rotation(command, context)
|
||||
.await
|
||||
}
|
||||
PluginAccountSaveReason::ModelDiscovery
|
||||
| PluginAccountSaveReason::Maintenance => {
|
||||
self.accounts
|
||||
.commit_credential_refresh(command, context)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|error| map_store_error(error, "plugin account save"))?;
|
||||
let credential_revision = result
|
||||
.credential_revision
|
||||
.ok_or_else(|| AdminError::internal("账号保存未返回凭据版本"))?;
|
||||
if let Some(provider) = provider {
|
||||
publish_credentials_and_observe_quota(
|
||||
&provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&result.account_id),
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
// 模型发现可能位于快照编译内,后台维护还持有刷新租约;
|
||||
// 只合并发布,由 Runtime 当前调用失效目录,避免重入准备或刷新锁。
|
||||
publish_committed_deferred(self.snapshot.as_ref(), result.config_revision)
|
||||
.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,786 @@
|
||||
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(),
|
||||
retained_continuations: 0,
|
||||
}
|
||||
});
|
||||
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(expected_revision, 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(result.config_revision, 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(original_revision, 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(expected_revision, 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(result.config_revision, 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(disabled_revision, 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(mutation.config_revision, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ pub trait SettingsService: Send + Sync {
|
||||
context: &MutationContext,
|
||||
command: crate::model::pricing::UpdatePricing,
|
||||
) -> Result<(), AdminError>;
|
||||
async fn client_profile_providers(
|
||||
&self,
|
||||
) -> Result<Vec<gateway_core::routing::ProviderKind>, AdminError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn client_profile_options(
|
||||
&self,
|
||||
_provider: &str,
|
||||
@@ -78,7 +83,7 @@ impl DefaultSettingsService {
|
||||
fn profile_provider(
|
||||
&self,
|
||||
provider: &str,
|
||||
) -> Result<Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
|
||||
) -> Result<crate::ports::provider_extensions::ProviderAdminHandle, AdminError> {
|
||||
let kind = gateway_core::routing::ProviderKind::new(provider)
|
||||
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
|
||||
self.providers
|
||||
@@ -129,6 +134,17 @@ impl SettingsService for DefaultSettingsService {
|
||||
{
|
||||
return Err(AdminError::invalid("请选择 1 至 10000 个模型"));
|
||||
}
|
||||
let providers = self
|
||||
.providers
|
||||
.pricing_catalog()
|
||||
.map_err(|error| super::map_provider_error(error, "provider pricing"))?;
|
||||
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(
|
||||
@@ -174,7 +190,10 @@ impl SettingsService for DefaultSettingsService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "model pricing"))?;
|
||||
Ok(crate::model::pricing::PricingCatalog {
|
||||
defaults: self.providers.pricing_catalog(),
|
||||
defaults: self
|
||||
.providers
|
||||
.pricing_catalog()
|
||||
.map_err(|error| super::map_provider_error(error, "provider pricing"))?,
|
||||
overrides: stored.overrides,
|
||||
synced: stored.synced,
|
||||
synced_at: stored.synced_at,
|
||||
@@ -258,12 +277,20 @@ impl SettingsService for DefaultSettingsService {
|
||||
publish_committed(self.snapshot.as_ref(), revision).await
|
||||
}
|
||||
|
||||
async fn client_profile_providers(
|
||||
&self,
|
||||
) -> Result<Vec<gateway_core::routing::ProviderKind>, AdminError> {
|
||||
self.providers
|
||||
.client_profile_providers()
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))
|
||||
}
|
||||
|
||||
async fn client_profile_options(
|
||||
&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 +299,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 +323,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 +345,41 @@ 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
|
||||
.freeze()
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
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"))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -141,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);
|
||||
|
||||
@@ -29,20 +29,21 @@ use gateway_admin::{
|
||||
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_capabilities::ProviderCredentialCapabilities,
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, AuthorizationCommitGuard, AuthorizationCredentialCommit,
|
||||
AuthorizationMutationTarget, AuthorizationStarted, CompleteAuthorization,
|
||||
ConsumeProviderResetCredit, CredentialCommitGuard, CredentialDetails,
|
||||
CredentialImportCommit, CredentialImportResult, CredentialMutationResult,
|
||||
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
|
||||
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileActivityInsights, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription,
|
||||
QuotaLocalUsageAttribution,
|
||||
CredentialRotationCommit, PendingAuthorizationMutation, PluginAccountSaveReason,
|
||||
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
|
||||
PreparedAuthorizationCommit, PreparedAuthorizationCredential, PreparedCredentialCreate,
|
||||
PreparedCredentialImport, PreparedCredentialRotation, PreparedCredentialRotationFacts,
|
||||
PreparedPluginAccountSave, ProviderDocument, ProviderExport,
|
||||
ProviderExportCredentialInput, ProviderModels, ProviderProfileActivityInsights,
|
||||
ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
|
||||
ProviderSubscription, QuotaLocalUsageAttribution,
|
||||
},
|
||||
quota_forecast_sampling::{QuotaForecastHistory, QuotaForecastUsage},
|
||||
settings::{
|
||||
@@ -65,12 +66,20 @@ pub(super) type EventLog = Arc<Mutex<Vec<&'static str>>>;
|
||||
|
||||
pub(super) struct FakeProviderAdmin {
|
||||
kind: ProviderKind,
|
||||
credential_capabilities: Mutex<ProviderCredentialCapabilities>,
|
||||
capabilities: Mutex<gateway_admin::model::provider_capabilities::ProviderAccountCapabilities>,
|
||||
events: EventLog,
|
||||
failure: Mutex<Option<ProviderAdminError>>,
|
||||
import_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
|
||||
quota_failure: Mutex<Option<ProviderAdminErrorKind>>,
|
||||
pending: Arc<Mutex<Option<PendingAuthorizationMutation>>>,
|
||||
retry_authorization_after_abort: Mutex<bool>,
|
||||
authorization_accounts: Mutex<Option<Vec<PreparedCredentialCreate>>>,
|
||||
authorization_polls: Mutex<
|
||||
std::collections::VecDeque<
|
||||
gateway_admin::model::provider_credentials::PreparedAuthorizationPoll,
|
||||
>,
|
||||
>,
|
||||
export_inputs: Mutex<Vec<ProviderExportCredentialInput>>,
|
||||
import_account_ids: Mutex<Vec<String>>,
|
||||
quota_requests: Mutex<Vec<ProviderQuotaRequest>>,
|
||||
@@ -86,15 +95,33 @@ pub(super) struct FakeProviderAdmin {
|
||||
}
|
||||
|
||||
impl FakeProviderAdmin {
|
||||
pub(super) fn set_authorization_accounts(&self, accounts: Vec<PreparedCredentialCreate>) {
|
||||
*self.authorization_accounts.lock().unwrap() = Some(accounts);
|
||||
}
|
||||
|
||||
pub(super) fn queue_authorization_poll(
|
||||
&self,
|
||||
result: gateway_admin::model::provider_credentials::PreparedAuthorizationPoll,
|
||||
) {
|
||||
self.authorization_polls.lock().unwrap().push_back(result);
|
||||
}
|
||||
|
||||
pub(super) fn new(kind: &str, events: EventLog) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
credential_capabilities: Mutex::new(ProviderCredentialCapabilities {
|
||||
export: true,
|
||||
..Default::default()
|
||||
}),
|
||||
capabilities: Mutex::default(),
|
||||
events,
|
||||
failure: Mutex::new(None),
|
||||
import_gate: Mutex::new(None),
|
||||
quota_failure: Mutex::new(None),
|
||||
pending: Arc::new(Mutex::new(None)),
|
||||
retry_authorization_after_abort: Mutex::new(false),
|
||||
authorization_accounts: Mutex::new(None),
|
||||
authorization_polls: Mutex::default(),
|
||||
export_inputs: Mutex::new(Vec::new()),
|
||||
import_account_ids: Mutex::new(vec!["acct_prepared".to_owned()]),
|
||||
quota_requests: Mutex::new(Vec::new()),
|
||||
@@ -258,6 +285,17 @@ impl FakeProviderAdmin {
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdmin for FakeProviderAdmin {
|
||||
fn credential_capabilities(&self) -> ProviderCredentialCapabilities {
|
||||
self.credential_capabilities.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn account_capabilities(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
_authentication_kind: &str,
|
||||
) -> gateway_admin::model::provider_capabilities::ProviderAccountCapabilities {
|
||||
*self.capabilities.lock().unwrap()
|
||||
}
|
||||
fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.kind
|
||||
}
|
||||
@@ -300,7 +338,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,8 +402,9 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
pending: PendingAuthorizationMutation,
|
||||
command: gateway_admin::model::provider_credentials::PrepareAuthorization,
|
||||
) -> Result<AuthorizationStarted, ProviderAdminError> {
|
||||
let pending = command.pending;
|
||||
self.record("provider.start_authorization");
|
||||
self.require_available()?;
|
||||
*self.pending.lock().expect("pending authorization") = Some(pending);
|
||||
@@ -398,12 +437,20 @@ 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(
|
||||
self.authorization_accounts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.unwrap_or_else(|| vec![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);
|
||||
@@ -419,6 +466,22 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
})
|
||||
}
|
||||
|
||||
async fn poll_authorization(
|
||||
&self,
|
||||
_command: gateway_admin::model::provider_credentials::PollAuthorization,
|
||||
) -> Result<
|
||||
gateway_admin::model::provider_credentials::PreparedAuthorizationPoll,
|
||||
ProviderAdminError,
|
||||
> {
|
||||
self.record("provider.poll_authorization");
|
||||
self.require_available()?;
|
||||
self.authorization_polls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.ok_or_else(unsupported)
|
||||
}
|
||||
|
||||
async fn prepare_rotation(
|
||||
&self,
|
||||
command: PrepareCredentialRotation,
|
||||
@@ -539,6 +602,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::CredentialBatchMutationResult,
|
||||
>,
|
||||
>,
|
||||
audit_requests: Mutex<Vec<String>>,
|
||||
import_settings: Mutex<Vec<Option<gateway_admin::model::accounts::AccountImportSettings>>>,
|
||||
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
|
||||
@@ -559,6 +628,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 +724,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,
|
||||
@@ -664,6 +745,12 @@ impl AccountStore for FakeAccountStore {
|
||||
let total = accounts.len() as u64;
|
||||
Ok(AccountPage {
|
||||
config_revision: revision(1),
|
||||
providers: accounts
|
||||
.iter()
|
||||
.map(|account| account.provider_kind.to_string())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect(),
|
||||
items: accounts.into_iter().map(Self::page_item).collect(),
|
||||
total,
|
||||
summary: AccountSummary {
|
||||
@@ -756,6 +843,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 +889,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,26 +934,65 @@ 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()?;
|
||||
let (account_id, credential_revision) = match command.credential {
|
||||
AuthorizationCredentialCommit::Create(credential) => (credential.account_id, None),
|
||||
AuthorizationCredentialCommit::Reauthorize(credential) => (
|
||||
credential.account_id,
|
||||
Some(revision(credential.expected_credential_revision.get() + 1)),
|
||||
),
|
||||
let accounts = match command.credential {
|
||||
AuthorizationCredentialCommit::Create(credentials) => credentials
|
||||
.into_iter()
|
||||
.map(
|
||||
|credential| gateway_admin::model::provider_credentials::AuthorizedAccount {
|
||||
account_id: credential.account_id,
|
||||
credential_revision: None,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
AuthorizationCredentialCommit::Reauthorize(credential) => vec![
|
||||
gateway_admin::model::provider_credentials::AuthorizedAccount {
|
||||
account_id: credential.account_id,
|
||||
credential_revision: Some(revision(
|
||||
credential.expected_credential_revision.get() + 1,
|
||||
)),
|
||||
},
|
||||
],
|
||||
};
|
||||
Ok(CredentialMutationResult {
|
||||
let result = gateway_admin::model::provider_credentials::CredentialBatchMutationResult {
|
||||
config_revision: revision(2),
|
||||
account_id,
|
||||
credential_revision,
|
||||
})
|
||||
accounts,
|
||||
};
|
||||
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<gateway_admin::model::provider_credentials::CredentialBatchMutationResult>,
|
||||
> {
|
||||
Ok(self.receipts.lock().unwrap().get(key).cloned())
|
||||
}
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
@@ -997,8 +1159,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),
|
||||
@@ -1258,6 +1419,66 @@ async fn connection_test_should_preserve_disabled_account_status() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_export_should_reject_unsupported_before_loading_secrets() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("local-echo", events.clone());
|
||||
provider.credential_capabilities.lock().unwrap().export = false;
|
||||
let store = FakeAccountStore::new("local-echo", events.clone());
|
||||
let error = accounts_service(provider, store)
|
||||
.await
|
||||
.accounts()
|
||||
.export(
|
||||
&context("unsupported-export"),
|
||||
vec![ProviderAccountId::new("acct_test").unwrap()],
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid);
|
||||
assert_eq!(recorded(&events), ["store.load_account"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_export_should_validate_all_providers_before_loading_any_secret() {
|
||||
for registered in [true, false] {
|
||||
let events = events();
|
||||
let native = FakeProviderAdmin::new("openai", events.clone());
|
||||
let plugin = FakeProviderAdmin::new("z-plugin", events.clone());
|
||||
plugin.credential_capabilities.lock().unwrap().export = false;
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let mut other = account_record("z-plugin");
|
||||
other.id = "acct_plugin".to_owned();
|
||||
store.accounts.lock().unwrap().push(other);
|
||||
let mut harness = super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.provider(native);
|
||||
if registered {
|
||||
harness = harness.provider(plugin);
|
||||
}
|
||||
let error = harness
|
||||
.build()
|
||||
.await
|
||||
.accounts()
|
||||
.export(
|
||||
&context("mixed-provider-export"),
|
||||
vec![
|
||||
ProviderAccountId::new("acct_test").unwrap(),
|
||||
ProviderAccountId::new("acct_plugin").unwrap(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid);
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
["store.load_account", "store.load_account"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_export_should_pass_store_loaded_timestamps_and_material_to_provider() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
@@ -1321,6 +1542,162 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() {
|
||||
assert_eq!(store.audit_requests(), ["refresh-request"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_account_saves_reuse_commits_and_defer_publication_inside_runtime_calls() {
|
||||
for (reason, commit_event, request_id) in [
|
||||
(
|
||||
PluginAccountSaveReason::ModelDiscovery,
|
||||
"store.commit_refresh",
|
||||
"plugin-model-discovery",
|
||||
),
|
||||
(
|
||||
PluginAccountSaveReason::Maintenance,
|
||||
"store.commit_refresh",
|
||||
"plugin-maintenance",
|
||||
),
|
||||
(
|
||||
PluginAccountSaveReason::Explicit,
|
||||
"store.commit_rotation",
|
||||
"plugin-explicit-save",
|
||||
),
|
||||
] {
|
||||
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(),
|
||||
reason,
|
||||
},
|
||||
&context(request_id),
|
||||
)
|
||||
.await
|
||||
.expect("save prepared plugin account facts");
|
||||
if reason == PluginAccountSaveReason::Explicit {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
provider.quota_started.notified(),
|
||||
)
|
||||
.await
|
||||
.expect("start post-commit quota observation");
|
||||
} else {
|
||||
// Runtime 内保存时仍持有编译或刷新资源,不能重入 Provider/额度查询。
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
assert_eq!(result.account_id.as_str(), "acct_test");
|
||||
assert_eq!(result.credential_revision, revision(2));
|
||||
assert_eq!(store.audit_requests(), [request_id]);
|
||||
let mut expected = vec!["store.credential_details", commit_event];
|
||||
if reason == PluginAccountSaveReason::Explicit {
|
||||
expected.extend([
|
||||
"provider.account_facts_changed",
|
||||
"snapshot.publish_committed",
|
||||
"provider.quota",
|
||||
]);
|
||||
} else {
|
||||
expected.push("snapshot.publish_committed_deferred");
|
||||
}
|
||||
assert_eq!(recorded(&events), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
})
|
||||
}
|
||||
|
||||
fn publish_committed_deferred(
|
||||
&self,
|
||||
revision: gateway_core::routing::ConfigRevision,
|
||||
) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(revision.get(), 2);
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("snapshot.publish_committed_deferred");
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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(),
|
||||
reason: PluginAccountSaveReason::Explicit,
|
||||
},
|
||||
&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();
|
||||
@@ -1644,6 +2021,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::provider_capabilities::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());
|
||||
@@ -1665,6 +2048,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,
|
||||
@@ -1690,6 +2074,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);
|
||||
}
|
||||
|
||||
@@ -2039,6 +2424,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));
|
||||
@@ -2069,6 +2459,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;
|
||||
@@ -2530,6 +2984,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();
|
||||
@@ -2710,7 +3210,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,
|
||||
@@ -2747,6 +3247,23 @@ 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,
|
||||
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()),
|
||||
@@ -2935,6 +3452,7 @@ impl AccountProbe for SuccessfulAccountProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Ok(AccountProbeResult {
|
||||
@@ -2950,6 +3468,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(
|
||||
@@ -2979,6 +3498,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"));
|
||||
@@ -3022,3 +3542,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,388 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_admin::model::{
|
||||
AdminError, AdminErrorKind,
|
||||
provider_credentials::{
|
||||
AuthorizationCommitGuard, AuthorizationMutationTarget, AuthorizationOwnerBinding,
|
||||
AuthorizationPollResult, PendingAuthorizationMutation, PollAuthorization,
|
||||
PreparedAuthorizationCommit, PreparedAuthorizationCredential, PreparedAuthorizationPoll,
|
||||
},
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
|
||||
use super::super::{
|
||||
AdminHarness,
|
||||
accounts::{
|
||||
EventLog, FakeAccountStore, FakeProviderAdmin, context, events, prepared_create_with_id,
|
||||
recorded,
|
||||
},
|
||||
};
|
||||
|
||||
struct Claim(EventLog);
|
||||
|
||||
struct FailedConsumptionClaim(EventLog);
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_receipts_prevent_replaying_upstream_authorization() {
|
||||
let log = events();
|
||||
let kind = ProviderKind::new("example").unwrap();
|
||||
let services = AdminHarness::new()
|
||||
.provider(FakeProviderAdmin::new("example", log.clone()))
|
||||
.build()
|
||||
.await;
|
||||
let error = services
|
||||
.credentials()
|
||||
.poll_authorization(&kind, poll())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Unavailable);
|
||||
assert!(recorded(&log).is_empty());
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthorizationCommitGuard for FailedConsumptionClaim {
|
||||
async fn commit(self: Box<Self>) -> Result<(), AdminError> {
|
||||
self.0.lock().unwrap().push("claim.consume_failed");
|
||||
Err(AdminError::unavailable("injected pending store failure"))
|
||||
}
|
||||
|
||||
async fn abort(self: Box<Self>) -> Result<(), AdminError> {
|
||||
panic!("a committed authorization must not be aborted")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_authorization_survives_cleanup_failure_and_does_not_require_a_provider_on_retry()
|
||||
{
|
||||
let log = events();
|
||||
let kind = ProviderKind::new("example").unwrap();
|
||||
let provider = FakeProviderAdmin::new("example", log.clone());
|
||||
let PreparedAuthorizationPoll::Complete(prepared) = completed(&log, &["acct_receipt"]) else {
|
||||
unreachable!()
|
||||
};
|
||||
provider.queue_authorization_poll(PreparedAuthorizationPoll::Complete(Box::new(
|
||||
prepared.with_authorization_guard(Box::new(FailedConsumptionClaim(log.clone()))),
|
||||
)));
|
||||
let store = FakeAccountStore::new("example", log.clone());
|
||||
let services = AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store.clone())
|
||||
.build()
|
||||
.await;
|
||||
let original = services
|
||||
.credentials()
|
||||
.poll_authorization(&kind, poll())
|
||||
.await
|
||||
.unwrap();
|
||||
// 只挂载同一个持久化端口,不再注册 Provider,证明恢复不重放任何插件操作。
|
||||
let restarted = AdminHarness::new().accounts(store.clone()).build().await;
|
||||
assert_eq!(
|
||||
restarted
|
||||
.credentials()
|
||||
.poll_authorization(&kind, poll())
|
||||
.await
|
||||
.unwrap(),
|
||||
original
|
||||
);
|
||||
assert_eq!(store.audit_requests().len(), 1);
|
||||
assert_eq!(
|
||||
recorded(&log)
|
||||
.iter()
|
||||
.filter(|event| **event == "provider.poll_authorization")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(recorded(&log).contains(&"claim.consume_failed"));
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthorizationCommitGuard for Claim {
|
||||
async fn commit(self: Box<Self>) -> Result<(), AdminError> {
|
||||
self.0.lock().unwrap().push("claim.commit");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn abort(self: Box<Self>) -> Result<(), AdminError> {
|
||||
self.0.lock().unwrap().push("claim.abort");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn poll() -> PollAuthorization {
|
||||
PollAuthorization {
|
||||
context: context("poll"),
|
||||
flow_id: "flow-test".into(),
|
||||
callback_url: None,
|
||||
settings: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn completed(log: &EventLog, ids: &[&str]) -> PreparedAuthorizationPoll {
|
||||
let kind = ProviderKind::new("example").unwrap();
|
||||
PreparedAuthorizationPoll::Complete(Box::new(
|
||||
PreparedAuthorizationCommit::new(
|
||||
PendingAuthorizationMutation::new(
|
||||
kind.clone(),
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: "login".into(),
|
||||
},
|
||||
AuthorizationOwnerBinding::from_context(&context("start")),
|
||||
),
|
||||
PreparedAuthorizationCredential::Create(
|
||||
ids.iter()
|
||||
.map(|id| prepared_create_with_id(kind.clone(), id, "account"))
|
||||
.collect(),
|
||||
),
|
||||
)
|
||||
.with_authorization_guard(Box::new(Claim(log.clone()))),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_poll_never_commits_or_observes_accounts() {
|
||||
let log = events();
|
||||
let provider = FakeProviderAdmin::new("example", log.clone());
|
||||
provider.queue_authorization_poll(PreparedAuthorizationPoll::Pending {
|
||||
retry_after: Duration::from_secs(5),
|
||||
});
|
||||
let store = FakeAccountStore::new("example", log.clone());
|
||||
let services = AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store.clone())
|
||||
.build()
|
||||
.await;
|
||||
let result = services
|
||||
.credentials()
|
||||
.poll_authorization(&ProviderKind::new("example").unwrap(), poll())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
AuthorizationPollResult::Pending {
|
||||
retry_after: Duration::from_secs(5)
|
||||
}
|
||||
);
|
||||
assert_eq!(recorded(&log), ["provider.poll_authorization"]);
|
||||
assert!(store.audit_requests().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_account_poll_commits_once_then_consumes_claim_and_publishes_all_accounts() {
|
||||
let log = events();
|
||||
let provider = FakeProviderAdmin::new("example", log.clone());
|
||||
provider.queue_authorization_poll(completed(&log, &["acct_one", "acct_two"]));
|
||||
let store = FakeAccountStore::new("example", log.clone());
|
||||
let services = AdminHarness::new()
|
||||
.provider(provider.clone())
|
||||
.accounts(store.clone())
|
||||
.build()
|
||||
.await;
|
||||
let result = services
|
||||
.credentials()
|
||||
.poll_authorization(&ProviderKind::new("example").unwrap(), poll())
|
||||
.await
|
||||
.unwrap();
|
||||
let AuthorizationPollResult::Complete(result) = result else {
|
||||
panic!("expected completed login")
|
||||
};
|
||||
assert_eq!(
|
||||
result
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|account| account.account_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["acct_one", "acct_two"]
|
||||
);
|
||||
provider.wait_for_quota_requests(2).await;
|
||||
assert_eq!(
|
||||
&recorded(&log)[..4],
|
||||
[
|
||||
"provider.poll_authorization",
|
||||
"store.commit_authorization",
|
||||
"claim.commit",
|
||||
"provider.account_facts_changed"
|
||||
]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["poll"]);
|
||||
assert_eq!(provider.quota_requests().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_batch_commit_releases_claim_without_publishing_partial_success() {
|
||||
let log = events();
|
||||
let provider = FakeProviderAdmin::new("example", log.clone());
|
||||
provider.queue_authorization_poll(completed(&log, &["acct_one", "acct_two"]));
|
||||
let store = FakeAccountStore::new("example", log.clone());
|
||||
store.fail_next_commit();
|
||||
let services = AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store)
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.credentials()
|
||||
.poll_authorization(&ProviderKind::new("example").unwrap(), poll())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
recorded(&log),
|
||||
[
|
||||
"provider.poll_authorization",
|
||||
"store.commit_authorization",
|
||||
"claim.abort"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_account_collections_release_claim_before_store() {
|
||||
for ids in [vec![], vec!["acct_one", "acct_one"], vec!["acct_one"; 201]] {
|
||||
let log = events();
|
||||
let provider = FakeProviderAdmin::new("example", log.clone());
|
||||
provider.queue_authorization_poll(completed(&log, &ids));
|
||||
let store = FakeAccountStore::new("example", log.clone());
|
||||
let services = AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store)
|
||||
.build()
|
||||
.await;
|
||||
let error = services
|
||||
.credentials()
|
||||
.poll_authorization(&ProviderKind::new("example").unwrap(), poll())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(
|
||||
recorded(&log),
|
||||
["provider.poll_authorization", "claim.abort"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forged_poll_owner_or_provider_cannot_commit_accounts() {
|
||||
for wrong_owner in [true, false] {
|
||||
let log = events();
|
||||
let provider = FakeProviderAdmin::new("example", log.clone());
|
||||
let PreparedAuthorizationPoll::Complete(mut prepared) = completed(&log, &["acct_one"])
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
if wrong_owner {
|
||||
let mut foreign = context("foreign-start");
|
||||
foreign.actor = gateway_admin::model::MutationActor::AdminSession {
|
||||
admin_user_id: "different-admin".into(),
|
||||
};
|
||||
prepared.pending = PendingAuthorizationMutation::new(
|
||||
ProviderKind::new("example").unwrap(),
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: "login".into(),
|
||||
},
|
||||
AuthorizationOwnerBinding::from_context(&foreign),
|
||||
);
|
||||
} else {
|
||||
let PreparedAuthorizationCredential::Create(accounts) = &mut prepared.credential else {
|
||||
unreachable!()
|
||||
};
|
||||
accounts[0].provider_kind = ProviderKind::new("another").unwrap();
|
||||
}
|
||||
provider.queue_authorization_poll(PreparedAuthorizationPoll::Complete(prepared));
|
||||
let store = FakeAccountStore::new("example", log.clone());
|
||||
let services = AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store)
|
||||
.build()
|
||||
.await;
|
||||
let error = services
|
||||
.credentials()
|
||||
.poll_authorization(&ProviderKind::new("example").unwrap(), poll())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(
|
||||
recorded(&log),
|
||||
["provider.poll_authorization", "claim.abort"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_poll_requires_a_bounded_positive_interval() {
|
||||
for retry_after in [Duration::ZERO, Duration::from_secs(301)] {
|
||||
let log = events();
|
||||
let provider = FakeProviderAdmin::new("example", log.clone());
|
||||
provider.queue_authorization_poll(PreparedAuthorizationPoll::Pending { retry_after });
|
||||
let store = FakeAccountStore::new("example", log.clone());
|
||||
let services = AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store)
|
||||
.build()
|
||||
.await;
|
||||
let error = services
|
||||
.credentials()
|
||||
.poll_authorization(&ProviderKind::new("example").unwrap(), poll())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Internal);
|
||||
assert_eq!(recorded(&log), ["provider.poll_authorization"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_account_completion_rejects_multiple_accounts_before_commit() {
|
||||
let log = events();
|
||||
let kind = ProviderKind::new("example").unwrap();
|
||||
let provider = FakeProviderAdmin::new("example", log.clone());
|
||||
provider.retry_authorization_after_abort();
|
||||
provider.set_authorization_accounts(vec![
|
||||
prepared_create_with_id(kind.clone(), "acct_one", "one"),
|
||||
prepared_create_with_id(kind.clone(), "acct_two", "two"),
|
||||
]);
|
||||
let store = FakeAccountStore::new("example", log.clone());
|
||||
let services = AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store.clone())
|
||||
.build()
|
||||
.await;
|
||||
let credentials = services.credentials().for_provider(&kind).unwrap();
|
||||
credentials
|
||||
.start_authorization(
|
||||
gateway_admin::model::provider_credentials::StartAuthorization {
|
||||
input: gateway_admin::model::provider_credentials::ProviderDocument::new(
|
||||
gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()),
|
||||
),
|
||||
context: context("start"),
|
||||
name: "login".into(),
|
||||
reauthorization: None,
|
||||
outbound_proxy: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = services
|
||||
.credentials()
|
||||
.complete_authorization(
|
||||
&kind,
|
||||
gateway_admin::model::provider_credentials::CompleteAuthorization {
|
||||
context: context("complete"),
|
||||
flow_id: "flow-test".into(),
|
||||
callback_url: "https://example.invalid/callback".into(),
|
||||
settings: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
assert_eq!(
|
||||
recorded(&log),
|
||||
[
|
||||
"provider.start_authorization",
|
||||
"provider.complete_authorization",
|
||||
"authorization_guard.abort"
|
||||
]
|
||||
);
|
||||
assert!(store.audit_requests().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod flow;
|
||||
mod openai;
|
||||
mod xai;
|
||||
+103
-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,8 +214,13 @@ 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 {
|
||||
input: gateway_admin::model::provider_credentials::ProviderDocument::new(
|
||||
gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()),
|
||||
),
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-start-openai-create"),
|
||||
name: "new OpenAI credential".to_owned(),
|
||||
@@ -214,13 +229,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 +264,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,8 +278,13 @@ 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 {
|
||||
input: gateway_admin::model::provider_credentials::ProviderDocument::new(
|
||||
gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()),
|
||||
),
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-start-openai-quota-failure"),
|
||||
name: "new OpenAI credential".to_owned(),
|
||||
@@ -270,13 +293,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,8 +328,13 @@ 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 {
|
||||
input: gateway_admin::model::provider_credentials::ProviderDocument::new(
|
||||
gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()),
|
||||
),
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-store-retry"),
|
||||
name: "new OpenAI credential".to_owned(),
|
||||
@@ -312,13 +343,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 +387,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,8 +409,13 @@ 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 {
|
||||
input: gateway_admin::model::provider_credentials::ProviderDocument::new(
|
||||
gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()),
|
||||
),
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-start-openai"),
|
||||
name: "reauthorize".to_owned(),
|
||||
@@ -396,13 +440,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 +474,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,8 +495,13 @@ 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 {
|
||||
input: gateway_admin::model::provider_credentials::ProviderDocument::new(
|
||||
gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()),
|
||||
),
|
||||
outbound_proxy: None,
|
||||
context: context("reauthorization-settings"),
|
||||
name: "existing account".to_owned(),
|
||||
@@ -458,13 +510,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);
|
||||
+50
-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,8 +135,13 @@ 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 {
|
||||
input: gateway_admin::model::provider_credentials::ProviderDocument::new(
|
||||
gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()),
|
||||
),
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-xai-create"),
|
||||
name: "new xAI credential".to_owned(),
|
||||
@@ -137,13 +150,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 +202,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 +238,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 +258,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),
|
||||
@@ -201,6 +200,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 +216,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::CredentialBatchMutationResult>,
|
||||
> {
|
||||
Err(unavailable("authorization receipt"))
|
||||
}
|
||||
|
||||
async fn commit_authorization(
|
||||
&self,
|
||||
_: AuthorizationCommit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
) -> AdminStoreResult<gateway_admin::model::provider_credentials::AuthorizationCommitResult>
|
||||
{
|
||||
Err(unavailable("authorization"))
|
||||
}
|
||||
|
||||
@@ -682,6 +738,10 @@ impl ObservabilityStore for UnavailableStore {
|
||||
Err(unavailable("usage records"))
|
||||
}
|
||||
|
||||
async fn usage_provider_kinds(&self, _: TimeRange) -> AdminStoreResult<Vec<String>> {
|
||||
Err(unavailable("usage providers"))
|
||||
}
|
||||
|
||||
async fn usage_record_detail(&self, _: &str) -> AdminStoreResult<UsageDetail> {
|
||||
Err(unavailable("usage detail"))
|
||||
}
|
||||
@@ -784,6 +844,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 +856,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 +887,7 @@ impl ProviderAdmin for UnavailableProvider {
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
_: PendingAuthorizationMutation,
|
||||
_: gateway_admin::model::provider_credentials::PrepareAuthorization,
|
||||
) -> Result<AuthorizationStarted, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
@@ -955,6 +1019,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 +1050,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 +1059,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())
|
||||
}
|
||||
|
||||
|
||||
@@ -750,6 +750,10 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
))
|
||||
}
|
||||
|
||||
async fn usage_provider_kinds(&self, _: TimeRange) -> AdminStoreResult<Vec<String>> {
|
||||
Ok(vec!["retired-provider".into()])
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
let items = self.usage_records.lock().expect("usage records").clone();
|
||||
let total = u64::try_from(items.len()).unwrap_or(u64::MAX);
|
||||
@@ -808,8 +812,7 @@ 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"),
|
||||
|
||||
@@ -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,439 @@
|
||||
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,
|
||||
source_revision: Revision,
|
||||
_: PluginInstanceSnapshot,
|
||||
) -> Result<ExtensionSetReference, AdminError> {
|
||||
if source_revision != self.data.lock().unwrap().snapshot.config_revision {
|
||||
return Err(AdminError::conflict("fixture configuration changed"));
|
||||
}
|
||||
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,766 @@
|
||||
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<ProxyRecord> {
|
||||
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 {
|
||||
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,404 @@
|
||||
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::ProxyRecord> {
|
||||
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,
|
||||
_: Revision,
|
||||
_: 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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -147,15 +147,28 @@ async fn authorization_uses_selected_proxy_regardless_of_probe_status() {
|
||||
.build()
|
||||
.await;
|
||||
let command = StartAuthorization {
|
||||
input: gateway_admin::model::provider_credentials::ProviderDocument::new(
|
||||
gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()),
|
||||
),
|
||||
outbound_proxy: Some(AccountProxySelection::Saved("proxy_oauth".to_owned())),
|
||||
context: context("oauth-proxy-status"),
|
||||
name: "授权账号".to_owned(),
|
||||
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_registered_plugin_provider_and_rejects_unknown_sync_provider() {
|
||||
let store = Arc::new(PricingSettingsStore::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.settings(store.clone())
|
||||
.provider(super::test_provider("example-plugin"))
|
||||
.build()
|
||||
.await;
|
||||
let context = MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::System,
|
||||
request_id: "request-plugin-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("plugin pricing");
|
||||
let update = UpdatePricing {
|
||||
provider: "example-plugin".to_owned(),
|
||||
models: vec!["example-model".to_owned()],
|
||||
change: PricingChange::Replace(pricing),
|
||||
};
|
||||
|
||||
services
|
||||
.settings()
|
||||
.update_pricing(&context, update.clone())
|
||||
.await
|
||||
.expect("registered plugin 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(),
|
||||
|
||||
@@ -54,6 +54,7 @@ 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 {
|
||||
@@ -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"] }
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! 已发布 Provider 的账号能力;响应只包含静态 schema,不包含配置或凭据。
|
||||
|
||||
use gateway_admin::model::provider_capabilities::{
|
||||
AuthorizationCompletion, ProviderCredentialDescriptor,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountProviderData {
|
||||
pub provider: String,
|
||||
pub credentials: AccountCredentialCapabilitiesData,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountCredentialCapabilitiesData {
|
||||
pub import: Option<Value>,
|
||||
pub login: Option<AccountLoginCapabilityData>,
|
||||
pub refresh: bool,
|
||||
pub export: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountLoginCapabilityData {
|
||||
pub input_schema: Value,
|
||||
pub completion: AccountAuthorizationCompletion,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccountAuthorizationCompletion {
|
||||
Callback,
|
||||
Poll,
|
||||
}
|
||||
|
||||
impl From<ProviderCredentialDescriptor> for AccountProviderData {
|
||||
fn from(value: ProviderCredentialDescriptor) -> Self {
|
||||
let capabilities = value.capabilities;
|
||||
Self {
|
||||
provider: value.provider.as_str().to_owned(),
|
||||
credentials: AccountCredentialCapabilitiesData {
|
||||
import: capabilities.import,
|
||||
login: capabilities.login.map(|login| AccountLoginCapabilityData {
|
||||
input_schema: login.input_schema,
|
||||
completion: match login.completion {
|
||||
AuthorizationCompletion::Callback => {
|
||||
AccountAuthorizationCompletion::Callback
|
||||
}
|
||||
AuthorizationCompletion::Poll => AccountAuthorizationCompletion::Poll,
|
||||
},
|
||||
}),
|
||||
refresh: capabilities.refresh,
|
||||
export: capabilities.export,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn providers<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: crate::auth::SessionState + Send + Sync,
|
||||
{
|
||||
let descriptors = state
|
||||
.admin_services()
|
||||
.credentials()
|
||||
.providers()
|
||||
.map_err(map_service_error)?;
|
||||
let data: Vec<_> = descriptors
|
||||
.into_iter()
|
||||
.map(AccountProviderData::from)
|
||||
.collect();
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
@@ -14,20 +14,8 @@ fn validate_account_notes(notes: Option<&str>) -> Result<(), WireValidationError
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum AccountProvider {
|
||||
OpenAi,
|
||||
Xai,
|
||||
}
|
||||
|
||||
impl AccountProvider {
|
||||
fn parse(value: &str) -> Result<Self, WireValidationError> {
|
||||
match value.trim() {
|
||||
"openai" => Ok(Self::OpenAi),
|
||||
"xai" => Ok(Self::Xai),
|
||||
_ => Err(WireValidationError::new("provider")),
|
||||
}
|
||||
}
|
||||
fn parse_provider(value: &str) -> Result<ProviderKind, WireValidationError> {
|
||||
ProviderKind::new(value.trim().to_owned()).map_err(|_| WireValidationError::new("provider"))
|
||||
}
|
||||
|
||||
/// 导入统一设置,复用编辑账号的备注、调度和分组约束。
|
||||
@@ -84,7 +72,7 @@ impl AccountImportRequest {
|
||||
if let Some(settings) = &self.settings {
|
||||
settings.validate()?;
|
||||
}
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
parse_provider(&self.provider)?;
|
||||
if !self.data.is_object()
|
||||
|| serde_json::to_vec(&self.data)
|
||||
.map_or(true, |encoded| encoded.len() > MAX_IMPORT_DATA_BYTES)
|
||||
@@ -97,9 +85,9 @@ impl AccountImportRequest {
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<(AccountProvider, ImportCredentials), WireValidationError> {
|
||||
) -> Result<(ProviderKind, ImportCredentials), WireValidationError> {
|
||||
self.validate()?;
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
let provider = parse_provider(&self.provider)?;
|
||||
Ok((
|
||||
provider,
|
||||
ImportCredentials {
|
||||
@@ -115,7 +103,7 @@ impl AccountImportRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct StartAccountAuthorizationRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
@@ -123,12 +111,17 @@ pub struct StartAccountAuthorizationRequest {
|
||||
pub name: String,
|
||||
pub account_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
|
||||
#[serde(default)]
|
||||
pub input: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl StartAccountAuthorizationRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
parse_provider(&self.provider)?;
|
||||
require_text(&self.name, MAX_NAME_BYTES, "name")?;
|
||||
if serde_json::to_vec(&self.input).map_or(true, |data| data.len() > 64 * 1024) {
|
||||
return Err(WireValidationError::new("input"));
|
||||
}
|
||||
if let Some(account_id) = self.account_id.as_deref() {
|
||||
require_account_id(account_id, "accountId")?;
|
||||
}
|
||||
@@ -138,9 +131,9 @@ impl StartAccountAuthorizationRequest {
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<(AccountProvider, StartAuthorization), WireValidationError> {
|
||||
) -> Result<(ProviderKind, StartAuthorization), WireValidationError> {
|
||||
self.validate()?;
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
let provider = parse_provider(&self.provider)?;
|
||||
let reauthorization = self
|
||||
.account_id
|
||||
.map(ProviderAccountId::new)
|
||||
@@ -150,6 +143,7 @@ impl StartAccountAuthorizationRequest {
|
||||
provider,
|
||||
StartAuthorization {
|
||||
context,
|
||||
input: ProviderDocument::new(OpaqueProviderData::new(self.input)),
|
||||
name: self.name,
|
||||
reauthorization,
|
||||
outbound_proxy: super::wire::proxy_selection(
|
||||
@@ -175,27 +169,17 @@ impl CompleteAccountAuthorizationRequest {
|
||||
if let Some(settings) = &self.settings {
|
||||
settings.validate()?;
|
||||
}
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
match provider {
|
||||
AccountProvider::OpenAi => {
|
||||
if !URL_SAFE_NO_PAD
|
||||
.decode(&self.flow_id)
|
||||
.is_ok_and(|decoded| decoded.len() == 32)
|
||||
{
|
||||
return Err(WireValidationError::new("flowId"));
|
||||
}
|
||||
}
|
||||
AccountProvider::Xai => require_wire_id(&self.flow_id, "flowId")?,
|
||||
}
|
||||
let provider = parse_provider(&self.provider)?;
|
||||
validate_authorization_flow(&provider, &self.flow_id)?;
|
||||
require_text(&self.callback_url, MAX_CALLBACK_URL_BYTES, "callbackUrl")
|
||||
}
|
||||
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<(AccountProvider, CompleteAuthorization), WireValidationError> {
|
||||
) -> Result<(ProviderKind, CompleteAuthorization), WireValidationError> {
|
||||
self.validate()?;
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
let provider = parse_provider(&self.provider)?;
|
||||
Ok((
|
||||
provider,
|
||||
CompleteAuthorization {
|
||||
@@ -211,9 +195,97 @@ impl CompleteAccountAuthorizationRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
fn validate_authorization_flow(
|
||||
provider: &ProviderKind,
|
||||
flow_id: &str,
|
||||
) -> Result<(), WireValidationError> {
|
||||
if provider.as_str() == "openai" {
|
||||
if !URL_SAFE_NO_PAD
|
||||
.decode(flow_id)
|
||||
.is_ok_and(|decoded| decoded.len() == 32)
|
||||
{
|
||||
return Err(WireValidationError::new("flowId"));
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
require_wire_id(flow_id, "flowId")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PollAccountAuthorizationRequest {
|
||||
pub provider: String,
|
||||
pub flow_id: String,
|
||||
pub callback_url: Option<String>,
|
||||
pub settings: Option<AccountImportSettingsRequest>,
|
||||
}
|
||||
|
||||
impl PollAccountAuthorizationRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
validate_authorization_flow(&parse_provider(&self.provider)?, &self.flow_id)?;
|
||||
if let Some(callback) = &self.callback_url {
|
||||
require_text(callback, MAX_CALLBACK_URL_BYTES, "callbackUrl")?;
|
||||
}
|
||||
if let Some(settings) = &self.settings {
|
||||
settings.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<(ProviderKind, PollAuthorization), WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok((
|
||||
parse_provider(&self.provider)?,
|
||||
PollAuthorization {
|
||||
context,
|
||||
flow_id: self.flow_id,
|
||||
callback_url: self.callback_url,
|
||||
settings: self
|
||||
.settings
|
||||
.map(AccountImportSettingsRequest::into_settings)
|
||||
.transpose()?,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(
|
||||
tag = "status",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum AccountAuthorizationPollData {
|
||||
Pending { retry_after_ms: u64 },
|
||||
Complete { account_ids: Vec<String> },
|
||||
}
|
||||
|
||||
impl From<AuthorizationPollResult> for AccountAuthorizationPollData {
|
||||
fn from(result: AuthorizationPollResult) -> Self {
|
||||
match result {
|
||||
AuthorizationPollResult::Pending { retry_after } => Self::Pending {
|
||||
retry_after_ms: retry_after.as_secs() * 1000
|
||||
+ u64::from(retry_after.subsec_millis()),
|
||||
},
|
||||
AuthorizationPollResult::Complete(result) => Self::Complete {
|
||||
account_ids: result
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| account.account_id.to_string())
|
||||
.collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateAccountRequest {
|
||||
pub connection: Option<AccountConnectionUpdateRequest>,
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
|
||||
pub account_id: String,
|
||||
@@ -229,6 +301,9 @@ pub struct UpdateAccountRequest {
|
||||
impl UpdateAccountRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
if let Some(connection) = &self.connection {
|
||||
connection.validate()?;
|
||||
}
|
||||
validate_account_notes(self.notes.as_deref())?;
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
@@ -236,9 +311,14 @@ impl UpdateAccountRequest {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn into_command(self) -> Result<UpdateAccount, WireValidationError> {
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
) -> Result<(UpdateAccount, Option<ProviderDocument>), WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(UpdateAccount {
|
||||
let connection = self
|
||||
.connection
|
||||
.map(AccountConnectionUpdateRequest::into_document);
|
||||
let settings = UpdateAccount {
|
||||
outbound_proxy: super::wire::proxy_selection(
|
||||
self.outbound_proxy_id,
|
||||
self.outbound_proxy_url,
|
||||
@@ -250,7 +330,45 @@ impl UpdateAccountRequest {
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
model_access: self.model_access,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
})
|
||||
};
|
||||
Ok((settings, connection))
|
||||
}
|
||||
}
|
||||
|
||||
/// 编辑 OpenAI API Key 账号的连接设置;省略密钥时保留已保存的值。
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountConnectionUpdateRequest {
|
||||
pub base_url: String,
|
||||
pub transport: String,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountConnectionUpdateRequest {
|
||||
fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_text(&self.base_url, 2048, "connection.baseUrl")?;
|
||||
if !matches!(self.transport.as_str(), "http" | "prefer_websocket") {
|
||||
return Err(WireValidationError::new("connection.transport"));
|
||||
}
|
||||
if self.api_key.as_ref().is_some_and(|key| {
|
||||
key.is_empty()
|
||||
|| key.len() > 16 * 1024
|
||||
|| !key.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
}) {
|
||||
return Err(WireValidationError::new("connection.apiKey"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_document(self) -> ProviderDocument {
|
||||
let mut material = Map::from_iter([
|
||||
("base_url".to_owned(), Value::String(self.base_url)),
|
||||
("transport".to_owned(), Value::String(self.transport)),
|
||||
]);
|
||||
if let Some(key) = self.api_key {
|
||||
material.insert("api_key".to_owned(), Value::String(key));
|
||||
}
|
||||
ProviderDocument::new(OpaqueProviderData::new(material))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +397,7 @@ pub struct AccountDeletionRequest {
|
||||
|
||||
impl AccountDeletionRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
parse_provider(&self.provider)?;
|
||||
if self.account_ids.is_empty() || self.account_ids.len() > MAX_ACCOUNT_DELETE_BATCH {
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
@@ -296,9 +414,9 @@ impl AccountDeletionRequest {
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<(AccountProvider, CredentialDeletion), WireValidationError> {
|
||||
) -> Result<(ProviderKind, CredentialDeletion), WireValidationError> {
|
||||
self.validate()?;
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
let provider = parse_provider(&self.provider)?;
|
||||
Ok((
|
||||
provider,
|
||||
CredentialDeletion {
|
||||
@@ -314,106 +432,6 @@ impl AccountDeletionRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct RotateAccountRequest {
|
||||
pub provider: String,
|
||||
pub account_id: String,
|
||||
pub access_token: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
pub id_token: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub transport: Option<String>,
|
||||
pub settings: Option<UpdateAccountRequest>,
|
||||
}
|
||||
|
||||
impl RotateAccountRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if AccountProvider::parse(&self.provider)? != AccountProvider::OpenAi {
|
||||
return Err(WireValidationError::new("provider"));
|
||||
}
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
if let Some(settings) = &self.settings {
|
||||
settings.validate()?;
|
||||
if settings.account_id != self.account_id {
|
||||
return Err(WireValidationError::new("settings.accountId"));
|
||||
}
|
||||
}
|
||||
if let Some(base_url) = &self.base_url {
|
||||
if self.access_token.is_some()
|
||||
|| self.refresh_token.is_some()
|
||||
|| self.id_token.is_some()
|
||||
|| base_url.is_empty()
|
||||
|| base_url.len() > 2048
|
||||
|| !matches!(self.transport.as_deref(), Some("http" | "prefer_websocket"))
|
||||
|| self.api_key.as_ref().is_some_and(|key| {
|
||||
key.is_empty()
|
||||
|| key.len() > 16 * 1024
|
||||
|| !key.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
})
|
||||
{
|
||||
return Err(WireValidationError::new("credential"));
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
if self.api_key.is_some() || self.transport.is_some() {
|
||||
return Err(WireValidationError::new("credential"));
|
||||
}
|
||||
validate_oauth_material(
|
||||
self.access_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| WireValidationError::new("accessToken"))?,
|
||||
self.refresh_token.as_deref(),
|
||||
self.id_token.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<RotateCredential, WireValidationError> {
|
||||
self.validate()?;
|
||||
let mut material = Map::new();
|
||||
if let Some(base_url) = self.base_url {
|
||||
material.insert("base_url".to_owned(), Value::String(base_url));
|
||||
material.insert(
|
||||
"transport".to_owned(),
|
||||
self.transport.map_or(Value::Null, Value::String),
|
||||
);
|
||||
if let Some(key) = self.api_key {
|
||||
material.insert("api_key".to_owned(), Value::String(key));
|
||||
}
|
||||
} else {
|
||||
material.insert(
|
||||
"access_token".to_owned(),
|
||||
self.access_token.map_or(Value::Null, Value::String),
|
||||
);
|
||||
material.insert(
|
||||
"refresh_token".to_owned(),
|
||||
self.refresh_token.map_or(Value::Null, Value::String),
|
||||
);
|
||||
material.insert(
|
||||
"id_token".to_owned(),
|
||||
self.id_token.map_or(Value::Null, Value::String),
|
||||
);
|
||||
}
|
||||
Ok(RotateCredential {
|
||||
settings: self
|
||||
.settings
|
||||
.map(UpdateAccountRequest::into_command)
|
||||
.transpose()?,
|
||||
mutation: CredentialMutation {
|
||||
context,
|
||||
account_id: ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
},
|
||||
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountImportData {
|
||||
@@ -579,47 +597,6 @@ fn require_text(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_oauth_material(
|
||||
access_token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
id_token: Option<&str>,
|
||||
) -> Result<(), WireValidationError> {
|
||||
if access_token.len() > MAX_ACCESS_TOKEN_BYTES
|
||||
|| !valid_visible_ascii(access_token)
|
||||
|| !valid_compact_jwt_shape(access_token)
|
||||
{
|
||||
return Err(WireValidationError::new("accessToken"));
|
||||
}
|
||||
if refresh_token.is_some_and(|token| {
|
||||
token.len() > MAX_REFRESH_TOKEN_BYTES
|
||||
|| !valid_visible_ascii(token)
|
||||
|| token == access_token
|
||||
}) {
|
||||
return Err(WireValidationError::new("refreshToken"));
|
||||
}
|
||||
if id_token.is_some_and(|token| {
|
||||
token.len() > MAX_ID_TOKEN_BYTES
|
||||
|| !valid_visible_ascii(token)
|
||||
|| !valid_compact_jwt_shape(token)
|
||||
}) {
|
||||
return Err(WireValidationError::new("idToken"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_compact_jwt_shape(value: &str) -> bool {
|
||||
let mut segments = value.split('.');
|
||||
matches!(
|
||||
(segments.next(), segments.next(), segments.next(), segments.next()),
|
||||
(Some(header), Some(payload), Some(signature), None)
|
||||
if !header.is_empty() && !payload.is_empty() && !signature.is_empty()
|
||||
)
|
||||
}
|
||||
|
||||
fn valid_visible_ascii(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
|
||||
}
|
||||
|
||||
pub(super) fn provider_document_value(document: ProviderDocument) -> Value {
|
||||
Value::Object(document.into_provider_data().into_inner())
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@ where
|
||||
Router::new()
|
||||
.merge(super::import_tasks::router::<S>())
|
||||
.route("/api/admin/accounts", get(list_accounts::<S>))
|
||||
.route(
|
||||
"/api/admin/accounts/providers",
|
||||
get(super::capabilities::providers::<S>),
|
||||
)
|
||||
.route("/api/admin/accounts/detail", get(account_detail::<S>))
|
||||
.route("/api/admin/accounts/export", get(export_accounts::<S>))
|
||||
.route("/api/admin/accounts/import", post(import_accounts::<S>))
|
||||
.route("/api/admin/accounts/refresh", post(refresh_account::<S>))
|
||||
.route("/api/admin/accounts/recover", post(recover_account::<S>))
|
||||
.route("/api/admin/accounts/rotate", post(rotate_account::<S>))
|
||||
.route("/api/admin/accounts/update", post(update_account::<S>))
|
||||
.route("/api/admin/accounts/delete", post(delete_accounts::<S>))
|
||||
.route(
|
||||
@@ -66,6 +69,10 @@ where
|
||||
"/api/admin/accounts/oauth/complete",
|
||||
post(complete_account_authorization::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/oauth/poll",
|
||||
post(poll_account_authorization::<S>),
|
||||
)
|
||||
}
|
||||
|
||||
async fn batch_update_accounts<S>(
|
||||
@@ -172,17 +179,14 @@ where
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = match provider {
|
||||
AccountProvider::OpenAi => {
|
||||
state
|
||||
.admin_services()
|
||||
.openai()
|
||||
.import_document(command)
|
||||
.await
|
||||
}
|
||||
AccountProvider::Xai => state.admin_services().xai().import_document(command).await,
|
||||
}
|
||||
.map_err(map_service_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.credentials()
|
||||
.for_provider(&provider)
|
||||
.map_err(map_service_error)?
|
||||
.import_document(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::CREATED,
|
||||
AdminEnvelope::ok(AccountImportData::from_result(result)),
|
||||
@@ -200,23 +204,14 @@ where
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = match provider {
|
||||
AccountProvider::OpenAi => {
|
||||
state
|
||||
.admin_services()
|
||||
.openai()
|
||||
.start_authorization(command)
|
||||
.await
|
||||
}
|
||||
AccountProvider::Xai => {
|
||||
state
|
||||
.admin_services()
|
||||
.xai()
|
||||
.start_authorization(command)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(map_service_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.credentials()
|
||||
.for_provider(&provider)
|
||||
.map_err(map_service_error)?
|
||||
.start_authorization(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::CREATED,
|
||||
AdminEnvelope::ok(AccountAuthorizationData::from(result)),
|
||||
@@ -234,49 +229,38 @@ where
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = match provider {
|
||||
AccountProvider::OpenAi => {
|
||||
state
|
||||
.admin_services()
|
||||
.openai()
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
}
|
||||
AccountProvider::Xai => {
|
||||
state
|
||||
.admin_services()
|
||||
.xai()
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(map_service_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.credentials()
|
||||
.complete_authorization(&provider, command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::CREATED,
|
||||
AdminEnvelope::ok(AccountMutationData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn rotate_account<S>(
|
||||
async fn poll_account_authorization<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<RotateAccountRequest>,
|
||||
AdminJson(request): AdminJson<PollAccountAuthorizationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.openai()
|
||||
.rotate(command)
|
||||
.credentials()
|
||||
.poll_authorization(&provider, command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountMutationData::from(result)),
|
||||
AdminEnvelope::ok(AccountAuthorizationPollData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -288,13 +272,39 @@ async fn update_account<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.update(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let (command, connection) = request.into_command().map_err(map_wire_error)?;
|
||||
let context = auth.context().mutation_context();
|
||||
let result = if let Some(provider_material) = connection {
|
||||
let provider = ProviderKind::new("openai").map_err(|_| AdminError::internal())?;
|
||||
let account_id = ProviderAccountId::new(command.account_id.clone())
|
||||
.map_err(|_| AdminError::internal())?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.credentials()
|
||||
.for_provider(&provider)
|
||||
.map_err(map_service_error)?
|
||||
.rotate(RotateCredential {
|
||||
mutation: CredentialMutation {
|
||||
context,
|
||||
account_id,
|
||||
},
|
||||
settings: Some(command),
|
||||
provider_material,
|
||||
})
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
AccountUpdateResult {
|
||||
account_id: result.account_id,
|
||||
config_revision: result.config_revision,
|
||||
}
|
||||
} else {
|
||||
state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.update(&context, command)
|
||||
.await
|
||||
.map_err(map_service_error)?
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(UpdatedAccountData::from(result)),
|
||||
@@ -312,11 +322,14 @@ where
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = match provider {
|
||||
AccountProvider::OpenAi => state.admin_services().openai().delete(command).await,
|
||||
AccountProvider::Xai => state.admin_services().xai().delete(command).await,
|
||||
}
|
||||
.map_err(map_service_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.credentials()
|
||||
.for_provider(&provider)
|
||||
.map_err(map_service_error)?
|
||||
.delete(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountDeletionData::from(result)),
|
||||
|
||||
@@ -152,11 +152,6 @@ where
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let (provider, command) = item.into_command(context.clone())?;
|
||||
let provider = ProviderKind::new(match provider {
|
||||
AccountProvider::OpenAi => "openai",
|
||||
AccountProvider::Xai => "xai",
|
||||
})
|
||||
.map_err(|_| WireValidationError::new("provider"))?;
|
||||
Ok(ImportTaskInput { provider, command })
|
||||
})
|
||||
.collect::<Result<Vec<_>, WireValidationError>>()
|
||||
|
||||
@@ -26,14 +26,15 @@ use gateway_admin::model::{
|
||||
},
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
|
||||
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
|
||||
ProviderDocument, ProviderModelCatalogDocument, ProviderModels,
|
||||
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage,
|
||||
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
|
||||
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
|
||||
ProviderResetCredits, ProviderSubscription, RotateCredential, StartAuthorization,
|
||||
AccountRefreshResult, AccountUsagePeriod, AuthorizationPollResult, AuthorizationStarted,
|
||||
CompleteAuthorization, ConsumeProviderResetCredit, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialImportResult, CredentialMutation,
|
||||
CredentialMutationResult, ImportCredentials, PollAuthorization, ProviderDocument,
|
||||
ProviderModelCatalogDocument, ProviderModels, ProviderProfileActivityInsights,
|
||||
ProviderProfileAvatar, ProviderProfileDailyUsage, ProviderProfileInvocation,
|
||||
ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota,
|
||||
ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
|
||||
ProviderSubscription, RotateCredential, StartAuthorization,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
@@ -56,28 +57,27 @@ const MAX_SEARCH_BYTES: usize = 256;
|
||||
const MAX_ID_BYTES: usize = 256;
|
||||
const MAX_NAME_BYTES: usize = 512;
|
||||
const MAX_IMPORT_DATA_BYTES: usize = 64 * 1024 * 1024;
|
||||
const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024;
|
||||
const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024;
|
||||
const MAX_ID_TOKEN_BYTES: usize = 16 * 1024;
|
||||
const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024;
|
||||
const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
|
||||
const MAX_ACCOUNT_GROUP_BATCH: usize = 1000;
|
||||
const MAX_AVATAR_VERSION_BYTES: usize = 32;
|
||||
|
||||
mod capabilities;
|
||||
mod credentials;
|
||||
mod handlers;
|
||||
mod import_tasks;
|
||||
mod presenter;
|
||||
mod wire;
|
||||
|
||||
pub use capabilities::*;
|
||||
pub use credentials::*;
|
||||
pub use handlers::{profile_avatar_response, router};
|
||||
pub(super) use presenter::quota_window_view;
|
||||
pub use wire::*;
|
||||
|
||||
use credentials::{
|
||||
AccountProvider, parse_account_weight, parse_concurrency_limit, provider_document_value,
|
||||
require_account_id, validate_wire_group_ids,
|
||||
parse_account_weight, parse_concurrency_limit, provider_document_value, require_account_id,
|
||||
validate_wire_group_ids,
|
||||
};
|
||||
use presenter::*;
|
||||
use wire::BatchUpdatedAccountsData;
|
||||
|
||||
@@ -15,6 +15,7 @@ pub(super) fn account_page_data(
|
||||
u32::try_from(result.total.div_ceil(u64::from(page_size))).unwrap_or(u32::MAX)
|
||||
};
|
||||
AccountPageData {
|
||||
providers: result.providers,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
@@ -60,6 +61,7 @@ pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData {
|
||||
pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
|
||||
let AccountDirectoryItem {
|
||||
account,
|
||||
capabilities,
|
||||
plan_type_display,
|
||||
projection,
|
||||
usage,
|
||||
@@ -77,6 +79,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
}
|
||||
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now);
|
||||
AccountView {
|
||||
capabilities: capabilities.into(),
|
||||
id: account.id.clone(),
|
||||
name: account.name,
|
||||
notes: account.notes,
|
||||
|
||||
@@ -162,7 +162,7 @@ impl ListQuery {
|
||||
if page_size == 0 || page_size > MAX_PAGE_SIZE {
|
||||
return Err(WireValidationError::new("pageSize"));
|
||||
}
|
||||
let provider_kind = parse_provider(self.provider.as_deref().unwrap_or("all"))?;
|
||||
let provider_kind = parse_provider(self.provider.as_deref().unwrap_or_default())?;
|
||||
let group_filter = match self.group_id.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some("ungrouped") => Some(AccountGroupFilter::Ungrouped),
|
||||
@@ -213,7 +213,7 @@ impl ListQuery {
|
||||
|
||||
fn parse_provider(value: &str) -> Result<Option<ProviderKind>, WireValidationError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.eq_ignore_ascii_case("all") {
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
ProviderKind::new(value.to_owned())
|
||||
@@ -248,6 +248,7 @@ pub struct AccountPageData {
|
||||
pub items: Vec<AccountView>,
|
||||
pub page: PageMeta,
|
||||
pub summary: AccountSummaryView,
|
||||
pub providers: Vec<String>,
|
||||
}
|
||||
|
||||
/// 账号概览计数。
|
||||
@@ -266,6 +267,7 @@ pub struct AccountSummaryView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountView {
|
||||
pub capabilities: AccountCapabilitiesView,
|
||||
pub outbound_proxy_endpoint: Option<String>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -303,6 +305,36 @@ pub struct AccountView {
|
||||
pub usage: AccountUsageView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountCapabilitiesView {
|
||||
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,
|
||||
}
|
||||
|
||||
impl From<gateway_admin::model::provider_capabilities::ProviderAccountCapabilities>
|
||||
for AccountCapabilitiesView
|
||||
{
|
||||
fn from(
|
||||
value: gateway_admin::model::provider_capabilities::ProviderAccountCapabilities,
|
||||
) -> Self {
|
||||
Self {
|
||||
quota: value.quota,
|
||||
quota_refresh: value.quota_refresh,
|
||||
profile: value.profile,
|
||||
subscription: value.subscription,
|
||||
avatar: value.avatar,
|
||||
reset_credits: value.reset_credits,
|
||||
consume_reset_credit: value.consume_reset_credit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 容量估算仅供管理端展示;金额不是订阅账单或可消费余额。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user